diff --git a/.changeset/linear-support.md b/.changeset/linear-support.md new file mode 100644 index 0000000..2bed699 --- /dev/null +++ b/.changeset/linear-support.md @@ -0,0 +1,5 @@ +--- +"@northguild/worktree": minor +--- + +Add `worktree branch --linear ` to start a worktree from a Linear issue, with a `linear.apiKey` config key set by `worktree config`. diff --git a/README.md b/README.md index cd04e2c..406bc23 100644 --- a/README.md +++ b/README.md @@ -284,7 +284,7 @@ line, `worktree: no default for ; pass `. the worktree, starts nothing and exits `1`. - **Source branch:** `defaultSourceBranch`, else `origin/main` with a warning. - **Removal** has no default: `remove -f`. -- **Bounded calls:** GitHub and Jira requests 15 s, `git fetch` 60 s, `gh auth token` and the session listing +- **Bounded calls:** GitHub, Jira and Linear requests 15 s, `git fetch` 60 s, `gh auth token` and the session listing 10 s, the install 10 min. A command still running at its bound is sent SIGTERM, then SIGKILL 2 s later, so one that ignores SIGTERM cannot hold the run open. @@ -307,7 +307,7 @@ worktree branch --github 42 --json --agent-file brief.md "warnings":[]} ``` -- `issue` is `{provider:"github",number,url}`, `{provider:"jira",key,url}`, or `null`. +- `issue` is `{provider:"github",number,url}`, `{provider:"jira",key,url}`, `{provider:"linear",key,url}`, or `null`. - `assigned` is `null` when no assignment was attempted. `herdr` and `agent` are `null` when skipped, and `agent.command` leaves the brief out. `agent.name` is `null` where this CLI named nothing (a detached start). - `installed` is `{ran:false,reason}` when skipped. A failed install still prints the whole document, with @@ -459,4 +459,5 @@ MIT - [x] Add `cleanup` command to cleanup stale worktrees - [x] Integrate with GitHub for automated branch naming - [x] Integrate with JIRA for automated branch naming +- [x] Integrate with Linear for automated branch naming - [ ] Integrate with ClickUp for automated branch naming diff --git a/context/stack.md b/context/stack.md index 6aee972..2f759ec 100644 --- a/context/stack.md +++ b/context/stack.md @@ -25,7 +25,7 @@ confusing errors rather than obvious ones — see the prerequisites and *Prepare bin/ oclif entry point — bin/run.js, produced by tsc src/commands/ one file per CLI command; each default-exports a BaseCommand subclass src/lib/ CLI helpers — git integration, validators, env, types, constants, cli -src/integrations/ GitHub and Jira integrations +src/integrations/ GitHub, Jira and Linear integrations scripts/ version-sync script, run by `pnpm sync-version`; `release-notes.mjs`, which publish.yml uses to cut a version's CHANGELOG section .changeset/ Changesets config and pending release notes (`.changeset/.md`) skills/ the usage skill, `skills/worktree/SKILL.md`; hand-written, shipped inside the npm package (package.json `files`) and installable with `npx skills add`. Nothing on npm discovers it since TanStack Intent went (`skills add` reads GitHub); it stays in `files` so an `npm i -g` install has a copy matching its own version on disk @@ -80,7 +80,7 @@ untrue on one of these surfaces is fixed by the phase that makes it untrue, not | Surface | Who reads it | What has to reach it | |---|---|---| | [`README.md`](../README.md) | someone deciding whether to install; npm renders it as the package page | the fast path — a new command, a renamed flag, a change to what the tool is for | -| `docs/src/app/docs/` | users looking something up, at https://northguild.github.io/worktree | any user-visible CLI behaviour. One page per command under `commands/` (7), plus `configuration/`, `getting-started/`, `faq/`, and `guides/` (6) | +| `docs/src/app/docs/` | users looking something up, at https://northguild.github.io/worktree | any user-visible CLI behaviour. One page per command under `commands/` (7), plus `configuration/`, `getting-started/`, `faq/`, and `guides/` (8) | | [`docs/README.md`](../docs/README.md) | someone working on the docs app | how the docs app is run or built | | [`docs/worker/README.md`](../docs/worker/README.md) | someone working on the chat proxy | the Worker's routes, secrets or deploy | | `skills/worktree/SKILL.md` | agents that install the skill with `npx skills add`; shipped in the npm package; also embedded in the docs chat system prompt | a command or flag change that the skill describes — **but see the caveat below** | diff --git a/docs/src/app/docs/commands/branch/page.mdx b/docs/src/app/docs/commands/branch/page.mdx index c3f18fe..ab836c3 100644 --- a/docs/src/app/docs/commands/branch/page.mdx +++ b/docs/src/app/docs/commands/branch/page.mdx @@ -15,6 +15,7 @@ worktree branch --github worktree branch --github --assign worktree branch --github --no-assign worktree branch --jira +worktree branch --linear worktree branch --agent "" worktree branch --agent-file worktree branch --agent-stdin @@ -36,6 +37,8 @@ worktree branch --github 42 --assign worktree branch --github 42 --no-assign worktree branch --jira DEV-123 worktree branch --jira dev-123 +worktree branch --linear ENG-123 +worktree branch --linear eng-123 worktree branch feature/add-bulk-actions --agent "add bulk actions to the table" worktree branch --github 42 --agent "implement the issue" worktree branch --github 42 --agent-file brief.md @@ -106,8 +109,9 @@ worktree config github.autoAssign true # always assign, stop asking - The assignment happens before the worktree is created, so the question is never buried behind creation output or a launched editor. Assigning is idempotent — GitHub does not replace existing assignees — so retrying after a failed creation is harmless. -- `--assign` and `--no-assign` both require `--github` or `--jira`; on their own there is no issue to act - on. `--assign --jira` warns that Jira assignment is not supported yet and carries on. +- `--assign` and `--no-assign` both require `--github`, `--jira` or `--linear`; on their own there is no issue to act + on. `--assign --jira` and `--assign --linear` warn that assignment is not supported for that provider yet + and carry on. ## Installing dependencies @@ -158,6 +162,23 @@ Jira authentication requires three configuration keys: 2. `northguild.worktree.jira.email` - Your Jira email address 3. `northguild.worktree.jira.apiToken` - Your Jira API token +## Linear Issue Branching + +Use the `--linear` flag (short: `-l`) to create a suggested branch name from an issue in your Linear workspace. It +cannot be combined with `--github` or `--jira`. See the [Linear guide](/docs/guides/linear-integration). + +- The issue identifier can be passed in any case (`eng-123`, `ENG-123`, etc.). +- The generated name is pre-filled in the prompt and still editable. +- Linear has no issue type, so the issue's labels pick the optional prefix, by exact name and ignoring case: + - `branchPrefix.bugfix` for a `bug` label + - `branchPrefix.chore` for a `chore` or `maintenance` label, when there is no `bug` label + - `branchPrefix.feature` for anything else, including no label + +If no prefix is configured, `worktree` falls back to an empty prefix and still generates a valid branch name. + +Linear authentication requires one configuration key, `northguild.worktree.linear.apiKey`, your Linear personal +API key. Without it the command offers to run `worktree config`, and when there is still no key (or the run is non-interactive) exits `2` with `missing_value`. Assigning the issue is not supported yet. + ## Agent Dispatch Use the `--agent` flag (short: `-a`) to hand the new worktree to a coding agent with a prompt, called the @@ -253,7 +274,7 @@ person goes to stderr instead: the spinner lines, the env-file list, notes and w | Key | Value | | --- | --- | | `path`, `branch`, `source` | the worktree's absolute path, its branch, and the ref it was created from | -| `issue` | `{provider:"github", number, url}` or `{provider:"jira", key, url}`; `null` when no issue was used. `url` is `null` when it is not known | +| `issue` | `{provider:"github", number, url}`, `{provider:"jira", key, url}` or `{provider:"linear", key, url}`; `null` when no issue was used. `url` is `null` when it is not known | | `assigned` | `true` or `false` once an assignment was attempted; `null` when none was (no issue, `--no-assign`, or a configured `false`) | | `envFilesCopied` | the env files copied, relative to the repository root; `[]` when there were none | | `installed` | `{ran:true, command, inferred, ok}`, plus `reason` when `ok` is `false`; `{ran:false, reason}` when the step was skipped | @@ -276,7 +297,7 @@ describing it goes to stderr as usual: | `code` | Means | Exit | | --- | --- | --- | | `missing_value` | a value with no default was not given; `details` names it and the flag | `2` | -| `invalid_value` | a value or flag combination was refused: a bad branch name, an empty brief, a malformed `--github` or `--jira` issue id | `2` | +| `invalid_value` | a value or flag combination was refused: a bad branch name, an empty brief, a malformed `--github`, `--jira` or `--linear` issue id | `2` | | `not_found` | something named does not exist, such as the `--source` branch | `2` | | `timeout` | a call that is bounded did not answer in time | `1` | | `failed` | anything else | `1` | diff --git a/docs/src/app/docs/commands/config/page.mdx b/docs/src/app/docs/commands/config/page.mdx index 03540ff..fc6d88d 100644 --- a/docs/src/app/docs/commands/config/page.mdx +++ b/docs/src/app/docs/commands/config/page.mdx @@ -28,7 +28,7 @@ nothing and exits `0`. - With `--list`, it only prints config names that currently have no value. - Without `--list`, it only prompts for missing values. - `--names` limits prompting to specific config keys. Naming keys is asking for them, so the optional group - confirms (Jira, GitHub, prefixes, opener, editor, Herdr, agent) are not asked for them. + confirms (Jira, GitHub, Linear, prefixes, opener, editor, Herdr, agent, post-create) are not asked for them. - `--yes` (`-y`) makes the run [non-interactive](/docs/commands/branch#non-interactive-runs): nothing is asked. Each key takes its default, or the value it already holds, and the run fails with exit `2` naming `worktree config ` for a key with neither, such as a token. Without `--names` the optional groups have no default and the run fails naming `--names `. diff --git a/docs/src/app/docs/configuration/page.mdx b/docs/src/app/docs/configuration/page.mdx index 319d301..a3eb086 100644 --- a/docs/src/app/docs/configuration/page.mdx +++ b/docs/src/app/docs/configuration/page.mdx @@ -20,6 +20,7 @@ title: Configuration - `jira.host` (Your Jira workspace URL, example: `https://company.atlassian.net`) - `jira.email` (Your Jira email address) - `jira.apiToken` (Jira API token) +- `linear.apiKey` (Linear personal API key, example: `lin_api_...`) - `branchPrefix.feature` (example: `feature/`) - `branchPrefix.bugfix` (example: `fix/`) - `branchPrefix.chore` (example: `chore/`) @@ -166,6 +167,7 @@ worktree config github.token worktree config jira.host https://company.atlassian.net worktree config jira.email your-email@example.com worktree config jira.apiToken +worktree config linear.apiKey worktree config branchPrefix.feature feature/ worktree config branchPrefix.bugfix fix/ worktree config branchPrefix.chore chore/ @@ -212,9 +214,18 @@ worktree config github.autoAssign true - Branch prefix keys are optional. If missing, `worktree` uses no prefix. - Issue IDs are case-insensitive (both `dev-123` and `DEV-123` work). +## Linear Integration Notes + +- `worktree branch --linear ` reads issues from the Linear workspace your key belongs to. +- `linear.apiKey` is the only Linear config key, and it is required. +- Linear has no issue type, so the branch prefix comes from the issue's labels: `bug` uses `branchPrefix.bugfix`, + `chore` or `maintenance` uses `branchPrefix.chore`, anything else uses `branchPrefix.feature`. +- Issue identifiers are case-insensitive (both `eng-123` and `ENG-123` work). + ## Recommended Team Baseline 1. Agree on one source branch (`origin/main` for most teams). 2. Standardize branch naming conventions. 3. Keep editor command consistent across local setups. 4. For Jira integration, share workspace URL and branch prefix conventions. +5. For Linear integration, share which labels map to bug and chore work, since the label decides the prefix. diff --git a/docs/src/app/docs/guides/_meta.ts b/docs/src/app/docs/guides/_meta.ts index d1b7278..19a6e18 100644 --- a/docs/src/app/docs/guides/_meta.ts +++ b/docs/src/app/docs/guides/_meta.ts @@ -3,6 +3,7 @@ import type { MetaRecord } from "nextra"; const meta: MetaRecord = { "github-issue-integration": "GitHub Issue Integration", "jira-integration": "Jira Issue Integration", + "linear-integration": "Linear Issue Integration", "context-switching": "Context Switching", "team-conventions": "Team Conventions", "env-files": "Environment Files", diff --git a/docs/src/app/docs/guides/linear-integration/page.mdx b/docs/src/app/docs/guides/linear-integration/page.mdx new file mode 100644 index 0000000..b12e748 --- /dev/null +++ b/docs/src/app/docs/guides/linear-integration/page.mdx @@ -0,0 +1,92 @@ +--- +title: Linear Issue Integration +--- + +# Linear Issue Integration + +`worktree` can derive branch names directly from Linear issues in your Linear workspace. + +## Basic Usage + +```bash +worktree branch --linear ENG-123 +worktree branch --linear eng-123 +worktree branch -l ENG-123 +``` + +Pass the issue identifier, the team key and number Linear shows on the issue (`ENG-123`). It is accepted in any +case. A pasted issue URL is not accepted. + +The generated branch name is shown as the default in the prompt, so you can accept it or edit it before creation. + +`--linear` cannot be combined with `--github` or `--jira`. `--assign` does not work with `--linear`: assigning a +Linear issue is not supported yet, so the flag warns `Assignment is not supported for Linear issues yet.` and the +worktree is still created. + +## How Branch Names Are Built + +1. Read the issue's identifier, title and labels from the Linear API. +2. Sanitize the title into a branch-safe slug. +3. Prepend a configured prefix chosen from the issue's labels. + +The name is the prefix, the identifier and the slug, for example `fix/ENG-123-fix-login-redirect`. Keeping the +identifier in the name is what lets Linear's GitHub integration link the branch to the issue. + +Linear has no issue type, so the prefix comes from the issue's labels. Labels are compared by exact name, +ignoring case: + +- a `bug` label -> `branchPrefix.bugfix` +- otherwise a `chore` or `maintenance` label -> `branchPrefix.chore` +- otherwise (a `Feature` or `Improvement` label, any other label, or none) -> `branchPrefix.feature` + +When an issue carries several of these labels, `bug` wins. A label such as `bugfix` or `Bug fix` is not `bug`, +so it does not match. + +If a prefix key is not configured, no prefix is applied. + +## What Is Read From the Issue + +**Issue content is used for the branch name and nothing else.** `worktree` reads the issue's identifier, its title +and the names of its labels. The title is reduced to lowercase letters, digits and `-` before it becomes part of +the branch name, and the labels only select one of the configured `branchPrefix.*` values, or none. The issue +description and comments are never requested, never printed, never written to the `--json` document (whose `issue` +field carries only the provider, the identifier and the URL) and never passed to an agent. + +With `--json`, `issue` is `{"provider":"linear","key":"ENG-123","url":"https://linear.app/..."}`. The `url` is the +one Linear returns, and is `null` only if Linear sent none. + +Requests to Linear are cut off after 15 seconds. + +## Authentication Resolution + +Authentication uses one config key, `linear.apiKey`, read from local git config. In a terminal, `branch --linear` +offers to run `worktree config` when it is missing. Without a key after that, and in any non-interactive run, the +command exits with code `2` and `missing_value`, naming `worktree config linear.apiKey `, before a worktree +is created. + +## Suggested Setup + +```bash +worktree config linear.apiKey +worktree config branchPrefix.feature feature/ +worktree config branchPrefix.bugfix fix/ +worktree config branchPrefix.chore chore/ +``` + +You can also run `worktree config` and answer yes to the Linear group, or configure only missing values: + +```bash +worktree config --missing --names linear.apiKey,branchPrefix.feature,branchPrefix.bugfix,branchPrefix.chore +``` + +An empty answer to the key prompt keeps the key already stored rather than clearing it. + +## Getting a Linear API Key + +1. In Linear, open Settings -> Security & access -> Personal API keys +2. Click "New API key" and give it a label +3. Copy the key and use it with `worktree config linear.apiKey` + +The key is stored locally in git config and never sent anywhere except to Linear. `worktree` never prints it: +any `lin_api_` key that would reach the output is replaced first. The key is checked the first time `--linear` +uses it; `worktree config` does not validate it. diff --git a/docs/src/app/docs/guides/page.mdx b/docs/src/app/docs/guides/page.mdx index fa32c70..27c8881 100644 --- a/docs/src/app/docs/guides/page.mdx +++ b/docs/src/app/docs/guides/page.mdx @@ -8,6 +8,8 @@ asIndexPage: true Practical workflows and conventions for teams using `worktree` every day. - [GitHub Issue Integration](/docs/guides/github-issue-integration) +- [Jira Issue Integration](/docs/guides/jira-integration) +- [Linear Issue Integration](/docs/guides/linear-integration) - [Context Switching](/docs/guides/context-switching) - [Team Conventions](/docs/guides/team-conventions) - [Environment Files](/docs/guides/env-files) diff --git a/docs/src/chat/components/MarkdownContent.tsx b/docs/src/chat/components/MarkdownContent.tsx index b67f2b1..34ba90c 100644 --- a/docs/src/chat/components/MarkdownContent.tsx +++ b/docs/src/chat/components/MarkdownContent.tsx @@ -76,6 +76,8 @@ const DOCS_ROUTE_MAP: Record = { "github issue integration": "/docs/guides/github-issue-integration", "jira issue integration": "/docs/guides/jira-integration", "jira integration": "/docs/guides/jira-integration", + "linear issue integration": "/docs/guides/linear-integration", + "linear integration": "/docs/guides/linear-integration", "context switching": "/docs/guides/context-switching", "team conventions": "/docs/guides/team-conventions", "environment files": "/docs/guides/env-files", diff --git a/docs/src/chat/docs-routes.generated.ts b/docs/src/chat/docs-routes.generated.ts index 64c5992..0c9041d 100644 --- a/docs/src/chat/docs-routes.generated.ts +++ b/docs/src/chat/docs-routes.generated.ts @@ -22,5 +22,6 @@ export const VALID_DOC_ROUTES: ReadonlySet = new Set([ "/docs/guides/github-issue-integration", "/docs/guides/herdr-spaces", "/docs/guides/jira-integration", + "/docs/guides/linear-integration", "/docs/guides/team-conventions", ]); diff --git a/skills/worktree/SKILL.md b/skills/worktree/SKILL.md index ffb8f25..2e33784 100644 --- a/skills/worktree/SKILL.md +++ b/skills/worktree/SKILL.md @@ -3,7 +3,7 @@ name: worktree description: > Use when creating, listing, opening, removing or cleaning up git worktrees with the `worktree` CLI (@northguild/worktree), including starting a - worktree from a GitHub or Jira issue, handing a new worktree to a coding + worktree from a GitHub, Jira or Linear issue, handing a new worktree to a coding agent, opening worktrees as Herdr spaces, and driving worktrees from a script, CI or another coding agent with `--json`. --- @@ -62,7 +62,7 @@ worktree checkout origin/feature/fix-login-timeout Use `checkout` only for branches that already exist on the remote. Use `branch` to create something new. -### Derive a branch name from a GitHub or Jira issue +### Derive a branch name from a GitHub, Jira or Linear issue ```bash # GitHub — requires github.token in config (or gh CLI authenticated) @@ -77,8 +77,17 @@ worktree branch --github 42 --no-assign # Jira — requires jira.host, jira.email, jira.apiToken in config worktree branch --jira DEV-123 worktree branch --jira dev-123 + +# Linear — requires linear.apiKey in config +worktree branch --linear ENG-123 +worktree branch --linear eng-123 ``` +`--github`, `--jira` and `--linear` are mutually exclusive. `--assign` and +`--no-assign` need one of the three; `--assign --linear` (as `--assign --jira`) +skips the assignment with a warning, because Linear assignment is not +supported yet. `--linear` alone warns nothing. + With neither flag, `github.autoAssign` decides whether the issue is assigned: `true` always, `false` never, and unset means you are asked once and the answer is saved to the key (a non-interactive run assigns instead, and @@ -93,9 +102,16 @@ not save the key; `false` or `--no-assign` skips assignment. Branch prefixes are - `Bug` → `branchPrefix.bugfix` - `Task` → `branchPrefix.chore` -**Issue content is used for the branch name and nothing else.** `--github` -and `--jira` read the issue's number or key, its title (Jira: summary) and its -type. The title is reduced to lowercase letters, digits and `-` before it +Linear has no issue type, so its labels decide, compared case-insensitively +and by exact name: a `bug` label → `branchPrefix.bugfix`; else a `chore` or +`maintenance` label → `branchPrefix.chore`; otherwise (`Feature`, +`Improvement`, no label) `branchPrefix.feature`. A bug label wins over the +others. The name is the prefix, the upper-cased identifier and the slugged +title, such as `fix/ENG-123-fix-login`. + +**Issue content is used for the branch name and nothing else.** `--github`, +`--jira` and `--linear` read the issue's number or key, its title (Jira: +summary) and its type (Linear: its labels). The title is reduced to lowercase letters, digits and `-` before it becomes part of the branch name, and the type only selects one of the configured `branchPrefix.*` values, or none. The issue body and comments are never printed, never written to the `--json` document (whose `issue` field @@ -171,7 +187,7 @@ Defaults when non-interactive: and exits `1`. - **Source branch:** `defaultSourceBranch`, else `origin/main` with a warning. - **Removal** has no default: `remove -f`. -- **Bounded calls:** GitHub and Jira requests 15 s, `git fetch` 60 s, +- **Bounded calls:** GitHub, Jira and Linear requests 15 s, `git fetch` 60 s, `gh auth token` and the session listing 10 s, the install 10 min. A command still running at its bound is sent SIGTERM, then SIGKILL 2 s later. @@ -191,7 +207,7 @@ document: "warnings":[]} ``` -- `issue` is `{provider:"github",number,url}`, `{provider:"jira",key,url}`, or +- `issue` is `{provider:"github",number,url}`, `{provider:"jira",key,url}`, `{provider:"linear",key,url}`, or `null`; `url` can itself be `null`. - `assigned` is `null` when no assignment was attempted. - `herdr` and `agent` are `null` when skipped. `agent.command` leaves the brief @@ -347,9 +363,10 @@ under `northguild.worktree.*`. | `jira.host` | `https://company.atlassian.net` | `--jira` flag | | `jira.email` | `you@company.com` | `--jira` flag | | `jira.apiToken` | `ATATT...` | `--jira` flag | -| `branchPrefix.feature` | `feature/` | auto-prefix on Feature issues | -| `branchPrefix.bugfix` | `fix/` | auto-prefix on Bug issues | -| `branchPrefix.chore` | `chore/` | auto-prefix on Task issues | +| `linear.apiKey` | `lin_api_...` | `--linear` flag | +| `branchPrefix.feature` | `feature/` | auto-prefix on Feature issues, and on Linear issues with no bug, chore or maintenance label | +| `branchPrefix.bugfix` | `fix/` | auto-prefix on Bug issues, and on Linear issues labelled `bug` | +| `branchPrefix.chore` | `chore/` | auto-prefix on Task issues, and on Linear issues labelled `chore` or `maintenance` | ```bash # Configure only missing keys @@ -463,6 +480,29 @@ Source: `docs/guides/jira-integration`, `src/integrations/jira.ts` --- +### HIGH `--linear` flag used without a Linear API key + +Wrong: + +```bash +worktree branch --linear ENG-123 +``` + +Correct: + +```bash +worktree config linear.apiKey lin_api_... +worktree branch --linear ENG-123 +``` + +`linear.apiKey` is the only Linear key. Without it `branch --linear` exits `2` +(`missing_value`) before any worktree is created, and a non-interactive run +never prompts for it. + +Source: `docs/guides/linear-integration`, `src/integrations/linear.ts` + +--- + ### HIGH `--agent` used without `agent.command` configured Wrong: diff --git a/src/commands/branch.test.ts b/src/commands/branch.test.ts index abb9916..27002bd 100644 --- a/src/commands/branch.test.ts +++ b/src/commands/branch.test.ts @@ -7,6 +7,7 @@ import ora from "ora"; import * as githubIntegration from "../integrations/github.js"; import * as herdr from "../integrations/herdr.js"; import * as jiraIntegration from "../integrations/jira.js"; +import * as linearIntegration from "../integrations/linear.js"; import { copyEnvFilesFromRootPath } from "../lib/env.js"; import * as git from "../lib/git.js"; import { runInstall } from "../lib/install.js"; @@ -614,7 +615,250 @@ describe("branch command", () => { }); }); + describe("--linear flag", () => { + const linearIssue = { + identifier: "ENG-123", + title: "Add dark mode", + url: "https://linear.app/acme/issue/ENG-123/add-dark-mode", + labels: [] as string[], + }; + + function stubConfig(apiKey = "lin_api_test") { + vi.spyOn(git, "gitGetConfigValue").mockImplementation((key: string) => { + if (key === "has-called-config") return Promise.resolve("true"); + if (key === "defaultSourceBranch") + return Promise.resolve("origin/main"); + if (key === "linear.apiKey") return Promise.resolve(apiKey); + if (key === "branchPrefix.feature") return Promise.resolve("feature/"); + if (key === "branchPrefix.bugfix") return Promise.resolve("fix/"); + return Promise.resolve(""); + }); + } + + it("pre-fills the derived name, with the prefix for the labels", async () => { + stubConfig(); + const fetchSpy = vi + .spyOn(linearIntegration, "fetchLinearIssue") + .mockResolvedValue({ ...linearIssue, labels: ["Bug"] }); + mockInput.mockResolvedValue("fix/ENG-123-add-dark-mode"); + const mockGitCreateWorktree = vi + .spyOn(git, "gitCreateWorktree") + .mockResolvedValue("/path/to/worktree"); + (branch as any).parse = vi.fn().mockResolvedValue({ + args: {}, + flags: { linear: "eng-123" }, + }); + + await branch.run(); + + // One fetch feeds both the name and `issue.url` (D9); Linear rate-limits + // per key, so a second request is a regression. + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(fetchSpy).toHaveBeenCalledWith("eng-123"); + expect(mockInput).toHaveBeenCalledWith({ + message: "Branch name", + default: "fix/ENG-123-add-dark-mode", + prefill: "editable", + validate: validators.isValidBranchName, + }); + expect(mockGitCreateWorktree).toHaveBeenCalledWith( + "fix/ENG-123-add-dark-mode", + "origin/main", + ); + }); + + it("uses the feature prefix when no label decides otherwise", async () => { + stubConfig(); + vi.spyOn(linearIntegration, "fetchLinearIssue").mockResolvedValue( + linearIssue, + ); + mockInput.mockImplementation(async (config: any) => config.default); + const mockGitCreateWorktree = vi + .spyOn(git, "gitCreateWorktree") + .mockResolvedValue("/path/to/worktree"); + (branch as any).parse = vi.fn().mockResolvedValue({ + args: {}, + flags: { linear: "ENG-123" }, + }); + + await branch.run(); + + expect(mockGitCreateWorktree).toHaveBeenCalledWith( + "feature/ENG-123-add-dark-mode", + "origin/main", + ); + }); + + it("reports the issue with the url the API returned", async () => { + stubConfig(); + vi.spyOn(linearIntegration, "fetchLinearIssue").mockResolvedValue( + linearIssue, + ); + mockInput.mockImplementation(async (config: any) => config.default); + vi.spyOn(git, "gitCreateWorktree").mockResolvedValue("/path/to/worktree"); + (branch as any).parse = vi.fn().mockResolvedValue({ + args: {}, + flags: { linear: "ENG-123" }, + }); + + const document = await branch.run(); + + expect(document.issue).toEqual({ + provider: "linear", + key: "ENG-123", + url: "https://linear.app/acme/issue/ENG-123/add-dark-mode", + }); + expect(document.assigned).toBeNull(); + }); + + it("includes linear.apiKey in the config it verifies", async () => { + stubConfig(); + vi.spyOn(linearIntegration, "fetchLinearIssue").mockResolvedValue( + linearIssue, + ); + mockInput.mockImplementation(async (config: any) => config.default); + vi.spyOn(git, "gitCreateWorktree").mockResolvedValue("/path/to/worktree"); + const verify = vi.spyOn(branch as any, "verifyConfig"); + (branch as any).parse = vi.fn().mockResolvedValue({ + args: {}, + flags: { linear: "ENG-123" }, + }); + + await branch.run(); + + expect(verify).toHaveBeenCalledWith([ + "defaultSourceBranch", + "linear.apiKey", + ]); + }); + + it("warns and carries on for --assign", async () => { + stubConfig(); + vi.spyOn(linearIntegration, "fetchLinearIssue").mockResolvedValue( + linearIssue, + ); + const mockAssign = vi.spyOn(githubIntegration, "assignGitHubIssue"); + const mockWarn = vi + .spyOn(branch, "warn") + .mockImplementation((input: any) => input); + mockInput.mockImplementation(async (config: any) => config.default); + const mockGitCreateWorktree = vi + .spyOn(git, "gitCreateWorktree") + .mockResolvedValue("/path/to/worktree"); + (branch as any).parse = vi.fn().mockResolvedValue({ + args: {}, + flags: { linear: "ENG-123", assign: true }, + }); + + await branch.run(); + + expect(mockWarn).toHaveBeenCalledWith( + "Assignment is not supported for Linear issues yet.", + ); + expect(mockAssign).not.toHaveBeenCalled(); + expect(mockGitCreateWorktree).toHaveBeenCalled(); + }); + + describe("with --json", () => { + let output: ReturnType; + const originalExitCode = process.exitCode; + + beforeEach(() => { + output = captureOutput(); + process.exitCode = undefined; + }); + + afterEach(() => { + output.restore(); + process.exitCode = originalExitCode; + }); + + async function runJson(flags: Record) { + const command = new Branch(["--json"], await loadConfig()); + (command as any).parse = vi.fn().mockResolvedValue({ args: {}, flags }); + (command as any).parsed = true; + vi.spyOn(command as any, "openWorktreePath").mockResolvedValue({ + opener: "none", + }); + return (command as any)._run(); + } + + it("carries {provider, key, url} in the document", async () => { + stubConfig(); + vi.spyOn(linearIntegration, "fetchLinearIssue").mockResolvedValue( + linearIssue, + ); + vi.spyOn(git, "gitCreateWorktree").mockResolvedValue( + "/path/to/worktree", + ); + + await runJson({ linear: "ENG-123" }); + + expect(output.document()).toMatchObject({ + branch: "feature/ENG-123-add-dark-mode", + issue: { + provider: "linear", + key: "ENG-123", + url: "https://linear.app/acme/issue/ENG-123/add-dark-mode", + }, + assigned: null, + }); + }); + + it("gives a missing key as missing_value with exit 2 and no worktree", async () => { + stubConfig(""); + const fetchSpy = vi.spyOn(linearIntegration, "fetchLinearIssue"); + const create = vi.spyOn(git, "gitCreateWorktree"); + + await runJson({ linear: "ENG-123" }); + + expect(output.document()).toMatchObject({ + error: { code: "missing_value" }, + }); + expect(process.exitCode).toBe(2); + expect(fetchSpy).not.toHaveBeenCalled(); + expect(create).not.toHaveBeenCalled(); + }); + + it("gives a failed fetch as failed with exit 1 and no worktree", async () => { + stubConfig(); + vi.spyOn(linearIntegration, "fetchLinearIssue").mockRejectedValue( + new Error("Linear: Unable to find issue ENG-123."), + ); + const create = vi.spyOn(git, "gitCreateWorktree"); + + await runJson({ linear: "ENG-123" }); + + expect(output.document()).toMatchObject({ + error: { code: "failed" }, + }); + expect(process.exitCode).toBe(1); + expect(create).not.toHaveBeenCalled(); + }); + }); + }); + describe("flag validation", () => { + it.each([ + ["--github", { github: "42", linear: "ENG-1" }], + ["--jira", { jira: "DEV-42", linear: "ENG-1" }], + ])("refuses --linear with %s", async (_label, flags) => { + vi.spyOn(git, "gitGetConfigValue").mockImplementation((key: string) => + Promise.resolve(key === "has-called-config" ? "true" : ""), + ); + const fetchSpy = vi.spyOn(linearIntegration, "fetchLinearIssue"); + (branch as any).parse = vi.fn().mockResolvedValue({ args: {}, flags }); + const mockError = vi.spyOn(branch, "error").mockImplementation(() => { + throw new Error("refused"); + }); + + await expect(branch.run()).rejects.toThrow("refused"); + expect(mockError).toHaveBeenCalledWith( + "Please provide only one of --github, --jira or --linear, not several.", + ); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + it("should error when both --github and --jira are provided", async () => { vi.spyOn(git, "gitGetConfigValue").mockImplementation((key: string) => { if (key === "has-called-config") return Promise.resolve("true"); @@ -627,14 +871,16 @@ describe("branch command", () => { }); const mockError = vi.spyOn(branch, "error").mockImplementation(() => { - throw new Error("Please provide either --github or --jira, not both."); + throw new Error( + "Please provide only one of --github, --jira or --linear, not several.", + ); }); await expect(branch.run()).rejects.toThrow( - "Please provide either --github or --jira, not both.", + "Please provide only one of --github, --jira or --linear, not several.", ); expect(mockError).toHaveBeenCalledWith( - "Please provide either --github or --jira, not both.", + "Please provide only one of --github, --jira or --linear, not several.", ); }); }); @@ -941,14 +1187,14 @@ describe("branch command", () => { it.each([ [true], [false], - ])("errors when the assign flag is %s with neither --github nor --jira", async (assign) => { + ])("errors when the assign flag is %s with none of --github, --jira or --linear", async (assign) => { vi.spyOn(git, "gitGetConfigValue").mockImplementation((key: string) => { if (key === "has-called-config") return Promise.resolve("true"); return Promise.resolve(""); }); const mockError = vi.spyOn(branch, "error").mockImplementation(() => { throw new Error( - "--assign/--no-assign requires either --github or --jira.", + "--assign/--no-assign requires one of --github, --jira or --linear.", ); }); @@ -960,10 +1206,10 @@ describe("branch command", () => { // Both spellings are one flag under `allowNo`, and neither has an // issue to act on here. await expect(branch.run()).rejects.toThrow( - "--assign/--no-assign requires either --github or --jira.", + "--assign/--no-assign requires one of --github, --jira or --linear.", ); expect(mockError).toHaveBeenCalledWith( - "--assign/--no-assign requires either --github or --jira.", + "--assign/--no-assign requires one of --github, --jira or --linear.", ); }); @@ -1873,5 +2119,11 @@ describe("branch command — --help", () => { expect(help).toContain("0 success"); expect(help).toContain("1 failure"); expect(help).toContain("2 usage"); + expect(help).toContain('{provider:"linear", key, url|null}'); + }); + + // D1: `--linear` is `-l`, beside `-g` and `-j`. + it("gives --linear the short flag -l", () => { + expect(Branch.flags.linear.char).toBe("l"); }); }); diff --git a/src/commands/branch.ts b/src/commands/branch.ts index 262f20d..97bb9c5 100644 --- a/src/commands/branch.ts +++ b/src/commands/branch.ts @@ -1,6 +1,10 @@ import { Args, Flags } from "@oclif/core"; import { assignGitHubIssue, fetchGitHubIssue } from "../integrations/github.js"; import { getJiraBranchNameFromIssue } from "../integrations/jira.js"; +import { + fetchLinearIssue, + getLinearBranchNameFromIssue, +} from "../integrations/linear.js"; import { BaseCommand, readAgentBrief } from "../lib/base-command.js"; import { copyEnvFilesFromRootPath } from "../lib/env.js"; import { @@ -13,7 +17,7 @@ import { import { type InstallResult, runInstall } from "../lib/install.js"; import { isNonInteractive } from "../lib/interaction.js"; import { createSpinner } from "../lib/progress.js"; -import { askConfirm, askInput } from "../lib/prompt.js"; +import { askConfirm, askInput, MissingValueError } from "../lib/prompt.js"; import type { BranchDocument, BranchIssue, @@ -35,7 +39,7 @@ export default class Branch extends BaseCommand { path, branch, source, issue, assigned, envFilesCopied, installed, herdr, agent, warnings issue: {provider:"github", number, url|null}, {provider:"jira", key, -url|null} or null +url|null}, {provider:"linear", key, url|null} or null assigned: boolean, or null if no assignment was attempted installed: {ran:true, command, inferred, ok, reason?} or {ran:false, reason} herdr: {space, pane, agent|null}, or null if Herdr was not opened @@ -57,6 +61,7 @@ was not delivered "<%= config.bin %> <%= command.id %> --github 42", "<%= config.bin %> <%= command.id %> --github 42 --assign", "<%= config.bin %> <%= command.id %> --jira DEV-123", + "<%= config.bin %> <%= command.id %> --linear ENG-123", '<%= config.bin %> <%= command.id %> --github 42 --agent "implement the issue"', "<%= config.bin %> <%= command.id %> --github 42 --agent-file brief.md", "<%= config.bin %> <%= command.id %> --github 42 --no-agent", @@ -77,6 +82,10 @@ was not delivered char: "j", description: "Create a branch from a Jira issue (issue ID)", }), + linear: Flags.string({ + char: "l", + description: "Create a branch from a Linear issue (issue identifier)", + }), // The three brief sources are one choice (D13), and `--no-agent` says there // is no brief to deliver, so it excludes all of them. agent: Flags.string({ @@ -106,7 +115,7 @@ was not delivered assign: Flags.boolean({ allowNo: true, description: - "Assign the issue to you when creating a branch from --github", + "Assign the issue to you when creating a branch from --github (not supported for --jira or --linear yet)", }), // Unset is meaningful: it defers to postCreate and the run's mode (D9), so // there is no `default`. `--no-install` is the one switch that wins over both. @@ -265,6 +274,27 @@ was not delivered } } + private async getLinearIssueBranchName(issueIdFlag: string): Promise<{ + branchName: string; + issue: BranchIssue; + }> { + const key = issueIdFlag.trim().toUpperCase(); + const spinner = createSpinner(`Fetching Linear issue ${key}`).start(); + try { + // One fetch gives both the name and the url the API reports (D9). + const issue = await fetchLinearIssue(issueIdFlag); + const branchName = await getLinearBranchNameFromIssue(issue); + spinner.succeed(); + return { + branchName, + issue: { provider: "linear", key: issue.identifier, url: issue.url }, + }; + } catch (error) { + spinner.fail(); + throw error; + } + } + /** * Flag, then config, then true when non-interactive, then prompt (D4, D8). The key is tri-state through unset * (D3): `true` always assigns, `false` never does, and unset means ask. @@ -357,11 +387,12 @@ was not delivered private async getBranchName( branchNameArg?: string, - flags?: { github?: string; jira?: string }, + flags?: { github?: string; jira?: string; linear?: string }, ): Promise<{ branchName: string; issue: BranchIssue | null }> { if ( !flags?.github && !flags?.jira && + !flags?.linear && branchNameArg && this.validateBranchName(branchNameArg) ) { @@ -372,7 +403,9 @@ was not delivered ? await this.getGithubIssueBranchName(flags.github) : flags?.jira ? await this.getJiraIssueBranchName(flags.jira) - : undefined; + : flags?.linear + ? await this.getLinearIssueBranchName(flags.linear) + : undefined; const defaultValue = derived?.branchName ?? ""; const branchName = await askInput( @@ -395,15 +428,24 @@ was not delivered public async run(): Promise { const { args, flags } = await this.parse(Branch); - if (flags.github && flags.jira) { - this.error("Please provide either --github or --jira, not both."); + if ([flags.github, flags.jira, flags.linear].filter(Boolean).length > 1) { + this.error( + "Please provide only one of --github, --jira or --linear, not several.", + ); } // There is no issue to assign without one of them. Checked here rather than // beside the assignment itself so a malformed command line fails before the // issue fetch and the branch-name prompt, in the shape of the check above. - if (flags.assign !== undefined && !flags.github && !flags.jira) { - this.error("--assign/--no-assign requires either --github or --jira."); + if ( + flags.assign !== undefined && + !flags.github && + !flags.jira && + !flags.linear + ) { + this.error( + "--assign/--no-assign requires one of --github, --jira or --linear.", + ); } // Read before anything is created: an empty or oversized brief is a usage @@ -436,8 +478,21 @@ was not delivered configNames.push("jira.host", "jira.email", "jira.apiToken"); } + if (flags.linear) { + configNames.push("linear.apiKey"); + } + // If there is no source flag provided, make sure defaultSourceBranch is configured await this.verifyConfig(configNames); + + // The key has no default and the fetch needs it, so a run still without one + // after the offer is a missing value, and it ends before a tree exists. + if (flags.linear && !(await gitGetConfigValue("linear.apiKey"))) { + throw new MissingValueError( + "the Linear API key", + "`worktree config linear.apiKey `", + ); + } const { branchName, issue } = await this.getBranchName( args.branchName, flags, @@ -452,6 +507,8 @@ was not delivered let assigned: boolean | null = null; if (flags.jira && flags.assign) { this.warn("Assignment is not supported for Jira issues yet."); + } else if (flags.linear && flags.assign) { + this.warn("Assignment is not supported for Linear issues yet."); } else if (flags.github) { if (await this.shouldAssignGithubIssue(flags.assign)) { assigned = await this.assignGithubIssue(flags.github); diff --git a/src/commands/config.test.ts b/src/commands/config.test.ts index 5047cc0..c775d0a 100644 --- a/src/commands/config.test.ts +++ b/src/commands/config.test.ts @@ -1079,6 +1079,161 @@ describe("config command", () => { }); }); + describe("Linear prompts", () => { + const linearRun = (flags: Record) => { + (config as any).parse = vi.fn().mockResolvedValue({ + args: {}, + flags: { list: false, missing: false, ...flags }, + }); + }; + + it("asks one confirm for the group, then the key with instructions", async () => { + mockConfirm.mockImplementation( + async ({ message }) => + message === "Do you want to configure Linear integration?", + ); + mockInput.mockResolvedValue("lin_api_prompted"); + const mockSet = vi.spyOn(git, "gitSetConfigValue").mockResolvedValue(); + linearRun({}); + + await config.run(); + + expect(mockConfirm).toHaveBeenCalledWith({ + message: "Do you want to configure Linear integration?", + }); + expect(mockConsoleLog).toHaveBeenCalledWith( + expect.stringContaining( + "Settings -> Security & access -> Personal API keys", + ), + ); + expect(mockSet).toHaveBeenCalledWith("linear.apiKey", "lin_api_prompted"); + }); + + it("skips the key prompt when the group is declined", async () => { + mockConfirm.mockResolvedValue(false); + linearRun({}); + + await config.run(); + + expect(mockConfirm).toHaveBeenCalledWith({ + message: "Do you want to configure Linear integration?", + }); + expect(mockInput).not.toHaveBeenCalledWith( + expect.objectContaining({ message: "Linear API key" }), + ); + }); + + it("answers the group yes without asking for --names linear.apiKey", async () => { + mockInput.mockResolvedValue("lin_api_named"); + const mockSet = vi.spyOn(git, "gitSetConfigValue").mockResolvedValue(); + linearRun({ names: "linear.apiKey" }); + + await config.run(); + + expect(mockConfirm).not.toHaveBeenCalled(); + expect(mockSet).toHaveBeenCalledWith("linear.apiKey", "lin_api_named"); + }); + + it("never offers the stored key as a default or prefill", async () => { + vi.spyOn(git, "gitGetConfigValue").mockImplementation((key: string) => + Promise.resolve( + key === "has-called-config" + ? "true" + : key === "linear.apiKey" + ? "lin_api_stored" + : "", + ), + ); + mockInput.mockResolvedValue("lin_api_new"); + linearRun({ names: "linear.apiKey" }); + + await config.run(); + + const options = mockInput.mock.calls.at(0)?.[0] as { + default?: string; + prefill?: string; + }; + expect(options.default).toBeUndefined(); + expect(options.prefill).toBeUndefined(); + }); + + // A bare `worktree config` prompts every key whatever it holds, so Enter + // has to leave a stored key alone, as it does for `github.token`. + it.each([ + "", + " ", + ])("writes nothing for an empty answer (%j)", async (answer) => { + mockInput.mockResolvedValue(answer); + const mockSet = vi.spyOn(git, "gitSetConfigValue").mockResolvedValue(); + linearRun({ names: "linear.apiKey" }); + + await config.run(); + + expect(mockSet).not.toHaveBeenCalledWith( + "linear.apiKey", + expect.anything(), + ); + }); + + it("stores a pasted key without surrounding whitespace", async () => { + mockInput.mockResolvedValue(" lin_api_pasted \n"); + const mockSet = vi.spyOn(git, "gitSetConfigValue").mockResolvedValue(); + linearRun({ names: "linear.apiKey" }); + + await config.run(); + + expect(mockSet).toHaveBeenCalledWith("linear.apiKey", "lin_api_pasted"); + }); + + it("lists the key under --missing when unset, and not when set", async () => { + linearRun({ list: true, missing: true }); + await config.run(); + expect(mockConsoleLog).toHaveBeenCalledWith("linear.apiKey"); + + mockConsoleLog.mockClear(); + vi.spyOn(git, "gitGetConfigValue").mockImplementation((key: string) => + Promise.resolve( + key === "has-called-config" + ? "true" + : key === "linear.apiKey" + ? "lin_api_stored" + : "", + ), + ); + await config.run(); + expect(mockConsoleLog).not.toHaveBeenCalledWith("linear.apiKey"); + expect(mockConsoleLog).not.toHaveBeenCalledWith( + "linear.apiKey=lin_api_stored", + ); + }); + + it("prompts for the key under a bare --missing run", async () => { + mockConfirm.mockImplementation( + async ({ message }) => + message === "Do you want to configure Linear integration?", + ); + mockInput.mockResolvedValue("lin_api_missing"); + linearRun({ missing: true, names: undefined }); + + await config.run(); + + expect(mockInput).toHaveBeenCalledWith( + expect.objectContaining({ message: "Linear API key" }), + ); + }); + + it("fails naming linear.apiKey when a non-interactive run has no key", async () => { + setNonInteractive(true); + linearRun({ names: "linear.apiKey" }); + + await expect(config.run()).rejects.toMatchObject({ + message: + "no default for linear.apiKey; pass worktree config linear.apiKey ", + }); + expect(mockInput).not.toHaveBeenCalled(); + }); + }); + // `github.token` was in CONFIG_NAMES with no prompt block, so `--missing` // listed it as missing and then asked nothing. These pin that both GitHub // keys are actually answerable — the dead-key shape is what they guard. diff --git a/src/commands/config.ts b/src/commands/config.ts index bbce5b7..b50455a 100644 --- a/src/commands/config.ts +++ b/src/commands/config.ts @@ -201,6 +201,10 @@ export default class Config extends BaseCommand { name.startsWith("github."), ); + const hasLinearPrompt = configNames.some((name) => + name.startsWith("linear."), + ); + // First check if there is anything to prompt if (configNames.length === 0) { this.log("No missing config found."); @@ -339,6 +343,46 @@ export default class Config extends BaseCommand { } } + // One key, so the group is one confirm and one prompt (D2). Like the Jira + // group, nothing is validated against Linear here: the key is checked when + // `branch --linear` first uses it. + if ( + hasLinearPrompt && + (await this.confirmGroup( + "Do you want to configure Linear integration?", + "Linear", + flags.names, + )) + ) { + if (shouldPrompt("linear.apiKey")) { + const linearKeyInstructions = [ + "To find or create your key, follow these steps:", + " - Go to Linear -> Settings -> Security & access -> Personal API keys", + " - Click 'New API key' and give it a label", + " - Copy your new key", + ]; + + this.log(linearKeyInstructions.join(EOL) + EOL); + // No default and no prefill, exactly as `jira.apiToken` is asked: a + // key is not echoed back as a prompt default. + const linearApiKey = await askInput( + { message: "Linear API key" }, + { + value: "linear.apiKey", + flag: "worktree config linear.apiKey ", + // The stored key if there is one, never an empty one. + fallback: (await gitGetConfigValue("linear.apiKey")) || undefined, + }, + ); + // Only a real answer is written, as with `github.token`: a bare + // `worktree config` prompts every key whatever it already holds, so an + // empty answer keeps the stored key instead of clearing it. + if (linearApiKey.trim()) { + await gitSetConfigValue("linear.apiKey", linearApiKey.trim()); + } + } + } + if (shouldPrompt("defaultSourceBranch")) { const defaultBranchName = await this.askConfigInput( "defaultSourceBranch", diff --git a/src/integrations/linear.test.ts b/src/integrations/linear.test.ts new file mode 100644 index 0000000..9dc85cb --- /dev/null +++ b/src/integrations/linear.test.ts @@ -0,0 +1,427 @@ +import * as cli from "../lib/cli.js"; +import { expectCommands } from "../test-setup.js"; +import { + fetchLinearIssue, + getLinearBranchNameFromIssue, + getLinearBranchType, + resolveLinearApiKey, + validateLinearCredentials, +} from "./linear.js"; + +const API_KEY = "lin_api_test0123456789"; + +// The shape Linear's GraphQL API answers with: `{ data: { issue: ... } }` +// (https://linear.app/developers/graphql, "Get issue by ID"). +function issuePayload(overrides: Record = {}) { + return { + data: { + issue: { + identifier: "ENG-123", + title: "Add dark mode", + url: "https://linear.app/acme/issue/ENG-123/add-dark-mode", + labels: { nodes: [{ name: "Feature" }] }, + ...overrides, + }, + }, + }; +} + +function respond(status: number, body: unknown) { + return { + ok: status >= 200 && status < 300, + status, + statusText: "", + json: vi.fn().mockResolvedValue(body), + }; +} + +function stubFetch(response: unknown) { + const fetchSpy = vi.fn().mockResolvedValue(response); + vi.stubGlobal("fetch", fetchSpy); + return fetchSpy; +} + +function stubKey(value = API_KEY) { + expectCommands("git config northguild.worktree.linear.apiKey"); + vi.spyOn(cli, "run").mockResolvedValueOnce(value); +} + +describe("Linear integration", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.unstubAllGlobals(); + }); + + describe("resolveLinearApiKey", () => { + it("reads linear.apiKey from git config", async () => { + stubKey(); + + await expect(resolveLinearApiKey()).resolves.toBe(API_KEY); + }); + + it("tells the user to run worktree config when the key is missing", async () => { + stubKey(""); + + await expect(resolveLinearApiKey()).rejects.toThrow( + 'Linear: Missing config value "linear.apiKey". Run "worktree config" to configure Linear integration.', + ); + }); + }); + + describe("fetchLinearIssue", () => { + // D5: same shape as a Jira key; refusal is invalid_value, exit 2. + it.each([ + "invalid", + "123-ENG", + "ENG-", + "ENG-1a", + "", + ])("refuses the identifier %j before any call", async (id) => { + const fetchSpy = stubFetch(respond(200, issuePayload())); + const refusal = fetchLinearIssue(id); + + await expect(refusal).rejects.toThrow( + `Linear: Invalid issue id "${id}".`, + ); + await expect(refusal).rejects.toMatchObject({ + code: "invalid_value", + oclif: { exit: 2 }, + }); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it("fails with the config hint when linear.apiKey is missing", async () => { + stubKey(""); + const fetchSpy = stubFetch(respond(200, issuePayload())); + + await expect(fetchLinearIssue("ENG-123")).rejects.toThrow( + 'Linear: Missing config value "linear.apiKey"', + ); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + // D3/D4: POST to the one endpoint, the bare key in Authorization (no + // Bearer), the id passed as a variable and never interpolated. + it("posts the query to the Linear endpoint with the bare key and the id as a variable", async () => { + stubKey(); + const fetchSpy = stubFetch(respond(200, issuePayload())); + + const issue = await fetchLinearIssue("eng-123"); + + expect(fetchSpy).toHaveBeenCalledTimes(1); + const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit]; + expect(url).toBe("https://api.linear.app/graphql"); + expect(init.method).toBe("POST"); + expect(init.headers).toEqual({ + "Content-Type": "application/json", + Authorization: API_KEY, + }); + expect(init.signal).toBeInstanceOf(AbortSignal); + const body = JSON.parse(init.body as string); + expect(body.variables).toEqual({ id: "ENG-123" }); + expect(body.query).toContain("$id: String!"); + expect(body.query).not.toContain("ENG-123"); + expect(issue).toEqual({ + identifier: "ENG-123", + title: "Add dark mode", + url: "https://linear.app/acme/issue/ENG-123/add-dark-mode", + labels: ["Feature"], + }); + }); + + // Issue content is the branch name's input and nothing else. + it("never asks for the description or the comments", async () => { + stubKey(); + const fetchSpy = stubFetch(respond(200, issuePayload())); + + await fetchLinearIssue("ENG-123"); + + const body = JSON.parse( + (fetchSpy.mock.calls[0] as [string, RequestInit])[1].body as string, + ); + expect(body.query).not.toMatch(/description|comments/i); + }); + + it("returns a null url when the API sends none", async () => { + stubKey(); + stubFetch(respond(200, issuePayload({ url: null }))); + + await expect(fetchLinearIssue("ENG-123")).resolves.toMatchObject({ + url: null, + }); + }); + + it("rejects a request that never answers, naming the call and not the key", async () => { + stubKey(); + vi.spyOn(AbortSignal, "timeout").mockReturnValue( + AbortSignal.abort(new DOMException("timed out", "TimeoutError")), + ); + vi.stubGlobal( + "fetch", + vi.fn( + (_url: string, init: RequestInit) => + new Promise((_resolve, reject) => { + if (init.signal?.aborted) { + reject(init.signal.reason); + } + }), + ), + ); + + const failure = fetchLinearIssue("ENG-123"); + + // TIMEOUT_MESSAGE in base-command.ts is /did not (?:answer|finish) within \d+s/ + await expect(failure).rejects.toThrow( + "Linear: the request did not answer within 15s.", + ); + await expect(failure).rejects.not.toThrow(/lin_api_/); + expect(AbortSignal.timeout).toHaveBeenCalledWith(15_000); + }); + + it.each([ + 401, 403, + ])("reports an authentication failure on HTTP %i", async (status) => { + stubKey(); + stubFetch(respond(status, {})); + + await expect(fetchLinearIssue("ENG-123")).rejects.toThrow( + "Linear: Authentication failed. Please verify linear.apiKey in your config.", + ); + }); + + // Risk §5: Linear can answer 200 (or a 4xx with a body) with an errors + // array for bad auth; the code is in errors[].extensions.code. + it.each([ + 200, 400, + ])("reports an authentication failure from a GraphQL AUTHENTICATION_ERROR on HTTP %i", async (status) => { + stubKey(); + stubFetch( + respond(status, { + errors: [ + { + message: "Authentication required, not authenticated", + extensions: { + type: "authentication error", + code: "AUTHENTICATION_ERROR", + }, + }, + ], + }), + ); + + await expect(fetchLinearIssue("ENG-123")).rejects.toThrow( + "Linear: Authentication failed. Please verify linear.apiKey in your config.", + ); + }); + + it("reports not found with Linear's message when errors come without an issue", async () => { + stubKey(); + stubFetch( + respond(200, { + data: null, + errors: [{ message: "Entity not found: Issue" }], + }), + ); + + await expect(fetchLinearIssue("ENG-999")).rejects.toThrow( + "Linear: Unable to find issue ENG-999. Entity not found: Issue", + ); + }); + + it("reports not found when the issue is null and there are no errors", async () => { + stubKey(); + stubFetch(respond(200, { data: { issue: null } })); + + await expect(fetchLinearIssue("ENG-999")).rejects.toThrow( + "Linear: Unable to find issue ENG-999.", + ); + }); + + it("says so on HTTP 429", async () => { + stubKey(); + stubFetch(respond(429, {})); + + await expect(fetchLinearIssue("ENG-123")).rejects.toThrow( + "Linear: Rate limited. Linear limits requests per API key; wait a moment and try again.", + ); + }); + + // https://linear.app/developers/rate-limiting: HTTP 400, code RATELIMITED. + it("says so on a GraphQL RATELIMITED error answered with HTTP 400", async () => { + stubKey(); + stubFetch( + respond(400, { + errors: [ + { + message: "Rate limit exceeded", + extensions: { code: "RATELIMITED" }, + }, + ], + }), + ); + + await expect(fetchLinearIssue("ENG-123")).rejects.toThrow( + "Linear: Rate limited. Linear limits requests per API key; wait a moment and try again.", + ); + }); + + it("reports the status on any other non-2xx answer", async () => { + stubKey(); + const answer = respond(500, ""); + answer.statusText = "Internal Server Error"; + stubFetch(answer); + + await expect(fetchLinearIssue("ENG-123")).rejects.toThrow( + "Linear: Failed to fetch issue ENG-123. 500 Internal Server Error", + ); + }); + + it("reports a body that is not JSON", async () => { + stubKey(); + const answer = respond(200, null); + answer.json.mockRejectedValue(new SyntaxError("Unexpected token <")); + stubFetch(answer); + + await expect(fetchLinearIssue("ENG-123")).rejects.toThrow( + "Linear: Unexpected response for issue ENG-123.", + ); + }); + + it.each([ + ["a body that is not an object", "oops"], + ["an issue without a title", issuePayload({ title: undefined })], + ["labels that are not a list", issuePayload({ labels: "bug" })], + ])("reports %s", async (_name, body) => { + stubKey(); + stubFetch(respond(200, body)); + + await expect(fetchLinearIssue("ENG-123")).rejects.toThrow( + "Linear: Unexpected response for issue ENG-123.", + ); + }); + + it("never puts the key in an error message", async () => { + stubKey(); + stubFetch(respond(401, {})); + + await expect(fetchLinearIssue("ENG-123")).rejects.not.toThrow(/lin_api_/); + }); + }); + + // D7, chosen in the plan: bug -> bugfix; chore|maintenance -> chore; else feature. + describe("getLinearBranchType", () => { + it.each([ + [["Bug"], "bugfix"], + [["bug"], "bugfix"], + [["BUG"], "bugfix"], + [["Chore"], "chore"], + [["MAINTENANCE"], "chore"], + [["maintenance"], "chore"], + [["Feature"], "feature"], + [["Improvement"], "feature"], + [[], "feature"], + [["Bug", "Chore"], "bugfix"], + [["chore", "maintenance", "Bug"], "bugfix"], + [["Improvement", "Chore"], "chore"], + [["Feature", "Improvement"], "feature"], + ] as const)("maps labels %j to %s", (labels, expected) => { + expect(getLinearBranchType([...labels])).toBe(expected); + }); + }); + + // D6: prefix + IDENTIFIER + "-" + sanitized title, "issue" when it empties. + describe("getLinearBranchNameFromIssue", () => { + it("builds the name from the label prefix, the identifier and the title", async () => { + expectCommands("git config northguild.worktree.branchPrefix.bugfix"); + vi.spyOn(cli, "run").mockResolvedValueOnce("fix/"); + + const name = await getLinearBranchNameFromIssue({ + identifier: "ENG-42", + title: "Fix login bug", + url: null, + labels: ["Bug"], + }); + + expect(name).toBe("fix/ENG-42-fix-login-bug"); + }); + + it("uses branchPrefix.feature when no label matches", async () => { + expectCommands("git config northguild.worktree.branchPrefix.feature"); + vi.spyOn(cli, "run").mockResolvedValueOnce("feature/"); + + const name = await getLinearBranchNameFromIssue({ + identifier: "ENG-7", + title: "Add dark mode", + url: null, + labels: [], + }); + + expect(name).toBe("feature/ENG-7-add-dark-mode"); + }); + + it("uses branchPrefix.chore for a maintenance label", async () => { + expectCommands("git config northguild.worktree.branchPrefix.chore"); + vi.spyOn(cli, "run").mockResolvedValueOnce("chore/"); + + const name = await getLinearBranchNameFromIssue({ + identifier: "ENG-8", + title: "Bump deps", + url: null, + labels: ["Maintenance"], + }); + + expect(name).toBe("chore/ENG-8-bump-deps"); + }); + + it('falls back to "issue" when the title leaves no slug', async () => { + expectCommands("git config northguild.worktree.branchPrefix.feature"); + vi.spyOn(cli, "run").mockResolvedValueOnce("feature/"); + + const name = await getLinearBranchNameFromIssue({ + identifier: "ENG-9", + title: "!!!", + url: null, + labels: [], + }); + + expect(name).toBe("feature/ENG-9-issue"); + }); + }); + + describe("validateLinearCredentials", () => { + it("is true when the viewer query succeeds", async () => { + stubKey(); + const fetchSpy = stubFetch( + respond(200, { data: { viewer: { id: "user-1" } } }), + ); + + await expect(validateLinearCredentials()).resolves.toBe(true); + const body = JSON.parse( + (fetchSpy.mock.calls[0] as [string, RequestInit])[1].body as string, + ); + expect(body.query).toContain("viewer"); + }); + + it("is false on an HTTP 401", async () => { + stubKey(); + stubFetch(respond(401, {})); + + await expect(validateLinearCredentials()).resolves.toBe(false); + }); + + it("is false on a GraphQL error answered with HTTP 200", async () => { + stubKey(); + stubFetch( + respond(200, { errors: [{ message: "Authentication required" }] }), + ); + + await expect(validateLinearCredentials()).resolves.toBe(false); + }); + + it("is false when the key is missing", async () => { + stubKey(""); + + await expect(validateLinearCredentials()).resolves.toBe(false); + }); + }); +}); diff --git a/src/integrations/linear.ts b/src/integrations/linear.ts new file mode 100644 index 0000000..131ecf1 --- /dev/null +++ b/src/integrations/linear.ts @@ -0,0 +1,252 @@ +import { gitGetConfigValue } from "../lib/git.js"; +import { InvalidValueError } from "../lib/prompt.js"; +import { sanitizeBranchName } from "../lib/utils.js"; + +export interface LinearIssue { + identifier: string; + title: string; + url: string | null; + labels: string[]; +} + +interface LinearGraphqlError { + message?: unknown; + extensions?: { code?: unknown }; +} + +interface LinearGraphqlResponse { + data?: unknown; + errors?: LinearGraphqlError[]; +} + +const LINEAR_ENDPOINT = "https://api.linear.app/graphql"; +const LINEAR_REQUEST_TIMEOUT_MS = 15_000; + +// D4: the id is a variable, never part of the query text. Title and labels are +// all the branch name needs; the description and comments are not requested. +const ISSUE_QUERY = `query Issue($id: String!) { + issue(id: $id) { + identifier + title + url + labels { + nodes { + name + } + } + } +}`; + +const VIEWER_QUERY = "query { viewer { id } }"; + +const AUTH_FAILED_MESSAGE = + "Linear: Authentication failed. Please verify linear.apiKey in your config."; + +// D5: same shape as a Jira key. +function getIssueIdentifier(issueId: string): string { + const normalizedIssueId = issueId.trim().toUpperCase(); + + if (!/^[A-Z][A-Z0-9]*-\d+$/.test(normalizedIssueId)) { + throw new InvalidValueError(`Linear: Invalid issue id "${issueId}".`); + } + + return normalizedIssueId; +} + +export async function resolveLinearApiKey(): Promise { + const apiKey = await gitGetConfigValue("linear.apiKey"); + + if (!apiKey) { + throw new Error( + 'Linear: Missing config value "linear.apiKey". Run "worktree config" to configure Linear integration.', + ); + } + + return apiKey; +} + +// Every Linear request is bounded. The timeout message matches TIMEOUT_MESSAGE +// in base-command.ts; it names no header and never the key. +export async function fetchLinear( + query: string, + variables: Record, + apiKey: string, +): Promise { + try { + return await fetch(LINEAR_ENDPOINT, { + method: "POST", + // D3: the raw key, no `Bearer` prefix (that is for OAuth tokens). + headers: { "Content-Type": "application/json", Authorization: apiKey }, + body: JSON.stringify({ query, variables }), + signal: AbortSignal.timeout(LINEAR_REQUEST_TIMEOUT_MS), + }); + } catch (error) { + if (error instanceof Error && error.name === "TimeoutError") { + throw new Error( + `Linear: the request did not answer within ${LINEAR_REQUEST_TIMEOUT_MS / 1000}s.`, + { cause: error }, + ); + } + throw error; + } +} + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value); +} + +async function readBody( + response: Response, +): Promise { + try { + const body: unknown = await response.json(); + return isRecord(body) ? (body as LinearGraphqlResponse) : undefined; + } catch { + return undefined; + } +} + +// Linear puts the code in extensions.code ("AUTHENTICATION_ERROR", +// "RATELIMITED"); extensions.type is a human-readable label. +function hasErrorCode(body: LinearGraphqlResponse, code: string): boolean { + return ( + Array.isArray(body.errors) && + body.errors.some((error) => error?.extensions?.code === code) + ); +} + +function parseLinearIssue(issue: unknown): LinearIssue | undefined { + if (!isRecord(issue)) { + return undefined; + } + + const { identifier, title, url, labels } = issue; + const nodes = isRecord(labels) ? labels.nodes : undefined; + + if ( + typeof identifier !== "string" || + typeof title !== "string" || + !Array.isArray(nodes) || + !nodes.every((node) => isRecord(node) && typeof node.name === "string") + ) { + return undefined; + } + + return { + identifier, + title, + url: typeof url === "string" ? url : null, + labels: nodes.map((node) => (node as { name: string }).name), + }; +} + +export async function fetchLinearIssue(issueId: string): Promise { + const identifier = getIssueIdentifier(issueId); + const apiKey = await resolveLinearApiKey(); + + const response = await fetchLinear(ISSUE_QUERY, { id: identifier }, apiKey); + + if (response.status === 401 || response.status === 403) { + throw new Error(AUTH_FAILED_MESSAGE); + } + + const body = await readBody(response); + + // Linear can report bad auth in the body of a 200 or a 4xx answer. + if (body && hasErrorCode(body, "AUTHENTICATION_ERROR")) { + throw new Error(AUTH_FAILED_MESSAGE); + } + + // Linear answers a rate-limited GraphQL request with HTTP 400 and the code + // in the body (https://linear.app/developers/rate-limiting); 429 is kept too. + if (response.status === 429 || (body && hasErrorCode(body, "RATELIMITED"))) { + throw new Error( + "Linear: Rate limited. Linear limits requests per API key; wait a moment and try again.", + ); + } + + if (!response.ok) { + throw new Error( + `Linear: Failed to fetch issue ${identifier}. ${response.status} ${response.statusText}`.trimEnd(), + ); + } + + if (!body) { + throw new Error(`Linear: Unexpected response for issue ${identifier}.`); + } + + const rawIssue = isRecord(body.data) ? body.data.issue : undefined; + + if (rawIssue === undefined || rawIssue === null) { + const messages = (Array.isArray(body.errors) ? body.errors : []) + .map((error) => error?.message) + .filter((message): message is string => typeof message === "string"); + + throw new Error( + `Linear: Unable to find issue ${identifier}.${messages.length > 0 ? ` ${messages.join(", ")}` : ""}`, + ); + } + + const issue = parseLinearIssue(rawIssue); + + if (!issue) { + throw new Error(`Linear: Unexpected response for issue ${identifier}.`); + } + + return issue; +} + +// D7: Linear has no issue type, so the label decides. Bug wins over the rest. +export function getLinearBranchType(labels: string[]) { + const names = labels.map((label) => label.toLowerCase()); + + if (names.includes("bug")) { + return "bugfix" as const; + } + + if (names.includes("chore") || names.includes("maintenance")) { + return "chore" as const; + } + + return "feature" as const; +} + +// Takes the issue already fetched so a caller can keep its url from the same +// request. D6: the same shape as Jira's name. +export async function getLinearBranchNameFromIssue( + issue: LinearIssue, +): Promise { + const branchType = getLinearBranchType(issue.labels); + + const prefix = await gitGetConfigValue( + branchType === "bugfix" + ? "branchPrefix.bugfix" + : branchType === "chore" + ? "branchPrefix.chore" + : "branchPrefix.feature", + ); + + return `${prefix}${issue.identifier}-${sanitizeBranchName(issue.title) || "issue"}`; +} + +export async function validateLinearCredentials(): Promise { + try { + const apiKey = await resolveLinearApiKey(); + const response = await fetchLinear(VIEWER_QUERY, {}, apiKey); + + if (!response.ok) { + return false; + } + + const body = await readBody(response); + + return ( + !!body && + !body.errors?.length && + isRecord(body.data) && + isRecord(body.data.viewer) + ); + } catch { + return false; + } +} diff --git a/src/lib/base-command.ts b/src/lib/base-command.ts index 4c15a0d..6d0a0c6 100644 --- a/src/lib/base-command.ts +++ b/src/lib/base-command.ts @@ -71,7 +71,7 @@ const JSON_ERROR_CODES: readonly JsonErrorCode[] = [ "failed", ]; -// The timeout messages this CLI writes — github.ts, jira.ts, git.ts, herdr.ts +// The timeout messages this CLI writes — github.ts, jira.ts, linear.ts, git.ts, herdr.ts // and cli.ts all say " did not answer|finish within s". const TIMEOUT_MESSAGE = /did not (?:answer|finish) within \d+s/; diff --git a/src/lib/constants.ts b/src/lib/constants.ts index aeaaaa2..23962fb 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -5,6 +5,7 @@ export const CONFIG_NAMES = [ "jira.host", "jira.email", "jira.apiToken", + "linear.apiKey", "github.token", "github.autoAssign", "branchPrefix.feature", diff --git a/src/lib/redact.test.ts b/src/lib/redact.test.ts new file mode 100644 index 0000000..9dbd358 --- /dev/null +++ b/src/lib/redact.test.ts @@ -0,0 +1,24 @@ +import { redactSecrets } from "./redact.js"; + +describe("redactSecrets", () => { + // D10: Linear personal API keys have the shape `lin_api_` + token. + it("replaces a Linear API key in text", () => { + // Built at runtime: a literal in this shape trips GitHub push protection. + const key = `lin_api_${"AbCdEf0123".repeat(4)}`; + + const redacted = redactSecrets(`Linear rejected ${key} just now`); + + expect(redacted).toBe("Linear rejected *** just now"); + expect(redacted).not.toContain("lin_api_"); + }); + + it("still replaces a GitHub token", () => { + expect(redactSecrets(`token ghp_${"a".repeat(36)}`)).toBe("token ***"); + }); + + it("leaves text without a secret alone", () => { + expect(redactSecrets("Linear: Unable to find issue ENG-1.")).toBe( + "Linear: Unable to find issue ENG-1.", + ); + }); +}); diff --git a/src/lib/redact.ts b/src/lib/redact.ts index 3063d4a..9402950 100644 --- a/src/lib/redact.ts +++ b/src/lib/redact.ts @@ -10,7 +10,7 @@ // `progress.ts`; living in either would make the other a cycle. const URL_CREDENTIALS = /(\b[a-z][a-z0-9+.-]*:\/\/)[^/\s@]+@/gi; const TOKEN_SHAPES = - /\b(?:gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,})/g; + /\b(?:gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|lin_api_[A-Za-z0-9]{20,})/g; export function redactSecrets(text: string): string { return text.replace(URL_CREDENTIALS, "$1***@").replace(TOKEN_SHAPES, "***"); diff --git a/src/lib/types.ts b/src/lib/types.ts index 9d2ab41..e8f39cc 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -138,7 +138,8 @@ export interface JsonErrorDocument { /** The issue a branch was derived from; `url` is `null` when not known. */ export type BranchIssue = | { provider: "github"; number: number; url: string | null } - | { provider: "jira"; key: string; url: string | null }; + | { provider: "jira"; key: string; url: string | null } + | { provider: "linear"; key: string; url: string | null }; export interface BranchDocument { path: string;