From e522c1a228c38c753c8fd0ee95549a0b7e7777ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Baldur=20P=C3=A1ll=20H=C3=B3lmgeirsson?= Date: Thu, 22 Oct 2026 22:07:00 +0000 Subject: [PATCH 1/5] feat(linear): add the Linear client, config key and key redaction Phase 1 of #107. src/integrations/linear.ts fetches one issue by identifier with a single GraphQL query (identifier, title, url, labels), authenticates with the bare personal API key, bounds every request at 15 s, and derives the branch prefix from the issue's labels. `linear.apiKey` joins CONFIG_NAMES and `lin_api_` keys are redacted from every output route. Auth and rate-limit errors are read from errors[].extensions.code, which is where Linear puts them (a live 401 probe and the rate-limiting docs), not extensions.type as the plan first said. Gate 1: pnpm check, typecheck, build, test (1056), docs:test (69) and the test-marker grep exit 0. Gate 2: reviewer subagent, FAIL then PASS WITH NOTES after one loopback. Refs #107 --- context/notes.md | 12 + src/integrations/linear.test.ts | 427 ++++++++++++++++++++++++++++++++ src/integrations/linear.ts | 252 +++++++++++++++++++ src/lib/constants.ts | 1 + src/lib/redact.test.ts | 24 ++ src/lib/redact.ts | 2 +- 6 files changed, 717 insertions(+), 1 deletion(-) create mode 100644 context/notes.md create mode 100644 src/integrations/linear.test.ts create mode 100644 src/integrations/linear.ts create mode 100644 src/lib/redact.test.ts diff --git a/context/notes.md b/context/notes.md new file mode 100644 index 0000000..273c1f2 --- /dev/null +++ b/context/notes.md @@ -0,0 +1,12 @@ +# Notes — linear-support (#107) + +Non-blocking observations from the gates. Branch-local; `/feature-close` deletes this file. + +- **Phase 1:** a non-2xx answer that is neither an auth error nor `RATELIMITED` drops Linear's GraphQL + message and reports `Failed to fetch issue … `. If Linear answers a missing issue with HTTP 400, + the user loses the "Unable to find issue" wording. Unverified without a valid key — the §9 live run settles it. +- **Phase 1:** the test `API_KEY` in `src/integrations/linear.test.ts` is shorter than the 20-character + redaction threshold, so it is not a realistic key; nothing asserts redaction with it today. +- **Phase 1:** `fetchLinear` re-throws a non-timeout error unchanged, and no test pins that. +- **Phase 1:** `linear.apiKey` is in `CONFIG_NAMES` before Phase 2 gives it a prompt; `--missing` lists it + and asks nothing until then. Closed by Phase 2. 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/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, "***"); From 6858fdda2ede99c7dbf600f28bfcc7f70a8a0472 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Baldur=20P=C3=A1ll=20H=C3=B3lmgeirsson?= Date: Thu, 22 Oct 2026 22:13:58 +0000 Subject: [PATCH 2/5] feat(config): add the Linear prompt group for linear.apiKey Phase 2 of #107. `worktree config` gains a Linear group: one confirm, then the API key prompt with instructions for finding a personal key. The stored key is never offered as a default or prefill, and an empty answer leaves a stored key in place, as `github.token` does. `--names linear.apiKey` skips the confirm, `--missing` lists the key while it is unset, and a non-interactive run with no key names `worktree config linear.apiKey `. Coder commit 5a82376 in the phase tree; the empty-answer rule replaced its validate in-host after Gate 2's first pass. Gate 1: pnpm check, typecheck, build, test (1066), docs:test (69) and the test-marker grep exit 0. Gate 2: reviewer subagent, PASS WITH NOTES, then PASS WITH NOTES on the note fix. Refs #107 --- context/notes.md | 6 ++ src/commands/config.test.ts | 155 ++++++++++++++++++++++++++++++++++++ src/commands/config.ts | 44 ++++++++++ 3 files changed, 205 insertions(+) diff --git a/context/notes.md b/context/notes.md index 273c1f2..1909732 100644 --- a/context/notes.md +++ b/context/notes.md @@ -10,3 +10,9 @@ Non-blocking observations from the gates. Branch-local; `/feature-close` deletes - **Phase 1:** `fetchLinear` re-throws a non-timeout error unchanged, and no test pins that. - **Phase 1:** `linear.apiKey` is in `CONFIG_NAMES` before Phase 2 gives it a prompt; `--missing` lists it and asks nothing until then. Closed by Phase 2. +- **Phase 2:** the Linear key prompt does not say that Enter keeps a stored key; the GitHub group's + instructions say what an empty answer does. +- **Phase 2:** a whitespace-only `linear.apiKey` hand-written into git config is kept by a non-interactive + run and only fails later, at `branch --linear`. +- **Phase 2:** the test "asks one confirm for the group…" does not assert that only one confirm ran, and the + empty-answer tests do not assert that the Linear prompt was reached. 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", From e907e197407e0ae3699d830dd802657e1db070ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Baldur=20P=C3=A1ll=20H=C3=B3lmgeirsson?= Date: Thu, 22 Oct 2026 22:19:17 +0000 Subject: [PATCH 3/5] feat(branch): add --linear to create a branch from a Linear issue Phase 3 of #107. `worktree branch --linear ENG-123` (`-l`) fetches the issue once and derives `ENG-123-` from it, the prefix from the issue's labels. `--json` carries `issue: {provider:"linear", key, url}`, the url as Linear returns it. `--linear` is exclusive with `--github` and `--jira`. `--assign` with it warns that assignment is not supported yet and carries on, and a missing `linear.apiKey` exits 2 with `missing_value`. Coder commit 1efb8a6 in the phase tree. Three assertions were added in-host after Gate 2: one fetch only, `-l`, and the help's linear `issue` shape. Gate 1: pnpm check, typecheck, build, test (1077), docs:test (69) and the test-marker grep exit 0. Gate 2: reviewer subagent, PASS WITH NOTES. Refs #107 --- context/notes.md | 9 ++ src/commands/branch.test.ts | 266 +++++++++++++++++++++++++++++++++++- src/commands/branch.ts | 75 ++++++++-- src/lib/base-command.ts | 2 +- src/lib/types.ts | 3 +- 5 files changed, 337 insertions(+), 18 deletions(-) diff --git a/context/notes.md b/context/notes.md index 1909732..7848663 100644 --- a/context/notes.md +++ b/context/notes.md @@ -16,3 +16,12 @@ Non-blocking observations from the gates. Branch-local; `/feature-close` deletes run and only fails later, at `branch --linear`. - **Phase 2:** the test "asks one confirm for the group…" does not assert that only one confirm ran, and the empty-answer tests do not assert that the Linear prompt was reached. +- **Phase 3:** `branch.ts` checks for a missing `linear.apiKey` itself, to get `missing_value` / exit 2, + because `resolveLinearApiKey` throws a plain `Error`. That function's empty-key check is now unreachable + from `branch`; having `resolveLinearApiKey` throw `MissingValueError` would let the branch check go. +- **Phase 3:** a missing Linear key exits 2 (`missing_value`), and a missing Jira credential still exits 1 + (`failed`). Intended by the plan for Linear only. +- **Phase 3:** with no key *and* a malformed id, `branch --linear foo` reports `missing_value` before D5's + `invalid_value`, because the key check runs before the id is validated. +- **Phase 3:** the reworded exclusivity and `--assign` messages are pinned only against the code; no plan + text decides their wording. 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/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/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; From 7615459b4aa62483b60731e5648c045a58c816eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Baldur=20P=C3=A1ll=20H=C3=B3lmgeirsson?= Date: Thu, 22 Oct 2026 22:27:05 +0000 Subject: [PATCH 4/5] docs(linear): document --linear, linear.apiKey and the label rule Phase 4 of #107. A new Linear Issue Integration guide covers usage, how branch names are built, the label -> prefix rule, getting a key and setup. The branch, config and configuration pages, the README, the shipped skill and the docs chat route map gain Linear beside Jira, and the route list is regenerated by `worker:build-context`. The guides index now lists the Jira guide too, which it never did. No changeset: context/release.md has the note written once per feature, by /feature-close. Gate 1: pnpm check, typecheck, build, test (1077), docs:test (69) and the test-marker grep exit 0. Gate 2: reviewer subagent, PASS WITH NOTES; four doc inaccuracies it found were fixed before this commit. Refs #107 --- README.md | 5 +- context/notes.md | 6 ++ context/stack.md | 4 +- docs/src/app/docs/commands/branch/page.mdx | 29 +++++- docs/src/app/docs/commands/config/page.mdx | 2 +- docs/src/app/docs/configuration/page.mdx | 11 +++ docs/src/app/docs/guides/_meta.ts | 1 + .../docs/guides/linear-integration/page.mdx | 92 +++++++++++++++++++ docs/src/app/docs/guides/page.mdx | 2 + docs/src/chat/components/MarkdownContent.tsx | 2 + docs/src/chat/docs-routes.generated.ts | 1 + skills/worktree/SKILL.md | 60 ++++++++++-- 12 files changed, 196 insertions(+), 19 deletions(-) create mode 100644 docs/src/app/docs/guides/linear-integration/page.mdx 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/notes.md b/context/notes.md index 7848663..bd5c6ba 100644 --- a/context/notes.md +++ b/context/notes.md @@ -25,3 +25,9 @@ Non-blocking observations from the gates. Branch-local; `/feature-close` deletes `invalid_value`, because the key check runs before the id is validated. - **Phase 3:** the reworded exclusivity and `--assign` messages are pinned only against the code; no plan text decides their wording. +- **Phase 4:** plan §8 says `pnpm docs:test` checks the new page through the route list; no docs test reads + it. The route list is generated from disk, but nothing checks that each `DOCS_ROUTE_MAP` entry in + `MarkdownContent.tsx` is a valid route. That gap predates this feature. Opening the page in + `pnpm docs:dev` is still a person's pass. +- **Phase 4:** some added doc lines break the surrounding hard wrap (SKILL.md, branch page, README). + Cosmetic once rendered. 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: From 81358d452b0861356b5af67b275bd98bc564f74e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Baldur=20P=C3=A1ll=20H=C3=B3lmgeirsson?= Date: Thu, 22 Oct 2026 22:38:35 +0000 Subject: [PATCH 5/5] chore(release): add the Linear support release note Retires #107. The one release note for the feature, as context/release.md asks (once per feature), and the branch-local context/notes.md removed. Owner checks left for a person: #109 (live run) and #110 (label rules). --- .changeset/linear-support.md | 5 +++++ context/notes.md | 33 --------------------------------- 2 files changed, 5 insertions(+), 33 deletions(-) create mode 100644 .changeset/linear-support.md delete mode 100644 context/notes.md 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/context/notes.md b/context/notes.md deleted file mode 100644 index bd5c6ba..0000000 --- a/context/notes.md +++ /dev/null @@ -1,33 +0,0 @@ -# Notes — linear-support (#107) - -Non-blocking observations from the gates. Branch-local; `/feature-close` deletes this file. - -- **Phase 1:** a non-2xx answer that is neither an auth error nor `RATELIMITED` drops Linear's GraphQL - message and reports `Failed to fetch issue … `. If Linear answers a missing issue with HTTP 400, - the user loses the "Unable to find issue" wording. Unverified without a valid key — the §9 live run settles it. -- **Phase 1:** the test `API_KEY` in `src/integrations/linear.test.ts` is shorter than the 20-character - redaction threshold, so it is not a realistic key; nothing asserts redaction with it today. -- **Phase 1:** `fetchLinear` re-throws a non-timeout error unchanged, and no test pins that. -- **Phase 1:** `linear.apiKey` is in `CONFIG_NAMES` before Phase 2 gives it a prompt; `--missing` lists it - and asks nothing until then. Closed by Phase 2. -- **Phase 2:** the Linear key prompt does not say that Enter keeps a stored key; the GitHub group's - instructions say what an empty answer does. -- **Phase 2:** a whitespace-only `linear.apiKey` hand-written into git config is kept by a non-interactive - run and only fails later, at `branch --linear`. -- **Phase 2:** the test "asks one confirm for the group…" does not assert that only one confirm ran, and the - empty-answer tests do not assert that the Linear prompt was reached. -- **Phase 3:** `branch.ts` checks for a missing `linear.apiKey` itself, to get `missing_value` / exit 2, - because `resolveLinearApiKey` throws a plain `Error`. That function's empty-key check is now unreachable - from `branch`; having `resolveLinearApiKey` throw `MissingValueError` would let the branch check go. -- **Phase 3:** a missing Linear key exits 2 (`missing_value`), and a missing Jira credential still exits 1 - (`failed`). Intended by the plan for Linear only. -- **Phase 3:** with no key *and* a malformed id, `branch --linear foo` reports `missing_value` before D5's - `invalid_value`, because the key check runs before the id is validated. -- **Phase 3:** the reworded exclusivity and `--assign` messages are pinned only against the code; no plan - text decides their wording. -- **Phase 4:** plan §8 says `pnpm docs:test` checks the new page through the route list; no docs test reads - it. The route list is generated from disk, but nothing checks that each `DOCS_ROUTE_MAP` entry in - `MarkdownContent.tsx` is a valid route. That gap predates this feature. Opening the page in - `pnpm docs:dev` is still a person's pass. -- **Phase 4:** some added doc lines break the surrounding hard wrap (SKILL.md, branch page, README). - Cosmetic once rendered.