pi-linear-worktree 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +66 -0
- package/extensions/index.ts +218 -0
- package/package.json +41 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Karl
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# pi-linear-worktree
|
|
2
|
+
|
|
3
|
+
A [pi](https://github.com/badlogic/pi-mono) extension that fetches Linear issues and creates git worktrees to solve them.
|
|
4
|
+
|
|
5
|
+
Type `/linear ENG-123` and the agent will:
|
|
6
|
+
|
|
7
|
+
1. Fetch the full issue from Linear (title, description, comments, labels, relations)
|
|
8
|
+
2. Create a git worktree with a branch named after the issue
|
|
9
|
+
3. Send the issue context to the agent and start solving it
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pi install npm:pi-linear-worktree
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Or try it without installing:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
pi -e npm:pi-linear-worktree
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Setup
|
|
24
|
+
|
|
25
|
+
Set your Linear API key as an environment variable:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
export LINEAR_API_KEY=lin_api_xxxxxxxxxxxxx
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
You can create a personal API key at [Linear Settings → API](https://linear.app/settings/api).
|
|
32
|
+
|
|
33
|
+
## Usage
|
|
34
|
+
|
|
35
|
+
Inside a git repository, run:
|
|
36
|
+
|
|
37
|
+
```
|
|
38
|
+
/linear <issue-id>
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
For example:
|
|
42
|
+
|
|
43
|
+
```
|
|
44
|
+
/linear ENG-123
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
The extension will:
|
|
48
|
+
|
|
49
|
+
- Fetch the issue details from Linear
|
|
50
|
+
- Create a worktree at `../<branch-name>` relative to the repo root
|
|
51
|
+
- If the worktree/branch already exists, reuse it
|
|
52
|
+
- Name the pi session after the issue
|
|
53
|
+
- Send the full issue context to the agent to start working on it
|
|
54
|
+
|
|
55
|
+
## What gets sent to the agent
|
|
56
|
+
|
|
57
|
+
- Issue title, description, state, priority, assignee
|
|
58
|
+
- Labels and parent issue
|
|
59
|
+
- Related issues
|
|
60
|
+
- All comments (chronological)
|
|
61
|
+
- Worktree path and branch name
|
|
62
|
+
- Instructions to work in the worktree and commit when done
|
|
63
|
+
|
|
64
|
+
## License
|
|
65
|
+
|
|
66
|
+
MIT
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Linear + Git Worktree extension for pi
|
|
3
|
+
*
|
|
4
|
+
* Provides a /linear command that:
|
|
5
|
+
* 1. Fetches issue details from Linear (title, description, comments, labels, etc.)
|
|
6
|
+
* 2. Creates a git worktree with a branch named after the issue
|
|
7
|
+
* 3. Sends the issue context to the agent and starts solving it
|
|
8
|
+
*
|
|
9
|
+
* Requires:
|
|
10
|
+
* - LINEAR_API_KEY environment variable
|
|
11
|
+
* - Current directory must be inside a git repository
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
15
|
+
import { LinearClient } from "@linear/sdk";
|
|
16
|
+
import path from "node:path";
|
|
17
|
+
|
|
18
|
+
function slugify(text: string): string {
|
|
19
|
+
return text
|
|
20
|
+
.toLowerCase()
|
|
21
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
22
|
+
.replace(/^-|-$/g, "")
|
|
23
|
+
.slice(0, 50);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Sanitize a branch name for use as a directory name (replace / with -). */
|
|
27
|
+
function branchToDir(branchName: string): string {
|
|
28
|
+
return branchName.replace(/\//g, "-");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function getLinearIssue(apiKey: string, issueId: string) {
|
|
32
|
+
const client = new LinearClient({ apiKey });
|
|
33
|
+
const issue = await client.issue(issueId);
|
|
34
|
+
|
|
35
|
+
const assignee = issue.assignee ? await issue.assignee : null;
|
|
36
|
+
const state = issue.state ? await issue.state : null;
|
|
37
|
+
const labels = await issue.labels();
|
|
38
|
+
const comments = await issue.comments();
|
|
39
|
+
const parent = issue.parent ? await issue.parent : null;
|
|
40
|
+
const relations = await issue.relations();
|
|
41
|
+
|
|
42
|
+
const relatedIssues: string[] = [];
|
|
43
|
+
for (const relation of relations.nodes) {
|
|
44
|
+
const related = await relation.relatedIssue;
|
|
45
|
+
if (related) {
|
|
46
|
+
relatedIssues.push(`- [${relation.type}] ${related.identifier}: ${related.title}`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const commentTexts = comments.nodes
|
|
51
|
+
.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime())
|
|
52
|
+
.map((c) => {
|
|
53
|
+
const date = new Date(c.createdAt).toISOString().split("T")[0];
|
|
54
|
+
return `[${date}] ${c.body}`;
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
identifier: issue.identifier,
|
|
59
|
+
title: issue.title,
|
|
60
|
+
description: issue.description || "(no description)",
|
|
61
|
+
priority: issue.priority,
|
|
62
|
+
priorityLabel: issue.priorityLabel,
|
|
63
|
+
state: state?.name || "Unknown",
|
|
64
|
+
assignee: assignee?.name || "Unassigned",
|
|
65
|
+
labels: labels.nodes.map((l) => l.name),
|
|
66
|
+
comments: commentTexts,
|
|
67
|
+
parent: parent ? `${parent.identifier}: ${parent.title}` : null,
|
|
68
|
+
relatedIssues,
|
|
69
|
+
url: issue.url,
|
|
70
|
+
branchName: issue.branchName || `${issue.identifier.toLowerCase()}-${slugify(issue.title)}`,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Find an existing worktree for the given branch by parsing `git worktree list --porcelain`.
|
|
76
|
+
* Returns the absolute worktree path if found, undefined otherwise.
|
|
77
|
+
*/
|
|
78
|
+
function findWorktreeForBranch(porcelainOutput: string, branchName: string): string | undefined {
|
|
79
|
+
const blocks = porcelainOutput.split("\n\n");
|
|
80
|
+
for (const block of blocks) {
|
|
81
|
+
const lines = block.split("\n");
|
|
82
|
+
const worktreeLine = lines.find((l) => l.startsWith("worktree "));
|
|
83
|
+
const branchLine = lines.find((l) => l.startsWith("branch "));
|
|
84
|
+
if (worktreeLine && branchLine) {
|
|
85
|
+
// branch line is "branch refs/heads/<name>"
|
|
86
|
+
const ref = branchLine.replace("branch ", "");
|
|
87
|
+
if (ref === `refs/heads/${branchName}`) {
|
|
88
|
+
return worktreeLine.replace("worktree ", "");
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return undefined;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export default function (pi: ExtensionAPI) {
|
|
96
|
+
pi.registerCommand("linear", {
|
|
97
|
+
description: "Fetch a Linear issue and create a git worktree to solve it. Usage: /linear <issue-id>",
|
|
98
|
+
handler: async (args, ctx) => {
|
|
99
|
+
const issueId = args?.trim();
|
|
100
|
+
if (!issueId) {
|
|
101
|
+
ctx.ui.notify("Usage: /linear <issue-id> (e.g. /linear ENG-123)", "error");
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const apiKey = process.env.LINEAR_API_KEY;
|
|
106
|
+
if (!apiKey) {
|
|
107
|
+
ctx.ui.notify("LINEAR_API_KEY environment variable is not set", "error");
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Check we're in a git repo
|
|
112
|
+
const gitCheck = await pi.exec("git", ["rev-parse", "--git-dir"], { timeout: 5000 });
|
|
113
|
+
if (gitCheck.code !== 0) {
|
|
114
|
+
ctx.ui.notify("Not inside a git repository", "error");
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Get the repo root (resolved to absolute path)
|
|
119
|
+
const repoRootResult = await pi.exec("git", ["rev-parse", "--show-toplevel"], { timeout: 5000 });
|
|
120
|
+
const repoRoot = repoRootResult.stdout.trim();
|
|
121
|
+
|
|
122
|
+
// Fetch issue from Linear
|
|
123
|
+
ctx.ui.setStatus("linear", "Fetching issue from Linear...");
|
|
124
|
+
let issue;
|
|
125
|
+
try {
|
|
126
|
+
issue = await getLinearIssue(apiKey, issueId);
|
|
127
|
+
} catch (err: any) {
|
|
128
|
+
ctx.ui.setStatus("linear", undefined);
|
|
129
|
+
ctx.ui.notify(`Failed to fetch issue: ${err.message}`, "error");
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const branchName = issue.branchName;
|
|
134
|
+
const dirName = branchToDir(branchName);
|
|
135
|
+
const worktreePath = path.resolve(repoRoot, "..", dirName);
|
|
136
|
+
|
|
137
|
+
ctx.ui.setStatus("linear", `Creating worktree for ${issue.identifier}...`);
|
|
138
|
+
|
|
139
|
+
// Fetch remote refs so we can detect remote branches
|
|
140
|
+
await pi.exec("git", ["fetch", "--quiet"], { timeout: 30000 });
|
|
141
|
+
|
|
142
|
+
// Check if a worktree for this branch already exists (match on branch ref, not path suffix)
|
|
143
|
+
const worktreeListResult = await pi.exec("git", ["worktree", "list", "--porcelain"], { timeout: 5000 });
|
|
144
|
+
const existingWorktree = findWorktreeForBranch(worktreeListResult.stdout, branchName);
|
|
145
|
+
|
|
146
|
+
let worktreeDir: string;
|
|
147
|
+
|
|
148
|
+
if (existingWorktree) {
|
|
149
|
+
worktreeDir = existingWorktree;
|
|
150
|
+
ctx.ui.notify(`Worktree already exists at ${worktreeDir}`, "info");
|
|
151
|
+
} else {
|
|
152
|
+
// Check if branch exists remotely or locally
|
|
153
|
+
const branchExistsLocal = await pi.exec("git", ["rev-parse", "--verify", branchName], { timeout: 5000 });
|
|
154
|
+
const branchExistsRemote = await pi.exec("git", ["rev-parse", "--verify", `origin/${branchName}`], {
|
|
155
|
+
timeout: 5000,
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
let createResult;
|
|
159
|
+
if (branchExistsLocal.code === 0) {
|
|
160
|
+
createResult = await pi.exec("git", ["worktree", "add", worktreePath, branchName], { timeout: 30000 });
|
|
161
|
+
} else if (branchExistsRemote.code === 0) {
|
|
162
|
+
createResult = await pi.exec(
|
|
163
|
+
"git",
|
|
164
|
+
["worktree", "add", "--track", "-b", branchName, worktreePath, `origin/${branchName}`],
|
|
165
|
+
{ timeout: 30000 },
|
|
166
|
+
);
|
|
167
|
+
} else {
|
|
168
|
+
createResult = await pi.exec("git", ["worktree", "add", "-b", branchName, worktreePath], {
|
|
169
|
+
timeout: 30000,
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (createResult.code !== 0) {
|
|
174
|
+
ctx.ui.setStatus("linear", undefined);
|
|
175
|
+
ctx.ui.notify(`Failed to create worktree: ${createResult.stderr}`, "error");
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
worktreeDir = worktreePath;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
ctx.ui.setStatus("linear", undefined);
|
|
182
|
+
|
|
183
|
+
// Name the session after the issue
|
|
184
|
+
pi.setSessionName(`${issue.identifier}: ${issue.title}`);
|
|
185
|
+
|
|
186
|
+
// Build context message for the agent
|
|
187
|
+
let context = `# Linear Issue: ${issue.identifier} — ${issue.title}\n\n`;
|
|
188
|
+
context += `**URL:** ${issue.url}\n`;
|
|
189
|
+
context += `**State:** ${issue.state}\n`;
|
|
190
|
+
context += `**Priority:** ${issue.priorityLabel}\n`;
|
|
191
|
+
context += `**Assignee:** ${issue.assignee}\n`;
|
|
192
|
+
if (issue.labels.length > 0) {
|
|
193
|
+
context += `**Labels:** ${issue.labels.join(", ")}\n`;
|
|
194
|
+
}
|
|
195
|
+
if (issue.parent) {
|
|
196
|
+
context += `**Parent Issue:** ${issue.parent}\n`;
|
|
197
|
+
}
|
|
198
|
+
context += `\n## Description\n\n${issue.description}\n`;
|
|
199
|
+
|
|
200
|
+
if (issue.relatedIssues.length > 0) {
|
|
201
|
+
context += `\n## Related Issues\n\n${issue.relatedIssues.join("\n")}\n`;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (issue.comments.length > 0) {
|
|
205
|
+
context += `\n## Comments\n\n${issue.comments.join("\n\n")}\n`;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
context += `\n---\n`;
|
|
209
|
+
context += `\n**Git worktree directory:** \`${worktreeDir}\`\n`;
|
|
210
|
+
context += `**Branch:** \`${branchName}\`\n`;
|
|
211
|
+
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`;
|
|
212
|
+
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.`;
|
|
213
|
+
|
|
214
|
+
// Send as user message to kick off the agent
|
|
215
|
+
pi.sendUserMessage(context);
|
|
216
|
+
},
|
|
217
|
+
});
|
|
218
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-linear-worktree",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Pi extension that fetches Linear issues and creates git worktrees to solve them",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"pi-package"
|
|
7
|
+
],
|
|
8
|
+
"license": "MIT",
|
|
9
|
+
"author": "Karl",
|
|
10
|
+
"repository": {
|
|
11
|
+
"type": "git",
|
|
12
|
+
"url": "https://github.com/therapys/pi-linear-worktree"
|
|
13
|
+
},
|
|
14
|
+
"type": "module",
|
|
15
|
+
"files": [
|
|
16
|
+
"extensions/",
|
|
17
|
+
"README.md",
|
|
18
|
+
"LICENSE"
|
|
19
|
+
],
|
|
20
|
+
"scripts": {
|
|
21
|
+
"typecheck": "tsc --noEmit",
|
|
22
|
+
"lint": "prettier --check .",
|
|
23
|
+
"format": "prettier --write ."
|
|
24
|
+
},
|
|
25
|
+
"pi": {
|
|
26
|
+
"extensions": [
|
|
27
|
+
"./extensions/index.ts"
|
|
28
|
+
]
|
|
29
|
+
},
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"@linear/sdk": "^76.0.0"
|
|
32
|
+
},
|
|
33
|
+
"peerDependencies": {
|
|
34
|
+
"@mariozechner/pi-coding-agent": "*"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"@mariozechner/pi-coding-agent": "*",
|
|
38
|
+
"prettier": "^3.5.0",
|
|
39
|
+
"typescript": "^5.7.0"
|
|
40
|
+
}
|
|
41
|
+
}
|