@tarcisiopgs/lisa 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +113 -0
- package/dist/index.js +1157 -0
- package/package.json +35 -0
package/README.md
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# lisa
|
|
2
|
+
|
|
3
|
+
Autonomous issue resolver — picks up issues from Linear or Trello, sends them to an AI coding agent (Claude Code, Gemini CLI, or OpenCode), and opens PRs via the GitHub API. No MCP servers required.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install -g @tarcisiopgs/lisa
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Environment Variables
|
|
12
|
+
|
|
13
|
+
lisa calls external APIs directly. Set these in your shell profile (`~/.zshrc` or `~/.bashrc`):
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
# Required (always)
|
|
17
|
+
export GITHUB_TOKEN=""
|
|
18
|
+
|
|
19
|
+
# Required when source = linear
|
|
20
|
+
export LINEAR_API_KEY=""
|
|
21
|
+
|
|
22
|
+
# Required when source = trello
|
|
23
|
+
export TRELLO_API_KEY=""
|
|
24
|
+
export TRELLO_TOKEN=""
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
The CLI will warn you if any required variable is missing.
|
|
28
|
+
|
|
29
|
+
## Quick Start
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
# Interactive setup
|
|
33
|
+
lisa init
|
|
34
|
+
|
|
35
|
+
# Run continuously
|
|
36
|
+
lisa run
|
|
37
|
+
|
|
38
|
+
# Single issue
|
|
39
|
+
lisa run --once
|
|
40
|
+
|
|
41
|
+
# Preview without executing
|
|
42
|
+
lisa run --dry-run
|
|
43
|
+
|
|
44
|
+
# Override provider
|
|
45
|
+
lisa run --provider gemini --once
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Commands
|
|
49
|
+
|
|
50
|
+
| Command | Description |
|
|
51
|
+
|---------|-------------|
|
|
52
|
+
| `lisa run` | Run the agent loop |
|
|
53
|
+
| `lisa run --once` | Process a single issue |
|
|
54
|
+
| `lisa run --limit N` | Process up to N issues |
|
|
55
|
+
| `lisa run --dry-run` | Preview without executing |
|
|
56
|
+
| `lisa config` | Interactive config wizard |
|
|
57
|
+
| `lisa config --show` | Show current config |
|
|
58
|
+
| `lisa config --set key=value` | Set a config value |
|
|
59
|
+
| `lisa init` | Create `.lisa/config.yaml` |
|
|
60
|
+
| `lisa status` | Show session stats |
|
|
61
|
+
|
|
62
|
+
## Providers
|
|
63
|
+
|
|
64
|
+
| Provider | CLI | Auto-approve Flag |
|
|
65
|
+
|----------|-----|-------------------|
|
|
66
|
+
| Claude Code | `claude` | `--dangerously-skip-permissions` |
|
|
67
|
+
| Gemini CLI | `gemini` | `--yolo` |
|
|
68
|
+
| OpenCode | `opencode` | implicit in `run` |
|
|
69
|
+
|
|
70
|
+
At least one provider must be installed and available in your PATH.
|
|
71
|
+
|
|
72
|
+
## Configuration
|
|
73
|
+
|
|
74
|
+
Config lives in `.lisa/config.yaml`:
|
|
75
|
+
|
|
76
|
+
```yaml
|
|
77
|
+
provider: claude
|
|
78
|
+
|
|
79
|
+
source: linear
|
|
80
|
+
source_config:
|
|
81
|
+
team: Internal
|
|
82
|
+
project: Zenixx
|
|
83
|
+
label: ready
|
|
84
|
+
status: Backlog
|
|
85
|
+
|
|
86
|
+
workspace: .
|
|
87
|
+
repos:
|
|
88
|
+
- name: app
|
|
89
|
+
path: ./app
|
|
90
|
+
match: "App:"
|
|
91
|
+
|
|
92
|
+
loop:
|
|
93
|
+
cooldown: 10
|
|
94
|
+
max_sessions: 0
|
|
95
|
+
|
|
96
|
+
logs:
|
|
97
|
+
dir: .lisa/logs
|
|
98
|
+
format: text
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
CLI flags override config values:
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
lisa run --provider gemini --label "urgent"
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## How It Works
|
|
108
|
+
|
|
109
|
+
1. **Fetch** — Calls the Linear GraphQL API or Trello REST API to get the next issue matching the configured label, team, and project. Issues are sorted by priority.
|
|
110
|
+
2. **Implement** — Builds a prompt with the issue title, description, and repo context, then sends it to the AI coding agent. The agent creates a branch, implements the changes, and pushes to origin.
|
|
111
|
+
3. **PR** — Creates a pull request via the GitHub API referencing the original issue.
|
|
112
|
+
4. **Update** — Moves the issue status to "In Review" and removes the pickup label via the source API.
|
|
113
|
+
5. **Loop** — Waits `cooldown` seconds, then picks the next issue. Repeats until no issues remain or the limit is reached.
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,1157 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import { existsSync as existsSync3, readFileSync as readFileSync2, readdirSync } from "fs";
|
|
5
|
+
import { join, resolve as resolvePath } from "path";
|
|
6
|
+
import { defineCommand, runMain } from "citty";
|
|
7
|
+
import * as clack from "@clack/prompts";
|
|
8
|
+
import pc2 from "picocolors";
|
|
9
|
+
|
|
10
|
+
// src/config.ts
|
|
11
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
12
|
+
import { resolve } from "path";
|
|
13
|
+
import { parse, stringify } from "yaml";
|
|
14
|
+
var CONFIG_DIR = ".lisa";
|
|
15
|
+
var CONFIG_FILE = "config.yaml";
|
|
16
|
+
var DEFAULT_CONFIG = {
|
|
17
|
+
provider: "",
|
|
18
|
+
source: "",
|
|
19
|
+
source_config: {
|
|
20
|
+
team: "",
|
|
21
|
+
project: "",
|
|
22
|
+
label: "",
|
|
23
|
+
status: ""
|
|
24
|
+
},
|
|
25
|
+
github: "cli",
|
|
26
|
+
workspace: "",
|
|
27
|
+
repos: [],
|
|
28
|
+
loop: {
|
|
29
|
+
cooldown: 0,
|
|
30
|
+
max_sessions: 0
|
|
31
|
+
},
|
|
32
|
+
logs: {
|
|
33
|
+
dir: "",
|
|
34
|
+
format: ""
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
function getConfigPath(cwd = process.cwd()) {
|
|
38
|
+
return resolve(cwd, CONFIG_DIR, CONFIG_FILE);
|
|
39
|
+
}
|
|
40
|
+
function configExists(cwd = process.cwd()) {
|
|
41
|
+
return existsSync(getConfigPath(cwd));
|
|
42
|
+
}
|
|
43
|
+
function loadConfig(cwd = process.cwd()) {
|
|
44
|
+
const configPath = getConfigPath(cwd);
|
|
45
|
+
if (!existsSync(configPath)) {
|
|
46
|
+
return { ...DEFAULT_CONFIG };
|
|
47
|
+
}
|
|
48
|
+
const raw = readFileSync(configPath, "utf-8");
|
|
49
|
+
const parsed = parse(raw);
|
|
50
|
+
return {
|
|
51
|
+
...DEFAULT_CONFIG,
|
|
52
|
+
...parsed,
|
|
53
|
+
source_config: { ...DEFAULT_CONFIG.source_config, ...parsed.source_config },
|
|
54
|
+
loop: { ...DEFAULT_CONFIG.loop, ...parsed.loop },
|
|
55
|
+
logs: { ...DEFAULT_CONFIG.logs, ...parsed.logs }
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
function saveConfig(config2, cwd = process.cwd()) {
|
|
59
|
+
const configPath = getConfigPath(cwd);
|
|
60
|
+
const dir = resolve(cwd, CONFIG_DIR);
|
|
61
|
+
if (!existsSync(dir)) {
|
|
62
|
+
mkdirSync(dir, { recursive: true });
|
|
63
|
+
}
|
|
64
|
+
writeFileSync(configPath, stringify(config2), "utf-8");
|
|
65
|
+
}
|
|
66
|
+
function mergeWithFlags(config2, flags) {
|
|
67
|
+
const merged = { ...config2 };
|
|
68
|
+
if (flags.provider) merged.provider = flags.provider;
|
|
69
|
+
if (flags.source) merged.source = flags.source;
|
|
70
|
+
if (flags.github) merged.github = flags.github;
|
|
71
|
+
if (flags.label) merged.source_config = { ...merged.source_config, label: flags.label };
|
|
72
|
+
return merged;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// src/logger.ts
|
|
76
|
+
import { appendFileSync, existsSync as existsSync2, mkdirSync as mkdirSync2 } from "fs";
|
|
77
|
+
import { dirname } from "path";
|
|
78
|
+
import pc from "picocolors";
|
|
79
|
+
var logFilePath = null;
|
|
80
|
+
var outputMode = "default";
|
|
81
|
+
var jsonEvents = [];
|
|
82
|
+
function setOutputMode(mode) {
|
|
83
|
+
outputMode = mode;
|
|
84
|
+
}
|
|
85
|
+
function initLogFile(path) {
|
|
86
|
+
const dir = dirname(path);
|
|
87
|
+
if (!existsSync2(dir)) {
|
|
88
|
+
mkdirSync2(dir, { recursive: true });
|
|
89
|
+
}
|
|
90
|
+
logFilePath = path;
|
|
91
|
+
}
|
|
92
|
+
function timestamp() {
|
|
93
|
+
return (/* @__PURE__ */ new Date()).toLocaleTimeString("en-US", { hour12: false });
|
|
94
|
+
}
|
|
95
|
+
function writeToFile(level, message) {
|
|
96
|
+
if (logFilePath) {
|
|
97
|
+
appendFileSync(logFilePath, `[${timestamp()}] [${level}] ${message}
|
|
98
|
+
`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
function emitJson(level, message) {
|
|
102
|
+
const event = { time: timestamp(), level, message };
|
|
103
|
+
jsonEvents.push(event);
|
|
104
|
+
console.log(JSON.stringify(event));
|
|
105
|
+
}
|
|
106
|
+
function log(message) {
|
|
107
|
+
if (outputMode === "json") return emitJson("info", message);
|
|
108
|
+
if (outputMode !== "quiet") {
|
|
109
|
+
console.log(`${pc.cyan("[lisa]")} ${pc.dim(timestamp())} ${message}`);
|
|
110
|
+
}
|
|
111
|
+
writeToFile("info", message);
|
|
112
|
+
}
|
|
113
|
+
function warn(message) {
|
|
114
|
+
if (outputMode === "json") return emitJson("warn", message);
|
|
115
|
+
if (outputMode !== "quiet") {
|
|
116
|
+
console.error(`${pc.yellow("[lisa]")} ${pc.dim(timestamp())} ${message}`);
|
|
117
|
+
}
|
|
118
|
+
writeToFile("warn", message);
|
|
119
|
+
}
|
|
120
|
+
function error(message) {
|
|
121
|
+
if (outputMode === "json") return emitJson("error", message);
|
|
122
|
+
console.error(`${pc.red("[lisa]")} ${pc.dim(timestamp())} ${message}`);
|
|
123
|
+
writeToFile("error", message);
|
|
124
|
+
}
|
|
125
|
+
function ok(message) {
|
|
126
|
+
if (outputMode === "json") return emitJson("ok", message);
|
|
127
|
+
if (outputMode !== "quiet") {
|
|
128
|
+
console.log(`${pc.green("[lisa]")} ${pc.dim(timestamp())} ${message}`);
|
|
129
|
+
}
|
|
130
|
+
writeToFile("ok", message);
|
|
131
|
+
}
|
|
132
|
+
function divider(session) {
|
|
133
|
+
log(`${"\u2501".repeat(3)} Session ${session} ${"\u2501".repeat(3)}`);
|
|
134
|
+
}
|
|
135
|
+
function banner() {
|
|
136
|
+
if (outputMode !== "default") return;
|
|
137
|
+
console.log(
|
|
138
|
+
pc.cyan(`
|
|
139
|
+
\u250C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510
|
|
140
|
+
\u2502 lisa \u2014 autonomous issue resolver \u2502
|
|
141
|
+
\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518
|
|
142
|
+
`)
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// src/loop.ts
|
|
147
|
+
import { resolve as resolve3 } from "path";
|
|
148
|
+
import { appendFileSync as appendFileSync2 } from "fs";
|
|
149
|
+
|
|
150
|
+
// src/prompt.ts
|
|
151
|
+
import { resolve as resolve2 } from "path";
|
|
152
|
+
function buildImplementPrompt(issue, config2) {
|
|
153
|
+
const workspace = resolve2(config2.workspace);
|
|
154
|
+
const repoEntries = config2.repos.map((r) => ` - If it says "Repo: ${r.name}" or title starts with "${r.match}" \u2192 \`${resolve2(workspace, r.path)}\``).join("\n");
|
|
155
|
+
return `You are an autonomous implementation agent. Your job is to implement a single
|
|
156
|
+
issue, validate it, commit, and push the branch.
|
|
157
|
+
|
|
158
|
+
## Issue
|
|
159
|
+
|
|
160
|
+
- **ID:** ${issue.id}
|
|
161
|
+
- **Title:** ${issue.title}
|
|
162
|
+
- **URL:** ${issue.url}
|
|
163
|
+
|
|
164
|
+
### Description
|
|
165
|
+
|
|
166
|
+
${issue.description}
|
|
167
|
+
|
|
168
|
+
## Instructions
|
|
169
|
+
|
|
170
|
+
1. **Identify the repo**: Look at the issue description for relevant files or repo references.
|
|
171
|
+
${repoEntries}
|
|
172
|
+
- If it references multiple repos, pick the PRIMARY one (the one with the most files listed).
|
|
173
|
+
|
|
174
|
+
2. **Create a branch**: From the repo's main branch, create a branch named after the issue
|
|
175
|
+
(e.g., \`feat/${issue.id.toLowerCase()}-short-description\`).
|
|
176
|
+
|
|
177
|
+
3. **Implement**: Follow the issue description exactly:
|
|
178
|
+
- Read all relevant files listed in the description first (if present)
|
|
179
|
+
- Follow the implementation instructions exactly
|
|
180
|
+
- Verify each acceptance criteria (if present)
|
|
181
|
+
- Respect any stack or technical constraints (if present)
|
|
182
|
+
|
|
183
|
+
4. **Validate**: Run the project's linter/typecheck/tests if available:
|
|
184
|
+
- Check \`package.json\` (or equivalent) for lint, typecheck, check, or test scripts.
|
|
185
|
+
- Run whichever validation scripts exist (e.g., \`npm run lint\`, \`npm run typecheck\`).
|
|
186
|
+
- Fix any errors before proceeding.
|
|
187
|
+
|
|
188
|
+
5. **Commit & Push**: Make atomic commits with conventional commit messages.
|
|
189
|
+
Push the branch to origin.
|
|
190
|
+
**IMPORTANT \u2014 Language rules:**
|
|
191
|
+
- All commit messages MUST be in English.
|
|
192
|
+
- Use conventional commits format: \`feat: ...\`, \`fix: ...\`, \`refactor: ...\`, \`chore: ...\`
|
|
193
|
+
|
|
194
|
+
## Rules
|
|
195
|
+
|
|
196
|
+
- **ALL git commits, branch names MUST be in English.**
|
|
197
|
+
- The issue description may be in any language \u2014 read it for context but write all code artifacts in English.
|
|
198
|
+
- Do NOT modify files outside the target repo.
|
|
199
|
+
- Do NOT install new dependencies unless the issue explicitly requires it.
|
|
200
|
+
- If you get stuck or the issue is unclear, STOP and explain why.
|
|
201
|
+
- One issue only. Do not pick up additional issues.
|
|
202
|
+
- If the repo has a CLAUDE.md, read it first and follow its conventions.
|
|
203
|
+
- Do NOT create pull requests \u2014 the caller handles that.
|
|
204
|
+
- Do NOT update the issue tracker \u2014 the caller handles that.`;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// src/providers/claude.ts
|
|
208
|
+
import { execa } from "execa";
|
|
209
|
+
var ClaudeProvider = class {
|
|
210
|
+
name = "claude";
|
|
211
|
+
async isAvailable() {
|
|
212
|
+
try {
|
|
213
|
+
await execa("claude", ["--version"]);
|
|
214
|
+
return true;
|
|
215
|
+
} catch {
|
|
216
|
+
return false;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
async run(prompt, opts) {
|
|
220
|
+
const start = Date.now();
|
|
221
|
+
try {
|
|
222
|
+
const proc = execa(
|
|
223
|
+
"claude",
|
|
224
|
+
["--dangerously-skip-permissions", "-p", prompt, "--output-format", "text"],
|
|
225
|
+
{
|
|
226
|
+
cwd: opts.cwd,
|
|
227
|
+
timeout: 30 * 60 * 1e3,
|
|
228
|
+
reject: false
|
|
229
|
+
}
|
|
230
|
+
);
|
|
231
|
+
proc.stdout?.pipe(process.stdout);
|
|
232
|
+
proc.stderr?.pipe(process.stderr);
|
|
233
|
+
const result = await proc;
|
|
234
|
+
const output = result.stdout + (result.stderr ? `
|
|
235
|
+
${result.stderr}` : "");
|
|
236
|
+
return {
|
|
237
|
+
success: result.exitCode === 0,
|
|
238
|
+
output,
|
|
239
|
+
duration: Date.now() - start
|
|
240
|
+
};
|
|
241
|
+
} catch (err) {
|
|
242
|
+
return {
|
|
243
|
+
success: false,
|
|
244
|
+
output: err instanceof Error ? err.message : String(err),
|
|
245
|
+
duration: Date.now() - start
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
// src/providers/gemini.ts
|
|
252
|
+
import { execa as execa2 } from "execa";
|
|
253
|
+
var GeminiProvider = class {
|
|
254
|
+
name = "gemini";
|
|
255
|
+
async isAvailable() {
|
|
256
|
+
try {
|
|
257
|
+
await execa2("gemini", ["--version"]);
|
|
258
|
+
return true;
|
|
259
|
+
} catch {
|
|
260
|
+
return false;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
async run(prompt, opts) {
|
|
264
|
+
const start = Date.now();
|
|
265
|
+
try {
|
|
266
|
+
const proc = execa2(
|
|
267
|
+
"gemini",
|
|
268
|
+
["--yolo", "-p", prompt],
|
|
269
|
+
{
|
|
270
|
+
cwd: opts.cwd,
|
|
271
|
+
timeout: 30 * 60 * 1e3,
|
|
272
|
+
reject: false
|
|
273
|
+
}
|
|
274
|
+
);
|
|
275
|
+
proc.stdout?.pipe(process.stdout);
|
|
276
|
+
proc.stderr?.pipe(process.stderr);
|
|
277
|
+
const result = await proc;
|
|
278
|
+
const output = result.stdout + (result.stderr ? `
|
|
279
|
+
${result.stderr}` : "");
|
|
280
|
+
return {
|
|
281
|
+
success: result.exitCode === 0,
|
|
282
|
+
output,
|
|
283
|
+
duration: Date.now() - start
|
|
284
|
+
};
|
|
285
|
+
} catch (err) {
|
|
286
|
+
return {
|
|
287
|
+
success: false,
|
|
288
|
+
output: err instanceof Error ? err.message : String(err),
|
|
289
|
+
duration: Date.now() - start
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
// src/providers/opencode.ts
|
|
296
|
+
import { execa as execa3 } from "execa";
|
|
297
|
+
var OpenCodeProvider = class {
|
|
298
|
+
name = "opencode";
|
|
299
|
+
async isAvailable() {
|
|
300
|
+
try {
|
|
301
|
+
await execa3("opencode", ["--version"]);
|
|
302
|
+
return true;
|
|
303
|
+
} catch {
|
|
304
|
+
return false;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
async run(prompt, opts) {
|
|
308
|
+
const start = Date.now();
|
|
309
|
+
try {
|
|
310
|
+
const proc = execa3(
|
|
311
|
+
"opencode",
|
|
312
|
+
["run", prompt],
|
|
313
|
+
{
|
|
314
|
+
cwd: opts.cwd,
|
|
315
|
+
timeout: 30 * 60 * 1e3,
|
|
316
|
+
reject: false
|
|
317
|
+
}
|
|
318
|
+
);
|
|
319
|
+
proc.stdout?.pipe(process.stdout);
|
|
320
|
+
proc.stderr?.pipe(process.stderr);
|
|
321
|
+
const result = await proc;
|
|
322
|
+
const output = result.stdout + (result.stderr ? `
|
|
323
|
+
${result.stderr}` : "");
|
|
324
|
+
return {
|
|
325
|
+
success: result.exitCode === 0,
|
|
326
|
+
output,
|
|
327
|
+
duration: Date.now() - start
|
|
328
|
+
};
|
|
329
|
+
} catch (err) {
|
|
330
|
+
return {
|
|
331
|
+
success: false,
|
|
332
|
+
output: err instanceof Error ? err.message : String(err),
|
|
333
|
+
duration: Date.now() - start
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
// src/providers/index.ts
|
|
340
|
+
var providers = {
|
|
341
|
+
claude: () => new ClaudeProvider(),
|
|
342
|
+
gemini: () => new GeminiProvider(),
|
|
343
|
+
opencode: () => new OpenCodeProvider()
|
|
344
|
+
};
|
|
345
|
+
async function getAvailableProviders() {
|
|
346
|
+
const all = Object.values(providers).map((f) => f());
|
|
347
|
+
const results = await Promise.all(
|
|
348
|
+
all.map(async (p) => ({ provider: p, available: await p.isAvailable() }))
|
|
349
|
+
);
|
|
350
|
+
return results.filter((r) => r.available).map((r) => r.provider);
|
|
351
|
+
}
|
|
352
|
+
function createProvider(name) {
|
|
353
|
+
const factory = providers[name];
|
|
354
|
+
if (!factory) {
|
|
355
|
+
throw new Error(`Unknown provider: ${name}. Available: ${Object.keys(providers).join(", ")}`);
|
|
356
|
+
}
|
|
357
|
+
return factory();
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// src/sources/linear.ts
|
|
361
|
+
var API_URL = "https://api.linear.app/graphql";
|
|
362
|
+
var REQUEST_TIMEOUT_MS = 3e4;
|
|
363
|
+
function getApiKey() {
|
|
364
|
+
const key = process.env.LINEAR_API_KEY;
|
|
365
|
+
if (!key) throw new Error("LINEAR_API_KEY is not set");
|
|
366
|
+
return key;
|
|
367
|
+
}
|
|
368
|
+
async function gql(query, variables) {
|
|
369
|
+
const res = await fetch(API_URL, {
|
|
370
|
+
method: "POST",
|
|
371
|
+
headers: {
|
|
372
|
+
"Content-Type": "application/json",
|
|
373
|
+
Authorization: getApiKey()
|
|
374
|
+
},
|
|
375
|
+
body: JSON.stringify({ query, variables }),
|
|
376
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
|
|
377
|
+
});
|
|
378
|
+
if (!res.ok) {
|
|
379
|
+
const text2 = await res.text();
|
|
380
|
+
throw new Error(`Linear API error (${res.status}): ${text2}`);
|
|
381
|
+
}
|
|
382
|
+
const json = await res.json();
|
|
383
|
+
if (json.errors?.length) {
|
|
384
|
+
throw new Error(`Linear GraphQL error: ${json.errors.map((e) => e.message).join(", ")}`);
|
|
385
|
+
}
|
|
386
|
+
return json.data;
|
|
387
|
+
}
|
|
388
|
+
var LinearSource = class {
|
|
389
|
+
name = "linear";
|
|
390
|
+
async fetchNextIssue(config2) {
|
|
391
|
+
const data = await gql(
|
|
392
|
+
`query($teamName: String!, $projectName: String!, $labelName: String!, $statusName: String!) {
|
|
393
|
+
issues(
|
|
394
|
+
filter: {
|
|
395
|
+
team: { name: { eq: $teamName } }
|
|
396
|
+
project: { name: { eq: $projectName } }
|
|
397
|
+
labels: { name: { eq: $labelName } }
|
|
398
|
+
state: { name: { eq: $statusName } }
|
|
399
|
+
}
|
|
400
|
+
first: 20
|
|
401
|
+
) {
|
|
402
|
+
nodes {
|
|
403
|
+
id
|
|
404
|
+
identifier
|
|
405
|
+
title
|
|
406
|
+
description
|
|
407
|
+
url
|
|
408
|
+
priority
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
}`,
|
|
412
|
+
{
|
|
413
|
+
teamName: config2.team,
|
|
414
|
+
projectName: config2.project,
|
|
415
|
+
labelName: config2.label,
|
|
416
|
+
statusName: config2.status
|
|
417
|
+
}
|
|
418
|
+
);
|
|
419
|
+
const issues = data.issues.nodes;
|
|
420
|
+
if (issues.length === 0) return null;
|
|
421
|
+
issues.sort((a, b) => {
|
|
422
|
+
const pa = a.priority === 0 ? 5 : a.priority;
|
|
423
|
+
const pb = b.priority === 0 ? 5 : b.priority;
|
|
424
|
+
return pa - pb;
|
|
425
|
+
});
|
|
426
|
+
const issue = issues[0];
|
|
427
|
+
return {
|
|
428
|
+
id: issue.identifier,
|
|
429
|
+
title: issue.title,
|
|
430
|
+
description: issue.description || "",
|
|
431
|
+
url: issue.url
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
async updateStatus(issueId, statusName) {
|
|
435
|
+
const issueData = await gql(
|
|
436
|
+
`query($identifier: String!) {
|
|
437
|
+
issue(id: $identifier) {
|
|
438
|
+
id
|
|
439
|
+
team { id }
|
|
440
|
+
}
|
|
441
|
+
}`,
|
|
442
|
+
{ identifier: issueId }
|
|
443
|
+
);
|
|
444
|
+
const statesData = await gql(
|
|
445
|
+
`query($teamId: String!) {
|
|
446
|
+
workflowStates(filter: { team: { id: { eq: $teamId } } }) {
|
|
447
|
+
nodes { id name }
|
|
448
|
+
}
|
|
449
|
+
}`,
|
|
450
|
+
{ teamId: issueData.issue.team.id }
|
|
451
|
+
);
|
|
452
|
+
const state = statesData.workflowStates.nodes.find(
|
|
453
|
+
(s) => s.name.toLowerCase() === statusName.toLowerCase()
|
|
454
|
+
);
|
|
455
|
+
if (!state) {
|
|
456
|
+
const available = statesData.workflowStates.nodes.map((s) => s.name).join(", ");
|
|
457
|
+
throw new Error(`Status "${statusName}" not found. Available: ${available}`);
|
|
458
|
+
}
|
|
459
|
+
await gql(
|
|
460
|
+
`mutation($issueId: String!, $stateId: String!) {
|
|
461
|
+
issueUpdate(id: $issueId, input: { stateId: $stateId }) {
|
|
462
|
+
success
|
|
463
|
+
}
|
|
464
|
+
}`,
|
|
465
|
+
{ issueId: issueData.issue.id, stateId: state.id }
|
|
466
|
+
);
|
|
467
|
+
}
|
|
468
|
+
async removeLabel(issueId, labelName) {
|
|
469
|
+
const issueData = await gql(
|
|
470
|
+
`query($identifier: String!) {
|
|
471
|
+
issue(id: $identifier) {
|
|
472
|
+
id
|
|
473
|
+
labels { nodes { id name } }
|
|
474
|
+
}
|
|
475
|
+
}`,
|
|
476
|
+
{ identifier: issueId }
|
|
477
|
+
);
|
|
478
|
+
const currentLabels = issueData.issue.labels.nodes;
|
|
479
|
+
const filtered = currentLabels.filter(
|
|
480
|
+
(l) => l.name.toLowerCase() !== labelName.toLowerCase()
|
|
481
|
+
);
|
|
482
|
+
if (filtered.length === currentLabels.length) return;
|
|
483
|
+
await gql(
|
|
484
|
+
`mutation($issueId: String!, $labelIds: [String!]!) {
|
|
485
|
+
issueUpdate(id: $issueId, input: { labelIds: $labelIds }) {
|
|
486
|
+
success
|
|
487
|
+
}
|
|
488
|
+
}`,
|
|
489
|
+
{
|
|
490
|
+
issueId: issueData.issue.id,
|
|
491
|
+
labelIds: filtered.map((l) => l.id)
|
|
492
|
+
}
|
|
493
|
+
);
|
|
494
|
+
}
|
|
495
|
+
};
|
|
496
|
+
|
|
497
|
+
// src/sources/trello.ts
|
|
498
|
+
var API_URL2 = "https://api.trello.com/1";
|
|
499
|
+
var REQUEST_TIMEOUT_MS2 = 3e4;
|
|
500
|
+
function getAuthHeaders() {
|
|
501
|
+
const key = process.env.TRELLO_API_KEY;
|
|
502
|
+
const token = process.env.TRELLO_TOKEN;
|
|
503
|
+
if (!key || !token) throw new Error("TRELLO_API_KEY and TRELLO_TOKEN must be set");
|
|
504
|
+
return {
|
|
505
|
+
Authorization: `OAuth oauth_consumer_key="${key}", oauth_token="${token}"`
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
async function trelloFetch(method, path, params = "") {
|
|
509
|
+
const sep = params ? "?" : "";
|
|
510
|
+
const url = `${API_URL2}${path}${sep}${params}`;
|
|
511
|
+
const res = await fetch(url, {
|
|
512
|
+
method,
|
|
513
|
+
headers: getAuthHeaders(),
|
|
514
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS2)
|
|
515
|
+
});
|
|
516
|
+
if (!res.ok) {
|
|
517
|
+
const text2 = await res.text();
|
|
518
|
+
throw new Error(`Trello API error (${res.status}): ${text2}`);
|
|
519
|
+
}
|
|
520
|
+
if (method === "DELETE") return void 0;
|
|
521
|
+
return await res.json();
|
|
522
|
+
}
|
|
523
|
+
async function trelloGet(path, params = "") {
|
|
524
|
+
return trelloFetch("GET", path, params);
|
|
525
|
+
}
|
|
526
|
+
async function trelloPut(path, params = "") {
|
|
527
|
+
return trelloFetch("PUT", path, params);
|
|
528
|
+
}
|
|
529
|
+
async function trelloDelete(path) {
|
|
530
|
+
await trelloFetch("DELETE", path);
|
|
531
|
+
}
|
|
532
|
+
async function findBoardByName(name) {
|
|
533
|
+
const boards = await trelloGet("/members/me/boards", "fields=name");
|
|
534
|
+
const board = boards.find((b) => b.name.toLowerCase() === name.toLowerCase());
|
|
535
|
+
if (!board) throw new Error(`Trello board "${name}" not found`);
|
|
536
|
+
return board;
|
|
537
|
+
}
|
|
538
|
+
async function findListByName(boardId, name) {
|
|
539
|
+
const lists = await trelloGet(`/boards/${boardId}/lists`, "fields=name");
|
|
540
|
+
const list = lists.find((l) => l.name.toLowerCase() === name.toLowerCase());
|
|
541
|
+
if (!list) {
|
|
542
|
+
const available = lists.map((l) => l.name).join(", ");
|
|
543
|
+
throw new Error(`Trello list "${name}" not found. Available: ${available}`);
|
|
544
|
+
}
|
|
545
|
+
return list;
|
|
546
|
+
}
|
|
547
|
+
async function findLabelByName(boardId, name) {
|
|
548
|
+
const labels = await trelloGet(`/boards/${boardId}/labels`, "fields=name");
|
|
549
|
+
const label = labels.find((l) => l.name.toLowerCase() === name.toLowerCase());
|
|
550
|
+
if (!label) throw new Error(`Trello label "${name}" not found`);
|
|
551
|
+
return label;
|
|
552
|
+
}
|
|
553
|
+
var TrelloSource = class {
|
|
554
|
+
name = "trello";
|
|
555
|
+
async fetchNextIssue(config2) {
|
|
556
|
+
const board = await findBoardByName(config2.team);
|
|
557
|
+
const list = await findListByName(board.id, config2.project);
|
|
558
|
+
const label = await findLabelByName(board.id, config2.label);
|
|
559
|
+
const cards = await trelloGet(
|
|
560
|
+
`/lists/${list.id}/cards`,
|
|
561
|
+
"fields=name,desc,url,idLabels,idList"
|
|
562
|
+
);
|
|
563
|
+
const matching = cards.filter((c) => c.idLabels.includes(label.id));
|
|
564
|
+
if (matching.length === 0) return null;
|
|
565
|
+
const card = matching[0];
|
|
566
|
+
return {
|
|
567
|
+
id: card.id,
|
|
568
|
+
title: card.name,
|
|
569
|
+
description: card.desc || "",
|
|
570
|
+
url: card.url
|
|
571
|
+
};
|
|
572
|
+
}
|
|
573
|
+
async updateStatus(cardId, listName) {
|
|
574
|
+
const card = await trelloGet(`/cards/${cardId}`, "fields=idBoard");
|
|
575
|
+
const list = await findListByName(card.idBoard, listName);
|
|
576
|
+
await trelloPut(`/cards/${cardId}`, `idList=${list.id}`);
|
|
577
|
+
}
|
|
578
|
+
async removeLabel(cardId, labelName) {
|
|
579
|
+
const card = await trelloGet(
|
|
580
|
+
`/cards/${cardId}`,
|
|
581
|
+
"fields=idBoard,idLabels"
|
|
582
|
+
);
|
|
583
|
+
const label = await findLabelByName(card.idBoard, labelName);
|
|
584
|
+
if (!card.idLabels.includes(label.id)) return;
|
|
585
|
+
await trelloDelete(`/cards/${cardId}/idLabels/${label.id}`);
|
|
586
|
+
}
|
|
587
|
+
};
|
|
588
|
+
|
|
589
|
+
// src/sources/index.ts
|
|
590
|
+
var sources = {
|
|
591
|
+
linear: () => new LinearSource(),
|
|
592
|
+
trello: () => new TrelloSource()
|
|
593
|
+
};
|
|
594
|
+
function createSource(name) {
|
|
595
|
+
const factory = sources[name];
|
|
596
|
+
if (!factory) {
|
|
597
|
+
throw new Error(`Unknown source: ${name}. Available: ${Object.keys(sources).join(", ")}`);
|
|
598
|
+
}
|
|
599
|
+
return factory();
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// src/github.ts
|
|
603
|
+
import { execa as execa4 } from "execa";
|
|
604
|
+
var API_URL3 = "https://api.github.com";
|
|
605
|
+
var REQUEST_TIMEOUT_MS3 = 3e4;
|
|
606
|
+
async function isGhCliAvailable() {
|
|
607
|
+
try {
|
|
608
|
+
await execa4("gh", ["auth", "status"]);
|
|
609
|
+
return true;
|
|
610
|
+
} catch {
|
|
611
|
+
return false;
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
function getToken() {
|
|
615
|
+
const token = process.env.GITHUB_TOKEN;
|
|
616
|
+
if (!token) throw new Error("GITHUB_TOKEN is not set");
|
|
617
|
+
return token;
|
|
618
|
+
}
|
|
619
|
+
async function createPullRequest(opts, method = "cli") {
|
|
620
|
+
if (method === "cli" && await isGhCliAvailable()) {
|
|
621
|
+
return createPullRequestWithGhCli(opts);
|
|
622
|
+
}
|
|
623
|
+
const res = await fetch(`${API_URL3}/repos/${opts.owner}/${opts.repo}/pulls`, {
|
|
624
|
+
method: "POST",
|
|
625
|
+
headers: {
|
|
626
|
+
Authorization: `Bearer ${getToken()}`,
|
|
627
|
+
Accept: "application/vnd.github+json",
|
|
628
|
+
"Content-Type": "application/json"
|
|
629
|
+
},
|
|
630
|
+
body: JSON.stringify({
|
|
631
|
+
title: opts.title,
|
|
632
|
+
body: opts.body,
|
|
633
|
+
head: opts.head,
|
|
634
|
+
base: opts.base
|
|
635
|
+
}),
|
|
636
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS3)
|
|
637
|
+
});
|
|
638
|
+
if (!res.ok) {
|
|
639
|
+
const text2 = await res.text();
|
|
640
|
+
throw new Error(`GitHub API error (${res.status}): ${text2}`);
|
|
641
|
+
}
|
|
642
|
+
const data = await res.json();
|
|
643
|
+
return { number: data.number, html_url: data.html_url };
|
|
644
|
+
}
|
|
645
|
+
async function createPullRequestWithGhCli(opts) {
|
|
646
|
+
const result = await execa4("gh", [
|
|
647
|
+
"pr",
|
|
648
|
+
"create",
|
|
649
|
+
"--repo",
|
|
650
|
+
`${opts.owner}/${opts.repo}`,
|
|
651
|
+
"--head",
|
|
652
|
+
opts.head,
|
|
653
|
+
"--base",
|
|
654
|
+
opts.base,
|
|
655
|
+
"--title",
|
|
656
|
+
opts.title,
|
|
657
|
+
"--body",
|
|
658
|
+
opts.body
|
|
659
|
+
]);
|
|
660
|
+
const url = result.stdout.trim();
|
|
661
|
+
const prNumberMatch = url.match(/\/pull\/(\d+)/);
|
|
662
|
+
const number = prNumberMatch ? Number.parseInt(prNumberMatch[1], 10) : 0;
|
|
663
|
+
return { number, html_url: url };
|
|
664
|
+
}
|
|
665
|
+
async function getRepoInfo(cwd) {
|
|
666
|
+
const { stdout: remoteUrl } = await execa4("git", ["remote", "get-url", "origin"], { cwd });
|
|
667
|
+
let owner;
|
|
668
|
+
let repo;
|
|
669
|
+
const sshMatch = remoteUrl.match(/git@github\.com:(.+?)\/(.+?)(?:\.git)?$/);
|
|
670
|
+
const httpsMatch = remoteUrl.match(/github\.com\/(.+?)\/(.+?)(?:\.git)?$/);
|
|
671
|
+
if (sshMatch) {
|
|
672
|
+
owner = sshMatch[1];
|
|
673
|
+
repo = sshMatch[2];
|
|
674
|
+
} else if (httpsMatch) {
|
|
675
|
+
owner = httpsMatch[1];
|
|
676
|
+
repo = httpsMatch[2];
|
|
677
|
+
} else {
|
|
678
|
+
throw new Error(`Cannot parse GitHub owner/repo from remote URL: ${remoteUrl}`);
|
|
679
|
+
}
|
|
680
|
+
const { stdout: branch } = await execa4("git", ["branch", "--show-current"], { cwd });
|
|
681
|
+
const { stdout: defaultBranch } = await execa4(
|
|
682
|
+
"git",
|
|
683
|
+
["symbolic-ref", "refs/remotes/origin/HEAD", "--short"],
|
|
684
|
+
{ cwd, reject: false }
|
|
685
|
+
).then(
|
|
686
|
+
(r) => r,
|
|
687
|
+
() => ({ stdout: "origin/main" })
|
|
688
|
+
);
|
|
689
|
+
return {
|
|
690
|
+
owner,
|
|
691
|
+
repo,
|
|
692
|
+
branch: branch.trim(),
|
|
693
|
+
defaultBranch: defaultBranch.replace("origin/", "").trim()
|
|
694
|
+
};
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
// src/loop.ts
|
|
698
|
+
async function runLoop(config2, opts) {
|
|
699
|
+
const provider = createProvider(config2.provider);
|
|
700
|
+
const source = createSource(config2.source);
|
|
701
|
+
const available = await provider.isAvailable();
|
|
702
|
+
if (!available) {
|
|
703
|
+
error(`Provider "${config2.provider}" is not installed or not in PATH.`);
|
|
704
|
+
process.exit(1);
|
|
705
|
+
}
|
|
706
|
+
log(
|
|
707
|
+
`Starting loop (provider: ${config2.provider}, source: ${config2.source}, label: ${config2.source_config.label})`
|
|
708
|
+
);
|
|
709
|
+
let session = 0;
|
|
710
|
+
while (true) {
|
|
711
|
+
session++;
|
|
712
|
+
if (opts.limit > 0 && session > opts.limit) {
|
|
713
|
+
ok(`Reached limit of ${opts.limit} issues. Stopping.`);
|
|
714
|
+
break;
|
|
715
|
+
}
|
|
716
|
+
const timestamp2 = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-").substring(0, 19);
|
|
717
|
+
const logFile = resolve3(config2.logs.dir, `session_${session}_${timestamp2}.log`);
|
|
718
|
+
divider(session);
|
|
719
|
+
log(`Fetching next '${config2.source_config.label}' issue from ${config2.source}...`);
|
|
720
|
+
if (opts.dryRun) {
|
|
721
|
+
log(`[dry-run] Would fetch issue from ${config2.source} (${config2.source_config.team}/${config2.source_config.project})`);
|
|
722
|
+
log("[dry-run] Then implement, push, create PR, and update issue status");
|
|
723
|
+
break;
|
|
724
|
+
}
|
|
725
|
+
let issue;
|
|
726
|
+
try {
|
|
727
|
+
issue = await source.fetchNextIssue(config2.source_config);
|
|
728
|
+
} catch (err) {
|
|
729
|
+
error(`Failed to fetch issues: ${err instanceof Error ? err.message : String(err)}`);
|
|
730
|
+
if (opts.once) break;
|
|
731
|
+
await sleep(config2.loop.cooldown * 1e3);
|
|
732
|
+
continue;
|
|
733
|
+
}
|
|
734
|
+
if (!issue) {
|
|
735
|
+
warn(
|
|
736
|
+
`No issues with label '${config2.source_config.label}' found. Sleeping ${config2.loop.cooldown}s...`
|
|
737
|
+
);
|
|
738
|
+
if (opts.once) break;
|
|
739
|
+
await sleep(config2.loop.cooldown * 1e3);
|
|
740
|
+
continue;
|
|
741
|
+
}
|
|
742
|
+
ok(`Picked up: ${issue.id} \u2014 ${issue.title}`);
|
|
743
|
+
const prompt = buildImplementPrompt(issue, config2);
|
|
744
|
+
log(`Implementing... (log: ${logFile})`);
|
|
745
|
+
initLogFile(logFile);
|
|
746
|
+
const workspace = resolve3(config2.workspace);
|
|
747
|
+
const result = await provider.run(prompt, {
|
|
748
|
+
logFile,
|
|
749
|
+
cwd: workspace
|
|
750
|
+
});
|
|
751
|
+
try {
|
|
752
|
+
appendFileSync2(logFile, `
|
|
753
|
+
${"=".repeat(80)}
|
|
754
|
+
Full output:
|
|
755
|
+
${result.output}
|
|
756
|
+
`);
|
|
757
|
+
} catch {
|
|
758
|
+
}
|
|
759
|
+
if (!result.success) {
|
|
760
|
+
error(`Session ${session} failed for ${issue.id}. Check ${logFile}`);
|
|
761
|
+
if (opts.once) break;
|
|
762
|
+
await sleep(config2.loop.cooldown * 1e3);
|
|
763
|
+
continue;
|
|
764
|
+
}
|
|
765
|
+
try {
|
|
766
|
+
const repoInfo = await getRepoInfo(workspace);
|
|
767
|
+
const pr = await createPullRequest({
|
|
768
|
+
owner: repoInfo.owner,
|
|
769
|
+
repo: repoInfo.repo,
|
|
770
|
+
head: repoInfo.branch,
|
|
771
|
+
base: repoInfo.defaultBranch,
|
|
772
|
+
title: issue.title,
|
|
773
|
+
body: `Closes ${issue.url}
|
|
774
|
+
|
|
775
|
+
Implemented by lisa.`
|
|
776
|
+
}, config2.github);
|
|
777
|
+
ok(`PR created: ${pr.html_url}`);
|
|
778
|
+
} catch (err) {
|
|
779
|
+
error(`Failed to create PR: ${err instanceof Error ? err.message : String(err)}`);
|
|
780
|
+
}
|
|
781
|
+
try {
|
|
782
|
+
await source.updateStatus(issue.id, "In Review");
|
|
783
|
+
ok(`Updated ${issue.id} status to "In Review"`);
|
|
784
|
+
} catch (err) {
|
|
785
|
+
error(`Failed to update status: ${err instanceof Error ? err.message : String(err)}`);
|
|
786
|
+
}
|
|
787
|
+
try {
|
|
788
|
+
await source.removeLabel(issue.id, config2.source_config.label);
|
|
789
|
+
ok(`Removed label "${config2.source_config.label}" from ${issue.id}`);
|
|
790
|
+
} catch (err) {
|
|
791
|
+
error(`Failed to remove label: ${err instanceof Error ? err.message : String(err)}`);
|
|
792
|
+
}
|
|
793
|
+
ok(
|
|
794
|
+
`Session ${session} complete for ${issue.id} (${formatDuration(result.duration)})`
|
|
795
|
+
);
|
|
796
|
+
if (opts.once) {
|
|
797
|
+
log("Single iteration mode. Exiting.");
|
|
798
|
+
break;
|
|
799
|
+
}
|
|
800
|
+
log(`Cooling down ${config2.loop.cooldown}s before next issue...`);
|
|
801
|
+
await sleep(config2.loop.cooldown * 1e3);
|
|
802
|
+
}
|
|
803
|
+
ok(`lisa finished. ${session} session(s) run.`);
|
|
804
|
+
}
|
|
805
|
+
function sleep(ms) {
|
|
806
|
+
return new Promise((resolve4) => setTimeout(resolve4, ms));
|
|
807
|
+
}
|
|
808
|
+
function formatDuration(ms) {
|
|
809
|
+
const seconds = Math.floor(ms / 1e3);
|
|
810
|
+
const minutes = Math.floor(seconds / 60);
|
|
811
|
+
const remaining = seconds % 60;
|
|
812
|
+
if (minutes > 0) return `${minutes}m ${remaining}s`;
|
|
813
|
+
return `${seconds}s`;
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
// src/cli.ts
|
|
817
|
+
var run = defineCommand({
|
|
818
|
+
meta: { name: "run", description: "Run the agent loop" },
|
|
819
|
+
args: {
|
|
820
|
+
once: { type: "boolean", description: "Run a single iteration", default: false },
|
|
821
|
+
limit: { type: "string", description: "Max number of issues to process", default: "0" },
|
|
822
|
+
"dry-run": { type: "boolean", description: "Preview without executing", default: false },
|
|
823
|
+
provider: { type: "string", description: "AI provider (claude, gemini, opencode)" },
|
|
824
|
+
source: { type: "string", description: "Issue source (linear, trello)" },
|
|
825
|
+
label: { type: "string", description: "Label to filter issues" },
|
|
826
|
+
github: { type: "string", description: "GitHub method: cli or token" },
|
|
827
|
+
json: { type: "boolean", description: "Output as JSON lines", default: false },
|
|
828
|
+
quiet: { type: "boolean", description: "Suppress non-essential output", default: false }
|
|
829
|
+
},
|
|
830
|
+
async run({ args }) {
|
|
831
|
+
if (args.json) setOutputMode("json");
|
|
832
|
+
else if (args.quiet) setOutputMode("quiet");
|
|
833
|
+
banner();
|
|
834
|
+
const config2 = loadConfig();
|
|
835
|
+
const merged = mergeWithFlags(config2, {
|
|
836
|
+
provider: args.provider,
|
|
837
|
+
source: args.source,
|
|
838
|
+
github: args.github,
|
|
839
|
+
label: args.label
|
|
840
|
+
});
|
|
841
|
+
const missingVars = await getMissingEnvVars(merged.source);
|
|
842
|
+
if (missingVars.length > 0) {
|
|
843
|
+
const shell = process.env.SHELL?.includes("zsh") ? "~/.zshrc" : "~/.bashrc";
|
|
844
|
+
console.error(pc2.red(`Missing required environment variables:
|
|
845
|
+
${missingVars.map((v) => ` ${v}`).join("\n")}`));
|
|
846
|
+
console.error(pc2.dim(`
|
|
847
|
+
Add them to your ${shell} and run: source ${shell}`));
|
|
848
|
+
process.exit(1);
|
|
849
|
+
}
|
|
850
|
+
await runLoop(merged, {
|
|
851
|
+
once: args.once,
|
|
852
|
+
limit: Number.parseInt(args.limit, 10),
|
|
853
|
+
dryRun: args["dry-run"]
|
|
854
|
+
});
|
|
855
|
+
}
|
|
856
|
+
});
|
|
857
|
+
var config = defineCommand({
|
|
858
|
+
meta: { name: "config", description: "Manage configuration" },
|
|
859
|
+
args: {
|
|
860
|
+
show: { type: "boolean", description: "Show current config", default: false },
|
|
861
|
+
set: { type: "string", description: "Set a config value (key=value)" }
|
|
862
|
+
},
|
|
863
|
+
async run({ args }) {
|
|
864
|
+
if (args.show) {
|
|
865
|
+
const cfg = loadConfig();
|
|
866
|
+
console.log(pc2.cyan("\nCurrent configuration:\n"));
|
|
867
|
+
console.log(JSON.stringify(cfg, null, 2));
|
|
868
|
+
return;
|
|
869
|
+
}
|
|
870
|
+
if (args.set) {
|
|
871
|
+
const [key, value] = args.set.split("=");
|
|
872
|
+
if (!key || !value) {
|
|
873
|
+
console.error(pc2.red("Usage: lisa config --set key=value"));
|
|
874
|
+
process.exit(1);
|
|
875
|
+
}
|
|
876
|
+
const cfg = loadConfig();
|
|
877
|
+
cfg[key] = value;
|
|
878
|
+
saveConfig(cfg);
|
|
879
|
+
log(`Set ${key} = ${value}`);
|
|
880
|
+
return;
|
|
881
|
+
}
|
|
882
|
+
await runConfigWizard();
|
|
883
|
+
}
|
|
884
|
+
});
|
|
885
|
+
var init = defineCommand({
|
|
886
|
+
meta: { name: "init", description: "Initialize lisa configuration" },
|
|
887
|
+
async run() {
|
|
888
|
+
if (!process.stdin.isTTY) {
|
|
889
|
+
console.error(pc2.red("Interactive mode requires a TTY. Cannot run init in non-interactive environments."));
|
|
890
|
+
process.exit(1);
|
|
891
|
+
}
|
|
892
|
+
if (configExists()) {
|
|
893
|
+
const overwrite = await clack.confirm({
|
|
894
|
+
message: "Config already exists. Overwrite?"
|
|
895
|
+
});
|
|
896
|
+
if (clack.isCancel(overwrite) || !overwrite) {
|
|
897
|
+
log("Cancelled.");
|
|
898
|
+
return;
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
await runConfigWizard();
|
|
902
|
+
}
|
|
903
|
+
});
|
|
904
|
+
var status = defineCommand({
|
|
905
|
+
meta: { name: "status", description: "Show session status and stats" },
|
|
906
|
+
async run() {
|
|
907
|
+
banner();
|
|
908
|
+
const config2 = loadConfig();
|
|
909
|
+
console.log(pc2.cyan("Configuration:"));
|
|
910
|
+
console.log(` Provider: ${pc2.bold(config2.provider)}`);
|
|
911
|
+
console.log(` Source: ${pc2.bold(config2.source)}`);
|
|
912
|
+
console.log(` Label: ${pc2.bold(config2.source_config.label)}`);
|
|
913
|
+
console.log(` Team: ${pc2.bold(config2.source_config.team)}`);
|
|
914
|
+
console.log(` Project: ${pc2.bold(config2.source_config.project)}`);
|
|
915
|
+
console.log(` Logs: ${pc2.dim(config2.logs.dir)}`);
|
|
916
|
+
const { readdirSync: readdirSync2, existsSync: existsSync4 } = await import("fs");
|
|
917
|
+
if (existsSync4(config2.logs.dir)) {
|
|
918
|
+
const logs = readdirSync2(config2.logs.dir).filter((f) => f.endsWith(".log"));
|
|
919
|
+
console.log(`
|
|
920
|
+
${pc2.cyan("Sessions:")} ${logs.length} log file(s) found`);
|
|
921
|
+
} else {
|
|
922
|
+
console.log(`
|
|
923
|
+
${pc2.dim("No sessions yet.")}`);
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
});
|
|
927
|
+
function getVersion() {
|
|
928
|
+
try {
|
|
929
|
+
const pkgPath = resolvePath(new URL(".", import.meta.url).pathname, "../package.json");
|
|
930
|
+
const pkg = JSON.parse(readFileSync2(pkgPath, "utf-8"));
|
|
931
|
+
return pkg.version;
|
|
932
|
+
} catch {
|
|
933
|
+
return "0.0.0";
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
var main = defineCommand({
|
|
937
|
+
meta: {
|
|
938
|
+
name: "lisa",
|
|
939
|
+
version: getVersion(),
|
|
940
|
+
description: "Autonomous issue resolver \u2014 AI agent loop for Linear/Trello"
|
|
941
|
+
},
|
|
942
|
+
subCommands: { run, config, init, status }
|
|
943
|
+
});
|
|
944
|
+
var LISA_ART = pc2.yellow(`
|
|
945
|
+
@@@@@@@
|
|
946
|
+
@@@%+=*@@@@
|
|
947
|
+
@@@@+-----#@@@
|
|
948
|
+
@@@@*--------+@@@ @@@@@
|
|
949
|
+
@@@*-----------=%@@ @@@@@@@@@@@
|
|
950
|
+
@@@@@@@@@@@@@@@@@@#---------------*@@@@@@@@@%#*+=-=@@
|
|
951
|
+
@@@#########%%%%%*=-----------------=%%%#*+=--------@@@
|
|
952
|
+
@@*-------------------------------------------------#@@
|
|
953
|
+
@@*-------------------------------------------------+@@
|
|
954
|
+
@@*--------------------------------------------------@@@
|
|
955
|
+
@@*--------------------------------------------------%@@
|
|
956
|
+
@@*--------------------------------------------------*@@@
|
|
957
|
+
@@*--------------------------------------------------=%@@@@@@@@
|
|
958
|
+
@@@+------------------------------------------------------=+#%@@@@@@
|
|
959
|
+
@@@@@*------------------------------------------------------------=+*%@@@
|
|
960
|
+
@@@@%*=------------------------------------------------------------------=@@
|
|
961
|
+
@@@@#+----------------------------------------------------------------------%@@
|
|
962
|
+
@@%=------------------------------------------------------------------------*@@
|
|
963
|
+
@@@+--------------=%*------=#*-----*+------+@*-----------------------------=@@@
|
|
964
|
+
@@@#-------------+@#-----=@@=-----@@-----=@@=-----------------------------@@@
|
|
965
|
+
@@@=----##-----=@@-----%@+------%@@@@@@@@@#*+-----=--------------------%@@
|
|
966
|
+
@@@*---*@%++*#@@@@@@@@@@*---=*@@@#+======+*%@%#%@%#------------------+@@@
|
|
967
|
+
@@@*--=@@@#+=:::::::-+%@#*@@#=::::::::::::-*@@+--------------------#@@
|
|
968
|
+
@@@@*%@#::::::::::::::*@@#::::::::::::::::::%@*-------------------+@@@
|
|
969
|
+
@@@@%:::::::::::::::+@%:::::::::::::::::::-@@--------------------+@@@
|
|
970
|
+
@@@@@@+:::-**-::::::::%@=:::::::::-=:::::::::@@*+==-----------------=%@@@
|
|
971
|
+
@@@@@+:::%@@*::::::::@@-::::::::+@@%:::::::-@@#%%#-------------------*@@@
|
|
972
|
+
@@#::::==:::::::::#@+::::::::-%@*:::::::%@+-------------------------#@@@
|
|
973
|
+
@@+::::::::-====+#@@=::::::::::::::::-%@*-------------------------=@@@
|
|
974
|
+
@@@+::::+%@%%%%%%#*%@%=:::::::::::::+@@+-------------------------#@@@
|
|
975
|
+
@@%#*#@#----------+%@@*+=--::::-*@@@=-----------------------=*@@@
|
|
976
|
+
@@@#*#@@--------------+#%@@@@@@@@@*+-------==--------------=%@@@@
|
|
977
|
+
@@@%=---@@=--------==--------===----------=%@@@@%*-----------%@@
|
|
978
|
+
@@%=-----=#@%*****#%@%---------------------%@+---+@@=---------*@@
|
|
979
|
+
@@#----------+*#%%%#*=------------------------=**+-*@#----------@@@
|
|
980
|
+
@@----------------------------------%@=------+@@%%=*@#----------*@@
|
|
981
|
+
@@*--------------------------------=#@@=-----=*=---@@+----------=@@
|
|
982
|
+
@@@@*=----------------------==+*#%@@@@@*----##+-=*@@+-------=++**@@@
|
|
983
|
+
@@@@@%%#*++====+++**##%@@@@@%#*+=-=@@----=@@@@@@#=---=*%@@@@@@@@@
|
|
984
|
+
@@@@@@@@@@@@@@@%%##*++=--------+-----*@#--------*@@@@@
|
|
985
|
+
@@#------------------------#@*--------%@@
|
|
986
|
+
@@@@#---------------------%@@@%#*+==+@@
|
|
987
|
+
@@@---------------------%@@@@@@@@@@@@
|
|
988
|
+
@@%---------------------%@@
|
|
989
|
+
@@@@@@***+--------------*#@@@@@@
|
|
990
|
+
@@@*=*@@%%%@@#%%%#+-=*%%%%@#=-:=@@
|
|
991
|
+
@@-:-@@-:::+@@****@@@%***@@=:::-@@
|
|
992
|
+
@@#=%@+::::#@*::::@@@-:::+@@#*#@@@
|
|
993
|
+
@@@@@@%%%@@@%*=*%@@@%+=*@@@@@@@
|
|
994
|
+
@@@@@@@@@%@@@ @%%%%@@
|
|
995
|
+
`);
|
|
996
|
+
async function runConfigWizard() {
|
|
997
|
+
console.log(LISA_ART);
|
|
998
|
+
clack.intro(pc2.cyan("lisa \u2014 autonomous issue resolver"));
|
|
999
|
+
const providerLabels = {
|
|
1000
|
+
claude: "Claude Code",
|
|
1001
|
+
gemini: "Gemini CLI",
|
|
1002
|
+
opencode: "OpenCode"
|
|
1003
|
+
};
|
|
1004
|
+
const available = await getAvailableProviders();
|
|
1005
|
+
if (available.length === 0) {
|
|
1006
|
+
clack.log.error("No compatible AI providers found.");
|
|
1007
|
+
clack.log.info(
|
|
1008
|
+
`Install at least one of the following providers to continue:
|
|
1009
|
+
|
|
1010
|
+
${pc2.bold("Claude Code")} ${pc2.dim("npm i -g @anthropic-ai/claude-code")}
|
|
1011
|
+
${pc2.bold("Gemini CLI")} ${pc2.dim("npm i -g @anthropic-ai/gemini-cli")}
|
|
1012
|
+
${pc2.bold("OpenCode")} ${pc2.dim("npm i -g opencode")}
|
|
1013
|
+
|
|
1014
|
+
After installing, run ${pc2.cyan("lisa init")} again.`
|
|
1015
|
+
);
|
|
1016
|
+
return process.exit(1);
|
|
1017
|
+
}
|
|
1018
|
+
let providerName;
|
|
1019
|
+
if (available.length === 1) {
|
|
1020
|
+
providerName = available[0].name;
|
|
1021
|
+
clack.log.info(`Found provider: ${pc2.bold(providerLabels[providerName])}`);
|
|
1022
|
+
} else {
|
|
1023
|
+
const selected = await clack.select({
|
|
1024
|
+
message: "Which AI provider do you want to use?",
|
|
1025
|
+
options: available.map((p) => ({
|
|
1026
|
+
value: p.name,
|
|
1027
|
+
label: providerLabels[p.name]
|
|
1028
|
+
}))
|
|
1029
|
+
});
|
|
1030
|
+
if (clack.isCancel(selected)) return process.exit(0);
|
|
1031
|
+
providerName = selected;
|
|
1032
|
+
}
|
|
1033
|
+
const source = await clack.select({
|
|
1034
|
+
message: "Where do your issues live?",
|
|
1035
|
+
options: [
|
|
1036
|
+
{ value: "linear", label: "Linear" },
|
|
1037
|
+
{ value: "trello", label: "Trello" }
|
|
1038
|
+
]
|
|
1039
|
+
});
|
|
1040
|
+
if (clack.isCancel(source)) return process.exit(0);
|
|
1041
|
+
const missing = await getMissingEnvVars(source);
|
|
1042
|
+
if (missing.length > 0) {
|
|
1043
|
+
const shell = process.env.SHELL?.includes("zsh") ? "~/.zshrc" : "~/.bashrc";
|
|
1044
|
+
clack.log.warning(
|
|
1045
|
+
`Missing environment variables:
|
|
1046
|
+
${missing.map((v) => ` ${pc2.bold(v)}`).join("\n")}
|
|
1047
|
+
|
|
1048
|
+
Add them to your environment variables:
|
|
1049
|
+
${missing.map((v) => ` export ${v}="your-key-here"`).join("\n")}
|
|
1050
|
+
|
|
1051
|
+
Then run: ${pc2.cyan(`source ${shell}`)}`
|
|
1052
|
+
);
|
|
1053
|
+
}
|
|
1054
|
+
const teamAnswer = await clack.text({
|
|
1055
|
+
message: source === "linear" ? "Linear team name?" : "Trello board name?",
|
|
1056
|
+
initialValue: "Internal"
|
|
1057
|
+
});
|
|
1058
|
+
if (clack.isCancel(teamAnswer)) return process.exit(0);
|
|
1059
|
+
const team = teamAnswer;
|
|
1060
|
+
const projectAnswer = await clack.text({
|
|
1061
|
+
message: source === "linear" ? "Project name?" : "Trello list name?",
|
|
1062
|
+
initialValue: "Zenixx"
|
|
1063
|
+
});
|
|
1064
|
+
if (clack.isCancel(projectAnswer)) return process.exit(0);
|
|
1065
|
+
const project = projectAnswer;
|
|
1066
|
+
const labelAnswer = await clack.text({
|
|
1067
|
+
message: "Label to pick up?",
|
|
1068
|
+
initialValue: "ready"
|
|
1069
|
+
});
|
|
1070
|
+
if (clack.isCancel(labelAnswer)) return process.exit(0);
|
|
1071
|
+
const label = labelAnswer;
|
|
1072
|
+
const githubMethod = await detectGitHubMethod();
|
|
1073
|
+
const repos = await detectGitRepos();
|
|
1074
|
+
const cfg = {
|
|
1075
|
+
provider: providerName,
|
|
1076
|
+
source,
|
|
1077
|
+
source_config: {
|
|
1078
|
+
team,
|
|
1079
|
+
project,
|
|
1080
|
+
label,
|
|
1081
|
+
status: "Backlog"
|
|
1082
|
+
},
|
|
1083
|
+
github: githubMethod,
|
|
1084
|
+
workspace: ".",
|
|
1085
|
+
repos,
|
|
1086
|
+
loop: { cooldown: 10, max_sessions: 0 },
|
|
1087
|
+
logs: { dir: ".lisa/logs", format: "text" }
|
|
1088
|
+
};
|
|
1089
|
+
saveConfig(cfg);
|
|
1090
|
+
clack.outro(pc2.green("Config saved to .lisa/config.yaml"));
|
|
1091
|
+
}
|
|
1092
|
+
async function detectGitHubMethod() {
|
|
1093
|
+
const hasToken = !!process.env.GITHUB_TOKEN;
|
|
1094
|
+
const hasCli = await isGhCliAvailable();
|
|
1095
|
+
if (hasToken && hasCli) {
|
|
1096
|
+
const selected = await clack.select({
|
|
1097
|
+
message: "Both GitHub CLI and GITHUB_TOKEN detected. Which do you want to use?",
|
|
1098
|
+
options: [
|
|
1099
|
+
{ value: "cli", label: "GitHub CLI", hint: "gh" },
|
|
1100
|
+
{ value: "token", label: "GitHub API", hint: "GITHUB_TOKEN" }
|
|
1101
|
+
]
|
|
1102
|
+
});
|
|
1103
|
+
if (clack.isCancel(selected)) return process.exit(0);
|
|
1104
|
+
return selected;
|
|
1105
|
+
}
|
|
1106
|
+
if (hasCli) {
|
|
1107
|
+
clack.log.info("Using GitHub CLI for pull requests.");
|
|
1108
|
+
return "cli";
|
|
1109
|
+
}
|
|
1110
|
+
if (hasToken) {
|
|
1111
|
+
clack.log.info("Using GITHUB_TOKEN for pull requests.");
|
|
1112
|
+
return "token";
|
|
1113
|
+
}
|
|
1114
|
+
return "token";
|
|
1115
|
+
}
|
|
1116
|
+
async function detectGitRepos() {
|
|
1117
|
+
const cwd = process.cwd();
|
|
1118
|
+
if (existsSync3(join(cwd, ".git"))) {
|
|
1119
|
+
clack.log.info(`Detected git repository in current directory.`);
|
|
1120
|
+
return [];
|
|
1121
|
+
}
|
|
1122
|
+
const entries = readdirSync(cwd, { withFileTypes: true });
|
|
1123
|
+
const gitDirs = entries.filter((e) => e.isDirectory() && existsSync3(join(cwd, e.name, ".git"))).map((e) => e.name);
|
|
1124
|
+
if (gitDirs.length === 0) {
|
|
1125
|
+
return [];
|
|
1126
|
+
}
|
|
1127
|
+
const selected = await clack.multiselect({
|
|
1128
|
+
message: "Select the repos to include in the workspace:",
|
|
1129
|
+
options: gitDirs.map((dir) => ({ value: dir, label: dir }))
|
|
1130
|
+
});
|
|
1131
|
+
if (clack.isCancel(selected)) return process.exit(0);
|
|
1132
|
+
return selected.map((dir) => ({
|
|
1133
|
+
name: dir,
|
|
1134
|
+
path: `./${dir}`,
|
|
1135
|
+
match: ""
|
|
1136
|
+
}));
|
|
1137
|
+
}
|
|
1138
|
+
async function getMissingEnvVars(source) {
|
|
1139
|
+
const missing = [];
|
|
1140
|
+
if (!process.env.GITHUB_TOKEN) {
|
|
1141
|
+
const ghAvailable = await isGhCliAvailable();
|
|
1142
|
+
if (!ghAvailable) missing.push("GITHUB_TOKEN");
|
|
1143
|
+
}
|
|
1144
|
+
if (source === "linear") {
|
|
1145
|
+
if (!process.env.LINEAR_API_KEY) missing.push("LINEAR_API_KEY");
|
|
1146
|
+
} else if (source === "trello") {
|
|
1147
|
+
if (!process.env.TRELLO_API_KEY) missing.push("TRELLO_API_KEY");
|
|
1148
|
+
if (!process.env.TRELLO_TOKEN) missing.push("TRELLO_TOKEN");
|
|
1149
|
+
}
|
|
1150
|
+
return missing;
|
|
1151
|
+
}
|
|
1152
|
+
function runCli() {
|
|
1153
|
+
runMain(main);
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
// src/index.ts
|
|
1157
|
+
runCli();
|
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@tarcisiopgs/lisa",
|
|
3
|
+
"version": "0.5.0",
|
|
4
|
+
"description": "Autonomous issue resolver — AI agent loop for Linear/Trello",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"lisa": "dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"build": "tsup",
|
|
11
|
+
"dev": "npx tsx src/index.ts",
|
|
12
|
+
"check": "biome check src/",
|
|
13
|
+
"format": "biome format --write src/",
|
|
14
|
+
"lint": "biome lint src/"
|
|
15
|
+
},
|
|
16
|
+
"dependencies": {
|
|
17
|
+
"@clack/prompts": "^1.0.1",
|
|
18
|
+
"citty": "^0.2.1",
|
|
19
|
+
"execa": "^9.6.1",
|
|
20
|
+
"picocolors": "^1.1.1",
|
|
21
|
+
"yaml": "^2.8.2"
|
|
22
|
+
},
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"@types/node": "^22.13.4",
|
|
25
|
+
"tsup": "^8.4.0",
|
|
26
|
+
"tsx": "^4.19.3",
|
|
27
|
+
"typescript": "^5.9.3"
|
|
28
|
+
},
|
|
29
|
+
"publishConfig": {
|
|
30
|
+
"access": "public"
|
|
31
|
+
},
|
|
32
|
+
"files": [
|
|
33
|
+
"dist"
|
|
34
|
+
]
|
|
35
|
+
}
|