capyai 0.2.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 +79 -0
- package/bin/capy.js +61 -0
- package/bin/capy.ts +62 -0
- package/dist/capy.js +1619 -0
- package/package.json +36 -0
- package/src/api.ts +125 -0
- package/src/cli.ts +722 -0
- package/src/config.ts +93 -0
- package/src/format.ts +45 -0
- package/src/github.ts +112 -0
- package/src/greptile.ts +151 -0
- package/src/quality.ts +131 -0
- package/src/types.ts +181 -0
- package/src/watch.ts +61 -0
package/dist/capy.js
ADDED
|
@@ -0,0 +1,1619 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// @bun
|
|
3
|
+
import { createRequire } from "node:module";
|
|
4
|
+
var __create = Object.create;
|
|
5
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
6
|
+
var __defProp = Object.defineProperty;
|
|
7
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
8
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
9
|
+
var __toESM = (mod, isNodeMode, target) => {
|
|
10
|
+
target = mod != null ? __create(__getProtoOf(mod)) : {};
|
|
11
|
+
const to = isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target;
|
|
12
|
+
for (let key of __getOwnPropNames(mod))
|
|
13
|
+
if (!__hasOwnProp.call(to, key))
|
|
14
|
+
__defProp(to, key, {
|
|
15
|
+
get: () => mod[key],
|
|
16
|
+
enumerable: true
|
|
17
|
+
});
|
|
18
|
+
return to;
|
|
19
|
+
};
|
|
20
|
+
var __export = (target, all) => {
|
|
21
|
+
for (var name in all)
|
|
22
|
+
__defProp(target, name, {
|
|
23
|
+
get: all[name],
|
|
24
|
+
enumerable: true,
|
|
25
|
+
configurable: true,
|
|
26
|
+
set: (newValue) => all[name] = () => newValue
|
|
27
|
+
});
|
|
28
|
+
};
|
|
29
|
+
var __esm = (fn, res) => () => (fn && (res = fn(fn = 0)), res);
|
|
30
|
+
var __require = /* @__PURE__ */ createRequire(import.meta.url);
|
|
31
|
+
|
|
32
|
+
// src/config.ts
|
|
33
|
+
import fs from "node:fs";
|
|
34
|
+
import path from "node:path";
|
|
35
|
+
function load() {
|
|
36
|
+
const envPath = process.env.CAPY_ENV_FILE || path.join(CONFIG_DIR, ".env");
|
|
37
|
+
try {
|
|
38
|
+
fs.readFileSync(envPath, "utf8").split(`
|
|
39
|
+
`).forEach((line) => {
|
|
40
|
+
const t = line.trim();
|
|
41
|
+
if (!t || t.startsWith("#"))
|
|
42
|
+
return;
|
|
43
|
+
const eq = t.indexOf("=");
|
|
44
|
+
if (eq === -1)
|
|
45
|
+
return;
|
|
46
|
+
const k = t.slice(0, eq).trim();
|
|
47
|
+
const v = t.slice(eq + 1).trim();
|
|
48
|
+
if (!process.env[k])
|
|
49
|
+
process.env[k] = v;
|
|
50
|
+
});
|
|
51
|
+
} catch {}
|
|
52
|
+
let cfg;
|
|
53
|
+
try {
|
|
54
|
+
cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf8"));
|
|
55
|
+
} catch {
|
|
56
|
+
cfg = {};
|
|
57
|
+
}
|
|
58
|
+
const merged = { ...DEFAULTS, ...cfg };
|
|
59
|
+
merged.quality = { ...DEFAULTS.quality, ...cfg.quality || {} };
|
|
60
|
+
if (process.env.CAPY_API_KEY)
|
|
61
|
+
merged.apiKey = process.env.CAPY_API_KEY;
|
|
62
|
+
if (process.env.CAPY_PROJECT_ID)
|
|
63
|
+
merged.projectId = process.env.CAPY_PROJECT_ID;
|
|
64
|
+
if (process.env.CAPY_SERVER)
|
|
65
|
+
merged.server = process.env.CAPY_SERVER;
|
|
66
|
+
return merged;
|
|
67
|
+
}
|
|
68
|
+
function save(cfg) {
|
|
69
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
70
|
+
fs.writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2) + `
|
|
71
|
+
`);
|
|
72
|
+
}
|
|
73
|
+
function get(key) {
|
|
74
|
+
const cfg = load();
|
|
75
|
+
if (key.includes(".")) {
|
|
76
|
+
const parts = key.split(".");
|
|
77
|
+
let val = cfg;
|
|
78
|
+
for (const p of parts) {
|
|
79
|
+
val = val?.[p];
|
|
80
|
+
}
|
|
81
|
+
return val;
|
|
82
|
+
}
|
|
83
|
+
return cfg[key];
|
|
84
|
+
}
|
|
85
|
+
function set(key, value) {
|
|
86
|
+
const cfg = load();
|
|
87
|
+
if (key.includes(".")) {
|
|
88
|
+
const parts = key.split(".");
|
|
89
|
+
let obj = cfg;
|
|
90
|
+
for (let i = 0;i < parts.length - 1; i++) {
|
|
91
|
+
if (!obj[parts[i]])
|
|
92
|
+
obj[parts[i]] = {};
|
|
93
|
+
obj = obj[parts[i]];
|
|
94
|
+
}
|
|
95
|
+
let parsed = value;
|
|
96
|
+
if (value === "true")
|
|
97
|
+
parsed = true;
|
|
98
|
+
else if (value === "false")
|
|
99
|
+
parsed = false;
|
|
100
|
+
else if (/^\d+$/.test(value))
|
|
101
|
+
parsed = parseInt(value);
|
|
102
|
+
obj[parts[parts.length - 1]] = parsed;
|
|
103
|
+
} else {
|
|
104
|
+
cfg[key] = value;
|
|
105
|
+
}
|
|
106
|
+
save(cfg);
|
|
107
|
+
}
|
|
108
|
+
var CONFIG_DIR, CONFIG_PATH, WATCH_DIR, DEFAULTS;
|
|
109
|
+
var init_config = __esm(() => {
|
|
110
|
+
CONFIG_DIR = path.join(process.env.HOME || "/root", ".capy");
|
|
111
|
+
CONFIG_PATH = path.join(CONFIG_DIR, "config.json");
|
|
112
|
+
WATCH_DIR = path.join(CONFIG_DIR, "watches");
|
|
113
|
+
DEFAULTS = {
|
|
114
|
+
apiKey: "",
|
|
115
|
+
projectId: "",
|
|
116
|
+
server: "https://capy.ai/api/v1",
|
|
117
|
+
repos: [],
|
|
118
|
+
defaultModel: "gpt-5.4",
|
|
119
|
+
quality: {
|
|
120
|
+
minReviewScore: 4,
|
|
121
|
+
requireCI: true,
|
|
122
|
+
requireTests: true,
|
|
123
|
+
requireLinearLink: true,
|
|
124
|
+
reviewProvider: "greptile"
|
|
125
|
+
},
|
|
126
|
+
watchInterval: 3,
|
|
127
|
+
notifyCommand: ""
|
|
128
|
+
};
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
// src/api.ts
|
|
132
|
+
async function request(method, path2, body) {
|
|
133
|
+
const cfg = load();
|
|
134
|
+
if (!cfg.apiKey) {
|
|
135
|
+
console.error("capy: API key not configured. Run: capy init");
|
|
136
|
+
process.exit(1);
|
|
137
|
+
}
|
|
138
|
+
const url = `${cfg.server}${path2}`;
|
|
139
|
+
const headers = {
|
|
140
|
+
Authorization: `Bearer ${cfg.apiKey}`,
|
|
141
|
+
Accept: "application/json"
|
|
142
|
+
};
|
|
143
|
+
const init = { method, headers };
|
|
144
|
+
if (body) {
|
|
145
|
+
headers["Content-Type"] = "application/json";
|
|
146
|
+
init.body = JSON.stringify(body);
|
|
147
|
+
}
|
|
148
|
+
let res;
|
|
149
|
+
try {
|
|
150
|
+
res = await fetch(url, init);
|
|
151
|
+
} catch (e) {
|
|
152
|
+
console.error(`capy: request failed — ${e.message}`);
|
|
153
|
+
process.exit(1);
|
|
154
|
+
}
|
|
155
|
+
const text = await res.text();
|
|
156
|
+
try {
|
|
157
|
+
const data = JSON.parse(text);
|
|
158
|
+
if (data.error) {
|
|
159
|
+
console.error(`capy: API error — ${data.error.message || data.error.code}`);
|
|
160
|
+
process.exit(1);
|
|
161
|
+
}
|
|
162
|
+
return data;
|
|
163
|
+
} catch {
|
|
164
|
+
console.error("capy: bad API response:", text.slice(0, 200));
|
|
165
|
+
process.exit(1);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
async function createThread(prompt, model, repos) {
|
|
169
|
+
const cfg = load();
|
|
170
|
+
return request("POST", "/threads", {
|
|
171
|
+
projectId: cfg.projectId,
|
|
172
|
+
prompt,
|
|
173
|
+
model: model || cfg.defaultModel,
|
|
174
|
+
repos: repos || cfg.repos
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
async function listThreads(opts = {}) {
|
|
178
|
+
const cfg = load();
|
|
179
|
+
const p = new URLSearchParams({ projectId: cfg.projectId, limit: String(opts.limit || 10) });
|
|
180
|
+
if (opts.status)
|
|
181
|
+
p.set("status", opts.status);
|
|
182
|
+
return request("GET", `/threads?${p}`);
|
|
183
|
+
}
|
|
184
|
+
async function getThread(id) {
|
|
185
|
+
return request("GET", `/threads/${id}`);
|
|
186
|
+
}
|
|
187
|
+
async function messageThread(id, msg) {
|
|
188
|
+
return request("POST", `/threads/${id}/message`, { message: msg });
|
|
189
|
+
}
|
|
190
|
+
async function stopThread(id) {
|
|
191
|
+
return request("POST", `/threads/${id}/stop`);
|
|
192
|
+
}
|
|
193
|
+
async function getThreadMessages(id, opts = {}) {
|
|
194
|
+
const p = new URLSearchParams({ limit: String(opts.limit || 50) });
|
|
195
|
+
return request("GET", `/threads/${id}/messages?${p}`);
|
|
196
|
+
}
|
|
197
|
+
async function createTask(prompt, model, opts = {}) {
|
|
198
|
+
const cfg = load();
|
|
199
|
+
return request("POST", "/tasks", {
|
|
200
|
+
projectId: cfg.projectId,
|
|
201
|
+
prompt,
|
|
202
|
+
title: (opts.title || prompt).slice(0, 80),
|
|
203
|
+
repos: cfg.repos,
|
|
204
|
+
model: model || cfg.defaultModel,
|
|
205
|
+
start: opts.start !== false,
|
|
206
|
+
...opts.labels ? { labels: opts.labels } : {}
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
async function listTasks(opts = {}) {
|
|
210
|
+
const cfg = load();
|
|
211
|
+
const p = new URLSearchParams({ projectId: cfg.projectId, limit: String(opts.limit || 30) });
|
|
212
|
+
if (opts.status)
|
|
213
|
+
p.set("status", opts.status);
|
|
214
|
+
return request("GET", `/tasks?${p}`);
|
|
215
|
+
}
|
|
216
|
+
async function getTask(id) {
|
|
217
|
+
return request("GET", `/tasks/${id}`);
|
|
218
|
+
}
|
|
219
|
+
async function startTask(id, model) {
|
|
220
|
+
return request("POST", `/tasks/${id}/start`, { model: model || load().defaultModel });
|
|
221
|
+
}
|
|
222
|
+
async function stopTask(id, reason) {
|
|
223
|
+
return request("POST", `/tasks/${id}/stop`, reason ? { reason } : {});
|
|
224
|
+
}
|
|
225
|
+
async function messageTask(id, msg) {
|
|
226
|
+
return request("POST", `/tasks/${id}/message`, { message: msg });
|
|
227
|
+
}
|
|
228
|
+
async function createPR(id, opts = {}) {
|
|
229
|
+
return request("POST", `/tasks/${id}/pr`, opts);
|
|
230
|
+
}
|
|
231
|
+
async function getDiff(id, mode = "run") {
|
|
232
|
+
return request("GET", `/tasks/${id}/diff?mode=${mode}`);
|
|
233
|
+
}
|
|
234
|
+
async function listModels() {
|
|
235
|
+
return request("GET", "/models");
|
|
236
|
+
}
|
|
237
|
+
var init_api = __esm(() => {
|
|
238
|
+
init_config();
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
// src/github.ts
|
|
242
|
+
import { execFileSync } from "node:child_process";
|
|
243
|
+
function gh(args, opts = {}) {
|
|
244
|
+
try {
|
|
245
|
+
return JSON.parse(execFileSync("gh", args, {
|
|
246
|
+
encoding: "utf8",
|
|
247
|
+
timeout: opts.timeout || 15000,
|
|
248
|
+
maxBuffer: 5 * 1024 * 1024
|
|
249
|
+
}));
|
|
250
|
+
} catch {
|
|
251
|
+
return null;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
function getPR(repo, number) {
|
|
255
|
+
return gh([
|
|
256
|
+
"pr",
|
|
257
|
+
"view",
|
|
258
|
+
String(number),
|
|
259
|
+
"--repo",
|
|
260
|
+
repo,
|
|
261
|
+
"--json",
|
|
262
|
+
"state,mergeable,mergedAt,closedAt,headRefName,baseRefName,title,body,url,number,additions,deletions,changedFiles,reviewDecision,statusCheckRollup,reviews,comments"
|
|
263
|
+
]);
|
|
264
|
+
}
|
|
265
|
+
function getPRReviewComments(repo, number) {
|
|
266
|
+
try {
|
|
267
|
+
const out = execFileSync("gh", ["api", `repos/${repo}/pulls/${number}/comments`, "--paginate"], {
|
|
268
|
+
encoding: "utf8",
|
|
269
|
+
timeout: 15000,
|
|
270
|
+
maxBuffer: 5 * 1024 * 1024
|
|
271
|
+
});
|
|
272
|
+
return JSON.parse(out);
|
|
273
|
+
} catch {
|
|
274
|
+
return [];
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
function getPRIssueComments(repo, number) {
|
|
278
|
+
try {
|
|
279
|
+
const out = execFileSync("gh", ["api", `repos/${repo}/issues/${number}/comments`, "--paginate"], {
|
|
280
|
+
encoding: "utf8",
|
|
281
|
+
timeout: 15000,
|
|
282
|
+
maxBuffer: 5 * 1024 * 1024
|
|
283
|
+
});
|
|
284
|
+
return JSON.parse(out);
|
|
285
|
+
} catch {
|
|
286
|
+
return [];
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
function getCIStatus(repo, number, prData) {
|
|
290
|
+
const pr = prData || getPR(repo, number);
|
|
291
|
+
if (!pr)
|
|
292
|
+
return null;
|
|
293
|
+
const checks = pr.statusCheckRollup || [];
|
|
294
|
+
const total = checks.length;
|
|
295
|
+
const passing = checks.filter((c) => c.conclusion === "SUCCESS" || c.conclusion === "NEUTRAL" || c.status === "COMPLETED").length;
|
|
296
|
+
const failing = checks.filter((c) => c.conclusion === "FAILURE" || c.conclusion === "ERROR" || c.conclusion === "TIMED_OUT");
|
|
297
|
+
const pending = checks.filter((c) => c.status === "IN_PROGRESS" || c.status === "QUEUED" || c.status === "PENDING");
|
|
298
|
+
return {
|
|
299
|
+
total,
|
|
300
|
+
passing,
|
|
301
|
+
failing: failing.map((c) => ({ name: c.name || c.context || "", conclusion: c.conclusion })),
|
|
302
|
+
pending: pending.map((c) => ({ name: c.name || c.context || "", status: c.status })),
|
|
303
|
+
allGreen: total > 0 && failing.length === 0 && pending.length === 0,
|
|
304
|
+
noChecks: total === 0
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
function parseGreptileReview(comments) {
|
|
308
|
+
const greptile = comments.find((c) => (c.user?.login || "").toLowerCase().includes("greptile") || (c.body || "").includes("Confidence Score"));
|
|
309
|
+
if (!greptile)
|
|
310
|
+
return null;
|
|
311
|
+
const body = greptile.body || "";
|
|
312
|
+
const scoreMatch = body.match(/(?:Confidence\s*Score|confidence)[:\s]*(\d(?:\.\d)?)\s*\/\s*5/i);
|
|
313
|
+
const score = scoreMatch ? parseFloat(scoreMatch[1]) : null;
|
|
314
|
+
const logicCount = (body.match(/\bLogic\b/gi) || []).length;
|
|
315
|
+
const syntaxCount = (body.match(/\bSyntax\b/gi) || []).length;
|
|
316
|
+
const styleCount = (body.match(/\bStyle\b/gi) || []).length;
|
|
317
|
+
return {
|
|
318
|
+
score,
|
|
319
|
+
issueCount: logicCount + syntaxCount + styleCount,
|
|
320
|
+
logic: logicCount,
|
|
321
|
+
syntax: syntaxCount,
|
|
322
|
+
style: styleCount,
|
|
323
|
+
body: body.slice(0, 2000),
|
|
324
|
+
url: greptile.html_url
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
function diffHasTests(files) {
|
|
328
|
+
if (!files)
|
|
329
|
+
return false;
|
|
330
|
+
return files.some((f) => {
|
|
331
|
+
const p = (f.path || f.filename || "").toLowerCase();
|
|
332
|
+
return p.includes("test") || p.includes("spec") || p.includes("__tests__") || p.endsWith(".test.ts") || p.endsWith(".test.js") || p.endsWith("_test.go") || p.endsWith(".spec.ts") || p.endsWith(".spec.js");
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
function getUnresolvedThreads(repo, number) {
|
|
336
|
+
try {
|
|
337
|
+
const query = `query { repository(owner:"${repo.split("/")[0]}", name:"${repo.split("/")[1]}") { pullRequest(number:${number}) { reviewThreads(first:100) { nodes { isResolved isOutdated comments(first:1) { nodes { body author { login } } } } } } } }`;
|
|
338
|
+
const out = execFileSync("gh", ["api", "graphql", "-f", `query=${query}`], {
|
|
339
|
+
encoding: "utf8",
|
|
340
|
+
timeout: 15000
|
|
341
|
+
});
|
|
342
|
+
const data = JSON.parse(out);
|
|
343
|
+
const threads = data?.data?.repository?.pullRequest?.reviewThreads?.nodes || [];
|
|
344
|
+
return threads.filter((t) => !t.isResolved && !t.isOutdated).map((t) => ({
|
|
345
|
+
body: t.comments?.nodes?.[0]?.body?.slice(0, 200) || "",
|
|
346
|
+
author: t.comments?.nodes?.[0]?.author?.login || "unknown"
|
|
347
|
+
}));
|
|
348
|
+
} catch {
|
|
349
|
+
return [];
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
var init_github = () => {};
|
|
353
|
+
|
|
354
|
+
// src/greptile.ts
|
|
355
|
+
async function mcp(method, params) {
|
|
356
|
+
const cfg = load();
|
|
357
|
+
const apiKey = cfg.greptileApiKey || process.env.GREPTILE_API_KEY || "";
|
|
358
|
+
if (!apiKey) {
|
|
359
|
+
console.error("capy: GREPTILE_API_KEY not set. Run: capy config greptileApiKey <key>");
|
|
360
|
+
process.exit(1);
|
|
361
|
+
}
|
|
362
|
+
const body = {
|
|
363
|
+
jsonrpc: "2.0",
|
|
364
|
+
id: Date.now(),
|
|
365
|
+
method: "tools/call",
|
|
366
|
+
params: {
|
|
367
|
+
name: method,
|
|
368
|
+
arguments: params
|
|
369
|
+
}
|
|
370
|
+
};
|
|
371
|
+
let res;
|
|
372
|
+
try {
|
|
373
|
+
res = await fetch(MCP_URL, {
|
|
374
|
+
method: "POST",
|
|
375
|
+
headers: {
|
|
376
|
+
Authorization: `Bearer ${apiKey}`,
|
|
377
|
+
"Content-Type": "application/json",
|
|
378
|
+
Accept: "application/json"
|
|
379
|
+
},
|
|
380
|
+
body: JSON.stringify(body),
|
|
381
|
+
signal: AbortSignal.timeout(30000)
|
|
382
|
+
});
|
|
383
|
+
} catch (e) {
|
|
384
|
+
console.error(`greptile: request failed — ${e.message}`);
|
|
385
|
+
return null;
|
|
386
|
+
}
|
|
387
|
+
const text = await res.text();
|
|
388
|
+
try {
|
|
389
|
+
const data = JSON.parse(text);
|
|
390
|
+
if (data.error) {
|
|
391
|
+
console.error(`greptile: ${data.error.message || JSON.stringify(data.error)}`);
|
|
392
|
+
return null;
|
|
393
|
+
}
|
|
394
|
+
if (data.result?.content) {
|
|
395
|
+
const textPart = data.result.content.find((c) => c.type === "text");
|
|
396
|
+
if (textPart) {
|
|
397
|
+
try {
|
|
398
|
+
return JSON.parse(textPart.text);
|
|
399
|
+
} catch {
|
|
400
|
+
return textPart.text;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
return data.result;
|
|
405
|
+
} catch {
|
|
406
|
+
console.error("greptile: bad response:", text.slice(0, 300));
|
|
407
|
+
return null;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
async function triggerReview(repo, prNumber, defaultBranch) {
|
|
411
|
+
return mcp("trigger_code_review", {
|
|
412
|
+
name: repo,
|
|
413
|
+
remote: "github",
|
|
414
|
+
defaultBranch: defaultBranch || "main",
|
|
415
|
+
prNumber
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
async function getReview(reviewId) {
|
|
419
|
+
return mcp("get_code_review", { codeReviewId: reviewId });
|
|
420
|
+
}
|
|
421
|
+
async function listComments(repo, prNumber, opts = {}) {
|
|
422
|
+
const params = {
|
|
423
|
+
name: repo,
|
|
424
|
+
remote: "github",
|
|
425
|
+
defaultBranch: opts.defaultBranch || "main",
|
|
426
|
+
prNumber
|
|
427
|
+
};
|
|
428
|
+
if (opts.greptileOnly)
|
|
429
|
+
params.greptileGenerated = true;
|
|
430
|
+
if (opts.unaddressedOnly)
|
|
431
|
+
params.addressed = false;
|
|
432
|
+
return mcp("list_merge_request_comments", params);
|
|
433
|
+
}
|
|
434
|
+
async function waitForReview(reviewId, timeoutMs = 120000) {
|
|
435
|
+
const start = Date.now();
|
|
436
|
+
while (Date.now() - start < timeoutMs) {
|
|
437
|
+
const review = await getReview(reviewId);
|
|
438
|
+
if (!review)
|
|
439
|
+
return null;
|
|
440
|
+
if (review.status === "COMPLETED")
|
|
441
|
+
return review;
|
|
442
|
+
if (review.status === "FAILED")
|
|
443
|
+
return review;
|
|
444
|
+
await new Promise((r) => setTimeout(r, 5000));
|
|
445
|
+
}
|
|
446
|
+
return null;
|
|
447
|
+
}
|
|
448
|
+
async function freshReview(repo, prNumber, defaultBranch) {
|
|
449
|
+
const trigger = await triggerReview(repo, prNumber, defaultBranch);
|
|
450
|
+
if (!trigger)
|
|
451
|
+
return null;
|
|
452
|
+
const reviewId = trigger.codeReviewId || trigger.id;
|
|
453
|
+
if (!reviewId)
|
|
454
|
+
return trigger;
|
|
455
|
+
console.error(`greptile: review triggered (${reviewId}), waiting...`);
|
|
456
|
+
return waitForReview(reviewId);
|
|
457
|
+
}
|
|
458
|
+
async function getUnaddressedIssues(repo, prNumber, defaultBranch) {
|
|
459
|
+
const comments = await listComments(repo, prNumber, {
|
|
460
|
+
defaultBranch,
|
|
461
|
+
greptileOnly: true,
|
|
462
|
+
unaddressedOnly: true
|
|
463
|
+
});
|
|
464
|
+
if (!comments || !Array.isArray(comments))
|
|
465
|
+
return [];
|
|
466
|
+
return comments.map((c) => ({
|
|
467
|
+
body: (c.body || "").slice(0, 200),
|
|
468
|
+
file: c.path || c.file || "?",
|
|
469
|
+
line: c.line || c.position || "?",
|
|
470
|
+
hasSuggestion: !!c.hasSuggestion,
|
|
471
|
+
suggestedCode: c.suggestedCode || null
|
|
472
|
+
}));
|
|
473
|
+
}
|
|
474
|
+
var MCP_URL = "https://api.greptile.com/mcp";
|
|
475
|
+
var init_greptile = __esm(() => {
|
|
476
|
+
init_config();
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
// src/quality.ts
|
|
480
|
+
function getGreptileStatusCheck(pr) {
|
|
481
|
+
if (!pr?.statusCheckRollup)
|
|
482
|
+
return null;
|
|
483
|
+
const c = pr.statusCheckRollup.find((c2) => (c2.name || c2.context || "").toLowerCase().includes("greptile"));
|
|
484
|
+
if (!c)
|
|
485
|
+
return null;
|
|
486
|
+
if (c.conclusion === "SUCCESS" || c.status === "COMPLETED")
|
|
487
|
+
return "success";
|
|
488
|
+
if (c.conclusion === "FAILURE" || c.conclusion === "ERROR")
|
|
489
|
+
return "failure";
|
|
490
|
+
if (c.status === "IN_PROGRESS" || c.status === "QUEUED" || c.status === "PENDING")
|
|
491
|
+
return "pending";
|
|
492
|
+
return null;
|
|
493
|
+
}
|
|
494
|
+
async function check(task) {
|
|
495
|
+
const cfg = load();
|
|
496
|
+
const thresholds = cfg.quality;
|
|
497
|
+
const gates = [];
|
|
498
|
+
const reviewProvider = cfg.quality.reviewProvider || "greptile";
|
|
499
|
+
const hasGreptileKey = !!(cfg.greptileApiKey || process.env.GREPTILE_API_KEY);
|
|
500
|
+
const useGreptile = (reviewProvider === "greptile" || reviewProvider === "both") && hasGreptileKey;
|
|
501
|
+
const useCapy = reviewProvider === "capy" || reviewProvider === "both";
|
|
502
|
+
const hasPR = !!(task.pullRequest && task.pullRequest.number);
|
|
503
|
+
gates.push({ name: "pr_exists", pass: hasPR, detail: hasPR ? `PR#${task.pullRequest.number}` : "No PR created" });
|
|
504
|
+
if (!hasPR) {
|
|
505
|
+
return {
|
|
506
|
+
pass: false,
|
|
507
|
+
passed: 0,
|
|
508
|
+
total: 1,
|
|
509
|
+
gates,
|
|
510
|
+
summary: "No PR. Create one first: capy pr " + (task.identifier || task.id)
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
const repo = task.pullRequest.repoFullName || cfg.repos[0] && cfg.repos[0].repoFullName;
|
|
514
|
+
if (!repo) {
|
|
515
|
+
return { pass: false, passed: 0, total: 1, gates, summary: "No repo configured. Run: capy init" };
|
|
516
|
+
}
|
|
517
|
+
const prNum = task.pullRequest.number;
|
|
518
|
+
const defaultBranch = cfg.repos.find((r) => r.repoFullName === repo)?.branch || "main";
|
|
519
|
+
const pr = getPR(repo, prNum);
|
|
520
|
+
if (pr) {
|
|
521
|
+
const merged = pr.state === "MERGED";
|
|
522
|
+
const open = pr.state === "OPEN";
|
|
523
|
+
gates.push({
|
|
524
|
+
name: "pr_open",
|
|
525
|
+
pass: merged || open,
|
|
526
|
+
detail: `${pr.state}${pr.reviewDecision ? ` (${pr.reviewDecision})` : ""}`
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
const ci = getCIStatus(repo, prNum, pr);
|
|
530
|
+
if (ci) {
|
|
531
|
+
const nonGreptile = (f) => !(f.name || "").toLowerCase().includes("greptile");
|
|
532
|
+
const failures = ci.failing.filter(nonGreptile);
|
|
533
|
+
const pending = ci.pending.filter(nonGreptile);
|
|
534
|
+
const ciGreen = failures.length === 0 && pending.length === 0;
|
|
535
|
+
const greptileCheck = !ci.failing.every(nonGreptile) || !ci.pending.every(nonGreptile);
|
|
536
|
+
gates.push({
|
|
537
|
+
name: "ci",
|
|
538
|
+
pass: ciGreen || ci.noChecks,
|
|
539
|
+
detail: ci.noChecks ? "No CI configured" : ciGreen ? `${ci.total - (greptileCheck ? 1 : 0)} passing` : `${failures.length} failing: ${failures.map((f) => f.name).join(", ")}`,
|
|
540
|
+
failing: failures,
|
|
541
|
+
pending
|
|
542
|
+
});
|
|
543
|
+
}
|
|
544
|
+
if (useGreptile) {
|
|
545
|
+
const status = getGreptileStatusCheck(pr);
|
|
546
|
+
if (status === "pending") {
|
|
547
|
+
gates.push({ name: "greptile", pass: false, detail: "Review still processing" });
|
|
548
|
+
} else {
|
|
549
|
+
const unaddressed = await getUnaddressedIssues(repo, prNum, defaultBranch);
|
|
550
|
+
gates.push({
|
|
551
|
+
name: "greptile",
|
|
552
|
+
pass: unaddressed.length === 0,
|
|
553
|
+
detail: unaddressed.length === 0 ? "All issues addressed" : `${unaddressed.length} unaddressed: ${unaddressed.slice(0, 3).map((u) => `${u.file}:${u.line}`).join(", ")}`,
|
|
554
|
+
issues: unaddressed
|
|
555
|
+
});
|
|
556
|
+
if (status === "failure") {
|
|
557
|
+
gates.push({ name: "greptile_check", pass: false, detail: "Status check failing" });
|
|
558
|
+
} else if (status === "success") {
|
|
559
|
+
gates.push({ name: "greptile_check", pass: true, detail: "Status check passing" });
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
if (useCapy) {
|
|
564
|
+
const unresolved = getUnresolvedThreads(repo, prNum);
|
|
565
|
+
gates.push({
|
|
566
|
+
name: "threads",
|
|
567
|
+
pass: unresolved.length === 0,
|
|
568
|
+
detail: unresolved.length === 0 ? "No unresolved threads" : `${unresolved.length} unresolved`,
|
|
569
|
+
threads: unresolved
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
if (thresholds.requireTests) {
|
|
573
|
+
let diffFiles = null;
|
|
574
|
+
try {
|
|
575
|
+
diffFiles = (await getDiff(task.identifier || task.id)).files || null;
|
|
576
|
+
} catch {}
|
|
577
|
+
const hasTests = diffFiles ? diffHasTests(diffFiles) : false;
|
|
578
|
+
gates.push({ name: "tests", pass: hasTests, detail: hasTests ? "Tests in diff" : "No test files in diff" });
|
|
579
|
+
}
|
|
580
|
+
const passed = gates.filter((g) => g.pass).length;
|
|
581
|
+
const total = gates.length;
|
|
582
|
+
const allPass = gates.every((g) => g.pass);
|
|
583
|
+
const failing = gates.filter((g) => !g.pass);
|
|
584
|
+
let summary;
|
|
585
|
+
if (allPass) {
|
|
586
|
+
summary = `${passed}/${total} gates passing. Ready to merge.`;
|
|
587
|
+
} else {
|
|
588
|
+
summary = `${passed}/${total} gates passing:
|
|
589
|
+
` + failing.map((g) => ` - ${g.name}: ${g.detail}`).join(`
|
|
590
|
+
`);
|
|
591
|
+
}
|
|
592
|
+
return { pass: allPass, passed, total, gates, summary };
|
|
593
|
+
}
|
|
594
|
+
var init_quality = __esm(() => {
|
|
595
|
+
init_github();
|
|
596
|
+
init_config();
|
|
597
|
+
init_greptile();
|
|
598
|
+
init_api();
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
// src/watch.ts
|
|
602
|
+
import fs2 from "node:fs";
|
|
603
|
+
import path2 from "node:path";
|
|
604
|
+
import { execSync } from "node:child_process";
|
|
605
|
+
function getCrontab() {
|
|
606
|
+
try {
|
|
607
|
+
return execSync("crontab -l 2>/dev/null", { encoding: "utf8" });
|
|
608
|
+
} catch {
|
|
609
|
+
return "";
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
function setCrontab(content) {
|
|
613
|
+
execSync(`echo ${JSON.stringify(content)} | crontab -`, { encoding: "utf8" });
|
|
614
|
+
}
|
|
615
|
+
function add(id, type, intervalMin) {
|
|
616
|
+
const watchDir = WATCH_DIR;
|
|
617
|
+
fs2.mkdirSync(watchDir, { recursive: true });
|
|
618
|
+
const thisDir = path2.dirname(new URL(import.meta.url).pathname);
|
|
619
|
+
const binPath = path2.resolve(thisDir, "..", "bin", "capy.ts");
|
|
620
|
+
const runtime = typeof Bun !== "undefined" ? "bun" : "node";
|
|
621
|
+
const tag = `# capy-watch:${id}`;
|
|
622
|
+
const cronLine = `*/${intervalMin} * * * * ${runtime} ${binPath} _poll ${id} ${type} ${tag}`;
|
|
623
|
+
let crontab = getCrontab();
|
|
624
|
+
if (crontab.includes(`capy-watch:${id}`))
|
|
625
|
+
return false;
|
|
626
|
+
crontab = crontab.trimEnd() + `
|
|
627
|
+
` + cronLine + `
|
|
628
|
+
`;
|
|
629
|
+
setCrontab(crontab);
|
|
630
|
+
fs2.writeFileSync(path2.join(watchDir, `${id}.json`), JSON.stringify({
|
|
631
|
+
id,
|
|
632
|
+
type,
|
|
633
|
+
intervalMin,
|
|
634
|
+
created: new Date().toISOString()
|
|
635
|
+
}));
|
|
636
|
+
return true;
|
|
637
|
+
}
|
|
638
|
+
function remove(id) {
|
|
639
|
+
let crontab = getCrontab();
|
|
640
|
+
const lines = crontab.split(`
|
|
641
|
+
`).filter((l) => !l.includes(`capy-watch:${id}`));
|
|
642
|
+
setCrontab(lines.join(`
|
|
643
|
+
`) + `
|
|
644
|
+
`);
|
|
645
|
+
try {
|
|
646
|
+
fs2.unlinkSync(path2.join(WATCH_DIR, `${id}.json`));
|
|
647
|
+
} catch {}
|
|
648
|
+
}
|
|
649
|
+
function list() {
|
|
650
|
+
try {
|
|
651
|
+
return fs2.readdirSync(WATCH_DIR).filter((f) => f.endsWith(".json")).map((f) => JSON.parse(fs2.readFileSync(path2.join(WATCH_DIR, f), "utf8")));
|
|
652
|
+
} catch {
|
|
653
|
+
return [];
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
function notify(text) {
|
|
657
|
+
const cfg = load();
|
|
658
|
+
const cmd = cfg.notifyCommand || "openclaw system event --text {text} --mode now";
|
|
659
|
+
try {
|
|
660
|
+
execSync(cmd.replace("{text}", JSON.stringify(text)), {
|
|
661
|
+
encoding: "utf8",
|
|
662
|
+
timeout: 15000
|
|
663
|
+
});
|
|
664
|
+
return true;
|
|
665
|
+
} catch {
|
|
666
|
+
return false;
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
var init_watch = __esm(() => {
|
|
670
|
+
init_config();
|
|
671
|
+
});
|
|
672
|
+
|
|
673
|
+
// src/format.ts
|
|
674
|
+
function pad(s, n) {
|
|
675
|
+
return (String(s) + " ".repeat(n)).slice(0, n);
|
|
676
|
+
}
|
|
677
|
+
function out(data) {
|
|
678
|
+
if (IS_JSON) {
|
|
679
|
+
console.log(JSON.stringify(data, null, 2));
|
|
680
|
+
} else if (typeof data === "string") {
|
|
681
|
+
console.log(data);
|
|
682
|
+
} else if (data !== null && data !== undefined) {
|
|
683
|
+
console.log(JSON.stringify(data, null, 2));
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
function table(headers, rows) {
|
|
687
|
+
if (IS_JSON) {
|
|
688
|
+
console.log(JSON.stringify(rows, null, 2));
|
|
689
|
+
return;
|
|
690
|
+
}
|
|
691
|
+
const widths = headers.map((h, i) => Math.max(h.length, ...rows.map((r) => String(r[i] || "").length)));
|
|
692
|
+
console.log(headers.map((h, i) => pad(h, widths[i] + 2)).join(""));
|
|
693
|
+
console.log("-".repeat(widths.reduce((a, b) => a + b + 2, 0)));
|
|
694
|
+
rows.forEach((r) => {
|
|
695
|
+
console.log(r.map((c, i) => pad(String(c || ""), widths[i] + 2)).join(""));
|
|
696
|
+
});
|
|
697
|
+
}
|
|
698
|
+
function credits(c) {
|
|
699
|
+
if (!c)
|
|
700
|
+
return "0";
|
|
701
|
+
if (typeof c === "number")
|
|
702
|
+
return String(c);
|
|
703
|
+
return `llm=${c.llm || 0} vm=${c.vm || 0}`;
|
|
704
|
+
}
|
|
705
|
+
function section(title) {
|
|
706
|
+
if (!IS_JSON) {
|
|
707
|
+
console.log(`
|
|
708
|
+
${title}`);
|
|
709
|
+
console.log("-".repeat(80));
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
var IS_JSON;
|
|
713
|
+
var init_format = __esm(() => {
|
|
714
|
+
IS_JSON = process.argv.includes("--json");
|
|
715
|
+
});
|
|
716
|
+
|
|
717
|
+
// src/cli.ts
|
|
718
|
+
var exports_cli = {};
|
|
719
|
+
__export(exports_cli, {
|
|
720
|
+
run: () => run
|
|
721
|
+
});
|
|
722
|
+
function parseModel(argv) {
|
|
723
|
+
const f = argv.find((a) => a.startsWith("--model="));
|
|
724
|
+
if (f)
|
|
725
|
+
return f.split("=")[1];
|
|
726
|
+
if (argv.includes("--opus"))
|
|
727
|
+
return "claude-opus-4-6";
|
|
728
|
+
if (argv.includes("--sonnet"))
|
|
729
|
+
return "claude-sonnet-4-6";
|
|
730
|
+
if (argv.includes("--mini"))
|
|
731
|
+
return "gpt-5.4-mini";
|
|
732
|
+
if (argv.includes("--fast"))
|
|
733
|
+
return "gpt-5.4-fast";
|
|
734
|
+
if (argv.includes("--kimi"))
|
|
735
|
+
return "kimi-k2.5";
|
|
736
|
+
if (argv.includes("--glm"))
|
|
737
|
+
return "glm-5";
|
|
738
|
+
if (argv.includes("--gemini"))
|
|
739
|
+
return "gemini-3.1-pro";
|
|
740
|
+
if (argv.includes("--grok"))
|
|
741
|
+
return "grok-4.1-fast";
|
|
742
|
+
if (argv.includes("--qwen"))
|
|
743
|
+
return "qwen-3-coder";
|
|
744
|
+
return null;
|
|
745
|
+
}
|
|
746
|
+
function strip(argv) {
|
|
747
|
+
return argv.filter((a) => !a.startsWith("--"));
|
|
748
|
+
}
|
|
749
|
+
function getMode(argv) {
|
|
750
|
+
const f = argv.find((a) => a.startsWith("--mode="));
|
|
751
|
+
return f ? f.split("=")[1] : "run";
|
|
752
|
+
}
|
|
753
|
+
function getInterval(argv) {
|
|
754
|
+
const f = argv.find((a) => a.startsWith("--interval="));
|
|
755
|
+
return f ? Math.max(1, Math.min(parseInt(f.split("=")[1]), 30)) : load().watchInterval;
|
|
756
|
+
}
|
|
757
|
+
async function run(cmd, argv) {
|
|
758
|
+
const handler = commands[cmd];
|
|
759
|
+
if (!handler) {
|
|
760
|
+
console.error(`capy: unknown command "${cmd}". Run: capy help`);
|
|
761
|
+
process.exit(1);
|
|
762
|
+
}
|
|
763
|
+
await handler(argv);
|
|
764
|
+
}
|
|
765
|
+
var commands;
|
|
766
|
+
var init_cli = __esm(() => {
|
|
767
|
+
init_api();
|
|
768
|
+
init_config();
|
|
769
|
+
init_github();
|
|
770
|
+
init_quality();
|
|
771
|
+
init_watch();
|
|
772
|
+
init_format();
|
|
773
|
+
init_greptile();
|
|
774
|
+
commands = {};
|
|
775
|
+
commands.init = async function(argv) {
|
|
776
|
+
const cfg = load();
|
|
777
|
+
const readline = await import("node:readline");
|
|
778
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
779
|
+
const ask = (q, def) => new Promise((r) => rl.question(`${q} [${def}]: `, (a) => r(a.trim() || def)));
|
|
780
|
+
cfg.apiKey = await ask("Capy API key", cfg.apiKey || "capy_...");
|
|
781
|
+
cfg.projectId = await ask("Project ID", cfg.projectId || "");
|
|
782
|
+
const repoStr = await ask("Repos (owner/repo:branch, comma-sep)", cfg.repos.map((r) => `${r.repoFullName}:${r.branch}`).join(",") || "owner/repo:main");
|
|
783
|
+
cfg.repos = repoStr.split(",").map((s) => {
|
|
784
|
+
const [repo, branch] = s.trim().split(":");
|
|
785
|
+
return { repoFullName: repo, branch: branch || "main" };
|
|
786
|
+
});
|
|
787
|
+
cfg.defaultModel = await ask("Default model", cfg.defaultModel);
|
|
788
|
+
cfg.quality.minReviewScore = parseInt(await ask("Min review score (1-5)", String(cfg.quality.minReviewScore)));
|
|
789
|
+
rl.close();
|
|
790
|
+
save(cfg);
|
|
791
|
+
console.log(`
|
|
792
|
+
Config saved to ${CONFIG_PATH}`);
|
|
793
|
+
};
|
|
794
|
+
commands.config = function(argv) {
|
|
795
|
+
const args = strip(argv);
|
|
796
|
+
if (args.length === 0) {
|
|
797
|
+
out(load());
|
|
798
|
+
return;
|
|
799
|
+
}
|
|
800
|
+
if (args.length === 1) {
|
|
801
|
+
const val = get(args[0]);
|
|
802
|
+
if (val === undefined) {
|
|
803
|
+
console.error(`capy: unknown config key "${args[0]}"`);
|
|
804
|
+
process.exit(1);
|
|
805
|
+
}
|
|
806
|
+
if (IS_JSON || typeof val === "object") {
|
|
807
|
+
out(IS_JSON ? { [args[0]]: val } : val);
|
|
808
|
+
} else {
|
|
809
|
+
console.log(String(val));
|
|
810
|
+
}
|
|
811
|
+
return;
|
|
812
|
+
}
|
|
813
|
+
set(args[0], args.slice(1).join(" "));
|
|
814
|
+
console.log(`Set ${args[0]} = ${get(args[0])}`);
|
|
815
|
+
};
|
|
816
|
+
commands.captain = commands.plan = async function(argv) {
|
|
817
|
+
const prompt = strip(argv).join(" ");
|
|
818
|
+
if (!prompt) {
|
|
819
|
+
console.error("Usage: capy captain <prompt>");
|
|
820
|
+
process.exit(1);
|
|
821
|
+
}
|
|
822
|
+
const model = parseModel(argv) || load().defaultModel;
|
|
823
|
+
const data = await createThread(prompt, model);
|
|
824
|
+
if (IS_JSON) {
|
|
825
|
+
out(data);
|
|
826
|
+
return;
|
|
827
|
+
}
|
|
828
|
+
console.log(`Captain started: https://app.capy.ai/threads/${data.id}`);
|
|
829
|
+
console.log(`Thread: ${data.id} Model: ${model}`);
|
|
830
|
+
};
|
|
831
|
+
commands.threads = async function(argv) {
|
|
832
|
+
const sub = strip(argv)[0] || "list";
|
|
833
|
+
if (sub === "list") {
|
|
834
|
+
const data = await listThreads();
|
|
835
|
+
if (IS_JSON) {
|
|
836
|
+
out(data.items || []);
|
|
837
|
+
return;
|
|
838
|
+
}
|
|
839
|
+
if (!data.items?.length) {
|
|
840
|
+
console.log("No threads.");
|
|
841
|
+
return;
|
|
842
|
+
}
|
|
843
|
+
table(["ID", "STATUS", "TITLE"], data.items.map((t) => [
|
|
844
|
+
t.id.slice(0, 16),
|
|
845
|
+
t.status,
|
|
846
|
+
(t.title || "(untitled)").slice(0, 40)
|
|
847
|
+
]));
|
|
848
|
+
return;
|
|
849
|
+
}
|
|
850
|
+
if (sub === "get") {
|
|
851
|
+
const id = strip(argv)[1];
|
|
852
|
+
if (!id) {
|
|
853
|
+
console.error("Usage: capy threads get <id>");
|
|
854
|
+
process.exit(1);
|
|
855
|
+
}
|
|
856
|
+
const data = await getThread(id);
|
|
857
|
+
if (IS_JSON) {
|
|
858
|
+
out(data);
|
|
859
|
+
return;
|
|
860
|
+
}
|
|
861
|
+
console.log(`Thread: ${data.id}`);
|
|
862
|
+
console.log(`Title: ${data.title || "(untitled)"}`);
|
|
863
|
+
console.log(`Status: ${data.status}`);
|
|
864
|
+
if (data.tasks?.length) {
|
|
865
|
+
console.log(`
|
|
866
|
+
Tasks (${data.tasks.length}):`);
|
|
867
|
+
data.tasks.forEach((t) => console.log(` ${t.identifier} ${t.title} [${t.status}]`));
|
|
868
|
+
}
|
|
869
|
+
if (data.pullRequests?.length) {
|
|
870
|
+
console.log(`
|
|
871
|
+
PRs:`);
|
|
872
|
+
data.pullRequests.forEach((p) => console.log(` PR#${p.number} ${p.url} [${p.state}]`));
|
|
873
|
+
}
|
|
874
|
+
return;
|
|
875
|
+
}
|
|
876
|
+
if (sub === "msg" || sub === "message") {
|
|
877
|
+
const id = strip(argv)[1], msg = strip(argv).slice(2).join(" ");
|
|
878
|
+
if (!id || !msg) {
|
|
879
|
+
console.error("Usage: capy threads msg <id> <text>");
|
|
880
|
+
process.exit(1);
|
|
881
|
+
}
|
|
882
|
+
await messageThread(id, msg);
|
|
883
|
+
console.log("Message sent.");
|
|
884
|
+
return;
|
|
885
|
+
}
|
|
886
|
+
if (sub === "stop") {
|
|
887
|
+
const id = strip(argv)[1];
|
|
888
|
+
if (!id) {
|
|
889
|
+
console.error("Usage: capy threads stop <id>");
|
|
890
|
+
process.exit(1);
|
|
891
|
+
}
|
|
892
|
+
await stopThread(id);
|
|
893
|
+
console.log(`Stopped thread ${id}.`);
|
|
894
|
+
return;
|
|
895
|
+
}
|
|
896
|
+
if (sub === "messages" || sub === "msgs") {
|
|
897
|
+
const id = strip(argv)[1];
|
|
898
|
+
if (!id) {
|
|
899
|
+
console.error("Usage: capy threads messages <id>");
|
|
900
|
+
process.exit(1);
|
|
901
|
+
}
|
|
902
|
+
const data = await getThreadMessages(id);
|
|
903
|
+
if (IS_JSON) {
|
|
904
|
+
out(data.items || []);
|
|
905
|
+
return;
|
|
906
|
+
}
|
|
907
|
+
(data.items || []).forEach((m) => {
|
|
908
|
+
console.log(`[${m.source}] ${m.content.slice(0, 200)}`);
|
|
909
|
+
console.log();
|
|
910
|
+
});
|
|
911
|
+
return;
|
|
912
|
+
}
|
|
913
|
+
console.error("Usage: capy threads [list|get|msg|stop|messages]");
|
|
914
|
+
process.exit(1);
|
|
915
|
+
};
|
|
916
|
+
commands.build = commands.run = async function(argv) {
|
|
917
|
+
const prompt = strip(argv).join(" ");
|
|
918
|
+
if (!prompt) {
|
|
919
|
+
console.error("Usage: capy build <prompt>");
|
|
920
|
+
process.exit(1);
|
|
921
|
+
}
|
|
922
|
+
const model = parseModel(argv) || load().defaultModel;
|
|
923
|
+
const data = await createTask(prompt, model);
|
|
924
|
+
if (IS_JSON) {
|
|
925
|
+
out(data);
|
|
926
|
+
return;
|
|
927
|
+
}
|
|
928
|
+
console.log(`Build started: https://app.capy.ai/tasks/${data.id}`);
|
|
929
|
+
console.log(`ID: ${data.identifier} Model: ${model}`);
|
|
930
|
+
};
|
|
931
|
+
commands.list = commands.ls = async function(argv) {
|
|
932
|
+
const status = strip(argv)[0];
|
|
933
|
+
const data = await listTasks({ status });
|
|
934
|
+
if (IS_JSON) {
|
|
935
|
+
out(data.items || []);
|
|
936
|
+
return;
|
|
937
|
+
}
|
|
938
|
+
if (!data.items?.length) {
|
|
939
|
+
console.log("No tasks.");
|
|
940
|
+
return;
|
|
941
|
+
}
|
|
942
|
+
table(["ID", "STATUS", "TITLE", "PR"], data.items.map((t) => [
|
|
943
|
+
t.identifier,
|
|
944
|
+
t.status,
|
|
945
|
+
(t.title || "").slice(0, 45),
|
|
946
|
+
t.pullRequest ? `PR#${t.pullRequest.number} [${t.pullRequest.state}]` : "—"
|
|
947
|
+
]));
|
|
948
|
+
};
|
|
949
|
+
commands.get = commands.show = async function(argv) {
|
|
950
|
+
const id = strip(argv)[0];
|
|
951
|
+
if (!id) {
|
|
952
|
+
console.error("Usage: capy get <id>");
|
|
953
|
+
process.exit(1);
|
|
954
|
+
}
|
|
955
|
+
const data = await getTask(id);
|
|
956
|
+
if (IS_JSON) {
|
|
957
|
+
out(data);
|
|
958
|
+
return;
|
|
959
|
+
}
|
|
960
|
+
console.log(`Task: ${data.identifier} — ${data.title}`);
|
|
961
|
+
console.log(`Status: ${data.status}`);
|
|
962
|
+
console.log(`Created: ${data.createdAt}`);
|
|
963
|
+
if (data.pullRequest) {
|
|
964
|
+
console.log(`PR: ${data.pullRequest.url || `#${data.pullRequest.number}`} [${data.pullRequest.state}]`);
|
|
965
|
+
}
|
|
966
|
+
if (data.jams?.length) {
|
|
967
|
+
console.log(`
|
|
968
|
+
Jams (${data.jams.length}):`);
|
|
969
|
+
data.jams.forEach((j, i) => {
|
|
970
|
+
console.log(` ${i + 1}. model=${j.model || "?"} status=${j.status || "?"} credits=${credits(j.credits)}`);
|
|
971
|
+
});
|
|
972
|
+
}
|
|
973
|
+
};
|
|
974
|
+
commands.start = async function(argv) {
|
|
975
|
+
const id = strip(argv)[0];
|
|
976
|
+
if (!id) {
|
|
977
|
+
console.error("Usage: capy start <id>");
|
|
978
|
+
process.exit(1);
|
|
979
|
+
}
|
|
980
|
+
const model = parseModel(argv) || load().defaultModel;
|
|
981
|
+
const data = await startTask(id, model);
|
|
982
|
+
if (IS_JSON) {
|
|
983
|
+
out(data);
|
|
984
|
+
return;
|
|
985
|
+
}
|
|
986
|
+
console.log(`Started ${data.identifier || id} → ${data.status}`);
|
|
987
|
+
};
|
|
988
|
+
commands.stop = commands.kill = async function(argv) {
|
|
989
|
+
const id = strip(argv)[0], reason = strip(argv).slice(1).join(" ");
|
|
990
|
+
if (!id) {
|
|
991
|
+
console.error("Usage: capy stop <id>");
|
|
992
|
+
process.exit(1);
|
|
993
|
+
}
|
|
994
|
+
const data = await stopTask(id, reason);
|
|
995
|
+
if (IS_JSON) {
|
|
996
|
+
out(data);
|
|
997
|
+
return;
|
|
998
|
+
}
|
|
999
|
+
console.log(`Stopped ${data.identifier || id} → ${data.status}`);
|
|
1000
|
+
};
|
|
1001
|
+
commands.msg = commands.message = async function(argv) {
|
|
1002
|
+
const id = strip(argv)[0], msg = strip(argv).slice(1).join(" ");
|
|
1003
|
+
if (!id || !msg) {
|
|
1004
|
+
console.error("Usage: capy msg <id> <text>");
|
|
1005
|
+
process.exit(1);
|
|
1006
|
+
}
|
|
1007
|
+
await messageTask(id, msg);
|
|
1008
|
+
if (IS_JSON) {
|
|
1009
|
+
out({ id, message: msg, status: "sent" });
|
|
1010
|
+
return;
|
|
1011
|
+
}
|
|
1012
|
+
console.log("Message sent.");
|
|
1013
|
+
};
|
|
1014
|
+
commands.diff = async function(argv) {
|
|
1015
|
+
const id = strip(argv)[0];
|
|
1016
|
+
if (!id) {
|
|
1017
|
+
console.error("Usage: capy diff <id>");
|
|
1018
|
+
process.exit(1);
|
|
1019
|
+
}
|
|
1020
|
+
const data = await getDiff(id, getMode(argv));
|
|
1021
|
+
if (IS_JSON) {
|
|
1022
|
+
out(data);
|
|
1023
|
+
return;
|
|
1024
|
+
}
|
|
1025
|
+
console.log(`Diff (${data.source || "unknown"}): +${data.stats?.additions || 0} -${data.stats?.deletions || 0} in ${data.stats?.files || 0} files
|
|
1026
|
+
`);
|
|
1027
|
+
if (data.files) {
|
|
1028
|
+
data.files.forEach((f) => {
|
|
1029
|
+
console.log(`--- ${f.path} (${f.state}) +${f.additions} -${f.deletions}`);
|
|
1030
|
+
if (f.patch)
|
|
1031
|
+
console.log(f.patch);
|
|
1032
|
+
console.log();
|
|
1033
|
+
});
|
|
1034
|
+
}
|
|
1035
|
+
};
|
|
1036
|
+
commands.pr = async function(argv) {
|
|
1037
|
+
const id = strip(argv)[0], title = strip(argv).slice(1).join(" ");
|
|
1038
|
+
if (!id) {
|
|
1039
|
+
console.error("Usage: capy pr <id> [title]");
|
|
1040
|
+
process.exit(1);
|
|
1041
|
+
}
|
|
1042
|
+
const body = title ? { title } : {};
|
|
1043
|
+
const data = await createPR(id, body);
|
|
1044
|
+
if (IS_JSON) {
|
|
1045
|
+
out(data);
|
|
1046
|
+
return;
|
|
1047
|
+
}
|
|
1048
|
+
console.log(`PR: ${data.url}`);
|
|
1049
|
+
console.log(`#${data.number} ${data.title} (${data.headRef} → ${data.baseRef})`);
|
|
1050
|
+
};
|
|
1051
|
+
commands.models = async function() {
|
|
1052
|
+
const data = await listModels();
|
|
1053
|
+
if (IS_JSON) {
|
|
1054
|
+
out(data.models || []);
|
|
1055
|
+
return;
|
|
1056
|
+
}
|
|
1057
|
+
if (data.models) {
|
|
1058
|
+
table(["MODEL", "PROVIDER", "CAPTAIN"], data.models.map((m) => [
|
|
1059
|
+
m.id,
|
|
1060
|
+
m.provider || "?",
|
|
1061
|
+
m.captainEligible ? "yes" : "no"
|
|
1062
|
+
]));
|
|
1063
|
+
}
|
|
1064
|
+
};
|
|
1065
|
+
commands.tools = commands.commands = function(argv) {
|
|
1066
|
+
const all = {
|
|
1067
|
+
captain: { args: "<prompt>", desc: "Start Captain thread" },
|
|
1068
|
+
build: { args: "<prompt>", desc: "Start Build agent (isolated)" },
|
|
1069
|
+
threads: { args: "[list|get|msg|stop]", desc: "Manage threads" },
|
|
1070
|
+
status: { args: "", desc: "Dashboard" },
|
|
1071
|
+
list: { args: "[status]", desc: "List tasks" },
|
|
1072
|
+
get: { args: "<id>", desc: "Task details" },
|
|
1073
|
+
start: { args: "<id>", desc: "Start task" },
|
|
1074
|
+
stop: { args: "<id> [reason]", desc: "Stop task" },
|
|
1075
|
+
msg: { args: "<id> <text>", desc: "Message task" },
|
|
1076
|
+
diff: { args: "<id>", desc: "View diff" },
|
|
1077
|
+
pr: { args: "<id> [title]", desc: "Create PR" },
|
|
1078
|
+
review: { args: "<id>", desc: "Quality gates check" },
|
|
1079
|
+
"re-review": { args: "<id>", desc: "Trigger Greptile re-review" },
|
|
1080
|
+
approve: { args: "<id>", desc: "Approve if gates pass" },
|
|
1081
|
+
retry: { args: "<id> [--fix=...]", desc: "Retry with failure context" },
|
|
1082
|
+
watch: { args: "<id>", desc: "Poll + notify on completion" },
|
|
1083
|
+
unwatch: { args: "<id>", desc: "Stop watching" },
|
|
1084
|
+
watches: { args: "", desc: "List watches" },
|
|
1085
|
+
models: { args: "", desc: "List models" },
|
|
1086
|
+
tools: { args: "", desc: "This list" },
|
|
1087
|
+
config: { args: "[key] [value]", desc: "Get/set config" },
|
|
1088
|
+
init: { args: "", desc: "Interactive setup" }
|
|
1089
|
+
};
|
|
1090
|
+
if (IS_JSON) {
|
|
1091
|
+
out(all);
|
|
1092
|
+
return;
|
|
1093
|
+
}
|
|
1094
|
+
const cfg = load();
|
|
1095
|
+
console.log(`Available commands:
|
|
1096
|
+
`);
|
|
1097
|
+
for (const [name, t] of Object.entries(all)) {
|
|
1098
|
+
console.log(` ${pad(name, 14)} ${pad(t.args, 24)} ${t.desc}`);
|
|
1099
|
+
}
|
|
1100
|
+
console.log(`
|
|
1101
|
+
Config: ${CONFIG_PATH}`);
|
|
1102
|
+
console.log(`Review provider: ${cfg.quality?.reviewProvider || "greptile"}`);
|
|
1103
|
+
console.log(`Default model: ${cfg.defaultModel}`);
|
|
1104
|
+
console.log(`Repos: ${(cfg.repos || []).map((r) => r.repoFullName).join(", ") || "none"}`);
|
|
1105
|
+
const envVars = [
|
|
1106
|
+
["CAPY_API_KEY", "API key (overrides config)"],
|
|
1107
|
+
["CAPY_PROJECT_ID", "Project ID (overrides config)"],
|
|
1108
|
+
["CAPY_SERVER", "API server URL"],
|
|
1109
|
+
["CAPY_ENV_FILE", "Path to .env file"],
|
|
1110
|
+
["GREPTILE_API_KEY", "Greptile API key"]
|
|
1111
|
+
];
|
|
1112
|
+
console.log(`
|
|
1113
|
+
Environment variables:`);
|
|
1114
|
+
envVars.forEach(([k, v]) => console.log(` ${pad(k, 20)} ${v}`));
|
|
1115
|
+
};
|
|
1116
|
+
commands.status = commands.dashboard = async function(argv) {
|
|
1117
|
+
const cfg = load();
|
|
1118
|
+
const threads = await listThreads({ limit: 10 });
|
|
1119
|
+
const tasks = await listTasks({ limit: 30 });
|
|
1120
|
+
if (IS_JSON) {
|
|
1121
|
+
out({
|
|
1122
|
+
threads: threads.items || [],
|
|
1123
|
+
tasks: tasks.items || [],
|
|
1124
|
+
watches: list()
|
|
1125
|
+
});
|
|
1126
|
+
return;
|
|
1127
|
+
}
|
|
1128
|
+
const active = (threads.items || []).filter((t) => t.status === "active");
|
|
1129
|
+
if (active.length) {
|
|
1130
|
+
section("ACTIVE THREADS");
|
|
1131
|
+
active.forEach((t) => console.log(` ${t.id.slice(0, 14)} ${(t.title || "(untitled)").slice(0, 50)} [active]`));
|
|
1132
|
+
}
|
|
1133
|
+
const allTasks = tasks.items || [];
|
|
1134
|
+
const buckets = {};
|
|
1135
|
+
allTasks.forEach((t) => {
|
|
1136
|
+
(buckets[t.status] = buckets[t.status] || []).push(t);
|
|
1137
|
+
});
|
|
1138
|
+
if (buckets.in_progress?.length) {
|
|
1139
|
+
section("IN PROGRESS");
|
|
1140
|
+
buckets.in_progress.forEach((t) => {
|
|
1141
|
+
const j = (t.jams || []).at(-1);
|
|
1142
|
+
const stuck = j && j.status === "idle" && (!j.credits || typeof j.credits === "object" && j.credits.llm === 0 && j.credits.vm === 0);
|
|
1143
|
+
console.log(` ${pad(t.identifier, 10)} ${pad((t.title || "").slice(0, 48), 50)}${stuck ? " !! STUCK" : ""}`);
|
|
1144
|
+
});
|
|
1145
|
+
}
|
|
1146
|
+
if (buckets.needs_review?.length) {
|
|
1147
|
+
section("NEEDS REVIEW");
|
|
1148
|
+
buckets.needs_review.forEach((t) => {
|
|
1149
|
+
let prInfo = "no PR";
|
|
1150
|
+
if (t.pullRequest?.number) {
|
|
1151
|
+
const repo = t.pullRequest.repoFullName || cfg.repos[0]?.repoFullName || "";
|
|
1152
|
+
const pr = getPR(repo, t.pullRequest.number);
|
|
1153
|
+
const state = pr ? pr.state : t.pullRequest.state || "?";
|
|
1154
|
+
const ci = getCIStatus(repo, t.pullRequest.number, pr);
|
|
1155
|
+
const ciStr = ci ? ci.allGreen ? "CI pass" : ci.noChecks ? "no CI" : "CI FAIL" : "?";
|
|
1156
|
+
prInfo = `PR#${t.pullRequest.number} [${state}] ${ciStr}`;
|
|
1157
|
+
}
|
|
1158
|
+
console.log(` ${pad(t.identifier, 10)} ${pad((t.title || "").slice(0, 42), 44)} ${prInfo}`);
|
|
1159
|
+
});
|
|
1160
|
+
}
|
|
1161
|
+
if (buckets.backlog?.length) {
|
|
1162
|
+
section(`BACKLOG (${buckets.backlog.length})`);
|
|
1163
|
+
buckets.backlog.forEach((t) => console.log(` ${pad(t.identifier, 10)} ${(t.title || "").slice(0, 60)}`));
|
|
1164
|
+
}
|
|
1165
|
+
const watches = list();
|
|
1166
|
+
if (watches.length) {
|
|
1167
|
+
section(`ACTIVE WATCHES (${watches.length})`);
|
|
1168
|
+
watches.forEach((w) => console.log(` ${pad(w.id.slice(0, 18), 20)} type=${w.type} every ${w.intervalMin}min`));
|
|
1169
|
+
}
|
|
1170
|
+
const stuckCount = (buckets.in_progress || []).filter((t) => {
|
|
1171
|
+
const j = (t.jams || []).at(-1);
|
|
1172
|
+
return j && j.status === "idle" && (!j.credits || typeof j.credits === "object" && j.credits.llm === 0 && j.credits.vm === 0);
|
|
1173
|
+
}).length;
|
|
1174
|
+
console.log(`
|
|
1175
|
+
Summary: ${allTasks.length} tasks, ${(buckets.in_progress || []).length} active, ${(buckets.needs_review || []).length} review, ${stuckCount} stuck`);
|
|
1176
|
+
};
|
|
1177
|
+
commands.review = async function(argv) {
|
|
1178
|
+
const id = strip(argv)[0];
|
|
1179
|
+
if (!id) {
|
|
1180
|
+
console.error("Usage: capy review <id>");
|
|
1181
|
+
process.exit(1);
|
|
1182
|
+
}
|
|
1183
|
+
const task = await getTask(id);
|
|
1184
|
+
const cfg = load();
|
|
1185
|
+
const reviewProvider = cfg.quality?.reviewProvider || "greptile";
|
|
1186
|
+
if (!task.pullRequest?.number) {
|
|
1187
|
+
if (IS_JSON) {
|
|
1188
|
+
out({ error: "no_pr", task: task.identifier });
|
|
1189
|
+
return;
|
|
1190
|
+
}
|
|
1191
|
+
console.log(`${task.identifier}: No PR. Create one first: capy pr ${task.identifier}`);
|
|
1192
|
+
return;
|
|
1193
|
+
}
|
|
1194
|
+
const repo = task.pullRequest.repoFullName || cfg.repos[0]?.repoFullName || "";
|
|
1195
|
+
const prNum = task.pullRequest.number;
|
|
1196
|
+
const defaultBranch = cfg.repos.find((r) => r.repoFullName === repo)?.branch || "main";
|
|
1197
|
+
let diffStats = null;
|
|
1198
|
+
try {
|
|
1199
|
+
const diff = await getDiff(id);
|
|
1200
|
+
diffStats = diff.stats || null;
|
|
1201
|
+
} catch {}
|
|
1202
|
+
const q = await check(task);
|
|
1203
|
+
let unaddressed = [];
|
|
1204
|
+
const hasGreptileKey = !!(cfg.greptileApiKey || process.env.GREPTILE_API_KEY);
|
|
1205
|
+
if ((reviewProvider === "greptile" || reviewProvider === "both") && hasGreptileKey) {
|
|
1206
|
+
unaddressed = await getUnaddressedIssues(repo, prNum, defaultBranch);
|
|
1207
|
+
}
|
|
1208
|
+
if (IS_JSON) {
|
|
1209
|
+
out({
|
|
1210
|
+
task: task.identifier,
|
|
1211
|
+
quality: q,
|
|
1212
|
+
unaddressed,
|
|
1213
|
+
reviewProvider,
|
|
1214
|
+
diff: diffStats ? { files: diffStats.files || 0, additions: diffStats.additions || 0, deletions: diffStats.deletions || 0 } : null
|
|
1215
|
+
});
|
|
1216
|
+
return;
|
|
1217
|
+
}
|
|
1218
|
+
const prOpen = q.gates.find((g) => g.name === "pr_open");
|
|
1219
|
+
console.log(`Review: ${task.identifier} — ${task.title}`);
|
|
1220
|
+
console.log(`PR: #${prNum} [${prOpen?.detail || task.pullRequest?.state || "?"}]`);
|
|
1221
|
+
if (diffStats)
|
|
1222
|
+
console.log(`Diff: +${diffStats.additions || 0} -${diffStats.deletions || 0} in ${diffStats.files || 0} files`);
|
|
1223
|
+
console.log(`Review: ${reviewProvider}`);
|
|
1224
|
+
console.log();
|
|
1225
|
+
q.gates.forEach((g) => {
|
|
1226
|
+
const icon = g.pass ? "✓" : "✗";
|
|
1227
|
+
console.log(` ${icon} ${g.name}: ${g.detail}`);
|
|
1228
|
+
if (g.name === "ci" && g.failing?.length) {
|
|
1229
|
+
g.failing.forEach((f) => console.log(` ✗ ${f.name} (${f.conclusion || f.status})`));
|
|
1230
|
+
}
|
|
1231
|
+
if (g.name === "ci" && g.pending?.length) {
|
|
1232
|
+
g.pending.forEach((f) => console.log(` ... ${f.name} (${f.status})`));
|
|
1233
|
+
}
|
|
1234
|
+
});
|
|
1235
|
+
if (unaddressed.length > 0) {
|
|
1236
|
+
console.log(`
|
|
1237
|
+
Unaddressed Greptile issues (${unaddressed.length}):`);
|
|
1238
|
+
unaddressed.forEach((u) => {
|
|
1239
|
+
console.log(` ${u.file}:${u.line} ${u.body}`);
|
|
1240
|
+
if (u.hasSuggestion)
|
|
1241
|
+
console.log(` ^ has suggested fix`);
|
|
1242
|
+
});
|
|
1243
|
+
}
|
|
1244
|
+
console.log(`
|
|
1245
|
+
${q.summary}`);
|
|
1246
|
+
const greptileGate = q.gates.find((g) => g.name === "greptile");
|
|
1247
|
+
if (greptileGate && !greptileGate.pass) {
|
|
1248
|
+
if (greptileGate.detail.includes("processing")) {
|
|
1249
|
+
console.log(`
|
|
1250
|
+
Greptile is still processing. Wait a minute, then: capy review ${task.identifier}`);
|
|
1251
|
+
} else {
|
|
1252
|
+
console.log(`
|
|
1253
|
+
Fix the unaddressed issues, push, and Greptile will auto-re-review.`);
|
|
1254
|
+
console.log(`Then: capy review ${task.identifier}`);
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
};
|
|
1258
|
+
commands["re-review"] = commands.rereview = async function(argv) {
|
|
1259
|
+
const id = strip(argv)[0];
|
|
1260
|
+
if (!id) {
|
|
1261
|
+
console.error("Usage: capy re-review <id>");
|
|
1262
|
+
process.exit(1);
|
|
1263
|
+
}
|
|
1264
|
+
const cfg = load();
|
|
1265
|
+
const reviewProvider = cfg.quality?.reviewProvider || "greptile";
|
|
1266
|
+
if (reviewProvider !== "greptile" && reviewProvider !== "both") {
|
|
1267
|
+
console.error(`capy: re-review requires Greptile provider (current: ${reviewProvider})`);
|
|
1268
|
+
process.exit(1);
|
|
1269
|
+
}
|
|
1270
|
+
if (!cfg.greptileApiKey && !process.env.GREPTILE_API_KEY) {
|
|
1271
|
+
console.error("capy: GREPTILE_API_KEY not set. Run: capy config greptileApiKey <key>");
|
|
1272
|
+
process.exit(1);
|
|
1273
|
+
}
|
|
1274
|
+
const task = await getTask(id);
|
|
1275
|
+
if (!task.pullRequest?.number) {
|
|
1276
|
+
console.error(`${task.identifier}: No PR. Create one first: capy pr ${task.identifier}`);
|
|
1277
|
+
process.exit(1);
|
|
1278
|
+
}
|
|
1279
|
+
const repo = task.pullRequest.repoFullName || cfg.repos[0]?.repoFullName || "";
|
|
1280
|
+
const prNum = task.pullRequest.number;
|
|
1281
|
+
const defaultBranch = cfg.repos.find((r) => r.repoFullName === repo)?.branch || "main";
|
|
1282
|
+
console.log(`Triggering fresh Greptile review for PR#${prNum}...`);
|
|
1283
|
+
console.log(`(Note: Greptile auto-reviews on every push via triggerOnUpdates. This is a manual override.)`);
|
|
1284
|
+
const result = await freshReview(repo, prNum, defaultBranch);
|
|
1285
|
+
if (IS_JSON) {
|
|
1286
|
+
out(result);
|
|
1287
|
+
return;
|
|
1288
|
+
}
|
|
1289
|
+
if (result) {
|
|
1290
|
+
if (result.status === "COMPLETED") {
|
|
1291
|
+
console.log("Review completed.");
|
|
1292
|
+
} else if (result.status === "FAILED") {
|
|
1293
|
+
console.log("Review failed. Check the PR state.");
|
|
1294
|
+
} else {
|
|
1295
|
+
console.log(`Review status: ${result.status || "unknown"}`);
|
|
1296
|
+
}
|
|
1297
|
+
} else {
|
|
1298
|
+
console.log("Review triggered. Check back shortly or run: capy review " + task.identifier);
|
|
1299
|
+
}
|
|
1300
|
+
const unaddressed = await getUnaddressedIssues(repo, prNum, defaultBranch);
|
|
1301
|
+
if (unaddressed.length > 0) {
|
|
1302
|
+
console.log(`
|
|
1303
|
+
Unaddressed issues: ${unaddressed.length}`);
|
|
1304
|
+
unaddressed.forEach((u) => console.log(` ${u.file}:${u.line} ${u.body}`));
|
|
1305
|
+
} else {
|
|
1306
|
+
console.log(`
|
|
1307
|
+
All issues addressed.`);
|
|
1308
|
+
}
|
|
1309
|
+
};
|
|
1310
|
+
commands.approve = async function(argv) {
|
|
1311
|
+
const id = strip(argv)[0];
|
|
1312
|
+
if (!id) {
|
|
1313
|
+
console.error("Usage: capy approve <id>");
|
|
1314
|
+
process.exit(1);
|
|
1315
|
+
}
|
|
1316
|
+
const force = argv.includes("--force");
|
|
1317
|
+
const task = await getTask(id);
|
|
1318
|
+
const cfg = load();
|
|
1319
|
+
const q = await check(task);
|
|
1320
|
+
if (IS_JSON) {
|
|
1321
|
+
out({ task: task.identifier, quality: q, approved: q.pass || force });
|
|
1322
|
+
return;
|
|
1323
|
+
}
|
|
1324
|
+
console.log(`${task.identifier} — ${task.title}
|
|
1325
|
+
`);
|
|
1326
|
+
q.gates.forEach((g) => {
|
|
1327
|
+
const icon = g.pass ? "✓" : "✗";
|
|
1328
|
+
console.log(` ${icon} ${g.name}: ${g.detail}`);
|
|
1329
|
+
});
|
|
1330
|
+
console.log(`
|
|
1331
|
+
${q.summary}`);
|
|
1332
|
+
if (!q.pass && !force) {
|
|
1333
|
+
console.log(`
|
|
1334
|
+
Blocked. Fix the failing gates or use --force to override.`);
|
|
1335
|
+
process.exit(1);
|
|
1336
|
+
}
|
|
1337
|
+
if (q.pass || force) {
|
|
1338
|
+
console.log(`
|
|
1339
|
+
✓ Approved.${force && !q.pass ? " (forced)" : ""}`);
|
|
1340
|
+
const approveCmd = cfg.approveCommand;
|
|
1341
|
+
if (approveCmd) {
|
|
1342
|
+
try {
|
|
1343
|
+
const { execSync: execSync2 } = await import("node:child_process");
|
|
1344
|
+
const expanded = approveCmd.replace("{task}", task.identifier || task.id).replace("{title}", task.title || "").replace("{pr}", String(task.pullRequest?.number || ""));
|
|
1345
|
+
execSync2(expanded, { encoding: "utf8", timeout: 15000, stdio: "pipe" });
|
|
1346
|
+
console.log("Post-approve hook ran.");
|
|
1347
|
+
} catch {}
|
|
1348
|
+
}
|
|
1349
|
+
}
|
|
1350
|
+
};
|
|
1351
|
+
commands.retry = async function(argv) {
|
|
1352
|
+
const id = strip(argv)[0];
|
|
1353
|
+
if (!id) {
|
|
1354
|
+
console.error('Usage: capy retry <id> [--fix "what to fix"]');
|
|
1355
|
+
process.exit(1);
|
|
1356
|
+
}
|
|
1357
|
+
const fixFlag = argv.find((a) => a.startsWith("--fix="));
|
|
1358
|
+
const fixArg = fixFlag ? fixFlag.split("=").slice(1).join("=") : null;
|
|
1359
|
+
const task = await getTask(id);
|
|
1360
|
+
const cfg = load();
|
|
1361
|
+
let context = `Previous attempt: ${task.identifier} "${task.title}" [${task.status}]
|
|
1362
|
+
`;
|
|
1363
|
+
try {
|
|
1364
|
+
const diff = await getDiff(id);
|
|
1365
|
+
if (diff.stats?.files && diff.stats.files > 0) {
|
|
1366
|
+
context += `
|
|
1367
|
+
Previous diff: +${diff.stats.additions} -${diff.stats.deletions} in ${diff.stats.files} files
|
|
1368
|
+
`;
|
|
1369
|
+
context += `Files changed: ${(diff.files || []).map((f) => f.path).join(", ")}
|
|
1370
|
+
`;
|
|
1371
|
+
} else {
|
|
1372
|
+
context += `
|
|
1373
|
+
Previous diff: empty (agent produced no changes)
|
|
1374
|
+
`;
|
|
1375
|
+
}
|
|
1376
|
+
} catch {
|
|
1377
|
+
context += `
|
|
1378
|
+
Previous diff: unavailable
|
|
1379
|
+
`;
|
|
1380
|
+
}
|
|
1381
|
+
if (task.pullRequest?.number) {
|
|
1382
|
+
const repo = task.pullRequest.repoFullName || cfg.repos[0]?.repoFullName || "";
|
|
1383
|
+
const prNum = task.pullRequest.number;
|
|
1384
|
+
const defaultBranch = cfg.repos.find((r) => r.repoFullName === repo)?.branch || "main";
|
|
1385
|
+
const reviewComments = getPRReviewComments(repo, prNum);
|
|
1386
|
+
const ci = getCIStatus(repo, prNum);
|
|
1387
|
+
const reviewProvider = cfg.quality?.reviewProvider || "greptile";
|
|
1388
|
+
const hasGreptileKey = !!(cfg.greptileApiKey || process.env.GREPTILE_API_KEY);
|
|
1389
|
+
if (reviewProvider === "greptile" && hasGreptileKey) {
|
|
1390
|
+
const unaddressed = await getUnaddressedIssues(repo, prNum, defaultBranch);
|
|
1391
|
+
if (unaddressed.length > 0) {
|
|
1392
|
+
context += `
|
|
1393
|
+
Unaddressed Greptile issues (${unaddressed.length}):
|
|
1394
|
+
`;
|
|
1395
|
+
unaddressed.forEach((u) => {
|
|
1396
|
+
context += ` ${u.file}:${u.line}: ${u.body}
|
|
1397
|
+
`;
|
|
1398
|
+
if (u.suggestedCode)
|
|
1399
|
+
context += ` Suggested fix: ${u.suggestedCode.slice(0, 200)}
|
|
1400
|
+
`;
|
|
1401
|
+
});
|
|
1402
|
+
} else {
|
|
1403
|
+
context += `
|
|
1404
|
+
Greptile: all issues addressed
|
|
1405
|
+
`;
|
|
1406
|
+
}
|
|
1407
|
+
} else {
|
|
1408
|
+
const issueComments = getPRIssueComments(repo, prNum);
|
|
1409
|
+
const greptileReview = parseGreptileReview(issueComments);
|
|
1410
|
+
if (greptileReview) {
|
|
1411
|
+
context += `
|
|
1412
|
+
Greptile review: ${greptileReview.score}/5 (stale — may not reflect latest)
|
|
1413
|
+
`;
|
|
1414
|
+
}
|
|
1415
|
+
}
|
|
1416
|
+
if (ci && !ci.allGreen) {
|
|
1417
|
+
context += `
|
|
1418
|
+
CI failures: ${ci.failing.map((f) => f.name).join(", ")}
|
|
1419
|
+
`;
|
|
1420
|
+
}
|
|
1421
|
+
if (reviewComments.length) {
|
|
1422
|
+
context += `
|
|
1423
|
+
Review comments (${reviewComments.length}):
|
|
1424
|
+
`;
|
|
1425
|
+
reviewComments.slice(0, 5).forEach((c) => {
|
|
1426
|
+
context += ` ${c.path}:${c.line || "?"}: ${(c.body || "").slice(0, 150)}
|
|
1427
|
+
`;
|
|
1428
|
+
});
|
|
1429
|
+
}
|
|
1430
|
+
}
|
|
1431
|
+
const originalPrompt = task.prompt || task.title;
|
|
1432
|
+
let retryPrompt = `RETRY: This is a retry of a previous attempt that had issues.
|
|
1433
|
+
|
|
1434
|
+
`;
|
|
1435
|
+
retryPrompt += `Original task: ${originalPrompt}
|
|
1436
|
+
|
|
1437
|
+
`;
|
|
1438
|
+
retryPrompt += `--- CONTEXT FROM PREVIOUS ATTEMPT ---
|
|
1439
|
+
${context}
|
|
1440
|
+
`;
|
|
1441
|
+
if (fixArg) {
|
|
1442
|
+
retryPrompt += `--- SPECIFIC FIX REQUESTED ---
|
|
1443
|
+
${fixArg}
|
|
1444
|
+
|
|
1445
|
+
`;
|
|
1446
|
+
}
|
|
1447
|
+
retryPrompt += `--- INSTRUCTIONS ---
|
|
1448
|
+
`;
|
|
1449
|
+
retryPrompt += `Fix the issues from the previous attempt. Do not repeat the same mistakes.
|
|
1450
|
+
`;
|
|
1451
|
+
retryPrompt += `Include tests. Run tests before completing. Verify CI will pass.
|
|
1452
|
+
`;
|
|
1453
|
+
if (IS_JSON) {
|
|
1454
|
+
out({ originalTask: task.identifier, retryPrompt, context });
|
|
1455
|
+
return;
|
|
1456
|
+
}
|
|
1457
|
+
if (task.status === "in_progress") {
|
|
1458
|
+
await stopTask(id, "Retrying with fixes");
|
|
1459
|
+
console.log(`Stopped ${task.identifier}.`);
|
|
1460
|
+
}
|
|
1461
|
+
const model = parseModel(argv) || cfg.defaultModel;
|
|
1462
|
+
const data = await createThread(retryPrompt, model);
|
|
1463
|
+
console.log(`Retry started: https://app.capy.ai/threads/${data.id}`);
|
|
1464
|
+
console.log(`Thread: ${data.id} Model: ${model}`);
|
|
1465
|
+
console.log(`
|
|
1466
|
+
Context included: ${context.split(`
|
|
1467
|
+
`).length} lines from previous attempt.`);
|
|
1468
|
+
};
|
|
1469
|
+
commands.watch = function(argv) {
|
|
1470
|
+
const id = strip(argv)[0];
|
|
1471
|
+
if (!id) {
|
|
1472
|
+
console.error("Usage: capy watch <id> [--interval=3]");
|
|
1473
|
+
process.exit(1);
|
|
1474
|
+
}
|
|
1475
|
+
const interval = getInterval(argv);
|
|
1476
|
+
const type = id.length > 20 || id.length > 10 && !id.match(/^[A-Z]+-\d+$/) ? "thread" : "task";
|
|
1477
|
+
const added = add(id, type, interval);
|
|
1478
|
+
if (IS_JSON) {
|
|
1479
|
+
out({ id, type, interval, added });
|
|
1480
|
+
return;
|
|
1481
|
+
}
|
|
1482
|
+
if (added) {
|
|
1483
|
+
console.log(`Watching ${id} (${type}) every ${interval}min. Will notify when done.`);
|
|
1484
|
+
} else {
|
|
1485
|
+
console.log(`Already watching ${id}.`);
|
|
1486
|
+
}
|
|
1487
|
+
};
|
|
1488
|
+
commands.unwatch = function(argv) {
|
|
1489
|
+
const id = strip(argv)[0];
|
|
1490
|
+
if (!id) {
|
|
1491
|
+
console.error("Usage: capy unwatch <id>");
|
|
1492
|
+
process.exit(1);
|
|
1493
|
+
}
|
|
1494
|
+
remove(id);
|
|
1495
|
+
if (IS_JSON) {
|
|
1496
|
+
out({ id, status: "removed" });
|
|
1497
|
+
return;
|
|
1498
|
+
}
|
|
1499
|
+
console.log(`Stopped watching ${id}.`);
|
|
1500
|
+
};
|
|
1501
|
+
commands.watches = function() {
|
|
1502
|
+
const w = list();
|
|
1503
|
+
if (IS_JSON) {
|
|
1504
|
+
out(w);
|
|
1505
|
+
return;
|
|
1506
|
+
}
|
|
1507
|
+
if (!w.length) {
|
|
1508
|
+
console.log("No active watches.");
|
|
1509
|
+
return;
|
|
1510
|
+
}
|
|
1511
|
+
w.forEach((e) => console.log(`${pad(e.id.slice(0, 20), 22)} type=${e.type} every ${e.intervalMin}min since ${e.created}`));
|
|
1512
|
+
};
|
|
1513
|
+
commands._poll = async function(argv) {
|
|
1514
|
+
const id = argv[0], type = argv[1] || "task";
|
|
1515
|
+
if (!id)
|
|
1516
|
+
process.exit(1);
|
|
1517
|
+
if (type === "thread") {
|
|
1518
|
+
const data2 = await getThread(id);
|
|
1519
|
+
if (data2.status === "idle" || data2.status === "archived") {
|
|
1520
|
+
const taskLines = (data2.tasks || []).map((t) => ` ${t.identifier}: ${t.title} [${t.status}]`).join(`
|
|
1521
|
+
`);
|
|
1522
|
+
const prLines = (data2.pullRequests || []).map((p) => ` PR#${p.number}: ${p.url} [${p.state}]`).join(`
|
|
1523
|
+
`);
|
|
1524
|
+
let msg = `[Capy] Captain thread finished.
|
|
1525
|
+
Title: ${data2.title || "(untitled)"}
|
|
1526
|
+
Status: ${data2.status}`;
|
|
1527
|
+
if (taskLines)
|
|
1528
|
+
msg += `
|
|
1529
|
+
|
|
1530
|
+
Tasks:
|
|
1531
|
+
${taskLines}`;
|
|
1532
|
+
if (prLines)
|
|
1533
|
+
msg += `
|
|
1534
|
+
|
|
1535
|
+
PRs:
|
|
1536
|
+
${prLines}`;
|
|
1537
|
+
msg += `
|
|
1538
|
+
|
|
1539
|
+
Run: capy review <task-id> for each task, then capy approve <task-id> if quality passes.`;
|
|
1540
|
+
notify(msg);
|
|
1541
|
+
remove(id);
|
|
1542
|
+
}
|
|
1543
|
+
return;
|
|
1544
|
+
}
|
|
1545
|
+
const data = await getTask(id);
|
|
1546
|
+
if (data.status === "needs_review" || data.status === "archived") {
|
|
1547
|
+
let msg = `[Capy] Task ${data.identifier} ready.
|
|
1548
|
+
Title: ${data.title}
|
|
1549
|
+
Status: ${data.status}`;
|
|
1550
|
+
if (data.pullRequest)
|
|
1551
|
+
msg += `
|
|
1552
|
+
PR: ${data.pullRequest.url || "#" + data.pullRequest.number}`;
|
|
1553
|
+
msg += `
|
|
1554
|
+
|
|
1555
|
+
Run: capy review ${data.identifier}, then capy approve ${data.identifier} if quality passes.`;
|
|
1556
|
+
notify(msg);
|
|
1557
|
+
remove(id);
|
|
1558
|
+
}
|
|
1559
|
+
};
|
|
1560
|
+
});
|
|
1561
|
+
|
|
1562
|
+
// bin/capy.ts
|
|
1563
|
+
import { createRequire as createRequire2 } from "module";
|
|
1564
|
+
var require2 = createRequire2(import.meta.url);
|
|
1565
|
+
var { version } = require2("../package.json");
|
|
1566
|
+
var cmd = process.argv[2];
|
|
1567
|
+
if (!cmd || cmd === "help" || cmd === "--help" || cmd === "-h") {
|
|
1568
|
+
console.log(`capy \u2014 agent orchestrator with quality gates
|
|
1569
|
+
|
|
1570
|
+
Usage: capy <command> [args] [flags]
|
|
1571
|
+
|
|
1572
|
+
Agents:
|
|
1573
|
+
captain <prompt> Start Captain thread
|
|
1574
|
+
build <prompt> Start Build agent
|
|
1575
|
+
threads [list|get|msg|stop|messages]
|
|
1576
|
+
|
|
1577
|
+
Tasks:
|
|
1578
|
+
status Dashboard
|
|
1579
|
+
list [status] List tasks
|
|
1580
|
+
get <id> Task details
|
|
1581
|
+
start/stop/msg <id> Control tasks
|
|
1582
|
+
diff <id> View diff
|
|
1583
|
+
pr <id> [title] Create PR
|
|
1584
|
+
|
|
1585
|
+
Quality:
|
|
1586
|
+
review <id> Gate check
|
|
1587
|
+
re-review <id> Trigger Greptile re-review
|
|
1588
|
+
approve <id> [--force] Approve if gates pass
|
|
1589
|
+
retry <id> [--fix="..."] Retry with context
|
|
1590
|
+
|
|
1591
|
+
Monitoring:
|
|
1592
|
+
watch/unwatch <id> Auto-poll + notify
|
|
1593
|
+
watches List watches
|
|
1594
|
+
|
|
1595
|
+
Config:
|
|
1596
|
+
init Interactive setup
|
|
1597
|
+
config [key] [value] Get/set config
|
|
1598
|
+
models List models
|
|
1599
|
+
tools All commands + env vars
|
|
1600
|
+
|
|
1601
|
+
Flags:
|
|
1602
|
+
--json --model=<id> --opus --sonnet --fast
|
|
1603
|
+
|
|
1604
|
+
v${version}
|
|
1605
|
+
`);
|
|
1606
|
+
process.exit(0);
|
|
1607
|
+
}
|
|
1608
|
+
try {
|
|
1609
|
+
const { run: run2 } = await Promise.resolve().then(() => (init_cli(), exports_cli));
|
|
1610
|
+
await run2(cmd, process.argv.slice(3));
|
|
1611
|
+
} catch (e) {
|
|
1612
|
+
const err = e;
|
|
1613
|
+
if (err.code === "MODULE_NOT_FOUND" || err.code === "ERR_MODULE_NOT_FOUND") {
|
|
1614
|
+
console.error("capy: broken install. Reinstall: npm i -g capy-cli");
|
|
1615
|
+
process.exit(1);
|
|
1616
|
+
}
|
|
1617
|
+
console.error(err.message);
|
|
1618
|
+
process.exit(1);
|
|
1619
|
+
}
|