@calltelemetry/openclaw-linear 0.3.1 → 0.4.1
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/README.md +322 -386
- package/index.ts +54 -2
- package/openclaw.plugin.json +9 -1
- package/package.json +3 -2
- package/src/active-session.ts +106 -0
- package/src/agent.ts +173 -1
- package/src/auth.ts +6 -2
- package/src/claude-tool.ts +280 -0
- package/src/cli-shared.ts +75 -0
- package/src/cli.ts +39 -0
- package/src/client.ts +1 -0
- package/src/code-tool.ts +202 -0
- package/src/codex-tool.ts +240 -0
- package/src/codex-worktree.ts +390 -0
- package/src/dispatch-service.ts +113 -0
- package/src/dispatch-state.ts +265 -0
- package/src/gemini-tool.ts +238 -0
- package/src/orchestration-tools.ts +134 -0
- package/src/pipeline.ts +343 -56
- package/src/tier-assess.ts +157 -0
- package/src/tools.ts +29 -79
- package/src/webhook.ts +532 -275
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { createInterface } from "node:readline";
|
|
3
|
+
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
4
|
+
import type { ActivityContent } from "./linear-api.js";
|
|
5
|
+
import {
|
|
6
|
+
buildLinearApi,
|
|
7
|
+
resolveSession,
|
|
8
|
+
extractPrompt,
|
|
9
|
+
DEFAULT_TIMEOUT_MS,
|
|
10
|
+
DEFAULT_BASE_REPO,
|
|
11
|
+
type CliToolParams,
|
|
12
|
+
type CliResult,
|
|
13
|
+
} from "./cli-shared.js";
|
|
14
|
+
|
|
15
|
+
const CODEX_BIN = "/home/claw/.npm-global/bin/codex";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Parse a JSONL line from `codex exec --json` and map it to a Linear activity.
|
|
19
|
+
*/
|
|
20
|
+
function mapCodexEventToActivity(event: any): ActivityContent | null {
|
|
21
|
+
const eventType = event?.type;
|
|
22
|
+
const item = event?.item;
|
|
23
|
+
|
|
24
|
+
if (item?.type === "reasoning") {
|
|
25
|
+
const text = item.text ?? "";
|
|
26
|
+
return { type: "thought", body: text ? text.slice(0, 500) : "Reasoning..." };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (
|
|
30
|
+
(eventType === "item.completed" || eventType === "item.started") &&
|
|
31
|
+
(item?.type === "agent_message" || item?.type === "message")
|
|
32
|
+
) {
|
|
33
|
+
const text = item.text ?? item.content ?? "";
|
|
34
|
+
if (text) return { type: "thought", body: text.slice(0, 1000) };
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (eventType === "item.started" && item?.type === "command_execution") {
|
|
39
|
+
const cmd = item.command ?? "unknown";
|
|
40
|
+
const cleaned = typeof cmd === "string"
|
|
41
|
+
? cmd.replace(/^\/usr\/bin\/\w+ -lc ['"]?/, "").replace(/['"]?$/, "")
|
|
42
|
+
: JSON.stringify(cmd);
|
|
43
|
+
return { type: "action", action: "Running", parameter: cleaned.slice(0, 200) };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (eventType === "item.completed" && item?.type === "command_execution") {
|
|
47
|
+
const cmd = item.command ?? "unknown";
|
|
48
|
+
const exitCode = item.exit_code ?? "?";
|
|
49
|
+
const output = item.aggregated_output ?? item.output ?? "";
|
|
50
|
+
const cleaned = typeof cmd === "string"
|
|
51
|
+
? cmd.replace(/^\/usr\/bin\/\w+ -lc ['"]?/, "").replace(/['"]?$/, "")
|
|
52
|
+
: JSON.stringify(cmd);
|
|
53
|
+
const truncated = output.length > 500 ? output.slice(0, 500) + "..." : output;
|
|
54
|
+
return {
|
|
55
|
+
type: "action",
|
|
56
|
+
action: `${cleaned.slice(0, 150)}`,
|
|
57
|
+
parameter: `exit ${exitCode}`,
|
|
58
|
+
result: truncated || undefined,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (eventType === "item.completed" && item?.type === "file_changes") {
|
|
63
|
+
const files = item.files ?? [];
|
|
64
|
+
const fileList = Array.isArray(files) ? files.join(", ") : String(files);
|
|
65
|
+
return { type: "action", action: "Modified files", parameter: fileList || "unknown files" };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (eventType === "turn.completed") {
|
|
69
|
+
const usage = event.usage;
|
|
70
|
+
if (usage) {
|
|
71
|
+
const input = usage.input_tokens ?? 0;
|
|
72
|
+
const cached = usage.cached_input_tokens ?? 0;
|
|
73
|
+
const output = usage.output_tokens ?? 0;
|
|
74
|
+
return { type: "thought", body: `Codex turn complete (${input} in / ${cached} cached / ${output} out tokens)` };
|
|
75
|
+
}
|
|
76
|
+
return { type: "thought", body: "Codex turn complete" };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Run Codex CLI with JSONL streaming, mapping events to Linear activities in real-time.
|
|
84
|
+
*/
|
|
85
|
+
export async function runCodex(
|
|
86
|
+
api: OpenClawPluginApi,
|
|
87
|
+
params: CliToolParams,
|
|
88
|
+
pluginConfig?: Record<string, unknown>,
|
|
89
|
+
): Promise<CliResult> {
|
|
90
|
+
api.logger.info(`codex_run params: ${JSON.stringify(params).slice(0, 500)}`);
|
|
91
|
+
|
|
92
|
+
const prompt = extractPrompt(params);
|
|
93
|
+
if (!prompt) {
|
|
94
|
+
return {
|
|
95
|
+
success: false,
|
|
96
|
+
output: `codex_run error: no prompt provided. Received keys: ${Object.keys(params).join(", ")}`,
|
|
97
|
+
error: "missing prompt",
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const { model, timeoutMs } = params;
|
|
102
|
+
const { agentSessionId, issueIdentifier } = resolveSession(params);
|
|
103
|
+
|
|
104
|
+
api.logger.info(`codex_run: session=${agentSessionId ?? "none"}, issue=${issueIdentifier ?? "none"}`);
|
|
105
|
+
|
|
106
|
+
const timeout = timeoutMs ?? (pluginConfig?.codexTimeoutMs as number) ?? DEFAULT_TIMEOUT_MS;
|
|
107
|
+
const workingDir = params.workingDir ?? (pluginConfig?.codexBaseRepo as string) ?? DEFAULT_BASE_REPO;
|
|
108
|
+
|
|
109
|
+
// Build Linear API for activity streaming
|
|
110
|
+
const linearApi = buildLinearApi(api, agentSessionId);
|
|
111
|
+
|
|
112
|
+
if (linearApi && agentSessionId) {
|
|
113
|
+
await linearApi.emitActivity(agentSessionId, {
|
|
114
|
+
type: "thought",
|
|
115
|
+
body: `Starting Codex: "${prompt.slice(0, 100)}${prompt.length > 100 ? "..." : ""}"`,
|
|
116
|
+
}).catch(() => {});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Build codex command
|
|
120
|
+
const args = ["exec", "--full-auto", "--json", "--ephemeral"];
|
|
121
|
+
if (model ?? pluginConfig?.codexModel) {
|
|
122
|
+
args.push("--model", (model ?? pluginConfig?.codexModel) as string);
|
|
123
|
+
}
|
|
124
|
+
args.push("-C", workingDir);
|
|
125
|
+
args.push(prompt);
|
|
126
|
+
|
|
127
|
+
api.logger.info(`Codex exec: ${CODEX_BIN} ${args.join(" ").slice(0, 200)}...`);
|
|
128
|
+
|
|
129
|
+
return new Promise<CliResult>((resolve) => {
|
|
130
|
+
const child = spawn(CODEX_BIN, args, {
|
|
131
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
132
|
+
env: { ...process.env },
|
|
133
|
+
timeout: 0,
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
let killed = false;
|
|
137
|
+
const timer = setTimeout(() => {
|
|
138
|
+
killed = true;
|
|
139
|
+
child.kill("SIGTERM");
|
|
140
|
+
setTimeout(() => { if (!child.killed) child.kill("SIGKILL"); }, 5_000);
|
|
141
|
+
}, timeout);
|
|
142
|
+
|
|
143
|
+
const collectedMessages: string[] = [];
|
|
144
|
+
const collectedCommands: string[] = [];
|
|
145
|
+
let stderrOutput = "";
|
|
146
|
+
|
|
147
|
+
const rl = createInterface({ input: child.stdout! });
|
|
148
|
+
rl.on("line", (line) => {
|
|
149
|
+
if (!line.trim()) return;
|
|
150
|
+
|
|
151
|
+
let event: any;
|
|
152
|
+
try {
|
|
153
|
+
event = JSON.parse(line);
|
|
154
|
+
} catch {
|
|
155
|
+
collectedMessages.push(line);
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const item = event?.item;
|
|
160
|
+
|
|
161
|
+
if (
|
|
162
|
+
event?.type === "item.completed" &&
|
|
163
|
+
(item?.type === "agent_message" || item?.type === "message")
|
|
164
|
+
) {
|
|
165
|
+
const text = item.text ?? item.content ?? "";
|
|
166
|
+
if (text) collectedMessages.push(text);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Skip reasoning events from final output — they're streamed to
|
|
170
|
+
// Linear as activities but don't belong in the returned result.
|
|
171
|
+
|
|
172
|
+
if (event?.type === "item.completed" && item?.type === "command_execution") {
|
|
173
|
+
const cmd = item.command ?? "unknown";
|
|
174
|
+
const exitCode = item.exit_code ?? "?";
|
|
175
|
+
const output = item.aggregated_output ?? item.output ?? "";
|
|
176
|
+
const cleanCmd = typeof cmd === "string"
|
|
177
|
+
? cmd.replace(/^\/usr\/bin\/\w+ -lc ['"]?/, "").replace(/['"]?$/, "")
|
|
178
|
+
: String(cmd);
|
|
179
|
+
const truncOutput = output.length > 500 ? output.slice(0, 500) + "..." : output;
|
|
180
|
+
collectedCommands.push(`\`${cleanCmd}\` → exit ${exitCode}${truncOutput ? "\n```\n" + truncOutput + "\n```" : ""}`);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const activity = mapCodexEventToActivity(event);
|
|
184
|
+
if (activity && linearApi && agentSessionId) {
|
|
185
|
+
linearApi.emitActivity(agentSessionId, activity).catch((err) => {
|
|
186
|
+
api.logger.warn(`Failed to emit Codex activity: ${err}`);
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
child.stderr?.on("data", (chunk) => {
|
|
192
|
+
stderrOutput += chunk.toString();
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
child.on("close", (code) => {
|
|
196
|
+
clearTimeout(timer);
|
|
197
|
+
rl.close();
|
|
198
|
+
|
|
199
|
+
const parts: string[] = [];
|
|
200
|
+
if (collectedMessages.length > 0) parts.push(collectedMessages.join("\n\n"));
|
|
201
|
+
if (collectedCommands.length > 0) parts.push(collectedCommands.join("\n\n"));
|
|
202
|
+
const output = parts.join("\n\n") || stderrOutput || "(no output)";
|
|
203
|
+
|
|
204
|
+
if (killed) {
|
|
205
|
+
api.logger.warn(`Codex timed out after ${timeout}ms`);
|
|
206
|
+
resolve({
|
|
207
|
+
success: false,
|
|
208
|
+
output: `Codex timed out after ${Math.round(timeout / 1000)}s. Partial output:\n${output}`,
|
|
209
|
+
error: "timeout",
|
|
210
|
+
});
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (code !== 0) {
|
|
215
|
+
api.logger.warn(`Codex exited with code ${code}`);
|
|
216
|
+
resolve({
|
|
217
|
+
success: false,
|
|
218
|
+
output: `Codex failed (exit ${code}):\n${output}`,
|
|
219
|
+
error: `exit ${code}`,
|
|
220
|
+
});
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
api.logger.info(`Codex completed successfully`);
|
|
225
|
+
resolve({ success: true, output });
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
child.on("error", (err) => {
|
|
229
|
+
clearTimeout(timer);
|
|
230
|
+
rl.close();
|
|
231
|
+
api.logger.error(`Codex spawn error: ${err}`);
|
|
232
|
+
resolve({
|
|
233
|
+
success: false,
|
|
234
|
+
output: `Failed to start Codex: ${err.message}`,
|
|
235
|
+
error: err.message,
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
|
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { existsSync, statSync, readdirSync, mkdirSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
|
|
6
|
+
const DEFAULT_BASE_REPO = "/home/claw/ai-workspace";
|
|
7
|
+
const DEFAULT_WORKTREE_BASE_DIR = path.join(homedir(), ".openclaw", "worktrees");
|
|
8
|
+
|
|
9
|
+
export interface WorktreeInfo {
|
|
10
|
+
path: string;
|
|
11
|
+
branch: string;
|
|
12
|
+
/** True if the worktree already existed and was resumed, not freshly created. */
|
|
13
|
+
resumed: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface WorktreeStatus {
|
|
17
|
+
filesChanged: string[];
|
|
18
|
+
hasUncommitted: boolean;
|
|
19
|
+
lastCommit: string | null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface WorktreeOptions {
|
|
23
|
+
/** Base git repo to create worktrees from. Default: /home/claw/ai-workspace */
|
|
24
|
+
baseRepo?: string;
|
|
25
|
+
/** Directory under which worktrees are created. Default: ~/.openclaw/worktrees */
|
|
26
|
+
baseDir?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function resolveBaseDir(baseDir?: string): string {
|
|
30
|
+
if (!baseDir) return DEFAULT_WORKTREE_BASE_DIR;
|
|
31
|
+
if (baseDir.startsWith("~/")) return baseDir.replace("~", homedir());
|
|
32
|
+
return baseDir;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function git(args: string[], cwd: string): string {
|
|
36
|
+
return execFileSync("git", args, {
|
|
37
|
+
cwd,
|
|
38
|
+
encoding: "utf8",
|
|
39
|
+
timeout: 30_000,
|
|
40
|
+
}).trim();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function gitLong(args: string[], cwd: string, timeout = 120_000): string {
|
|
44
|
+
return execFileSync("git", args, {
|
|
45
|
+
cwd,
|
|
46
|
+
encoding: "utf8",
|
|
47
|
+
timeout,
|
|
48
|
+
}).trim();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Create a git worktree for isolated work on a Linear issue.
|
|
53
|
+
*
|
|
54
|
+
* Path: {baseDir}/{issueIdentifier}/ — deterministic, persistent.
|
|
55
|
+
* Branch: codex/{issueIdentifier}
|
|
56
|
+
*
|
|
57
|
+
* Idempotent: if the worktree already exists, returns it without recreating.
|
|
58
|
+
* If the branch exists but the worktree is gone, recreates the worktree from
|
|
59
|
+
* the existing branch (resume scenario).
|
|
60
|
+
*/
|
|
61
|
+
export function createWorktree(
|
|
62
|
+
issueIdentifier: string,
|
|
63
|
+
opts?: WorktreeOptions,
|
|
64
|
+
): WorktreeInfo {
|
|
65
|
+
const repo = opts?.baseRepo ?? DEFAULT_BASE_REPO;
|
|
66
|
+
const baseDir = resolveBaseDir(opts?.baseDir);
|
|
67
|
+
|
|
68
|
+
if (!existsSync(repo)) {
|
|
69
|
+
throw new Error(`Base repo not found: ${repo}`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Ensure base directory exists
|
|
73
|
+
if (!existsSync(baseDir)) {
|
|
74
|
+
mkdirSync(baseDir, { recursive: true });
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const branch = `codex/${issueIdentifier}`;
|
|
78
|
+
const worktreePath = path.join(baseDir, issueIdentifier);
|
|
79
|
+
|
|
80
|
+
// Fetch latest from origin (best effort) — do this early so both
|
|
81
|
+
// resume and fresh paths have up-to-date refs.
|
|
82
|
+
try {
|
|
83
|
+
git(["fetch", "origin"], repo);
|
|
84
|
+
} catch {
|
|
85
|
+
// Offline or no remote — continue with local state
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Idempotent: if worktree already exists, return it
|
|
89
|
+
if (existsSync(worktreePath)) {
|
|
90
|
+
try {
|
|
91
|
+
// Verify it's a valid git worktree
|
|
92
|
+
git(["rev-parse", "--git-dir"], worktreePath);
|
|
93
|
+
return { path: worktreePath, branch, resumed: true };
|
|
94
|
+
} catch {
|
|
95
|
+
// Directory exists but isn't a valid worktree — remove and recreate
|
|
96
|
+
try {
|
|
97
|
+
git(["worktree", "remove", "--force", worktreePath], repo);
|
|
98
|
+
} catch { /* best effort */ }
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Check if branch already exists (resume scenario)
|
|
103
|
+
const branchExists = branchExistsInRepo(branch, repo);
|
|
104
|
+
|
|
105
|
+
if (branchExists) {
|
|
106
|
+
// Recreate worktree from existing branch — preserves previous work
|
|
107
|
+
git(["worktree", "add", worktreePath, branch], repo);
|
|
108
|
+
return { path: worktreePath, branch, resumed: true };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Fresh start: new branch off HEAD
|
|
112
|
+
git(["worktree", "add", "-b", branch, worktreePath], repo);
|
|
113
|
+
return { path: worktreePath, branch, resumed: false };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Check if a branch exists in the repo.
|
|
118
|
+
*/
|
|
119
|
+
function branchExistsInRepo(branch: string, repo: string): boolean {
|
|
120
|
+
try {
|
|
121
|
+
const result = git(["branch", "--list", branch], repo);
|
|
122
|
+
return result.trim().length > 0;
|
|
123
|
+
} catch {
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Get the status of a worktree: changed files, uncommitted work, last commit.
|
|
130
|
+
*/
|
|
131
|
+
export function getWorktreeStatus(worktreePath: string): WorktreeStatus {
|
|
132
|
+
if (!existsSync(worktreePath)) {
|
|
133
|
+
return { filesChanged: [], hasUncommitted: false, lastCommit: null };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const diffOutput = git(
|
|
137
|
+
["diff", "--name-only", "HEAD"],
|
|
138
|
+
worktreePath,
|
|
139
|
+
);
|
|
140
|
+
const stagedOutput = git(
|
|
141
|
+
["diff", "--name-only", "--cached"],
|
|
142
|
+
worktreePath,
|
|
143
|
+
);
|
|
144
|
+
const untrackedOutput = git(
|
|
145
|
+
["ls-files", "--others", "--exclude-standard"],
|
|
146
|
+
worktreePath,
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
const allFiles = new Set<string>();
|
|
150
|
+
for (const line of [...diffOutput.split("\n"), ...stagedOutput.split("\n"), ...untrackedOutput.split("\n")]) {
|
|
151
|
+
const trimmed = line.trim();
|
|
152
|
+
if (trimmed) allFiles.add(trimmed);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const hasUncommitted = allFiles.size > 0;
|
|
156
|
+
|
|
157
|
+
let lastCommit: string | null = null;
|
|
158
|
+
try {
|
|
159
|
+
lastCommit = git(["log", "-1", "--oneline"], worktreePath);
|
|
160
|
+
} catch {
|
|
161
|
+
// No commits yet
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return {
|
|
165
|
+
filesChanged: [...allFiles],
|
|
166
|
+
hasUncommitted,
|
|
167
|
+
lastCommit,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Remove a worktree and optionally delete its branch.
|
|
173
|
+
*/
|
|
174
|
+
export function removeWorktree(
|
|
175
|
+
worktreePath: string,
|
|
176
|
+
opts?: { deleteBranch?: boolean; baseRepo?: string },
|
|
177
|
+
): void {
|
|
178
|
+
const repo = opts?.baseRepo ?? DEFAULT_BASE_REPO;
|
|
179
|
+
|
|
180
|
+
if (existsSync(worktreePath)) {
|
|
181
|
+
git(["worktree", "remove", "--force", worktreePath], repo);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (opts?.deleteBranch) {
|
|
185
|
+
// Extract issue identifier from worktree path to find matching branch
|
|
186
|
+
const dirName = path.basename(worktreePath);
|
|
187
|
+
const branch = `codex/${dirName}`;
|
|
188
|
+
try {
|
|
189
|
+
git(["branch", "-D", branch], repo);
|
|
190
|
+
} catch {
|
|
191
|
+
// Branch doesn't exist or already deleted
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Push the worktree branch and create a GitHub PR via `gh`.
|
|
198
|
+
*/
|
|
199
|
+
export function createPullRequest(
|
|
200
|
+
worktreePath: string,
|
|
201
|
+
title: string,
|
|
202
|
+
body: string,
|
|
203
|
+
): { prUrl: string } {
|
|
204
|
+
// Commit any uncommitted changes first
|
|
205
|
+
const status = getWorktreeStatus(worktreePath);
|
|
206
|
+
if (status.hasUncommitted) {
|
|
207
|
+
git(["add", "-A"], worktreePath);
|
|
208
|
+
git(
|
|
209
|
+
[
|
|
210
|
+
"-c", "user.name=claw",
|
|
211
|
+
"-c", "user.email=claw@calltelemetry.com",
|
|
212
|
+
"commit", "-m", title,
|
|
213
|
+
],
|
|
214
|
+
worktreePath,
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Get branch name
|
|
219
|
+
const branch = git(["rev-parse", "--abbrev-ref", "HEAD"], worktreePath);
|
|
220
|
+
|
|
221
|
+
// Push branch
|
|
222
|
+
git(["push", "-u", "origin", branch], worktreePath);
|
|
223
|
+
|
|
224
|
+
// Create PR via gh CLI
|
|
225
|
+
const prUrl = execFileSync(
|
|
226
|
+
"gh",
|
|
227
|
+
["pr", "create", "--title", title, "--body", body, "--head", branch],
|
|
228
|
+
{ cwd: worktreePath, encoding: "utf8", timeout: 30_000 },
|
|
229
|
+
).trim();
|
|
230
|
+
|
|
231
|
+
return { prUrl };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export interface WorktreeEntry {
|
|
235
|
+
path: string;
|
|
236
|
+
branch: string;
|
|
237
|
+
issueIdentifier: string;
|
|
238
|
+
ageMs: number;
|
|
239
|
+
hasChanges: boolean;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* List all worktrees in the configured base directory.
|
|
244
|
+
*/
|
|
245
|
+
export function listWorktrees(opts?: WorktreeOptions): WorktreeEntry[] {
|
|
246
|
+
const baseDir = resolveBaseDir(opts?.baseDir);
|
|
247
|
+
const entries: WorktreeEntry[] = [];
|
|
248
|
+
|
|
249
|
+
if (!existsSync(baseDir)) return [];
|
|
250
|
+
|
|
251
|
+
let dirs: string[];
|
|
252
|
+
try {
|
|
253
|
+
dirs = readdirSync(baseDir).map((d) => path.join(baseDir, d));
|
|
254
|
+
} catch {
|
|
255
|
+
return [];
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
for (const dir of dirs) {
|
|
259
|
+
if (!existsSync(dir)) continue;
|
|
260
|
+
try {
|
|
261
|
+
const stat = statSync(dir);
|
|
262
|
+
if (!stat.isDirectory()) continue;
|
|
263
|
+
|
|
264
|
+
// Verify it's a git worktree
|
|
265
|
+
try {
|
|
266
|
+
git(["rev-parse", "--git-dir"], dir);
|
|
267
|
+
} catch {
|
|
268
|
+
continue; // Not a git worktree
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
let branch = "unknown";
|
|
272
|
+
try {
|
|
273
|
+
branch = git(["rev-parse", "--abbrev-ref", "HEAD"], dir);
|
|
274
|
+
} catch {}
|
|
275
|
+
|
|
276
|
+
let hasChanges = false;
|
|
277
|
+
try {
|
|
278
|
+
const status = getWorktreeStatus(dir);
|
|
279
|
+
hasChanges = status.hasUncommitted;
|
|
280
|
+
} catch {}
|
|
281
|
+
|
|
282
|
+
entries.push({
|
|
283
|
+
path: dir,
|
|
284
|
+
branch,
|
|
285
|
+
issueIdentifier: path.basename(dir),
|
|
286
|
+
ageMs: Date.now() - stat.mtimeMs,
|
|
287
|
+
hasChanges,
|
|
288
|
+
});
|
|
289
|
+
} catch {
|
|
290
|
+
// Skip unreadable dirs
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
return entries.sort((a, b) => b.ageMs - a.ageMs);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
export interface PrepareResult {
|
|
298
|
+
pulled: boolean;
|
|
299
|
+
pullOutput?: string;
|
|
300
|
+
submodulesInitialized: boolean;
|
|
301
|
+
submoduleOutput?: string;
|
|
302
|
+
errors: string[];
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Prepare a worktree for a code run:
|
|
307
|
+
* 1. Pull latest from origin for the issue branch (fast-forward only)
|
|
308
|
+
* 2. Initialize and update all git submodules recursively
|
|
309
|
+
*
|
|
310
|
+
* Safe to call on every run — idempotent. Failures are non-fatal;
|
|
311
|
+
* the code run proceeds even if pull or submodule init fails.
|
|
312
|
+
*/
|
|
313
|
+
export function prepareWorkspace(worktreePath: string, branch: string): PrepareResult {
|
|
314
|
+
const errors: string[] = [];
|
|
315
|
+
let pulled = false;
|
|
316
|
+
let pullOutput: string | undefined;
|
|
317
|
+
let submodulesInitialized = false;
|
|
318
|
+
let submoduleOutput: string | undefined;
|
|
319
|
+
|
|
320
|
+
// 1. Pull latest from origin (ff-only to avoid merge conflicts)
|
|
321
|
+
try {
|
|
322
|
+
// Check if remote branch exists before pulling
|
|
323
|
+
const remoteBranch = `origin/${branch}`;
|
|
324
|
+
try {
|
|
325
|
+
git(["rev-parse", "--verify", remoteBranch], worktreePath);
|
|
326
|
+
// Remote branch exists — pull latest
|
|
327
|
+
pullOutput = git(["pull", "--ff-only", "origin", branch], worktreePath);
|
|
328
|
+
pulled = true;
|
|
329
|
+
} catch {
|
|
330
|
+
// Remote branch doesn't exist yet (fresh issue branch) — nothing to pull
|
|
331
|
+
pullOutput = "remote branch not found, skipping pull";
|
|
332
|
+
}
|
|
333
|
+
} catch (err) {
|
|
334
|
+
const msg = `pull failed: ${err}`;
|
|
335
|
+
errors.push(msg);
|
|
336
|
+
pullOutput = msg;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// 2. Initialize and update all submodules recursively
|
|
340
|
+
try {
|
|
341
|
+
submoduleOutput = gitLong(
|
|
342
|
+
["submodule", "update", "--init", "--recursive"],
|
|
343
|
+
worktreePath,
|
|
344
|
+
120_000, // submodule clone can take a while
|
|
345
|
+
);
|
|
346
|
+
submodulesInitialized = true;
|
|
347
|
+
} catch (err) {
|
|
348
|
+
const msg = `submodule init failed: ${err}`;
|
|
349
|
+
errors.push(msg);
|
|
350
|
+
submoduleOutput = msg;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
return { pulled, pullOutput, submodulesInitialized, submoduleOutput, errors };
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Remove worktrees older than maxAgeMs.
|
|
358
|
+
* Returns list of removed paths.
|
|
359
|
+
*/
|
|
360
|
+
export function pruneStaleWorktrees(
|
|
361
|
+
maxAgeMs: number = 24 * 60 * 60_000,
|
|
362
|
+
opts?: WorktreeOptions & { dryRun?: boolean },
|
|
363
|
+
): { removed: string[]; skipped: string[]; errors: string[] } {
|
|
364
|
+
const worktrees = listWorktrees(opts);
|
|
365
|
+
const repo = opts?.baseRepo ?? DEFAULT_BASE_REPO;
|
|
366
|
+
const removed: string[] = [];
|
|
367
|
+
const skipped: string[] = [];
|
|
368
|
+
const errors: string[] = [];
|
|
369
|
+
|
|
370
|
+
for (const wt of worktrees) {
|
|
371
|
+
if (wt.ageMs < maxAgeMs) {
|
|
372
|
+
skipped.push(wt.path);
|
|
373
|
+
continue;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
if (opts?.dryRun) {
|
|
377
|
+
removed.push(wt.path);
|
|
378
|
+
continue;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
try {
|
|
382
|
+
removeWorktree(wt.path, { deleteBranch: true, baseRepo: repo });
|
|
383
|
+
removed.push(wt.path);
|
|
384
|
+
} catch (err) {
|
|
385
|
+
errors.push(`${wt.path}: ${err}`);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
return { removed, skipped, errors };
|
|
390
|
+
}
|