SLOPSHOPPER

Linear Workflow

Linear workflow for Claude Code: issue-next, issue-start, issue-review, issue-ship and issue-plan-cycle skills backed by a deterministic CLI, a linear-manager…

newpanebandguardcommandtoast
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · linear-workflow
│ ┃ Linear ✕ › fix the failing auth test and add an audit log call │ ┃ No Linear issue on this branch. /linear ABC- │ ┃ one. ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /linear │ ⎿ linear-workflow: Linear pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Linear
No Linear issue on this branch. /linear ABC-123 pins one.
README

Linear Workflow

Work Linear issues from Claude Code, from picking the next issue in the cycle to the merged PR. Linear stays up to date along the way, and the current issue is always shown above the prompt.

Overview

The plugin has four parts:

  • Skills (slash commands) take an issue from the cycle to a merged PR: issue-next → issue-start → (work) → issue-review → issue-ship, plus issue-plan-cycle to fill the cycle.
  • A CLI (bin/linear-workflow.mjs) runs the Linear and GitHub steps that need no judgment: fetching and ranking issues, status changes, cycle changes, PR checks. It's faster than an LLM and gives the same answer every time. Claude keeps the parts that need judgment: reading the issue, writing the code, the commit message, PR summary and comments.
  • An agent, linear-manager, creates and edits issues, and stands in for the CLI when it can't reach Linear.
  • A mod: live code that shows the current issue above the prompt, lists your other Claude Code sessions, and warns when two of them collide. It shares its code with the CLI (lib/).

Claude Code namespaces plugin skills and agents, so the full names are /linear-workflow:issue-start and linear-workflow:linear-manager. Type /issue and pick from the menu.

Quick start

/plugin marketplace add Sarimarcus/claude-code-plugins
/plugin install linear-workflow@sarimarcus
  1. Create a Linear personal API key (Settings → Security & access; see Linear's API docs) and set it: export LINEAR_API_KEY=lin_api_… in your shell profile, or a LINEAR_API_KEY= line in your repo's git-ignored .env. The CLI and the mod both use it.
  2. Optional: connect Linear's MCP server for the linear-manager agent (the Linear connector on claude.ai, or claude mcp add --transport http linear https://mcp.linear.app/mcp).
  3. Run /linear-workflow:issue-next.

Skills

/linear-workflow:issue-next

Picks what to work on next. Read-only. Runs on Haiku: it only presents the CLI's ranked list.

/linear-workflow:issue-next

Claude will:

  • Fetch the active cycle's Todo and In Progress issues that are assigned to you and unblocked
  • Replace an in-progress epic with its next unblocked sub-issue
  • Rank them by priority, then milestone date, then age, and show the top 5 with a reason for each
  • Offer to start the top one (Y), another (ABC-140), or none

/linear-workflow:issue-start <ABC-123>

Starts work on an issue.

/linear-workflow:issue-start ENG-123

Claude will:

  • Move the issue to In Progress
  • Show its Context, Implementation and Scope, but not the Acceptance Criteria, which are for the reviewer
  • Reproduce actionable comments verbatim and treat them as scope (a newer comment beats the description)
  • Ask before continuing if the issue has open blockers
  • Create Linear's branch for the issue, then start working without waiting

/linear-workflow:issue-review [ABC-123]

Puts the work up for review. Never merges or deploys.

/linear-workflow:issue-review

Claude will:

  • Refuse if you're on the base branch, or on a branch that names another issue
  • Check each acceptance criterion against the change, and ask before going on if one isn't met
  • Run your checks (from .claude/linear.json, or the obvious ones such as npm test)
  • Commit only the files for this issue (asking about unrelated ones) with (ENG-123) in the message
  • Push the branch and open a PR whose body says Closes ENG-123
  • Move the issue to In Review and post a completion comment

/linear-workflow:issue-ship [ABC-123]

Merges the reviewed PR and closes the issue.

/linear-workflow:issue-ship

Claude will:

  • Refuse when there's no open PR, it's a draft, it has conflicts, changes were requested, checks failed, or work on the branch isn't in the PR yet
  • Merge it (gh pr merge, or git merge --no-ff locally so your pre-push hooks run)
  • Check on GitHub that the PR really merged before touching Linear
  • Move the issue to Done, and roll up the parent issue once all its sub-issues are finished

/linear-workflow:issue-plan-cycle

Fills the active cycle from the backlog. Runs on Haiku, like issue-next. The other skills use your session's model, because they write code, judge acceptance criteria or merge.

Claude will:

  • List unscheduled, unblocked Backlog and Todo issues by priority, holding back sub-issues whose parent is already in the cycle
  • Ask which to add (all, none, or a list of ids), then set the cycle on those issues and change nothing else

Without an id, issue-review and issue-ship use the session's issue, then the branch name, then your issues in Linear. If the id was guessed, they show it first, and issue-ship asks you to confirm it before merging.

The linear-manager agent

Use it for what the CLI doesn't do: filing issues, splitting a plan into sub-issues, labels, relations, milestones, searches ("use linear-manager to file a bug for…"). The skills also fall back to it when the CLI can't reach Linear. It follows the same rules as the CLI:

  • passes names, not IDs (state: "In Review", labels: ["Bug"], assignee: "me")
  • makes status changes idempotent: a no-op when the issue is already in that state
  • checks a parent's sub-issues by asking whether an unfinished one exists, instead of listing them all
  • never returns issue descriptions in lists, so long backlogs don't flood your conversation
  • never edits files or runs git

The CLI

The skills call it as node "${CLAUDE_PLUGIN_ROOT}/bin/linear-workflow.mjs" <command>. You can run it yourself from any repo. It prints compact JSON on stdout and one summary line on stderr, stating what it examined. Exit codes: 0 ok, 1 error or bad input, 2 refused (needs a human).

Built for a small context. Each skill step makes one call, and each call returns only the fields that step reads. Every tool call is a model turn that re-reads the conversation, so fewer, smaller calls are the main saving. The start view doesn't contain the acceptance criteria at all, so keeping them out of the implementer's view doesn't depend on Claude following an instruction.

One call per skill stepReturns
start <ABC-123>The issue to work from (description without acceptance criteria, blockers, branch name, every comment verbatim) and the repo state (branch, base, branching setting, uncommitted files)
review [ABC-123]Which issue (argument, branch name, or your only started issue), its acceptance criteria, and the repo state: base branch, the issue the branch names, this branch's open PR, checks, uncommitted files
ship [ABC-123] [--pr N]Which issue, merge settings, and the PR verdict (as pr-check)
Single stepsWhat it does
configRepo root, current branch and the issue its name points to (branchIssue), resolved base branch (baseBranch, else origin's default) and whether you're on it, this branch's open PR, team keys, whether a key was found, .claude/linear.json
`resolve [ABC-123\123]`Which issue to act on: the argument, then the branch name, then your only started issue. Exit 2 with candidates when it can't tell
`issue <ABC-123> [--view start\review]`The full issue (description, parsed acceptanceCriteria, blockers, sub-issues, links, branch name, every comment), or just one skill's view of it
queue [--limit N]The active cycle's Todo and In Progress issues (not In Review, not blocked), epics replaced by their next sub-issue, ranked, each with its rank and display-ready urgency (overdue 3d, ends in 5d…)
plan-cycle [--limit N]Unscheduled, unblocked backlog issues for the active cycle, ranked
`transition <ABC-123> <state> [--comment TEXT \--comment-file F]`Idempotent status change, comment, parent roll-up (into the parent's own team states)
set-cycle <n> <ABC-123>…Put issues in a cycle and change nothing else
pr-check <ABC-123> [--pr N]Whether the issue's PR can merge: ready, wait (checks running) or stop, with reasons. Only PRs whose branch or title names the issue, or whose body closes it, count; --pr picks one of several
pr-merged <ABC-123> --pr NWhether that PR really landed: exit 0 only when GitHub says merged and names the merge commit

Global flags: --team KEY overrides the team key; --pretty indents the JSON; --out FILE writes the full JSON to a file and prints only its path. Uncommitted-file lists are capped at the first 20, with the total count.

Ranking is priority (Urgent first, no priority last), then milestone target date, then age. A parent rolls up only when no sibling is unfinished, and anything not done or canceled counts as unfinished (In Review included when the target is Done).

The mod

── Linear ─────────────────────────────────────────────────────────
◆ ENG-2919  In Progress · High
  Core affiliate link builders
  root branch · scope: web · parent ENG-2900            Details  Hide
  sessions  ENG-2748 In Review (eng-2748)  ·  ⚠  ENG-2912 In Progress (main)
  • Band above the prompt: the current issue's id, state, priority, title, scope and parent.
  • Details pane (/linear): description, sub-issues, links, latest comments, and Open in Linear.
  • Only your own issue: a session shows an issue it took on: one you pinned (/linear ENG-123, typing /linear-workflow:issue-start ENG-123, or Claude starting it from your words), or the issue in a branch name (alex/eng-2919-link-builders → ENG-2919) that this session switched to itself. A branch issue that another live session in the same checkout has claimed is left to that session, and yours shows No Linear issue found and the sessions row. A lone session opened on a feature branch still picks its issue up.
  • Other sessions (sessions row): every Claude Code session on your machine that runs the plugin, with its issue and checkout (main or the worktree name).
  • Conflict warnings: a toast and a red ⚠ when another session in the same checkout is on a different issue (you share one branch and one working tree), or two sessions are on the same issue.
  • Your issue stays put. If another session switches the shared checkout to another branch, your session keeps its issue (held) until that issue is Done or Canceled, or your own session switches branches.
  • Scope drift (optional): with a sub-project registry, an edit outside the issue's labelled sub-projects raises a toast.
  • Stays current: the issue is fetched again at the end of any turn that wrote to Linear (a skill's transition, the linear-manager agent, a Linear MCP write), and every pollMinutes otherwise. A toast says when its state changed.
/linearWhat it does
/linearOpen the details pane
/linear ENG-123Pin an issue to this session. The pin lasts until this session itself switches to another issue's branch
/linear clearDrop the pin and follow the branch again
/linear refreshRe-fetch from Linear now
/linear hide / showHide or show the band

Requirements

  • Claude Code 2.1.289 or later. Mods are a recent feature, and this is the version the plugin was built and tested on.
  • Node.js 22.18 or later for the CLI (it runs TypeScript directly, no build step).
  • A Linear personal API key, for the CLI and the mod.
  • Optional: a Linear MCP connection, for the linear-manager agent.
  • Optional: Linear's GitHub integration, which links PRs to issues and moves them on merge. The skills work with or without it, because their transitions are idempotent.
  • GitHub CLI gh, logged in, for issue-review and issue-ship.
  • git 2.23 or later (git switch).

Configuration

Nothing is required beyond the API key. Everything else is inferred, and each layer below overrides the one before it:

  1. Built-in defaults: GitHub flow, a branch per issue, standard Linear state names.
  2. Plugin settings (per user): the API key, team keys, polling, the session list.
  3. Project settings (.claude/linear.json, per repo, committed): states, branching, checks, merge method, issue template.
  4. Project conventions (CLAUDE.md, CONTRIBUTING.md, the PR template): the skills follow them for commit messages, PR bodies and anything else they describe.
  5. Your own skills: see Customizing.

Plugin settings

OptionDefaultMeaning
linearApiKey—Linear API key, stored as a secret (secret options are not shown in /config). If unset: LINEAR_API_KEY in the environment, then in <repo>/.env
teamKeys(auto)Team keys to find in branch names, for repos whose .claude/linear.json sets no teamKey. Else the keys of the teams you belong to. The mod passes this to the CLI too
pinCommandissue-startSkill whose first argument pins an issue to the session. Empty disables it
pollMinutes5How often the mod refreshes the issue
showOtherSessionstrueShare this session's issue with your other sessions and list theirs
registryFile—Sub-project registry for scope and drift (monorepos). Empty: off

Project settings (.claude/linear.json)

Optional, all fields optional. A missing value is inferred, or the skill asks once. See linear.example.json.

{
  "team": "Engineering",
  "teamKey": "ENG",
  "project": "Website",
  "assignee": "me",
  "baseBranch": "main",
  "checks": ["npm run build", "npm test"],
  "mergeMethod": "squash",
  "deleteBranch": true,
  "branching": "create",
  "states": { "inProgress": "Doing", "inReview": "Ready for QA", "done": "Shipped" },
  "issueTemplate": ".github/linear-issue.md"
}
FieldDefaultMeaning
team, teamKey, project, assigneeinferredWhere the skills look and file issues. teamKey also lets you type bare numbers (123)
baseBranchorigin's default branchBase for new branches and PRs
checksinferred (npm test, make test…)Commands issue-review runs before committing
mergeMethodmergemerge, squash, rebase, or local (git merge --no-ff + push, so your own pre-push hooks run)
deleteBranchfalseDelete the branch after merging
branchingcreateissue-start creates the issue's branch (create), asks (ask), or stays on the current one (none)
statesLinear's usual namesYour team's names for In Progress, In Review and Done, used by every transition and by the queue's review filter
issueTemplate4-section templateA markdown file the agent uses for new issue descriptions

Sub-project registry (monorepos)

Set the registryFile plugin setting to a JSON file at the repo root:

{ "projects": [ { "key": "web", "path": "apps/web" }, { "key": "api", "path": "services/api" } ] }

An issue labelled web (or whose parent is) is scoped to apps/web, so editing services/api/... raises a drift toast once per issue and sub-project. An issue with no matching label is scoped to everything.

Customizing

  • Settings first. Most differences between teams (state names, branching, checks, merge method, templates) are settings above, and project conventions in CLAUDE.md take precedence over the skills' defaults.
  • Replace a skill. Copy skills/<name>/SKILL.md into your project's .claude/skills/<name>/ and edit it. Yours runs as /<name>, and the plugin's stays available as /linear-workflow:<name>. Keep calling the CLI for the deterministic steps so the checks stay the same. If you rename issue-start, set the pinCommand plugin setting to your skill's name so the band follows it.
  • Use the CLI directly. It works without the skills, in your own scripts or CI: node <plugin>/bin/linear-workflow.mjs queue.
  • Turn parts off. /linear hide hides the band; showOtherSessions: false stops the session list; leaving registryFile empty keeps scope and drift checks off.

Safety

  • issue-plan-cycle can't be started by Claude on its own. You have to type it. issue-review and issue-ship can also be invoked by Claude. When Claude does that without you asking to ship, issue-ship confirms the issue and PR with you before merging.
  • Typing issue-review or issue-ship yourself authorizes the push or merge for that issue only.
  • The skills never run git push --force, git reset --hard, git clean, git checkout -- ., git restore ., a bare git stash or git commit --amend: each can destroy work that isn't yours. Every eval checks this. The only push to the base branch is the merge itself, when issue-ship uses mergeMethod: local.
  • issue-review refuses on a branch that names another issue, so work never lands under the wrong issue.
  • issue-ship checks GitHub before writing to Linear, so a merge that failed is never reported as done.

Best practices

  • One issue per session. Several sessions work best in separate git worktrees. In a shared checkout the band warns you, but the shared tree is still shared.
  • Put the steps of the work in the issue (Context, Implementation, Scope). issue-start gives Claude those sections and leaves out the acceptance criteria.
  • Put decisions made after filing in comments, because issue-start treats them as scope.
  • Set checks in .claude/linear.json so issue-review runs exactly what your CI runs.

Privacy and data handling

  • The CLI and the mod send GraphQL queries and updates to api.linear.app with your key. They never write it to disk. When you set the key as the secret plugin option, the mod exports it as LINEAR_API_KEY to the session's shell so the CLI can use it.
  • The CLI's pr-check and the review and ship skills use the gh CLI. The agent uses your Linear MCP connection. What any of them return goes into the conversation like any tool output.
  • Session list: each session writes ~/.claude/linear-workflow/sessions/<session-id>.json (checkout path, issue id, title, state) every minute. These files stay on your machine and are deleted when the session ends, or after a day if it crashed. To turn this off, set showOtherSessions: false.

Troubleshooting

No band. It only shows when the branch names an issue or one is pinned. Check teamKeys, or pin one with /linear ENG-123. If the band says Linear API key not found, set the key (see Configuration).

The details pane doesn't open by itself. A pane the session opens on its own needs a wide terminal (about 144 columns). /linear opens it at any width.

A skill says LINEAR_API_KEY not set. Set the key (Quick start, step 1). Run node "<plugin>/bin/linear-workflow.mjs" config to see what the CLI finds.

The agent says Linear tools are missing. Connect the Linear MCP server (Quick start, step 2), then restart the session.

gh errors in issue-review or issue-ship. Run gh auth status. If the repository doesn't allow your mergeMethod, issue-ship asks which one to use.

The band shows held. Another session moved the shared branch, and yours kept its issue (until it is Done or Canceled). /linear clear follows the branch again.

Limitations

  • Single-repository workflow (one PR per issue). Multi-repo or submodule shipping isn't supported.
  • The skills follow GitHub's PR model through gh. GitLab and Bitbucket aren't supported.
  • The mod sees branch switches made through Claude Code, but not ones made in your own terminal. Use /linear clear after those.
  • The shared logic (ranking, roll-up, state matching, PR verdicts, mod helpers) has unit tests, including transitions against a fake Linear API. The skills have evals that check they follow the CLI's verdicts, run against canned CLI answers. Nothing runs end to end against a real Linear workspace or GitHub repo.

Development

claude --plugin-dir .      # from this folder: load from source, hot-reloads on save
claude plugin validate .
claude plugin test .       # unit tests (lib/ and the mod)

Dev tooling lives at the repo root, outside the plugin, so installing the plugin pulls in none of it:

npm install                # at the repo root: TypeScript and Node types
npm run typecheck          # mod + lib, then CLI + lib
npm test                   # same as claude plugin test
npm run eval               # skill evals, then writes evals/RESULTS.md

Evals (evals/) check the skills' guardrails: issue-ship stops on a missing, draft or failing PR asks when several PRs or running checks are involved, and doesn't merge on its own when Claude reaches it without being asked to ship; issue-review refuses on the base branch or another issue's branch, flags unmet acceptance criteria and never stages unrelated files; no skill ever runs a destructive git command; issue-start never stashes or discards a dirty tree, quotes actionable comments verbatim and hides acceptance criteria; issue-next keeps the CLI's order and urgency. Each case's scaffold.sh builds a git repo and canned CLI answers in .lw/; with EVAL_LINEAR_WORKFLOW_FIXTURES set, the CLI answers from there (and logs each call to calls.log) instead of calling Linear or GitHub. Granting Bash needs Claude Code's sandbox: on Linux, apt install bubblewrap socat. Each case runs 3 times, so a full run costs real tokens (about $3). The latest results are in evals/RESULTS.md: commit it with each release.

The engine generates .claude-plugin/types/ when the mod loads, and the type-check needs it. Layout: lib/ is shared code with no Node or browser APIs; hooks/ is the mod; bin/ is the CLI.

Author

Olivier Depiesse (@Sarimarcus)

Version

See CHANGELOG.md for the current version and its history. The version lives only in plugin.json (and the marketplace entry): don't write it here.

License

MIT

Source 5 files
hooks/register.tsx 609 lines
1import { atom, read, update } from 'claude-code'
2import type { ElementTable, EngineInterface, Register } from 'claude-code'
3
4import { createClient, LinearError } from '../lib/linear.ts'
5import {
6  checkoutLabel,
7  conflicts,
8  decideSource,
9  driftSite,
10  findRoot,
11  idFromText,
12  isCheckout,
13  touchesLinear,
14  liveOthers,
15  pickSource,
16  resolveApiKey,
17  scopeSites,
18  stateColor,
19  toPaneIssue,
20} from '../lib/logic.ts'
21import { issueDetail, myTeamKeys, normalizeId, resolveTeamKeys } from '../lib/workflow.ts'
22import type { Issue, IssueSource, SessionEntry } from '../types'
23
24const PANE = 'linear-workflow'
25const HEARTBEAT_MS = 60 * 1000
26const GC_MS = 24 * 60 * 60 * 1000
27
28const issueAtom = atom({ plugin: 'linear-workflow', key: 'issue' } as const, null)
29const sourceAtom = atom({ plugin: 'linear-workflow', key: 'source' } as const, null)
30const errorAtom = atom({ plugin: 'linear-workflow', key: 'error' } as const, null)
31const hiddenAtom = atom({ plugin: 'linear-workflow', key: 'isBandHidden' } as const, false)
32const pinnedAtom = atom({ plugin: 'linear-workflow', key: 'pinned' } as const, null)
33const pinnedByAtom = atom({ plugin: 'linear-workflow', key: 'pinnedBy' } as const, null)
34const warnedAtom = atom({ plugin: 'linear-workflow', key: 'warned' } as const, [])
35const othersAtom = atom({ plugin: 'linear-workflow', key: 'others' } as const, [])
36const conflictsWarnedAtom = atom({ plugin: 'linear-workflow', key: 'conflictsWarned' } as const, [])
37const claimedBranchAtom = atom({ plugin: 'linear-workflow', key: 'claimedBranch' } as const, null)
38
39type Repo = { root: string; sites: { key: string; path: string }[] }
40
41let repo: Repo | null = null
42let apiKey: string | undefined
43let teamKeys: string[] = []
44let config = {
45  linearApiKey: '',
46  teamKeys: '',
47  registryFile: '',
48  pinCommand: 'issue-start',
49  pollMinutes: 5,
50  showOtherSessions: true,
51}
52let lastBranchId: string | undefined
53let ownCheckout = false
54let linearTouched = false
55let registryDir: string | null = null
56let inflight: Promise<void> = Promise.resolve()
57
58async function git($: EngineInterface, cwd: string, args: string[]): Promise<string> {
59  try {
60    const ran = await $.process.run(['git', '-C', cwd, ...args], { timeoutMs: 5000 })
61    return ran.exitCode === 0 ? ran.stdout.trim() : ''
62  } catch {
63    return ''
64  }
65}
66
67async function loadRepo($: EngineInterface): Promise<Repo | null> {
68  const top = await findRoot(args => git($, '.', args))
69  if (!top) return null
70
71  let sites: Repo['sites'] = []
72  try {
73    if (!config.registryFile) throw new Error('no registry')
74    const registry = JSON.parse(await $.fs.read(`${top}/${config.registryFile}`)) as {
75      projects?: { key: string; path: string; active?: boolean }[]
76      sites?: { key: string; path: string; active?: boolean }[]
77    }
78    sites = (registry.projects ?? registry.sites ?? [])
79      .filter(s => s.active !== false)
80      .map(s => ({ key: s.key, path: `${top}/${s.path}` }))
81  } catch {
82    sites = []
83  }
84  return { root: top, sites }
85}
86
87async function loadKey($: EngineInterface): Promise<string | undefined> {
88  if (config.linearApiKey) {
89    // Hand the secret option to the workflow CLI, which runs in Bash.
90    await $.env.set('LINEAR_API_KEY', config.linearApiKey)
91  }
92  const envFileText = repo ? await $.fs.read(`${repo.root}/.env`).catch(() => undefined) : undefined
93  return resolveApiKey({ option: config.linearApiKey, env: await $.env.get('LINEAR_API_KEY'), envFileText })
94}
95
96async function resolveSource($: EngineInterface): Promise<IssueSource | null> {
97  if (!repo) return null
98  const rootBranch = await git($, repo.root, ['rev-parse', '--abbrev-ref', 'HEAD'])
99  const branches = await Promise.all(repo.sites.map(site => git($, site.path, ['rev-parse', '--abbrev-ref', 'HEAD'])))
100  const siteBranches = Object.fromEntries(repo.sites.map((site, n) => [site.key, branches[n] ?? '']))
101  const fromBranch = pickSource(rootBranch, siteBranches, teamKeys)
102  const pinned = await read($, pinnedAtom)
103  const pinnedBy = await read($, pinnedByAtom)
104  const previous = await read($, sourceAtom)
105  const claimedBranch = await read($, claimedBranchAtom)
106  const branchMoved = lastBranchId !== undefined && (fromBranch?.id ?? '') !== lastBranchId
107  const own = ownCheckout
108  ownCheckout = false
109  lastBranchId = fromBranch?.id ?? ''
110  const others = await read($, othersAtom)
111  const root = repo.root
112  const d = decideSource({
113    fromBranch,
114    pinned,
115    pinnedBy,
116    previous,
117    branchMoved,
118    own,
119    claimedBranch,
120    claimedElsewhere: id => others.some(o => o.checkout === root && o.issue === id && o.claimed !== false),
121  })
122  const by = d.pinnedBy as 'command' | 'manual' | 'held' | null
123  if (d.pinned !== pinned || d.pinnedBy !== pinnedBy) await setPin($, d.pinned, by)
124  if (d.claimedBranch !== claimedBranch) await update($, claimedBranchAtom, () => d.claimedBranch)
125  if (d.use === 'pin' && d.pinned) return pinnedSource(d.pinned, by)
126  return d.use === 'branch' ? fromBranch : null
127}
128
129function pinnedSource(id: string, by: 'command' | 'manual' | 'held' | null): IssueSource {
130  const detail =
131    by === 'held' ? 'held: another session moved the branch' : by === 'command' ? `pinned by /${config.pinCommand}` : 'pinned with /linear'
132  return { id, from: 'pinned', detail }
133}
134
135async function fetchIssue($: EngineInterface, id: string): Promise<Issue | string> {
136  if (!apiKey) return 'Linear API key not found (plugin option, LINEAR_API_KEY env, or <repo>/.env)'
137  try {
138    return toPaneIssue(await issueDetail(linear($), id, { latestComments: 3 }))
139  } catch (err) {
140    if (err instanceof LinearError) return err.message.startsWith(id) ? err.message : `${id}: ${err.message}`
141    return `${id}: ${err instanceof Error ? err.message : 'fetch failed'}`
142  }
143}
144
145async function setPin($: EngineInterface, id: string | null, by: 'command' | 'manual' | 'held' | null): Promise<void> {
146  await update($, pinnedAtom, () => id)
147  await update($, pinnedByAtom, () => by)
148}
149
150/** Pin the issue a pin command (`/issue-start ABC-123`) names, however it was invoked: typed or by the Skill tool. */
151async function pinFromSkill($: EngineInterface, name: string, args: string): Promise<void> {
152  if (!config.pinCommand || name.split(':').pop() !== config.pinCommand) return
153  const id = normalizeId(args, teamKeys.length === 1 ? teamKeys[0] : undefined) ?? idFromText(args)
154  if (!id) return
155  await setPin($, id, 'command')
156  await refresh($, true)
157}
158
159function refresh($: EngineInterface, force: boolean): Promise<void> {
160  inflight = inflight.then(() => refreshNow($, force)).then(() => syncSessions($)).catch(() => undefined)
161  return inflight
162}
163
164function heartbeat($: EngineInterface): Promise<void> {
165  inflight = inflight.then(() => syncSessions($)).catch(() => undefined)
166  return inflight
167}
168
169async function syncSessions($: EngineInterface): Promise<void> {
170  if (!repo || !registryDir || !config.showOtherSessions) return
171  const selfId = await $.session.id()
172  const now = await $.clock.now()
173  const source = await read($, sourceAtom)
174  const issue = await read($, issueAtom)
175  const fresh = issue && issue.identifier === source?.id ? issue : null
176  const self: SessionEntry = {
177    sessionId: selfId,
178    checkout: repo.root,
179    label: checkoutLabel(repo.root),
180    issue: source?.id ?? null,
181    title: fresh?.title ?? null,
182    state: fresh?.state ?? null,
183    stateType: fresh?.stateType ?? null,
184    claimed: Boolean(await read($, pinnedAtom)) || (source !== null && (await read($, claimedBranchAtom)) === source.id),
185    updatedAt: now,
186  }
187  await $.fs.write(`${registryDir}/${selfId}.json`, JSON.stringify(self))
188
189  const entries: SessionEntry[] = []
190  for (const f of await $.fs.list(registryDir).catch(() => [])) {
191    if (!f.name.endsWith('.json')) continue
192    const path = `${registryDir}/${f.name}`
193    if (now - f.mtimeMs > GC_MS) {
194      await $.process.run(['rm', '-f', path], { timeoutMs: 2000 }).catch(() => undefined)
195      continue
196    }
197    try {
198      entries.push(JSON.parse(await $.fs.read(path)) as SessionEntry)
199    } catch {
200      // a file mid-write; the next heartbeat reads it
201    }
202  }
203
204  const others = liveOthers(entries, selfId, now)
205  const shape = (list: SessionEntry[]) => JSON.stringify(list.map(o => ({ ...o, updatedAt: 0 })))
206  if (shape(others) !== shape(await read($, othersAtom))) await update($, othersAtom, () => others)
207
208  const warned = await read($, conflictsWarnedAtom)
209  const unwarned = conflicts(self, others).filter(c => !warned.includes(c.key))
210  if (unwarned.length) {
211    await update($, conflictsWarnedAtom, list => [...list, ...unwarned.map(c => c.key)])
212    for (const c of unwarned) $.ui.toast(`⚠ ${c.text}`)
213  }
214}
215
216async function refreshNow($: EngineInterface, force: boolean): Promise<void> {
217  if (teamKeys.length === 0 && apiKey) teamKeys = await loadTeamKeys($)
218  const source = await resolveSource($)
219  const previous = await read($, sourceAtom)
220  if (JSON.stringify(previous) !== JSON.stringify(source)) await update($, sourceAtom, () => source)
221
222  if (!source) {
223    await update($, issueAtom, () => null)
224    await update($, errorAtom, () => null)
225    return
226  }
227
228  const current = await read($, issueAtom)
229  if (!force && current?.identifier === source.id) return
230
231  const got = await fetchIssue($, source.id)
232  if ((await read($, sourceAtom))?.id !== source.id) return
233  if (typeof got === 'string') {
234    await update($, errorAtom, () => got)
235    return
236  }
237  if (current?.identifier === got.identifier && current.state !== got.state) {
238    $.ui.toast(`${got.identifier} moved to ${got.state}`)
239  }
240  // A held issue that is finished has nothing left to hold on to: let go and follow the branch rules again.
241  if (source.from === 'pinned' && (await read($, pinnedByAtom)) === 'held' && (got.stateType === 'completed' || got.stateType === 'canceled')) {
242    await setPin($, null, null)
243    await update($, claimedBranchAtom, () => null)
244    await update($, issueAtom, () => null)
245    return refreshNow($, force)
246  }
247  await update($, errorAtom, () => null)
248  await update($, issueAtom, () => got)
249}
250
251function linear($: EngineInterface) {
252  return createClient(async (url, init) => {
253    const res = await $.http.fetch(url, init)
254    return { status: res.status, text: res.text }
255  }, apiKey ?? '')
256}
257
258async function loadTeamKeys($: EngineInterface): Promise<string[]> {
259  if (config.teamKeys) {
260    // The CLI resolves team keys the same way; hand it the option too.
261    await $.env.set('LINEAR_WORKFLOW_TEAM_KEYS', config.teamKeys)
262  }
263  return resolveTeamKeys({
264    option: config.teamKeys,
265    configText: repo ? await $.fs.read(`${repo.root}/.claude/linear.json`).catch(() => undefined) : undefined,
266    fetchMine: apiKey ? () => myTeamKeys(linear($)) : undefined,
267  })
268}
269
270type Ui = Pick<ElementTable, 'Box' | 'Text'>
271
272/** `── Title n ─────` across the width; the title never shrinks, the rule does. */
273function divider({ Box, Text }: Ui, title: string, width: number, opts: { count?: number; dim?: boolean; marginTop?: number } = {}) {
274  return (
275    <Box marginTop={opts.marginTop ?? 0}>
276      <Box flexShrink={0}>
277        <Text dimColor>── </Text>
278        <Text bold dimColor={opts.dim}>{title}</Text>
279        {opts.count !== undefined && <Text dimColor> {opts.count}</Text>}
280        <Text> </Text>
281      </Box>
282      <Box flexShrink={1} overflow="hidden">
283        <Text dimColor wrap="truncate">{'─'.repeat(Math.max(0, width))}</Text>
284      </Box>
285    </Box>
286  )
287}
288
289/** `◆ ABC-123  In Progress · High` */
290function header({ Box, Text }: Ui, issue: Issue) {
291  return (
292    <Box>
293      <Text bold color={stateColor(issue.stateType)}>◆ </Text>
294      <Text bold>{issue.identifier}  </Text>
295      <Text color={stateColor(issue.stateType)}>{issue.state}</Text>
296      <Text dimColor> · {issue.priority}</Text>
297    </Box>
298  )
299}
300
301export const register: Register = (on, options) => {
302  config = {
303    linearApiKey: String(options.linearApiKey ?? ''),
304    teamKeys: String(options.teamKeys ?? ''),
305    registryFile: String(options.registryFile ?? ''),
306    pinCommand: String(options.pinCommand ?? 'issue-start').replace(/^\//, ''),
307    pollMinutes: Math.max(1, Number(options.pollMinutes ?? 5) || 5),
308    showOtherSessions: options.showOtherSessions !== false,
309  }
310
311  on('session.start', async ($, e, next) => {
312    const started = await next(e)
313    await $.command.register({
314      name: 'linear',
315      description: 'Linear issue pane — /linear [ABC-123 | refresh | clear | hide | show]',
316    })
317    $.clock.every(config.pollMinutes * 60 * 1000, () => refresh($, true))
318    $.clock.every(HEARTBEAT_MS, () => heartbeat($))
319    repo = await loadRepo($)
320    apiKey = await loadKey($)
321    teamKeys = await loadTeamKeys($)
322    const home = await $.env.get('HOME')
323    registryDir = home ? `${home}/.claude/linear-workflow/sessions` : null
324    // Learn which issues other sessions claim before choosing this session's own.
325    await heartbeat($)
326    await refresh($, true)
327    if (await read($, sourceAtom)) void $.ui.open({ id: PANE, title: 'Linear' })
328    return started
329  })
330
331  on('command.run', { command: 'linear' }, async ($, e) => {
332    const arg = e.args.trim()
333    switch (arg.toLowerCase()) {
334      case 'refresh':
335        await refresh($, true)
336        return { text: 'Linear issue refreshed.' }
337      case 'clear':
338        await setPin($, null, null)
339        await refresh($, true)
340        return { text: 'Pin cleared; following the branch again.' }
341      case 'hide':
342      case 'show':
343        await update($, hiddenAtom, () => arg.toLowerCase() === 'hide')
344        return { text: `Band ${arg.toLowerCase() === 'hide' ? 'hidden' : 'shown'}.` }
345    }
346
347    if (arg) {
348      const id = idFromText(arg)
349      if (!id) return { text: `Not an issue id: ${arg}` }
350      await setPin($, id, 'manual')
351      await refresh($, true)
352    }
353    await $.ui.open({ id: PANE, title: 'Linear' })
354    return { text: arg ? `Pinned ${idFromText(arg)}.` : 'Linear pane opened.' }
355  })
356
357  on('command.run', async ($, e, next) => {
358    await pinFromSkill($, String(e.command), e.args)
359    return next(e)
360  })
361
362  on('session.end', async ($, e, next) => {
363    if (registryDir) {
364      await $.process.run(['rm', '-f', `${registryDir}/${e.sessionId}.json`], { timeoutMs: 2000 }).catch(() => undefined)
365    }
366    return next(e)
367  })
368
369  on('turn.complete', async ($, e, next) => {
370    const done = await next(e)
371    // Re-fetch when this turn wrote to Linear, so the band shows the new state now, not at the next poll.
372    const force = linearTouched
373    linearTouched = false
374    await refresh($, force)
375    return done
376  })
377
378  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
379    const result = await next(e)
380    const succeeded = !('deny' in result && result.deny) && !result.isError
381    if (succeeded && isCheckout(String((e as unknown as { command?: string }).command ?? ''))) ownCheckout = true
382    return result
383  })
384
385  on('tool.call', async ($, e, next) => {
386    const tool = String(e.tool)
387    if (touchesLinear(tool, e as unknown as { command?: unknown; subagent_type?: unknown })) linearTouched = true
388    if (tool === 'Skill') {
389      const input = e as unknown as { skill?: unknown; args?: unknown }
390      await pinFromSkill($, String(input.skill ?? ''), String(input.args ?? ''))
391    }
392    if (repo && (tool === 'Edit' || tool === 'Write' || tool === 'MultiEdit' || tool === 'NotebookEdit')) {
393      const input = e as unknown as { file_path?: string; notebook_path?: string }
394      const path = input.file_path ?? input.notebook_path ?? ''
395      const fetched = await read($, issueAtom)
396      const issue = fetched?.identifier === (await read($, sourceAtom))?.id ? fetched : null
397      const siteKeys = repo.sites.map(s => s.key)
398      const site = issue && driftSite(path, repo.sites, scopeSites(issue, siteKeys))
399      if (issue && site) {
400        const tag = `${issue.identifier}:${site}`
401        if (!(await read($, warnedAtom)).includes(tag)) {
402          await update($, warnedAtom, list => [...list, tag])
403          $.ui.toast(`Drift: editing ${site}, outside ${issue.identifier}'s scope (${scopeSites(issue, siteKeys).join(', ')})`)
404        }
405      }
406    }
407    return next(e)
408  })
409
410  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
411    const source = await read($, sourceAtom)
412    const others = await read($, othersAtom)
413    if (e.props.hasSurvey || !repo || (await read($, hiddenAtom))) return next(e)
414
415    const { Box, Text, Button } = $.ui.resolve(e)
416    const issue = await read($, issueAtom)
417    const error = await read($, errorAtom)
418    const width = e.props.bodyColumns
419    const clashing = new Set(
420      repo ? conflicts({ checkout: repo.root, issue: source?.id ?? null }, others).map(c => c.sessionId) : [],
421    )
422
423    const ui = { Box, Text }
424    const rule = divider(ui, 'Linear', width, { dim: true })
425    const sessionsRow = others.length > 0 && (
426      <Box>
427        <Box flexShrink={0}>
428          <Text dimColor>  sessions  </Text>
429        </Box>
430        {others.map((o, i) => {
431          const clash = clashing.has(o.sessionId)
432          const text = o.issue ? `${o.issue}${o.state ? ` ${o.state}` : ''} (${o.label})` : `${o.label}: no issue`
433          return (
434            <Text key={o.sessionId} color={clash ? 'red' : undefined} dimColor={!clash} wrap="truncate">
435              {i > 0 ? '  ·  ' : ''}
436              {clash ? '⚠  ' : ''}
437              {text}
438            </Text>
439          )
440        })}
441      </Box>
442    )
443    const buttons = (
444      <Box>
445        <Button key="details" label="Details" plain onPress={() => $.ui.open({ id: PANE, title: 'Linear' })} />
446        <Text>  </Text>
447        <Button key="hide" label="Hide" plain onPress={() => update($, hiddenAtom, () => true)} />
448      </Box>
449    )
450
451    if (!source) {
452      return (
453        <Box flexDirection="column" width={width} marginTop={1}>
454          {rule}
455          <Text dimColor>  No Linear issue found</Text>
456          {sessionsRow}
457        </Box>
458      )
459    }
460
461    if (!issue || issue.identifier !== source.id) {
462      return (
463        <Box flexDirection="column" width={width} marginTop={1}>
464          {rule}
465          <Text dimColor wrap="truncate">
466            ◆ {source.id}  {error ?? 'loading…'}
467          </Text>
468          {sessionsRow}
469        </Box>
470      )
471    }
472
473    const siteKeys = repo?.sites.map(s => s.key) ?? []
474    const scope = scopeSites(issue, siteKeys)
475    const meta = [
476      source.from === 'pinned' ? (source.detail.startsWith('held') ? 'held' : 'pinned') : source.from === 'root' ? 'root branch' : source.detail,
477      siteKeys.length === 0 ? '' : scope.length ? `scope: ${scope.join(', ')}` : 'scope: all',
478      issue.parent ? `parent ${issue.parent.identifier}` : '',
479      error ? `stale: ${error}` : '',
480    ].filter(Boolean)
481
482    return (
483      <Box flexDirection="column" width={width} marginTop={1}>
484        {rule}
485        {header(ui, issue)}
486        <Text wrap="truncate">  {issue.title}</Text>
487        <Box justifyContent="space-between">
488          <Box flexShrink={1}>
489            <Text dimColor wrap="truncate">  {meta.join(' · ')}</Text>
490          </Box>
491          <Box flexShrink={0}>{buttons}</Box>
492        </Box>
493        {sessionsRow}
494      </Box>
495    )
496  })
497
498  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
499    const { Box, Text, Button, Link, Markdown } = $.ui.resolve(e)
500    const source = await read($, sourceAtom)
501    const issue = await read($, issueAtom)
502    const error = await read($, errorAtom)
503    const others = await read($, othersAtom)
504    const width = e.props.bodyColumns
505    const clashes = repo ? conflicts({ checkout: repo.root, issue: source?.id ?? null }, others) : []
506
507    const ui = { Box, Text }
508    const section = (title: string, count?: number) => divider(ui, title, width, { count, marginTop: 1 })
509    const field = (label: string, value: string | null | undefined) =>
510      value ? (
511        <Box key={label}>
512          <Box flexShrink={0} width={11}>
513            <Text dimColor>{label}</Text>
514          </Box>
515          <Box flexShrink={1}>
516            <Text wrap="truncate">{value}</Text>
517          </Box>
518        </Box>
519      ) : null
520    const othersSection = others.length > 0 && (
521      <Box flexDirection="column">
522        {section('Other sessions', others.length)}
523        {others.map(o => (
524          <Box key={o.sessionId}>
525            <Text color={o.stateType ? stateColor(o.stateType) : undefined}>{(o.state ?? '—').padEnd(13)}</Text>
526            <Text wrap="truncate">{o.issue ?? 'no issue'}  {o.title ?? ''}</Text>
527            <Text dimColor>  {o.label}</Text>
528          </Box>
529        ))}
530        {clashes.map(c => <Text key={c.key} color="red">⚠  {c.text}</Text>)}
531      </Box>
532    )
533
534    if (!issue || issue.identifier !== source?.id) {
535      return (
536        <Box flexDirection="column" width={width}>
537          <Text dimColor>{source ? `${source.id}: ${error ?? 'loading…'}` : 'No Linear issue on this branch. /linear ABC-123 pins one.'}</Text>
538          {othersSection}
539        </Box>
540      )
541    }
542
543    const siteKeys = repo?.sites.map(s => s.key) ?? []
544    const scope = scopeSites(issue, siteKeys)
545    const sourceText = source
546      ? source.from === 'pinned' ? source.detail : source.from === 'root' ? `branch ${source.detail}` : source.detail
547      : null
548
549    return (
550      <Box flexDirection="column" width={width}>
551        {header(ui, issue)}
552        <Text bold>{issue.title}</Text>
553        <Box marginTop={1}>
554          <Button key="copy" label="Copy link" onPress={async () => {
555            const copied = await $.ui.copy({ text: issue.url })
556            $.ui.toast(copied.isCopied ? `Copied ${issue.url}` : issue.url)
557          }} />
558          <Text>  </Text>
559          <Button key="refresh" label="Refresh" hotkey="r" onPress={() => refresh($, true)} />
560        </Box>
561        {error && <Text color="red">Refresh failed: {error}</Text>}
562
563        <Box flexDirection="column" marginTop={1}>
564          {field('Parent', issue.parent ? `${issue.parent.identifier} · ${issue.parent.title}` : null)}
565          {field('Assignee', issue.assignee)}
566          {field('Cycle', issue.cycle !== null ? String(issue.cycle) : null)}
567          {field('Milestone', issue.milestone)}
568          {field('Labels', issue.labels.join(', ') || null)}
569          {field('Scope', scope.length ? scope.join(', ') : 'all')}
570          {field('Source', sourceText)}
571        </Box>
572
573        {issue.children.length > 0 && (
574          <Box flexDirection="column">
575            {section('Sub-issues', issue.children.length)}
576            {issue.children.map(c => (
577              <Box key={c.identifier}>
578                <Text color={stateColor(c.stateType)}>{c.state.padEnd(13)}</Text>
579                <Text wrap="truncate">{c.identifier}  {c.title}</Text>
580              </Box>
581            ))}
582          </Box>
583        )}
584
585        {section('Description')}
586        <Markdown text={issue.description || '_No description._'} />
587
588        {issue.comments.length > 0 && (
589          <Box flexDirection="column">
590            {section('Latest comments', issue.comments.length)}
591            {issue.comments.map((c, i) => (
592              <Box key={`c${i}`} flexDirection="column" marginTop={i > 0 ? 1 : 0}>
593                <Text dimColor>{c.author} · {c.createdAt}</Text>
594                <Markdown text={c.body} />
595              </Box>
596            ))}
597          </Box>
598        )}
599
600        {section('Links')}
601        <Link href={issue.url} label="Open in Linear" />
602        {issue.links.map(l => <Link key={l.url} href={l.url} label={l.title} />)}
603
604        {othersSection}
605      </Box>
606    )
607  })
608}
609
lib/linear.ts 106 lines
1// Linear GraphQL client shared by the mod (through $.http.fetch) and the CLI (through fetch).
2
3export type Fetcher = (
4  url: string,
5  init: { method: string; headers: Record<string, string>; body: string },
6) => Promise<{ status: number; text: string }>
7
8export type StateType = 'triage' | 'backlog' | 'unstarted' | 'started' | 'completed' | 'canceled'
9
10export type IssueSummary = {
11  id: string
12  identifier: string
13  title: string
14  url: string
15  priority: number
16  priorityLabel: string
17  state: { name: string; type: StateType }
18  createdAt: string
19  branchName: string
20  milestone: { name: string; targetDate: string | null } | null
21  labels: string[]
22  parent: { identifier: string; title: string; cycleId: string | null; labels: string[] } | null
23  cycleId: string | null
24  blockedBy: { identifier: string; state: string }[]
25  childCount: number
26}
27
28export class LinearError extends Error {}
29
30const ENDPOINT = 'https://api.linear.app/graphql'
31
32export function createClient(fetcher: Fetcher, apiKey: string) {
33  async function query<T>(text: string, variables: Record<string, unknown> = {}): Promise<T> {
34    const res = await fetcher(ENDPOINT, {
35      method: 'POST',
36      headers: { Authorization: apiKey, 'Content-Type': 'application/json' },
37      body: JSON.stringify({ query: text, variables }),
38    })
39    let body: { data?: T; errors?: { message: string }[] }
40    try {
41      body = JSON.parse(res.text) as typeof body
42    } catch {
43      throw new LinearError(`Linear returned HTTP ${res.status} with a non-JSON body`)
44    }
45    if (body.errors?.length) throw new LinearError(body.errors.map(e => e.message).join('; '))
46    if (!body.data) throw new LinearError(`Linear returned HTTP ${res.status} with no data`)
47    return body.data
48  }
49  return { query }
50}
51
52export type Client = ReturnType<typeof createClient>
53
54export const SUMMARY_FIELDS = `id identifier title url priority priorityLabel createdAt branchName
55state{name type} projectMilestone{name targetDate} labels{nodes{name}} cycle{id}
56parent{identifier title cycle{id} labels{nodes{name}}} children{nodes{id}}
57inverseRelations{nodes{type issue{identifier state{name type}}}}`
58
59type RawSummary = {
60  id: string
61  identifier: string
62  title: string
63  url: string
64  priority: number
65  priorityLabel: string
66  createdAt: string
67  branchName: string
68  state: { name: string; type: StateType }
69  projectMilestone: { name: string; targetDate: string | null } | null
70  labels: { nodes: { name: string }[] }
71  cycle: { id: string } | null
72  parent: { identifier: string; title: string; cycle: { id: string } | null; labels: { nodes: { name: string }[] } } | null
73  children: { nodes: { id: string }[] }
74  inverseRelations: { nodes: { type: string; issue: { identifier: string; state: { name: string; type: StateType } } }[] }
75}
76
77export function isOpenType(type: StateType): boolean {
78  return type !== 'completed' && type !== 'canceled'
79}
80
81export function toSummary(raw: RawSummary): IssueSummary {
82  return {
83    id: raw.id,
84    identifier: raw.identifier,
85    title: raw.title,
86    url: raw.url,
87    priority: raw.priority,
88    priorityLabel: raw.priorityLabel,
89    state: raw.state,
90    createdAt: raw.createdAt,
91    branchName: raw.branchName,
92    milestone: raw.projectMilestone,
93    labels: raw.labels.nodes.map(l => l.name),
94    parent: raw.parent
95      ? { identifier: raw.parent.identifier, title: raw.parent.title, cycleId: raw.parent.cycle?.id ?? null, labels: raw.parent.labels.nodes.map(l => l.name) }
96      : null,
97    cycleId: raw.cycle?.id ?? null,
98    blockedBy: raw.inverseRelations.nodes
99      .filter(r => r.type === 'blocks' && isOpenType(r.issue.state.type))
100      .map(r => ({ identifier: r.issue.identifier, state: r.issue.state.name })),
101    childCount: raw.children.nodes.length,
102  }
103}
104
105export type { RawSummary }
106
lib/logic.ts 200 lines
1import type { Issue, IssueSource, SessionEntry } from '../types'
2import type { IssueDetail } from './workflow.ts'
3
4const ANY_ID = /\b([A-Z][A-Z0-9]{1,9})-(\d+)\b/
5
6export function idFromBranch(branch: string, teamKeys: string[]): string | undefined {
7  if (teamKeys.length === 0) return undefined
8  const re = new RegExp(`(?:^|/)(${teamKeys.join('|')})-(\\d+)(?:-|$)`, 'i')
9  const m = re.exec(branch.trim())
10  return m?.[1] && m[2] ? `${m[1].toUpperCase()}-${m[2]}` : undefined
11}
12
13export function idFromText(text: string): string | undefined {
14  const m = ANY_ID.exec(text.toUpperCase())
15  return m?.[1] && m[2] ? `${m[1]}-${m[2]}` : undefined
16}
17
18export function readEnvValue(text: string, key: string): string | undefined {
19  for (const line of text.split('\n')) {
20    const m = /^\s*(?:export\s+)?([A-Z0-9_]+)\s*=\s*(.*)$/.exec(line)
21    if (m?.[1] === key) return (m[2] ?? '').trim().replace(/^(['"])(.*)\1$/, '$2')
22  }
23  return undefined
24}
25
26/** The issue named by the root branch, else by the most sub-project branches. */
27export function pickSource(rootBranch: string, siteBranches: Record<string, string>, teamKeys: string[]): IssueSource | null {
28  const rootId = idFromBranch(rootBranch, teamKeys)
29  if (rootId) return { id: rootId, from: 'root', detail: rootBranch }
30
31  const bySite = new Map<string, string[]>()
32  for (const [site, branch] of Object.entries(siteBranches)) {
33    const id = idFromBranch(branch, teamKeys)
34    if (id) bySite.set(id, [...(bySite.get(id) ?? []), site])
35  }
36  if (bySite.size === 0) return null
37
38  const top = [...bySite.entries()].sort((a, b) => b[1].length - a[1].length)[0]
39  if (!top) return null
40  const [id, sites] = top
41  const others = bySite.size > 1 ? `, mixed: ${[...bySite.keys()].filter(k => k !== id).join(', ')}` : ''
42  return { id, from: 'sites', detail: `${sites.length} project(s) on it${others}` }
43}
44
45/** Site keys the issue is scoped to through its labels (or its parent's); empty means every site. */
46export function scopeSites(issue: Issue, siteKeys: string[]): string[] {
47  const own = issue.labels.filter(l => siteKeys.includes(l))
48  if (own.length > 0) return own
49  return (issue.parent?.labels ?? []).filter(l => siteKeys.includes(l))
50}
51
52export function driftSite(
53  filePath: string,
54  sites: { key: string; path: string }[],
55  scope: string[],
56): string | undefined {
57  if (scope.length === 0) return undefined
58  const site = sites.find(s => filePath.startsWith(`${s.path.replace(/\/$/, '')}/`))
59  return site && !scope.includes(site.key) ? site.key : undefined
60}
61
62/** The pane's view of an issue, from the shared fetch: latest comments, bodies capped. */
63export function toPaneIssue(d: IssueDetail): Issue {
64  return {
65    identifier: d.identifier,
66    title: d.title,
67    url: d.url,
68    state: d.state.name,
69    stateType: d.state.type,
70    priority: d.priorityLabel,
71    labels: d.labels,
72    parent: d.parent ? { identifier: d.parent.identifier, title: d.parent.title, labels: d.parent.labels } : null,
73    assignee: d.assignee,
74    cycle: d.cycle,
75    milestone: d.milestone?.name ?? null,
76    description: d.description.slice(0, 6000),
77    children: d.children.map(c => ({ identifier: c.identifier, title: c.title, state: c.state.name, stateType: c.state.type })),
78    comments: d.comments.slice(-3).map(c => ({ author: c.author, createdAt: c.createdAt.slice(0, 10), body: c.body.slice(0, 1200) })),
79    links: d.links,
80  }
81}
82
83/** The repo the session works in: the superproject when inside a submodule. */
84export async function findRoot(git: (args: string[]) => Promise<string>): Promise<string> {
85  return (await git(['rev-parse', '--show-superproject-working-tree'])) || (await git(['rev-parse', '--show-toplevel']))
86}
87
88/** API key: explicit option, then the environment, then `<root>/.env`. */
89export function resolveApiKey(input: { option?: string; env?: string; envFileText?: string }): string | undefined {
90  return input.option || input.env || (input.envFileText ? readEnvValue(input.envFileText, 'LINEAR_API_KEY') : undefined) || undefined
91}
92
93export function stateColor(type: string): string {
94  switch (type) {
95    case 'started': return 'yellow'
96    case 'completed': return 'green'
97    case 'canceled': return 'gray'
98    case 'triage': return 'magenta'
99    default: return 'blue'
100  }
101}
102
103export const STALE_MS = 3 * 60 * 1000
104
105export function isCheckout(command: string): boolean {
106  return command.split(/&&|\|\||;|\n/).some(part => {
107    if (!/\bgit\b(?:\s+-C\s+\S+|\s+-[-\w]+(?:=\S+)?)*\s+(?:checkout|switch)\b/.test(part)) return false
108    return !/\s--(?:\s|$)/.test(part)
109  })
110}
111
112export function checkoutLabel(checkout: string): string {
113  const m = /\/worktrees\/([^/]+)\/?$/.exec(checkout)
114  return m?.[1] ?? 'main'
115}
116
117export function liveOthers(entries: SessionEntry[], selfId: string, now: number): SessionEntry[] {
118  return entries
119    .filter(s => s.sessionId !== selfId && now - s.updatedAt < STALE_MS)
120    .sort((a, b) => a.label.localeCompare(b.label) || (a.issue ?? '').localeCompare(b.issue ?? ''))
121}
122
123export type Conflict = { key: string; sessionId: string; text: string }
124
125export function conflicts(self: { checkout: string; issue: string | null }, others: SessionEntry[]): Conflict[] {
126  const found: Conflict[] = []
127  for (const o of others) {
128    if (self.issue && o.issue === self.issue) {
129      found.push({ key: `same:${o.sessionId}:${o.issue}`, sessionId: o.sessionId, text: `another session (${o.label}) is also on ${o.issue}` })
130    } else if (o.checkout === self.checkout && o.issue && self.issue && o.issue !== self.issue) {
131      found.push({
132        key: `checkout:${o.sessionId}:${o.issue}:${self.issue}`,
133        sessionId: o.sessionId,
134        text: `${o.issue} is being worked in this same checkout (${o.label}) — shared branch and tree`,
135      })
136    }
137  }
138  return found
139}
140
141export type SourceDecision = {
142  /** Which issue the session shows: its pin, the branch's, or none. */
143  use: 'pin' | 'branch' | 'none'
144  pinned: string | null
145  pinnedBy: string | null
146  /** The branch issue this session took on by switching to it itself. */
147  claimedBranch: string | null
148}
149
150/**
151 * Decide which issue a session shows. A session only shows an issue it took on: a pin, a branch it
152 * switched to itself, or a branch issue no other live session in the same checkout has claimed.
153 */
154export function decideSource(input: {
155  fromBranch: IssueSource | null
156  pinned: string | null
157  pinnedBy: string | null
158  previous: IssueSource | null
159  branchMoved: boolean
160  own: boolean
161  claimedBranch: string | null
162  claimedElsewhere: (id: string) => boolean
163}): SourceDecision {
164  const { fromBranch, previous, branchMoved, own, claimedElsewhere } = input
165  let { pinned, pinnedBy, claimedBranch } = input
166  // Only a switch this session made claims (or, onto a branch without an issue, releases) a branch issue.
167  if (own && branchMoved) claimedBranch = fromBranch?.id ?? null
168
169  if (branchMoved && !own) {
170    // Another session moved the shared branch: keep the issue this session had claimed.
171    if (!pinned && previous && claimedBranch === previous.id) {
172      pinned = previous.id
173      pinnedBy = 'held'
174    }
175  } else if (pinned && pinnedBy === 'held' && own && fromBranch?.id === pinned) {
176    pinned = null
177    pinnedBy = null
178  } else if (pinned && branchMoved) {
179    const release = pinnedBy === 'manual' ? fromBranch !== null && fromBranch.id !== pinned : fromBranch?.id !== pinned
180    if (release) {
181      pinned = null
182      pinnedBy = null
183    }
184  }
185
186  const decided = (use: SourceDecision['use']): SourceDecision => ({ use, pinned, pinnedBy, claimedBranch })
187  if (pinned) return decided('pin')
188  if (!fromBranch) return decided('none')
189  if (claimedBranch === fromBranch.id) return decided('branch')
190  if (claimedElsewhere(fromBranch.id)) return decided('none')
191  return decided('branch')
192}
193
194/** Whether a tool call may have changed Linear, so the issue is worth fetching again. */
195export function touchesLinear(tool: string, input: { command?: unknown; subagent_type?: unknown }): boolean {
196  if (tool === 'Bash') return /linear-workflow\.ts"?\s+(transition|set-cycle)\b/.test(String(input.command ?? ''))
197  if (tool === 'Agent') return /linear-manager/.test(String(input.subagent_type ?? ''))
198  return /^mcp__.*linear.*__(save|create|update)_(issue|comment)$/i.test(tool)
199}
200
lib/workflow.ts 495 lines
1import type { Client, IssueSummary, RawSummary, StateType } from './linear.ts'
2import { isOpenType, LinearError, SUMMARY_FIELDS, toSummary } from './linear.ts'
3
4export type ProjectConfig = {
5  team?: string
6  teamKey?: string
7  project?: string
8  assignee?: string
9  baseBranch?: string
10  checks?: string[]
11  mergeMethod?: 'merge' | 'squash' | 'rebase' | 'local'
12  deleteBranch?: boolean
13  /** Your team's names for the three states the skills move issues to. */
14  states?: StateNames
15  /** What issue-start does about branches: create one (default), ask, or stay on the current one. */
16  branching?: 'create' | 'ask' | 'none'
17  /** Path (from the repo root) of a markdown template the agent uses for new issue descriptions. */
18  issueTemplate?: string
19}
20
21export type StateNames = { inProgress?: string; inReview?: string; done?: string }
22
23export function parseProjectConfig(text: string | undefined): ProjectConfig {
24  if (!text) return {}
25  const raw = JSON.parse(text) as Record<string, unknown>
26  const out: ProjectConfig = {}
27  for (const key of ['team', 'teamKey', 'project', 'assignee', 'baseBranch'] as const) {
28    if (typeof raw[key] === 'string') out[key] = raw[key] as string
29  }
30  if (Array.isArray(raw.checks)) out.checks = raw.checks.filter((c): c is string => typeof c === 'string')
31  if (['merge', 'squash', 'rebase', 'local'].includes(String(raw.mergeMethod))) {
32    out.mergeMethod = raw.mergeMethod as ProjectConfig['mergeMethod']
33  }
34  if (typeof raw.deleteBranch === 'boolean') out.deleteBranch = raw.deleteBranch
35  if (['create', 'ask', 'none'].includes(String(raw.branching))) out.branching = raw.branching as ProjectConfig['branching']
36  if (typeof raw.issueTemplate === 'string') out.issueTemplate = raw.issueTemplate
37  if (raw.states && typeof raw.states === 'object') {
38    const st = raw.states as Record<string, unknown>
39    const names: StateNames = {}
40    for (const key of ['inProgress', 'inReview', 'done'] as const) if (typeof st[key] === 'string') names[key] = st[key] as string
41    out.states = names
42  }
43  return out
44}
45
46/** `ENG-12`, `eng-12`, `#12` or `12` (with a single known team key) → `ENG-12`. */
47export function normalizeId(input: string, teamKey?: string): string | undefined {
48  const text = input.trim()
49  const full = /^([A-Za-z][A-Za-z0-9]{0,9})-(\d+)$/.exec(text)
50  if (full?.[1] && full[2]) return `${full[1].toUpperCase()}-${full[2]}`
51  const bare = /^#?(\d+)$/.exec(text)
52  if (bare?.[1] && teamKey) return `${teamKey.toUpperCase()}-${bare[1]}`
53  return undefined
54}
55
56/** Team keys to look for in branch names: the repo's linear.json, then the user's plugin option, then your teams. */
57export async function resolveTeamKeys(input: {
58  option?: string
59  configText?: string
60  fetchMine?: () => Promise<string[]>
61}): Promise<string[]> {
62  let fromConfig: string[] = []
63  try {
64    fromConfig = splitKeys(parseProjectConfig(input.configText).teamKey)
65  } catch {
66    // unreadable linear.json: fall through
67  }
68  if (fromConfig.length) return fromConfig
69  const fromOption = splitKeys(input.option)
70  if (fromOption.length) return fromOption
71  try {
72    return input.fetchMine ? splitKeys((await input.fetchMine()).join(',')) : []
73  } catch {
74    return []
75  }
76}
77
78function splitKeys(value: string | undefined): string[] {
79  return String(value ?? '')
80    .split(/[\s,]+/)
81    .map(k => k.trim().toUpperCase())
82    .filter(k => /^[A-Z][A-Z0-9]{0,9}$/.test(k))
83}
84
85/** Whole days from `today` (YYYY-MM-DD) to `date`; negative when overdue. */
86export function daysUntil(date: string | null | undefined, today: string): number | null {
87  if (!date) return null
88  const ms = Date.parse(`${date.slice(0, 10)}T00:00:00Z`) - Date.parse(`${today}T00:00:00Z`)
89  return Number.isNaN(ms) ? null : Math.round(ms / 86_400_000)
90}
91
92// ---------- ranking (pure) ----------
93
94function priorityRank(p: number): number {
95  return p === 0 ? 5 : p
96}
97
98export function compareIssues(a: IssueSummary, b: IssueSummary): number {
99  const byPriority = priorityRank(a.priority) - priorityRank(b.priority)
100  if (byPriority) return byPriority
101  const da = a.milestone?.targetDate ?? '9999-99-99'
102  const db = b.milestone?.targetDate ?? '9999-99-99'
103  if (da !== db) return da < db ? -1 : 1
104  return a.createdAt < b.createdAt ? -1 : a.createdAt > b.createdAt ? 1 : 0
105}
106
107export function rank(issues: IssueSummary[]): IssueSummary[] {
108  return [...issues].sort(compareIssues)
109}
110
111const ACTIONABLE: StateType[] = ['unstarted', 'started']
112
113const REVIEW_NAMES = ['in review', 'review', 'to review', 'qa', 'in qa', 'code review']
114
115let customStates: StateNames = {}
116
117/** Apply the project's own state names (`states` in .claude/linear.json) for this process. */
118export function useStateNames(names: StateNames | undefined): void {
119  customStates = names ?? {}
120}
121
122/** Linear files review states under `started`, like In Progress; they are not work to pick up. */
123export function isInReview(state: { name: string }): boolean {
124  const name = state.name.trim().toLowerCase()
125  return REVIEW_NAMES.includes(name) || name === customStates.inReview?.trim().toLowerCase()
126}
127
128export function isActionable(issue: IssueSummary): boolean {
129  return ACTIONABLE.includes(issue.state.type) && !isInReview(issue.state) && issue.blockedBy.length === 0
130}
131
132// ---------- reads ----------
133
134export type Cycle = { id: string; number: number; startsAt: string; endsAt: string; teamKey: string }
135
136export async function activeCycle(client: Client, teamKey?: string): Promise<Cycle | null> {
137  const data = await client.query<{ viewer: { teamMemberships: { nodes: { team: { key: string; activeCycle: { id: string; number: number; startsAt: string; endsAt: string } | null } }[] } } }>(
138    `query{viewer{teamMemberships{nodes{team{key activeCycle{id number startsAt endsAt}}}}}}`,
139  )
140  const teams = data.viewer.teamMemberships.nodes.map(n => n.team)
141  const team = teamKey ? teams.find(t => t.key.toUpperCase() === teamKey.toUpperCase()) : teams.find(t => t.activeCycle)
142  if (teamKey && !team) throw new LinearError(`You are not a member of a team with key ${teamKey}`)
143  return team?.activeCycle ? { ...team.activeCycle, teamKey: team.key } : null
144}
145
146export type QueueEntry = IssueSummary & { epic: { identifier: string; title: string } | null; dueInDays: number | null }
147export type QueueResult = {
148  cycle: Cycle | null
149  examined: number
150  candidates: QueueEntry[]
151  droppedBlocked: string[]
152  droppedEpics: { identifier: string; reason: string }[]
153}
154
155export async function queue(client: Client, opts: { teamKey?: string; limit?: number; today: string }): Promise<QueueResult> {
156  const cycle = await activeCycle(client, opts.teamKey)
157  if (!cycle) return { cycle: null, examined: 0, candidates: [], droppedBlocked: [], droppedEpics: [] }
158  const data = await client.query<{ issues: { nodes: RawSummary[] } }>(
159    `query($cycle:ID!){issues(first:250,filter:{cycle:{id:{eq:$cycle}},assignee:{isMe:{eq:true}},state:{type:{in:["unstarted","started"]}}}){nodes{${SUMMARY_FIELDS}}}}`,
160    { cycle: cycle.id },
161  )
162  const issues = data.issues.nodes.map(toSummary).filter(i => !isInReview(i.state))
163  const droppedBlocked = issues.filter(i => i.blockedBy.length > 0).map(i => i.identifier)
164  const droppedEpics: QueueResult['droppedEpics'] = []
165  const candidates: QueueEntry[] = []
166  const due = (i: IssueSummary) => daysUntil(i.milestone?.targetDate, opts.today)
167
168  const unblocked = issues.filter(i => i.blockedBy.length === 0)
169  const isEpic = (i: IssueSummary) => i.state.type === 'started' && i.childCount > 0
170  const nexts = await Promise.all(unblocked.map(i => (isEpic(i) ? nextChild(client, i.id) : Promise.resolve(null))))
171  unblocked.forEach((issue, n) => {
172    if (!isEpic(issue)) {
173      candidates.push({ ...issue, epic: null, dueInDays: due(issue) })
174      return
175    }
176    const next = nexts[n]
177    if (next) candidates.push({ ...next, epic: { identifier: issue.identifier, title: issue.title }, dueInDays: due(next) })
178    else droppedEpics.push({ identifier: issue.identifier, reason: 'no unblocked Todo or In Progress sub-issue' })
179  })
180  const seen = new Set<string>()
181  const unique = candidates.filter(c => (seen.has(c.identifier) ? false : (seen.add(c.identifier), true)))
182  return {
183    cycle,
184    examined: issues.length,
185    candidates: rank(unique).slice(0, opts.limit ?? 10) as QueueEntry[],
186    droppedBlocked,
187    droppedEpics,
188  }
189}
190
191async function nextChild(client: Client, parentId: string): Promise<IssueSummary | null> {
192  const data = await client.query<{ issue: { children: { nodes: RawSummary[] } } }>(
193    `query($id:String!){issue(id:$id){children(first:250,filter:{state:{type:{in:["unstarted","started"]}}}){nodes{${SUMMARY_FIELDS}}}}}`,
194    { id: parentId },
195  )
196  return rank(data.issue.children.nodes.map(toSummary).filter(isActionable))[0] ?? null
197}
198
199export type PlanCycleResult = {
200  cycle: (Cycle & { issueCount: number }) | null
201  examined: number
202  candidates: IssueSummary[]
203  droppedBlocked: number
204  droppedChildren: number
205}
206
207export async function planCycle(client: Client, opts: { teamKey?: string; project?: string; limit?: number }): Promise<PlanCycleResult> {
208  const cycle = await activeCycle(client, opts.teamKey)
209  if (!cycle) return { cycle: null, examined: 0, candidates: [], droppedBlocked: 0, droppedChildren: 0 }
210  const projectFilter = opts.project ? `,project:{name:{eqIgnoreCase:$project}}` : ''
211  const data = await client.query<{ issues: { nodes: RawSummary[] }; cycle: { issues: { nodes: { id: string }[] } } }>(
212    `query($cycle:String!,$team:String!${opts.project ? ',$project:String!' : ''}){
213      cycle(id:$cycle){issues(first:250){nodes{id}}}
214      issues(first:250,filter:{cycle:{null:true},assignee:{isMe:{eq:true}},team:{key:{eq:$team}},state:{type:{in:["backlog","unstarted"]}}${projectFilter}}){nodes{${SUMMARY_FIELDS}}}}`,
215    { cycle: cycle.id, team: cycle.teamKey, ...(opts.project ? { project: opts.project } : {}) },
216  )
217  const issues = data.issues.nodes.map(toSummary)
218  const blocked = issues.filter(i => i.blockedBy.length > 0)
219  const children = issues.filter(i => i.blockedBy.length === 0 && i.parent?.cycleId === cycle.id)
220  const rest = issues.filter(i => i.blockedBy.length === 0 && i.parent?.cycleId !== cycle.id)
221  return {
222    cycle: { ...cycle, issueCount: data.cycle.issues.nodes.length },
223    examined: issues.length,
224    candidates: rank(rest).slice(0, opts.limit ?? 15),
225    droppedBlocked: blocked.length,
226    droppedChildren: children.length,
227  }
228}
229
230export type Criterion = { text: string; checked: boolean }
231
232const HEADING = /^(#{1,6})\s+(.*?)\s*:?\s*$/
233export const ACCEPTANCE_HEADING = /^acceptance criteria$/i
234
235/**
236 * Where a `## Title` section sits in `lines`: its heading line, and where it ends (the next heading of the
237 * same or a higher level, so its sub-headings stay inside). One rule for every reader of a section.
238 */
239export function findSection(lines: string[], title: RegExp): { heading: number; end: number } | null {
240  let heading = -1
241  let level = 0
242  for (let i = 0; i < lines.length; i++) {
243    const h = HEADING.exec((lines[i] as string).trim())
244    if (!h?.[1]) continue
245    if (heading === -1) {
246      if (title.test(h[2] ?? '')) {
247        heading = i
248        level = h[1].length
249      }
250    } else if (h[1].length <= level) return { heading, end: i }
251  }
252  return heading === -1 ? null : { heading, end: lines.length }
253}
254
255/**
256 * The criteria in the "Acceptance Criteria" section, sub-headings included, with their checkbox state.
257 * A section written as prose becomes one criterion, so a non-empty section never reads as "none".
258 */
259export function parseAcceptanceCriteria(description: string): Criterion[] {
260  const lines = description.split('\n')
261  const section = findSection(lines, ACCEPTANCE_HEADING)
262  if (!section) return []
263  const body = lines.slice(section.heading + 1, section.end).filter(l => !HEADING.test(l.trim()))
264  const out: Criterion[] = []
265  for (const line of body) {
266    const m = /^\s*(?:[-*+]|\d+[.)])\s+(?:\[([ xX])\]\s+)?(.+?)\s*$/.exec(line)
267    if (m?.[2]) out.push({ text: m[2], checked: (m[1] ?? ' ').toLowerCase() === 'x' })
268  }
269  if (out.length) return out
270  const prose = body.map(l => l.trim()).filter(Boolean).join(' ')
271  return prose ? [{ text: prose, checked: false }] : []
272}
273
274export type IssueDetail = IssueSummary & {
275  acceptanceCriteria: Criterion[]
276  description: string
277  teamKey: string
278  assignee: string | null
279  cycle: number | null
280  children: { identifier: string; title: string; state: { name: string; type: StateType } }[]
281  links: { title: string; url: string }[]
282  comments: { id: string; author: string; createdAt: string; body: string }[]
283}
284
285/** The full issue. `latestComments` fetches only that many of the newest comments (the pane's need). */
286export async function issueDetail(client: Client, id: string, opts: { latestComments?: number } = {}): Promise<IssueDetail> {
287  const comments = opts.latestComments ? `comments(last:${Math.max(1, Math.floor(opts.latestComments))})` : 'comments(first:250)'
288  const data = await client.query<{ issue: (RawSummary & {
289    description: string | null
290    team: { key: string }
291    assignee: { name: string } | null
292    cycle: { id: string; number: number } | null
293    subIssues: { nodes: { identifier: string; title: string; state: { name: string; type: StateType } }[] }
294    attachments: { nodes: { title: string; url: string }[] }
295    comments: { nodes: { id: string; body: string; createdAt: string; user: { name: string } | null }[] }
296  }) | null }>(
297    `query($id:String!){issue(id:$id){${SUMMARY_FIELDS} description team{key} assignee{name} cycle{id number}
298      subIssues: children(first:250){nodes{identifier title state{name type}}}
299      attachments{nodes{title url}}
300      ${comments}{nodes{id body createdAt user{name}}}}}`,
301    { id },
302  )
303  if (!data.issue) throw new LinearError(`${id}: not found`)
304  const raw = data.issue
305  return {
306    ...toSummary(raw),
307    description: raw.description ?? '',
308    acceptanceCriteria: parseAcceptanceCriteria(raw.description ?? ''),
309    teamKey: raw.team.key,
310    assignee: raw.assignee?.name ?? null,
311    cycle: raw.cycle?.number ?? null,
312    children: raw.subIssues.nodes,
313    links: raw.attachments.nodes,
314    comments: raw.comments.nodes
315      .map(c => ({ id: c.id, author: c.user?.name ?? 'integration', createdAt: c.createdAt, body: c.body }))
316      .sort((a, b) => (a.createdAt < b.createdAt ? -1 : 1)),
317  }
318}
319
320export async function myTeamKeys(client: Client): Promise<string[]> {
321  const data = await client.query<{ viewer: { teamMemberships: { nodes: { team: { key: string } }[] } } }>(
322    `query{viewer{teamMemberships{nodes{team{key}}}}}`,
323  )
324  return data.viewer.teamMemberships.nodes.map(n => n.team.key.toUpperCase())
325}
326
327export async function myOpenIssues(client: Client): Promise<IssueSummary[]> {
328  const data = await client.query<{ issues: { nodes: RawSummary[] } }>(
329    `query{issues(first:50,filter:{assignee:{isMe:{eq:true}},state:{type:{eq:"started"}}}){nodes{${SUMMARY_FIELDS}}}}`,
330  )
331  return data.issues.nodes.map(toSummary)
332}
333
334// ---------- writes ----------
335
336export type TeamState = { id: string; name: string; type: StateType; position: number }
337
338const ROLE_ALIASES: Record<string, string[]> = {
339  'in progress': ['in progress', 'started', 'doing'],
340  'in review': REVIEW_NAMES,
341  done: ['done', 'completed', 'shipped', 'closed'],
342}
343
344const ROLE_KEYS: Record<string, keyof StateNames> = { 'in progress': 'inProgress', 'in review': 'inReview', done: 'done' }
345
346/** Resolve a target like "In Review" against a team's workflow states, the project's own names first. */
347export function resolveState(states: TeamState[], target: string): TeamState | undefined {
348  const wanted = target.trim().toLowerCase()
349  const role = Object.entries(ROLE_ALIASES).find(([, names]) => names.includes(wanted))
350  const customName = role ? customStates[ROLE_KEYS[role[0]] as keyof StateNames]?.trim().toLowerCase() : undefined
351  const custom = customName ? states.find(s => s.name.toLowerCase() === customName) : undefined
352  if (custom) return custom
353  const exact = states.find(s => s.name.toLowerCase() === wanted)
354  if (exact) return exact
355  if (role) {
356    const byAlias = states.find(s => role[1].includes(s.name.toLowerCase()))
357    if (byAlias) return byAlias
358    const fallbackType: StateType = role[0] === 'done' ? 'completed' : 'started'
359    const ofType = states.filter(s => s.type === fallbackType).sort((a, b) => a.position - b.position)
360    return role[0] === 'in review' ? ofType[ofType.length - 1] : ofType[0]
361  }
362  return undefined
363}
364
365export type Role = 'done' | 'review'
366
367/** The roll-up role of a target state, or null when a move there never rolls a parent up. */
368export function rollUpRole(state: { name: string; type: StateType }): Role | null {
369  if (state.type === 'completed') return 'done'
370  return isInReview(state) ? 'review' : null
371}
372
373/** A sibling still owes work unless it is completed or canceled, or, for a review roll-up, in review. */
374export function siblingsUnfinished(siblings: { identifier: string; state: { name: string; type: StateType } }[], role: Role): string[] {
375  return siblings
376    .filter(s => isOpenType(s.state.type) && !(role === 'review' && isInReview(s.state)))
377    .map(s => s.identifier)
378}
379
380export type TransitionResult = {
381  issue: string
382  from: string
383  to: string
384  changed: boolean
385  commented: boolean
386  parent: { issue: string; rolledUp: boolean; unfinished: string[]; changed: boolean; note?: string } | null
387}
388
389type RawForTransition = {
390  id: string
391  identifier: string
392  state: { id: string; name: string; type: StateType }
393  team: { states: { nodes: TeamState[] } }
394  parent: {
395    id: string
396    identifier: string
397    state: { id: string; name: string }
398    team: { states: { nodes: TeamState[] } }
399    children: { nodes: { identifier: string; state: { name: string; type: StateType } }[] }
400  } | null
401}
402
403export async function transition(client: Client, id: string, target: string, comment?: string): Promise<TransitionResult> {
404  const data = await client.query<{ issue: RawForTransition | null }>(
405    `query($id:String!){issue(id:$id){id identifier state{id name type} team{states{nodes{id name type position}}}
406      parent{id identifier state{id name} team{states{nodes{id name type position}}}
407        children(first:250){nodes{identifier state{name type}}}}}}`,
408    { id },
409  )
410  const issue = data.issue
411  if (!issue) throw new LinearError(`${id}: not found`)
412  const state = resolveState(issue.team.states.nodes, target)
413  if (!state) throw new LinearError(`No workflow state matches "${target}" (have: ${issue.team.states.nodes.map(s => s.name).join(', ')})`)
414
415  const changed = issue.state.id !== state.id
416  if (changed) await setState(client, issue.id, state.id)
417  if (comment) await addComment(client, issue.id, comment)
418
419  let parent: TransitionResult['parent'] = null
420  const role = rollUpRole(state)
421  if (role && issue.parent) {
422    const siblings = issue.parent.children.nodes.map(s => (s.identifier === issue.identifier ? { ...s, state: { name: state.name, type: state.type } } : s))
423    const unfinished = siblingsUnfinished(siblings, role)
424    // The parent may live in another team: move it to that team's state for the same role.
425    const resolved = resolveState(issue.parent.team.states.nodes, role === 'done' ? 'Done' : 'In Review')
426    // resolveState falls back to an In Progress-like state; a review roll-up needs a real review state.
427    const parentState = role === 'review' && resolved && !isInReview(resolved) ? undefined : resolved
428    const parentChanged = unfinished.length === 0 && parentState !== undefined && issue.parent.state.id !== parentState.id
429    if (parentChanged && parentState) {
430      await setState(client, issue.parent.id, parentState.id)
431      await addComment(client, issue.parent.id, 'All sub-issues complete.')
432    }
433    parent = {
434      issue: issue.parent.identifier,
435      rolledUp: unfinished.length === 0 && parentState !== undefined,
436      unfinished,
437      changed: parentChanged,
438      ...(parentState ? {} : { note: `parent's team has no state for "${role}"` }),
439    }
440  }
441  return { issue: issue.identifier, from: issue.state.name, to: state.name, changed, commented: Boolean(comment), parent }
442}
443
444async function setState(client: Client, issueId: string, stateId: string): Promise<void> {
445  const r = await client.query<{ issueUpdate: { success: boolean } }>(
446    `mutation($id:String!,$state:String!){issueUpdate(id:$id,input:{stateId:$state}){success}}`,
447    { id: issueId, state: stateId },
448  )
449  if (!r.issueUpdate.success) throw new LinearError(`issueUpdate failed for ${issueId}`)
450}
451
452async function addComment(client: Client, issueId: string, body: string): Promise<void> {
453  const r = await client.query<{ commentCreate: { success: boolean } }>(
454    `mutation($id:String!,$body:String!){commentCreate(input:{issueId:$id,body:$body}){success}}`,
455    { id: issueId, body },
456  )
457  if (!r.commentCreate.success) throw new LinearError(`commentCreate failed for ${issueId}`)
458}
459
460export type SetCycleResult = { cycle: number; set: string[]; already: string[]; failed: { issue: string; error: string }[] }
461
462export async function setCycle(client: Client, teamKey: string | undefined, number: number, ids: string[]): Promise<SetCycleResult> {
463  const team = teamKey ?? (await activeCycle(client))?.teamKey
464  if (!team) throw new LinearError('No team: pass --team or set teamKey in .claude/linear.json')
465  const found = await client.query<{ cycles: { nodes: { id: string; number: number }[] } }>(
466    `query($team:String!,$n:Float!){cycles(filter:{team:{key:{eq:$team}},number:{eq:$n}}){nodes{id number}}}`,
467    { team, n: number },
468  )
469  const target = found.cycles.nodes[0]
470  if (!target) throw new LinearError(`Team ${team} has no cycle #${number}`)
471  const out: SetCycleResult = { cycle: number, set: [], already: [], failed: [] }
472  const results = await Promise.allSettled(
473    ids.map(async id => {
474      const cur = await client.query<{ issue: { id: string; cycle: { id: string } | null } | null }>(
475        `query($id:String!){issue(id:$id){id cycle{id}}}`,
476        { id },
477      )
478      if (!cur.issue) throw new LinearError('not found')
479      if (cur.issue.cycle?.id === target.id) return 'already' as const
480      const r = await client.query<{ issueUpdate: { success: boolean } }>(
481        `mutation($id:String!,$c:String!){issueUpdate(id:$id,input:{cycleId:$c}){success}}`,
482        { id: cur.issue.id, c: target.id },
483      )
484      if (!r.issueUpdate.success) throw new LinearError('issueUpdate failed')
485      return 'set' as const
486    }),
487  )
488  results.forEach((r, n) => {
489    const id = ids[n] as string
490    if (r.status === 'fulfilled') out[r.value].push(id)
491    else out.failed.push({ issue: id, error: r.reason instanceof Error ? r.reason.message : String(r.reason) })
492  })
493  return out
494}
495
types/index.d.ts 54 lines
1export type IssueChild = { identifier: string; title: string; state: string; stateType: string }
2
3export type IssueComment = { author: string; createdAt: string; body: string }
4
5export type Issue = {
6  identifier: string
7  title: string
8  url: string
9  state: string
10  stateType: string
11  priority: string
12  labels: string[]
13  parent: { identifier: string; title: string; labels: string[] } | null
14  assignee: string | null
15  cycle: number | null
16  milestone: string | null
17  description: string
18  children: IssueChild[]
19  comments: IssueComment[]
20  links: { title: string; url: string }[]
21}
22
23export type IssueSource = { id: string; from: 'root' | 'sites' | 'pinned'; detail: string }
24
25export type SessionEntry = {
26  sessionId: string
27  checkout: string
28  label: string
29  issue: string | null
30  title: string | null
31  state: string | null
32  stateType: string | null
33  /** Pinned, or on a branch this session switched to itself. */
34  claimed: boolean
35  updatedAt: number
36}
37
38declare module 'claude-code' {
39  interface PluginState {
40    'linear-workflow': {
41      issue: Issue | null
42      source: IssueSource | null
43      error: string | null
44      isBandHidden: boolean
45      pinned: string | null
46      pinnedBy: 'command' | 'manual' | 'held' | null
47      warned: string[]
48      others: SessionEntry[]
49      conflictsWarned: string[]
50      claimedBranch: string | null
51    }
52  }
53}
54