gitxplain 0.1.0 → 0.1.6
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/.env.example +5 -10
- package/.github/workflows/ci.yml +28 -0
- package/.github/workflows/release.yml +27 -0
- package/IMPLEMENTATION.md +225 -0
- package/README.md +201 -0
- package/cli/index.js +693 -27
- package/cli/services/aiService.js +2 -2
- package/cli/services/chatService.js +683 -0
- package/cli/services/commitService.js +379 -0
- package/cli/services/envLoader.js +33 -0
- package/cli/services/gitConnectionService.js +267 -0
- package/cli/services/gitService.js +633 -1
- package/cli/services/mergeService.js +826 -0
- package/cli/services/outputFormatter.js +185 -13
- package/cli/services/pipelineService.js +721 -0
- package/cli/services/promptService.js +66 -2
- package/cli/services/splitService.js +472 -0
- package/package.json +4 -3
- package/prompts/commit.txt +57 -0
- package/prompts/split.txt +44 -0
|
@@ -0,0 +1,683 @@
|
|
|
1
|
+
import readline from "node:readline";
|
|
2
|
+
import process from "node:process";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import os from "node:os";
|
|
6
|
+
import { execSync } from "node:child_process";
|
|
7
|
+
import { getProviderConfig, validateProviderConfig } from "./aiService.js";
|
|
8
|
+
import { fetchGitHubRepositories, fetchGitHubCommits, fetchCommitDetails, fetchRepoTree, downloadCommitArchive, fetchFileContent, fetchRepoIssues } from "./gitConnectionService.js";
|
|
9
|
+
|
|
10
|
+
const COLORS = {
|
|
11
|
+
reset: "\x1b[0m",
|
|
12
|
+
bold: "\x1b[1m",
|
|
13
|
+
blue: "\x1b[34m",
|
|
14
|
+
green: "\x1b[32m",
|
|
15
|
+
yellow: "\x1b[33m",
|
|
16
|
+
cyan: "\x1b[36m",
|
|
17
|
+
red: "\x1b[31m"
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export function getBootHelpText() {
|
|
21
|
+
return [
|
|
22
|
+
"Available commands:",
|
|
23
|
+
" help Show this command list",
|
|
24
|
+
" repos Select a GitHub repository and commit for analysis",
|
|
25
|
+
" issues Summarize open issues for the selected repository",
|
|
26
|
+
" status Review local uncommitted git diff",
|
|
27
|
+
" download Download the selected repository at the selected commit",
|
|
28
|
+
" clear Reset the current chat history",
|
|
29
|
+
" exit Close the interactive session"
|
|
30
|
+
].join("\n");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export class ChatService {
|
|
34
|
+
constructor(token, providerOverride, modelOverride, username) {
|
|
35
|
+
this.token = token;
|
|
36
|
+
this.username = username;
|
|
37
|
+
this.providerOverride = providerOverride;
|
|
38
|
+
this.modelOverride = modelOverride;
|
|
39
|
+
this.conversationHistory = [];
|
|
40
|
+
this.repoContext = null;
|
|
41
|
+
this.repositories = [];
|
|
42
|
+
this.activeRepo = null;
|
|
43
|
+
this.activeCommit = null;
|
|
44
|
+
this.config = getProviderConfig(providerOverride, modelOverride);
|
|
45
|
+
validateProviderConfig(this.config);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async initializeRepoContext() {
|
|
49
|
+
try {
|
|
50
|
+
this.repositories = await fetchGitHubRepositories(this.token);
|
|
51
|
+
|
|
52
|
+
const repoList = this.repositories
|
|
53
|
+
.slice(0, 10)
|
|
54
|
+
.map(
|
|
55
|
+
(repo) =>
|
|
56
|
+
`${repo.name}: ${repo.description || "No description"} (${repo.language || "Unknown"})`
|
|
57
|
+
)
|
|
58
|
+
.join("\n");
|
|
59
|
+
|
|
60
|
+
this.repoContext = {
|
|
61
|
+
repos: repoList,
|
|
62
|
+
reposCount: this.repositories.length,
|
|
63
|
+
topRepos: this.repositories.slice(0, 5).map((r) => r.name).join(", ")
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
return this.repoContext;
|
|
67
|
+
} catch (error) {
|
|
68
|
+
throw new Error(`Failed to initialize GitHub context: ${error.message}`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
buildSystemPrompt() {
|
|
73
|
+
return `You are an expert GitHub assistant with access to the user's repositories. You have full knowledge of the user's codebase and projects.
|
|
74
|
+
|
|
75
|
+
User: @${this.username}
|
|
76
|
+
Total Repositories: ${this.repoContext.reposCount}
|
|
77
|
+
|
|
78
|
+
Recent Repositories:
|
|
79
|
+
${this.repoContext.repos}
|
|
80
|
+
|
|
81
|
+
IMPORTANT INSTRUCTIONS:
|
|
82
|
+
- CRITICAL: DO NOT USE MARKDOWN ANYWHERE IN YOUR RESPONSE.
|
|
83
|
+
- NO ASTERISKS (*), NO BACKTICKS (\`), NO HASH SIGN (#), NO BOLD, NO ITALICS.
|
|
84
|
+
- YOU MUST ONLY OUTPUT RAW PLAIN TEXT WITH CLEAN, MINIMAL SPACING.
|
|
85
|
+
- NEVER repeat or echo the user's prompt in your response. Answer directly.
|
|
86
|
+
- Avoid long block paragraphs. Break your response into neat, readable chunks.
|
|
87
|
+
- For sections or headers, simply use Title Case followed by a colon (e.g., "Overview:"). Do not underline them or use all-caps.
|
|
88
|
+
- Use standard 2-space indentation and simple dashes (-) for lists.
|
|
89
|
+
- Maintain a tidy, professional structure. For example, when comparing commits:
|
|
90
|
+
|
|
91
|
+
Commit: [SHA]
|
|
92
|
+
Author: [Name]
|
|
93
|
+
Summary: [Brief summary]
|
|
94
|
+
Files modified: [List of files]
|
|
95
|
+
|
|
96
|
+
Analysis:
|
|
97
|
+
- [Key point 1]
|
|
98
|
+
- [Key point 2]
|
|
99
|
+
|
|
100
|
+
- Respond directly without any thinking or processing messages.
|
|
101
|
+
- If the user asks you to download, clone, or save the repository, DO NOT give them instructions. Instead, your entire response MUST be exactly the text "[ACTION:DOWNLOAD]".
|
|
102
|
+
- If you need to read a specific file from the repository, output EXACTLY "[ACTION:READ:path/to/file.ext]". I will intercept this and provide the contents.
|
|
103
|
+
- If the user asks you to apply a code fix, output EXACTLY "[ACTION:PATCH:path/to/file.ext] --- new file content here ---".
|
|
104
|
+
- If you are asked to compare multiple commits or analyze changes across them, output EXACTLY "[ACTION:SELECT_COMMITS]". I will open a multi-selection UI for the user.
|
|
105
|
+
- If you need to access a specific historical commit directly by its SHA, output EXACTLY "[ACTION:FETCH_COMMIT:sha]".
|
|
106
|
+
- If the user asks you to execute a local git command or terminal command (e.g., git commit, git push, git checkout, etc.), output EXACTLY "[ACTION:EXECUTE:command]". I will run it dynamically and provide the output.
|
|
107
|
+
- Be concise, structural, helpful, and visually neat for a plain-text terminal environment.`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async sendMessage(userMessage) {
|
|
111
|
+
this.conversationHistory.push({
|
|
112
|
+
role: "user",
|
|
113
|
+
content: userMessage
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
try {
|
|
117
|
+
if (this.config.provider === "gemini") {
|
|
118
|
+
return await this.sendGeminiMessage();
|
|
119
|
+
}
|
|
120
|
+
return await this.sendOpenAICompatibleMessage();
|
|
121
|
+
} catch (error) {
|
|
122
|
+
throw new Error(`Failed to get response from LLM: ${error.message}`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async sendOpenAICompatibleMessage() {
|
|
127
|
+
const headers = {
|
|
128
|
+
"Content-Type": "application/json",
|
|
129
|
+
Authorization: `Bearer ${this.config.apiKey}`
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
if (this.config.provider === "openrouter") {
|
|
133
|
+
headers["HTTP-Referer"] = process.env.OPENROUTER_SITE_URL ?? "https://github.com";
|
|
134
|
+
headers["X-Title"] = process.env.OPENROUTER_APP_NAME ?? "gitxplain";
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const body = {
|
|
138
|
+
model: this.config.model,
|
|
139
|
+
messages: [
|
|
140
|
+
{
|
|
141
|
+
role: "system",
|
|
142
|
+
content: this.buildSystemPrompt()
|
|
143
|
+
},
|
|
144
|
+
...this.conversationHistory
|
|
145
|
+
],
|
|
146
|
+
temperature: 0.7
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const response = await fetch(`${this.config.baseUrl}/chat/completions`, {
|
|
150
|
+
method: "POST",
|
|
151
|
+
headers,
|
|
152
|
+
body: JSON.stringify(body)
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
if (!response.ok) {
|
|
156
|
+
const errorText = await response.text();
|
|
157
|
+
throw new Error(
|
|
158
|
+
`${this.config.provider} request failed (${response.status}): ${errorText}`
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const data = await response.json();
|
|
163
|
+
const assistantMessage =
|
|
164
|
+
data.choices?.[0]?.message?.content?.trim() || "No response from the model.";
|
|
165
|
+
|
|
166
|
+
this.conversationHistory.push({
|
|
167
|
+
role: "assistant",
|
|
168
|
+
content: assistantMessage
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
return assistantMessage;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async sendGeminiMessage() {
|
|
175
|
+
const response = await fetch(
|
|
176
|
+
`${this.config.baseUrl}/models/${this.config.model}:generateContent?key=${encodeURIComponent(this.config.apiKey)}`,
|
|
177
|
+
{
|
|
178
|
+
method: "POST",
|
|
179
|
+
headers: {
|
|
180
|
+
"Content-Type": "application/json"
|
|
181
|
+
},
|
|
182
|
+
body: JSON.stringify({
|
|
183
|
+
systemInstruction: {
|
|
184
|
+
parts: [
|
|
185
|
+
{
|
|
186
|
+
text: this.buildSystemPrompt()
|
|
187
|
+
}
|
|
188
|
+
]
|
|
189
|
+
},
|
|
190
|
+
contents: [
|
|
191
|
+
...this.conversationHistory.map((msg) => ({
|
|
192
|
+
role: msg.role === "user" ? "user" : "model",
|
|
193
|
+
parts: [{ text: msg.content }]
|
|
194
|
+
}))
|
|
195
|
+
],
|
|
196
|
+
generationConfig: {
|
|
197
|
+
temperature: 0.7
|
|
198
|
+
}
|
|
199
|
+
})
|
|
200
|
+
}
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
if (!response.ok) {
|
|
204
|
+
const errorText = await response.text();
|
|
205
|
+
throw new Error(`gemini request failed (${response.status}): ${errorText}`);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const data = await response.json();
|
|
209
|
+
const parts = data.candidates?.[0]?.content?.parts ?? [];
|
|
210
|
+
const assistantMessage = parts
|
|
211
|
+
.map((part) => part.text)
|
|
212
|
+
.filter(Boolean)
|
|
213
|
+
.join("\n")
|
|
214
|
+
.trim() || "No response from the model.";
|
|
215
|
+
|
|
216
|
+
this.conversationHistory.push({
|
|
217
|
+
role: "assistant",
|
|
218
|
+
content: assistantMessage
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
return assistantMessage;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async startInteractiveChat() {
|
|
225
|
+
const rl = readline.createInterface({
|
|
226
|
+
input: process.stdin,
|
|
227
|
+
output: process.stdout
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
const question = (prompt) => {
|
|
231
|
+
return new Promise((resolve) => {
|
|
232
|
+
rl.question(`${COLORS.bold}${COLORS.blue}${prompt}${COLORS.reset}`, resolve);
|
|
233
|
+
});
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
console.log(`${COLORS.bold}${COLORS.cyan}GitHub Chat${COLORS.reset}`);
|
|
237
|
+
console.log(`${COLORS.cyan}${getBootHelpText()}\n${COLORS.reset}`);
|
|
238
|
+
console.log(`${COLORS.cyan}Model: ${this.config.model} (${this.config.provider})\n${COLORS.reset}`);
|
|
239
|
+
|
|
240
|
+
while (true) {
|
|
241
|
+
const userInput = await question("You: ");
|
|
242
|
+
const normalizedInput = userInput.trim().toLowerCase();
|
|
243
|
+
|
|
244
|
+
if (normalizedInput === "help") {
|
|
245
|
+
console.log(`\n${COLORS.cyan}${getBootHelpText()}\n${COLORS.reset}`);
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (normalizedInput === "exit") {
|
|
250
|
+
console.log(`${COLORS.green}Goodbye!${COLORS.reset}`);
|
|
251
|
+
rl.close();
|
|
252
|
+
break;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (normalizedInput === "clear") {
|
|
256
|
+
this.conversationHistory = [];
|
|
257
|
+
console.log(`${COLORS.yellow}Conversation history cleared.\n${COLORS.reset}`);
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (normalizedInput === "download") {
|
|
262
|
+
if (!this.activeRepo || !this.activeCommit) {
|
|
263
|
+
console.log(`${COLORS.yellow}No repository/commit selected. Please type 'repos' first.\n${COLORS.reset}`);
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
try {
|
|
268
|
+
console.log(`\n${COLORS.cyan}⏳ Downloading ${this.activeRepo.name} (Commit: ${this.activeCommit.sha})...${COLORS.reset}`);
|
|
269
|
+
const filename = `${this.activeRepo.name}-${this.activeCommit.sha}.zip`;
|
|
270
|
+
const destPath = path.join(os.homedir(), "Downloads", filename);
|
|
271
|
+
|
|
272
|
+
await downloadCommitArchive(this.token, this.activeRepo.owner, this.activeRepo.name, this.activeCommit.fullSha, destPath);
|
|
273
|
+
console.log(`${COLORS.green}✅ Downloaded source successfully to: ${destPath}\n${COLORS.reset}`);
|
|
274
|
+
} catch (err) {
|
|
275
|
+
console.error(`${COLORS.red}❌ Download failed: ${err.message}\n${COLORS.reset}`);
|
|
276
|
+
}
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (normalizedInput === "repos") {
|
|
281
|
+
const selectedRepo = await this.showRepoSelector();
|
|
282
|
+
if (selectedRepo) {
|
|
283
|
+
this.activeRepo = selectedRepo;
|
|
284
|
+
try {
|
|
285
|
+
console.log(`\n${COLORS.cyan}⏳ Fetching recent commits for ${selectedRepo.name}...${COLORS.reset}`);
|
|
286
|
+
const commits = await fetchGitHubCommits(this.token, selectedRepo.owner, selectedRepo.name);
|
|
287
|
+
const selectedCommit = await this.showCommitSelector(commits, selectedRepo.name);
|
|
288
|
+
|
|
289
|
+
if (selectedCommit) {
|
|
290
|
+
this.activeCommit = selectedCommit;
|
|
291
|
+
console.log(`\n${COLORS.cyan}⏳ Downloading file structure and code diffs for context awareness...${COLORS.reset}`);
|
|
292
|
+
const tree = await fetchRepoTree(this.token, selectedRepo.owner, selectedRepo.name, selectedCommit.fullSha);
|
|
293
|
+
const details = await fetchCommitDetails(this.token, selectedRepo.owner, selectedRepo.name, selectedCommit.fullSha);
|
|
294
|
+
|
|
295
|
+
const treeStr = tree.join('\n');
|
|
296
|
+
let filesStr = details.files.map(f => `--- File: ${f.filename} (Status: ${f.status}) ---\nAdditions: ${f.additions} | Deletions: ${f.deletions}\nPatch/Diff:\n${f.patch}`).join('\n\n');
|
|
297
|
+
|
|
298
|
+
if (filesStr.length > 8000) {
|
|
299
|
+
filesStr = filesStr.substring(0, 8000) + "\n\n...[Warning: Log truncated due to size. Some diff patches could be missing from this log.]";
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const systemContext = `I have selected the repository: ${selectedRepo.name} (Owner: ${selectedRepo.owner}). Current selected commit: ${selectedCommit.sha} - ${selectedCommit.message}.
|
|
303
|
+
|
|
304
|
+
[REPOSITORY FILE STRUCTURE AT COMMIT]
|
|
305
|
+
${treeStr}
|
|
306
|
+
|
|
307
|
+
[COMMIT CHANGES / CODE DIFFS]
|
|
308
|
+
${filesStr}
|
|
309
|
+
|
|
310
|
+
Please acknowledge this selection in a maximum of 3 sentences, giving a brief summary of what codebase files were touched or updated in this commit.`;
|
|
311
|
+
const response = await this.sendMessage(systemContext);
|
|
312
|
+
console.log(`\n${COLORS.green}Assistant: ${COLORS.reset}${response}\n`);
|
|
313
|
+
}
|
|
314
|
+
} catch (err) {
|
|
315
|
+
console.error(`\n${COLORS.red}Failed to fetch context: ${err.message}${COLORS.reset}\n`);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
if (!userInput.trim()) {
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (normalizedInput === "status") {
|
|
326
|
+
try {
|
|
327
|
+
const diff = execSync("git diff").toString();
|
|
328
|
+
if (!diff) {
|
|
329
|
+
console.log(`\n${COLORS.yellow}No uncommitted changes found locally.${COLORS.reset}\n`);
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
const prompt = `Please review these uncommitted local changes and explain what has been modified, potential bugs, or suggest a good commit message:\n\n${diff.substring(0, 5000)}`;
|
|
333
|
+
console.log(`\n${COLORS.cyan}⏳ Analyzing local uncommitted changes...${COLORS.reset}`);
|
|
334
|
+
const response = await this.sendMessage(prompt);
|
|
335
|
+
console.log(`\n${COLORS.green}Assistant: ${COLORS.reset}${response}\n`);
|
|
336
|
+
} catch (e) {
|
|
337
|
+
console.error(`\n${COLORS.red}Failed to read git status locally. Are you in a git repo?${COLORS.reset}\n`);
|
|
338
|
+
}
|
|
339
|
+
continue;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
if (normalizedInput === "issues") {
|
|
343
|
+
if (!this.activeRepo) {
|
|
344
|
+
console.log(`${COLORS.yellow}No active repository. Please type 'repos' first.\n${COLORS.reset}`);
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
347
|
+
try {
|
|
348
|
+
const issues = await fetchRepoIssues(this.token, this.activeRepo.owner, this.activeRepo.name);
|
|
349
|
+
const issueStr = issues.length > 0
|
|
350
|
+
? issues.map(i => `#${i.number} (${i.user}): ${i.title}`).join('\n')
|
|
351
|
+
: "No open issues.";
|
|
352
|
+
const prompt = `Here are the latest open issues for ${this.activeRepo.name}:\n\n${issueStr}\n\nPlease act as a PM and summarize these issues.`;
|
|
353
|
+
console.log(`\n${COLORS.cyan}⏳ Fetching and summarizing issues...${COLORS.reset}`);
|
|
354
|
+
const response = await this.sendMessage(prompt);
|
|
355
|
+
console.log(`\n${COLORS.green}Assistant: ${COLORS.reset}${response}\n`);
|
|
356
|
+
} catch (e) {
|
|
357
|
+
console.error(`\n${COLORS.red}Failed to fetch issues: ${e.message}${COLORS.reset}\n`);
|
|
358
|
+
}
|
|
359
|
+
continue;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
try {
|
|
363
|
+
let response = await this.sendMessage(userInput);
|
|
364
|
+
let keepProcessing = true;
|
|
365
|
+
|
|
366
|
+
while (keepProcessing) {
|
|
367
|
+
keepProcessing = false;
|
|
368
|
+
|
|
369
|
+
if (response.includes("[ACTION:SELECT_COMMITS]")) {
|
|
370
|
+
if (!this.activeRepo) {
|
|
371
|
+
console.log(`\n${COLORS.yellow}Assistant: Please type 'repos' to select a repository first before comparing commits.\n${COLORS.reset}`);
|
|
372
|
+
response = "User has not selected a repository. Tell them to do so.";
|
|
373
|
+
} else {
|
|
374
|
+
console.log(`\n${COLORS.cyan}Assistant requested commit selection for comparison...${COLORS.reset}`);
|
|
375
|
+
const commits = await fetchGitHubCommits(this.token, this.activeRepo.owner, this.activeRepo.name);
|
|
376
|
+
const selectedCommits = await this.showCommitSelector(commits, this.activeRepo.name, true);
|
|
377
|
+
|
|
378
|
+
if (selectedCommits && selectedCommits.length > 0) {
|
|
379
|
+
console.log(`\n${COLORS.cyan}⏳ Fetching context for ${selectedCommits.length} selected commits...${COLORS.reset}`);
|
|
380
|
+
let multiContext = `The user selected ${selectedCommits.length} commits for analysis:\n\n`;
|
|
381
|
+
for (const sc of selectedCommits) {
|
|
382
|
+
try {
|
|
383
|
+
const details = await fetchCommitDetails(this.token, this.activeRepo.owner, this.activeRepo.name, sc.fullSha);
|
|
384
|
+
let filesStr = details.files.map(f => `--- File: ${f.filename} (Status: ${f.status}) ---\nAdditions: ${f.additions} | Deletions: ${f.deletions}\nPatch:\n${f.patch}`).join('\n\n');
|
|
385
|
+
if (filesStr.length > 3000) filesStr = filesStr.substring(0, 3000) + "\n\n...[truncated]";
|
|
386
|
+
multiContext += `[COMMIT: ${sc.sha} - ${sc.message}]\nChanges/Diffs:\n${filesStr}\n\n`;
|
|
387
|
+
} catch (e) {
|
|
388
|
+
multiContext += `[COMMIT: ${sc.sha} - ${sc.message}]\nFailed to load diff: ${e.message}\n\n`;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
multiContext += "Please proceed with analyzing or comparing these commits as requested.";
|
|
392
|
+
response = await this.sendMessage(multiContext);
|
|
393
|
+
keepProcessing = true;
|
|
394
|
+
continue;
|
|
395
|
+
} else {
|
|
396
|
+
response = "User cancelled commit selection or no commits were selected.";
|
|
397
|
+
keepProcessing = true;
|
|
398
|
+
continue;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const fetchCommitMatch = response.match(/\[ACTION:FETCH_COMMIT:([^\]]+)\]/);
|
|
404
|
+
if (fetchCommitMatch && this.activeRepo) {
|
|
405
|
+
const sha = fetchCommitMatch[1];
|
|
406
|
+
console.log(`\n${COLORS.cyan}⏳ Fetching commit data for ${sha}...${COLORS.reset}`);
|
|
407
|
+
try {
|
|
408
|
+
const details = await fetchCommitDetails(this.token, this.activeRepo.owner, this.activeRepo.name, sha);
|
|
409
|
+
let filesStr = details.files.map(f => `--- File: ${f.filename} (Status: ${f.status}) ---\nAdditions: ${f.additions} | Deletions: ${f.deletions}\nPatch/Diff:\n${f.patch}`).join('\n\n');
|
|
410
|
+
if (filesStr.length > 6000) filesStr = filesStr.substring(0, 6000) + "\n\n...[truncated]";
|
|
411
|
+
const commitPrompt = `I fetched commit ${sha}:\n\nChanges/Diffs:\n${filesStr}\n\nPlease analyze this state or continue with the user's request.`;
|
|
412
|
+
response = await this.sendMessage(commitPrompt);
|
|
413
|
+
} catch (err) {
|
|
414
|
+
const commitPrompt = `I tried to fetch commit ${sha} but failed: ${err.message}.`;
|
|
415
|
+
response = await this.sendMessage(commitPrompt);
|
|
416
|
+
}
|
|
417
|
+
keepProcessing = true;
|
|
418
|
+
continue;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
const readMatch = response.match(/\[ACTION:READ:([^\]]+)\]/);
|
|
422
|
+
if (readMatch && this.activeRepo && this.activeCommit) {
|
|
423
|
+
const fileToRead = readMatch[1];
|
|
424
|
+
console.log(`\n${COLORS.cyan}⏳ Requesting file context: ${fileToRead}...${COLORS.reset}`);
|
|
425
|
+
try {
|
|
426
|
+
const fileContent = await fetchFileContent(this.token, this.activeRepo.owner, this.activeRepo.name, this.activeCommit.fullSha, fileToRead);
|
|
427
|
+
const readPrompt = `I fetched the content for ${fileToRead}:\n\n${fileContent.substring(0, 7000)}\n\n(Truncated if too long). Please continue with the user's initial request.`;
|
|
428
|
+
response = await this.sendMessage(readPrompt);
|
|
429
|
+
} catch (err) {
|
|
430
|
+
const readPrompt = `I tried to fetch ${fileToRead} but failed: ${err.message}. Please inform the user or try another file.`;
|
|
431
|
+
response = await this.sendMessage(readPrompt);
|
|
432
|
+
}
|
|
433
|
+
keepProcessing = true;
|
|
434
|
+
continue;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
const execMatch = response.match(/\[ACTION:EXECUTE:([^\]]+)\]/);
|
|
438
|
+
if (execMatch) {
|
|
439
|
+
const cmd = execMatch[1];
|
|
440
|
+
console.log(`\n${COLORS.cyan}🔧 Executing: ${cmd}...${COLORS.reset}`);
|
|
441
|
+
try {
|
|
442
|
+
const output = execSync(cmd, { encoding: "utf8", stdio: "pipe" });
|
|
443
|
+
const outputStr = output ? output.trim() : "Success (no output)";
|
|
444
|
+
const execPrompt = `I executed \`${cmd}\`. Terminal output:\n\n${outputStr.substring(0, 5000)}\n\nPlease continue answering the user.`;
|
|
445
|
+
response = await this.sendMessage(execPrompt);
|
|
446
|
+
} catch (err) {
|
|
447
|
+
let errOut = err.stderr || err.stdout || err.message;
|
|
448
|
+
if (Buffer.isBuffer(errOut)) errOut = errOut.toString("utf8");
|
|
449
|
+
const execPrompt = `I tried to execute \`${cmd}\` but it failed with error:\n\n${String(errOut).trim().substring(0, 5000)}\n\nPlease inform the user and tell them what went wrong.`;
|
|
450
|
+
response = await this.sendMessage(execPrompt);
|
|
451
|
+
}
|
|
452
|
+
keepProcessing = true;
|
|
453
|
+
continue;
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
const patchMatch = response.match(/\[ACTION:PATCH:([^\]]+)\]([\s\S]*)/);
|
|
458
|
+
if (patchMatch) {
|
|
459
|
+
const fileToPatch = patchMatch[1];
|
|
460
|
+
const content = patchMatch[2].trim();
|
|
461
|
+
try {
|
|
462
|
+
fs.writeFileSync(path.resolve(process.cwd(), fileToPatch), content);
|
|
463
|
+
console.log(`\n${COLORS.green}✅ Assistant successfully patched file: ${fileToPatch}${COLORS.reset}\n`);
|
|
464
|
+
response = `Applied the patch to ${fileToPatch}.`;
|
|
465
|
+
} catch (e) {
|
|
466
|
+
response = `Failed to write patch: ${e.message}`;
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
if (response.includes("[ACTION:DOWNLOAD]")) {
|
|
471
|
+
if (!this.activeRepo || !this.activeCommit) {
|
|
472
|
+
console.log(`\n${COLORS.yellow}Assistant: I cannot download because no repository or commit is selected. Please type 'repos' first.\n${COLORS.reset}`);
|
|
473
|
+
continue;
|
|
474
|
+
}
|
|
475
|
+
console.log(`\n${COLORS.cyan}Assistant: Executing download sequence for ${this.activeRepo.name}...${COLORS.reset}`);
|
|
476
|
+
try {
|
|
477
|
+
const filename = `${this.activeRepo.name}-${this.activeCommit.sha}.zip`;
|
|
478
|
+
const destPath = path.join(os.homedir(), "Downloads", filename);
|
|
479
|
+
await downloadCommitArchive(this.token, this.activeRepo.owner, this.activeRepo.name, this.activeCommit.fullSha, destPath);
|
|
480
|
+
console.log(`${COLORS.green}✅ Downloaded source successfully to: ${destPath}\n${COLORS.reset}`);
|
|
481
|
+
} catch (err) {
|
|
482
|
+
console.error(`${COLORS.red}❌ Download failed: ${err.message}\n${COLORS.reset}`);
|
|
483
|
+
}
|
|
484
|
+
} else {
|
|
485
|
+
// Extra step: forcibly strip any residual markdown the LLM might have ignored
|
|
486
|
+
const cleanResponse = response
|
|
487
|
+
.replace(/\*\*(.*?)\*\*/g, '$1')
|
|
488
|
+
.replace(/\*(.*?)\*/g, '$1')
|
|
489
|
+
.replace(/__(.*?)__/g, '$1')
|
|
490
|
+
.replace(/```(?:[a-zA-Z0-9]+)?\n(.*?)\n```/gs, '$1')
|
|
491
|
+
.replace(/`(.*?)`/g, '$1')
|
|
492
|
+
.replace(/^#+\s+/gm, '')
|
|
493
|
+
.trim();
|
|
494
|
+
console.log(`\n${COLORS.green}Assistant: ${COLORS.reset}${cleanResponse}\n`);
|
|
495
|
+
}
|
|
496
|
+
} catch (error) {
|
|
497
|
+
console.error(`\n${COLORS.red}Error: ${error.message}${COLORS.reset}\n`);
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
async showRepoSelector() {
|
|
503
|
+
return new Promise((resolve) => {
|
|
504
|
+
const repos = this.repositories;
|
|
505
|
+
if (repos.length === 0) {
|
|
506
|
+
console.log("No repositories found.\n");
|
|
507
|
+
resolve();
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
let selected = 0;
|
|
512
|
+
let pageSize = 10;
|
|
513
|
+
const displayRepos = () => {
|
|
514
|
+
let page = Math.floor(selected / pageSize);
|
|
515
|
+
let start = page * pageSize;
|
|
516
|
+
let end = Math.min(start + pageSize, repos.length);
|
|
517
|
+
process.stdout.write('\x1Bc');
|
|
518
|
+
console.log(`Your Repositories (Page ${page + 1}/${Math.ceil(repos.length / pageSize)})`);
|
|
519
|
+
console.log("Use Up/Down to navigate, Left/Right for pages, Enter to select, Ctrl+C to exit:\n");
|
|
520
|
+
for (let i = start; i < end; i++) {
|
|
521
|
+
const repo = repos[i];
|
|
522
|
+
if (i === selected) {
|
|
523
|
+
console.log(`\x1b[36m> ${repo.name}\x1b[0m`);
|
|
524
|
+
} else {
|
|
525
|
+
console.log(` ${repo.name}`);
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
};
|
|
529
|
+
|
|
530
|
+
displayRepos();
|
|
531
|
+
|
|
532
|
+
const stdin = process.stdin;
|
|
533
|
+
if (!stdin.isTTY) {
|
|
534
|
+
resolve();
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
const onKeyPress = (ch, key) => {
|
|
539
|
+
if (key && key.ctrl && key.name === 'c') {
|
|
540
|
+
process.exit();
|
|
541
|
+
} else if (key && key.name === 'up') {
|
|
542
|
+
selected = (selected - 1 + repos.length) % repos.length;
|
|
543
|
+
displayRepos();
|
|
544
|
+
} else if (key && key.name === 'down') {
|
|
545
|
+
selected = (selected + 1) % repos.length;
|
|
546
|
+
displayRepos();
|
|
547
|
+
} else if (key && key.name === 'left') {
|
|
548
|
+
let page = Math.floor(selected / pageSize);
|
|
549
|
+
if (page > 0) {
|
|
550
|
+
selected = (page - 1) * pageSize;
|
|
551
|
+
displayRepos();
|
|
552
|
+
}
|
|
553
|
+
} else if (key && key.name === 'right') {
|
|
554
|
+
let page = Math.floor(selected / pageSize);
|
|
555
|
+
if (page < Math.floor((repos.length - 1) / pageSize)) {
|
|
556
|
+
selected = Math.min((page + 1) * pageSize, repos.length - 1);
|
|
557
|
+
displayRepos();
|
|
558
|
+
}
|
|
559
|
+
} else if (key && key.name === 'return') {
|
|
560
|
+
stdin.removeListener("keypress", onKeyPress);
|
|
561
|
+
stdin.setRawMode(false);
|
|
562
|
+
const selectedRepo = repos[selected];
|
|
563
|
+
console.log(`\nSelected: ${selectedRepo.name}`);
|
|
564
|
+
console.log(`URL: ${selectedRepo.url || "N/A"}\n`);
|
|
565
|
+
resolve(selectedRepo);
|
|
566
|
+
}
|
|
567
|
+
};
|
|
568
|
+
|
|
569
|
+
stdin.on("keypress", onKeyPress);
|
|
570
|
+
stdin.setRawMode(true);
|
|
571
|
+
stdin.resume();
|
|
572
|
+
});
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
async showCommitSelector(commits, repoName, isMultiSelect = false) {
|
|
576
|
+
return new Promise((resolve) => {
|
|
577
|
+
if (!commits || commits.length === 0) {
|
|
578
|
+
console.log(`No commits found for ${repoName}.\n`);
|
|
579
|
+
resolve();
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
let selected = 0;
|
|
584
|
+
let pageSize = 10;
|
|
585
|
+
let selectedSet = new Set();
|
|
586
|
+
|
|
587
|
+
const displayCommits = () => {
|
|
588
|
+
let page = Math.floor(selected / pageSize);
|
|
589
|
+
let start = page * pageSize;
|
|
590
|
+
let end = Math.min(start + pageSize, commits.length);
|
|
591
|
+
|
|
592
|
+
process.stdout.write('\x1Bc');
|
|
593
|
+
console.log(`Recent commits for ${repoName} (Page ${page + 1}/${Math.ceil(commits.length / pageSize)})`);
|
|
594
|
+
if (isMultiSelect) {
|
|
595
|
+
console.log("Use Up/Down to navigate, Space to toggle selection, Enter to confirm, Ctrl+C to exit:\n");
|
|
596
|
+
} else {
|
|
597
|
+
console.log("Use Up/Down to navigate, Left/Right for pages, Enter to select, Ctrl+C to exit:\n");
|
|
598
|
+
}
|
|
599
|
+
for (let i = start; i < end; i++) {
|
|
600
|
+
const commit = commits[i];
|
|
601
|
+
const commitText = `[${commit.sha}] ${commit.message} - ${commit.author} (${new Date(commit.date).toLocaleDateString()})`;
|
|
602
|
+
let prefix = " ";
|
|
603
|
+
if (isMultiSelect) {
|
|
604
|
+
prefix = selectedSet.has(i) ? "[*] " : "[ ] ";
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
if (i === selected) {
|
|
608
|
+
console.log(`\x1b[36m> ${prefix}${commitText}\x1b[0m`);
|
|
609
|
+
} else {
|
|
610
|
+
console.log(` ${prefix}${commitText}`);
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
};
|
|
614
|
+
|
|
615
|
+
displayCommits();
|
|
616
|
+
|
|
617
|
+
const stdin = process.stdin;
|
|
618
|
+
if (!stdin.isTTY) {
|
|
619
|
+
resolve(commits[0]);
|
|
620
|
+
return;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
const onKeyPress = (ch, key) => {
|
|
624
|
+
if (key && key.ctrl && key.name === 'c') {
|
|
625
|
+
process.exit();
|
|
626
|
+
} else if (key && key.name === 'up') {
|
|
627
|
+
selected = (selected - 1 + commits.length) % commits.length;
|
|
628
|
+
displayCommits();
|
|
629
|
+
} else if (key && key.name === 'down') {
|
|
630
|
+
selected = (selected + 1) % commits.length;
|
|
631
|
+
displayCommits();
|
|
632
|
+
} else if (isMultiSelect && (ch === ' ' || (key && key.name === 'space'))) {
|
|
633
|
+
if (selectedSet.has(selected)) {
|
|
634
|
+
selectedSet.delete(selected);
|
|
635
|
+
} else {
|
|
636
|
+
selectedSet.add(selected);
|
|
637
|
+
}
|
|
638
|
+
displayCommits();
|
|
639
|
+
} else if (key && key.name === 'left') {
|
|
640
|
+
let page = Math.floor(selected / pageSize);
|
|
641
|
+
if (page > 0) {
|
|
642
|
+
selected = (page - 1) * pageSize;
|
|
643
|
+
displayCommits();
|
|
644
|
+
}
|
|
645
|
+
} else if (key && key.name === 'right') {
|
|
646
|
+
let page = Math.floor(selected / pageSize);
|
|
647
|
+
if (page < Math.floor((commits.length - 1) / pageSize)) {
|
|
648
|
+
selected = Math.min((page + 1) * pageSize, commits.length - 1);
|
|
649
|
+
displayCommits();
|
|
650
|
+
}
|
|
651
|
+
} else if (key && key.name === 'return') {
|
|
652
|
+
stdin.removeListener("keypress", onKeyPress);
|
|
653
|
+
stdin.setRawMode(false);
|
|
654
|
+
|
|
655
|
+
if (isMultiSelect) {
|
|
656
|
+
if (selectedSet.size === 0) {
|
|
657
|
+
selectedSet.add(selected);
|
|
658
|
+
}
|
|
659
|
+
const selectedCommits = Array.from(selectedSet).map(idx => commits[idx]);
|
|
660
|
+
console.log(`\nSelected ${selectedCommits.length} commits for comparison.`);
|
|
661
|
+
resolve(selectedCommits);
|
|
662
|
+
} else {
|
|
663
|
+
const selectedCommit = commits[selected];
|
|
664
|
+
console.log(`\nSelected Commit: ${selectedCommit.sha}`);
|
|
665
|
+
resolve(selectedCommit);
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
};
|
|
669
|
+
|
|
670
|
+
stdin.on("keypress", onKeyPress);
|
|
671
|
+
stdin.setRawMode(true);
|
|
672
|
+
if (stdin.isPaused()) {
|
|
673
|
+
stdin.resume();
|
|
674
|
+
}
|
|
675
|
+
});
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
export async function startChatSession(token, providerOverride, modelOverride, username) {
|
|
680
|
+
const chatService = new ChatService(token, providerOverride, modelOverride, username);
|
|
681
|
+
await chatService.initializeRepoContext();
|
|
682
|
+
await chatService.startInteractiveChat();
|
|
683
|
+
}
|