@adriandmitroca/relay 0.0.2
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 +121 -0
- package/dist/assets/index-BcE2ldjQ.css +1 -0
- package/dist/assets/index-RaJgQa_m.js +15 -0
- package/dist/index.html +16 -0
- package/package.json +47 -0
- package/scripts/install-service.sh +52 -0
- package/scripts/uninstall-service.sh +10 -0
- package/src/api/config.ts +481 -0
- package/src/api/issues.ts +81 -0
- package/src/api/middleware.ts +14 -0
- package/src/api/router.ts +31 -0
- package/src/cli.ts +184 -0
- package/src/config.ts +195 -0
- package/src/constants.ts +21 -0
- package/src/daemon.ts +1096 -0
- package/src/dashboard.ts +175 -0
- package/src/db.ts +718 -0
- package/src/notifications/telegram.ts +334 -0
- package/src/queue.ts +98 -0
- package/src/sources/asana.ts +161 -0
- package/src/sources/jira.ts +255 -0
- package/src/sources/linear.ts +233 -0
- package/src/sources/sentry.ts +222 -0
- package/src/sources/types.ts +20 -0
- package/src/utils/html.ts +23 -0
- package/src/utils/logger.ts +49 -0
- package/src/worker/claude.ts +297 -0
- package/src/worker/fix.ts +195 -0
- package/src/worker/git.ts +111 -0
- package/src/worker/triage.ts +122 -0
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export type Severity = "critical" | "high" | "medium" | "low";
|
|
2
|
+
|
|
3
|
+
export interface NormalizedIssue {
|
|
4
|
+
source: string;
|
|
5
|
+
sourceId: string;
|
|
6
|
+
externalUrl: string;
|
|
7
|
+
projectKey: string;
|
|
8
|
+
title: string;
|
|
9
|
+
body: string;
|
|
10
|
+
severity: Severity;
|
|
11
|
+
metadata: Record<string, unknown>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface SourceAdapter {
|
|
15
|
+
name: string;
|
|
16
|
+
poll(projectKey: string): Promise<NormalizedIssue[]>;
|
|
17
|
+
getIssueContext(issue: NormalizedIssue): Promise<string>;
|
|
18
|
+
onFixAccepted?(issue: NormalizedIssue, prUrl: string): Promise<void>;
|
|
19
|
+
onFixDiscarded?(issue: NormalizedIssue): Promise<void>;
|
|
20
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export function esc(s: string): string {
|
|
2
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
/** Decode HTML entities that may already be encoded in source data (e.g. Sentry titles). */
|
|
6
|
+
export function decodeHtmlEntities(s: string): string {
|
|
7
|
+
return s
|
|
8
|
+
.replace(/&/g, "&")
|
|
9
|
+
.replace(/</g, "<")
|
|
10
|
+
.replace(/>/g, ">")
|
|
11
|
+
.replace(/"/g, '"')
|
|
12
|
+
.replace(/'/g, "'");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Sanitize a string for use as a Telegram forum topic name (plain text, no parse mode).
|
|
16
|
+
* Replaces angle brackets with Unicode equivalents so they don't confuse Telegram clients. */
|
|
17
|
+
export function sanitizeTopicName(s: string): string {
|
|
18
|
+
return decodeHtmlEntities(s)
|
|
19
|
+
.replace(/</g, "\u2039") // ‹
|
|
20
|
+
.replace(/>/g, "\u203A") // ›
|
|
21
|
+
.replace(/\s+/g, " ")
|
|
22
|
+
.trim();
|
|
23
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
const LEVELS = { debug: 0, info: 1, warn: 2, error: 3 } as const;
|
|
2
|
+
type Level = keyof typeof LEVELS;
|
|
3
|
+
|
|
4
|
+
const COLORS: Record<Level, string> = {
|
|
5
|
+
debug: "\x1b[90m",
|
|
6
|
+
info: "\x1b[36m",
|
|
7
|
+
warn: "\x1b[33m",
|
|
8
|
+
error: "\x1b[31m",
|
|
9
|
+
};
|
|
10
|
+
const RESET = "\x1b[0m";
|
|
11
|
+
const BOLD = "\x1b[1m";
|
|
12
|
+
|
|
13
|
+
let minLevel: Level = "info";
|
|
14
|
+
let fileWriter: ReturnType<ReturnType<typeof Bun.file>["writer"]> | null = null;
|
|
15
|
+
|
|
16
|
+
export function setLogLevel(level: Level) {
|
|
17
|
+
minLevel = level;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function setLogFile(path: string) {
|
|
21
|
+
fileWriter = Bun.file(path).writer();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function timestamp(): string {
|
|
25
|
+
const d = new Date();
|
|
26
|
+
return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}:${String(d.getSeconds()).padStart(2, "0")}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function log(level: Level, msg: string, data?: Record<string, unknown>) {
|
|
30
|
+
if (LEVELS[level] < LEVELS[minLevel]) return;
|
|
31
|
+
const ts = timestamp();
|
|
32
|
+
const label = level.toUpperCase().padEnd(5);
|
|
33
|
+
const kvs = data
|
|
34
|
+
? " " + Object.entries(data).map(([k, v]) => `${k}=${typeof v === "string" ? v : JSON.stringify(v)}`).join(" ")
|
|
35
|
+
: "";
|
|
36
|
+
const colored = `${COLORS[level]}[${ts}] ${BOLD}${label}${RESET}${COLORS[level]} ${msg}${kvs}${RESET}`;
|
|
37
|
+
Bun.write(Bun.stderr, colored + "\n");
|
|
38
|
+
if (fileWriter) {
|
|
39
|
+
fileWriter.write(`[${ts}] ${label} ${msg}${kvs}\n`);
|
|
40
|
+
fileWriter.flush();
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const logger = {
|
|
45
|
+
debug: (msg: string, data?: Record<string, unknown>) => log("debug", msg, data),
|
|
46
|
+
info: (msg: string, data?: Record<string, unknown>) => log("info", msg, data),
|
|
47
|
+
warn: (msg: string, data?: Record<string, unknown>) => log("warn", msg, data),
|
|
48
|
+
error: (msg: string, data?: Record<string, unknown>) => log("error", msg, data),
|
|
49
|
+
};
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
import { appendFileSync } from "node:fs";
|
|
2
|
+
import { logger } from "../utils/logger.ts";
|
|
3
|
+
|
|
4
|
+
export interface ClaudeResult {
|
|
5
|
+
success: boolean;
|
|
6
|
+
output: string;
|
|
7
|
+
error?: string;
|
|
8
|
+
exitCode: number;
|
|
9
|
+
durationMs: number;
|
|
10
|
+
sessionId?: string;
|
|
11
|
+
inputTokens: number;
|
|
12
|
+
outputTokens: number;
|
|
13
|
+
costUsd?: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type ClaudeStreamEvent =
|
|
17
|
+
| { type: "tool_use"; tool: string }
|
|
18
|
+
| { type: "text"; text: string }
|
|
19
|
+
| { type: "result"; text: string; sessionId?: string; costUsd?: number };
|
|
20
|
+
|
|
21
|
+
export interface ClaudeOptions {
|
|
22
|
+
cwd?: string;
|
|
23
|
+
timeoutMs?: number;
|
|
24
|
+
allowedTools?: string[];
|
|
25
|
+
model?: "sonnet" | "opus" | "haiku";
|
|
26
|
+
sessionId?: string;
|
|
27
|
+
resume?: string;
|
|
28
|
+
onEvent?: (event: ClaudeStreamEvent) => void;
|
|
29
|
+
logPath?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface ProcessResult {
|
|
33
|
+
stdout: string;
|
|
34
|
+
stderr: string;
|
|
35
|
+
exitCode: number;
|
|
36
|
+
durationMs: number;
|
|
37
|
+
timedOut: boolean;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function spawnClaude(prompt: string, opts: ClaudeOptions): Promise<ProcessResult> {
|
|
41
|
+
const { cwd, timeoutMs = 300_000, allowedTools, model, resume, onEvent, logPath } = opts;
|
|
42
|
+
const start = performance.now();
|
|
43
|
+
|
|
44
|
+
const args = buildArgs({ prompt, allowedTools, model, resume });
|
|
45
|
+
|
|
46
|
+
logger.debug("Running Claude", { cwd, timeoutMs, model, resume: !!resume, toolCount: allowedTools?.length ?? 0 });
|
|
47
|
+
|
|
48
|
+
const env = { ...process.env };
|
|
49
|
+
delete env.CLAUDECODE;
|
|
50
|
+
|
|
51
|
+
const proc = Bun.spawn(["claude", ...args], { cwd, env, stdout: "pipe", stderr: "pipe" });
|
|
52
|
+
|
|
53
|
+
const result = await Promise.race([
|
|
54
|
+
streamProcess(proc, onEvent, logPath),
|
|
55
|
+
timeout(timeoutMs).then(() => {
|
|
56
|
+
proc.kill("SIGTERM");
|
|
57
|
+
return Bun.sleep(10_000).then(() => {
|
|
58
|
+
try { proc.kill("SIGKILL"); } catch {}
|
|
59
|
+
return { stdout: "", stderr: "", exitCode: -1, timedOut: true as const };
|
|
60
|
+
});
|
|
61
|
+
}),
|
|
62
|
+
]);
|
|
63
|
+
|
|
64
|
+
const durationMs = Math.round(performance.now() - start);
|
|
65
|
+
|
|
66
|
+
if ("timedOut" in result && result.timedOut) {
|
|
67
|
+
logger.warn("Claude timed out", { durationMs, timeoutMs });
|
|
68
|
+
return { stdout: "", stderr: "", exitCode: -1, durationMs, timedOut: true };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return { ...result, durationMs, timedOut: false };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function runClaudeText(prompt: string, opts: ClaudeOptions = {}): Promise<ClaudeResult> {
|
|
75
|
+
const result = await spawnClaude(prompt, opts);
|
|
76
|
+
|
|
77
|
+
if (result.timedOut) {
|
|
78
|
+
return { success: false, output: "", error: `Timed out after ${opts.timeoutMs ?? 300_000}ms`, exitCode: -1, durationMs: result.durationMs, inputTokens: 0, outputTokens: 0 };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (result.exitCode !== 0) {
|
|
82
|
+
logger.warn("Claude exited with error", { exitCode: result.exitCode, stderr: result.stderr.slice(0, 500) });
|
|
83
|
+
return { success: false, output: result.stdout, error: result.stderr || `Exit code ${result.exitCode}`, exitCode: result.exitCode, durationMs: result.durationMs, inputTokens: 0, outputTokens: 0 };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const parsed = parseStreamJson(result.stdout);
|
|
87
|
+
logger.debug("Claude completed", { durationMs: result.durationMs, outputLength: parsed.text.length, sessionId: parsed.sessionId, inputTokens: parsed.inputTokens, outputTokens: parsed.outputTokens });
|
|
88
|
+
return { success: true, output: parsed.text, exitCode: 0, durationMs: result.durationMs, sessionId: parsed.sessionId, inputTokens: parsed.inputTokens, outputTokens: parsed.outputTokens, costUsd: parsed.costUsd };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export async function runClaudeJson<T>(prompt: string, opts: ClaudeOptions = {}): Promise<{ success: boolean; data?: T; error?: string; durationMs: number; sessionId?: string; inputTokens: number; outputTokens: number; costUsd?: number }> {
|
|
92
|
+
const result = await spawnClaude(prompt, opts);
|
|
93
|
+
|
|
94
|
+
if (result.timedOut) {
|
|
95
|
+
return { success: false, error: `Timed out after ${opts.timeoutMs ?? 300_000}ms`, durationMs: result.durationMs, inputTokens: 0, outputTokens: 0 };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (result.exitCode !== 0) {
|
|
99
|
+
return { success: false, error: result.stderr || `Exit code ${result.exitCode}`, durationMs: result.durationMs, inputTokens: 0, outputTokens: 0 };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
try {
|
|
103
|
+
const parsed = parseStreamJson(result.stdout);
|
|
104
|
+
const data = extractJson(parsed.text) as T;
|
|
105
|
+
return { success: true, data, durationMs: result.durationMs, sessionId: parsed.sessionId, inputTokens: parsed.inputTokens, outputTokens: parsed.outputTokens, costUsd: parsed.costUsd };
|
|
106
|
+
} catch (err) {
|
|
107
|
+
logger.warn("Failed to parse Claude JSON output", { error: String(err), stdout: result.stdout.slice(0, 300) });
|
|
108
|
+
return { success: false, error: `Failed to parse JSON: ${result.stdout.slice(0, 200)}`, durationMs: result.durationMs, inputTokens: 0, outputTokens: 0 };
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function buildArgs(opts: {
|
|
113
|
+
prompt: string;
|
|
114
|
+
allowedTools?: string[];
|
|
115
|
+
model?: string;
|
|
116
|
+
resume?: string;
|
|
117
|
+
}): string[] {
|
|
118
|
+
const args: string[] = [];
|
|
119
|
+
|
|
120
|
+
if (opts.resume) {
|
|
121
|
+
args.push("--resume", opts.resume, "-p", opts.prompt);
|
|
122
|
+
} else {
|
|
123
|
+
args.push("-p", opts.prompt);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
args.push("--dangerously-skip-permissions", "--verbose", "--output-format", "stream-json");
|
|
127
|
+
|
|
128
|
+
if (opts.model) {
|
|
129
|
+
args.push("--model", opts.model);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (opts.allowedTools?.length) {
|
|
133
|
+
for (const tool of opts.allowedTools) {
|
|
134
|
+
args.push("--allowedTools", tool);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return args;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function parseStreamJson(stdout: string): { text: string; sessionId?: string; inputTokens: number; outputTokens: number; costUsd?: number } {
|
|
142
|
+
const lines = stdout.split("\n").filter((l) => l.trim());
|
|
143
|
+
let sessionId: string | undefined;
|
|
144
|
+
let resultText: string | undefined;
|
|
145
|
+
let costUsd: number | undefined;
|
|
146
|
+
let inputTokens = 0;
|
|
147
|
+
let outputTokens = 0;
|
|
148
|
+
const textParts: string[] = [];
|
|
149
|
+
|
|
150
|
+
for (const line of lines) {
|
|
151
|
+
let event: Record<string, unknown>;
|
|
152
|
+
try {
|
|
153
|
+
event = JSON.parse(line);
|
|
154
|
+
} catch {
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (event.type === "system" && typeof event.session_id === "string") {
|
|
159
|
+
sessionId = event.session_id;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (event.type === "message_start") {
|
|
163
|
+
const msg = event.message as Record<string, unknown> | undefined;
|
|
164
|
+
const usage = msg?.usage as Record<string, unknown> | undefined;
|
|
165
|
+
if (typeof usage?.input_tokens === "number") inputTokens += usage.input_tokens;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (event.type === "message_delta") {
|
|
169
|
+
const usage = event.usage as Record<string, unknown> | undefined;
|
|
170
|
+
if (typeof usage?.output_tokens === "number") outputTokens += usage.output_tokens;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (event.type === "content_block_delta") {
|
|
174
|
+
const delta = event.delta as Record<string, unknown> | undefined;
|
|
175
|
+
if (delta?.type === "text_delta" && typeof delta.text === "string") {
|
|
176
|
+
textParts.push(delta.text);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (event.type === "assistant" && Array.isArray(event.content)) {
|
|
181
|
+
for (const block of event.content) {
|
|
182
|
+
if ((block as Record<string, unknown>).type === "text" && typeof (block as Record<string, unknown>).text === "string") {
|
|
183
|
+
textParts.push((block as Record<string, unknown>).text as string);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (event.type === "result") {
|
|
189
|
+
if (typeof event.result === "string") resultText = event.result;
|
|
190
|
+
if (typeof event.session_id === "string") sessionId = event.session_id;
|
|
191
|
+
if (typeof event.cost_usd === "number") costUsd = event.cost_usd;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const text = resultText ?? textParts.join("");
|
|
196
|
+
return { text, sessionId, inputTokens, outputTokens, costUsd };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async function streamProcess(
|
|
200
|
+
proc: ReturnType<typeof Bun.spawn>,
|
|
201
|
+
onEvent?: (event: ClaudeStreamEvent) => void,
|
|
202
|
+
logPath?: string,
|
|
203
|
+
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
|
|
204
|
+
const stderrPromise = new Response(proc.stderr).text();
|
|
205
|
+
|
|
206
|
+
const reader = (proc.stdout as ReadableStream<Uint8Array>).getReader();
|
|
207
|
+
const decoder = new TextDecoder();
|
|
208
|
+
let buffer = "";
|
|
209
|
+
const allLines: string[] = [];
|
|
210
|
+
|
|
211
|
+
while (true) {
|
|
212
|
+
const { done, value } = await reader.read();
|
|
213
|
+
if (done) break;
|
|
214
|
+
buffer += decoder.decode(value, { stream: true });
|
|
215
|
+
|
|
216
|
+
while (buffer.includes("\n")) {
|
|
217
|
+
const idx = buffer.indexOf("\n");
|
|
218
|
+
const line = buffer.slice(0, idx);
|
|
219
|
+
buffer = buffer.slice(idx + 1);
|
|
220
|
+
if (line.trim()) {
|
|
221
|
+
allLines.push(line);
|
|
222
|
+
if (logPath) appendFileSync(logPath, line + "\n");
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (onEvent) {
|
|
226
|
+
const event = parseLineToEvent(line);
|
|
227
|
+
if (event) onEvent(event);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (buffer.trim()) {
|
|
233
|
+
allLines.push(buffer);
|
|
234
|
+
if (logPath) appendFileSync(logPath, buffer + "\n");
|
|
235
|
+
if (onEvent) {
|
|
236
|
+
const event = parseLineToEvent(buffer);
|
|
237
|
+
if (event) onEvent(event);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const [exitCode, stderr] = await Promise.all([proc.exited, stderrPromise]);
|
|
242
|
+
const stdout = allLines.join("\n");
|
|
243
|
+
return { stdout, stderr, exitCode };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export function parseLineToEvent(line: string): ClaudeStreamEvent | null {
|
|
247
|
+
if (!line.trim()) return null;
|
|
248
|
+
|
|
249
|
+
let event: Record<string, unknown>;
|
|
250
|
+
try {
|
|
251
|
+
event = JSON.parse(line);
|
|
252
|
+
} catch {
|
|
253
|
+
return null;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (event.type === "content_block_start") {
|
|
257
|
+
const content = event.content_block as Record<string, unknown> | undefined;
|
|
258
|
+
if (content?.type === "tool_use" && typeof content.name === "string") {
|
|
259
|
+
return { type: "tool_use", tool: content.name };
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (event.type === "content_block_delta") {
|
|
264
|
+
const delta = event.delta as Record<string, unknown> | undefined;
|
|
265
|
+
if (delta?.type === "text_delta" && typeof delta.text === "string") {
|
|
266
|
+
return { type: "text", text: delta.text };
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if (event.type === "result" && typeof event.result === "string") {
|
|
271
|
+
return {
|
|
272
|
+
type: "result",
|
|
273
|
+
text: event.result,
|
|
274
|
+
sessionId: typeof event.session_id === "string" ? event.session_id : undefined,
|
|
275
|
+
costUsd: typeof event.cost_usd === "number" ? event.cost_usd : undefined,
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
return null;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function extractJson(text: string): unknown {
|
|
283
|
+
try {
|
|
284
|
+
return JSON.parse(text);
|
|
285
|
+
} catch {}
|
|
286
|
+
|
|
287
|
+
const match = text.match(/\{[\s\S]*\}/);
|
|
288
|
+
if (match) {
|
|
289
|
+
return JSON.parse(match[0]);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
throw new Error("No JSON object found in response");
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function timeout(ms: number): Promise<void> {
|
|
296
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
297
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { runClaudeText, type ClaudeOptions, type ClaudeStreamEvent } from "./claude.ts";
|
|
2
|
+
import { hasChanges, autoCommit, hasNewCommits, getDiffSummary, getDiffPatch } from "./git.ts";
|
|
3
|
+
import { logger } from "../utils/logger.ts";
|
|
4
|
+
|
|
5
|
+
export interface FixResult {
|
|
6
|
+
success: boolean;
|
|
7
|
+
summary: string;
|
|
8
|
+
diffSummary: string;
|
|
9
|
+
diffPatch: string;
|
|
10
|
+
error?: string;
|
|
11
|
+
failureReason?: string;
|
|
12
|
+
durationMs: number;
|
|
13
|
+
inputTokens: number;
|
|
14
|
+
outputTokens: number;
|
|
15
|
+
costUsd?: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function fixIssue(opts: {
|
|
19
|
+
issueContext: string;
|
|
20
|
+
triagePlan: string;
|
|
21
|
+
worktreePath: string;
|
|
22
|
+
baseBranch: string;
|
|
23
|
+
testCommand?: string;
|
|
24
|
+
claudeTimeout: number;
|
|
25
|
+
allowedTools: string[];
|
|
26
|
+
projectContext?: string;
|
|
27
|
+
model?: "sonnet" | "opus" | "haiku";
|
|
28
|
+
sessionId?: string;
|
|
29
|
+
onEvent?: (event: ClaudeStreamEvent) => void;
|
|
30
|
+
logPath?: string;
|
|
31
|
+
}): Promise<FixResult> {
|
|
32
|
+
const { issueContext, triagePlan, worktreePath, baseBranch, testCommand, claudeTimeout, allowedTools, projectContext, model, sessionId, onEvent, logPath } = opts;
|
|
33
|
+
|
|
34
|
+
const testInstruction = testCommand
|
|
35
|
+
? `After making your changes, run \`${testCommand}\` to verify your fix passes. Fix any test failures.`
|
|
36
|
+
: "If there are existing tests related to your changes, run them to verify nothing is broken.";
|
|
37
|
+
|
|
38
|
+
const contextSection = projectContext ? `\n## Project Context\n${projectContext}\n` : "";
|
|
39
|
+
|
|
40
|
+
const fullPrompt = `You are an AI coding agent tasked with implementing a task. You are working in a git worktree.
|
|
41
|
+
${contextSection}
|
|
42
|
+
## Task
|
|
43
|
+
${issueContext}
|
|
44
|
+
|
|
45
|
+
## Plan
|
|
46
|
+
${triagePlan}
|
|
47
|
+
|
|
48
|
+
## Instructions
|
|
49
|
+
1. Implement the changes following the plan above
|
|
50
|
+
2. ${testInstruction}
|
|
51
|
+
3. Stage and commit your changes with a message: \`fix: <concise description>\`
|
|
52
|
+
4. Make sure all changes are committed before finishing
|
|
53
|
+
|
|
54
|
+
Important:
|
|
55
|
+
- Only change what's necessary for this task
|
|
56
|
+
- Don't refactor unrelated code
|
|
57
|
+
- Don't add unnecessary comments or documentation
|
|
58
|
+
- Ensure backward compatibility — don't change public API signatures unless required
|
|
59
|
+
- If tests fail after your changes, debug and fix them`;
|
|
60
|
+
|
|
61
|
+
const resumePrompt = `Continue with the implementation. You already explored the codebase during triage — use that context.
|
|
62
|
+
|
|
63
|
+
## Instructions
|
|
64
|
+
1. Implement the changes following the plan from the triage phase
|
|
65
|
+
2. ${testInstruction}
|
|
66
|
+
3. Stage and commit your changes with a message: \`fix: <concise description>\`
|
|
67
|
+
4. Make sure all changes are committed before finishing
|
|
68
|
+
|
|
69
|
+
Important:
|
|
70
|
+
- Only change what's necessary for this task
|
|
71
|
+
- Don't refactor unrelated code
|
|
72
|
+
- Don't add unnecessary comments or documentation
|
|
73
|
+
- Ensure backward compatibility — don't change public API signatures unless required
|
|
74
|
+
- If tests fail after your changes, debug and fix them`;
|
|
75
|
+
|
|
76
|
+
const claudeOpts: ClaudeOptions = {
|
|
77
|
+
cwd: worktreePath,
|
|
78
|
+
timeoutMs: claudeTimeout,
|
|
79
|
+
allowedTools,
|
|
80
|
+
model,
|
|
81
|
+
resume: sessionId,
|
|
82
|
+
onEvent,
|
|
83
|
+
logPath,
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
let result = await runClaudeText(sessionId ? resumePrompt : fullPrompt, claudeOpts);
|
|
87
|
+
|
|
88
|
+
// If resume failed, retry without it (session may be stale or cwd mismatch)
|
|
89
|
+
if (!result.success && sessionId) {
|
|
90
|
+
logger.warn("Resume failed, retrying without session", { error: result.error });
|
|
91
|
+
result = await runClaudeText(fullPrompt, { ...claudeOpts, resume: undefined });
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (!result.success) {
|
|
95
|
+
const failureReason = result.error?.includes("Timed out") ? "fix:timeout" : "fix:claude_failed";
|
|
96
|
+
return {
|
|
97
|
+
success: false,
|
|
98
|
+
summary: "",
|
|
99
|
+
diffSummary: "",
|
|
100
|
+
diffPatch: "",
|
|
101
|
+
error: result.error ?? "Claude failed",
|
|
102
|
+
failureReason,
|
|
103
|
+
durationMs: result.durationMs,
|
|
104
|
+
inputTokens: result.inputTokens,
|
|
105
|
+
outputTokens: result.outputTokens,
|
|
106
|
+
costUsd: result.costUsd,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Check if Claude made changes
|
|
111
|
+
const hasCommits = await hasNewCommits(worktreePath, baseBranch);
|
|
112
|
+
|
|
113
|
+
if (!hasCommits) {
|
|
114
|
+
// Check if there are uncommitted changes and auto-commit
|
|
115
|
+
if (await hasChanges(worktreePath)) {
|
|
116
|
+
logger.info("Claude forgot to commit, validating and auto-committing");
|
|
117
|
+
|
|
118
|
+
// Run tests before auto-committing
|
|
119
|
+
if (testCommand) {
|
|
120
|
+
const testResult = await Bun.$`sh -c ${testCommand}`.quiet().nothrow().cwd(worktreePath);
|
|
121
|
+
if (testResult.exitCode !== 0) {
|
|
122
|
+
return {
|
|
123
|
+
success: false,
|
|
124
|
+
summary: "",
|
|
125
|
+
diffSummary: "",
|
|
126
|
+
diffPatch: "",
|
|
127
|
+
error: `Tests failed on uncommitted changes: ${testResult.stderr.toString().slice(0, 300)}`,
|
|
128
|
+
failureReason: "fix:tests_failed",
|
|
129
|
+
durationMs: result.durationMs,
|
|
130
|
+
inputTokens: result.inputTokens,
|
|
131
|
+
outputTokens: result.outputTokens,
|
|
132
|
+
costUsd: result.costUsd,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const committed = await autoCommit(worktreePath, "fix: automated changes");
|
|
138
|
+
if (!committed) {
|
|
139
|
+
return {
|
|
140
|
+
success: false,
|
|
141
|
+
summary: "",
|
|
142
|
+
diffSummary: "",
|
|
143
|
+
diffPatch: "",
|
|
144
|
+
error: "Changes were made but failed to commit",
|
|
145
|
+
failureReason: "fix:commit_failed",
|
|
146
|
+
durationMs: result.durationMs,
|
|
147
|
+
inputTokens: result.inputTokens,
|
|
148
|
+
outputTokens: result.outputTokens,
|
|
149
|
+
costUsd: result.costUsd,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
} else {
|
|
153
|
+
return {
|
|
154
|
+
success: false,
|
|
155
|
+
summary: "",
|
|
156
|
+
diffSummary: "",
|
|
157
|
+
diffPatch: "",
|
|
158
|
+
error: "No changes were made",
|
|
159
|
+
failureReason: "fix:no_changes",
|
|
160
|
+
durationMs: result.durationMs,
|
|
161
|
+
inputTokens: result.inputTokens,
|
|
162
|
+
outputTokens: result.outputTokens,
|
|
163
|
+
costUsd: result.costUsd,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const diffSummary = await getDiffSummary(worktreePath);
|
|
169
|
+
const diffPatch = await getDiffPatch(worktreePath);
|
|
170
|
+
|
|
171
|
+
// Extract a summary from Claude's output (first few meaningful lines)
|
|
172
|
+
const summary = extractSummary(result.output);
|
|
173
|
+
|
|
174
|
+
logger.info("Fix completed", { diffSummary: diffSummary.slice(0, 200) });
|
|
175
|
+
|
|
176
|
+
return {
|
|
177
|
+
success: true,
|
|
178
|
+
summary,
|
|
179
|
+
diffSummary,
|
|
180
|
+
diffPatch,
|
|
181
|
+
durationMs: result.durationMs,
|
|
182
|
+
inputTokens: result.inputTokens,
|
|
183
|
+
outputTokens: result.outputTokens,
|
|
184
|
+
costUsd: result.costUsd,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function extractSummary(output: string): string {
|
|
189
|
+
// Claude usually ends with a prose summary paragraph — grab the last one
|
|
190
|
+
const paragraphs = output.split(/\n\n+/).map((p) => p.trim()).filter((p) => p.length > 0);
|
|
191
|
+
const last = paragraphs[paragraphs.length - 1] ?? "";
|
|
192
|
+
// Strip code fences and keep first 2 lines max
|
|
193
|
+
const lines = last.split("\n").filter((l) => !l.startsWith("```") && l.trim().length > 0);
|
|
194
|
+
return lines.slice(0, 2).join("\n").slice(0, 300).trim();
|
|
195
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { logger } from "../utils/logger.ts";
|
|
2
|
+
import { join, dirname } from "node:path";
|
|
3
|
+
|
|
4
|
+
export interface WorktreeInfo {
|
|
5
|
+
worktreePath: string;
|
|
6
|
+
branchName: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export async function createWorktree(
|
|
10
|
+
repoPath: string,
|
|
11
|
+
source: string,
|
|
12
|
+
sourceId: string,
|
|
13
|
+
baseBranch: string,
|
|
14
|
+
): Promise<WorktreeInfo> {
|
|
15
|
+
const repoParent = dirname(repoPath);
|
|
16
|
+
const worktreeDir = join(repoParent, ".relay-worktrees");
|
|
17
|
+
const worktreePath = join(worktreeDir, `fix-${source}-${sourceId}`);
|
|
18
|
+
const branchName = `relay/fix-${source}-${sourceId}`;
|
|
19
|
+
|
|
20
|
+
// Fetch latest (60s timeout to prevent hanging on slow/broken connections)
|
|
21
|
+
await Promise.race([
|
|
22
|
+
Bun.$`git -C ${repoPath} fetch origin ${baseBranch}`.quiet().nothrow(),
|
|
23
|
+
new Promise<void>((_, reject) => setTimeout(() => reject(new Error("git fetch timed out after 60s")), 60_000)),
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
// Clean up existing worktree/branch if they exist
|
|
27
|
+
await Bun.$`git -C ${repoPath} worktree remove --force ${worktreePath}`.quiet().nothrow();
|
|
28
|
+
await Bun.$`git -C ${repoPath} branch -D ${branchName}`.quiet().nothrow();
|
|
29
|
+
|
|
30
|
+
// Ensure parent dir exists
|
|
31
|
+
await Bun.$`mkdir -p ${worktreeDir}`.quiet();
|
|
32
|
+
|
|
33
|
+
// Create worktree from origin/baseBranch
|
|
34
|
+
const result = await Bun.$`git -C ${repoPath} worktree add -b ${branchName} ${worktreePath} origin/${baseBranch}`.quiet().nothrow();
|
|
35
|
+
if (result.exitCode !== 0) {
|
|
36
|
+
const stderr = result.stderr.toString();
|
|
37
|
+
throw new Error(`Failed to create worktree: ${stderr}`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
logger.info("Created worktree", { path: worktreePath, branch: branchName });
|
|
41
|
+
return { worktreePath, branchName };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function removeWorktree(repoPath: string, worktreePath: string, branchName: string) {
|
|
45
|
+
await Bun.$`git -C ${repoPath} worktree remove --force ${worktreePath}`.quiet().nothrow();
|
|
46
|
+
await Bun.$`git -C ${repoPath} branch -D ${branchName}`.quiet().nothrow();
|
|
47
|
+
await Bun.$`git -C ${repoPath} worktree prune`.quiet().nothrow();
|
|
48
|
+
logger.info("Removed worktree", { path: worktreePath, branch: branchName });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function pushBranch(worktreePath: string, branchName: string): Promise<void> {
|
|
52
|
+
const push = await Bun.$`git -C ${worktreePath} push -u origin ${branchName}`.quiet().nothrow();
|
|
53
|
+
if (push.exitCode !== 0) {
|
|
54
|
+
throw new Error(`Failed to push: ${push.stderr.toString()}`);
|
|
55
|
+
}
|
|
56
|
+
logger.info("Pushed branch", { branch: branchName });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function buildPRUrl(
|
|
60
|
+
worktreePath: string,
|
|
61
|
+
branchName: string,
|
|
62
|
+
baseBranch: string,
|
|
63
|
+
title: string,
|
|
64
|
+
body: string,
|
|
65
|
+
): Promise<string> {
|
|
66
|
+
const repo = await getRemoteRepo(worktreePath);
|
|
67
|
+
const params = new URLSearchParams({ expand: "1", title, body });
|
|
68
|
+
return `https://github.com/${repo}/compare/${baseBranch}...${branchName}?${params.toString()}`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function getDiffSummary(worktreePath: string): Promise<string> {
|
|
72
|
+
const result = await Bun.$`git -C ${worktreePath} diff HEAD~1 --stat`.quiet().nothrow();
|
|
73
|
+
if (result.exitCode !== 0) {
|
|
74
|
+
return "(no diff available)";
|
|
75
|
+
}
|
|
76
|
+
return result.stdout.toString().trim();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function getDiffPatch(worktreePath: string): Promise<string> {
|
|
80
|
+
const result = await Bun.$`git -C ${worktreePath} diff HEAD~1`.quiet().nothrow();
|
|
81
|
+
if (result.exitCode !== 0) {
|
|
82
|
+
return "(no diff available)";
|
|
83
|
+
}
|
|
84
|
+
return result.stdout.toString().trim();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export async function hasChanges(worktreePath: string): Promise<boolean> {
|
|
88
|
+
const staged = await Bun.$`git -C ${worktreePath} diff --cached --quiet`.quiet().nothrow();
|
|
89
|
+
const unstaged = await Bun.$`git -C ${worktreePath} diff --quiet`.quiet().nothrow();
|
|
90
|
+
const untracked = await Bun.$`git -C ${worktreePath} ls-files --others --exclude-standard`.quiet().nothrow();
|
|
91
|
+
return staged.exitCode !== 0 || unstaged.exitCode !== 0 || untracked.stdout.toString().trim().length > 0;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export async function autoCommit(worktreePath: string, message: string): Promise<boolean> {
|
|
95
|
+
await Bun.$`git -C ${worktreePath} add -A`.quiet().nothrow();
|
|
96
|
+
const result = await Bun.$`git -C ${worktreePath} commit -m ${message}`.quiet().nothrow();
|
|
97
|
+
return result.exitCode === 0;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export async function hasNewCommits(worktreePath: string, baseBranch: string): Promise<boolean> {
|
|
101
|
+
const result = await Bun.$`git -C ${worktreePath} log origin/${baseBranch}..HEAD --oneline`.quiet().nothrow();
|
|
102
|
+
return result.stdout.toString().trim().length > 0;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function getRemoteRepo(worktreePath: string): Promise<string> {
|
|
106
|
+
const result = await Bun.$`git -C ${worktreePath} remote get-url origin`.quiet();
|
|
107
|
+
const url = result.stdout.toString().trim();
|
|
108
|
+
// Extract owner/repo from git URL
|
|
109
|
+
const match = url.match(/github\.com[:/](.+?)(?:\.git)?$/);
|
|
110
|
+
return match?.[1] ?? url;
|
|
111
|
+
}
|