Skip to content

feat(linear): first-class Linear integration (PR 1/4)#185

Merged
rafeegnash merged 2 commits into
masterfrom
feat/linear-cli
Jun 2, 2026
Merged

feat(linear): first-class Linear integration (PR 1/4)#185
rafeegnash merged 2 commits into
masterfrom
feat/linear-cli

Conversation

@rafeegnash
Copy link
Copy Markdown
Collaborator

Summary

First-class Linear CLI provider following the Sentry/Cloudflare recipe. Task/project/cycle management end-to-end so agents can read, triage, and mutate Linear without leaving Clanker. PR 1 of 4 in the Linear + Notion roadmap.

What lands

  • `internal/linear/` — GraphQL client targeting `https://api.linear.app/graphql\`. Auth is the raw Personal API Key (NO `Bearer` prefix — Linear's feat: discover, compliance flags and more aws services #1 footgun, tested explicitly). 429 backoff with jitter; Relay-style cursor pagination.
  • Resources: Workspaces, Teams (with inlined WorkflowStates), Issues (rich filter + full CRUD + comments), Projects (CRUD), Cycles (CRUD), Labels (list + create — used for the `infra::` annotation convention coming in PR2), Users (list + find-by-display-name), Documents (read-only).
  • `clanker linear` cobra tree (alias `lin`): `list` / `get` / `resolve` / `assign` / `comment` / `create {issue|project|cycle}` / `update issue` / `label create`. `--format json` everywhere for scripting.
  • `clanker linear ask` — LLM-powered triage. Parallel context gathering via errgroup (issues + projects + cycles + teams + labels concurrently); per-workspace history at `~/.clanker/linear-{workspaceID}.json` with `safeSlug` path-traversal guard.
  • MCP tools (12 total): read (ask, list_issues, get_issue, list_projects, list_cycles, list_teams, search_by_label) all flagged `ReadOnlyHintAnnotation`; write (create_issue, update_issue, comment_issue, create_project, update_project) deliberately not — so cautious MCP clients prompt for confirmation.

Tests

Happy-path GraphQL + the no-Bearer-prefix invariant + 429 retry-after + the GraphQL `errors` envelope + IssueFilter→GraphQL input mapping + partial-patch pointer serialisation + history round-trip + safeSlug path traversal.

Test plan

  • `make ci` clean
  • `go test -race -count=1 ./internal/linear/...` passes
  • `./bin/clanker linear --help` shows all subcommands
  • Smoke against a real Personal API Key:
    • `clanker linear list issues --state started` returns rows
    • `clanker linear create issue --team-id --title "test"` returns new identifier
    • `clanker linear ask "what's on my plate this cycle?"` returns LLM summary citing identifiers
  • MCP: `clanker mcp --transport stdio` exposes `clanker_linear_*` tools

Out of scope (later PRs)

  • PR 2: Linear in the desktop app (window with proper kanban via `@dnd-kit`, ConfigWizard step, ProfileSettings card, MCP passthrough headers, the infra-annotation side-panel on the canvas)
  • PR 3 + 4: Notion (CLI then app)
  • Initiatives + Roadmaps: Linear's higher-level grouping — projects + cycles cover 90% of operator value, defer
  • Webhook receiver: Linear has no GraphQL subscriptions; polling at 30-60s is fine for MVP

nash added 2 commits June 2, 2026 15:23
Adds Linear as a CLI provider following the established Sentry/Cloudflare
recipe — task/project/cycle management end-to-end so agents can read,
triage, and mutate Linear without leaving Clanker.

internal/linear/ — GraphQL client targeting https://api.linear.app/graphql.
Auth header is the raw Personal API Key with NO Bearer prefix (Linear's
#1 footgun, tested explicitly). Honours X-RateLimit-Requests-Remaining;
backoff with jitter on 429s. Cursor pagination matches Linear's Relay
shape (first/after/pageInfo).

Resource coverage:
- Workspaces, Teams (with inlined WorkflowStates for the kanban path)
- Issues — list with rich filters (state.type, team, project, cycle,
  label, assignee, priority), get by UUID or identifier, create with
  full input, update with partial pointer-patch, comment
- Projects — list/get/create/update (state, lead, dates, team scoping)
- Cycles — list with active/future filters, get, create, update
- Labels — list (used for the infra:<type>:<id> annotation lookup
  planned for PR2), find-by-name, create
- Users — list + find-by-display-name for the assign CLI
- Documents — read-only list/get

CLI surface:
- `clanker linear list {issues|projects|teams|cycles|labels|users|docs}`
  with all filter flags
- `clanker linear get {issue|project|cycle|doc|team}` with id-or-identifier
- `clanker linear resolve <id>...` (moves to first 'completed' state on
  the issue's team)
- `clanker linear assign`, `comment`, `create issue|project|cycle`,
  `update issue`, `label create`
- `clanker linear ask "..."` — LLM-powered triage with parallel context
  gathering via errgroup (issues + projects + cycles + teams + labels
  fetched concurrently); per-workspace history at
  ~/.clanker/linear-{workspaceID}.json with safeSlug path-traversal
  guard

MCP tools (registered alongside Sentry/Tencent in mcp.go):
- Read (ReadOnlyHintAnnotation=true): _ask, _list_issues, _get_issue,
  _list_projects, _list_cycles, _list_teams, _search_by_label
- Write (no hint — destructive): _create_issue, _update_issue,
  _comment_issue, _create_project, _update_project

Tests: client (happy path, no-Bearer auth header, 429 retry-after,
GraphQL errors envelope, IssueFilter→GraphQL mapping, partial-patch
serialisation), conversation history round-trip + path traversal.

Docs: .clanker.example.yaml section explains the no-Bearer-prefix
gotcha and scope inheritance.

PR1 of 4 toward full Linear+Notion across CLI and desktop app —
desktop PR (provider window + kanban + infra-annotations) follows.
…filter

Pre-merge review found four real bugs.

Backend (internal/linear):
- issues.go: AssigneeMe was declared but never read in toGraphQL —
  callers setting it got silent no-filtering. Renamed to AssigneeIsMe
  and wired to `assignee.isMe.eq: true`. AssigneeID takes precedence
  when both are set.
- issues.go: ResolveIssueID resolves human identifiers (ENG-42) to the
  UUIDs Linear's mutations actually require. The mutation endpoints
  silently 4xx on identifiers — agents will pass them, so resolve at
  the boundary.
- client.go: parseAPIError caps the raw body at 512 bytes so a WAF/CDN
  HTML response (10KB+ of marketing) doesn't bloat every log line that
  prints err.Error().
- issues.go: queryIssueComments doc says "newest first" but the query
  orders ASC. Fixed the docstring to match the query.

CLI (cmd/linear.go + internal/linear/static_commands.go):
- linear.go: dropped the local --api-key / --workspace / --team flags
  that were shadowing the parent's persistent flags. `clanker linear
  --api-key X ask "q"` was silently ignoring the explicit flag because
  Cobra resolves the local declaration first.
- linear.go: gatherLinearContext switched from substring match
  ("my" matched "myql"/"company") to a word-boundary regex helper.
- static_commands.go: assign / comment / update issue / resolve all
  pipe user input through ResolveIssueID first. The resolve command
  had an additional bug — used the original `id` argument in the
  UpdateIssue call instead of issue.ID after a successful GetIssue,
  so `resolve ENG-42` would GET-OK then PUT-fail.
- static_commands.go: updateIssuesToStateType caches the team→state
  lookup so a batch like `resolve ENG-1 ENG-2 ENG-3` makes one
  GetTeam call, not three.
- static_commands.go: buildLabelCommand uses a named createCmd
  variable instead of the fragile lc.Commands()[0] indexing.

MCP (cmd/mcp_linear.go):
- update_issue and comment_issue handlers resolve the issueId before
  calling Linear so agents passing "ENG-42" work end-to-end.
- All 5 mutation tools now carry mcp.WithDestructiveHintAnnotation(true)
  so MCP clients prompt for confirmation. Sentry's resolve/ignore is
  the same level of impact; Linear's writes create user-visible
  changes (assignment notifications, comments) and deserve the gate.

Docs (.clanker.example.yaml):
- Document the infra:<type>:<id> annotation label convention that the
  desktop PR will use to bridge cloud resources to Linear issues.
- Note the conversation-history file path so operators can clear it.

Tests:
- TestFilterToGraphQL_AssigneeIsMe covers the new clause + the
  AssigneeID precedence rule.
- TestResolveIssueID exercises UUID passthrough vs ENG-42 lookup.
@rafeegnash rafeegnash merged commit 825ffa5 into master Jun 2, 2026
1 check passed
@rafeegnash rafeegnash deleted the feat/linear-cli branch June 2, 2026 10:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant