Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 74 additions & 32 deletions src/services/ai-setup.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,21 +16,16 @@ export interface AiSetupData {

export class AiSetupWizard {
static async promptAiSetup(existingConfig: MosesConfig | null): Promise<AiSetupData> {
Display.section('🤖 AI TOOL CONFIGURATION');
Display.info(
'💡 TIP: Moses uses local AI tools to process reviews. Make sure your chosen tool',
);
Display.info(' is installed and configured with the necessary API keys.');
AiSetupWizard.displayIntro();

const tool = await AiSetupWizard.chooseAiTool(existingConfig?.ai?.tool);

Display.info('\n💡 Feedback Style: Choose how you want the AI to post comments on the MR.');
AiSetupWizard.displayFeedbackStyleTip();
const feedbackStyle = await AiSetupWizard.chooseFeedbackStyle(
existingConfig?.ai?.feedbackStyle,
);

Display.info('\n💡 Diff Limits: Large files can be slow and expensive (tokens).');
Display.info(' This limit skips files with too many changes.');
AiSetupWizard.displayDiffLimitTip();
const maxDiffChanges = await AiSetupWizard.chooseMaxDiffChanges(
existingConfig?.ai?.maxDiffChanges,
);
Expand All @@ -40,39 +35,86 @@ export class AiSetupWizard {

static async chooseAiTool(existingTool: AiToolKey | undefined): Promise<AiToolKey> {
while (true) {
const chosen = await Prompt.select<AiToolKey>({
message: 'Choose the AI tool for review:',
choices: AI_TOOLS.map((tool) => ({ name: tool.name, value: tool.key })),
default: existingTool,
});

const toolInfo = AI_TOOLS.find((tool) => tool.key === chosen);
if (!toolInfo) {
throw new Error(`Unsupported AI tool: ${String(chosen)}`);
}

const toolSpinner = Display.spinner(`Checking ${toolInfo.name} installation...`);
const validation = ToolValidator.validateToolInstallation(toolInfo.key);
toolSpinner.stop();
const chosen = await AiSetupWizard.promptAiTool(existingTool);
const toolInfo = AiSetupWizard.findAiToolByKey(chosen);
const validation = AiSetupWizard.validateAiToolInstallation(toolInfo);

if (validation.installed) {
if (validation.installed && validation.path) {
Display.success(`${toolInfo.name} found at ${validation.path}`);
return toolInfo.key;
return chosen;
}

Display.error(`${toolInfo.name} not found!`);
Display.info(`\n📦 Install with:\n ${validation.installCmd ?? toolInfo.install}`);
Display.info(`\n📖 Documentation: ${validation.installUrl}\n`);
const retry = await Prompt.confirm({
message: 'Do you want to choose another tool?',
default: true,
});
if (!retry) {
if (!validation.installed) {
AiSetupWizard.displayAiToolInstallInfo(
toolInfo,
validation.installCmd,
validation.installUrl,
);
}
const shouldRetry = await AiSetupWizard.askRetryToolSelection();
if (!shouldRetry) {
throw new Error('AI tool not installed. Install a supported tool and run again.');
}
}
}

private static displayIntro(): void {
Display.section('🤖 AI TOOL CONFIGURATION');
Display.info(
'💡 TIP: Moses uses local AI tools to process reviews. Make sure your chosen tool',
);
Display.info(' is installed and configured with the necessary API keys.');
}

private static displayFeedbackStyleTip(): void {
Display.info('\n💡 Feedback Style: Choose how you want the AI to post comments on the MR.');
}

private static displayDiffLimitTip(): void {
Display.info('\n💡 Diff Limits: Large files can be slow and expensive (tokens).');
Display.info(' This limit skips files with too many changes.');
}

private static async promptAiTool(existingTool: AiToolKey | undefined): Promise<AiToolKey> {
return Prompt.select<AiToolKey>({
message: 'Choose the AI tool for review:',
choices: AI_TOOLS.map((tool) => ({ name: tool.name, value: tool.key })),
default: existingTool,
});
}

private static findAiToolByKey(toolKey: AiToolKey) {
const toolInfo = AI_TOOLS.find((tool) => tool.key === toolKey);
if (!toolInfo) {
throw new Error(`Unsupported AI tool: ${String(toolKey)}`);
}
return toolInfo;
}

private static validateAiToolInstallation(toolInfo: { key: AiToolKey; name: string }) {
const toolSpinner = Display.spinner(`Checking ${toolInfo.name} installation...`);
const validation = ToolValidator.validateToolInstallation(toolInfo.key);
toolSpinner.stop();
return validation;
}

private static displayAiToolInstallInfo(
toolInfo: { install: string; name: string },
installCmd: string | undefined,
installUrl: string,
): void {
Display.error(`${toolInfo.name} not found!`);
Display.info(`\n📦 Install with:\n ${installCmd ?? toolInfo.install}`);
Display.info(`\n📖 Documentation: ${installUrl}\n`);
}

private static async askRetryToolSelection(): Promise<boolean> {
return Prompt.confirm({
message: 'Do you want to choose another tool?',
default: true,
});
}

static async chooseFeedbackStyle(
existingStyle: FeedbackStyle | undefined,
): Promise<FeedbackStyle> {
Expand Down
55 changes: 40 additions & 15 deletions src/services/context-manager.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,32 +21,57 @@ export class ContextManager {
files: string[];
}> {
const contextDir = ContextManager.getContextDir();
await ContextManager.ensureContextDirectory(contextDir);
const files = await ContextManager.listPromptFiles();
await ContextManager.copyMissingPromptFiles(files, contextDir);
return { contextDir, files };
}

static async readContextPrompt(extraPrompt = ''): Promise<string> {
const contextDir = ContextManager.getContextDir();
const mdFiles = await ContextManager.listMarkdownFiles(contextDir);
const segments = await ContextManager.readTrimmedContents(contextDir, mdFiles);
return ContextManager.buildContextPrompt(segments, extraPrompt);
}

private static async ensureContextDirectory(contextDir: string): Promise<void> {
await fs.mkdir(contextDir, { recursive: true });
}

private static async listPromptFiles(): Promise<string[]> {
return fs.readdir(PROMPTS_DIR);
}

const files = await fs.readdir(PROMPTS_DIR);
private static async copyMissingPromptFiles(files: string[], contextDir: string): Promise<void> {
for (const file of files) {
const srcPath = path.join(PROMPTS_DIR, file);
const destPath = path.join(contextDir, file);

try {
await fs.access(destPath);
} catch {
await fs.copyFile(srcPath, destPath);
}
await ContextManager.copyPromptFileIfMissing(file, contextDir);
}
return { contextDir, files };
}

static async readContextPrompt(extraPrompt = ''): Promise<string> {
const contextDir = ContextManager.getContextDir();
private static async copyPromptFileIfMissing(file: string, contextDir: string): Promise<void> {
const srcPath = path.join(PROMPTS_DIR, file);
const destPath = path.join(contextDir, file);

try {
await fs.access(destPath);
} catch {
await fs.copyFile(srcPath, destPath);
}
}

private static async listMarkdownFiles(contextDir: string): Promise<string[]> {
const files = await fs.readdir(contextDir);
const mdFiles = files.filter((file) => file.endsWith('.md')).sort();
return files.filter((file) => file.endsWith('.md')).sort();
}

private static async readTrimmedContents(contextDir: string, files: string[]): Promise<string[]> {
const contents = await Promise.all(
mdFiles.map((file) => fs.readFile(path.join(contextDir, file), 'utf-8')),
files.map((file) => fs.readFile(path.join(contextDir, file), 'utf-8')),
);
return contents.map((content) => content.trim());
}

const segments = contents.map((c) => c.trim());
private static buildContextPrompt(segments: string[], extraPrompt: string): string {
if (extraPrompt.trim()) {
segments.push(`# Additional user context\n${extraPrompt.trim()}`);
}
Expand Down
75 changes: 57 additions & 18 deletions src/services/git-operations.service.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { execSync } from 'node:child_process';
import { execFileSync, execSync } from 'node:child_process';
import path from 'node:path';
import fs from 'node:fs/promises';
import os from 'node:os';
Expand Down Expand Up @@ -33,29 +33,20 @@ export class GitOperationsService {
}

static async cloneRepository(repoUrl: string, token: string): Promise<string> {
const reposDir = DEFAULT_REPOS_DIR.replace(/^~(?=\/|$)/, os.homedir());
await fs.mkdir(reposDir, { recursive: true });

const url = new URL(repoUrl);
const dirName = `${url.hostname}${url.pathname.replace(/\//g, '-')}`.replace(/\.git$/, '');
const targetPath = path.join(reposDir, dirName);

try {
await fs.access(path.join(targetPath, '.git'));
const targetPath = await GitOperationsService.resolveTargetPath(repoUrl);
if (await GitOperationsService.isRepositoryCloned(targetPath)) {
return targetPath;
} catch {
// Continue to clone
}

const authUrl = repoUrl.replace(/^https:\/\//, `https://oauth2:${token}@`);

try {
execSync(`git clone --depth 1 ${authUrl} ${targetPath}`, { stdio: 'inherit' });
GitOperationsService.runClone(repoUrl, token, targetPath);
return targetPath;
} catch (error) {
throw new Error(
`Failed to clone repository: ${error instanceof Error ? error.message : String(error)}`,
);
const errorMessage =
error instanceof Error
? GitOperationsService.maskToken(error.message, token)
: String(error);
throw new Error(`Failed to clone repository: ${errorMessage}`);
}
}

Expand All @@ -66,4 +57,52 @@ export class GitOperationsService {

return `${url.origin}${parts[0]}.git`;
}

private static resolveReposDir(): string {
return DEFAULT_REPOS_DIR.replace(/^~(?=\/|$)/, os.homedir());
}

private static async resolveTargetPath(repoUrl: string): Promise<string> {
const reposDir = GitOperationsService.resolveReposDir();
await fs.mkdir(reposDir, { recursive: true });
const url = new URL(repoUrl);
const dirName = `${url.hostname}${url.pathname.replace(/\//g, '-')}`.replace(/\.git$/, '');
return path.join(reposDir, dirName);
}

private static async isRepositoryCloned(targetPath: string): Promise<boolean> {
try {
await fs.access(path.join(targetPath, '.git'));
return true;
} catch {
return false;
}
}

private static buildCloneEnv(token: string): Record<string, string | undefined> {
const basicAuth = Buffer.from(`oauth2:${token}`).toString('base64');
return {
...process.env,
GIT_CONFIG_COUNT: '1',
GIT_CONFIG_KEY_0: 'http.extraHeader',
GIT_CONFIG_VALUE_0: `Authorization: Basic ${basicAuth}`,
};
}

private static runClone(repoUrl: string, token: string, targetPath: string): void {
execFileSync('git', ['clone', '--depth', '1', repoUrl, targetPath], {
stdio: 'inherit',
env: GitOperationsService.buildCloneEnv(token),
});
}

Comment on lines +82 to +98
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

buildCloneEnv overwrites any pre-existing GIT_CONFIG_COUNT / GIT_CONFIG_KEY_* / GIT_CONFIG_VALUE_* environment variables inherited from the parent process, which can break users who already rely on those settings. Prefer passing config via git -c http.extraHeader=... clone ... (or increment/append to the existing GIT_CONFIG_COUNT entries) so you don’t clobber existing Git env-based config.

Suggested change
private static buildCloneEnv(token: string): Record<string, string | undefined> {
const basicAuth = Buffer.from(`oauth2:${token}`).toString('base64');
return {
...process.env,
GIT_CONFIG_COUNT: '1',
GIT_CONFIG_KEY_0: 'http.extraHeader',
GIT_CONFIG_VALUE_0: `Authorization: Basic ${basicAuth}`,
};
}
private static runClone(repoUrl: string, token: string, targetPath: string): void {
execFileSync('git', ['clone', '--depth', '1', repoUrl, targetPath], {
stdio: 'inherit',
env: GitOperationsService.buildCloneEnv(token),
});
}
private static runClone(repoUrl: string, token: string, targetPath: string): void {
const basicAuth = Buffer.from(`oauth2:${token}`).toString('base64');
execFileSync(
'git',
[
'-c',
`http.extraHeader=Authorization: Basic ${basicAuth}`,
'clone',
'--depth',
'1',
repoUrl,
targetPath,
],
{
stdio: 'inherit',
},
);
}

Copilot uses AI. Check for mistakes.
private static maskToken(text: string, token: string): string {
if (!token) return text;
const basicAuth = Buffer.from(`oauth2:${token}`).toString('base64');
const encodedToken = encodeURIComponent(token);
return [token, encodedToken, basicAuth].reduce(
(maskedText, secret) => (secret ? maskedText.replaceAll(secret, '***') : maskedText),
text,
);
}
}
50 changes: 35 additions & 15 deletions src/services/git-repo-resolver.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,20 +9,15 @@ export class GitRepoResolver {
static async resolveRepositoryPath(url: string, config: MosesConfig): Promise<string | null> {
const targetRepoUrl = GitOperationsService.getRepoUrlFromMrUrl(url);

if (GitOperationsService.isCurrentDirMatchingRepo(targetRepoUrl)) {
Display.success('✅ Repository detected in current directory. Using local context.');
return process.cwd();
const localRepositoryPath = GitRepoResolver.resolveCurrentDirectoryRepository(targetRepoUrl);
if (localRepositoryPath) {
return localRepositoryPath;
}

Display.info('📂 Current directory does not match the MR repository.');
const shouldDownload = await Prompt.confirm({
message: 'Do you want to download the repository locally to provide more context to the AI?',
default: true,
});

const shouldDownload = await GitRepoResolver.promptForRepositoryDownload();
if (!shouldDownload) return null;

return this.downloadRepository(url, targetRepoUrl, config);
return GitRepoResolver.downloadRepository(url, targetRepoUrl, config);
}

private static async downloadRepository(
Expand All @@ -31,19 +26,44 @@ export class GitRepoResolver {
config: MosesConfig,
): Promise<string | null> {
const parsedUrl = UrlParser.parseMergeRequestUrl(url);
const gitlabConfig = ConfigStore.findGitlabInstance(config, parsedUrl.host);
const gitlabConfig = GitRepoResolver.findGitlabConfig(config, parsedUrl.host);

if (!gitlabConfig) {
Display.error(`No GitLab instance configured for host: ${parsedUrl.host}`);
return null;
}

return GitRepoResolver.cloneRepositoryWithFeedback(targetRepoUrl, gitlabConfig.token);
}

private static resolveCurrentDirectoryRepository(targetRepoUrl: string): string | null {
if (!GitOperationsService.isCurrentDirMatchingRepo(targetRepoUrl)) {
return null;
}

Display.success('✅ Repository detected in current directory. Using local context.');
return process.cwd();
}

private static async promptForRepositoryDownload(): Promise<boolean> {
Display.info('📂 Current directory does not match the MR repository.');
return Prompt.confirm({
message: 'Do you want to download the repository locally to provide more context to the AI?',
default: true,
});
}

private static findGitlabConfig(config: MosesConfig, host: string) {
return ConfigStore.findGitlabInstance(config, host);
}

private static async cloneRepositoryWithFeedback(
targetRepoUrl: string,
token: string,
): Promise<string | null> {
const spinner = Display.spinner('Cloning repository...');
try {
const repoPath = await GitOperationsService.cloneRepository(
targetRepoUrl,
gitlabConfig.token,
);
const repoPath = await GitOperationsService.cloneRepository(targetRepoUrl, token);
spinner.succeed(`Repository cloned to: ${repoPath}`);
return repoPath;
} catch (error) {
Expand Down
Loading
Loading