/** * Linear + Git Worktree extension for pi * * Provides a /linear command that: * 1. Fetches issue details from Linear (title, description, comments, labels, etc.) * 2. Creates a git worktree with a branch named after the issue * 3. Sends the issue context to the agent and starts solving it * * Requires: * - LINEAR_API_KEY environment variable * - Current directory must be inside a git repository */ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; import { LinearClient } from "@linear/sdk"; import path from "node:path"; function slugify(text: string): string { return text .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-|-$/g, "") .slice(0, 50); } /** Sanitize a branch name for use as a directory name (replace / with -). */ function branchToDir(branchName: string): string { return branchName.replace(/\//g, "-"); } async function getLinearIssue(apiKey: string, issueId: string) { const client = new LinearClient({ apiKey }); const issue = await client.issue(issueId); const assignee = issue.assignee ? await issue.assignee : null; const state = issue.state ? await issue.state : null; const labels = await issue.labels(); const comments = await issue.comments(); const parent = issue.parent ? await issue.parent : null; const relations = await issue.relations(); const relatedIssues: string[] = []; for (const relation of relations.nodes) { const related = await relation.relatedIssue; if (related) { relatedIssues.push(`- [${relation.type}] ${related.identifier}: ${related.title}`); } } const commentTexts = comments.nodes .sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()) .map((c) => { const date = new Date(c.createdAt).toISOString().split("T")[0]; return `[${date}] ${c.body}`; }); return { identifier: issue.identifier, title: issue.title, description: issue.description || "(no description)", priority: issue.priority, priorityLabel: issue.priorityLabel, state: state?.name || "Unknown", assignee: assignee?.name || "Unassigned", labels: labels.nodes.map((l) => l.name), comments: commentTexts, parent: parent ? `${parent.identifier}: ${parent.title}` : null, relatedIssues, url: issue.url, branchName: issue.branchName || `${issue.identifier.toLowerCase()}-${slugify(issue.title)}`, }; } /** * Find an existing worktree for the given branch by parsing `git worktree list --porcelain`. * Returns the absolute worktree path if found, undefined otherwise. */ function findWorktreeForBranch(porcelainOutput: string, branchName: string): string | undefined { const blocks = porcelainOutput.split("\n\n"); for (const block of blocks) { const lines = block.split("\n"); const worktreeLine = lines.find((l) => l.startsWith("worktree ")); const branchLine = lines.find((l) => l.startsWith("branch ")); if (worktreeLine && branchLine) { // branch line is "branch refs/heads/" const ref = branchLine.replace("branch ", ""); if (ref === `refs/heads/${branchName}`) { return worktreeLine.replace("worktree ", ""); } } } return undefined; } export default function (pi: ExtensionAPI) { pi.registerCommand("linear", { description: "Fetch a Linear issue and create a git worktree to solve it. Usage: /linear ", handler: async (args, ctx) => { const issueId = args?.trim(); if (!issueId) { ctx.ui.notify("Usage: /linear (e.g. /linear ENG-123)", "error"); return; } const apiKey = process.env.LINEAR_API_KEY; if (!apiKey) { ctx.ui.notify("LINEAR_API_KEY environment variable is not set", "error"); return; } // Check we're in a git repo const gitCheck = await pi.exec("git", ["rev-parse", "--git-dir"], { timeout: 5000 }); if (gitCheck.code !== 0) { ctx.ui.notify("Not inside a git repository", "error"); return; } // Get the repo root (resolved to absolute path) const repoRootResult = await pi.exec("git", ["rev-parse", "--show-toplevel"], { timeout: 5000 }); const repoRoot = repoRootResult.stdout.trim(); // Fetch issue from Linear ctx.ui.setStatus("linear", "Fetching issue from Linear..."); let issue; try { issue = await getLinearIssue(apiKey, issueId); } catch (err: any) { ctx.ui.setStatus("linear", undefined); ctx.ui.notify(`Failed to fetch issue: ${err.message}`, "error"); return; } const branchName = issue.branchName; const dirName = branchToDir(branchName); const worktreePath = path.resolve(repoRoot, "..", dirName); ctx.ui.setStatus("linear", `Creating worktree for ${issue.identifier}...`); // Fetch remote refs so we can detect remote branches await pi.exec("git", ["fetch", "--quiet"], { timeout: 30000 }); // Check if a worktree for this branch already exists (match on branch ref, not path suffix) const worktreeListResult = await pi.exec("git", ["worktree", "list", "--porcelain"], { timeout: 5000 }); const existingWorktree = findWorktreeForBranch(worktreeListResult.stdout, branchName); let worktreeDir: string; if (existingWorktree) { worktreeDir = existingWorktree; ctx.ui.notify(`Worktree already exists at ${worktreeDir}`, "info"); } else { // Check if branch exists remotely or locally const branchExistsLocal = await pi.exec("git", ["rev-parse", "--verify", branchName], { timeout: 5000 }); const branchExistsRemote = await pi.exec("git", ["rev-parse", "--verify", `origin/${branchName}`], { timeout: 5000, }); let createResult; if (branchExistsLocal.code === 0) { createResult = await pi.exec("git", ["worktree", "add", worktreePath, branchName], { timeout: 30000 }); } else if (branchExistsRemote.code === 0) { createResult = await pi.exec( "git", ["worktree", "add", "--track", "-b", branchName, worktreePath, `origin/${branchName}`], { timeout: 30000 }, ); } else { createResult = await pi.exec("git", ["worktree", "add", "-b", branchName, worktreePath], { timeout: 30000, }); } if (createResult.code !== 0) { ctx.ui.setStatus("linear", undefined); ctx.ui.notify(`Failed to create worktree: ${createResult.stderr}`, "error"); return; } worktreeDir = worktreePath; } ctx.ui.setStatus("linear", undefined); // Name the session after the issue pi.setSessionName(`${issue.identifier}: ${issue.title}`); // Build context message for the agent let context = `# Linear Issue: ${issue.identifier} — ${issue.title}\n\n`; context += `**URL:** ${issue.url}\n`; context += `**State:** ${issue.state}\n`; context += `**Priority:** ${issue.priorityLabel}\n`; context += `**Assignee:** ${issue.assignee}\n`; if (issue.labels.length > 0) { context += `**Labels:** ${issue.labels.join(", ")}\n`; } if (issue.parent) { context += `**Parent Issue:** ${issue.parent}\n`; } context += `\n## Description\n\n${issue.description}\n`; if (issue.relatedIssues.length > 0) { context += `\n## Related Issues\n\n${issue.relatedIssues.join("\n")}\n`; } if (issue.comments.length > 0) { context += `\n## Comments\n\n${issue.comments.join("\n\n")}\n`; } context += `\n---\n`; context += `\n**Git worktree directory:** \`${worktreeDir}\`\n`; context += `**Branch:** \`${branchName}\`\n`; context += `\nIMPORTANT: All file operations (read, edit, write, bash) MUST target the worktree directory above, NOT the current working directory. Always use absolute paths under \`${worktreeDir}/\` or \`cd ${worktreeDir}\` before running commands.\n`; context += `\nStart by understanding the codebase in the worktree, then implement the changes needed to resolve this issue. Commit your work to the branch when done.`; // Send as user message to kick off the agent pi.sendUserMessage(context); }, }); }