@tarcisiopgs/lisa 0.9.2 → 0.9.3
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/dist/index.js +1274 -299
- package/package.json +23 -5
package/dist/index.js
CHANGED
|
@@ -2,16 +2,21 @@
|
|
|
2
2
|
|
|
3
3
|
// src/cli.ts
|
|
4
4
|
import { execSync as execSync4 } from "child_process";
|
|
5
|
-
import { existsSync as
|
|
6
|
-
import { join as
|
|
7
|
-
import { defineCommand, runMain } from "citty";
|
|
5
|
+
import { existsSync as existsSync6, readdirSync, readFileSync as readFileSync6 } from "fs";
|
|
6
|
+
import { join as join8, resolve as resolvePath } from "path";
|
|
8
7
|
import * as clack from "@clack/prompts";
|
|
8
|
+
import { defineCommand, runMain } from "citty";
|
|
9
9
|
import pc2 from "picocolors";
|
|
10
10
|
|
|
11
11
|
// src/config.ts
|
|
12
12
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
13
13
|
import { resolve } from "path";
|
|
14
14
|
import { parse, stringify } from "yaml";
|
|
15
|
+
var DEFAULT_OVERSEER_CONFIG = {
|
|
16
|
+
enabled: false,
|
|
17
|
+
check_interval: 30,
|
|
18
|
+
stuck_threshold: 300
|
|
19
|
+
};
|
|
15
20
|
var CONFIG_DIR = ".lisa";
|
|
16
21
|
var CONFIG_FILE = "config.yaml";
|
|
17
22
|
var DEFAULT_CONFIG = {
|
|
@@ -37,7 +42,8 @@ var DEFAULT_CONFIG = {
|
|
|
37
42
|
logs: {
|
|
38
43
|
dir: "",
|
|
39
44
|
format: ""
|
|
40
|
-
}
|
|
45
|
+
},
|
|
46
|
+
overseer: { ...DEFAULT_OVERSEER_CONFIG }
|
|
41
47
|
};
|
|
42
48
|
function getConfigPath(cwd = process.cwd()) {
|
|
43
49
|
return resolve(cwd, CONFIG_DIR, CONFIG_FILE);
|
|
@@ -69,12 +75,19 @@ function loadConfig(cwd = process.cwd()) {
|
|
|
69
75
|
...parsed,
|
|
70
76
|
source_config: sourceConfig,
|
|
71
77
|
loop: { ...DEFAULT_CONFIG.loop, ...parsed.loop ?? {} },
|
|
72
|
-
logs: { ...DEFAULT_CONFIG.logs, ...parsed.logs ?? {} }
|
|
78
|
+
logs: { ...DEFAULT_CONFIG.logs, ...parsed.logs ?? {} },
|
|
79
|
+
overseer: {
|
|
80
|
+
...DEFAULT_OVERSEER_CONFIG,
|
|
81
|
+
...parsed.overseer ?? {}
|
|
82
|
+
}
|
|
73
83
|
};
|
|
74
84
|
if (!config2.base_branch) config2.base_branch = "main";
|
|
75
85
|
for (const repo of config2.repos) {
|
|
76
86
|
if (!repo.base_branch) repo.base_branch = config2.base_branch;
|
|
77
87
|
}
|
|
88
|
+
if (!config2.models && config2.provider) {
|
|
89
|
+
config2.models = [config2.provider];
|
|
90
|
+
}
|
|
78
91
|
return config2;
|
|
79
92
|
}
|
|
80
93
|
function saveConfig(config2, cwd = process.cwd()) {
|
|
@@ -84,7 +97,20 @@ function saveConfig(config2, cwd = process.cwd()) {
|
|
|
84
97
|
mkdirSync(dir, { recursive: true });
|
|
85
98
|
}
|
|
86
99
|
const sc = config2.source_config;
|
|
87
|
-
const sourceYaml = config2.source === "trello" ? {
|
|
100
|
+
const sourceYaml = config2.source === "trello" ? {
|
|
101
|
+
board: sc.team,
|
|
102
|
+
pick_from: sc.pick_from || sc.project,
|
|
103
|
+
label: sc.label,
|
|
104
|
+
in_progress: sc.in_progress,
|
|
105
|
+
done: sc.done
|
|
106
|
+
} : {
|
|
107
|
+
team: sc.team,
|
|
108
|
+
project: sc.project,
|
|
109
|
+
label: sc.label,
|
|
110
|
+
pick_from: sc.pick_from,
|
|
111
|
+
in_progress: sc.in_progress,
|
|
112
|
+
done: sc.done
|
|
113
|
+
};
|
|
88
114
|
const output = { ...config2, source_config: sourceYaml };
|
|
89
115
|
writeFileSync(configPath, stringify(output), "utf-8");
|
|
90
116
|
}
|
|
@@ -97,6 +123,101 @@ function mergeWithFlags(config2, flags) {
|
|
|
97
123
|
return merged;
|
|
98
124
|
}
|
|
99
125
|
|
|
126
|
+
// src/github.ts
|
|
127
|
+
import { execa } from "execa";
|
|
128
|
+
var API_URL = "https://api.github.com";
|
|
129
|
+
var REQUEST_TIMEOUT_MS = 3e4;
|
|
130
|
+
async function isGhCliAvailable() {
|
|
131
|
+
try {
|
|
132
|
+
await execa("gh", ["auth", "status"]);
|
|
133
|
+
return true;
|
|
134
|
+
} catch {
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
function getToken() {
|
|
139
|
+
const token = process.env.GITHUB_TOKEN;
|
|
140
|
+
if (!token) throw new Error("GITHUB_TOKEN is not set");
|
|
141
|
+
return token;
|
|
142
|
+
}
|
|
143
|
+
async function createPullRequest(opts, method = "cli") {
|
|
144
|
+
if (method === "cli" && await isGhCliAvailable()) {
|
|
145
|
+
return createPullRequestWithGhCli(opts);
|
|
146
|
+
}
|
|
147
|
+
const res = await fetch(`${API_URL}/repos/${opts.owner}/${opts.repo}/pulls`, {
|
|
148
|
+
method: "POST",
|
|
149
|
+
headers: {
|
|
150
|
+
Authorization: `Bearer ${getToken()}`,
|
|
151
|
+
Accept: "application/vnd.github+json",
|
|
152
|
+
"Content-Type": "application/json"
|
|
153
|
+
},
|
|
154
|
+
body: JSON.stringify({
|
|
155
|
+
title: opts.title,
|
|
156
|
+
body: opts.body,
|
|
157
|
+
head: opts.head,
|
|
158
|
+
base: opts.base
|
|
159
|
+
}),
|
|
160
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
|
|
161
|
+
});
|
|
162
|
+
if (!res.ok) {
|
|
163
|
+
const text2 = await res.text();
|
|
164
|
+
throw new Error(`GitHub API error (${res.status}): ${text2}`);
|
|
165
|
+
}
|
|
166
|
+
const data = await res.json();
|
|
167
|
+
return { number: data.number, html_url: data.html_url };
|
|
168
|
+
}
|
|
169
|
+
async function createPullRequestWithGhCli(opts) {
|
|
170
|
+
const result = await execa("gh", [
|
|
171
|
+
"pr",
|
|
172
|
+
"create",
|
|
173
|
+
"--repo",
|
|
174
|
+
`${opts.owner}/${opts.repo}`,
|
|
175
|
+
"--head",
|
|
176
|
+
opts.head,
|
|
177
|
+
"--base",
|
|
178
|
+
opts.base,
|
|
179
|
+
"--title",
|
|
180
|
+
opts.title,
|
|
181
|
+
"--body",
|
|
182
|
+
opts.body
|
|
183
|
+
]);
|
|
184
|
+
const url = result.stdout.trim();
|
|
185
|
+
const prNumberMatch = url.match(/\/pull\/(\d+)/);
|
|
186
|
+
const number = prNumberMatch ? Number.parseInt(prNumberMatch[1] ?? "0", 10) : 0;
|
|
187
|
+
return { number, html_url: url };
|
|
188
|
+
}
|
|
189
|
+
async function getRepoInfo(cwd) {
|
|
190
|
+
const { stdout: remoteUrl } = await execa("git", ["remote", "get-url", "origin"], { cwd });
|
|
191
|
+
let owner;
|
|
192
|
+
let repo;
|
|
193
|
+
const sshMatch = remoteUrl.match(/git@github\.com:(.+?)\/(.+?)(?:\.git)?$/);
|
|
194
|
+
const httpsMatch = remoteUrl.match(/github\.com\/(.+?)\/(.+?)(?:\.git)?$/);
|
|
195
|
+
if (sshMatch) {
|
|
196
|
+
owner = sshMatch[1] ?? "";
|
|
197
|
+
repo = sshMatch[2] ?? "";
|
|
198
|
+
} else if (httpsMatch) {
|
|
199
|
+
owner = httpsMatch[1] ?? "";
|
|
200
|
+
repo = httpsMatch[2] ?? "";
|
|
201
|
+
} else {
|
|
202
|
+
throw new Error(`Cannot parse GitHub owner/repo from remote URL: ${remoteUrl}`);
|
|
203
|
+
}
|
|
204
|
+
const { stdout: branch } = await execa("git", ["branch", "--show-current"], { cwd });
|
|
205
|
+
const { stdout: defaultBranch } = await execa(
|
|
206
|
+
"git",
|
|
207
|
+
["symbolic-ref", "refs/remotes/origin/HEAD", "--short"],
|
|
208
|
+
{ cwd, reject: false }
|
|
209
|
+
).then(
|
|
210
|
+
(r) => r,
|
|
211
|
+
() => ({ stdout: "origin/main" })
|
|
212
|
+
);
|
|
213
|
+
return {
|
|
214
|
+
owner,
|
|
215
|
+
repo,
|
|
216
|
+
branch: branch.trim(),
|
|
217
|
+
defaultBranch: defaultBranch.replace("origin/", "").trim()
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
100
221
|
// src/logger.ts
|
|
101
222
|
import { appendFileSync, existsSync as existsSync2, mkdirSync as mkdirSync2, writeFileSync as writeFileSync2 } from "fs";
|
|
102
223
|
import { dirname } from "path";
|
|
@@ -131,26 +252,38 @@ function emitJson(level, message) {
|
|
|
131
252
|
console.log(JSON.stringify(event));
|
|
132
253
|
}
|
|
133
254
|
function log(message) {
|
|
134
|
-
if (outputMode === "json")
|
|
255
|
+
if (outputMode === "json") {
|
|
256
|
+
emitJson("info", message);
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
135
259
|
if (outputMode !== "quiet") {
|
|
136
260
|
console.log(`${pc.cyan("[lisa]")} ${pc.dim(timestamp())} ${message}`);
|
|
137
261
|
}
|
|
138
262
|
writeToFile("info", message);
|
|
139
263
|
}
|
|
140
264
|
function warn(message) {
|
|
141
|
-
if (outputMode === "json")
|
|
265
|
+
if (outputMode === "json") {
|
|
266
|
+
emitJson("warn", message);
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
142
269
|
if (outputMode !== "quiet") {
|
|
143
270
|
console.error(`${pc.yellow("[lisa]")} ${pc.dim(timestamp())} ${message}`);
|
|
144
271
|
}
|
|
145
272
|
writeToFile("warn", message);
|
|
146
273
|
}
|
|
147
274
|
function error(message) {
|
|
148
|
-
if (outputMode === "json")
|
|
275
|
+
if (outputMode === "json") {
|
|
276
|
+
emitJson("error", message);
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
149
279
|
console.error(`${pc.red("[lisa]")} ${pc.dim(timestamp())} ${message}`);
|
|
150
280
|
writeToFile("error", message);
|
|
151
281
|
}
|
|
152
282
|
function ok(message) {
|
|
153
|
-
if (outputMode === "json")
|
|
283
|
+
if (outputMode === "json") {
|
|
284
|
+
emitJson("ok", message);
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
154
287
|
if (outputMode !== "quiet") {
|
|
155
288
|
console.log(`${pc.green("[lisa]")} ${pc.dim(timestamp())} ${message}`);
|
|
156
289
|
}
|
|
@@ -188,7 +321,7 @@ function banner() {
|
|
|
188
321
|
@%@+=#@==@#:+@##@
|
|
189
322
|
@@@@%%@##%
|
|
190
323
|
`;
|
|
191
|
-
const title = "
|
|
324
|
+
const title = " Lisa \u2014 deterministic autonomous issue resolver ";
|
|
192
325
|
const border = "\u2500".repeat(title.length);
|
|
193
326
|
console.log(pc.yellow(art));
|
|
194
327
|
console.log(pc.cyan(` \u250C${border}\u2510`));
|
|
@@ -198,18 +331,232 @@ function banner() {
|
|
|
198
331
|
}
|
|
199
332
|
|
|
200
333
|
// src/loop.ts
|
|
201
|
-
import {
|
|
202
|
-
import {
|
|
334
|
+
import { appendFileSync as appendFileSync6, readFileSync as readFileSync5, unlinkSync as unlinkSync4 } from "fs";
|
|
335
|
+
import { join as join7, resolve as resolve5 } from "path";
|
|
336
|
+
import { execa as execa3 } from "execa";
|
|
203
337
|
|
|
204
|
-
// src/
|
|
338
|
+
// src/lifecycle.ts
|
|
339
|
+
import { spawn } from "child_process";
|
|
340
|
+
import { createConnection } from "net";
|
|
205
341
|
import { resolve as resolve2 } from "path";
|
|
206
|
-
|
|
342
|
+
var managedResources = [];
|
|
343
|
+
var cleanupRegistered = false;
|
|
344
|
+
function isPortInUse(port) {
|
|
345
|
+
return new Promise((resolve6) => {
|
|
346
|
+
const socket = createConnection({ port }, () => {
|
|
347
|
+
socket.destroy();
|
|
348
|
+
resolve6(true);
|
|
349
|
+
});
|
|
350
|
+
socket.on("error", () => {
|
|
351
|
+
socket.destroy();
|
|
352
|
+
resolve6(false);
|
|
353
|
+
});
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
function waitForPort(port, timeoutMs) {
|
|
357
|
+
return new Promise((resolve6) => {
|
|
358
|
+
const deadline = Date.now() + timeoutMs;
|
|
359
|
+
const check = () => {
|
|
360
|
+
if (Date.now() > deadline) {
|
|
361
|
+
resolve6(false);
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
isPortInUse(port).then((inUse) => {
|
|
365
|
+
if (inUse) {
|
|
366
|
+
resolve6(true);
|
|
367
|
+
} else {
|
|
368
|
+
setTimeout(check, 500);
|
|
369
|
+
}
|
|
370
|
+
});
|
|
371
|
+
};
|
|
372
|
+
check();
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
function spawnResource(config2, baseCwd) {
|
|
376
|
+
const cwd = config2.cwd ? resolve2(baseCwd, config2.cwd) : baseCwd;
|
|
377
|
+
const child = spawn("sh", ["-c", config2.up], {
|
|
378
|
+
cwd,
|
|
379
|
+
stdio: "ignore",
|
|
380
|
+
detached: true
|
|
381
|
+
});
|
|
382
|
+
child.unref();
|
|
383
|
+
return child;
|
|
384
|
+
}
|
|
385
|
+
function runSetupCommand(command, cwd) {
|
|
386
|
+
return new Promise((resolve6, reject) => {
|
|
387
|
+
const child = spawn("sh", ["-c", command], {
|
|
388
|
+
cwd,
|
|
389
|
+
stdio: "inherit"
|
|
390
|
+
});
|
|
391
|
+
child.on("close", (code) => {
|
|
392
|
+
if (code === 0) {
|
|
393
|
+
resolve6();
|
|
394
|
+
} else {
|
|
395
|
+
reject(new Error(`Setup command failed with exit code ${code}: ${command}`));
|
|
396
|
+
}
|
|
397
|
+
});
|
|
398
|
+
child.on("error", (err) => {
|
|
399
|
+
reject(new Error(`Setup command error: ${err.message}`));
|
|
400
|
+
});
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
async function startResources(repo, baseCwd) {
|
|
404
|
+
const lifecycle = repo.lifecycle;
|
|
405
|
+
if (!lifecycle) return true;
|
|
406
|
+
registerCleanup();
|
|
407
|
+
for (const resource of lifecycle.resources) {
|
|
408
|
+
const alreadyRunning = await isPortInUse(resource.check_port);
|
|
409
|
+
if (alreadyRunning) {
|
|
410
|
+
ok(`Resource "${resource.name}" already running on port ${resource.check_port}`);
|
|
411
|
+
continue;
|
|
412
|
+
}
|
|
413
|
+
log(`Starting resource "${resource.name}" on port ${resource.check_port}...`);
|
|
414
|
+
const child = spawnResource(resource, baseCwd);
|
|
415
|
+
managedResources.push({
|
|
416
|
+
name: resource.name,
|
|
417
|
+
config: resource,
|
|
418
|
+
process: child
|
|
419
|
+
});
|
|
420
|
+
const timeoutMs = (resource.startup_timeout || 30) * 1e3;
|
|
421
|
+
const ready = await waitForPort(resource.check_port, timeoutMs);
|
|
422
|
+
if (!ready) {
|
|
423
|
+
error(
|
|
424
|
+
`Resource "${resource.name}" failed to start within ${resource.startup_timeout}s`
|
|
425
|
+
);
|
|
426
|
+
await stopResources();
|
|
427
|
+
return false;
|
|
428
|
+
}
|
|
429
|
+
ok(`Resource "${resource.name}" is ready on port ${resource.check_port}`);
|
|
430
|
+
}
|
|
431
|
+
for (const command of lifecycle.setup) {
|
|
432
|
+
log(`Running setup: ${command}`);
|
|
433
|
+
try {
|
|
434
|
+
await runSetupCommand(command, baseCwd);
|
|
435
|
+
ok(`Setup complete: ${command}`);
|
|
436
|
+
} catch (err) {
|
|
437
|
+
error(`Setup failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
438
|
+
await stopResources();
|
|
439
|
+
return false;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
return true;
|
|
443
|
+
}
|
|
444
|
+
async function stopResources() {
|
|
445
|
+
for (const managed of managedResources) {
|
|
446
|
+
const { name, config: config2, process: child } = managed;
|
|
447
|
+
log(`Stopping resource "${name}"...`);
|
|
448
|
+
try {
|
|
449
|
+
if (config2.down === "auto") {
|
|
450
|
+
if (child?.pid) {
|
|
451
|
+
try {
|
|
452
|
+
process.kill(-child.pid, "SIGTERM");
|
|
453
|
+
} catch {
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
} else {
|
|
457
|
+
await new Promise((resolve6) => {
|
|
458
|
+
const down = spawn("sh", ["-c", config2.down], {
|
|
459
|
+
stdio: "ignore"
|
|
460
|
+
});
|
|
461
|
+
down.on("close", () => resolve6());
|
|
462
|
+
down.on("error", () => resolve6());
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
ok(`Resource "${name}" stopped`);
|
|
466
|
+
} catch (err) {
|
|
467
|
+
warn(
|
|
468
|
+
`Failed to stop resource "${name}": ${err instanceof Error ? err.message : String(err)}`
|
|
469
|
+
);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
managedResources.length = 0;
|
|
473
|
+
}
|
|
474
|
+
function registerCleanup() {
|
|
475
|
+
if (cleanupRegistered) return;
|
|
476
|
+
cleanupRegistered = true;
|
|
477
|
+
const cleanup = () => {
|
|
478
|
+
for (const managed of managedResources) {
|
|
479
|
+
const { config: config2, process: child } = managed;
|
|
480
|
+
try {
|
|
481
|
+
if (config2.down === "auto") {
|
|
482
|
+
if (child?.pid) {
|
|
483
|
+
process.kill(-child.pid, "SIGTERM");
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
} catch {
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
};
|
|
490
|
+
process.on("exit", cleanup);
|
|
491
|
+
process.on("SIGINT", () => {
|
|
492
|
+
cleanup();
|
|
493
|
+
process.exit(130);
|
|
494
|
+
});
|
|
495
|
+
process.on("SIGTERM", () => {
|
|
496
|
+
cleanup();
|
|
497
|
+
process.exit(143);
|
|
498
|
+
});
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// src/prompt.ts
|
|
502
|
+
import { existsSync as existsSync3, readFileSync as readFileSync2 } from "fs";
|
|
503
|
+
import { join, resolve as resolve3 } from "path";
|
|
504
|
+
function detectTestRunner(cwd) {
|
|
505
|
+
const packageJsonPath = join(cwd, "package.json");
|
|
506
|
+
if (!existsSync3(packageJsonPath)) return null;
|
|
507
|
+
try {
|
|
508
|
+
const content = JSON.parse(readFileSync2(packageJsonPath, "utf-8"));
|
|
509
|
+
const deps = { ...content.dependencies, ...content.devDependencies };
|
|
510
|
+
if ("vitest" in deps) return "vitest";
|
|
511
|
+
if ("jest" in deps) return "jest";
|
|
512
|
+
return null;
|
|
513
|
+
} catch {
|
|
514
|
+
return null;
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
function buildImplementPrompt(issue, config2, testRunner) {
|
|
207
518
|
if (config2.workflow === "worktree") {
|
|
208
|
-
return buildWorktreePrompt(issue);
|
|
519
|
+
return buildWorktreePrompt(issue, testRunner);
|
|
209
520
|
}
|
|
210
|
-
return buildBranchPrompt(issue, config2);
|
|
521
|
+
return buildBranchPrompt(issue, config2, testRunner);
|
|
522
|
+
}
|
|
523
|
+
function buildTestInstructions(testRunner) {
|
|
524
|
+
if (!testRunner) return "";
|
|
525
|
+
return `
|
|
526
|
+
**MANDATORY \u2014 Unit Tests:**
|
|
527
|
+
This project uses **${testRunner}** as its test runner.
|
|
528
|
+
- You MUST write unit tests (\`*.test.ts\`) for every new file or module you create.
|
|
529
|
+
- Tests should cover the main functionality, edge cases, and error scenarios.
|
|
530
|
+
- Run \`npm run test\` and ensure ALL tests pass before committing.
|
|
531
|
+
- Do NOT skip writing tests \u2014 the PR will be blocked if tests are missing or failing.
|
|
532
|
+
`;
|
|
533
|
+
}
|
|
534
|
+
function buildReadmeInstructions() {
|
|
535
|
+
return `
|
|
536
|
+
**README.md Evaluation:**
|
|
537
|
+
After implementing, review the diff of all changed files and check if README.md needs updating.
|
|
538
|
+
|
|
539
|
+
Update README.md if the changes include:
|
|
540
|
+
- New or removed CLI commands or flags
|
|
541
|
+
- New or removed providers or sources
|
|
542
|
+
- Configuration schema changes (new fields, renamed fields, removed fields)
|
|
543
|
+
- Pipeline or workflow stage changes
|
|
544
|
+
- New or removed environment variables
|
|
545
|
+
- Architectural changes
|
|
546
|
+
|
|
547
|
+
Do NOT update README.md for:
|
|
548
|
+
- Internal refactors that don't change documented behavior
|
|
549
|
+
- Bug fixes that don't change documented behavior
|
|
550
|
+
- Test-only changes
|
|
551
|
+
- Logging or formatting changes
|
|
552
|
+
- Dependency updates
|
|
553
|
+
|
|
554
|
+
If an update is needed, keep the existing README style and structure. Include the README change in the same commit as the implementation.
|
|
555
|
+
`;
|
|
211
556
|
}
|
|
212
|
-
function buildWorktreePrompt(issue) {
|
|
557
|
+
function buildWorktreePrompt(issue, testRunner) {
|
|
558
|
+
const testBlock = buildTestInstructions(testRunner ?? null);
|
|
559
|
+
const readmeBlock = buildReadmeInstructions();
|
|
213
560
|
return `You are an autonomous implementation agent. Your job is to implement a single
|
|
214
561
|
issue, validate it, commit, and push the branch.
|
|
215
562
|
|
|
@@ -233,7 +580,7 @@ ${issue.description}
|
|
|
233
580
|
- Follow the implementation instructions exactly
|
|
234
581
|
- Verify each acceptance criteria (if present)
|
|
235
582
|
- Respect any stack or technical constraints (if present)
|
|
236
|
-
|
|
583
|
+
${testBlock}${readmeBlock}
|
|
237
584
|
2. **Validate**: Run the project's linter/typecheck/tests if available:
|
|
238
585
|
- Check \`package.json\` (or equivalent) for lint, typecheck, check, or test scripts.
|
|
239
586
|
- Run whichever validation scripts exist (e.g., \`npm run lint\`, \`npm run typecheck\`).
|
|
@@ -245,9 +592,14 @@ ${issue.description}
|
|
|
245
592
|
- All commit messages MUST be in English.
|
|
246
593
|
- Use conventional commits format: \`feat: ...\`, \`fix: ...\`, \`refactor: ...\`, \`chore: ...\`
|
|
247
594
|
|
|
595
|
+
4. **PR Metadata**: Before finishing, create a file named \`.pr-title\` at the repository root
|
|
596
|
+
containing a single line with the PR title in **English** using conventional commit format
|
|
597
|
+
(e.g., \`feat: add user authentication\`, \`fix: resolve null pointer in login flow\`).
|
|
598
|
+
This file is used by the caller to create the pull request. Do NOT commit this file.
|
|
599
|
+
|
|
248
600
|
## Rules
|
|
249
601
|
|
|
250
|
-
- **ALL git commits MUST be in English.**
|
|
602
|
+
- **ALL git commits, PR titles, and PR descriptions MUST be in English.**
|
|
251
603
|
- The issue description may be in any language \u2014 read it for context but write all code artifacts in English.
|
|
252
604
|
- Do NOT install new dependencies unless the issue explicitly requires it.
|
|
253
605
|
- If you get stuck or the issue is unclear, STOP and explain why.
|
|
@@ -256,10 +608,14 @@ ${issue.description}
|
|
|
256
608
|
- Do NOT create pull requests \u2014 the caller handles that.
|
|
257
609
|
- Do NOT update the issue tracker \u2014 the caller handles that.`;
|
|
258
610
|
}
|
|
259
|
-
function buildBranchPrompt(issue, config2) {
|
|
260
|
-
const workspace =
|
|
261
|
-
const repoEntries = config2.repos.map(
|
|
611
|
+
function buildBranchPrompt(issue, config2, testRunner) {
|
|
612
|
+
const workspace = resolve3(config2.workspace);
|
|
613
|
+
const repoEntries = config2.repos.map(
|
|
614
|
+
(r) => ` - If it says "Repo: ${r.name}" or title starts with "${r.match}" \u2192 \`${resolve3(workspace, r.path)}\` (base branch: \`${r.base_branch}\`)`
|
|
615
|
+
).join("\n");
|
|
262
616
|
const baseBranchInstruction = config2.repos.length > 0 ? "From the repo's base branch (listed above)" : `From \`${config2.base_branch}\``;
|
|
617
|
+
const testBlock = buildTestInstructions(testRunner ?? null);
|
|
618
|
+
const readmeBlock = buildReadmeInstructions();
|
|
263
619
|
return `You are an autonomous implementation agent. Your job is to implement a single
|
|
264
620
|
issue, validate it, commit, and push the branch.
|
|
265
621
|
|
|
@@ -287,7 +643,7 @@ ${repoEntries}
|
|
|
287
643
|
- Follow the implementation instructions exactly
|
|
288
644
|
- Verify each acceptance criteria (if present)
|
|
289
645
|
- Respect any stack or technical constraints (if present)
|
|
290
|
-
|
|
646
|
+
${testBlock}${readmeBlock}
|
|
291
647
|
4. **Validate**: Run the project's linter/typecheck/tests if available:
|
|
292
648
|
- Check \`package.json\` (or equivalent) for lint, typecheck, check, or test scripts.
|
|
293
649
|
- Run whichever validation scripts exist (e.g., \`npm run lint\`, \`npm run typecheck\`).
|
|
@@ -299,9 +655,14 @@ ${repoEntries}
|
|
|
299
655
|
- All commit messages MUST be in English.
|
|
300
656
|
- Use conventional commits format: \`feat: ...\`, \`fix: ...\`, \`refactor: ...\`, \`chore: ...\`
|
|
301
657
|
|
|
658
|
+
6. **PR Metadata**: Before finishing, create a file named \`.pr-title\` at the repository root
|
|
659
|
+
containing a single line with the PR title in **English** using conventional commit format
|
|
660
|
+
(e.g., \`feat: add user authentication\`, \`fix: resolve null pointer in login flow\`).
|
|
661
|
+
This file is used by the caller to create the pull request. Do NOT commit this file.
|
|
662
|
+
|
|
302
663
|
## Rules
|
|
303
664
|
|
|
304
|
-
- **ALL git commits, branch names MUST be in English.**
|
|
665
|
+
- **ALL git commits, branch names, PR titles, and PR descriptions MUST be in English.**
|
|
305
666
|
- The issue description may be in any language \u2014 read it for context but write all code artifacts in English.
|
|
306
667
|
- Do NOT modify files outside the target repo.
|
|
307
668
|
- Do NOT install new dependencies unless the issue explicitly requires it.
|
|
@@ -312,11 +673,175 @@ ${repoEntries}
|
|
|
312
673
|
- Do NOT update the issue tracker \u2014 the caller handles that.`;
|
|
313
674
|
}
|
|
314
675
|
|
|
676
|
+
// src/guardrails.ts
|
|
677
|
+
import { existsSync as existsSync4, mkdirSync as mkdirSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync3 } from "fs";
|
|
678
|
+
import { dirname as dirname2, join as join2 } from "path";
|
|
679
|
+
var GUARDRAILS_FILE = ".lisa/guardrails.md";
|
|
680
|
+
var MAX_ENTRIES = 20;
|
|
681
|
+
var CONTEXT_LINES = 20;
|
|
682
|
+
function guardrailsPath(dir) {
|
|
683
|
+
return join2(dir, GUARDRAILS_FILE);
|
|
684
|
+
}
|
|
685
|
+
function readGuardrails(dir) {
|
|
686
|
+
const path = guardrailsPath(dir);
|
|
687
|
+
if (!existsSync4(path)) return "";
|
|
688
|
+
try {
|
|
689
|
+
return readFileSync3(path, "utf-8");
|
|
690
|
+
} catch {
|
|
691
|
+
return "";
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
function buildGuardrailsSection(dir) {
|
|
695
|
+
const content = readGuardrails(dir);
|
|
696
|
+
if (!content.trim()) return "";
|
|
697
|
+
return `
|
|
698
|
+
## Guardrails \u2014 Avoid these known pitfalls
|
|
699
|
+
|
|
700
|
+
${content}
|
|
701
|
+
`;
|
|
702
|
+
}
|
|
703
|
+
function extractContext(output) {
|
|
704
|
+
const lines = output.trim().split("\n");
|
|
705
|
+
return lines.slice(-CONTEXT_LINES).join("\n");
|
|
706
|
+
}
|
|
707
|
+
function extractErrorType(output) {
|
|
708
|
+
if (/429|rate.?limit|quota/i.test(output)) return "Rate limit / quota exceeded";
|
|
709
|
+
if (/ETIMEDOUT|ECONNREFUSED|ECONNRESET|ENOTFOUND/.test(output)) return "Network error";
|
|
710
|
+
if (/timeout|timed?\s*out/i.test(output)) return "Timeout";
|
|
711
|
+
const exitMatch = output.match(/exit code[:\s]+(\d+)/i);
|
|
712
|
+
if (exitMatch) return `Exit code ${exitMatch[1]}`;
|
|
713
|
+
if (/exit(?:ed)? with/i.test(output)) return "Non-zero exit code";
|
|
714
|
+
return "Unknown error";
|
|
715
|
+
}
|
|
716
|
+
function appendEntry(dir, entry) {
|
|
717
|
+
const path = guardrailsPath(dir);
|
|
718
|
+
const guardrailsDir = dirname2(path);
|
|
719
|
+
if (!existsSync4(guardrailsDir)) {
|
|
720
|
+
mkdirSync3(guardrailsDir, { recursive: true });
|
|
721
|
+
}
|
|
722
|
+
const existing = existsSync4(path) ? readFileSync3(path, "utf-8") : "";
|
|
723
|
+
const newEntryText = formatEntry(entry);
|
|
724
|
+
let content;
|
|
725
|
+
if (!existing.trim()) {
|
|
726
|
+
content = `# Guardrails \u2014 Li\xE7\xF5es aprendidas
|
|
727
|
+
|
|
728
|
+
${newEntryText}`;
|
|
729
|
+
} else {
|
|
730
|
+
const header = extractHeader(existing);
|
|
731
|
+
const entries = splitEntries(existing);
|
|
732
|
+
entries.push(newEntryText);
|
|
733
|
+
const rotated = entries.length > MAX_ENTRIES ? entries.slice(-MAX_ENTRIES) : entries;
|
|
734
|
+
content = `${header}
|
|
735
|
+
|
|
736
|
+
${rotated.join("\n\n")}`;
|
|
737
|
+
}
|
|
738
|
+
writeFileSync3(path, content, "utf-8");
|
|
739
|
+
}
|
|
740
|
+
function formatEntry(entry) {
|
|
741
|
+
return [
|
|
742
|
+
`## Issue ${entry.issueId} (${entry.date})`,
|
|
743
|
+
`- Provider: ${entry.provider}`,
|
|
744
|
+
`- Erro: ${entry.errorType}`,
|
|
745
|
+
`- Contexto:`,
|
|
746
|
+
"```",
|
|
747
|
+
entry.context,
|
|
748
|
+
"```"
|
|
749
|
+
].join("\n");
|
|
750
|
+
}
|
|
751
|
+
function extractHeader(content) {
|
|
752
|
+
const firstEntry = content.search(/^## /m);
|
|
753
|
+
if (firstEntry === -1) return content.trim();
|
|
754
|
+
return content.slice(0, firstEntry).trim();
|
|
755
|
+
}
|
|
756
|
+
function splitEntries(content) {
|
|
757
|
+
const positions = [];
|
|
758
|
+
const regex = /^## /gm;
|
|
759
|
+
for (const match of content.matchAll(regex)) {
|
|
760
|
+
positions.push(match.index);
|
|
761
|
+
}
|
|
762
|
+
return positions.map((start, i) => {
|
|
763
|
+
const end = positions[i + 1] ?? content.length;
|
|
764
|
+
return content.slice(start, end).trim();
|
|
765
|
+
});
|
|
766
|
+
}
|
|
767
|
+
|
|
315
768
|
// src/providers/claude.ts
|
|
316
|
-
import {
|
|
317
|
-
import { appendFileSync as appendFileSync2,
|
|
318
|
-
import { join } from "path";
|
|
769
|
+
import { execSync, spawn as spawn2 } from "child_process";
|
|
770
|
+
import { appendFileSync as appendFileSync2, mkdtempSync, unlinkSync, writeFileSync as writeFileSync4 } from "fs";
|
|
319
771
|
import { tmpdir } from "os";
|
|
772
|
+
import { join as join3 } from "path";
|
|
773
|
+
|
|
774
|
+
// src/overseer.ts
|
|
775
|
+
import { execFile } from "child_process";
|
|
776
|
+
import { promisify } from "util";
|
|
777
|
+
var execFileAsync = promisify(execFile);
|
|
778
|
+
var STUCK_MESSAGE = "\n[lisa-overseer] Provider killed: no git changes detected within the stuck threshold. Eligible for fallback.\n";
|
|
779
|
+
async function getGitSnapshot(cwd) {
|
|
780
|
+
try {
|
|
781
|
+
const { stdout } = await execFileAsync("git", ["status", "--porcelain"], {
|
|
782
|
+
cwd,
|
|
783
|
+
timeout: 1e4
|
|
784
|
+
});
|
|
785
|
+
return stdout;
|
|
786
|
+
} catch {
|
|
787
|
+
return "";
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
function startOverseer(proc, cwd, config2, getSnapshot = getGitSnapshot) {
|
|
791
|
+
if (!config2.enabled) {
|
|
792
|
+
return {
|
|
793
|
+
stop() {
|
|
794
|
+
},
|
|
795
|
+
wasKilled() {
|
|
796
|
+
return false;
|
|
797
|
+
}
|
|
798
|
+
};
|
|
799
|
+
}
|
|
800
|
+
let killed = false;
|
|
801
|
+
let lastSnapshot;
|
|
802
|
+
let lastChangeTime = Date.now();
|
|
803
|
+
let timer = null;
|
|
804
|
+
const check = async () => {
|
|
805
|
+
if (killed) return;
|
|
806
|
+
try {
|
|
807
|
+
const snapshot = await getSnapshot(cwd);
|
|
808
|
+
if (lastSnapshot === void 0) {
|
|
809
|
+
lastSnapshot = snapshot;
|
|
810
|
+
lastChangeTime = Date.now();
|
|
811
|
+
return;
|
|
812
|
+
}
|
|
813
|
+
if (snapshot !== lastSnapshot) {
|
|
814
|
+
lastSnapshot = snapshot;
|
|
815
|
+
lastChangeTime = Date.now();
|
|
816
|
+
return;
|
|
817
|
+
}
|
|
818
|
+
const idleMs = Date.now() - lastChangeTime;
|
|
819
|
+
if (idleMs >= config2.stuck_threshold * 1e3) {
|
|
820
|
+
killed = true;
|
|
821
|
+
if (timer) {
|
|
822
|
+
clearInterval(timer);
|
|
823
|
+
timer = null;
|
|
824
|
+
}
|
|
825
|
+
proc.kill("SIGTERM");
|
|
826
|
+
}
|
|
827
|
+
} catch {
|
|
828
|
+
}
|
|
829
|
+
};
|
|
830
|
+
timer = setInterval(check, config2.check_interval * 1e3);
|
|
831
|
+
return {
|
|
832
|
+
stop() {
|
|
833
|
+
if (timer) {
|
|
834
|
+
clearInterval(timer);
|
|
835
|
+
timer = null;
|
|
836
|
+
}
|
|
837
|
+
},
|
|
838
|
+
wasKilled() {
|
|
839
|
+
return killed;
|
|
840
|
+
}
|
|
841
|
+
};
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
// src/providers/claude.ts
|
|
320
845
|
var ClaudeProvider = class {
|
|
321
846
|
name = "claude";
|
|
322
847
|
async isAvailable() {
|
|
@@ -329,11 +854,11 @@ var ClaudeProvider = class {
|
|
|
329
854
|
}
|
|
330
855
|
async run(prompt, opts) {
|
|
331
856
|
const start = Date.now();
|
|
332
|
-
const tmpDir = mkdtempSync(
|
|
333
|
-
const promptFile =
|
|
334
|
-
|
|
857
|
+
const tmpDir = mkdtempSync(join3(tmpdir(), "lisa-"));
|
|
858
|
+
const promptFile = join3(tmpDir, "prompt.md");
|
|
859
|
+
writeFileSync4(promptFile, prompt, "utf-8");
|
|
335
860
|
try {
|
|
336
|
-
const proc =
|
|
861
|
+
const proc = spawn2(
|
|
337
862
|
"sh",
|
|
338
863
|
["-c", `claude -p --dangerously-skip-permissions "$(cat '${promptFile}')"`],
|
|
339
864
|
{
|
|
@@ -342,6 +867,7 @@ var ClaudeProvider = class {
|
|
|
342
867
|
env: { ...process.env, CLAUDECODE: void 0 }
|
|
343
868
|
}
|
|
344
869
|
);
|
|
870
|
+
const overseer = opts.overseer?.enabled ? startOverseer(proc, opts.cwd, opts.overseer) : null;
|
|
345
871
|
const chunks = [];
|
|
346
872
|
proc.stdout.on("data", (chunk) => {
|
|
347
873
|
const text2 = chunk.toString();
|
|
@@ -360,11 +886,17 @@ var ClaudeProvider = class {
|
|
|
360
886
|
} catch {
|
|
361
887
|
}
|
|
362
888
|
});
|
|
363
|
-
const exitCode = await new Promise((
|
|
364
|
-
proc.on("close", (code) =>
|
|
889
|
+
const exitCode = await new Promise((resolve6) => {
|
|
890
|
+
proc.on("close", (code) => {
|
|
891
|
+
overseer?.stop();
|
|
892
|
+
resolve6(code ?? 1);
|
|
893
|
+
});
|
|
365
894
|
});
|
|
895
|
+
if (overseer?.wasKilled()) {
|
|
896
|
+
chunks.push(STUCK_MESSAGE);
|
|
897
|
+
}
|
|
366
898
|
return {
|
|
367
|
-
success: exitCode === 0,
|
|
899
|
+
success: exitCode === 0 && !overseer?.wasKilled(),
|
|
368
900
|
output: chunks.join(""),
|
|
369
901
|
duration: Date.now() - start
|
|
370
902
|
};
|
|
@@ -384,10 +916,10 @@ var ClaudeProvider = class {
|
|
|
384
916
|
};
|
|
385
917
|
|
|
386
918
|
// src/providers/gemini.ts
|
|
387
|
-
import {
|
|
388
|
-
import { appendFileSync as appendFileSync3,
|
|
389
|
-
import { join as join2 } from "path";
|
|
919
|
+
import { execSync as execSync2, spawn as spawn3 } from "child_process";
|
|
920
|
+
import { appendFileSync as appendFileSync3, mkdtempSync as mkdtempSync2, unlinkSync as unlinkSync2, writeFileSync as writeFileSync5 } from "fs";
|
|
390
921
|
import { tmpdir as tmpdir2 } from "os";
|
|
922
|
+
import { join as join4 } from "path";
|
|
391
923
|
var GeminiProvider = class {
|
|
392
924
|
name = "gemini";
|
|
393
925
|
async isAvailable() {
|
|
@@ -400,18 +932,15 @@ var GeminiProvider = class {
|
|
|
400
932
|
}
|
|
401
933
|
async run(prompt, opts) {
|
|
402
934
|
const start = Date.now();
|
|
403
|
-
const tmpDir = mkdtempSync2(
|
|
404
|
-
const promptFile =
|
|
405
|
-
|
|
935
|
+
const tmpDir = mkdtempSync2(join4(tmpdir2(), "lisa-"));
|
|
936
|
+
const promptFile = join4(tmpDir, "prompt.md");
|
|
937
|
+
writeFileSync5(promptFile, prompt, "utf-8");
|
|
406
938
|
try {
|
|
407
|
-
const proc =
|
|
408
|
-
|
|
409
|
-
["
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
stdio: ["ignore", "pipe", "pipe"]
|
|
413
|
-
}
|
|
414
|
-
);
|
|
939
|
+
const proc = spawn3("sh", ["-c", `gemini --yolo -p "$(cat '${promptFile}')"`], {
|
|
940
|
+
cwd: opts.cwd,
|
|
941
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
942
|
+
});
|
|
943
|
+
const overseer = opts.overseer?.enabled ? startOverseer(proc, opts.cwd, opts.overseer) : null;
|
|
415
944
|
const chunks = [];
|
|
416
945
|
proc.stdout.on("data", (chunk) => {
|
|
417
946
|
const text2 = chunk.toString();
|
|
@@ -430,11 +959,17 @@ var GeminiProvider = class {
|
|
|
430
959
|
} catch {
|
|
431
960
|
}
|
|
432
961
|
});
|
|
433
|
-
const exitCode = await new Promise((
|
|
434
|
-
proc.on("close", (code) =>
|
|
962
|
+
const exitCode = await new Promise((resolve6) => {
|
|
963
|
+
proc.on("close", (code) => {
|
|
964
|
+
overseer?.stop();
|
|
965
|
+
resolve6(code ?? 1);
|
|
966
|
+
});
|
|
435
967
|
});
|
|
968
|
+
if (overseer?.wasKilled()) {
|
|
969
|
+
chunks.push(STUCK_MESSAGE);
|
|
970
|
+
}
|
|
436
971
|
return {
|
|
437
|
-
success: exitCode === 0,
|
|
972
|
+
success: exitCode === 0 && !overseer?.wasKilled(),
|
|
438
973
|
output: chunks.join(""),
|
|
439
974
|
duration: Date.now() - start
|
|
440
975
|
};
|
|
@@ -454,10 +989,10 @@ var GeminiProvider = class {
|
|
|
454
989
|
};
|
|
455
990
|
|
|
456
991
|
// src/providers/opencode.ts
|
|
457
|
-
import {
|
|
458
|
-
import { appendFileSync as appendFileSync4,
|
|
459
|
-
import { join as join3 } from "path";
|
|
992
|
+
import { execSync as execSync3, spawn as spawn4 } from "child_process";
|
|
993
|
+
import { appendFileSync as appendFileSync4, mkdtempSync as mkdtempSync3, unlinkSync as unlinkSync3, writeFileSync as writeFileSync6 } from "fs";
|
|
460
994
|
import { tmpdir as tmpdir3 } from "os";
|
|
995
|
+
import { join as join5 } from "path";
|
|
461
996
|
var OpenCodeProvider = class {
|
|
462
997
|
name = "opencode";
|
|
463
998
|
async isAvailable() {
|
|
@@ -470,18 +1005,15 @@ var OpenCodeProvider = class {
|
|
|
470
1005
|
}
|
|
471
1006
|
async run(prompt, opts) {
|
|
472
1007
|
const start = Date.now();
|
|
473
|
-
const tmpDir = mkdtempSync3(
|
|
474
|
-
const promptFile =
|
|
475
|
-
|
|
1008
|
+
const tmpDir = mkdtempSync3(join5(tmpdir3(), "lisa-"));
|
|
1009
|
+
const promptFile = join5(tmpDir, "prompt.md");
|
|
1010
|
+
writeFileSync6(promptFile, prompt, "utf-8");
|
|
476
1011
|
try {
|
|
477
|
-
const proc =
|
|
478
|
-
|
|
479
|
-
["
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
stdio: ["ignore", "pipe", "pipe"]
|
|
483
|
-
}
|
|
484
|
-
);
|
|
1012
|
+
const proc = spawn4("sh", ["-c", `opencode run "$(cat '${promptFile}')"`], {
|
|
1013
|
+
cwd: opts.cwd,
|
|
1014
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
1015
|
+
});
|
|
1016
|
+
const overseer = opts.overseer?.enabled ? startOverseer(proc, opts.cwd, opts.overseer) : null;
|
|
485
1017
|
const chunks = [];
|
|
486
1018
|
proc.stdout.on("data", (chunk) => {
|
|
487
1019
|
const text2 = chunk.toString();
|
|
@@ -500,11 +1032,17 @@ var OpenCodeProvider = class {
|
|
|
500
1032
|
} catch {
|
|
501
1033
|
}
|
|
502
1034
|
});
|
|
503
|
-
const exitCode = await new Promise((
|
|
504
|
-
proc.on("close", (code) =>
|
|
1035
|
+
const exitCode = await new Promise((resolve6) => {
|
|
1036
|
+
proc.on("close", (code) => {
|
|
1037
|
+
overseer?.stop();
|
|
1038
|
+
resolve6(code ?? 1);
|
|
1039
|
+
});
|
|
505
1040
|
});
|
|
1041
|
+
if (overseer?.wasKilled()) {
|
|
1042
|
+
chunks.push(STUCK_MESSAGE);
|
|
1043
|
+
}
|
|
506
1044
|
return {
|
|
507
|
-
success: exitCode === 0,
|
|
1045
|
+
success: exitCode === 0 && !overseer?.wasKilled(),
|
|
508
1046
|
output: chunks.join(""),
|
|
509
1047
|
duration: Date.now() - start
|
|
510
1048
|
};
|
|
@@ -543,24 +1081,126 @@ function createProvider(name) {
|
|
|
543
1081
|
}
|
|
544
1082
|
return factory();
|
|
545
1083
|
}
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
1084
|
+
var ELIGIBLE_ERROR_PATTERNS = [
|
|
1085
|
+
/429/i,
|
|
1086
|
+
/quota/i,
|
|
1087
|
+
/rate.?limit/i,
|
|
1088
|
+
/too many requests/i,
|
|
1089
|
+
/resource.?exhausted/i,
|
|
1090
|
+
/overloaded/i,
|
|
1091
|
+
/unavailable/i,
|
|
1092
|
+
/not.?found.*model/i,
|
|
1093
|
+
/model.*not.?found/i,
|
|
1094
|
+
/does not exist/i,
|
|
1095
|
+
/ETIMEDOUT/,
|
|
1096
|
+
/ECONNREFUSED/,
|
|
1097
|
+
/ECONNRESET/,
|
|
1098
|
+
/ENOTFOUND/,
|
|
1099
|
+
/timeout/i,
|
|
1100
|
+
/timed?\s*out/i,
|
|
1101
|
+
/network.?error/i,
|
|
1102
|
+
/not installed/i,
|
|
1103
|
+
/not in PATH/i,
|
|
1104
|
+
/command not found/i,
|
|
1105
|
+
/lisa-overseer/i
|
|
1106
|
+
];
|
|
1107
|
+
function isEligibleForFallback(output) {
|
|
1108
|
+
return ELIGIBLE_ERROR_PATTERNS.some((pattern) => pattern.test(output));
|
|
554
1109
|
}
|
|
555
|
-
async function
|
|
556
|
-
const
|
|
557
|
-
|
|
558
|
-
|
|
1110
|
+
async function runWithFallback(models, prompt, opts) {
|
|
1111
|
+
const attempts = [];
|
|
1112
|
+
for (const model of models) {
|
|
1113
|
+
const provider = createProvider(model);
|
|
1114
|
+
const available = await provider.isAvailable();
|
|
1115
|
+
if (!available) {
|
|
1116
|
+
attempts.push({
|
|
1117
|
+
provider: model,
|
|
1118
|
+
success: false,
|
|
1119
|
+
error: `Provider "${model}" is not installed or not in PATH`,
|
|
1120
|
+
duration: 0
|
|
1121
|
+
});
|
|
1122
|
+
continue;
|
|
1123
|
+
}
|
|
1124
|
+
const guardrailsSection = opts.guardrailsDir ? buildGuardrailsSection(opts.guardrailsDir) : "";
|
|
1125
|
+
const fullPrompt = guardrailsSection ? `${prompt}${guardrailsSection}` : prompt;
|
|
1126
|
+
const result = await provider.run(fullPrompt, opts);
|
|
1127
|
+
if (result.success) {
|
|
1128
|
+
attempts.push({
|
|
1129
|
+
provider: model,
|
|
1130
|
+
success: true,
|
|
1131
|
+
duration: result.duration
|
|
1132
|
+
});
|
|
1133
|
+
return {
|
|
1134
|
+
success: true,
|
|
1135
|
+
output: result.output,
|
|
1136
|
+
duration: result.duration,
|
|
1137
|
+
providerUsed: model,
|
|
1138
|
+
attempts
|
|
1139
|
+
};
|
|
1140
|
+
}
|
|
1141
|
+
if (opts.guardrailsDir && opts.issueId) {
|
|
1142
|
+
appendEntry(opts.guardrailsDir, {
|
|
1143
|
+
issueId: opts.issueId,
|
|
1144
|
+
date: (/* @__PURE__ */ new Date()).toISOString().slice(0, 10),
|
|
1145
|
+
provider: model,
|
|
1146
|
+
errorType: extractErrorType(result.output),
|
|
1147
|
+
context: extractContext(result.output)
|
|
1148
|
+
});
|
|
1149
|
+
}
|
|
1150
|
+
const eligible = isEligibleForFallback(result.output);
|
|
1151
|
+
attempts.push({
|
|
1152
|
+
provider: model,
|
|
1153
|
+
success: false,
|
|
1154
|
+
error: eligible ? "Eligible error (quota/unavailable/timeout)" : "Non-eligible error",
|
|
1155
|
+
duration: result.duration
|
|
1156
|
+
});
|
|
1157
|
+
if (!eligible) {
|
|
1158
|
+
return {
|
|
1159
|
+
success: false,
|
|
1160
|
+
output: result.output,
|
|
1161
|
+
duration: result.duration,
|
|
1162
|
+
providerUsed: model,
|
|
1163
|
+
attempts
|
|
1164
|
+
};
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
const totalDuration = attempts.reduce((sum, a) => sum + a.duration, 0);
|
|
1168
|
+
return {
|
|
1169
|
+
success: false,
|
|
1170
|
+
output: formatAttemptsReport(attempts),
|
|
1171
|
+
duration: totalDuration,
|
|
1172
|
+
providerUsed: attempts[attempts.length - 1]?.provider ?? models[0] ?? "claude",
|
|
1173
|
+
attempts
|
|
1174
|
+
};
|
|
1175
|
+
}
|
|
1176
|
+
function formatAttemptsReport(attempts) {
|
|
1177
|
+
const lines = ["All models exhausted. Attempt history:"];
|
|
1178
|
+
for (const [i, a] of attempts.entries()) {
|
|
1179
|
+
const status2 = a.success ? "OK" : "FAILED";
|
|
1180
|
+
const error2 = a.error ? ` \u2014 ${a.error}` : "";
|
|
1181
|
+
const duration = a.duration > 0 ? ` (${Math.round(a.duration / 1e3)}s)` : "";
|
|
1182
|
+
lines.push(` ${i + 1}. ${a.provider}: ${status2}${error2}${duration}`);
|
|
1183
|
+
}
|
|
1184
|
+
return lines.join("\n");
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
// src/sources/linear.ts
|
|
1188
|
+
var API_URL2 = "https://api.linear.app/graphql";
|
|
1189
|
+
var REQUEST_TIMEOUT_MS2 = 3e4;
|
|
1190
|
+
function getApiKey() {
|
|
1191
|
+
const key = process.env.LINEAR_API_KEY;
|
|
1192
|
+
if (!key) throw new Error("LINEAR_API_KEY is not set");
|
|
1193
|
+
return key;
|
|
1194
|
+
}
|
|
1195
|
+
async function gql(query, variables) {
|
|
1196
|
+
const res = await fetch(API_URL2, {
|
|
1197
|
+
method: "POST",
|
|
1198
|
+
headers: {
|
|
559
1199
|
"Content-Type": "application/json",
|
|
560
1200
|
Authorization: getApiKey()
|
|
561
1201
|
},
|
|
562
1202
|
body: JSON.stringify({ query, variables }),
|
|
563
|
-
signal: AbortSignal.timeout(
|
|
1203
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS2)
|
|
564
1204
|
});
|
|
565
1205
|
if (!res.ok) {
|
|
566
1206
|
const text2 = await res.text();
|
|
@@ -584,7 +1224,7 @@ var LinearSource = class {
|
|
|
584
1224
|
labels: { name: { eq: $labelName } }
|
|
585
1225
|
state: { name: { eq: $statusName } }
|
|
586
1226
|
}
|
|
587
|
-
first:
|
|
1227
|
+
first: 50
|
|
588
1228
|
) {
|
|
589
1229
|
nodes {
|
|
590
1230
|
id
|
|
@@ -593,6 +1233,15 @@ var LinearSource = class {
|
|
|
593
1233
|
description
|
|
594
1234
|
url
|
|
595
1235
|
priority
|
|
1236
|
+
inverseRelations(first: 50) {
|
|
1237
|
+
nodes {
|
|
1238
|
+
type
|
|
1239
|
+
issue {
|
|
1240
|
+
identifier
|
|
1241
|
+
state { type }
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
596
1245
|
}
|
|
597
1246
|
}
|
|
598
1247
|
}`,
|
|
@@ -605,12 +1254,32 @@ var LinearSource = class {
|
|
|
605
1254
|
);
|
|
606
1255
|
const issues = data.issues.nodes;
|
|
607
1256
|
if (issues.length === 0) return null;
|
|
608
|
-
|
|
1257
|
+
const unblocked = [];
|
|
1258
|
+
const blocked = [];
|
|
1259
|
+
for (const issue2 of issues) {
|
|
1260
|
+
const activeBlockers = issue2.inverseRelations.nodes.filter((r) => r.type === "blocks").filter((r) => r.issue.state.type !== "completed" && r.issue.state.type !== "canceled").map((r) => r.issue.identifier);
|
|
1261
|
+
if (activeBlockers.length === 0) {
|
|
1262
|
+
unblocked.push(issue2);
|
|
1263
|
+
} else {
|
|
1264
|
+
blocked.push({ identifier: issue2.identifier, blockers: activeBlockers });
|
|
1265
|
+
}
|
|
1266
|
+
}
|
|
1267
|
+
if (unblocked.length === 0) {
|
|
1268
|
+
if (blocked.length > 0) {
|
|
1269
|
+
warn("No unblocked issues found. Blocked issues:");
|
|
1270
|
+
for (const entry of blocked) {
|
|
1271
|
+
warn(` ${entry.identifier} \u2014 blocked by: ${entry.blockers.join(", ")}`);
|
|
1272
|
+
}
|
|
1273
|
+
}
|
|
1274
|
+
return null;
|
|
1275
|
+
}
|
|
1276
|
+
unblocked.sort((a, b) => {
|
|
609
1277
|
const pa = a.priority === 0 ? 5 : a.priority;
|
|
610
1278
|
const pb = b.priority === 0 ? 5 : b.priority;
|
|
611
1279
|
return pa - pb;
|
|
612
1280
|
});
|
|
613
|
-
const issue =
|
|
1281
|
+
const issue = unblocked[0];
|
|
1282
|
+
if (!issue) return null;
|
|
614
1283
|
return {
|
|
615
1284
|
id: issue.identifier,
|
|
616
1285
|
title: issue.title,
|
|
@@ -618,6 +1287,28 @@ var LinearSource = class {
|
|
|
618
1287
|
url: issue.url
|
|
619
1288
|
};
|
|
620
1289
|
}
|
|
1290
|
+
async fetchIssueById(id) {
|
|
1291
|
+
const identifier = parseLinearIdentifier(id);
|
|
1292
|
+
const data = await gql(
|
|
1293
|
+
`query($identifier: String!) {
|
|
1294
|
+
issue(id: $identifier) {
|
|
1295
|
+
id
|
|
1296
|
+
identifier
|
|
1297
|
+
title
|
|
1298
|
+
description
|
|
1299
|
+
url
|
|
1300
|
+
}
|
|
1301
|
+
}`,
|
|
1302
|
+
{ identifier }
|
|
1303
|
+
);
|
|
1304
|
+
if (!data.issue) return null;
|
|
1305
|
+
return {
|
|
1306
|
+
id: data.issue.identifier,
|
|
1307
|
+
title: data.issue.title,
|
|
1308
|
+
description: data.issue.description || "",
|
|
1309
|
+
url: data.issue.url
|
|
1310
|
+
};
|
|
1311
|
+
}
|
|
621
1312
|
async updateStatus(issueId, statusName) {
|
|
622
1313
|
const issueData = await gql(
|
|
623
1314
|
`query($identifier: String!) {
|
|
@@ -643,7 +1334,7 @@ var LinearSource = class {
|
|
|
643
1334
|
const available = statesData.workflowStates.nodes.map((s) => s.name).join(", ");
|
|
644
1335
|
throw new Error(`Status "${statusName}" not found. Available: ${available}`);
|
|
645
1336
|
}
|
|
646
|
-
await gql(
|
|
1337
|
+
const mutationResult = await gql(
|
|
647
1338
|
`mutation($issueId: String!, $stateId: String!) {
|
|
648
1339
|
issueUpdate(id: $issueId, input: { stateId: $stateId }) {
|
|
649
1340
|
success
|
|
@@ -651,6 +1342,11 @@ var LinearSource = class {
|
|
|
651
1342
|
}`,
|
|
652
1343
|
{ issueId: issueData.issue.id, stateId: state.id }
|
|
653
1344
|
);
|
|
1345
|
+
if (!mutationResult.issueUpdate.success) {
|
|
1346
|
+
throw new Error(
|
|
1347
|
+
`issueUpdate returned success=false for ${issueId} (stateId: ${state.id}, stateName: ${state.name})`
|
|
1348
|
+
);
|
|
1349
|
+
}
|
|
654
1350
|
}
|
|
655
1351
|
async attachPullRequest(_issueId, _prUrl) {
|
|
656
1352
|
}
|
|
@@ -665,11 +1361,9 @@ var LinearSource = class {
|
|
|
665
1361
|
{ identifier: issueId }
|
|
666
1362
|
);
|
|
667
1363
|
const currentLabels = issueData.issue.labels.nodes;
|
|
668
|
-
const filtered = currentLabels.filter(
|
|
669
|
-
(l) => l.name.toLowerCase() !== labelName.toLowerCase()
|
|
670
|
-
);
|
|
1364
|
+
const filtered = currentLabels.filter((l) => l.name.toLowerCase() !== labelName.toLowerCase());
|
|
671
1365
|
if (filtered.length === currentLabels.length) return;
|
|
672
|
-
await gql(
|
|
1366
|
+
const mutationResult = await gql(
|
|
673
1367
|
`mutation($issueId: String!, $labelIds: [String!]!) {
|
|
674
1368
|
issueUpdate(id: $issueId, input: { labelIds: $labelIds }) {
|
|
675
1369
|
success
|
|
@@ -680,12 +1374,22 @@ var LinearSource = class {
|
|
|
680
1374
|
labelIds: filtered.map((l) => l.id)
|
|
681
1375
|
}
|
|
682
1376
|
);
|
|
1377
|
+
if (!mutationResult.issueUpdate.success) {
|
|
1378
|
+
throw new Error(
|
|
1379
|
+
`issueUpdate returned success=false when removing label "${labelName}" from ${issueId}`
|
|
1380
|
+
);
|
|
1381
|
+
}
|
|
683
1382
|
}
|
|
684
1383
|
};
|
|
1384
|
+
function parseLinearIdentifier(input) {
|
|
1385
|
+
const urlMatch = input.match(/\/issue\/([A-Z]+-\d+)/);
|
|
1386
|
+
if (urlMatch?.[1]) return urlMatch[1];
|
|
1387
|
+
return input;
|
|
1388
|
+
}
|
|
685
1389
|
|
|
686
1390
|
// src/sources/trello.ts
|
|
687
|
-
var
|
|
688
|
-
var
|
|
1391
|
+
var API_URL3 = "https://api.trello.com/1";
|
|
1392
|
+
var REQUEST_TIMEOUT_MS3 = 3e4;
|
|
689
1393
|
function getAuthHeaders() {
|
|
690
1394
|
const key = process.env.TRELLO_API_KEY;
|
|
691
1395
|
const token = process.env.TRELLO_TOKEN;
|
|
@@ -696,11 +1400,11 @@ function getAuthHeaders() {
|
|
|
696
1400
|
}
|
|
697
1401
|
async function trelloFetch(method, path, params = "") {
|
|
698
1402
|
const sep = params ? "?" : "";
|
|
699
|
-
const url = `${
|
|
1403
|
+
const url = `${API_URL3}${path}${sep}${params}`;
|
|
700
1404
|
const res = await fetch(url, {
|
|
701
1405
|
method,
|
|
702
1406
|
headers: getAuthHeaders(),
|
|
703
|
-
signal: AbortSignal.timeout(
|
|
1407
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS3)
|
|
704
1408
|
});
|
|
705
1409
|
if (!res.ok) {
|
|
706
1410
|
const text2 = await res.text();
|
|
@@ -755,6 +1459,7 @@ var TrelloSource = class {
|
|
|
755
1459
|
const matching = cards.filter((c) => c.idLabels.includes(label.id));
|
|
756
1460
|
if (matching.length === 0) return null;
|
|
757
1461
|
const card = matching[0];
|
|
1462
|
+
if (!card) return null;
|
|
758
1463
|
return {
|
|
759
1464
|
id: card.id,
|
|
760
1465
|
title: card.name,
|
|
@@ -762,6 +1467,23 @@ var TrelloSource = class {
|
|
|
762
1467
|
url: card.url
|
|
763
1468
|
};
|
|
764
1469
|
}
|
|
1470
|
+
async fetchIssueById(id) {
|
|
1471
|
+
const shortLink = parseTrelloIdentifier(id);
|
|
1472
|
+
try {
|
|
1473
|
+
const card = await trelloGet(
|
|
1474
|
+
`/cards/${shortLink}`,
|
|
1475
|
+
"fields=name,desc,url,idLabels,idList"
|
|
1476
|
+
);
|
|
1477
|
+
return {
|
|
1478
|
+
id: card.id,
|
|
1479
|
+
title: card.name,
|
|
1480
|
+
description: card.desc || "",
|
|
1481
|
+
url: card.url
|
|
1482
|
+
};
|
|
1483
|
+
} catch {
|
|
1484
|
+
return null;
|
|
1485
|
+
}
|
|
1486
|
+
}
|
|
765
1487
|
async updateStatus(cardId, listName) {
|
|
766
1488
|
const card = await trelloGet(`/cards/${cardId}`, "fields=idBoard");
|
|
767
1489
|
const list = await findListByName(card.idBoard, listName);
|
|
@@ -780,6 +1502,11 @@ var TrelloSource = class {
|
|
|
780
1502
|
await trelloDelete(`/cards/${cardId}/idLabels/${label.id}`);
|
|
781
1503
|
}
|
|
782
1504
|
};
|
|
1505
|
+
function parseTrelloIdentifier(input) {
|
|
1506
|
+
const urlMatch = input.match(/\/c\/([a-zA-Z0-9]+)/);
|
|
1507
|
+
if (urlMatch?.[1]) return urlMatch[1];
|
|
1508
|
+
return input;
|
|
1509
|
+
}
|
|
783
1510
|
|
|
784
1511
|
// src/sources/index.ts
|
|
785
1512
|
var sources = {
|
|
@@ -794,118 +1521,42 @@ function createSource(name) {
|
|
|
794
1521
|
return factory();
|
|
795
1522
|
}
|
|
796
1523
|
|
|
797
|
-
// src/github.ts
|
|
798
|
-
import { execa } from "execa";
|
|
799
|
-
var API_URL3 = "https://api.github.com";
|
|
800
|
-
var REQUEST_TIMEOUT_MS3 = 3e4;
|
|
801
|
-
async function isGhCliAvailable() {
|
|
802
|
-
try {
|
|
803
|
-
await execa("gh", ["auth", "status"]);
|
|
804
|
-
return true;
|
|
805
|
-
} catch {
|
|
806
|
-
return false;
|
|
807
|
-
}
|
|
808
|
-
}
|
|
809
|
-
function getToken() {
|
|
810
|
-
const token = process.env.GITHUB_TOKEN;
|
|
811
|
-
if (!token) throw new Error("GITHUB_TOKEN is not set");
|
|
812
|
-
return token;
|
|
813
|
-
}
|
|
814
|
-
async function createPullRequest(opts, method = "cli") {
|
|
815
|
-
if (method === "cli" && await isGhCliAvailable()) {
|
|
816
|
-
return createPullRequestWithGhCli(opts);
|
|
817
|
-
}
|
|
818
|
-
const res = await fetch(`${API_URL3}/repos/${opts.owner}/${opts.repo}/pulls`, {
|
|
819
|
-
method: "POST",
|
|
820
|
-
headers: {
|
|
821
|
-
Authorization: `Bearer ${getToken()}`,
|
|
822
|
-
Accept: "application/vnd.github+json",
|
|
823
|
-
"Content-Type": "application/json"
|
|
824
|
-
},
|
|
825
|
-
body: JSON.stringify({
|
|
826
|
-
title: opts.title,
|
|
827
|
-
body: opts.body,
|
|
828
|
-
head: opts.head,
|
|
829
|
-
base: opts.base
|
|
830
|
-
}),
|
|
831
|
-
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS3)
|
|
832
|
-
});
|
|
833
|
-
if (!res.ok) {
|
|
834
|
-
const text2 = await res.text();
|
|
835
|
-
throw new Error(`GitHub API error (${res.status}): ${text2}`);
|
|
836
|
-
}
|
|
837
|
-
const data = await res.json();
|
|
838
|
-
return { number: data.number, html_url: data.html_url };
|
|
839
|
-
}
|
|
840
|
-
async function createPullRequestWithGhCli(opts) {
|
|
841
|
-
const result = await execa("gh", [
|
|
842
|
-
"pr",
|
|
843
|
-
"create",
|
|
844
|
-
"--repo",
|
|
845
|
-
`${opts.owner}/${opts.repo}`,
|
|
846
|
-
"--head",
|
|
847
|
-
opts.head,
|
|
848
|
-
"--base",
|
|
849
|
-
opts.base,
|
|
850
|
-
"--title",
|
|
851
|
-
opts.title,
|
|
852
|
-
"--body",
|
|
853
|
-
opts.body
|
|
854
|
-
]);
|
|
855
|
-
const url = result.stdout.trim();
|
|
856
|
-
const prNumberMatch = url.match(/\/pull\/(\d+)/);
|
|
857
|
-
const number = prNumberMatch ? Number.parseInt(prNumberMatch[1], 10) : 0;
|
|
858
|
-
return { number, html_url: url };
|
|
859
|
-
}
|
|
860
|
-
async function getRepoInfo(cwd) {
|
|
861
|
-
const { stdout: remoteUrl } = await execa("git", ["remote", "get-url", "origin"], { cwd });
|
|
862
|
-
let owner;
|
|
863
|
-
let repo;
|
|
864
|
-
const sshMatch = remoteUrl.match(/git@github\.com:(.+?)\/(.+?)(?:\.git)?$/);
|
|
865
|
-
const httpsMatch = remoteUrl.match(/github\.com\/(.+?)\/(.+?)(?:\.git)?$/);
|
|
866
|
-
if (sshMatch) {
|
|
867
|
-
owner = sshMatch[1];
|
|
868
|
-
repo = sshMatch[2];
|
|
869
|
-
} else if (httpsMatch) {
|
|
870
|
-
owner = httpsMatch[1];
|
|
871
|
-
repo = httpsMatch[2];
|
|
872
|
-
} else {
|
|
873
|
-
throw new Error(`Cannot parse GitHub owner/repo from remote URL: ${remoteUrl}`);
|
|
874
|
-
}
|
|
875
|
-
const { stdout: branch } = await execa("git", ["branch", "--show-current"], { cwd });
|
|
876
|
-
const { stdout: defaultBranch } = await execa(
|
|
877
|
-
"git",
|
|
878
|
-
["symbolic-ref", "refs/remotes/origin/HEAD", "--short"],
|
|
879
|
-
{ cwd, reject: false }
|
|
880
|
-
).then(
|
|
881
|
-
(r) => r,
|
|
882
|
-
() => ({ stdout: "origin/main" })
|
|
883
|
-
);
|
|
884
|
-
return {
|
|
885
|
-
owner,
|
|
886
|
-
repo,
|
|
887
|
-
branch: branch.trim(),
|
|
888
|
-
defaultBranch: defaultBranch.replace("origin/", "").trim()
|
|
889
|
-
};
|
|
890
|
-
}
|
|
891
|
-
|
|
892
1524
|
// src/worktree.ts
|
|
893
|
-
import {
|
|
894
|
-
import { join as
|
|
1525
|
+
import { appendFileSync as appendFileSync5, existsSync as existsSync5, readFileSync as readFileSync4 } from "fs";
|
|
1526
|
+
import { join as join6, resolve as resolve4 } from "path";
|
|
895
1527
|
import { execa as execa2 } from "execa";
|
|
896
1528
|
var WORKTREES_DIR = ".worktrees";
|
|
897
1529
|
function generateBranchName(issueId, title) {
|
|
898
1530
|
const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").substring(0, 40);
|
|
899
1531
|
return `feat/${issueId.toLowerCase()}-${slug}`;
|
|
900
1532
|
}
|
|
1533
|
+
async function cleanupOrphanedWorktree(repoRoot, branchName) {
|
|
1534
|
+
const { stdout: branchList } = await execa2("git", ["branch", "--list", branchName], {
|
|
1535
|
+
cwd: repoRoot,
|
|
1536
|
+
reject: false
|
|
1537
|
+
});
|
|
1538
|
+
if (!branchList.trim()) {
|
|
1539
|
+
return false;
|
|
1540
|
+
}
|
|
1541
|
+
const worktreePath = join6(repoRoot, WORKTREES_DIR, branchName);
|
|
1542
|
+
const { stdout: worktreeList } = await execa2("git", ["worktree", "list", "--porcelain"], {
|
|
1543
|
+
cwd: repoRoot,
|
|
1544
|
+
reject: false
|
|
1545
|
+
});
|
|
1546
|
+
if (worktreeList.includes(worktreePath)) {
|
|
1547
|
+
await execa2("git", ["worktree", "remove", worktreePath, "--force"], { cwd: repoRoot });
|
|
1548
|
+
await execa2("git", ["worktree", "prune"], { cwd: repoRoot });
|
|
1549
|
+
}
|
|
1550
|
+
await execa2("git", ["branch", "-D", branchName], { cwd: repoRoot });
|
|
1551
|
+
return true;
|
|
1552
|
+
}
|
|
901
1553
|
async function createWorktree(repoRoot, branchName, baseBranch) {
|
|
902
|
-
const worktreePath =
|
|
1554
|
+
const worktreePath = join6(repoRoot, WORKTREES_DIR, branchName);
|
|
1555
|
+
await cleanupOrphanedWorktree(repoRoot, branchName);
|
|
903
1556
|
await execa2("git", ["fetch", "origin", baseBranch], { cwd: repoRoot });
|
|
904
|
-
await execa2(
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
{ cwd: repoRoot }
|
|
908
|
-
);
|
|
1557
|
+
await execa2("git", ["worktree", "add", "-b", branchName, worktreePath, `origin/${baseBranch}`], {
|
|
1558
|
+
cwd: repoRoot
|
|
1559
|
+
});
|
|
909
1560
|
return worktreePath;
|
|
910
1561
|
}
|
|
911
1562
|
async function removeWorktree(repoRoot, worktreePath) {
|
|
@@ -915,13 +1566,13 @@ async function removeWorktree(repoRoot, worktreePath) {
|
|
|
915
1566
|
await execa2("git", ["worktree", "prune"], { cwd: repoRoot });
|
|
916
1567
|
}
|
|
917
1568
|
function ensureWorktreeGitignore(repoRoot) {
|
|
918
|
-
const gitignorePath =
|
|
919
|
-
if (!
|
|
1569
|
+
const gitignorePath = join6(repoRoot, ".gitignore");
|
|
1570
|
+
if (!existsSync5(gitignorePath)) {
|
|
920
1571
|
appendFileSync5(gitignorePath, `${WORKTREES_DIR}
|
|
921
1572
|
`);
|
|
922
1573
|
return;
|
|
923
1574
|
}
|
|
924
|
-
const content =
|
|
1575
|
+
const content = readFileSync4(gitignorePath, "utf-8");
|
|
925
1576
|
if (!content.split("\n").some((line) => line.trim() === WORKTREES_DIR)) {
|
|
926
1577
|
const separator = content.endsWith("\n") ? "" : "\n";
|
|
927
1578
|
appendFileSync5(gitignorePath, `${separator}${WORKTREES_DIR}
|
|
@@ -930,27 +1581,23 @@ function ensureWorktreeGitignore(repoRoot) {
|
|
|
930
1581
|
}
|
|
931
1582
|
async function findBranchByIssueId(repoRoot, issueId) {
|
|
932
1583
|
const needle = issueId.toLowerCase();
|
|
933
|
-
const { stdout: local } = await execa2(
|
|
934
|
-
"
|
|
935
|
-
"--sort=-committerdate",
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
], { cwd: repoRoot });
|
|
1584
|
+
const { stdout: local } = await execa2(
|
|
1585
|
+
"git",
|
|
1586
|
+
["for-each-ref", "--sort=-committerdate", "--format=%(refname:short)", "refs/heads/"],
|
|
1587
|
+
{ cwd: repoRoot }
|
|
1588
|
+
);
|
|
939
1589
|
const localMatch = local.split("\n").map((b) => b.trim()).filter(Boolean).find((b) => b.toLowerCase().includes(needle));
|
|
940
1590
|
if (localMatch) return localMatch;
|
|
941
|
-
const { stdout: remote } = await execa2(
|
|
942
|
-
"
|
|
943
|
-
"--sort=-committerdate",
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
], { cwd: repoRoot });
|
|
1591
|
+
const { stdout: remote } = await execa2(
|
|
1592
|
+
"git",
|
|
1593
|
+
["for-each-ref", "--sort=-committerdate", "--format=%(refname:short)", "refs/remotes/origin/"],
|
|
1594
|
+
{ cwd: repoRoot }
|
|
1595
|
+
);
|
|
947
1596
|
const remoteMatch = remote.split("\n").map((b) => b.trim()).filter(Boolean).find((b) => b.toLowerCase().includes(needle));
|
|
948
1597
|
if (remoteMatch) return remoteMatch.replace("origin/", "");
|
|
949
|
-
const { stdout: lsRemote } = await execa2("git", [
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
"origin"
|
|
953
|
-
], { cwd: repoRoot });
|
|
1598
|
+
const { stdout: lsRemote } = await execa2("git", ["ls-remote", "--heads", "origin"], {
|
|
1599
|
+
cwd: repoRoot
|
|
1600
|
+
});
|
|
954
1601
|
const lsMatch = lsRemote.split("\n").map((l) => l.trim()).filter(Boolean).map((l) => l.split(" ")[1]?.replace("refs/heads/", "") ?? "").find((b) => b.toLowerCase().includes(needle));
|
|
955
1602
|
if (lsMatch) return lsMatch;
|
|
956
1603
|
return void 0;
|
|
@@ -959,17 +1606,18 @@ function determineRepoPath(repos, issue, workspace) {
|
|
|
959
1606
|
if (repos.length === 0) return void 0;
|
|
960
1607
|
if (issue.repo) {
|
|
961
1608
|
const match = repos.find((r) => r.name === issue.repo);
|
|
962
|
-
if (match) return
|
|
1609
|
+
if (match) return join6(workspace, match.path);
|
|
963
1610
|
}
|
|
964
1611
|
for (const r of repos) {
|
|
965
1612
|
if (r.match && issue.title.startsWith(r.match)) {
|
|
966
|
-
return
|
|
1613
|
+
return join6(workspace, r.path);
|
|
967
1614
|
}
|
|
968
1615
|
}
|
|
969
|
-
|
|
1616
|
+
const first = repos[0];
|
|
1617
|
+
return first ? join6(workspace, first.path) : void 0;
|
|
970
1618
|
}
|
|
971
1619
|
async function detectFeatureBranches(repos, issueId, workspace, globalBaseBranch) {
|
|
972
|
-
const entries = repos.length > 0 ? repos.map((r) => ({ path:
|
|
1620
|
+
const entries = repos.length > 0 ? repos.map((r) => ({ path: resolve4(workspace, r.path), baseBranch: r.base_branch })) : [{ path: workspace, baseBranch: globalBaseBranch }];
|
|
973
1621
|
const needle = issueId.toLowerCase();
|
|
974
1622
|
const results = [];
|
|
975
1623
|
const matched = /* @__PURE__ */ new Set();
|
|
@@ -979,7 +1627,7 @@ async function detectFeatureBranches(repos, issueId, workspace, globalBaseBranch
|
|
|
979
1627
|
const { stdout } = await execa2("git", ["branch", "--show-current"], { cwd: entry.path });
|
|
980
1628
|
const current = stdout.trim();
|
|
981
1629
|
currentBranches.push({ ...entry, current });
|
|
982
|
-
if (current
|
|
1630
|
+
if (current?.toLowerCase().includes(needle)) {
|
|
983
1631
|
results.push({ repoPath: entry.path, branch: current });
|
|
984
1632
|
matched.add(entry.path);
|
|
985
1633
|
}
|
|
@@ -1003,17 +1651,105 @@ async function detectFeatureBranches(repos, issueId, workspace, globalBaseBranch
|
|
|
1003
1651
|
}
|
|
1004
1652
|
|
|
1005
1653
|
// src/loop.ts
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1654
|
+
var activeCleanup = null;
|
|
1655
|
+
var shuttingDown = false;
|
|
1656
|
+
function resolveModels(config2) {
|
|
1657
|
+
if (config2.models && config2.models.length > 0) return config2.models;
|
|
1658
|
+
return [config2.provider];
|
|
1659
|
+
}
|
|
1660
|
+
function buildPrBody(issue, providerUsed) {
|
|
1661
|
+
return `Closes ${issue.url}
|
|
1662
|
+
|
|
1663
|
+
Implemented by [lisa](https://github.com/tarcisiopgs/lisa) using **${providerUsed}**.`;
|
|
1664
|
+
}
|
|
1665
|
+
var PR_TITLE_FILE = ".pr-title";
|
|
1666
|
+
function readPrTitle(cwd) {
|
|
1667
|
+
try {
|
|
1668
|
+
const title = readFileSync5(join7(cwd, PR_TITLE_FILE), "utf-8").trim().split("\n")[0]?.trim();
|
|
1669
|
+
return title || null;
|
|
1670
|
+
} catch {
|
|
1671
|
+
return null;
|
|
1672
|
+
}
|
|
1673
|
+
}
|
|
1674
|
+
function cleanupPrTitle(cwd) {
|
|
1675
|
+
try {
|
|
1676
|
+
unlinkSync4(join7(cwd, PR_TITLE_FILE));
|
|
1677
|
+
} catch {
|
|
1678
|
+
}
|
|
1679
|
+
}
|
|
1680
|
+
function installSignalHandlers() {
|
|
1681
|
+
const cleanup = async (signal) => {
|
|
1682
|
+
if (shuttingDown) {
|
|
1683
|
+
warn("Force exiting...");
|
|
1684
|
+
process.exit(1);
|
|
1685
|
+
}
|
|
1686
|
+
shuttingDown = true;
|
|
1687
|
+
warn(`Received ${signal}. Reverting active issue...`);
|
|
1688
|
+
if (activeCleanup) {
|
|
1689
|
+
const { issueId, previousStatus, source } = activeCleanup;
|
|
1690
|
+
try {
|
|
1691
|
+
await Promise.race([
|
|
1692
|
+
source.updateStatus(issueId, previousStatus),
|
|
1693
|
+
new Promise(
|
|
1694
|
+
(_, reject) => setTimeout(() => reject(new Error("Revert timed out")), 5e3)
|
|
1695
|
+
)
|
|
1696
|
+
]);
|
|
1697
|
+
ok(`Reverted ${issueId} to "${previousStatus}"`);
|
|
1698
|
+
} catch (err) {
|
|
1699
|
+
error(
|
|
1700
|
+
`Failed to revert ${issueId}: ${err instanceof Error ? err.message : String(err)}`
|
|
1701
|
+
);
|
|
1702
|
+
}
|
|
1703
|
+
}
|
|
1012
1704
|
process.exit(1);
|
|
1705
|
+
};
|
|
1706
|
+
process.on("SIGINT", () => {
|
|
1707
|
+
cleanup("SIGINT");
|
|
1708
|
+
});
|
|
1709
|
+
process.on("SIGTERM", () => {
|
|
1710
|
+
cleanup("SIGTERM");
|
|
1711
|
+
});
|
|
1712
|
+
}
|
|
1713
|
+
async function recoverOrphanIssues(source, config2) {
|
|
1714
|
+
const orphanConfig = {
|
|
1715
|
+
...config2.source_config,
|
|
1716
|
+
pick_from: config2.source_config.in_progress
|
|
1717
|
+
};
|
|
1718
|
+
while (true) {
|
|
1719
|
+
let orphan;
|
|
1720
|
+
try {
|
|
1721
|
+
orphan = await source.fetchNextIssue(orphanConfig);
|
|
1722
|
+
} catch (err) {
|
|
1723
|
+
warn(
|
|
1724
|
+
`Failed to check for orphan issues: ${err instanceof Error ? err.message : String(err)}`
|
|
1725
|
+
);
|
|
1726
|
+
break;
|
|
1727
|
+
}
|
|
1728
|
+
if (!orphan) break;
|
|
1729
|
+
warn(
|
|
1730
|
+
`Found orphan issue ${orphan.id} stuck in "${config2.source_config.in_progress}". Reverting to "${config2.source_config.pick_from}".`
|
|
1731
|
+
);
|
|
1732
|
+
try {
|
|
1733
|
+
await source.updateStatus(orphan.id, config2.source_config.pick_from);
|
|
1734
|
+
ok(`Recovered orphan ${orphan.id}`);
|
|
1735
|
+
} catch (err) {
|
|
1736
|
+
error(
|
|
1737
|
+
`Failed to recover orphan ${orphan.id}: ${err instanceof Error ? err.message : String(err)}`
|
|
1738
|
+
);
|
|
1739
|
+
break;
|
|
1740
|
+
}
|
|
1013
1741
|
}
|
|
1742
|
+
}
|
|
1743
|
+
async function runLoop(config2, opts) {
|
|
1744
|
+
const source = createSource(config2.source);
|
|
1745
|
+
const models = resolveModels(config2);
|
|
1746
|
+
installSignalHandlers();
|
|
1014
1747
|
log(
|
|
1015
|
-
`Starting loop (
|
|
1748
|
+
`Starting loop (models: ${models.join(" \u2192 ")}, source: ${config2.source}, label: ${config2.source_config.label}, workflow: ${config2.workflow})`
|
|
1016
1749
|
);
|
|
1750
|
+
if (!opts.dryRun) {
|
|
1751
|
+
await recoverOrphanIssues(source, config2);
|
|
1752
|
+
}
|
|
1017
1753
|
let session = 0;
|
|
1018
1754
|
while (true) {
|
|
1019
1755
|
session++;
|
|
@@ -1022,18 +1758,29 @@ async function runLoop(config2, opts) {
|
|
|
1022
1758
|
break;
|
|
1023
1759
|
}
|
|
1024
1760
|
const timestamp2 = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-").substring(0, 19);
|
|
1025
|
-
const logFile =
|
|
1761
|
+
const logFile = resolve5(config2.logs.dir, `session_${session}_${timestamp2}.log`);
|
|
1026
1762
|
divider(session);
|
|
1027
|
-
|
|
1763
|
+
if (opts.issueId) {
|
|
1764
|
+
log(`Fetching issue '${opts.issueId}' from ${config2.source}...`);
|
|
1765
|
+
} else {
|
|
1766
|
+
log(`Fetching next '${config2.source_config.label}' issue from ${config2.source}...`);
|
|
1767
|
+
}
|
|
1028
1768
|
if (opts.dryRun) {
|
|
1029
|
-
|
|
1769
|
+
if (opts.issueId) {
|
|
1770
|
+
log(`[dry-run] Would fetch issue '${opts.issueId}' from ${config2.source}`);
|
|
1771
|
+
} else {
|
|
1772
|
+
log(
|
|
1773
|
+
`[dry-run] Would fetch issue from ${config2.source} (${config2.source_config.team}/${config2.source_config.project})`
|
|
1774
|
+
);
|
|
1775
|
+
}
|
|
1030
1776
|
log(`[dry-run] Workflow mode: ${config2.workflow}`);
|
|
1777
|
+
log(`[dry-run] Models priority: ${models.join(" \u2192 ")}`);
|
|
1031
1778
|
log("[dry-run] Then implement, push, create PR, and update issue status");
|
|
1032
1779
|
break;
|
|
1033
1780
|
}
|
|
1034
1781
|
let issue;
|
|
1035
1782
|
try {
|
|
1036
|
-
issue = await source.fetchNextIssue(config2.source_config);
|
|
1783
|
+
issue = opts.issueId ? await source.fetchIssueById(opts.issueId) : await source.fetchNextIssue(config2.source_config);
|
|
1037
1784
|
} catch (err) {
|
|
1038
1785
|
error(`Failed to fetch issues: ${err instanceof Error ? err.message : String(err)}`);
|
|
1039
1786
|
if (opts.once) break;
|
|
@@ -1041,10 +1788,15 @@ async function runLoop(config2, opts) {
|
|
|
1041
1788
|
continue;
|
|
1042
1789
|
}
|
|
1043
1790
|
if (!issue) {
|
|
1044
|
-
|
|
1791
|
+
if (opts.issueId) {
|
|
1792
|
+
error(`Issue '${opts.issueId}' not found.`);
|
|
1793
|
+
} else {
|
|
1794
|
+
ok(`No more issues with label '${config2.source_config.label}'. Done.`);
|
|
1795
|
+
}
|
|
1045
1796
|
break;
|
|
1046
1797
|
}
|
|
1047
1798
|
ok(`Picked up: ${issue.id} \u2014 ${issue.title}`);
|
|
1799
|
+
const previousStatus = config2.source_config.pick_from;
|
|
1048
1800
|
try {
|
|
1049
1801
|
const inProgress = config2.source_config.in_progress;
|
|
1050
1802
|
await source.updateStatus(issue.id, inProgress);
|
|
@@ -1052,8 +1804,71 @@ async function runLoop(config2, opts) {
|
|
|
1052
1804
|
} catch (err) {
|
|
1053
1805
|
warn(`Failed to update status: ${err instanceof Error ? err.message : String(err)}`);
|
|
1054
1806
|
}
|
|
1055
|
-
|
|
1056
|
-
|
|
1807
|
+
activeCleanup = { issueId: issue.id, previousStatus, source };
|
|
1808
|
+
let sessionResult;
|
|
1809
|
+
try {
|
|
1810
|
+
sessionResult = config2.workflow === "worktree" ? await runWorktreeSession(config2, issue, logFile, session, models) : await runBranchSession(config2, issue, logFile, session, models);
|
|
1811
|
+
} catch (err) {
|
|
1812
|
+
error(
|
|
1813
|
+
`Unhandled error in session for ${issue.id}: ${err instanceof Error ? err.message : String(err)}`
|
|
1814
|
+
);
|
|
1815
|
+
try {
|
|
1816
|
+
await source.updateStatus(issue.id, previousStatus);
|
|
1817
|
+
ok(`Reverted ${issue.id} to "${previousStatus}"`);
|
|
1818
|
+
} catch (revertErr) {
|
|
1819
|
+
error(
|
|
1820
|
+
`Failed to revert status: ${revertErr instanceof Error ? revertErr.message : String(revertErr)}`
|
|
1821
|
+
);
|
|
1822
|
+
}
|
|
1823
|
+
activeCleanup = null;
|
|
1824
|
+
if (opts.once) break;
|
|
1825
|
+
log(`Cooling down ${config2.loop.cooldown}s before next issue...`);
|
|
1826
|
+
await sleep(config2.loop.cooldown * 1e3);
|
|
1827
|
+
continue;
|
|
1828
|
+
}
|
|
1829
|
+
if (!sessionResult.success) {
|
|
1830
|
+
error(`All models failed for ${issue.id}. Reverting to "${previousStatus}".`);
|
|
1831
|
+
logAttemptHistory(sessionResult);
|
|
1832
|
+
try {
|
|
1833
|
+
await source.updateStatus(issue.id, previousStatus);
|
|
1834
|
+
ok(`Reverted ${issue.id} to "${previousStatus}"`);
|
|
1835
|
+
} catch (err) {
|
|
1836
|
+
error(
|
|
1837
|
+
`Failed to revert status: ${err instanceof Error ? err.message : String(err)}`
|
|
1838
|
+
);
|
|
1839
|
+
}
|
|
1840
|
+
activeCleanup = null;
|
|
1841
|
+
if (opts.once) {
|
|
1842
|
+
log("Single iteration mode. Exiting.");
|
|
1843
|
+
break;
|
|
1844
|
+
}
|
|
1845
|
+
log(`Cooling down ${config2.loop.cooldown}s before next issue...`);
|
|
1846
|
+
await sleep(config2.loop.cooldown * 1e3);
|
|
1847
|
+
continue;
|
|
1848
|
+
}
|
|
1849
|
+
ok(`Completed with provider: ${sessionResult.providerUsed}`);
|
|
1850
|
+
if (sessionResult.prUrls.length === 0) {
|
|
1851
|
+
warn(
|
|
1852
|
+
`Session succeeded but no PRs created for ${issue.id}. Reverting to "${previousStatus}".`
|
|
1853
|
+
);
|
|
1854
|
+
try {
|
|
1855
|
+
await source.updateStatus(issue.id, previousStatus);
|
|
1856
|
+
ok(`Reverted ${issue.id} to "${previousStatus}"`);
|
|
1857
|
+
} catch (err) {
|
|
1858
|
+
error(
|
|
1859
|
+
`Failed to revert status: ${err instanceof Error ? err.message : String(err)}`
|
|
1860
|
+
);
|
|
1861
|
+
}
|
|
1862
|
+
activeCleanup = null;
|
|
1863
|
+
if (opts.once) {
|
|
1864
|
+
log("Single iteration mode. Exiting.");
|
|
1865
|
+
break;
|
|
1866
|
+
}
|
|
1867
|
+
log(`Cooling down ${config2.loop.cooldown}s before next issue...`);
|
|
1868
|
+
await sleep(config2.loop.cooldown * 1e3);
|
|
1869
|
+
continue;
|
|
1870
|
+
}
|
|
1871
|
+
for (const prUrl of sessionResult.prUrls) {
|
|
1057
1872
|
try {
|
|
1058
1873
|
await source.attachPullRequest(issue.id, prUrl);
|
|
1059
1874
|
ok(`Attached PR to ${issue.id}`);
|
|
@@ -1061,19 +1876,24 @@ async function runLoop(config2, opts) {
|
|
|
1061
1876
|
warn(`Failed to attach PR: ${err instanceof Error ? err.message : String(err)}`);
|
|
1062
1877
|
}
|
|
1063
1878
|
}
|
|
1879
|
+
let statusUpdated = false;
|
|
1064
1880
|
try {
|
|
1065
1881
|
const doneStatus = config2.source_config.done;
|
|
1066
1882
|
await source.updateStatus(issue.id, doneStatus);
|
|
1067
1883
|
ok(`Updated ${issue.id} status to "${doneStatus}"`);
|
|
1884
|
+
statusUpdated = true;
|
|
1068
1885
|
} catch (err) {
|
|
1069
1886
|
error(`Failed to update status: ${err instanceof Error ? err.message : String(err)}`);
|
|
1070
1887
|
}
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1888
|
+
if (statusUpdated && !opts.issueId) {
|
|
1889
|
+
try {
|
|
1890
|
+
await source.removeLabel(issue.id, config2.source_config.label);
|
|
1891
|
+
ok(`Removed label "${config2.source_config.label}" from ${issue.id}`);
|
|
1892
|
+
} catch (err) {
|
|
1893
|
+
error(`Failed to remove label: ${err instanceof Error ? err.message : String(err)}`);
|
|
1894
|
+
}
|
|
1076
1895
|
}
|
|
1896
|
+
activeCleanup = null;
|
|
1077
1897
|
if (opts.once) {
|
|
1078
1898
|
log("Single iteration mode. Exiting.");
|
|
1079
1899
|
break;
|
|
@@ -1083,14 +1903,46 @@ async function runLoop(config2, opts) {
|
|
|
1083
1903
|
}
|
|
1084
1904
|
ok(`lisa finished. ${session} session(s) run.`);
|
|
1085
1905
|
}
|
|
1906
|
+
function logAttemptHistory(result) {
|
|
1907
|
+
for (const [i, attempt] of result.fallback.attempts.entries()) {
|
|
1908
|
+
const status2 = attempt.success ? "OK" : "FAILED";
|
|
1909
|
+
const error2 = attempt.error ? ` \u2014 ${attempt.error}` : "";
|
|
1910
|
+
const duration = attempt.duration > 0 ? ` (${Math.round(attempt.duration / 1e3)}s)` : "";
|
|
1911
|
+
warn(` Attempt ${i + 1}: ${attempt.provider} ${status2}${error2}${duration}`);
|
|
1912
|
+
}
|
|
1913
|
+
}
|
|
1086
1914
|
function resolveBaseBranch(config2, repoPath) {
|
|
1087
|
-
const workspace =
|
|
1088
|
-
const repo = config2.repos.find((r) =>
|
|
1915
|
+
const workspace = resolve5(config2.workspace);
|
|
1916
|
+
const repo = config2.repos.find((r) => resolve5(workspace, r.path) === repoPath);
|
|
1089
1917
|
return repo?.base_branch ?? config2.base_branch;
|
|
1090
1918
|
}
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1919
|
+
function findRepoConfig(config2, issue) {
|
|
1920
|
+
if (config2.repos.length === 0) return void 0;
|
|
1921
|
+
if (issue.repo) {
|
|
1922
|
+
const match = config2.repos.find((r) => r.name === issue.repo);
|
|
1923
|
+
if (match) return match;
|
|
1924
|
+
}
|
|
1925
|
+
for (const r of config2.repos) {
|
|
1926
|
+
if (r.match && issue.title.startsWith(r.match)) return r;
|
|
1927
|
+
}
|
|
1928
|
+
return config2.repos[0];
|
|
1929
|
+
}
|
|
1930
|
+
async function runTestValidation(cwd) {
|
|
1931
|
+
const testRunner = detectTestRunner(cwd);
|
|
1932
|
+
if (!testRunner) return true;
|
|
1933
|
+
log(`Running test validation (${testRunner} detected)...`);
|
|
1934
|
+
try {
|
|
1935
|
+
await execa3("npm", ["run", "test"], { cwd, stdio: "pipe" });
|
|
1936
|
+
ok("Tests passed.");
|
|
1937
|
+
return true;
|
|
1938
|
+
} catch (err) {
|
|
1939
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1940
|
+
error(`Tests failed: ${message}`);
|
|
1941
|
+
return false;
|
|
1942
|
+
}
|
|
1943
|
+
}
|
|
1944
|
+
async function runWorktreeSession(config2, issue, logFile, session, models) {
|
|
1945
|
+
const workspace = resolve5(config2.workspace);
|
|
1094
1946
|
const repoPath = determineRepoPath(config2.repos, issue, workspace) ?? workspace;
|
|
1095
1947
|
const defaultBranch = resolveBaseBranch(config2, repoPath);
|
|
1096
1948
|
const branchName = generateBranchName(issue.id, issue.title);
|
|
@@ -1100,39 +1952,105 @@ async function runWorktreeSession(config2, issue, logFile, session) {
|
|
|
1100
1952
|
worktreePath = await createWorktree(repoPath, branchName, defaultBranch);
|
|
1101
1953
|
} catch (err) {
|
|
1102
1954
|
error(`Failed to create worktree: ${err instanceof Error ? err.message : String(err)}`);
|
|
1103
|
-
return
|
|
1955
|
+
return {
|
|
1956
|
+
success: false,
|
|
1957
|
+
providerUsed: models[0] ?? "claude",
|
|
1958
|
+
prUrls: [],
|
|
1959
|
+
fallback: {
|
|
1960
|
+
success: false,
|
|
1961
|
+
output: "",
|
|
1962
|
+
duration: 0,
|
|
1963
|
+
providerUsed: models[0] ?? "claude",
|
|
1964
|
+
attempts: []
|
|
1965
|
+
}
|
|
1966
|
+
};
|
|
1104
1967
|
}
|
|
1105
1968
|
ok(`Worktree created at ${worktreePath}`);
|
|
1106
|
-
const
|
|
1969
|
+
const repo = findRepoConfig(config2, issue);
|
|
1970
|
+
if (repo?.lifecycle) {
|
|
1971
|
+
const started = await startResources(repo, worktreePath);
|
|
1972
|
+
if (!started) {
|
|
1973
|
+
error(`Lifecycle startup failed for ${issue.id}. Aborting session.`);
|
|
1974
|
+
await cleanupWorktree(repoPath, worktreePath);
|
|
1975
|
+
return {
|
|
1976
|
+
success: false,
|
|
1977
|
+
providerUsed: models[0] ?? "claude",
|
|
1978
|
+
prUrls: [],
|
|
1979
|
+
fallback: {
|
|
1980
|
+
success: false,
|
|
1981
|
+
output: "",
|
|
1982
|
+
duration: 0,
|
|
1983
|
+
providerUsed: models[0] ?? "claude",
|
|
1984
|
+
attempts: []
|
|
1985
|
+
}
|
|
1986
|
+
};
|
|
1987
|
+
}
|
|
1988
|
+
}
|
|
1989
|
+
const testRunner = detectTestRunner(worktreePath);
|
|
1990
|
+
if (testRunner) {
|
|
1991
|
+
log(`Detected test runner: ${testRunner}`);
|
|
1992
|
+
}
|
|
1993
|
+
const prompt = buildImplementPrompt(issue, config2, testRunner);
|
|
1107
1994
|
log(`Implementing in worktree... (log: ${logFile})`);
|
|
1108
1995
|
initLogFile(logFile);
|
|
1109
|
-
const result = await
|
|
1996
|
+
const result = await runWithFallback(models, prompt, {
|
|
1997
|
+
logFile,
|
|
1998
|
+
cwd: worktreePath,
|
|
1999
|
+
guardrailsDir: repoPath,
|
|
2000
|
+
issueId: issue.id,
|
|
2001
|
+
overseer: config2.overseer
|
|
2002
|
+
});
|
|
1110
2003
|
try {
|
|
1111
|
-
appendFileSync6(
|
|
2004
|
+
appendFileSync6(
|
|
2005
|
+
logFile,
|
|
2006
|
+
`
|
|
1112
2007
|
${"=".repeat(80)}
|
|
2008
|
+
Provider used: ${result.providerUsed}
|
|
1113
2009
|
Full output:
|
|
1114
2010
|
${result.output}
|
|
1115
|
-
`
|
|
2011
|
+
`
|
|
2012
|
+
);
|
|
1116
2013
|
} catch {
|
|
1117
2014
|
}
|
|
2015
|
+
if (repo?.lifecycle) {
|
|
2016
|
+
await stopResources();
|
|
2017
|
+
}
|
|
1118
2018
|
if (!result.success) {
|
|
1119
2019
|
error(`Session ${session} failed for ${issue.id}. Check ${logFile}`);
|
|
1120
2020
|
await cleanupWorktree(repoPath, worktreePath);
|
|
1121
|
-
return [];
|
|
2021
|
+
return { success: false, providerUsed: result.providerUsed, prUrls: [], fallback: result };
|
|
2022
|
+
}
|
|
2023
|
+
const testsPassed = await runTestValidation(worktreePath);
|
|
2024
|
+
if (!testsPassed) {
|
|
2025
|
+
error(`Tests failed for ${issue.id}. Blocking PR creation.`);
|
|
2026
|
+
await cleanupWorktree(repoPath, worktreePath);
|
|
2027
|
+
return { success: false, providerUsed: result.providerUsed, prUrls: [], fallback: result };
|
|
1122
2028
|
}
|
|
2029
|
+
try {
|
|
2030
|
+
await execa3("git", ["push", "-u", "origin", branchName], { cwd: worktreePath });
|
|
2031
|
+
} catch (err) {
|
|
2032
|
+
error(
|
|
2033
|
+
`Failed to push branch to remote: ${err instanceof Error ? err.message : String(err)}`
|
|
2034
|
+
);
|
|
2035
|
+
await cleanupWorktree(repoPath, worktreePath);
|
|
2036
|
+
return { success: false, providerUsed: result.providerUsed, prUrls: [], fallback: result };
|
|
2037
|
+
}
|
|
2038
|
+
const prTitle = readPrTitle(worktreePath) ?? issue.title;
|
|
2039
|
+
cleanupPrTitle(worktreePath);
|
|
1123
2040
|
const prUrls = [];
|
|
1124
2041
|
try {
|
|
1125
2042
|
const repoInfo = await getRepoInfo(worktreePath);
|
|
1126
|
-
const pr = await createPullRequest(
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
2043
|
+
const pr = await createPullRequest(
|
|
2044
|
+
{
|
|
2045
|
+
owner: repoInfo.owner,
|
|
2046
|
+
repo: repoInfo.repo,
|
|
2047
|
+
head: branchName,
|
|
2048
|
+
base: defaultBranch,
|
|
2049
|
+
title: prTitle,
|
|
2050
|
+
body: buildPrBody(issue, result.providerUsed)
|
|
2051
|
+
},
|
|
2052
|
+
config2.github
|
|
2053
|
+
);
|
|
1136
2054
|
ok(`PR created: ${pr.html_url}`);
|
|
1137
2055
|
prUrls.push(pr.html_url);
|
|
1138
2056
|
} catch (err) {
|
|
@@ -1140,49 +2058,98 @@ Implemented by [lisa](https://github.com/tarcisiopgs/lisa).`
|
|
|
1140
2058
|
}
|
|
1141
2059
|
await cleanupWorktree(repoPath, worktreePath);
|
|
1142
2060
|
ok(`Session ${session} complete for ${issue.id}`);
|
|
1143
|
-
return prUrls;
|
|
2061
|
+
return { success: true, providerUsed: result.providerUsed, prUrls, fallback: result };
|
|
1144
2062
|
}
|
|
1145
|
-
async function runBranchSession(config2, issue, logFile, session) {
|
|
1146
|
-
const
|
|
1147
|
-
const
|
|
1148
|
-
|
|
2063
|
+
async function runBranchSession(config2, issue, logFile, session, models) {
|
|
2064
|
+
const workspace = resolve5(config2.workspace);
|
|
2065
|
+
const testRunner = detectTestRunner(workspace);
|
|
2066
|
+
if (testRunner) {
|
|
2067
|
+
log(`Detected test runner: ${testRunner}`);
|
|
2068
|
+
}
|
|
2069
|
+
const prompt = buildImplementPrompt(issue, config2, testRunner);
|
|
2070
|
+
const repo = findRepoConfig(config2, issue);
|
|
2071
|
+
if (repo?.lifecycle) {
|
|
2072
|
+
const cwd = resolve5(workspace, repo.path);
|
|
2073
|
+
const started = await startResources(repo, cwd);
|
|
2074
|
+
if (!started) {
|
|
2075
|
+
error(`Lifecycle startup failed for ${issue.id}. Aborting session.`);
|
|
2076
|
+
return {
|
|
2077
|
+
success: false,
|
|
2078
|
+
providerUsed: models[0] ?? "claude",
|
|
2079
|
+
prUrls: [],
|
|
2080
|
+
fallback: {
|
|
2081
|
+
success: false,
|
|
2082
|
+
output: "",
|
|
2083
|
+
duration: 0,
|
|
2084
|
+
providerUsed: models[0] ?? "claude",
|
|
2085
|
+
attempts: []
|
|
2086
|
+
}
|
|
2087
|
+
};
|
|
2088
|
+
}
|
|
2089
|
+
}
|
|
1149
2090
|
log(`Implementing... (log: ${logFile})`);
|
|
1150
2091
|
initLogFile(logFile);
|
|
1151
|
-
const result = await
|
|
2092
|
+
const result = await runWithFallback(models, prompt, {
|
|
2093
|
+
logFile,
|
|
2094
|
+
cwd: workspace,
|
|
2095
|
+
guardrailsDir: workspace,
|
|
2096
|
+
issueId: issue.id,
|
|
2097
|
+
overseer: config2.overseer
|
|
2098
|
+
});
|
|
1152
2099
|
try {
|
|
1153
|
-
appendFileSync6(
|
|
2100
|
+
appendFileSync6(
|
|
2101
|
+
logFile,
|
|
2102
|
+
`
|
|
1154
2103
|
${"=".repeat(80)}
|
|
2104
|
+
Provider used: ${result.providerUsed}
|
|
1155
2105
|
Full output:
|
|
1156
2106
|
${result.output}
|
|
1157
|
-
`
|
|
2107
|
+
`
|
|
2108
|
+
);
|
|
1158
2109
|
} catch {
|
|
1159
2110
|
}
|
|
2111
|
+
if (repo?.lifecycle) {
|
|
2112
|
+
await stopResources();
|
|
2113
|
+
}
|
|
1160
2114
|
if (!result.success) {
|
|
1161
2115
|
error(`Session ${session} failed for ${issue.id}. Check ${logFile}`);
|
|
1162
|
-
return [];
|
|
1163
|
-
}
|
|
1164
|
-
const
|
|
2116
|
+
return { success: false, providerUsed: result.providerUsed, prUrls: [], fallback: result };
|
|
2117
|
+
}
|
|
2118
|
+
const testsPassed = await runTestValidation(workspace);
|
|
2119
|
+
if (!testsPassed) {
|
|
2120
|
+
error(`Tests failed for ${issue.id}. Blocking PR creation.`);
|
|
2121
|
+
return { success: false, providerUsed: result.providerUsed, prUrls: [], fallback: result };
|
|
2122
|
+
}
|
|
2123
|
+
const detected = await detectFeatureBranches(
|
|
2124
|
+
config2.repos,
|
|
2125
|
+
issue.id,
|
|
2126
|
+
workspace,
|
|
2127
|
+
config2.base_branch
|
|
2128
|
+
);
|
|
1165
2129
|
if (detected.length === 0) {
|
|
1166
2130
|
error(`Could not detect feature branch for ${issue.id} \u2014 skipping PR creation`);
|
|
1167
2131
|
ok(`Session ${session} complete for ${issue.id}`);
|
|
1168
|
-
return [];
|
|
2132
|
+
return { success: true, providerUsed: result.providerUsed, prUrls: [], fallback: result };
|
|
1169
2133
|
}
|
|
2134
|
+
const prTitle = readPrTitle(workspace) ?? issue.title;
|
|
2135
|
+
cleanupPrTitle(workspace);
|
|
1170
2136
|
const prUrls = [];
|
|
1171
2137
|
for (const { repoPath, branch } of detected) {
|
|
1172
2138
|
const baseBranch = resolveBaseBranch(config2, repoPath);
|
|
1173
2139
|
if (branch === baseBranch) continue;
|
|
1174
2140
|
try {
|
|
1175
2141
|
const repoInfo = await getRepoInfo(repoPath);
|
|
1176
|
-
const pr = await createPullRequest(
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
2142
|
+
const pr = await createPullRequest(
|
|
2143
|
+
{
|
|
2144
|
+
owner: repoInfo.owner,
|
|
2145
|
+
repo: repoInfo.repo,
|
|
2146
|
+
head: branch,
|
|
2147
|
+
base: baseBranch,
|
|
2148
|
+
title: prTitle,
|
|
2149
|
+
body: buildPrBody(issue, result.providerUsed)
|
|
2150
|
+
},
|
|
2151
|
+
config2.github
|
|
2152
|
+
);
|
|
1186
2153
|
ok(`PR created: ${pr.html_url}`);
|
|
1187
2154
|
prUrls.push(pr.html_url);
|
|
1188
2155
|
} catch (err) {
|
|
@@ -1190,7 +2157,7 @@ Implemented by [lisa](https://github.com/tarcisiopgs/lisa).`
|
|
|
1190
2157
|
}
|
|
1191
2158
|
}
|
|
1192
2159
|
ok(`Session ${session} complete for ${issue.id}`);
|
|
1193
|
-
return prUrls;
|
|
2160
|
+
return { success: true, providerUsed: result.providerUsed, prUrls, fallback: result };
|
|
1194
2161
|
}
|
|
1195
2162
|
async function cleanupWorktree(repoRoot, worktreePath) {
|
|
1196
2163
|
try {
|
|
@@ -1201,7 +2168,7 @@ async function cleanupWorktree(repoRoot, worktreePath) {
|
|
|
1201
2168
|
}
|
|
1202
2169
|
}
|
|
1203
2170
|
function sleep(ms) {
|
|
1204
|
-
return new Promise((
|
|
2171
|
+
return new Promise((resolve6) => setTimeout(resolve6, ms));
|
|
1205
2172
|
}
|
|
1206
2173
|
|
|
1207
2174
|
// src/cli.ts
|
|
@@ -1211,6 +2178,7 @@ var run = defineCommand({
|
|
|
1211
2178
|
once: { type: "boolean", description: "Run a single iteration", default: false },
|
|
1212
2179
|
limit: { type: "string", description: "Max number of issues to process", default: "0" },
|
|
1213
2180
|
"dry-run": { type: "boolean", description: "Preview without executing", default: false },
|
|
2181
|
+
issue: { type: "string", description: "Run a specific issue by identifier or URL" },
|
|
1214
2182
|
provider: { type: "string", description: "AI provider (claude, gemini, opencode)" },
|
|
1215
2183
|
source: { type: "string", description: "Issue source (linear, trello)" },
|
|
1216
2184
|
label: { type: "string", description: "Label to filter issues" },
|
|
@@ -1236,16 +2204,21 @@ var run = defineCommand({
|
|
|
1236
2204
|
const missingVars = await getMissingEnvVars(merged.source);
|
|
1237
2205
|
if (missingVars.length > 0) {
|
|
1238
2206
|
const shell = process.env.SHELL?.includes("zsh") ? "~/.zshrc" : "~/.bashrc";
|
|
1239
|
-
console.error(
|
|
1240
|
-
|
|
2207
|
+
console.error(
|
|
2208
|
+
pc2.red(
|
|
2209
|
+
`Missing required environment variables:
|
|
2210
|
+
${missingVars.map((v) => ` ${v}`).join("\n")}`
|
|
2211
|
+
)
|
|
2212
|
+
);
|
|
1241
2213
|
console.error(pc2.dim(`
|
|
1242
2214
|
Add them to your ${shell} and run: source ${shell}`));
|
|
1243
2215
|
process.exit(1);
|
|
1244
2216
|
}
|
|
1245
2217
|
await runLoop(merged, {
|
|
1246
|
-
once: args.once,
|
|
2218
|
+
once: args.once || !!args.issue,
|
|
1247
2219
|
limit: Number.parseInt(args.limit, 10),
|
|
1248
|
-
dryRun: args["dry-run"]
|
|
2220
|
+
dryRun: args["dry-run"],
|
|
2221
|
+
issueId: args.issue
|
|
1249
2222
|
});
|
|
1250
2223
|
}
|
|
1251
2224
|
});
|
|
@@ -1281,7 +2254,9 @@ var init = defineCommand({
|
|
|
1281
2254
|
meta: { name: "init", description: "Initialize lisa configuration" },
|
|
1282
2255
|
async run() {
|
|
1283
2256
|
if (!process.stdin.isTTY) {
|
|
1284
|
-
console.error(
|
|
2257
|
+
console.error(
|
|
2258
|
+
pc2.red("Interactive mode requires a TTY. Cannot run init in non-interactive environments.")
|
|
2259
|
+
);
|
|
1285
2260
|
process.exit(1);
|
|
1286
2261
|
}
|
|
1287
2262
|
if (configExists()) {
|
|
@@ -1315,8 +2290,8 @@ var status = defineCommand({
|
|
|
1315
2290
|
console.log(` In progress: ${pc2.bold(config2.source_config.in_progress)}`);
|
|
1316
2291
|
console.log(` Done: ${pc2.bold(config2.source_config.done)}`);
|
|
1317
2292
|
console.log(` Logs: ${pc2.dim(config2.logs.dir)}`);
|
|
1318
|
-
const { readdirSync: readdirSync2, existsSync:
|
|
1319
|
-
if (
|
|
2293
|
+
const { readdirSync: readdirSync2, existsSync: existsSync7 } = await import("fs");
|
|
2294
|
+
if (existsSync7(config2.logs.dir)) {
|
|
1320
2295
|
const logs = readdirSync2(config2.logs.dir).filter((f) => f.endsWith(".log"));
|
|
1321
2296
|
console.log(`
|
|
1322
2297
|
${pc2.cyan("Sessions:")} ${logs.length} log file(s) found`);
|
|
@@ -1329,7 +2304,7 @@ ${pc2.dim("No sessions yet.")}`);
|
|
|
1329
2304
|
function getVersion() {
|
|
1330
2305
|
try {
|
|
1331
2306
|
const pkgPath = resolvePath(new URL(".", import.meta.url).pathname, "../package.json");
|
|
1332
|
-
const pkg = JSON.parse(
|
|
2307
|
+
const pkg = JSON.parse(readFileSync6(pkgPath, "utf-8"));
|
|
1333
2308
|
return pkg.version;
|
|
1334
2309
|
} catch {
|
|
1335
2310
|
return "0.0.0";
|
|
@@ -1339,7 +2314,7 @@ var main = defineCommand({
|
|
|
1339
2314
|
meta: {
|
|
1340
2315
|
name: "lisa",
|
|
1341
2316
|
version: getVersion(),
|
|
1342
|
-
description: "
|
|
2317
|
+
description: "Deterministic autonomous issue resolver \u2014 structured AI agent loop for Linear/Trello"
|
|
1343
2318
|
},
|
|
1344
2319
|
subCommands: { run, config, init, status }
|
|
1345
2320
|
});
|
|
@@ -1365,7 +2340,7 @@ After installing, run ${pc2.cyan("lisa init")} again.`
|
|
|
1365
2340
|
return process.exit(1);
|
|
1366
2341
|
}
|
|
1367
2342
|
let providerName;
|
|
1368
|
-
if (available.length === 1) {
|
|
2343
|
+
if (available.length === 1 && available[0]) {
|
|
1369
2344
|
providerName = available[0].name;
|
|
1370
2345
|
clack.log.info(`Found provider: ${pc2.bold(providerLabels[providerName])}`);
|
|
1371
2346
|
} else {
|
|
@@ -1551,12 +2526,12 @@ async function detectGitHubMethod() {
|
|
|
1551
2526
|
}
|
|
1552
2527
|
async function detectGitRepos() {
|
|
1553
2528
|
const cwd = process.cwd();
|
|
1554
|
-
if (
|
|
2529
|
+
if (existsSync6(join8(cwd, ".git"))) {
|
|
1555
2530
|
clack.log.info(`Detected git repository in current directory.`);
|
|
1556
2531
|
return [];
|
|
1557
2532
|
}
|
|
1558
2533
|
const entries = readdirSync(cwd, { withFileTypes: true });
|
|
1559
|
-
const gitDirs = entries.filter((e) => e.isDirectory() &&
|
|
2534
|
+
const gitDirs = entries.filter((e) => e.isDirectory() && existsSync6(join8(cwd, e.name, ".git"))).map((e) => e.name);
|
|
1560
2535
|
if (gitDirs.length === 0) {
|
|
1561
2536
|
return [];
|
|
1562
2537
|
}
|
|
@@ -1566,7 +2541,7 @@ async function detectGitRepos() {
|
|
|
1566
2541
|
});
|
|
1567
2542
|
if (clack.isCancel(selected)) return process.exit(0);
|
|
1568
2543
|
return selected.map((dir) => ({
|
|
1569
|
-
name: getGitRepoName(
|
|
2544
|
+
name: getGitRepoName(join8(cwd, dir)) ?? dir,
|
|
1570
2545
|
path: `./${dir}`,
|
|
1571
2546
|
match: "",
|
|
1572
2547
|
base_branch: ""
|