diff --git a/src/services/ai-setup.service.ts b/src/services/ai-setup.service.ts index 0fe97cf..1969d73 100644 --- a/src/services/ai-setup.service.ts +++ b/src/services/ai-setup.service.ts @@ -16,21 +16,16 @@ export interface AiSetupData { export class AiSetupWizard { static async promptAiSetup(existingConfig: MosesConfig | null): Promise { - 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, ); @@ -40,39 +35,86 @@ export class AiSetupWizard { static async chooseAiTool(existingTool: AiToolKey | undefined): Promise { while (true) { - const chosen = await Prompt.select({ - 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 { + return Prompt.select({ + 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 { + return Prompt.confirm({ + message: 'Do you want to choose another tool?', + default: true, + }); + } + static async chooseFeedbackStyle( existingStyle: FeedbackStyle | undefined, ): Promise { diff --git a/src/services/context-manager.service.ts b/src/services/context-manager.service.ts index b1fea19..85e18ab 100644 --- a/src/services/context-manager.service.ts +++ b/src/services/context-manager.service.ts @@ -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 { + 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 { await fs.mkdir(contextDir, { recursive: true }); + } + + private static async listPromptFiles(): Promise { + return fs.readdir(PROMPTS_DIR); + } - const files = await fs.readdir(PROMPTS_DIR); + private static async copyMissingPromptFiles(files: string[], contextDir: string): Promise { 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 { - const contextDir = ContextManager.getContextDir(); + private static async copyPromptFileIfMissing(file: string, contextDir: string): Promise { + 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 { 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 { 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()}`); } diff --git a/src/services/git-operations.service.ts b/src/services/git-operations.service.ts index 1fc57ab..953de9d 100644 --- a/src/services/git-operations.service.ts +++ b/src/services/git-operations.service.ts @@ -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'; @@ -33,29 +33,20 @@ export class GitOperationsService { } static async cloneRepository(repoUrl: string, token: string): Promise { - 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}`); } } @@ -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 { + 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 { + try { + await fs.access(path.join(targetPath, '.git')); + return true; + } catch { + return false; + } + } + + private static buildCloneEnv(token: string): Record { + 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 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, + ); + } } diff --git a/src/services/git-repo-resolver.service.ts b/src/services/git-repo-resolver.service.ts index a84a6e0..f5b5dc2 100644 --- a/src/services/git-repo-resolver.service.ts +++ b/src/services/git-repo-resolver.service.ts @@ -9,20 +9,15 @@ export class GitRepoResolver { static async resolveRepositoryPath(url: string, config: MosesConfig): Promise { 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( @@ -31,19 +26,44 @@ export class GitRepoResolver { config: MosesConfig, ): Promise { 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 { + 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 { 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) { diff --git a/src/services/gitlab-api.service.ts b/src/services/gitlab-api.service.ts index 778de86..8311f78 100644 --- a/src/services/gitlab-api.service.ts +++ b/src/services/gitlab-api.service.ts @@ -48,21 +48,9 @@ export class GitlabApiService { ): Promise { const client = GitlabApiService.createClient(baseURL, token); const [mr, diffs, commits] = await Promise.all([ - GitlabApiService.withRetry(() => - client.get( - `/api/v4/projects/${projectId}/merge_requests/${mrIid}`, - ), - ), - GitlabApiService.withRetry(() => - client.get( - `/api/v4/projects/${projectId}/merge_requests/${mrIid}/diffs`, - ), - ), - GitlabApiService.withRetry(() => - client.get( - `/api/v4/projects/${projectId}/merge_requests/${mrIid}/commits`, - ), - ), + GitlabApiService.fetchMergeRequest(client, projectId, mrIid), + GitlabApiService.fetchMergeRequestDiffs(client, projectId, mrIid), + GitlabApiService.fetchMergeRequestCommits(client, projectId, mrIid), ]); return { mr: mr.data, @@ -70,4 +58,23 @@ export class GitlabApiService { commits: commits.data, }; } + + private static getMergeRequestBasePath(projectId: string, mrIid: string): string { + return `/api/v4/projects/${projectId}/merge_requests/${mrIid}`; + } + + private static fetchMergeRequest(client: AxiosInstance, projectId: string, mrIid: string) { + const route = GitlabApiService.getMergeRequestBasePath(projectId, mrIid); + return GitlabApiService.withRetry(() => client.get(route)); + } + + private static fetchMergeRequestDiffs(client: AxiosInstance, projectId: string, mrIid: string) { + const route = `${GitlabApiService.getMergeRequestBasePath(projectId, mrIid)}/diffs`; + return GitlabApiService.withRetry(() => client.get(route)); + } + + private static fetchMergeRequestCommits(client: AxiosInstance, projectId: string, mrIid: string) { + const route = `${GitlabApiService.getMergeRequestBasePath(projectId, mrIid)}/commits`; + return GitlabApiService.withRetry(() => client.get(route)); + } } diff --git a/src/services/gitlab-data-provider.service.ts b/src/services/gitlab-data-provider.service.ts index 7d968ff..beeaa13 100644 --- a/src/services/gitlab-data-provider.service.ts +++ b/src/services/gitlab-data-provider.service.ts @@ -8,46 +8,70 @@ import type { MosesConfig } from '../types/moses-config.type.js'; export class GitlabDataProvider { static async fetchMrData(url: string, config: MosesConfig) { - let parsedUrl; + const parsedUrl = GitlabDataProvider.parseMergeRequestUrl(url); + if (!parsedUrl) return null; + const gitlabConfig = GitlabDataProvider.findGitlabConfig(config, parsedUrl.host); + if (!gitlabConfig) { + Display.error(`No GitLab instance configured for host: ${parsedUrl.host}`); + return null; + } + + return GitlabDataProvider.fetchAndDisplayMergeRequest( + gitlabConfig.url, + gitlabConfig.token, + parsedUrl.projectId, + parsedUrl.mrIid, + ); + } + + private static parseMergeRequestUrl(url: string) { try { - parsedUrl = UrlParser.parseMergeRequestUrl(url); + return UrlParser.parseMergeRequestUrl(url); } catch (error: unknown) { Display.error(error instanceof Error ? error.message : 'Invalid Merge Request URL.'); return null; } + } - const gitlabConfig = ConfigStore.findGitlabInstance(config, parsedUrl.host); - if (!gitlabConfig) { - Display.error(`No GitLab instance configured for host: ${parsedUrl.host}`); - return null; - } + private static findGitlabConfig(config: MosesConfig, host: string) { + return ConfigStore.findGitlabInstance(config, host); + } + private static async fetchAndDisplayMergeRequest( + baseUrl: string, + token: string, + projectId: string, + mrIid: string, + ) { const spinner = Display.spinner('Fetching MR data...'); try { - const data = await GitlabApiService.getMergeRequestData( - gitlabConfig.url, - gitlabConfig.token, - parsedUrl.projectId, - parsedUrl.mrIid, - ); + const data = await GitlabApiService.getMergeRequestData(baseUrl, token, projectId, mrIid); spinner.succeed(`MR #${data.mr.iid} — "${data.mr.title}" loaded`); - - Display.info(`šŸ‘¤ Author: ${data.mr.author?.name ?? data.mr.author?.username ?? 'unknown'}`); - Display.info(`🌿 Branch: ${data.mr.source_branch} → ${data.mr.target_branch}`); - Display.info(`šŸ“… Date: ${dayjs(data.mr.created_at).format('YYYY-MM-DD')}`); - Display.info( - `šŸ“Š Stats: ${data.diffs.length} files | changes_count: ${data.mr.changes_count ?? '?'}`, - ); - + GitlabDataProvider.displayMergeRequestSummary(data); return data; } catch (error: unknown) { - spinner.fail('Failed to fetch MR data.'); - if (axios.isAxiosError(error) && error.response?.status === 404) { - Display.error('MR not found (404). Check URL and access (VPN, permissions).'); - } else { - Display.error(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`); - } + GitlabDataProvider.handleFetchError(error, spinner); return null; } } + + private static displayMergeRequestSummary( + data: Awaited>, + ): void { + Display.info(`šŸ‘¤ Author: ${data.mr.author?.name ?? data.mr.author?.username ?? 'unknown'}`); + Display.info(`🌿 Branch: ${data.mr.source_branch} → ${data.mr.target_branch}`); + Display.info(`šŸ“… Date: ${dayjs(data.mr.created_at).format('YYYY-MM-DD')}`); + Display.info( + `šŸ“Š Stats: ${data.diffs.length} files | changes_count: ${data.mr.changes_count ?? '?'}`, + ); + } + + private static handleFetchError(error: unknown, spinner: { fail: (text: string) => void }): void { + spinner.fail('Failed to fetch MR data.'); + if (axios.isAxiosError(error) && error.response?.status === 404) { + Display.error('MR not found (404). Check URL and access (VPN, permissions).'); + return; + } + Display.error(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`); + } } diff --git a/src/services/gitlab-instance-manager.service.ts b/src/services/gitlab-instance-manager.service.ts index c854327..0d612c1 100644 --- a/src/services/gitlab-instance-manager.service.ts +++ b/src/services/gitlab-instance-manager.service.ts @@ -14,7 +14,7 @@ export class GitlabInstanceManager { Display.section('šŸ“‹ CONFIGURED GITLAB INSTANCES'); config.gitlabs.forEach((gitlab) => { - this.displayInstanceDetails(gitlab, gitlab.name === config.defaultGitlab); + GitlabInstanceManager.displayInstanceDetails(gitlab, gitlab.name === config.defaultGitlab); }); Display.info('TIP: You can use "moses gitlab default" to change the default instance.'); @@ -39,11 +39,7 @@ export class GitlabInstanceManager { return; } - const choices = config.gitlabs.map((gitlab) => ({ - name: `${gitlab.name} (${gitlab.url})`, - value: gitlab.name, - })); - + const choices = GitlabInstanceManager.buildInstanceChoices(config); const nextDefault = await Prompt.select({ message: 'Choose the default GitLab instance:', choices, @@ -54,15 +50,10 @@ export class GitlabInstanceManager { } static async updateConfig(config: MosesConfig, nextDefault: string): Promise { - const updatedGitlabs = config.gitlabs.map((gitlab) => ({ - ...gitlab, - default: gitlab.name === nextDefault, - })); - const nextConfig: MosesConfig = { ...config, defaultGitlab: nextDefault, - gitlabs: updatedGitlabs, + gitlabs: GitlabInstanceManager.markDefaultGitlab(config.gitlabs, nextDefault), }; await ConfigStore.set(nextConfig); @@ -79,4 +70,21 @@ export class GitlabInstanceManager { Display.error('Could not switch GitLab instance.'); console.log(error); } + + private static buildInstanceChoices(config: MosesConfig): { name: string; value: string }[] { + return config.gitlabs.map((gitlab) => ({ + name: `${gitlab.name} (${gitlab.url})`, + value: gitlab.name, + })); + } + + private static markDefaultGitlab( + gitlabs: GitlabInstance[], + nextDefault: string, + ): GitlabInstance[] { + return gitlabs.map((gitlab) => ({ + ...gitlab, + default: gitlab.name === nextDefault, + })); + } } diff --git a/src/services/gitlab-setup.service.ts b/src/services/gitlab-setup.service.ts index 055631c..dbd6504 100644 --- a/src/services/gitlab-setup.service.ts +++ b/src/services/gitlab-setup.service.ts @@ -14,53 +14,67 @@ export interface GitlabSetupData { export class GitlabSetupWizard { static async promptGitlabSetup(existingConfig: MosesConfig | null): Promise { + GitlabSetupWizard.displayIntro(); + const name = await GitlabSetupWizard.promptInstanceName(existingConfig); + const url = await GitlabSetupWizard.chooseGitlabBaseUrl(); + GitlabSetupWizard.displayTokenHelp(url); + const token = await GitlabSetupWizard.promptValidatedToken(url); + + return { name, url, token }; + } + + static async chooseGitlabBaseUrl(): Promise { + const gitlabType = await GitlabSetupWizard.promptGitlabType(); + if (gitlabType === 'default') { + return 'https://gitlab.com'; + } + + return GitlabSetupWizard.promptSelfHostedGitlabUrl(); + } + + static async validateGitlabToken(gitlabUrl: string, token: string) { + const tokenSpinner = Display.spinner('Validating token...'); + try { + const user = await GitlabApiService.validateToken(gitlabUrl, token); + tokenSpinner.succeed(`Valid token! User: @${user.username}`); + return user; + } catch (error: unknown) { + const status = axios.isAxiosError(error) ? error.response?.status : undefined; + const message = status ? `Failed (Status ${status})` : 'Invalid or expired token.'; + tokenSpinner.fail(message); + const settingsBase = gitlabUrl.replace(/\/$/, ''); + Display.link(` ${settingsBase}/-/user_settings/personal_access_tokens`); + throw error; + } + } + + private static displayIntro(): void { Display.section('šŸ“‹ GITLAB CONFIGURATION'); Display.info( 'šŸ’” TIP: Use a nickname to Identify this GitLab config (e.g. "work", "gitlab-org").', ); Display.info(' You can run "init" again later to add more instances.'); + } - const name = await Prompt.ask({ + private static async promptInstanceName(existingConfig: MosesConfig | null): Promise { + return Prompt.ask({ message: 'Instance nickname (e.g., "work", "gitlab-com" - to identify this GitLab config):', default: existingConfig?.defaultGitlab ?? 'gitlab-main', schema: z.string().min(1, 'Nickname is required'), }); - - const url = await GitlabSetupWizard.chooseGitlabBaseUrl(); - const settingsBase = url.replace(/\/$/, ''); - Display.info(`šŸ’” Create a new Personal Access Token with "api" scope here:`); - Display.link(`${settingsBase}/-/user_settings/personal_access_tokens`); - - let token = ''; - while (true) { - token = await Prompt.password({ - message: 'Personal Access Token (scope: api):', - }); - - try { - await GitlabSetupWizard.validateGitlabToken(url, token); - break; - } catch { - // Just loop back on invalid token - } - } - - return { name, url, token }; } - static async chooseGitlabBaseUrl(): Promise { - const gitlabType = await Prompt.select({ + private static async promptGitlabType(): Promise<'default' | 'self'> { + return Prompt.select<'default' | 'self'>({ message: 'Which GitLab do you want to use?', choices: [ { name: 'GitLab.com (gitlab.com) — Default', value: 'default' }, { name: 'Self-Hosted GitLab (provide a custom URL)', value: 'self' }, ], }); + } - if (gitlabType === 'default') { - return 'https://gitlab.com'; - } - + private static async promptSelfHostedGitlabUrl(): Promise { return Prompt.ask({ message: 'GitLab URL:', default: 'https://gitlab.your-domain.com', @@ -70,19 +84,24 @@ export class GitlabSetupWizard { }); } - static async validateGitlabToken(gitlabUrl: string, token: string) { - const tokenSpinner = Display.spinner('Validating token...'); - try { - const user = await GitlabApiService.validateToken(gitlabUrl, token); - tokenSpinner.succeed(`Valid token! User: @${user.username}`); - return user; - } catch (error: unknown) { - const status = axios.isAxiosError(error) ? error.response?.status : undefined; - const message = status ? `Failed (Status ${status})` : 'Invalid or expired token.'; - tokenSpinner.fail(message); - const settingsBase = gitlabUrl.replace(/\/$/, ''); - Display.link(` ${settingsBase}/-/user_settings/personal_access_tokens`); - throw error; + private static displayTokenHelp(url: string): void { + const settingsBase = url.replace(/\/$/, ''); + Display.info('šŸ’” Create a new Personal Access Token with "api" scope here:'); + Display.link(`${settingsBase}/-/user_settings/personal_access_tokens`); + } + + private static async promptValidatedToken(url: string): Promise { + while (true) { + const token = await Prompt.password({ + message: 'Personal Access Token (scope: api):', + }); + + try { + await GitlabSetupWizard.validateGitlabToken(url, token); + return token; + } catch { + // Just loop back on invalid token + } } } } diff --git a/src/services/mr-markdown-formatter.service.ts b/src/services/mr-markdown-formatter.service.ts index 62486d0..5700ff1 100644 --- a/src/services/mr-markdown-formatter.service.ts +++ b/src/services/mr-markdown-formatter.service.ts @@ -17,19 +17,9 @@ export class MrMarkdownFormatter { url, }: BuildMergeRequestMarkdownInput): string { const createdAt = dayjs(mr.created_at).format('YYYY-MM-DD'); - const changedFiles = Array.isArray(diffs) ? diffs.length : 0; - const additions = mr.changes_count ?? '?'; - - const commitLines = commits - .map((commit) => `- ${commit.short_id} — ${commit.title}`) - .join('\n'); - - const diffSections = diffs - .map((item) => { - const diff = item.diff ?? ''; - return `### \`${item.new_path ?? item.old_path}\`\n\n\`\`\`diff\n${diff}\n\`\`\`\n`; - }) - .join('\n'); + const commitLines = MrMarkdownFormatter.formatCommitLines(commits); + const diffSections = MrMarkdownFormatter.formatDiffSections(diffs); + const stats = MrMarkdownFormatter.buildStatsSection(diffs, mr.changes_count); return `# MR #${mr.iid} — ${mr.title} @@ -40,8 +30,7 @@ export class MrMarkdownFormatter { ## šŸ“Š Statistics -- Changed files: ${changedFiles} -- Changes (GitLab): ${additions} +${stats} ## šŸ“ Description @@ -57,6 +46,29 @@ ${diffSections || '_No diffs_'} `; } + private static buildStatsSection( + diffs: MergeRequestBundle['diffs'], + changesCount: string | number | null | undefined, + ): string { + const changedFiles = Array.isArray(diffs) ? diffs.length : 0; + const additions = changesCount ?? '?'; + return `- Changed files: ${changedFiles} +- Changes (GitLab): ${additions}`; + } + + private static formatCommitLines(commits: MergeRequestBundle['commits']): string { + return commits.map((commit) => `- ${commit.short_id} — ${commit.title}`).join('\n'); + } + + private static formatDiffSections(diffs: MergeRequestBundle['diffs']): string { + return diffs + .map((item) => { + const diff = item.diff ?? ''; + return `### \`${item.new_path ?? item.old_path}\`\n\n\`\`\`diff\n${diff}\n\`\`\`\n`; + }) + .join('\n'); + } + static countDiffChanges(diffs: MergeRequestDiff[] = []): number { if (!Array.isArray(diffs)) return 0; return diffs.reduce((total, item) => { diff --git a/src/services/review-orchestrator.service.ts b/src/services/review-orchestrator.service.ts index d8fde11..9ccae3e 100644 --- a/src/services/review-orchestrator.service.ts +++ b/src/services/review-orchestrator.service.ts @@ -18,70 +18,100 @@ export class ReviewOrchestrator { ): Promise { const markdownSpinner = Display.spinner('Preparing context and diff...'); try { - await ContextManager.ensureDefaultContextFiles(); - let contextPrompt = await ContextManager.readContextPrompt(options.prompt ?? ''); + const contextPrompt = await ReviewOrchestrator.buildContextPrompt( + options.prompt ?? '', + repoPath, + ); + const markdown = ReviewOrchestrator.buildReviewMarkdown(url, data); - if (repoPath) { - const repoContext = await RepoScanner.scanRepoForContext(repoPath); - if (repoContext) { - contextPrompt = `${contextPrompt}\n${repoContext}`; - } - } + markdownSpinner.succeed(`Context and diff prepared (folder: ${DEFAULT_CONTEXT_DIR})`); + await ReviewOrchestrator.executeAiReview(config, markdown, contextPrompt); + } catch (error: unknown) { + markdownSpinner.fail('Failed to generate markdown or run AI review.'); + Display.error(error instanceof Error ? error.message : 'Unknown error during AI review.'); + } + } - const markdown = MrMarkdownFormatter.buildMergeRequestMarkdown({ - mr: data.mr, - diffs: data.diffs, - commits: data.commits, - url, - }); + private static async buildContextPrompt( + extraPrompt: string, + repoPath: string | null, + ): Promise { + await ContextManager.ensureDefaultContextFiles(); + const baseContext = await ContextManager.readContextPrompt(extraPrompt); + return ReviewOrchestrator.appendRepoContext(baseContext, repoPath); + } - markdownSpinner.succeed(`Context and diff prepared (folder: ${DEFAULT_CONTEXT_DIR})`); + private static async appendRepoContext( + baseContext: string, + repoPath: string | null, + ): Promise { + if (!repoPath) { + return baseContext; + } - const reviewSpinner = Display.spinner('Connecting to AI tool...'); - Display.info('\nšŸ¤– Starting review with AI tool...'); - Display.info('────────────────────────────────────────────────────────'); + const repoContext = await RepoScanner.scanRepoForContext(repoPath); + if (!repoContext) { + return baseContext; + } - await new Promise((resolve, reject) => { - let firstChunk = true; - const stopSpinnerOnStart = () => { - if (firstChunk) { - reviewSpinner.stop(); - firstChunk = false; - } - }; + return `${baseContext}\n${repoContext}`; + } + + private static buildReviewMarkdown(url: string, data: MergeRequestBundle): string { + return MrMarkdownFormatter.buildMergeRequestMarkdown({ + mr: data.mr, + diffs: data.diffs, + commits: data.commits, + url, + }); + } - AiReviewService.runAiReview(config.ai?.tool ?? 'copilot', markdown, { - options: { - feedbackStyle: config.ai?.feedbackStyle, - contextPrompt, - }, - onStdout: (chunk: string) => { - stopSpinnerOnStart(); - Display.stream(chunk); - }, - onStderr: (chunk: string) => { - stopSpinnerOnStart(); - Display.stream(chunk); - }, - onClose: (code: number | null) => { - if (code === 0) { - Display.info('\n────────────────────────────────────────────────────────'); - Display.success('Analysis completed'); - resolve(); - } else { - reviewSpinner.fail('AI analysis failed'); - reject(new Error(`AI process exited with code ${String(code)}`)); - } - }, - onError: (error: Error) => { + private static async executeAiReview( + config: MosesConfig, + markdown: string, + contextPrompt: string, + ): Promise { + const reviewSpinner = Display.spinner('Connecting to AI tool...'); + Display.info('\nšŸ¤– Starting review with AI tool...'); + Display.info('────────────────────────────────────────────────────────'); + + await new Promise((resolve, reject) => { + let firstChunk = true; + const stopSpinnerOnStart = () => { + if (firstChunk) { + reviewSpinner.stop(); + firstChunk = false; + } + }; + + AiReviewService.runAiReview(config.ai?.tool ?? 'copilot', markdown, { + options: { + feedbackStyle: config.ai?.feedbackStyle, + contextPrompt, + }, + onStdout: (chunk: string) => { + stopSpinnerOnStart(); + Display.stream(chunk); + }, + onStderr: (chunk: string) => { + stopSpinnerOnStart(); + Display.stream(chunk); + }, + onClose: (code: number | null) => { + if (code === 0) { + Display.info('\n────────────────────────────────────────────────────────'); + Display.success('Analysis completed'); + resolve(); + } else { reviewSpinner.fail('AI analysis failed'); - reject(error); - }, - }); + reject(new Error(`AI process exited with code ${String(code)}`)); + } + }, + onError: (error: Error) => { + reviewSpinner.fail('AI analysis failed'); + reject(error); + }, }); - } catch (error: unknown) { - markdownSpinner.fail('Failed to generate markdown or run AI review.'); - Display.error(error instanceof Error ? error.message : 'Unknown error during AI review.'); - } + }); } }