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/src/config.ts ADDED
@@ -0,0 +1,93 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import type { CapyConfig, QualityConfig } from "./types.js";
4
+
5
+ export const CONFIG_DIR = path.join(process.env.HOME || "/root", ".capy");
6
+ export const CONFIG_PATH = path.join(CONFIG_DIR, "config.json");
7
+ export const WATCH_DIR = path.join(CONFIG_DIR, "watches");
8
+
9
+ const DEFAULTS: CapyConfig = {
10
+ apiKey: "",
11
+ projectId: "",
12
+ server: "https://capy.ai/api/v1",
13
+ repos: [],
14
+ defaultModel: "gpt-5.4",
15
+ quality: {
16
+ minReviewScore: 4,
17
+ requireCI: true,
18
+ requireTests: true,
19
+ requireLinearLink: true,
20
+ reviewProvider: "greptile",
21
+ },
22
+ watchInterval: 3,
23
+ notifyCommand: "",
24
+ };
25
+
26
+ export function load(): CapyConfig {
27
+ const envPath = process.env.CAPY_ENV_FILE || path.join(CONFIG_DIR, ".env");
28
+ try {
29
+ fs.readFileSync(envPath, "utf8").split("\n").forEach(line => {
30
+ const t = line.trim();
31
+ if (!t || t.startsWith("#")) return;
32
+ const eq = t.indexOf("=");
33
+ if (eq === -1) return;
34
+ const k = t.slice(0, eq).trim();
35
+ const v = t.slice(eq + 1).trim();
36
+ if (!process.env[k]) process.env[k] = v;
37
+ });
38
+ } catch {}
39
+
40
+ let cfg: Partial<CapyConfig>;
41
+ try {
42
+ cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf8"));
43
+ } catch {
44
+ cfg = {};
45
+ }
46
+
47
+ const merged: CapyConfig = { ...DEFAULTS, ...cfg } as CapyConfig;
48
+ merged.quality = { ...DEFAULTS.quality, ...(cfg.quality || {}) } as QualityConfig;
49
+
50
+ if (process.env.CAPY_API_KEY) merged.apiKey = process.env.CAPY_API_KEY;
51
+ if (process.env.CAPY_PROJECT_ID) merged.projectId = process.env.CAPY_PROJECT_ID;
52
+ if (process.env.CAPY_SERVER) merged.server = process.env.CAPY_SERVER;
53
+
54
+ return merged;
55
+ }
56
+
57
+ export function save(cfg: CapyConfig): void {
58
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
59
+ fs.writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2) + "\n");
60
+ }
61
+
62
+ export function get(key: string): unknown {
63
+ const cfg = load() as Record<string, unknown>;
64
+ if (key.includes(".")) {
65
+ const parts = key.split(".");
66
+ let val: unknown = cfg;
67
+ for (const p of parts) {
68
+ val = (val as Record<string, unknown>)?.[p];
69
+ }
70
+ return val;
71
+ }
72
+ return cfg[key];
73
+ }
74
+
75
+ export function set(key: string, value: string): void {
76
+ const cfg = load() as Record<string, unknown>;
77
+ if (key.includes(".")) {
78
+ const parts = key.split(".");
79
+ let obj: Record<string, unknown> = cfg;
80
+ for (let i = 0; i < parts.length - 1; i++) {
81
+ if (!obj[parts[i]]) obj[parts[i]] = {};
82
+ obj = obj[parts[i]] as Record<string, unknown>;
83
+ }
84
+ let parsed: unknown = value;
85
+ if (value === "true") parsed = true;
86
+ else if (value === "false") parsed = false;
87
+ else if (/^\d+$/.test(value)) parsed = parseInt(value);
88
+ obj[parts[parts.length - 1]] = parsed;
89
+ } else {
90
+ cfg[key] = value;
91
+ }
92
+ save(cfg as CapyConfig);
93
+ }
package/src/format.ts ADDED
@@ -0,0 +1,45 @@
1
+ import type { Credits } from "./types.js";
2
+
3
+ export const IS_JSON = process.argv.includes("--json");
4
+
5
+ export function pad(s: string | number, n: number): string {
6
+ return (String(s) + " ".repeat(n)).slice(0, n);
7
+ }
8
+
9
+ export function out(data: unknown): void {
10
+ if (IS_JSON) {
11
+ console.log(JSON.stringify(data, null, 2));
12
+ } else if (typeof data === "string") {
13
+ console.log(data);
14
+ } else if (data !== null && data !== undefined) {
15
+ console.log(JSON.stringify(data, null, 2));
16
+ }
17
+ }
18
+
19
+ export function table(headers: string[], rows: (string | number | null | undefined)[][]): void {
20
+ if (IS_JSON) {
21
+ console.log(JSON.stringify(rows, null, 2));
22
+ return;
23
+ }
24
+ const widths = headers.map((h, i) =>
25
+ Math.max(h.length, ...rows.map(r => String(r[i] || "").length))
26
+ );
27
+ console.log(headers.map((h, i) => pad(h, widths[i] + 2)).join(""));
28
+ console.log("-".repeat(widths.reduce((a, b) => a + b + 2, 0)));
29
+ rows.forEach(r => {
30
+ console.log(r.map((c, i) => pad(String(c || ""), widths[i] + 2)).join(""));
31
+ });
32
+ }
33
+
34
+ export function credits(c: Credits | number | null | undefined): string {
35
+ if (!c) return "0";
36
+ if (typeof c === "number") return String(c);
37
+ return `llm=${c.llm || 0} vm=${c.vm || 0}`;
38
+ }
39
+
40
+ export function section(title: string): void {
41
+ if (!IS_JSON) {
42
+ console.log(`\n${title}`);
43
+ console.log("-".repeat(80));
44
+ }
45
+ }
package/src/github.ts ADDED
@@ -0,0 +1,112 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import type { PRData, CIStatus, GreptileReview, DiffFile, StatusCheck } from "./types.js";
3
+
4
+ function gh(args: string[], opts: { timeout?: number } = {}): any | null {
5
+ try {
6
+ return JSON.parse(execFileSync("gh", args, {
7
+ encoding: "utf8",
8
+ timeout: opts.timeout || 15000,
9
+ maxBuffer: 5 * 1024 * 1024,
10
+ }));
11
+ } catch {
12
+ return null;
13
+ }
14
+ }
15
+
16
+ export function getPR(repo: string, number: number): PRData | null {
17
+ return gh(["pr", "view", String(number), "--repo", repo, "--json",
18
+ "state,mergeable,mergedAt,closedAt,headRefName,baseRefName,title,body,url,number,additions,deletions,changedFiles,reviewDecision,statusCheckRollup,reviews,comments"]);
19
+ }
20
+
21
+ export function getPRReviewComments(repo: string, number: number): any[] {
22
+ try {
23
+ const out = execFileSync("gh", ["api", `repos/${repo}/pulls/${number}/comments`, "--paginate"], {
24
+ encoding: "utf8", timeout: 15000, maxBuffer: 5 * 1024 * 1024,
25
+ });
26
+ return JSON.parse(out);
27
+ } catch { return []; }
28
+ }
29
+
30
+ export function getPRIssueComments(repo: string, number: number): any[] {
31
+ try {
32
+ const out = execFileSync("gh", ["api", `repos/${repo}/issues/${number}/comments`, "--paginate"], {
33
+ encoding: "utf8", timeout: 15000, maxBuffer: 5 * 1024 * 1024,
34
+ });
35
+ return JSON.parse(out);
36
+ } catch { return []; }
37
+ }
38
+
39
+ export function getCIStatus(repo: string, number: number, prData?: PRData | null): CIStatus | null {
40
+ const pr = prData || getPR(repo, number);
41
+ if (!pr) return null;
42
+ const checks: StatusCheck[] = pr.statusCheckRollup || [];
43
+ const total = checks.length;
44
+ const passing = checks.filter(c =>
45
+ c.conclusion === "SUCCESS" || c.conclusion === "NEUTRAL" || c.status === "COMPLETED"
46
+ ).length;
47
+ const failing = checks.filter(c =>
48
+ c.conclusion === "FAILURE" || c.conclusion === "ERROR" || c.conclusion === "TIMED_OUT"
49
+ );
50
+ const pending = checks.filter(c =>
51
+ c.status === "IN_PROGRESS" || c.status === "QUEUED" || c.status === "PENDING"
52
+ );
53
+ return {
54
+ total,
55
+ passing,
56
+ failing: failing.map(c => ({ name: c.name || c.context || "", conclusion: c.conclusion })),
57
+ pending: pending.map(c => ({ name: c.name || c.context || "", status: c.status })),
58
+ allGreen: total > 0 && failing.length === 0 && pending.length === 0,
59
+ noChecks: total === 0,
60
+ };
61
+ }
62
+
63
+ export function parseGreptileReview(comments: any[]): GreptileReview | null {
64
+ const greptile = comments.find((c: any) =>
65
+ (c.user?.login || "").toLowerCase().includes("greptile") ||
66
+ (c.body || "").includes("Confidence Score")
67
+ );
68
+ if (!greptile) return null;
69
+
70
+ const body: string = greptile.body || "";
71
+ const scoreMatch = body.match(/(?:Confidence\s*Score|confidence)[:\s]*(\d(?:\.\d)?)\s*\/\s*5/i);
72
+ const score = scoreMatch ? parseFloat(scoreMatch[1]) : null;
73
+
74
+ const logicCount = (body.match(/\bLogic\b/gi) || []).length;
75
+ const syntaxCount = (body.match(/\bSyntax\b/gi) || []).length;
76
+ const styleCount = (body.match(/\bStyle\b/gi) || []).length;
77
+
78
+ return {
79
+ score,
80
+ issueCount: logicCount + syntaxCount + styleCount,
81
+ logic: logicCount,
82
+ syntax: syntaxCount,
83
+ style: styleCount,
84
+ body: body.slice(0, 2000),
85
+ url: greptile.html_url,
86
+ };
87
+ }
88
+
89
+ export function diffHasTests(files: DiffFile[]): boolean {
90
+ if (!files) return false;
91
+ return files.some(f => {
92
+ const p = (f.path || f.filename || "").toLowerCase();
93
+ return p.includes("test") || p.includes("spec") || p.includes("__tests__") ||
94
+ p.endsWith(".test.ts") || p.endsWith(".test.js") || p.endsWith("_test.go") ||
95
+ p.endsWith(".spec.ts") || p.endsWith(".spec.js");
96
+ });
97
+ }
98
+
99
+ export function getUnresolvedThreads(repo: string, number: number): { body: string; author: string }[] {
100
+ try {
101
+ 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 } } } } } } } }`;
102
+ const out = execFileSync("gh", ["api", "graphql", "-f", `query=${query}`], {
103
+ encoding: "utf8", timeout: 15000,
104
+ });
105
+ const data = JSON.parse(out);
106
+ const threads = data?.data?.repository?.pullRequest?.reviewThreads?.nodes || [];
107
+ return threads.filter((t: any) => !t.isResolved && !t.isOutdated).map((t: any) => ({
108
+ body: t.comments?.nodes?.[0]?.body?.slice(0, 200) || "",
109
+ author: t.comments?.nodes?.[0]?.author?.login || "unknown",
110
+ }));
111
+ } catch { return []; }
112
+ }
@@ -0,0 +1,151 @@
1
+ import * as config from "./config.js";
2
+ import type { UnaddressedIssue } from "./types.js";
3
+
4
+ const MCP_URL = "https://api.greptile.com/mcp";
5
+
6
+ async function mcp(method: string, params: Record<string, unknown>): Promise<any> {
7
+ const cfg = config.load();
8
+ const apiKey = cfg.greptileApiKey || process.env.GREPTILE_API_KEY || "";
9
+ if (!apiKey) {
10
+ console.error("capy: GREPTILE_API_KEY not set. Run: capy config greptileApiKey <key>");
11
+ process.exit(1);
12
+ }
13
+
14
+ const body = {
15
+ jsonrpc: "2.0",
16
+ id: Date.now(),
17
+ method: "tools/call",
18
+ params: {
19
+ name: method,
20
+ arguments: params,
21
+ },
22
+ };
23
+
24
+ let res: Response;
25
+ try {
26
+ res = await fetch(MCP_URL, {
27
+ method: "POST",
28
+ headers: {
29
+ "Authorization": `Bearer ${apiKey}`,
30
+ "Content-Type": "application/json",
31
+ "Accept": "application/json",
32
+ },
33
+ body: JSON.stringify(body),
34
+ signal: AbortSignal.timeout(30000),
35
+ });
36
+ } catch (e: unknown) {
37
+ console.error(`greptile: request failed — ${(e as Error).message}`);
38
+ return null;
39
+ }
40
+
41
+ const text = await res.text();
42
+ try {
43
+ const data = JSON.parse(text);
44
+ if (data.error) {
45
+ console.error(`greptile: ${data.error.message || JSON.stringify(data.error)}`);
46
+ return null;
47
+ }
48
+ if (data.result?.content) {
49
+ const textPart = data.result.content.find((c: any) => c.type === "text");
50
+ if (textPart) {
51
+ try { return JSON.parse(textPart.text); } catch { return textPart.text; }
52
+ }
53
+ }
54
+ return data.result;
55
+ } catch {
56
+ console.error("greptile: bad response:", text.slice(0, 300));
57
+ return null;
58
+ }
59
+ }
60
+
61
+ export async function triggerReview(repo: string, prNumber: number, defaultBranch?: string): Promise<any> {
62
+ return mcp("trigger_code_review", {
63
+ name: repo,
64
+ remote: "github",
65
+ defaultBranch: defaultBranch || "main",
66
+ prNumber,
67
+ });
68
+ }
69
+
70
+ export async function listReviews(repo: string, prNumber: number): Promise<any> {
71
+ return mcp("list_code_reviews", {
72
+ name: repo,
73
+ remote: "github",
74
+ defaultBranch: "main",
75
+ prNumber,
76
+ limit: 5,
77
+ });
78
+ }
79
+
80
+ export async function getReview(reviewId: string): Promise<any> {
81
+ return mcp("get_code_review", { codeReviewId: reviewId });
82
+ }
83
+
84
+ export async function getPR(repo: string, prNumber: number, defaultBranch?: string): Promise<any> {
85
+ return mcp("get_merge_request", {
86
+ name: repo,
87
+ remote: "github",
88
+ defaultBranch: defaultBranch || "main",
89
+ prNumber,
90
+ });
91
+ }
92
+
93
+ export async function listComments(repo: string, prNumber: number, opts: { defaultBranch?: string; greptileOnly?: boolean; unaddressedOnly?: boolean } = {}): Promise<any> {
94
+ const params: Record<string, unknown> = {
95
+ name: repo,
96
+ remote: "github",
97
+ defaultBranch: opts.defaultBranch || "main",
98
+ prNumber,
99
+ };
100
+ if (opts.greptileOnly) params.greptileGenerated = true;
101
+ if (opts.unaddressedOnly) params.addressed = false;
102
+ return mcp("list_merge_request_comments", params);
103
+ }
104
+
105
+ export async function waitForReview(reviewId: string, timeoutMs = 120000): Promise<any> {
106
+ const start = Date.now();
107
+ while (Date.now() - start < timeoutMs) {
108
+ const review = await getReview(reviewId);
109
+ if (!review) return null;
110
+ if (review.status === "COMPLETED") return review;
111
+ if (review.status === "FAILED") return review;
112
+ await new Promise(r => setTimeout(r, 5000));
113
+ }
114
+ return null;
115
+ }
116
+
117
+ export async function freshReview(repo: string, prNumber: number, defaultBranch?: string): Promise<any> {
118
+ const trigger = await triggerReview(repo, prNumber, defaultBranch);
119
+ if (!trigger) return null;
120
+
121
+ const reviewId = trigger.codeReviewId || trigger.id;
122
+ if (!reviewId) return trigger;
123
+
124
+ console.error(`greptile: review triggered (${reviewId}), waiting...`);
125
+ return waitForReview(reviewId);
126
+ }
127
+
128
+ export async function getUnaddressedIssues(repo: string, prNumber: number, defaultBranch?: string): Promise<UnaddressedIssue[]> {
129
+ const comments = await listComments(repo, prNumber, {
130
+ defaultBranch,
131
+ greptileOnly: true,
132
+ unaddressedOnly: true,
133
+ });
134
+ if (!comments || !Array.isArray(comments)) return [];
135
+ return comments.map((c: any) => ({
136
+ body: (c.body || "").slice(0, 200),
137
+ file: c.path || c.file || "?",
138
+ line: c.line || c.position || "?",
139
+ hasSuggestion: !!c.hasSuggestion,
140
+ suggestedCode: c.suggestedCode || null,
141
+ }));
142
+ }
143
+
144
+ export async function needsReReview(repo: string, prNumber: number, defaultBranch?: string): Promise<{ hasNewCommits: boolean; reviewCompleteness: string } | null> {
145
+ const pr = await getPR(repo, prNumber, defaultBranch);
146
+ if (!pr) return null;
147
+ return {
148
+ hasNewCommits: !!pr.reviewAnalysis?.hasNewCommitsSinceReview,
149
+ reviewCompleteness: pr.reviewAnalysis?.reviewCompleteness || "unknown",
150
+ };
151
+ }
package/src/quality.ts ADDED
@@ -0,0 +1,131 @@
1
+ import * as github from "./github.js";
2
+ import * as config from "./config.js";
3
+ import * as greptile from "./greptile.js";
4
+ import * as api from "./api.js";
5
+ import type { Task, QualityGate, QualityResult, PRData } from "./types.js";
6
+
7
+ function getGreptileStatusCheck(pr: PRData | null): string | null {
8
+ if (!pr?.statusCheckRollup) return null;
9
+ const c = pr.statusCheckRollup.find(c =>
10
+ (c.name || c.context || "").toLowerCase().includes("greptile")
11
+ );
12
+ if (!c) return null;
13
+ if (c.conclusion === "SUCCESS" || c.status === "COMPLETED") return "success";
14
+ if (c.conclusion === "FAILURE" || c.conclusion === "ERROR") return "failure";
15
+ if (c.status === "IN_PROGRESS" || c.status === "QUEUED" || c.status === "PENDING") return "pending";
16
+ return null;
17
+ }
18
+
19
+ export async function check(task: Task): Promise<QualityResult> {
20
+ const cfg = config.load();
21
+ const thresholds = cfg.quality;
22
+ const gates: QualityGate[] = [];
23
+
24
+ const reviewProvider = cfg.quality.reviewProvider || "greptile";
25
+ const hasGreptileKey = !!(cfg.greptileApiKey || process.env.GREPTILE_API_KEY);
26
+ const useGreptile = (reviewProvider === "greptile" || reviewProvider === "both") && hasGreptileKey;
27
+ const useCapy = reviewProvider === "capy" || reviewProvider === "both";
28
+
29
+ const hasPR = !!(task.pullRequest && task.pullRequest.number);
30
+ gates.push({ name: "pr_exists", pass: hasPR, detail: hasPR ? `PR#${task.pullRequest!.number}` : "No PR created" });
31
+
32
+ if (!hasPR) {
33
+ return {
34
+ pass: false, passed: 0, total: 1, gates,
35
+ summary: "No PR. Create one first: capy pr " + (task.identifier || task.id),
36
+ };
37
+ }
38
+
39
+ const repo = task.pullRequest!.repoFullName || (cfg.repos[0] && cfg.repos[0].repoFullName);
40
+ if (!repo) {
41
+ return { pass: false, passed: 0, total: 1, gates, summary: "No repo configured. Run: capy init" };
42
+ }
43
+ const prNum = task.pullRequest!.number!;
44
+ const defaultBranch = cfg.repos.find(r => r.repoFullName === repo)?.branch || "main";
45
+
46
+ const pr = github.getPR(repo, prNum);
47
+ if (pr) {
48
+ const merged = pr.state === "MERGED";
49
+ const open = pr.state === "OPEN";
50
+ gates.push({
51
+ name: "pr_open",
52
+ pass: merged || open,
53
+ detail: `${pr.state}${pr.reviewDecision ? ` (${pr.reviewDecision})` : ""}`,
54
+ });
55
+ }
56
+
57
+ const ci = github.getCIStatus(repo, prNum, pr);
58
+ if (ci) {
59
+ const nonGreptile = (f: { name: string }) => !(f.name || "").toLowerCase().includes("greptile");
60
+ const failures = ci.failing.filter(nonGreptile);
61
+ const pending = ci.pending.filter(nonGreptile);
62
+ const ciGreen = failures.length === 0 && pending.length === 0;
63
+ const greptileCheck = !ci.failing.every(nonGreptile) || !ci.pending.every(nonGreptile);
64
+
65
+ gates.push({
66
+ name: "ci",
67
+ pass: ciGreen || ci.noChecks,
68
+ detail: ci.noChecks ? "No CI configured" :
69
+ ciGreen ? `${ci.total - (greptileCheck ? 1 : 0)} passing` :
70
+ `${failures.length} failing: ${failures.map(f => f.name).join(", ")}`,
71
+ failing: failures,
72
+ pending,
73
+ });
74
+ }
75
+
76
+ if (useGreptile) {
77
+ const status = getGreptileStatusCheck(pr);
78
+
79
+ if (status === "pending") {
80
+ gates.push({ name: "greptile", pass: false, detail: "Review still processing" });
81
+ } else {
82
+ const unaddressed = await greptile.getUnaddressedIssues(repo, prNum, defaultBranch);
83
+ gates.push({
84
+ name: "greptile",
85
+ pass: unaddressed.length === 0,
86
+ detail: unaddressed.length === 0
87
+ ? "All issues addressed"
88
+ : `${unaddressed.length} unaddressed: ${unaddressed.slice(0, 3).map(u => `${u.file}:${u.line}`).join(", ")}`,
89
+ issues: unaddressed,
90
+ });
91
+
92
+ if (status === "failure") {
93
+ gates.push({ name: "greptile_check", pass: false, detail: "Status check failing" });
94
+ } else if (status === "success") {
95
+ gates.push({ name: "greptile_check", pass: true, detail: "Status check passing" });
96
+ }
97
+ }
98
+ }
99
+
100
+ if (useCapy) {
101
+ const unresolved = github.getUnresolvedThreads(repo, prNum);
102
+ gates.push({
103
+ name: "threads",
104
+ pass: unresolved.length === 0,
105
+ detail: unresolved.length === 0 ? "No unresolved threads" : `${unresolved.length} unresolved`,
106
+ threads: unresolved,
107
+ });
108
+ }
109
+
110
+ if (thresholds.requireTests) {
111
+ let diffFiles = null;
112
+ try { diffFiles = (await api.getDiff(task.identifier || task.id)).files || null; } catch {}
113
+ const hasTests = diffFiles ? github.diffHasTests(diffFiles) : false;
114
+ gates.push({ name: "tests", pass: hasTests, detail: hasTests ? "Tests in diff" : "No test files in diff" });
115
+ }
116
+
117
+ const passed = gates.filter(g => g.pass).length;
118
+ const total = gates.length;
119
+ const allPass = gates.every(g => g.pass);
120
+ const failing = gates.filter(g => !g.pass);
121
+
122
+ let summary: string;
123
+ if (allPass) {
124
+ summary = `${passed}/${total} gates passing. Ready to merge.`;
125
+ } else {
126
+ summary = `${passed}/${total} gates passing:\n` +
127
+ failing.map(g => ` - ${g.name}: ${g.detail}`).join("\n");
128
+ }
129
+
130
+ return { pass: allPass, passed, total, gates, summary };
131
+ }