diff --git a/src/lib/slack-bot.ts b/src/lib/slack-bot.ts index db9cf99ed..867b0b114 100644 --- a/src/lib/slack-bot.ts +++ b/src/lib/slack-bot.ts @@ -7,6 +7,13 @@ import { getGitHubTokenForUser, getGitHubTokenForOrganization, } from '@/lib/cloud-agent/github-integration-helpers'; +import { + getGitLabTokenForUser, + getGitLabTokenForOrganization, + getGitLabInstanceUrlForUser, + getGitLabInstanceUrlForOrganization, + buildGitLabCloneUrl, +} from '@/lib/cloud-agent/gitlab-integration-helpers'; import type OpenAI from 'openai'; import type { Owner } from '@/lib/integrations/core/types'; import { @@ -21,6 +28,10 @@ import { formatGitHubRepositoriesForPrompt, getGitHubRepositoryContext, } from '@/lib/slack-bot/github-repository-context'; +import { + formatGitLabRepositoriesForPrompt, + getGitLabRepositoryContext, +} from '@/lib/slack-bot/gitlab-repository-context'; import { formatSlackConversationContextForPrompt, getSlackConversationContext, @@ -66,23 +77,29 @@ const KILO_BOT_SYSTEM_PROMPT = `You are Kilo Bot, a helpful AI assistant integra Additional context may be appended to this prompt: - Slack conversation context (recent messages, thread context) - Available GitHub repositories for this Slack integration +- Available GitLab projects for this Slack integration -Treat this context as authoritative. Prefer selecting a repo from the provided repository list. If the user requests work on a repo that isn't in the list, ask them to confirm the exact owner/repo and ensure it's accessible to the integration. Never invent repository names. +Treat this context as authoritative. Prefer selecting a repo from the provided repository list. If the user requests work on a repo that isn't in the list, ask them to confirm the exact owner/repo (or group/project for GitLab) and ensure it's accessible to the integration. Never invent repository names. ## Tool: spawn_cloud_agent -You can call the tool "spawn_cloud_agent" to run a Cloud Agent session for coding work on a GitHub repository. +You can call the tool "spawn_cloud_agent" to run a Cloud Agent session for coding work on a GitHub repository or GitLab project. ### When to use it Use spawn_cloud_agent when the user asks you to: - change code, fix bugs, implement features, or refactor - review/analyze code in a repo beyond a quick, high-level answer -- do any task where you must inspect files, run tests, or open a PR +- do any task where you must inspect files, run tests, or open a PR/MR If the user is only asking a question you can answer directly (conceptual, small snippet, explanation), do not call the tool. ### How to use it -Provide: -- githubRepo: "owner/repo" +Provide exactly ONE of: +- githubRepo: "owner/repo" — for GitHub repositories +- gitlabProject: "group/project" or "group/subgroup/project" — for GitLab projects + +Determine which platform to use based on the repository context provided below. If the user mentions a repo that appears in the GitHub list, use githubRepo. If it appears in the GitLab list, use gitlabProject. + +Also provide: - mode: - code: implement changes - debug: investigate failures, flaky tests, production issues @@ -94,11 +111,11 @@ Provide: Your prompt to the agent should usually include: - the desired outcome (what "done" looks like) - any constraints (keep changes minimal, follow existing patterns, etc.) -- a request to open a PR and return the PR URL +- a request to open a PR (GitHub) or MR (GitLab) and return the URL ## Accuracy & safety -- Don't claim you ran tools, changed code, or created a PR unless the tool results confirm it. -- Don't fabricate links (including PR URLs). +- Don't claim you ran tools, changed code, or created a PR/MR unless the tool results confirm it. +- Don't fabricate links (including PR/MR URLs). - If you can't proceed (missing repo, missing details, permissions), say what's missing and what you need next.`; /** @@ -109,7 +126,7 @@ const SPAWN_CLOUD_AGENT_TOOL: OpenAI.Chat.Completions.ChatCompletionTool = { function: { name: 'spawn_cloud_agent', description: - 'Spawn a Cloud Agent session to perform coding tasks on a GitHub repository. The agent can make code changes, fix bugs, implement features, and more.', + 'Spawn a Cloud Agent session to perform coding tasks on a GitHub repository or GitLab project. Provide exactly one of githubRepo or gitlabProject.', parameters: { type: 'object', properties: { @@ -118,6 +135,12 @@ const SPAWN_CLOUD_AGENT_TOOL: OpenAI.Chat.Completions.ChatCompletionTool = { description: 'The GitHub repository in owner/repo format (e.g., "facebook/react")', pattern: '^[-a-zA-Z0-9_.]+/[-a-zA-Z0-9_.]+$', }, + gitlabProject: { + type: 'string', + description: + 'The GitLab project path in group/project format (e.g., "mygroup/myproject"). May include nested groups (e.g., "group/subgroup/project").', + pattern: '^[-a-zA-Z0-9_.]+(/[-a-zA-Z0-9_.]+)+$', + }, prompt: { type: 'string', description: @@ -131,7 +154,7 @@ const SPAWN_CLOUD_AGENT_TOOL: OpenAI.Chat.Completions.ChatCompletionTool = { default: 'code', }, }, - required: ['githubRepo', 'prompt'], + required: ['prompt'], }, }, }; @@ -214,11 +237,13 @@ async function getSlackRequesterInfo( /** * Spawn a Cloud Agent session and collect the results. + * Supports both GitHub (githubRepo) and GitLab (gitlabProject) repositories. * Delegates to the shared runSessionToCompletion helper. */ async function spawnCloudAgentSession( args: { - githubRepo: string; + githubRepo?: string; + gitlabProject?: string; prompt: string; mode?: string; }, @@ -231,37 +256,110 @@ async function spawnCloudAgentSession( console.log('[SlackBot] spawnCloudAgentSession called with args:', JSON.stringify(args, null, 2)); console.log('[SlackBot] Owner:', JSON.stringify(owner, null, 2)); - let githubToken: string | undefined; - let kilocodeOrganizationId: string | undefined; + if (args.githubRepo && args.gitlabProject) { + return { + response: 'Error: Both githubRepo and gitlabProject were specified. Provide exactly one.', + }; + } - // Handle organization-owned integrations + if (!args.githubRepo && !args.gitlabProject) { + return { + response: 'Error: No repository specified. Provide either githubRepo or gitlabProject.', + }; + } + + // Validate the repo identifier has at least "owner/repo" shape (non-empty segments around a slash) + const repoIdentifier = args.githubRepo ?? args.gitlabProject; + if (!repoIdentifier || !/\/./.test(repoIdentifier)) { + return { + response: `Error: Invalid repository identifier "${repoIdentifier ?? ''}". Expected format like "owner/repo".`, + }; + } + + let kilocodeOrganizationId: string | undefined; if (owner.type === 'org') { - githubToken = await getGitHubTokenForOrganization(owner.id); kilocodeOrganizationId = owner.id; - } else { - githubToken = await getGitHubTokenForUser(owner.id); } - // Append PR signature to the prompt if we have requester info + // Append PR/MR signature to the prompt if we have requester info const promptWithSignature = requesterInfo ? args.prompt + buildPrSignature(requesterInfo) : args.prompt; - const result = await runSessionToCompletion({ - client: createCloudAgentNextClient(authToken, { skipBalanceCheck: true }), - prepareInput: { - githubRepo: args.githubRepo, + // Build platform-specific prepareInput and initiateInput + let prepareInput: PrepareSessionInput; + let initiateInput: { githubToken?: string; kilocodeOrganizationId?: string }; + + if (args.gitlabProject) { + // GitLab path: get token + instance URL, build clone URL, use gitUrl/gitToken + const gitlabToken = + owner.type === 'org' + ? await getGitLabTokenForOrganization(owner.id) + : await getGitLabTokenForUser(owner.id); + + if (!gitlabToken) { + return { + response: + 'Error: No GitLab token available. Please ensure a GitLab integration is connected in your Kilo Code settings.', + }; + } + + const instanceUrl = + owner.type === 'org' + ? await getGitLabInstanceUrlForOrganization(owner.id) + : await getGitLabInstanceUrlForUser(owner.id); + + const gitUrl = buildGitLabCloneUrl(args.gitlabProject, instanceUrl); + + const isSelfHosted = !/^https?:\/\/(www\.)?gitlab\.com(\/|$)/i.test(instanceUrl); + console.log( + '[SlackBot] GitLab session - project:', + args.gitlabProject, + 'instance:', + isSelfHosted ? 'self-hosted' : 'gitlab.com' + ); + + prepareInput = { prompt: promptWithSignature, mode: (args.mode as PrepareSessionInput['mode']) || 'code', model, - githubToken, + gitUrl, + gitToken: gitlabToken, + platform: 'gitlab', kilocodeOrganizationId, createdOnPlatform: 'slack', - }, - initiateInput: { + }; + initiateInput = { kilocodeOrganizationId }; + } else { + // GitHub path: get token, use githubRepo/githubToken + const githubToken = + owner.type === 'org' + ? await getGitHubTokenForOrganization(owner.id) + : await getGitHubTokenForUser(owner.id); + + if (!githubToken) { + return { + response: + 'Error: No GitHub token available. Please ensure a GitHub integration is connected in your Kilo Code settings.', + }; + } + + prepareInput = { + githubRepo: args.githubRepo, + prompt: promptWithSignature, + mode: (args.mode as PrepareSessionInput['mode']) || 'code', + model, githubToken, kilocodeOrganizationId, - }, + createdOnPlatform: 'slack', + }; + initiateInput = { githubToken, kilocodeOrganizationId }; + } + + const result = await runSessionToCompletion({ + client: createCloudAgentNextClient(authToken, { skipBalanceCheck: true }), + prepareInput, + initiateInput, ticketPayload: { userId: ticketUserId, organizationId: owner.type === 'org' ? owner.id : undefined, @@ -371,14 +469,29 @@ export async function processKiloBotMessage( ? await getSlackRequesterInfo(installation, slackEventContext) : undefined; - // Get repository context (no extra requests; uses the same integration row) - const repoContext = await getGitHubRepositoryContext(owner); - const repoCount = repoContext.repositories ? repoContext.repositories.length : 0; - console.log('[SlackBot] Found', repoCount, 'available repositories'); + // Get repository context (no extra requests; uses the same integration rows) + const githubRepoContext = await getGitHubRepositoryContext(owner); + const gitlabRepoContext = await getGitLabRepositoryContext(owner); + const githubRepoCount = githubRepoContext.repositories + ? githubRepoContext.repositories.length + : 0; + const gitlabRepoCount = gitlabRepoContext.repositories + ? gitlabRepoContext.repositories.length + : 0; + console.log( + '[SlackBot] Found', + githubRepoCount, + 'GitHub and', + gitlabRepoCount, + 'GitLab repositories' + ); - // Build system prompt with Slack context + repository context + // Build system prompt with Slack context + repository context for both platforms const systemPrompt = - KILO_BOT_SYSTEM_PROMPT + slackContextForPrompt + formatGitHubRepositoriesForPrompt(repoContext); + KILO_BOT_SYSTEM_PROMPT + + slackContextForPrompt + + formatGitHubRepositoriesForPrompt(githubRepoContext) + + formatGitLabRepositoriesForPrompt(gitlabRepoContext); const runResult = await runBot({ authToken, diff --git a/src/lib/slack-bot/gitlab-repository-context.test.ts b/src/lib/slack-bot/gitlab-repository-context.test.ts new file mode 100644 index 000000000..a158bae82 --- /dev/null +++ b/src/lib/slack-bot/gitlab-repository-context.test.ts @@ -0,0 +1,96 @@ +import { + formatGitLabRepositoriesForPrompt, + type GitLabRepositoryContext, +} from './gitlab-repository-context'; + +describe('formatGitLabRepositoriesForPrompt', () => { + test('shows account, instance, and repository list when repos are available', () => { + const context: GitLabRepositoryContext = { + accountLogin: 'gitlab-user', + repositoryAccess: 'selected', + repositoriesSyncedAt: '2024-01-15T10:00:00Z', + instanceUrl: 'https://gitlab.com', + repositories: [ + { id: 1, name: 'project-a', full_name: 'mygroup/project-a', private: false }, + { id: 2, name: 'project-b', full_name: 'mygroup/subgroup/project-b', private: true }, + ], + }; + + const result = formatGitLabRepositoriesForPrompt(context); + + expect(result).toContain('GitLab repository context'); + expect(result).toContain('Account: gitlab-user'); + expect(result).toContain('Instance: gitlab.com'); + expect(result).toContain('Repository access: selected'); + expect(result).toContain('Repositories synced at: 2024-01-15T10:00:00Z'); + expect(result).toContain('mygroup/project-a [id: 1]'); + expect(result).toContain('mygroup/subgroup/project-b (private) [id: 2]'); + expect(result).toContain('nested groups'); + }); + + test('shows "all access" message when repositoryAccess is "all" and no repos listed', () => { + const context: GitLabRepositoryContext = { + accountLogin: 'gitlab-user', + repositoryAccess: 'all', + repositoriesSyncedAt: null, + instanceUrl: 'https://gitlab.com', + repositories: [], + }; + + const result = formatGitLabRepositoriesForPrompt(context); + + expect(result).toContain('not stored for "all" access'); + expect(result).toContain('group/project format'); + }); + + test('shows "no repos connected" when no integration repos and access is not "all"', () => { + const context: GitLabRepositoryContext = { + accountLogin: null, + repositoryAccess: null, + repositoriesSyncedAt: null, + instanceUrl: null, + repositories: null, + }; + + const result = formatGitLabRepositoriesForPrompt(context); + + expect(result).toContain('No GitLab repositories are currently connected'); + }); + + test('redacts self-hosted instance URL to prevent leaking internal hostnames', () => { + const context: GitLabRepositoryContext = { + accountLogin: 'admin', + repositoryAccess: 'selected', + repositoriesSyncedAt: null, + instanceUrl: 'https://gitlab.example.com', + repositories: [{ id: 10, name: 'internal', full_name: 'team/internal', private: true }], + }; + + const result = formatGitLabRepositoriesForPrompt(context); + + expect(result).toContain('Instance: self-hosted GitLab'); + expect(result).not.toContain('gitlab.example.com'); + expect(result).toContain('team/internal (private) [id: 10]'); + }); + + test('handles null repositories the same as empty array', () => { + const contextNull: GitLabRepositoryContext = { + accountLogin: 'user', + repositoryAccess: 'selected', + repositoriesSyncedAt: null, + instanceUrl: 'https://gitlab.com', + repositories: null, + }; + + const contextEmpty: GitLabRepositoryContext = { + ...contextNull, + repositories: [], + }; + + const resultNull = formatGitLabRepositoriesForPrompt(contextNull); + const resultEmpty = formatGitLabRepositoriesForPrompt(contextEmpty); + + expect(resultNull).toContain('No GitLab repositories are currently connected'); + expect(resultEmpty).toContain('No GitLab repositories are currently connected'); + }); +}); diff --git a/src/lib/slack-bot/gitlab-repository-context.ts b/src/lib/slack-bot/gitlab-repository-context.ts new file mode 100644 index 000000000..5d5e6a702 --- /dev/null +++ b/src/lib/slack-bot/gitlab-repository-context.ts @@ -0,0 +1,93 @@ +import type { Owner, PlatformRepository } from '@/lib/integrations/core/types'; +import { PLATFORM } from '@/lib/integrations/core/constants'; +import { getIntegrationForOwner } from '@/lib/integrations/db/platform-integrations'; + +export type GitLabRepositoryContext = { + accountLogin: string | null; + repositoryAccess: string | null; + repositoriesSyncedAt: string | null; + repositories: PlatformRepository[] | null; + instanceUrl: string | null; +}; + +/** + * Get GitLab repository context for an owner from their GitLab integration. + * This does not perform extra API requests; it uses data stored on the integration row. + */ +export async function getGitLabRepositoryContext(owner: Owner): Promise { + const integration = await getIntegrationForOwner(owner, PLATFORM.GITLAB); + if (!integration) { + return { + accountLogin: null, + repositoryAccess: null, + repositoriesSyncedAt: null, + repositories: null, + instanceUrl: null, + }; + } + + const repositories = integration.repositories ? integration.repositories : null; + const metadata = integration.metadata as { gitlab_instance_url?: string } | null; + const instanceUrl = metadata?.gitlab_instance_url || 'https://gitlab.com'; + + return { + accountLogin: integration.platform_account_login, + repositoryAccess: integration.repository_access, + repositoriesSyncedAt: integration.repositories_synced_at, + repositories, + instanceUrl, + }; +} + +/** Redact self-hosted GitLab URLs so internal hostnames don't leak into LLM prompts. */ +function describeInstance(url: string): string { + try { + const hostname = new URL(url).hostname; + if (hostname === 'gitlab.com') return 'gitlab.com'; + } catch { + // malformed URL — fall through + } + return 'self-hosted GitLab'; +} + +export function formatGitLabRepositoriesForPrompt(context: GitLabRepositoryContext): string { + const headerLines: string[] = ['\n\nGitLab repository context for this workspace:']; + + if (context.accountLogin) { + headerLines.push(`- Account: ${context.accountLogin}`); + } + if (context.instanceUrl) { + headerLines.push(`- Instance: ${describeInstance(context.instanceUrl)}`); + } + if (context.repositoryAccess) { + headerLines.push(`- Repository access: ${context.repositoryAccess}`); + } + if (context.repositoriesSyncedAt) { + headerLines.push(`- Repositories synced at: ${context.repositoriesSyncedAt}`); + } + + const header = headerLines.join('\n'); + + if (!context.repositories || context.repositories.length === 0) { + if (context.repositoryAccess === 'all') { + return `${header} +- Repository list: not stored for "all" access (no repo list to show without extra requests). + +When the user asks you to work on a GitLab project, ask them to specify the project path explicitly in group/project format.`; + } + + return `${header} +- No GitLab repositories are currently connected. The user will need to specify a project manually.`; + } + + const repoList = context.repositories + .map(repo => `- ${repo.full_name}${repo.private ? ' (private)' : ''} [id: ${repo.id}]`) + .join('\n'); + + return `${header} + +Available GitLab projects: +${repoList} + +When the user asks you to work on code without specifying a project, try to infer the correct project from context or ask them to clarify which project they want to use. GitLab project paths may have nested groups (e.g., group/subgroup/project).`; +}