diff --git a/Dockerfile.worker b/Dockerfile.worker index c4c44e69..5950f51e 100644 --- a/Dockerfile.worker +++ b/Dockerfile.worker @@ -52,6 +52,12 @@ RUN ARCH=$(dpkg --print-architecture) && \ rm /tmp/ast-grep.zip && \ chmod +x /usr/local/bin/sg +# Install glab (GitLab CLI) +RUN ARCH=$(dpkg --print-architecture) && \ + curl -L "https://gitlab.com/gitlab-org/cli/-/releases/v1.52.0/downloads/glab_1.52.0_linux_${ARCH}.deb" -o /tmp/glab.deb && \ + dpkg -i /tmp/glab.deb && \ + rm /tmp/glab.deb + # Install agent CLIs used by headless engines in worker jobs RUN npm install -g @anthropic-ai/claude-code @openai/codex@0.114.0 opencode-ai diff --git a/package-lock.json b/package-lock.json index 75b129a6..eefe6eb0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "MIT", "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.2.91", + "@gitbeaker/rest": "^43.8.0", "@hono/node-server": "^1.13.7", "@hono/trpc-server": "^0.4.2", "@llmist/cli": "^16.0.3", @@ -1947,6 +1948,45 @@ "module-details-from-path": "^1.0.4" } }, + "node_modules/@gitbeaker/core": { + "version": "43.8.0", + "resolved": "https://registry.npmjs.org/@gitbeaker/core/-/core-43.8.0.tgz", + "integrity": "sha512-H+LfKuf4dExBinb79c+CXViRBvTVQNf5BYLNSizm2SiqdED5JruhKX88payefleY0szp7G/mySlFSXPyGRH1dQ==", + "dependencies": { + "@gitbeaker/requester-utils": "^43.8.0", + "qs": "^6.14.0", + "xcase": "^2.0.1" + }, + "engines": { + "node": ">=18.20.0" + } + }, + "node_modules/@gitbeaker/requester-utils": { + "version": "43.8.0", + "resolved": "https://registry.npmjs.org/@gitbeaker/requester-utils/-/requester-utils-43.8.0.tgz", + "integrity": "sha512-d/SiJdxijc+aH5ZBQOw83XLxNSXqsBZNm5k3nPu1EHxGxK0fajXmxdMl0/vNXbKRggnIquFCxURkrQSEzfjqxQ==", + "dependencies": { + "picomatch-browser": "^2.2.6", + "qs": "^6.14.0", + "rate-limiter-flexible": "^8.0.1", + "xcase": "^2.0.1" + }, + "engines": { + "node": ">=18.20.0" + } + }, + "node_modules/@gitbeaker/rest": { + "version": "43.8.0", + "resolved": "https://registry.npmjs.org/@gitbeaker/rest/-/rest-43.8.0.tgz", + "integrity": "sha512-xxqsNsUXaFang9b2e/NTIgqUeuUlifA2Opy1mOVqTDuJZZNIOTgUNyziwBJoleBhMC0XuvY3JNVMWthufcVjRw==", + "dependencies": { + "@gitbeaker/core": "^43.8.0", + "@gitbeaker/requester-utils": "^43.8.0" + }, + "engines": { + "node": ">=18.20.0" + } + }, "node_modules/@google/genai": { "version": "1.44.0", "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.44.0.tgz", @@ -9288,6 +9328,17 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/picomatch-browser": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/picomatch-browser/-/picomatch-browser-2.2.6.tgz", + "integrity": "sha512-0ypsOQt9D4e3hziV8O4elD9uN0z/jtUEfxVRtNaAAtXIyUx9m/SzlO020i8YNL2aL/E6blOvvHQcin6HZlFy/w==", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/pkce-challenge": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", @@ -9507,6 +9558,11 @@ "node": ">= 0.6" } }, + "node_modules/rate-limiter-flexible": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/rate-limiter-flexible/-/rate-limiter-flexible-8.3.0.tgz", + "integrity": "sha512-mzwlfipDLlRinPgELqVDJetke6Snq26nL565m8nLWXIcWgosYSeNRgqwh7ZrZ4MfYs8CNfmLvR5SBVz3rISQsQ==" + }, "node_modules/raw-body": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", @@ -11089,6 +11145,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/xcase": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/xcase/-/xcase-2.0.1.tgz", + "integrity": "sha512-UmFXIPU+9Eg3E9m/728Bii0lAIuoc+6nbrNUKaRPJOFp91ih44qqGlWtxMB6kXFrRD6po+86ksHM5XHCfk6iPw==" + }, "node_modules/xml-name-validator": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", diff --git a/package.json b/package.json index abb447e9..5e7e518d 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,7 @@ "license": "MIT", "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.2.91", + "@gitbeaker/rest": "^43.8.0", "@hono/node-server": "^1.13.7", "@hono/trpc-server": "^0.4.2", "@llmist/cli": "^16.0.3", diff --git a/src/agents/definitions/contextSteps.ts b/src/agents/definitions/contextSteps.ts index 9c59e6b0..947bc786 100644 --- a/src/agents/definitions/contextSteps.ts +++ b/src/agents/definitions/contextSteps.ts @@ -5,6 +5,7 @@ * These are the building blocks composed by the YAML contextPipeline arrays. */ +import { getIntegrationProvider } from '../../db/repositories/credentialsRepository.js'; import { formatCheckStatus } from '../../gadgets/github/core/getPRChecks.js'; import { ListDirectory } from '../../gadgets/ListDirectory.js'; import { readWorkItem, readWorkItemWithMedia } from '../../gadgets/pm/core/readWorkItem.js'; @@ -17,6 +18,7 @@ import { saveTodos, } from '../../gadgets/todo/storage.js'; import { githubClient } from '../../github/client.js'; +import { gitlabClient } from '../../gitlab/client.js'; import { getJiraConfig, getTrelloConfig } from '../../pm/config.js'; import { getPMProviderOrNull, MAX_IMAGES_PER_WORK_ITEM } from '../../pm/index.js'; import { getSentryClient } from '../../sentry/client.js'; @@ -157,6 +159,24 @@ export async function fetchPRContextStep(params: FetchContextParams): Promise { const injections: ContextInjection[] = []; const { owner, repo } = parseRepoFullName(repoFullName); @@ -215,6 +235,102 @@ export async function fetchPRContextStep(params: FetchContextParams): Promise { + const injections: ContextInjection[] = []; + + params.logWriter('INFO', 'Fetching MR details and diff from GitLab', { + projectPath, + mrIid, + }); + + const mrDetails = await gitlabClient.getMR(projectPath, mrIid); + const mrDiff = await gitlabClient.getMRDiff(projectPath, mrIid); + + // Format MR details + const detailsFormatted = [ + `MR #${mrDetails.iid}: ${mrDetails.title}`, + `State: ${mrDetails.state}`, + `Author: ${mrDetails.author.username}`, + `Source: ${mrDetails.sourceBranch} → Target: ${mrDetails.targetBranch}`, + `URL: ${mrDetails.webUrl}`, + mrDetails.description ? `\nDescription:\n${mrDetails.description}` : '', + ] + .filter(Boolean) + .join('\n'); + + // Format diff + const diffFormatted = mrDiff + .map((f) => { + const status = f.newFile + ? 'added' + : f.deletedFile + ? 'removed' + : f.renamedFile + ? 'renamed' + : 'modified'; + return `--- ${f.oldPath}\n+++ ${f.newPath} (${status})\n${f.diff || '(binary or empty)'}`; + }) + .join('\n\n'); + + injections.push({ + toolName: 'GetMRDetails', + params: { comment: 'Pre-fetching MR details for review context', projectPath, mrIid }, + result: detailsFormatted, + description: 'Pre-fetched MR details', + }); + + injections.push({ + toolName: 'GetMRDiff', + params: { comment: 'Pre-fetching MR diff for code review', projectPath, mrIid }, + result: diffFormatted, + description: 'Pre-fetched MR diff', + }); + + // Read full contents of changed files from the local repo + const prDiffCompat = mrDiff.map((f) => ({ + filename: f.newPath, + status: (f.newFile + ? 'added' + : f.deletedFile + ? 'removed' + : f.renamedFile + ? 'renamed' + : 'modified') as + | 'added' + | 'removed' + | 'modified' + | 'renamed' + | 'copied' + | 'changed' + | 'unchanged', + additions: 0, + deletions: 0, + changes: 0, + patch: f.diff, + })); + params.logWriter('INFO', 'Reading MR file contents', { fileCount: mrDiff.length }); + const fileContents = await readPRFileContents(params.repoDir, prDiffCompat); + params.logWriter('INFO', 'File contents loaded', { + included: fileContents.included.length, + skipped: fileContents.skipped.length, + }); + + for (const file of fileContents.included) { + injections.push({ + toolName: 'ReadFile', + params: { comment: `Pre-fetching ${file.path} for review`, filePath: file.path }, + result: `path=${file.path}\n\n${file.content}`, + description: `Pre-fetched ${file.path}`, + }); + } + + return injections; +} + export async function fetchPRConversationStep( params: FetchContextParams, ): Promise { @@ -222,6 +338,16 @@ export async function fetchPRConversationStep( if (!repoFullName || !prNumber) { throw new Error('fetchPRConversationStep requires repoFullName and prNumber in input'); } + + // Check if the project uses GitLab + const scmProvider = params.project?.id + ? await getIntegrationProvider(params.project.id, 'scm') + : null; + + if (scmProvider === 'gitlab') { + return fetchGitLabMRConversationStep(params, repoFullName, prNumber); + } + const injections: ContextInjection[] = []; const { owner, repo } = parseRepoFullName(repoFullName); @@ -272,6 +398,43 @@ export async function fetchPRConversationStep( return injections; } +async function fetchGitLabMRConversationStep( + params: FetchContextParams, + projectPath: string, + mrIid: number, +): Promise { + const injections: ContextInjection[] = []; + + params.logWriter('INFO', 'Fetching MR conversation context from GitLab', { + projectPath, + mrIid, + }); + + const notes = await gitlabClient.getMRNotes(projectPath, mrIid); + + // Filter to non-system notes (user comments only) + const userNotes = notes.filter((n) => !n.system); + + const formatted = userNotes + .map( + (n) => `[${n.createdAt}] @${n.author.username}${n.resolved ? ' (resolved)' : ''}:\n${n.body}`, + ) + .join('\n\n---\n\n'); + + injections.push({ + toolName: 'GetMRNotes', + params: { + comment: 'Pre-fetching MR notes for conversation context', + projectPath, + mrIid, + }, + result: formatted || '(No comments on this MR)', + description: 'Pre-fetched MR notes', + }); + + return injections; +} + export async function prepopulateTodosStep( params: FetchContextParams, ): Promise { diff --git a/src/agents/definitions/resolve-conflicts.yaml b/src/agents/definitions/resolve-conflicts.yaml index 77fcbc7d..ddb53b32 100644 --- a/src/agents/definitions/resolve-conflicts.yaml +++ b/src/agents/definitions/resolve-conflicts.yaml @@ -29,7 +29,7 @@ triggers: label: PR Conflict Detected description: Trigger when a PR has merge conflicts with the base branch defaultEnabled: false - providers: [github] + providers: [github, gitlab] contextPipeline: [prContext, directoryListing, contextFiles, workItem] strategies: {} diff --git a/src/agents/definitions/respond-to-ci.yaml b/src/agents/definitions/respond-to-ci.yaml index 680ed114..ebbc4a8d 100644 --- a/src/agents/definitions/respond-to-ci.yaml +++ b/src/agents/definitions/respond-to-ci.yaml @@ -30,7 +30,7 @@ triggers: label: Check Suite Failure description: Trigger when CI checks fail defaultEnabled: false - providers: [github] + providers: [github, gitlab] contextPipeline: [prContext, directoryListing, contextFiles, workItem] strategies: {} diff --git a/src/agents/definitions/respond-to-pr-comment.yaml b/src/agents/definitions/respond-to-pr-comment.yaml index da1ac847..8ff60afd 100644 --- a/src/agents/definitions/respond-to-pr-comment.yaml +++ b/src/agents/definitions/respond-to-pr-comment.yaml @@ -28,7 +28,7 @@ triggers: label: PR Comment @mention description: Trigger when the implementer bot is @mentioned in a PR comment defaultEnabled: false - providers: [github] + providers: [github, gitlab] contextPipeline: [prContext, prConversation, directoryListing, contextFiles] strategies: diff --git a/src/agents/definitions/respond-to-review.yaml b/src/agents/definitions/respond-to-review.yaml index 2a3a6059..94d785c8 100644 --- a/src/agents/definitions/respond-to-review.yaml +++ b/src/agents/definitions/respond-to-review.yaml @@ -29,7 +29,7 @@ triggers: label: PR Review Submitted description: Trigger when a review with changes requested or comments is submitted defaultEnabled: false - providers: [github] + providers: [github, gitlab] contextPipeline: [prContext, prConversation, directoryListing, contextFiles] strategies: @@ -47,7 +47,7 @@ prompts: <%= it.commentBody %> --- - Carefully read each review comment and make the requested changes. Commit and push your changes when done. Use the ReplyToReviewComment tool to respond to individual review comments as you address them. Focus on surgical, targeted fixes unless the reviewer clearly asks for broader changes. + Carefully read each review comment and make the requested changes. Commit and push your changes when done. Use the comment reply tool (ReplyToReviewComment or PostMRNote, whichever is available) to respond to individual review comments as you address them. Focus on surgical, targeted fixes unless the reviewer clearly asks for broader changes. hooks: trailing: diff --git a/src/agents/definitions/review.yaml b/src/agents/definitions/review.yaml index e1135f63..86cc5a28 100644 --- a/src/agents/definitions/review.yaml +++ b/src/agents/definitions/review.yaml @@ -28,7 +28,7 @@ triggers: label: CI Passed description: Trigger review when CI checks pass on a PR defaultEnabled: false - providers: [github] + providers: [github, gitlab] parameters: - name: authorMode type: select @@ -41,13 +41,13 @@ triggers: label: On Review Requested description: Trigger review when a CASCADE persona is explicitly requested as reviewer defaultEnabled: false - providers: [github] + providers: [github, gitlab] contextPipeline: [prContext, contextFiles] - event: scm:pr-opened label: PR Opened description: Trigger review when a new PR is opened (without waiting for CI) defaultEnabled: false - providers: [github] + providers: [github, gitlab] parameters: - name: authorMode type: select @@ -62,7 +62,7 @@ prompts: taskPrompt: | Review PR #<%= it.prNumber %>. - Examine the code changes carefully and submit your review using CreatePRReview. + Examine the code changes carefully and submit your review using the review submission tool (CreatePRReview or CreateMRReview, whichever is available). hooks: trailing: diff --git a/src/agents/definitions/schema.ts b/src/agents/definitions/schema.ts index 8124bc20..dc357b8a 100644 --- a/src/agents/definitions/schema.ts +++ b/src/agents/definitions/schema.ts @@ -9,7 +9,7 @@ import { CAPABILITIES } from '../capabilities/registry.js'; export const IntegrationCategorySchema = z.enum(['pm', 'scm', 'alerting']); // Known providers for validation -export const KnownProviderSchema = z.enum(['trello', 'jira', 'github', 'sentry']); +export const KnownProviderSchema = z.enum(['trello', 'jira', 'github', 'gitlab', 'sentry']); // Trigger event format validation: {category}:{event-name} // Categories: pm, scm (integration-bound), alerting (monitoring), internal (orchestration chaining) diff --git a/src/agents/definitions/toolManifests.ts b/src/agents/definitions/toolManifests.ts index 1a109b74..4f83f6ce 100644 --- a/src/agents/definitions/toolManifests.ts +++ b/src/agents/definitions/toolManifests.ts @@ -10,6 +10,19 @@ import { replyToReviewCommentDef, updatePRCommentDef, } from '../../gadgets/github/definitions.js'; +import { + approveMRDef, + createMRDef, + createMRReviewDef, + getFailedPipelineJobsDef, + getMRDetailsDef, + getMRDiffDef, + getMRNotesDef, + getPipelineStatusDef, + mergeMRDef, + postMRNoteDef, + updateMRNoteDef, +} from '../../gadgets/gitlab/definitions.js'; import { addChecklistDef, createWorkItemDef, @@ -23,14 +36,11 @@ import { } from '../../gadgets/pm/definitions.js'; import { finishDef } from '../../gadgets/session/definitions.js'; import { generateToolManifest } from '../../gadgets/shared/manifestGenerator.js'; +import type { ToolDefinition } from '../../gadgets/shared/toolDefinition.js'; import type { ToolManifest } from '../contracts/index.js'; -/** - * All tool definitions in display order. - * PM tools → SCM tools → Session tools. - */ -const ALL_DEFINITIONS = [ - // PM tools +/** PM tool definitions (shared across all SCM providers). */ +const PM_DEFINITIONS: ToolDefinition[] = [ readWorkItemDef, postCommentDef, updateWorkItemDef, @@ -40,7 +50,10 @@ const ALL_DEFINITIONS = [ moveWorkItemDef, pmUpdateChecklistItemDef, pmDeleteChecklistItemDef, - // SCM tools +]; + +/** GitHub SCM tool definitions. */ +const GITHUB_SCM_DEFINITIONS: ToolDefinition[] = [ createPRDef, getPRDetailsDef, getPRDiffDef, @@ -51,14 +64,33 @@ const ALL_DEFINITIONS = [ replyToReviewCommentDef, createPRReviewDef, getCIRunLogsDef, - // Session tools - finishDef, ]; +/** GitLab SCM tool definitions. */ +const GITLAB_SCM_DEFINITIONS: ToolDefinition[] = [ + createMRDef, + getMRDetailsDef, + getMRDiffDef, + getMRNotesDef, + postMRNoteDef, + updateMRNoteDef, + createMRReviewDef, + approveMRDef, + getPipelineStatusDef, + getFailedPipelineJobsDef, + mergeMRDef, +]; + +/** Session tool definitions. */ +const SESSION_DEFINITIONS: ToolDefinition[] = [finishDef]; + /** * Get the CLI tool manifests for CASCADE-specific tools. - * These describe the tools available via cascade-tools CLI. + * Selects GitHub or GitLab SCM tools based on CASCADE_SCM_PROVIDER env var. */ export function getToolManifests(): ToolManifest[] { - return ALL_DEFINITIONS.map((def) => generateToolManifest(def)); + const scmProvider = process.env.CASCADE_SCM_PROVIDER; + const scmDefs = scmProvider === 'gitlab' ? GITLAB_SCM_DEFINITIONS : GITHUB_SCM_DEFINITIONS; + const allDefs = [...PM_DEFINITIONS, ...scmDefs, ...SESSION_DEFINITIONS]; + return allDefs.map((def) => generateToolManifest(def)); } diff --git a/src/api/routers/_shared/triggerTypes.ts b/src/api/routers/_shared/triggerTypes.ts index 1b8f1e23..dbf2ce1d 100644 --- a/src/api/routers/_shared/triggerTypes.ts +++ b/src/api/routers/_shared/triggerTypes.ts @@ -168,63 +168,63 @@ export const TRIGGER_REGISTRY: Record = { label: 'CI Passed', description: 'CI check suite passed', contextPipeline: ['prContext'], - providers: ['github'], + providers: ['github', 'gitlab'], }, { event: 'scm:check-suite-failure', label: 'CI Failed', description: 'CI check suite failed', contextPipeline: ['prContext'], - providers: ['github'], + providers: ['github', 'gitlab'], }, { event: 'scm:pr-review-submitted', label: 'PR Review Submitted', description: 'Review submitted on PR', contextPipeline: ['prContext', 'prConversation'], - providers: ['github'], + providers: ['github', 'gitlab'], }, { event: 'scm:review-requested', label: 'Review Requested', description: 'Review requested on PR', contextPipeline: ['prContext'], - providers: ['github'], + providers: ['github', 'gitlab'], }, { event: 'scm:pr-opened', label: 'PR Opened', description: 'PR opened', contextPipeline: ['prContext'], - providers: ['github'], + providers: ['github', 'gitlab'], }, { event: 'scm:pr-comment-mention', label: 'PR Comment Mention', description: 'Bot @mentioned in PR comment', contextPipeline: ['prContext', 'prConversation'], - providers: ['github'], + providers: ['github', 'gitlab'], }, { event: 'scm:pr-merged', label: 'PR Merged', description: 'PR merged to base branch', contextPipeline: ['prContext'], - providers: ['github'], + providers: ['github', 'gitlab'], }, { event: 'scm:pr-ready-to-merge', label: 'PR Ready to Merge', description: 'PR approved and CI passed', contextPipeline: ['prContext'], - providers: ['github'], + providers: ['github', 'gitlab'], }, { event: 'scm:pr-conflict-detected', label: 'PR Conflict Detected', description: 'PR has merge conflicts with the base branch', contextPipeline: ['prContext'], - providers: ['github'], + providers: ['github', 'gitlab'], }, ], internal: [ diff --git a/src/api/routers/webhooks.ts b/src/api/routers/webhooks.ts index 1aa412c4..5d39c263 100644 --- a/src/api/routers/webhooks.ts +++ b/src/api/routers/webhooks.ts @@ -6,6 +6,7 @@ import { resolveProjectContext, } from './webhooks/context.js'; import { githubCreateWebhook, githubDeleteWebhook, githubListWebhooks } from './webhooks/github.js'; +import { gitlabCreateWebhook, gitlabDeleteWebhook, gitlabListWebhooks } from './webhooks/gitlab.js'; import { jiraCreateWebhook, jiraDeleteWebhook, @@ -15,12 +16,13 @@ import { import { trelloCreateWebhook, trelloDeleteWebhook, trelloListWebhooks } from './webhooks/trello.js'; import type { GitHubWebhook, + GitLabWebhookInfo, JiraWebhookInfo, SentryWebhookInfo, TrelloWebhook, } from './webhooks/types.js'; -export type { GitHubWebhook, JiraWebhookInfo, SentryWebhookInfo, TrelloWebhook }; +export type { GitHubWebhook, GitLabWebhookInfo, JiraWebhookInfo, SentryWebhookInfo, TrelloWebhook }; export const webhooksRouter = router({ list: adminProcedure @@ -35,9 +37,10 @@ export const webhooksRouter = router({ const pctx = await resolveProjectContext(input.projectId, ctx.effectiveOrgId); applyOneTimeTokens(pctx, input.oneTimeTokens); - const [trelloResult, githubResult, jiraResult] = await Promise.allSettled([ + const [trelloResult, githubResult, gitlabResult, jiraResult] = await Promise.allSettled([ trelloListWebhooks(pctx), githubListWebhooks(pctx), + gitlabListWebhooks(pctx), jiraListWebhooks(pctx), ]); @@ -55,11 +58,13 @@ export const webhooksRouter = router({ return { trello: trelloResult.status === 'fulfilled' ? trelloResult.value : [], github: githubResult.status === 'fulfilled' ? githubResult.value : [], + gitlab: gitlabResult.status === 'fulfilled' ? gitlabResult.value : [], jira: jiraResult.status === 'fulfilled' ? jiraResult.value : [], sentry, errors: { trello: trelloResult.status === 'rejected' ? String(trelloResult.reason) : null, github: githubResult.status === 'rejected' ? String(githubResult.reason) : null, + gitlab: gitlabResult.status === 'rejected' ? String(gitlabResult.reason) : null, jira: jiraResult.status === 'rejected' ? String(jiraResult.reason) : null, }, }; @@ -72,6 +77,7 @@ export const webhooksRouter = router({ callbackBaseUrl: z.string().url(), trelloOnly: z.boolean().optional(), githubOnly: z.boolean().optional(), + gitlabOnly: z.boolean().optional(), jiraOnly: z.boolean().optional(), oneTimeTokens: oneTimeTokensSchema, }), @@ -83,6 +89,7 @@ export const webhooksRouter = router({ const results: { trello?: TrelloWebhook | string; github?: GitHubWebhook | string; + gitlab?: GitLabWebhookInfo | string; jira?: JiraWebhookInfo | string; sentry?: SentryWebhookInfo; labelsEnsured?: string[]; @@ -134,8 +141,27 @@ export const webhooksRouter = router({ results.labelsEnsured = await jiraEnsureLabels(pctx); } + // GitLab webhook + if ( + !input.trelloOnly && + !input.githubOnly && + !input.jiraOnly && + pctx.scmProvider === 'gitlab' && + pctx.gitlabToken + ) { + const gitlabCallbackUrl = `${baseUrl}/gitlab/webhook`; + const existingGitlab = await gitlabListWebhooks(pctx); + const gitlabDuplicate = existingGitlab.find((w) => w.url === gitlabCallbackUrl); + + if (gitlabDuplicate) { + results.gitlab = `Already exists: ${gitlabDuplicate.id}`; + } else { + results.gitlab = await gitlabCreateWebhook(pctx, gitlabCallbackUrl); + } + } + // GitHub webhook - if (!input.trelloOnly && !input.jiraOnly && pctx.githubToken) { + if (!input.trelloOnly && !input.gitlabOnly && !input.jiraOnly && pctx.githubToken) { const githubCallbackUrl = `${baseUrl}/github/webhook`; const existing = await githubListWebhooks(pctx); const duplicate = existing.find( @@ -168,6 +194,7 @@ export const webhooksRouter = router({ callbackBaseUrl: z.string().url(), trelloOnly: z.boolean().optional(), githubOnly: z.boolean().optional(), + gitlabOnly: z.boolean().optional(), jiraOnly: z.boolean().optional(), oneTimeTokens: oneTimeTokensSchema, }), @@ -176,9 +203,10 @@ export const webhooksRouter = router({ const pctx = await resolveProjectContext(input.projectId, ctx.effectiveOrgId); applyOneTimeTokens(pctx, input.oneTimeTokens); const baseUrl = input.callbackBaseUrl.replace(/\/$/, ''); - const deleted: { trello: string[]; github: number[]; jira: number[] } = { + const deleted: { trello: string[]; github: number[]; gitlab: number[]; jira: number[] } = { trello: [], github: [], + gitlab: [], jira: [], }; @@ -210,7 +238,7 @@ export const webhooksRouter = router({ } // GitHub - if (!input.trelloOnly && !input.jiraOnly && pctx.githubToken) { + if (!input.trelloOnly && !input.gitlabOnly && !input.jiraOnly && pctx.githubToken) { const githubCallbackUrl = `${baseUrl}/github/webhook`; const existing = await githubListWebhooks(pctx); const matching = existing.filter( @@ -222,6 +250,25 @@ export const webhooksRouter = router({ } } + // GitLab + if ( + !input.trelloOnly && + !input.githubOnly && + !input.jiraOnly && + pctx.scmProvider === 'gitlab' && + pctx.gitlabToken + ) { + const gitlabCallbackUrl = `${baseUrl}/gitlab/webhook`; + const existingGitlab = await gitlabListWebhooks(pctx); + const matchingGitlab = existingGitlab.filter( + (w) => w.url === gitlabCallbackUrl || w.url === `${baseUrl}/webhook/gitlab`, + ); + for (const w of matchingGitlab) { + await gitlabDeleteWebhook(pctx, w.id); + deleted.gitlab.push(w.id); + } + } + return deleted; }), }); diff --git a/src/api/routers/webhooks/context.ts b/src/api/routers/webhooks/context.ts index 0d3a8636..74ec6c51 100644 --- a/src/api/routers/webhooks/context.ts +++ b/src/api/routers/webhooks/context.ts @@ -38,11 +38,26 @@ export async function resolveProjectContext( const alertingIntegration = await getIntegrationByProjectAndCategory(projectId, 'alerting'); const sentryConfigured = alertingIntegration?.provider === 'sentry' && !!creds.SENTRY_API_TOKEN; + // Determine SCM provider from integration config + const scmIntegration = await getIntegrationByProjectAndCategory(projectId, 'scm'); + const scmProvider = (scmIntegration?.provider === 'gitlab' ? 'gitlab' : 'github') as + | 'github' + | 'gitlab'; + + // Resolve GitLab host: integration config → GITLAB_HOST env var → default + const scmConfig = (scmIntegration?.config ?? {}) as Record; + const gitlabHost = + (scmConfig.host as string | undefined) ?? + (process.env.GITLAB_HOST + ? `https://${process.env.GITLAB_HOST.replace(/^https?:\/\//, '')}` + : undefined); + return { projectId, orgId: project.orgId, repo: project.repo, pmType: project.pm?.type ?? 'trello', + scmProvider, boardId: trelloConfig?.boardId, jiraBaseUrl: jiraConfig?.baseUrl, jiraProjectKey: jiraConfig?.projectKey, @@ -50,6 +65,9 @@ export async function resolveProjectContext( trelloApiKey: creds.TRELLO_API_KEY ?? '', trelloToken: creds.TRELLO_TOKEN ?? '', githubToken: creds.GITHUB_TOKEN_IMPLEMENTER ?? '', + gitlabToken: creds.GITLAB_TOKEN_IMPLEMENTER ?? '', + gitlabHost, + gitlabWebhookSecret: creds.GITLAB_WEBHOOK_SECRET ?? undefined, jiraEmail: creds.JIRA_EMAIL ?? '', jiraApiToken: creds.JIRA_API_TOKEN ?? '', webhookSecret: creds.GITHUB_WEBHOOK_SECRET ?? undefined, diff --git a/src/api/routers/webhooks/gitlab.ts b/src/api/routers/webhooks/gitlab.ts new file mode 100644 index 00000000..64c2d957 --- /dev/null +++ b/src/api/routers/webhooks/gitlab.ts @@ -0,0 +1,164 @@ +/** + * GitLab webhook CRUD operations. + * + * Uses raw `fetch()` with the PRIVATE-TOKEN header — same pattern as the + * router platform client (`src/router/platformClients/gitlab.ts`). + */ + +import { logger } from '../../../utils/logging.js'; +import type { GitLabWebhookInfo, ProjectContext } from './types.js'; + +/** Events CASCADE needs from GitLab webhooks. */ +const GITLAB_WEBHOOK_EVENTS = { + push_events: true, + merge_requests_events: true, + pipeline_events: true, + note_events: true, +} as const; + +function getGitLabApiBase(ctx: ProjectContext): string { + const host = ctx.gitlabHost ?? 'https://gitlab.com'; + return `${host.replace(/\/$/, '')}/api/v4`; +} + +function encodedProjectPath(ctx: ProjectContext): string { + if (!ctx.repo) { + throw new Error('Cannot manage GitLab webhooks: no repo (project path) configured'); + } + return encodeURIComponent(ctx.repo); +} + +function headers(ctx: ProjectContext): Record { + return { + 'PRIVATE-TOKEN': ctx.gitlabToken, + 'Content-Type': 'application/json', + }; +} + +interface GitLabHookApiResponse { + id: number; + url: string; + enable_ssl_verification: boolean; + push_events: boolean; + merge_requests_events: boolean; + pipeline_events: boolean; + note_events: boolean; + [key: string]: unknown; +} + +function mapHook(hook: GitLabHookApiResponse): GitLabWebhookInfo { + return { + id: hook.id, + url: hook.url, + enableSslVerification: hook.enable_ssl_verification, + pushEvents: hook.push_events, + mergeRequestsEvents: hook.merge_requests_events, + pipelineEvents: hook.pipeline_events, + noteEvents: hook.note_events, + }; +} + +export async function gitlabListWebhooks(ctx: ProjectContext): Promise { + if (!ctx.gitlabToken) return []; + if (!ctx.repo) return []; + + const base = getGitLabApiBase(ctx); + const path = encodedProjectPath(ctx); + const url = `${base}/projects/${path}/hooks`; + + const response = await fetch(url, { + method: 'GET', + headers: headers(ctx), + }); + + if (!response.ok) { + const body = await response.text(); + logger.warn('[GitLabWebhook] Failed to list webhooks', { + status: response.status, + body, + projectId: ctx.projectId, + }); + throw new Error(`GitLab API returned ${response.status}: ${body}`); + } + + const data = (await response.json()) as GitLabHookApiResponse[]; + return data.map(mapHook); +} + +export async function gitlabCreateWebhook( + ctx: ProjectContext, + callbackURL: string, +): Promise { + if (!ctx.repo) { + throw new Error('Cannot create GitLab webhook: no repo (project path) configured'); + } + + // Delete any existing webhooks with the same callback URL to prevent duplicates. + const existingWebhooks = await gitlabListWebhooks(ctx); + for (const webhook of existingWebhooks) { + if (webhook.url === callbackURL) { + try { + await gitlabDeleteWebhook(ctx, webhook.id); + logger.info('[GitLabWebhook] Deleted existing webhook to prevent duplicates', { + webhookId: webhook.id, + projectId: ctx.projectId, + repo: ctx.repo, + }); + } catch (err) { + logger.warn('[GitLabWebhook] Failed to delete existing webhook (continuing)', { + webhookId: webhook.id, + projectId: ctx.projectId, + error: String(err), + }); + } + } + } + + const base = getGitLabApiBase(ctx); + const path = encodedProjectPath(ctx); + const url = `${base}/projects/${path}/hooks`; + + const body: Record = { + url: callbackURL, + ...GITLAB_WEBHOOK_EVENTS, + }; + + // GitLab sends the token as X-Gitlab-Token header on each delivery + if (ctx.gitlabWebhookSecret) { + body.token = ctx.gitlabWebhookSecret; + } + + const response = await fetch(url, { + method: 'POST', + headers: headers(ctx), + body: JSON.stringify(body), + }); + + if (!response.ok) { + const respBody = await response.text(); + throw new Error(`GitLab API returned ${response.status}: ${respBody}`); + } + + const data = (await response.json()) as GitLabHookApiResponse; + return mapHook(data); +} + +export async function gitlabDeleteWebhook(ctx: ProjectContext, hookId: number): Promise { + if (!ctx.repo) { + throw new Error('Cannot delete GitLab webhook: no repo (project path) configured'); + } + + const base = getGitLabApiBase(ctx); + const path = encodedProjectPath(ctx); + const url = `${base}/projects/${path}/hooks/${hookId}`; + + const response = await fetch(url, { + method: 'DELETE', + headers: { 'PRIVATE-TOKEN': ctx.gitlabToken }, + }); + + if (!response.ok) { + const body = await response.text(); + throw new Error(`GitLab API returned ${response.status}: ${body}`); + } +} diff --git a/src/api/routers/webhooks/types.ts b/src/api/routers/webhooks/types.ts index 323b3ccb..a842c0e8 100644 --- a/src/api/routers/webhooks/types.ts +++ b/src/api/routers/webhooks/types.ts @@ -26,6 +26,16 @@ export interface JiraWebhookInfo { enabled: boolean; } +export interface GitLabWebhookInfo { + id: number; + url: string; + enableSslVerification: boolean; + pushEvents: boolean; + mergeRequestsEvents: boolean; + pipelineEvents: boolean; + noteEvents: boolean; +} + export interface SentryWebhookInfo { url: string; webhookSecretSet: boolean; @@ -37,6 +47,7 @@ export interface ProjectContext { orgId: string; repo?: string; pmType: 'trello' | 'jira'; + scmProvider: 'github' | 'gitlab'; boardId?: string; jiraBaseUrl?: string; jiraProjectKey?: string; @@ -44,6 +55,9 @@ export interface ProjectContext { trelloApiKey: string; trelloToken: string; githubToken: string; + gitlabToken: string; + gitlabHost?: string; + gitlabWebhookSecret?: string; jiraEmail?: string; jiraApiToken?: string; webhookSecret?: string; diff --git a/src/backends/claude-code/hooks.ts b/src/backends/claude-code/hooks.ts index c14e98b4..de3671e0 100644 --- a/src/backends/claude-code/hooks.ts +++ b/src/backends/claude-code/hooks.ts @@ -30,6 +30,16 @@ const BLOCKED_PATTERNS: Array<{ pattern: RegExp; reason: string }> = [ reason: 'Do not use `git push` directly. Use `cascade-tools scm create-pr` instead — it handles push atomically.', }, + { + pattern: /\bglab\s+mr\s+create\b/, + reason: + 'Do not use `glab mr create`. Use `cascade-tools scm create-pr` instead — it pushes the branch and creates the MR atomically.', + }, + { + pattern: /\bglab\s+mr\s+merge\b/, + reason: + 'Do not use `glab mr merge`. Merging is managed externally. Use `cascade-tools scm create-pr` to create MRs.', + }, ]; /** diff --git a/src/backends/claude-code/index.ts b/src/backends/claude-code/index.ts index 64bf1600..b75e227c 100644 --- a/src/backends/claude-code/index.ts +++ b/src/backends/claude-code/index.ts @@ -219,7 +219,8 @@ export class ClaudeCodeEngine extends NativeToolEngine { async execute(input: AgentExecutionPlan): Promise { const startTime = Date.now(); - const systemPrompt = buildSystemPrompt(input.systemPrompt, input.availableTools); + const scmProvider = process.env.CASCADE_SCM_PROVIDER; + const systemPrompt = buildSystemPrompt(input.systemPrompt, input.availableTools, scmProvider); // Collect supported images for native SDK delivery; strip from injections so // offloadLargeContext does not also write them to disk (redundant for this engine). diff --git a/src/backends/codex/index.ts b/src/backends/codex/index.ts index ed6d194a..42fdf5ce 100644 --- a/src/backends/codex/index.ts +++ b/src/backends/codex/index.ts @@ -425,7 +425,11 @@ export class CodexEngine extends NativeToolEngine { async execute(input: AgentExecutionPlan): Promise { const startTime = Date.now(); - const systemPrompt = buildSystemPrompt(input.systemPrompt, input.availableTools); + const systemPrompt = buildSystemPrompt( + input.systemPrompt, + input.availableTools, + process.env.CASCADE_SCM_PROVIDER, + ); const { prompt: taskPrompt, hasOffloadedContext } = await buildTaskPrompt( input.taskPrompt, input.contextInjections, diff --git a/src/backends/opencode/index.ts b/src/backends/opencode/index.ts index 4bfefc4f..7716e569 100644 --- a/src/backends/opencode/index.ts +++ b/src/backends/opencode/index.ts @@ -164,7 +164,11 @@ async function promptOpenCodeSession( path: { id: sessionId }, body: { agent, - system: buildSystemPrompt(input.systemPrompt, input.availableTools), + system: buildSystemPrompt( + input.systemPrompt, + input.availableTools, + process.env.CASCADE_SCM_PROVIDER, + ), parts: buildPromptParts(promptText), }, throwOnError: true, diff --git a/src/backends/shared/nativeToolPrompts.ts b/src/backends/shared/nativeToolPrompts.ts index f3283c2c..c70d302f 100644 --- a/src/backends/shared/nativeToolPrompts.ts +++ b/src/backends/shared/nativeToolPrompts.ts @@ -12,7 +12,7 @@ You are operating in a native-tool environment, not a gadget/function-call envir - use the shell tool for all \`cascade-tools ...\`, \`git ...\`, \`rg ...\`, \`fd ...\`, test, lint, and build commands - When the task instructions mention gadget names like \`CreatePR\`, \`PostComment\`, \`UpdateChecklistItem\`, \`Finish\`, \`ReadWorkItem\`, \`TodoUpsert\`, or \`TodoUpdateStatus\`, treat that as a request to run the equivalent real command or tool action, not to print the gadget name. - If you catch yourself composing a pseudo tool call in plain text, stop and use the real tool instead. -- Trello, JIRA, and GitHub attachment URLs require backend authentication. NEVER curl, wget, or HTTP-fetch them — they return an authorization error. Work item images are pre-fetched and available either as images in your conversation context or as files under \`.cascade/context/images/\` — use whichever is present; never fetch the original URLs.`; +- Trello, JIRA, GitHub, and GitLab attachment URLs require backend authentication. NEVER curl, wget, or HTTP-fetch them — they return an authorization error. Work item images are pre-fetched and available either as images in your conversation context or as files under \`.cascade/context/images/\` — use whichever is present; never fetch the original URLs.`; /** * Format a single CLI parameter for tool guidance documentation. @@ -42,17 +42,23 @@ function formatParam( * Build prompt guidance for CASCADE-specific CLI tools. * Native-tool engines invoke these via shell commands. */ -export function buildToolGuidance(tools: ToolManifest[]): string { +export function buildToolGuidance(tools: ToolManifest[], scmProvider?: string): string { if (tools.length === 0) return ''; let guidance = '## CASCADE Tools\n\n'; guidance += 'Use the shell tool to invoke these CASCADE-specific commands.\n'; guidance += 'All commands output JSON. Parse the output to extract results.\n\n'; guidance += - '**CRITICAL**: You MUST use these cascade-tools commands for all PM (Trello/JIRA), SCM (GitHub), and session operations. ' + - 'Do NOT use `gh` CLI or other tools directly — native-tool engine runs block `gh`, and cascade-tools handle authentication, push, and ' + + '**CRITICAL**: You MUST use these cascade-tools commands for all PM (Trello/JIRA), SCM (GitHub/GitLab), and session operations. ' + + 'Do NOT use `gh` or `glab` CLI directly — cascade-tools handle authentication, push, and ' + 'state tracking that raw CLI tools do not. For example, `cascade-tools scm create-pr` pushes ' + - 'the branch AND creates the PR atomically.\n\n'; + 'the branch AND creates the PR/MR atomically. The SCM provider (GitHub or GitLab) is auto-detected.\n\n'; + + if (scmProvider === 'gitlab') { + guidance += + '**GitLab mode**: This project uses GitLab. The `--owner` and `--repo` flags are auto-resolved from the git remote — you do not need to specify them. ' + + 'PR-related tools work with GitLab Merge Requests (MRs). `prNumber` maps to MR IID.\n\n'; + } for (const tool of tools) { guidance += `### ${tool.name}\n`; @@ -107,8 +113,12 @@ export async function buildTaskPrompt( /** * Build the system prompt by combining CASCADE's agent prompt with tool guidance. */ -export function buildSystemPrompt(systemPrompt: string, tools: ToolManifest[]): string { - const toolGuidance = buildToolGuidance(tools); +export function buildSystemPrompt( + systemPrompt: string, + tools: ToolManifest[], + scmProvider?: string, +): string { + const toolGuidance = buildToolGuidance(tools, scmProvider); const promptWithRules = `${NATIVE_TOOL_EXECUTION_RULES}\n\n${systemPrompt}`; return toolGuidance ? `${promptWithRules}\n\n${toolGuidance}` : promptWithRules; } diff --git a/src/cli/base.ts b/src/cli/base.ts index 9c0cb5da..ebc59b93 100644 --- a/src/cli/base.ts +++ b/src/cli/base.ts @@ -1,14 +1,32 @@ import { execFileSync } from 'node:child_process'; import { Command } from '@oclif/core'; +// Bootstrap integrations so PM/SCM registries are populated before commands run. +// This is a no-op if already bootstrapped (idempotent guards inside). +import '../integrations/bootstrap.js'; import { withGitHubToken } from '../github/client.js'; +import { withGitLabToken } from '../gitlab/client.js'; import { withJiraCredentials } from '../jira/client.js'; import { createPMProvider, withPMProvider } from '../pm/index.js'; import { withTrelloCredentials } from '../trello/client.js'; import type { ProjectConfig } from '../types/index.js'; +/** + * Detect the active SCM provider based on environment variables. + * Checks CASCADE_SCM_PROVIDER (explicit, set by router) first, + * then falls back to credential inference. + */ +export function detectSCMProvider(): 'github' | 'gitlab' { + const explicit = process.env.CASCADE_SCM_PROVIDER; + if (explicit === 'gitlab') return 'gitlab'; + if (explicit === 'github') return 'github'; + if (process.env.GITLAB_TOKEN_IMPLEMENTER) return 'gitlab'; + return 'github'; +} + /** * Resolve repository owner/repo from flags, env vars, or git remote (in that order). + * Supports both GitHub (owner/repo) and GitLab (group/subgroup/repo) patterns. */ export function resolveOwnerRepo( flagOwner?: string, @@ -20,19 +38,42 @@ export function resolveOwnerRepo( const envRepo = process.env.CASCADE_REPO_NAME; if (envOwner && envRepo) return { owner: envOwner, repo: envRepo }; - // Fallback: detect from git remote (same as create-pr) + // Fallback: detect from git remote const url = execFileSync('git', ['remote', 'get-url', 'origin'], { encoding: 'utf-8' }).trim(); + + // Try GitLab pattern first (gitlab.com or custom host) + const gitlabMatch = url.match(/gitlab[^/]*[/:](.+?)\/([^/]+?)(?:\.git)?$/); + if (gitlabMatch) return { owner: gitlabMatch[1], repo: gitlabMatch[2] }; + + // GitHub pattern const match = url.match(/github\.com[/:]([^/]+)\/(.+?)(?:\.git)?$/); if (!match) throw new Error(`Cannot detect owner/repo from git remote: ${url}`); return { owner: match[1], repo: match[2] }; } +/** + * Resolve the full project path from git remote for GitLab. + * GitLab uses path_with_namespace (e.g. "group/subgroup/repo"). + */ +export function resolveProjectPath(): string { + const url = execFileSync('git', ['remote', 'get-url', 'origin'], { encoding: 'utf-8' }).trim(); + // SSH: git@gitlab.com:appsome/bdgt.git → appsome/bdgt + const sshMatch = url.match(/@[^:]+:(.+?)(?:\.git)?$/); + if (sshMatch) return sshMatch[1]; + // HTTPS: https://oauth2:token@gitlab.com/appsome/bdgt.git → appsome/bdgt + // Match path after the host (after ://...host/) + const httpsMatch = url.match(/https?:\/\/[^/]+\/(.+?)(?:\.git)?$/); + if (httpsMatch) return httpsMatch[1]; + throw new Error(`Cannot detect project path from git remote: ${url}`); +} + export abstract class CredentialScopedCommand extends Command { /** Subclasses implement this instead of run() */ abstract execute(): Promise; async run(): Promise { - const githubToken = process.env.GITHUB_TOKEN; + const githubToken = process.env.GITHUB_TOKEN || process.env.GITHUB_TOKEN_IMPLEMENTER; + const gitlabToken = process.env.GITLAB_TOKEN_IMPLEMENTER; const trelloApiKey = process.env.TRELLO_API_KEY; const trelloToken = process.env.TRELLO_TOKEN; const jiraEmail = process.env.JIRA_EMAIL; @@ -41,6 +82,11 @@ export abstract class CredentialScopedCommand extends Command { let fn: () => Promise = () => this.execute(); + if (gitlabToken) { + const prev = fn; + const host = process.env.GITLAB_HOST ?? 'https://gitlab.com'; + fn = () => withGitLabToken(gitlabToken, prev, host); + } if (githubToken) { const prev = fn; fn = () => withGitHubToken(githubToken, prev); diff --git a/src/cli/dashboard/webhooks/create.ts b/src/cli/dashboard/webhooks/create.ts index fca76db1..e7fc7235 100644 --- a/src/cli/dashboard/webhooks/create.ts +++ b/src/cli/dashboard/webhooks/create.ts @@ -15,6 +15,7 @@ export default class WebhooksCreate extends DashboardCommand { }), 'trello-only': Flags.boolean({ description: 'Only create Trello webhook', default: false }), 'github-only': Flags.boolean({ description: 'Only create GitHub webhook', default: false }), + 'gitlab-only': Flags.boolean({ description: 'Only create GitLab webhook', default: false }), 'github-token': Flags.string({ description: 'One-time GitHub PAT with admin:repo_hook scope', }), @@ -44,6 +45,7 @@ export default class WebhooksCreate extends DashboardCommand { callbackBaseUrl, trelloOnly: flags['trello-only'], githubOnly: flags['github-only'], + gitlabOnly: flags['gitlab-only'], oneTimeTokens: Object.keys(oneTimeTokens).length > 0 ? oneTimeTokens : undefined, }), ); @@ -71,6 +73,14 @@ export default class WebhooksCreate extends DashboardCommand { } } + if (result.gitlab) { + if (typeof result.gitlab === 'string') { + this.log(`GitLab: ${result.gitlab}`); + } else { + this.success(`Created GitLab webhook: [${result.gitlab.id}] ${result.gitlab.url}`); + } + } + if (result.jira) { if (typeof result.jira === 'string') { this.log(`JIRA: ${result.jira}`); diff --git a/src/cli/dashboard/webhooks/delete.ts b/src/cli/dashboard/webhooks/delete.ts index dfc64909..44c3debc 100644 --- a/src/cli/dashboard/webhooks/delete.ts +++ b/src/cli/dashboard/webhooks/delete.ts @@ -15,6 +15,7 @@ export default class WebhooksDelete extends DashboardCommand { }), 'trello-only': Flags.boolean({ description: 'Only delete Trello webhooks', default: false }), 'github-only': Flags.boolean({ description: 'Only delete GitHub webhooks', default: false }), + 'gitlab-only': Flags.boolean({ description: 'Only delete GitLab webhooks', default: false }), 'github-token': Flags.string({ description: 'One-time GitHub PAT with admin:repo_hook scope', }), @@ -43,6 +44,7 @@ export default class WebhooksDelete extends DashboardCommand { callbackBaseUrl, trelloOnly: flags['trello-only'], githubOnly: flags['github-only'], + gitlabOnly: flags['gitlab-only'], oneTimeTokens: Object.keys(oneTimeTokens).length > 0 ? oneTimeTokens : undefined, }), ); @@ -68,6 +70,14 @@ export default class WebhooksDelete extends DashboardCommand { this.log('No matching GitHub webhooks found.'); } + if (result.gitlab && result.gitlab.length > 0) { + this.success( + `Deleted ${result.gitlab.length} GitLab webhook(s): ${result.gitlab.join(', ')}`, + ); + } else { + this.log('No matching GitLab webhooks found.'); + } + if (result.jira.length > 0) { this.success(`Deleted ${result.jira.length} JIRA webhook(s): ${result.jira.join(', ')}`); } else { diff --git a/src/cli/dashboard/webhooks/list.ts b/src/cli/dashboard/webhooks/list.ts index 495f90cc..f28be15d 100644 --- a/src/cli/dashboard/webhooks/list.ts +++ b/src/cli/dashboard/webhooks/list.ts @@ -2,7 +2,7 @@ import { Args, Flags } from '@oclif/core'; import { DashboardCommand } from '../_shared/base.js'; export default class WebhooksList extends DashboardCommand { - static override description = 'List Trello, GitHub, and JIRA webhooks for a project.'; + static override description = 'List Trello, GitHub, GitLab, and JIRA webhooks for a project.'; static override args = { projectId: Args.string({ description: 'Project ID', required: true }), @@ -73,6 +73,16 @@ export default class WebhooksList extends DashboardCommand { } } + this.log(''); + this.log('GitLab webhooks:'); + if (!result.gitlab || result.gitlab.length === 0) { + this.log(' (none)'); + } else { + for (const w of result.gitlab) { + this.log(` [${w.id}] ${w.url} (active: ${w.enableSslVerification !== false})`); + } + } + this.log(''); this.log('JIRA webhooks:'); if (result.jira.length === 0) { diff --git a/src/cli/scm/create-pr-review.ts b/src/cli/scm/create-pr-review.ts index 07686761..8a6b5359 100644 --- a/src/cli/scm/create-pr-review.ts +++ b/src/cli/scm/create-pr-review.ts @@ -1,16 +1,19 @@ import { GITHUB_ACK_COMMENT_ID_ENV_VAR } from '../../backends/secretBuilder.js'; import { createPRReview } from '../../gadgets/github/core/createPRReview.js'; import { createPRReviewDef } from '../../gadgets/github/definitions.js'; +import { createMRReview } from '../../gadgets/gitlab/core/createMRReview.js'; import { writeReviewSidecar } from '../../gadgets/session/core/sidecar.js'; import { REVIEW_SIDECAR_ENV_VAR } from '../../gadgets/sessionState.js'; import { createCLICommand } from '../../gadgets/shared/cliCommandFactory.js'; import { githubClient } from '../../github/client.js'; +import { gitlabClient } from '../../gitlab/client.js'; +import { detectSCMProvider, resolveProjectPath } from '../base.js'; /** * Delete the GitHub ack/progress comment (best-effort). * Returns true if the comment was successfully deleted. */ -async function deleteAckComment(owner: string, repo: string): Promise { +async function deleteGitHubAckComment(owner: string, repo: string): Promise { const ackCommentIdStr = process.env[GITHUB_ACK_COMMENT_ID_ENV_VAR]; if (!ackCommentIdStr) return false; @@ -25,7 +28,51 @@ async function deleteAckComment(owner: string, repo: string): Promise { } } +/** + * Delete the GitLab ack/progress note (best-effort). + * Returns true if the note was successfully deleted. + */ +async function deleteGitLabAckNote(projectPath: string, mrIid: number): Promise { + const ackNoteIdStr = process.env[GITHUB_ACK_COMMENT_ID_ENV_VAR]; + if (!ackNoteIdStr) return false; + + const ackNoteId = Number(ackNoteIdStr); + if (!Number.isFinite(ackNoteId) || ackNoteId <= 0) return false; + + try { + await gitlabClient.deleteMRNote(projectPath, mrIid, ackNoteId); + return true; + } catch { + return false; + } +} + export default createCLICommand(createPRReviewDef, async (params) => { + if (detectSCMProvider() === 'gitlab') { + const projectPath = resolveProjectPath(); + const mrIid = params.prNumber as number; + + const result = await createMRReview({ + projectPath, + mrIid, + event: params.event as 'APPROVE' | 'REQUEST_CHANGES' | 'COMMENT', + body: params.body as string, + }); + + // Delete ack note (best-effort) + const ackCommentDeleted = await deleteGitLabAckNote(projectPath, mrIid); + + writeReviewSidecar( + process.env[REVIEW_SIDECAR_ENV_VAR], + `${projectPath}!${mrIid}`, + params.event as string, + params.body as string, + ackCommentDeleted, + ); + + return result; + } + const result = await createPRReview({ owner: params.owner as string, repo: params.repo as string, @@ -36,7 +83,10 @@ export default createCLICommand(createPRReviewDef, async (params) => { }); // Delete ack comment (best-effort) - const ackCommentDeleted = await deleteAckComment(params.owner as string, params.repo as string); + const ackCommentDeleted = await deleteGitHubAckComment( + params.owner as string, + params.repo as string, + ); writeReviewSidecar( process.env[REVIEW_SIDECAR_ENV_VAR], diff --git a/src/cli/scm/create-pr.ts b/src/cli/scm/create-pr.ts index d18f6bc6..4ff1bee1 100644 --- a/src/cli/scm/create-pr.ts +++ b/src/cli/scm/create-pr.ts @@ -1,14 +1,40 @@ import { createPR } from '../../gadgets/github/core/createPR.js'; import { createPRDef } from '../../gadgets/github/definitions.js'; +import { createMR } from '../../gadgets/gitlab/core/createMR.js'; import { writePRSidecar } from '../../gadgets/session/core/sidecar.js'; import { PR_SIDECAR_ENV_VAR } from '../../gadgets/sessionState.js'; import { createCLICommand } from '../../gadgets/shared/cliCommandFactory.js'; +import { detectSCMProvider } from '../base.js'; export default createCLICommand(createPRDef, async (params) => { const base = params.base as string | undefined; if (!base) { throw new Error('--base is required (or set CASCADE_BASE_BRANCH env var)'); } + + if (detectSCMProvider() === 'gitlab') { + const result = await createMR({ + title: params.title as string, + body: params.body as string, + head: params.head as string, + base, + draft: params.draft as boolean | undefined, + commit: params.commit as boolean | undefined, + commitMessage: params.commitMessage as string | undefined, + push: params.push as boolean | undefined, + }); + + writePRSidecar( + process.env[PR_SIDECAR_ENV_VAR], + result.mrUrl, + result.mrIid, + result.alreadyExisted, + result.projectPath, + ); + + return result; + } + const result = await createPR({ title: params.title as string, body: params.body as string, diff --git a/src/cli/scm/get-ci-run-logs.ts b/src/cli/scm/get-ci-run-logs.ts index d053f1ac..31dceedf 100644 --- a/src/cli/scm/get-ci-run-logs.ts +++ b/src/cli/scm/get-ci-run-logs.ts @@ -1,7 +1,33 @@ import { getCIRunLogs } from '../../gadgets/github/core/getCIRunLogs.js'; import { getCIRunLogsDef } from '../../gadgets/github/definitions.js'; +import { getFailedPipelineJobs } from '../../gadgets/gitlab/core/getFailedPipelineJobs.js'; import { createCLICommand } from '../../gadgets/shared/cliCommandFactory.js'; +import { gitlabClient } from '../../gitlab/client.js'; +import { detectSCMProvider, resolveProjectPath } from '../base.js'; export default createCLICommand(getCIRunLogsDef, async (params) => { + if (detectSCMProvider() === 'gitlab') { + const projectPath = resolveProjectPath(); + const ref = params.ref as string; + try { + // Try as a numeric pipeline ID first + const pipelineId = Number(ref); + if (Number.isFinite(pipelineId) && pipelineId > 0) { + return getFailedPipelineJobs(projectPath, pipelineId); + } + + // For SHA or branch ref, look up the latest pipeline automatically + const pipelines = await gitlabClient.listPipelines(projectPath, ref); + if (pipelines.length === 0) { + return `No pipelines found for ref "${ref}" in ${projectPath}`; + } + // Use the most recent pipeline + const latestPipeline = pipelines[0]; + return getFailedPipelineJobs(projectPath, latestPipeline.id); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return `Error fetching CI run logs: ${message}`; + } + } return getCIRunLogs(params.owner as string, params.repo as string, params.ref as string); }); diff --git a/src/cli/scm/get-pr-checks.ts b/src/cli/scm/get-pr-checks.ts index bbb0f418..832cf473 100644 --- a/src/cli/scm/get-pr-checks.ts +++ b/src/cli/scm/get-pr-checks.ts @@ -1,7 +1,45 @@ import { getPRChecks } from '../../gadgets/github/core/getPRChecks.js'; import { getPRChecksDef } from '../../gadgets/github/definitions.js'; import { createCLICommand } from '../../gadgets/shared/cliCommandFactory.js'; +import { gitlabClient } from '../../gitlab/client.js'; +import { detectSCMProvider, resolveProjectPath } from '../base.js'; export default createCLICommand(getPRChecksDef, async (params) => { + if (detectSCMProvider() === 'gitlab') { + const projectPath = resolveProjectPath(); + const mrIid = params.prNumber as number; + try { + const mr = await gitlabClient.getMR(projectPath, mrIid); + // Look up latest pipelines for the MR's source branch + const pipelines = await gitlabClient.listPipelines(projectPath, mr.sourceBranch); + const lines = [ + `MR !${mr.iid}: ${mr.title}`, + `Source branch: ${mr.sourceBranch}`, + `Head SHA: ${mr.sha.slice(0, 7)}`, + `Has conflicts: ${mr.hasConflicts}`, + '', + ]; + if (pipelines.length > 0) { + lines.push('Recent pipelines:'); + for (const p of pipelines) { + const statusIcon = p.status === 'success' ? '✅' : p.status === 'failed' ? '❌' : '⏳'; + lines.push( + ` ${statusIcon} Pipeline #${p.id}: ${p.status} (${p.sha.slice(0, 7)}) ${p.webUrl}`, + ); + } + const latest = pipelines[0]; + if (latest.status === 'failed') { + lines.push(''); + lines.push(`Use GetCIRunLogs with ref "${mr.sourceBranch}" to see failure details.`); + } + } else { + lines.push('No pipelines found for this branch.'); + } + return lines.join('\n'); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return `Error fetching MR pipeline status: ${message}`; + } + } return getPRChecks(params.owner as string, params.repo as string, params.prNumber as number); }); diff --git a/src/cli/scm/get-pr-comments.ts b/src/cli/scm/get-pr-comments.ts index 459e8f87..cc11d7a6 100644 --- a/src/cli/scm/get-pr-comments.ts +++ b/src/cli/scm/get-pr-comments.ts @@ -1,7 +1,13 @@ import { getPRComments } from '../../gadgets/github/core/getPRComments.js'; import { getPRCommentsDef } from '../../gadgets/github/definitions.js'; +import { getMRNotes } from '../../gadgets/gitlab/core/getMRNotes.js'; import { createCLICommand } from '../../gadgets/shared/cliCommandFactory.js'; +import { detectSCMProvider, resolveProjectPath } from '../base.js'; export default createCLICommand(getPRCommentsDef, async (params) => { + if (detectSCMProvider() === 'gitlab') { + const projectPath = resolveProjectPath(); + return getMRNotes(projectPath, params.prNumber as number); + } return getPRComments(params.owner as string, params.repo as string, params.prNumber as number); }); diff --git a/src/cli/scm/get-pr-details.ts b/src/cli/scm/get-pr-details.ts index 97ecf37f..7dafec32 100644 --- a/src/cli/scm/get-pr-details.ts +++ b/src/cli/scm/get-pr-details.ts @@ -1,7 +1,13 @@ import { getPRDetails } from '../../gadgets/github/core/getPRDetails.js'; import { getPRDetailsDef } from '../../gadgets/github/definitions.js'; +import { getMRDetails } from '../../gadgets/gitlab/core/getMRDetails.js'; import { createCLICommand } from '../../gadgets/shared/cliCommandFactory.js'; +import { detectSCMProvider, resolveProjectPath } from '../base.js'; export default createCLICommand(getPRDetailsDef, async (params) => { + if (detectSCMProvider() === 'gitlab') { + const projectPath = resolveProjectPath(); + return getMRDetails(projectPath, params.prNumber as number); + } return getPRDetails(params.owner as string, params.repo as string, params.prNumber as number); }); diff --git a/src/cli/scm/get-pr-diff.ts b/src/cli/scm/get-pr-diff.ts index 0cdd6bd7..f7c49cea 100644 --- a/src/cli/scm/get-pr-diff.ts +++ b/src/cli/scm/get-pr-diff.ts @@ -1,7 +1,13 @@ import { getPRDiff } from '../../gadgets/github/core/getPRDiff.js'; import { getPRDiffDef } from '../../gadgets/github/definitions.js'; +import { getMRDiff } from '../../gadgets/gitlab/core/getMRDiff.js'; import { createCLICommand } from '../../gadgets/shared/cliCommandFactory.js'; +import { detectSCMProvider, resolveProjectPath } from '../base.js'; export default createCLICommand(getPRDiffDef, async (params) => { + if (detectSCMProvider() === 'gitlab') { + const projectPath = resolveProjectPath(); + return getMRDiff(projectPath, params.prNumber as number); + } return getPRDiff(params.owner as string, params.repo as string, params.prNumber as number); }); diff --git a/src/cli/scm/post-pr-comment.ts b/src/cli/scm/post-pr-comment.ts index 78967c3c..3216e491 100644 --- a/src/cli/scm/post-pr-comment.ts +++ b/src/cli/scm/post-pr-comment.ts @@ -1,8 +1,14 @@ import { postPRComment } from '../../gadgets/github/core/postPRComment.js'; import { postPRCommentDef } from '../../gadgets/github/definitions.js'; +import { postMRNote } from '../../gadgets/gitlab/core/postMRNote.js'; import { createCLICommand } from '../../gadgets/shared/cliCommandFactory.js'; +import { detectSCMProvider, resolveProjectPath } from '../base.js'; export default createCLICommand(postPRCommentDef, async (params) => { + if (detectSCMProvider() === 'gitlab') { + const projectPath = resolveProjectPath(); + return postMRNote(projectPath, params.prNumber as number, params.body as string); + } return postPRComment( params.owner as string, params.repo as string, diff --git a/src/cli/scm/reply-to-review-comment.ts b/src/cli/scm/reply-to-review-comment.ts index 0c80995b..9ec8851b 100644 --- a/src/cli/scm/reply-to-review-comment.ts +++ b/src/cli/scm/reply-to-review-comment.ts @@ -1,8 +1,16 @@ import { replyToReviewComment } from '../../gadgets/github/core/replyToReviewComment.js'; import { replyToReviewCommentDef } from '../../gadgets/github/definitions.js'; +import { postMRNote } from '../../gadgets/gitlab/core/postMRNote.js'; import { createCLICommand } from '../../gadgets/shared/cliCommandFactory.js'; +import { detectSCMProvider, resolveProjectPath } from '../base.js'; export default createCLICommand(replyToReviewCommentDef, async (params) => { + if (detectSCMProvider() === 'gitlab') { + const projectPath = resolveProjectPath(); + // For GitLab, replying to a review comment maps to posting a new note on the MR. + // GitLab discussion threading is handled at the API level differently from GitHub. + return postMRNote(projectPath, params.prNumber as number, params.body as string); + } return replyToReviewComment( params.owner as string, params.repo as string, diff --git a/src/cli/scm/update-pr-comment.ts b/src/cli/scm/update-pr-comment.ts index dbd392ff..25be160d 100644 --- a/src/cli/scm/update-pr-comment.ts +++ b/src/cli/scm/update-pr-comment.ts @@ -1,8 +1,24 @@ import { updatePRComment } from '../../gadgets/github/core/updatePRComment.js'; import { updatePRCommentDef } from '../../gadgets/github/definitions.js'; +import { updateMRNote } from '../../gadgets/gitlab/core/updateMRNote.js'; import { createCLICommand } from '../../gadgets/shared/cliCommandFactory.js'; +import { detectSCMProvider, resolveProjectPath } from '../base.js'; export default createCLICommand(updatePRCommentDef, async (params) => { + if (detectSCMProvider() === 'gitlab') { + const projectPath = resolveProjectPath(); + // GitLab's updateMRNote requires the MR IID. The GitHub UpdatePRComment + // definition doesn't include prNumber (GitHub comments are globally addressable). + // For GitLab, resolve the MR IID from the prNumber param (if the agent passes it + // as extra context) or from CASCADE_SCM_MR_IID env var. + const mrIid = + (params.prNumber as number | undefined) ?? + (Number(process.env.CASCADE_SCM_MR_IID) || undefined); + if (!mrIid) { + return 'Error: GitLab requires the MR IID to update a note. Pass --prNumber or set CASCADE_SCM_MR_IID.'; + } + return updateMRNote(projectPath, mrIid, params.commentId as number, params.body as string); + } return updatePRComment( params.owner as string, params.repo as string, diff --git a/src/config/integrationRoles.ts b/src/config/integrationRoles.ts index 124f53df..56764031 100644 --- a/src/config/integrationRoles.ts +++ b/src/config/integrationRoles.ts @@ -1,5 +1,5 @@ export type IntegrationCategory = 'pm' | 'scm' | 'alerting'; -export type IntegrationProvider = 'trello' | 'jira' | 'github' | 'sentry'; +export type IntegrationProvider = 'trello' | 'jira' | 'github' | 'gitlab' | 'sentry'; export interface CredentialRoleDef { role: string; @@ -52,6 +52,23 @@ const _rolesRegistry = new Map([ }, ], ], + [ + 'gitlab', + [ + { + role: 'implementer_token', + label: 'Implementer Token', + envVarKey: 'GITLAB_TOKEN_IMPLEMENTER', + }, + { role: 'reviewer_token', label: 'Reviewer Token', envVarKey: 'GITLAB_TOKEN_REVIEWER' }, + { + role: 'webhook_secret', + label: 'Webhook Secret', + envVarKey: 'GITLAB_WEBHOOK_SECRET', + optional: true, + }, + ], + ], [ 'sentry', [ @@ -70,6 +87,7 @@ const _categoryRegistry = new Map([ ['trello', 'pm'], ['jira', 'pm'], ['github', 'scm'], + ['gitlab', 'scm'], ['sentry', 'alerting'], ]); diff --git a/src/config/provider.ts b/src/config/provider.ts index d26f9a7d..28287f39 100644 --- a/src/config/provider.ts +++ b/src/config/provider.ts @@ -10,6 +10,7 @@ import { loadConfigFromDb, } from '../db/repositories/configRepository.js'; import { + getIntegrationProvider, resolveAllProjectCredentials, resolveProjectCredential, } from '../db/repositories/credentialsRepository.js'; @@ -176,7 +177,8 @@ export function setCredentialResolver(resolver: CredentialResolver | null): void /** * Resolve an integration credential for a project by category and role. - * Resolves via the active CredentialResolver using the envVarKey mapping. + * Looks up the project's configured provider first to resolve the correct + * env var key (e.g. GITHUB_TOKEN_IMPLEMENTER vs GITLAB_TOKEN_IMPLEMENTER). * Throws if the credential is not found. */ export async function getIntegrationCredential( @@ -184,7 +186,10 @@ export async function getIntegrationCredential( category: string, role: string, ): Promise { - const envKey = roleToEnvVarKey(category, role); + const provider = await getIntegrationProvider(projectId, category); + const envKey = provider + ? roleToEnvVarKeyForProvider(provider, role) + : roleToEnvVarKey(category, role); if (!envKey) { throw new Error( `Integration credential '${category}/${role}' not found for project '${projectId}'`, @@ -200,14 +205,17 @@ export async function getIntegrationCredential( /** * Resolve an integration credential for a project, returning null if not found. - * Resolves via the active CredentialResolver using the envVarKey mapping. + * Looks up the project's configured provider first to resolve the correct env var key. */ export async function getIntegrationCredentialOrNull( projectId: string, category: string, role: string, ): Promise { - const envKey = roleToEnvVarKey(category, role); + const provider = await getIntegrationProvider(projectId, category); + const envKey = provider + ? roleToEnvVarKeyForProvider(provider, role) + : roleToEnvVarKey(category, role); if (!envKey) return null; return getResolver().resolve(projectId, envKey); } @@ -247,9 +255,19 @@ export function invalidateConfigCache(): void { // Internal helpers // ============================================================================ +/** + * Map a provider+role pair to the corresponding env var key. + * Used when the project's configured provider is known. + */ +function roleToEnvVarKeyForProvider(provider: string, role: string): string | undefined { + const roles = PROVIDER_CREDENTIAL_ROLES[provider as keyof typeof PROVIDER_CREDENTIAL_ROLES]; + if (!roles) return undefined; + return roles.find((r) => r.role === role)?.envVarKey; +} + /** * Map a category+role pair to the corresponding env var key. - * Used for env-var and DB lookups in resolver implementations. + * Falls back to iterating all providers in the category (used when provider is unknown). */ function roleToEnvVarKey(category: string, role: string): string | undefined { // Look through all providers in the category to find the role diff --git a/src/db/migrations/0049_add_gitlab_scm_provider.sql b/src/db/migrations/0049_add_gitlab_scm_provider.sql new file mode 100644 index 00000000..475f1759 --- /dev/null +++ b/src/db/migrations/0049_add_gitlab_scm_provider.sql @@ -0,0 +1,11 @@ +-- 0048_add_gitlab_scm_provider.sql +-- Add gitlab as a valid SCM provider in the integration category/provider CHECK constraint. +ALTER TABLE project_integrations + DROP CONSTRAINT IF EXISTS chk_integration_category_provider, + ADD CONSTRAINT chk_integration_category_provider CHECK ( + (category = 'pm' AND provider IN ('trello', 'jira')) + OR (category = 'scm' AND provider IN ('github', 'gitlab')) + OR (category = 'email' AND provider IN ('imap', 'gmail')) + OR (category = 'sms' AND provider IN ('twilio')) + OR (category = 'alerting' AND provider IN ('sentry')) + ); diff --git a/src/db/migrations/meta/_journal.json b/src/db/migrations/meta/_journal.json index b09ac4ec..af0915ba 100644 --- a/src/db/migrations/meta/_journal.json +++ b/src/db/migrations/meta/_journal.json @@ -344,6 +344,13 @@ "when": 1783000000000, "tag": "0048_remove_squint_db_url", "breakpoints": false + }, + { + "idx": 49, + "version": "7", + "when": 1784000000000, + "tag": "0049_add_gitlab_scm_provider", + "breakpoints": false } ] } diff --git a/src/db/repositories/configMapper.ts b/src/db/repositories/configMapper.ts index 64889b01..472887aa 100644 --- a/src/db/repositories/configMapper.ts +++ b/src/db/repositories/configMapper.ts @@ -31,6 +31,9 @@ export interface JiraIntegrationConfig { // biome-ignore lint/complexity/noBannedTypes: GitHub config has no fields (credentials are in integration_credentials) export type GitHubIntegrationConfig = {}; +// biome-ignore lint/complexity/noBannedTypes: GitLab config has no fields (credentials are in integration_credentials) +export type GitLabIntegrationConfig = {}; + // --------------------------------------------------------------------------- // Row interfaces (mirrors DB select shapes) // --------------------------------------------------------------------------- @@ -61,6 +64,7 @@ export interface MapProjectInput { trelloConfig?: TrelloIntegrationConfig; jiraConfig?: JiraIntegrationConfig; githubConfig?: GitHubIntegrationConfig; + gitlabConfig?: GitLabIntegrationConfig; } // --------------------------------------------------------------------------- @@ -223,15 +227,18 @@ export function extractIntegrationConfigs(integrations: IntegrationRow[]): { trelloConfig?: TrelloIntegrationConfig; jiraConfig?: JiraIntegrationConfig; githubConfig?: GitHubIntegrationConfig; + gitlabConfig?: GitLabIntegrationConfig; } { const trelloRow = integrations.find((i) => i.provider === 'trello'); const jiraRow = integrations.find((i) => i.provider === 'jira'); const githubRow = integrations.find((i) => i.provider === 'github'); + const gitlabRow = integrations.find((i) => i.provider === 'gitlab'); return { trelloConfig: trelloRow?.config as TrelloIntegrationConfig | undefined, jiraConfig: jiraRow?.config as JiraIntegrationConfig | undefined, githubConfig: githubRow?.config as GitHubIntegrationConfig | undefined, + gitlabConfig: gitlabRow?.config as GitLabIntegrationConfig | undefined, }; } diff --git a/src/gadgets/gitlab/ApproveMR.ts b/src/gadgets/gitlab/ApproveMR.ts new file mode 100644 index 00000000..cfe15cdb --- /dev/null +++ b/src/gadgets/gitlab/ApproveMR.ts @@ -0,0 +1,11 @@ +import { createGadgetClass } from '../shared/gadgetFactory.js'; +import { approveMR } from './core/approveMR.js'; +import { approveMRDef } from './definitions.js'; + +export const ApproveMR = createGadgetClass(approveMRDef, async (params) => { + return approveMR( + params.projectPath as string, + params.mrIid as number, + params.action as 'approve' | 'unapprove', + ); +}); diff --git a/src/gadgets/gitlab/CreateMR.ts b/src/gadgets/gitlab/CreateMR.ts new file mode 100644 index 00000000..02c81cd4 --- /dev/null +++ b/src/gadgets/gitlab/CreateMR.ts @@ -0,0 +1,26 @@ +import { getBaseBranch, recordPRCreation } from '../sessionState.js'; +import { createGadgetClass } from '../shared/gadgetFactory.js'; +import { createMR } from './core/createMR.js'; +import { createMRDef } from './definitions.js'; + +export const CreateMR = createGadgetClass(createMRDef, async (params) => { + const result = await createMR({ + title: params.title as string, + body: params.body as string, + head: params.head as string, + base: getBaseBranch(), + draft: params.draft as boolean | undefined, + commit: params.commit as boolean | undefined, + commitMessage: params.commitMessage as string | undefined, + push: params.push as boolean | undefined, + }); + + recordPRCreation(result.mrUrl); + + if (result.alreadyExisted) { + return `MR already exists for this branch: !${result.mrIid} — ${result.mrUrl}`; + } + + const draftLabel = (params.draft as boolean | undefined) ? ' (draft)' : ''; + return `MR !${result.mrIid} created successfully${draftLabel}: ${result.mrUrl}`; +}); diff --git a/src/gadgets/gitlab/CreateMRReview.ts b/src/gadgets/gitlab/CreateMRReview.ts new file mode 100644 index 00000000..1aa70c90 --- /dev/null +++ b/src/gadgets/gitlab/CreateMRReview.ts @@ -0,0 +1,24 @@ +import { deleteInitialComment, recordReviewSubmission } from '../sessionState.js'; +import { createGadgetClass } from '../shared/gadgetFactory.js'; +import { formatGadgetError } from '../utils.js'; +import { createMRReview } from './core/createMRReview.js'; +import { createMRReviewDef } from './definitions.js'; + +export const CreateMRReview = createGadgetClass(createMRReviewDef, async (params) => { + try { + const result = await createMRReview({ + projectPath: params.projectPath as string, + mrIid: params.mrIid as number, + event: params.event as 'APPROVE' | 'REQUEST_CHANGES' | 'COMMENT', + body: params.body as string, + }); + recordReviewSubmission('', params.body as string, result.event); + // Delete the stale ack/progress comment immediately after review submission. + // Best-effort: wrapped in deleteInitialComment's own try-catch. + // Note: GitLab doesn't use owner/repo, passing projectPath as owner for compatibility. + await deleteInitialComment(params.projectPath as string, ''); + return `Review submitted successfully (${result.event})`; + } catch (error) { + return formatGadgetError('submitting review', error); + } +}); diff --git a/src/gadgets/gitlab/GetFailedPipelineJobs.ts b/src/gadgets/gitlab/GetFailedPipelineJobs.ts new file mode 100644 index 00000000..209f4099 --- /dev/null +++ b/src/gadgets/gitlab/GetFailedPipelineJobs.ts @@ -0,0 +1,7 @@ +import { createGadgetClass } from '../shared/gadgetFactory.js'; +import { getFailedPipelineJobs } from './core/getFailedPipelineJobs.js'; +import { getFailedPipelineJobsDef } from './definitions.js'; + +export const GetFailedPipelineJobs = createGadgetClass(getFailedPipelineJobsDef, async (params) => { + return getFailedPipelineJobs(params.projectPath as string, params.pipelineId as number); +}); diff --git a/src/gadgets/gitlab/GetMRDetails.ts b/src/gadgets/gitlab/GetMRDetails.ts new file mode 100644 index 00000000..ecca65b0 --- /dev/null +++ b/src/gadgets/gitlab/GetMRDetails.ts @@ -0,0 +1,7 @@ +import { createGadgetClass } from '../shared/gadgetFactory.js'; +import { getMRDetails } from './core/getMRDetails.js'; +import { getMRDetailsDef } from './definitions.js'; + +export const GetMRDetails = createGadgetClass(getMRDetailsDef, async (params) => { + return getMRDetails(params.projectPath as string, params.mrIid as number); +}); diff --git a/src/gadgets/gitlab/GetMRDiff.ts b/src/gadgets/gitlab/GetMRDiff.ts new file mode 100644 index 00000000..3620f925 --- /dev/null +++ b/src/gadgets/gitlab/GetMRDiff.ts @@ -0,0 +1,7 @@ +import { createGadgetClass } from '../shared/gadgetFactory.js'; +import { getMRDiff } from './core/getMRDiff.js'; +import { getMRDiffDef } from './definitions.js'; + +export const GetMRDiff = createGadgetClass(getMRDiffDef, async (params) => { + return getMRDiff(params.projectPath as string, params.mrIid as number); +}); diff --git a/src/gadgets/gitlab/GetMRNotes.ts b/src/gadgets/gitlab/GetMRNotes.ts new file mode 100644 index 00000000..54f0b83b --- /dev/null +++ b/src/gadgets/gitlab/GetMRNotes.ts @@ -0,0 +1,7 @@ +import { createGadgetClass } from '../shared/gadgetFactory.js'; +import { getMRNotes } from './core/getMRNotes.js'; +import { getMRNotesDef } from './definitions.js'; + +export const GetMRNotes = createGadgetClass(getMRNotesDef, async (params) => { + return getMRNotes(params.projectPath as string, params.mrIid as number); +}); diff --git a/src/gadgets/gitlab/GetPipelineStatus.ts b/src/gadgets/gitlab/GetPipelineStatus.ts new file mode 100644 index 00000000..ae780c54 --- /dev/null +++ b/src/gadgets/gitlab/GetPipelineStatus.ts @@ -0,0 +1,7 @@ +import { createGadgetClass } from '../shared/gadgetFactory.js'; +import { getPipelineStatus } from './core/getPipelineStatus.js'; +import { getPipelineStatusDef } from './definitions.js'; + +export const GetPipelineStatus = createGadgetClass(getPipelineStatusDef, async (params) => { + return getPipelineStatus(params.projectPath as string, params.pipelineId as number); +}); diff --git a/src/gadgets/gitlab/MergeMR.ts b/src/gadgets/gitlab/MergeMR.ts new file mode 100644 index 00000000..c4fe7ae6 --- /dev/null +++ b/src/gadgets/gitlab/MergeMR.ts @@ -0,0 +1,11 @@ +import { createGadgetClass } from '../shared/gadgetFactory.js'; +import { mergeMR } from './core/mergeMR.js'; +import { mergeMRDef } from './definitions.js'; + +export const MergeMR = createGadgetClass(mergeMRDef, async (params) => { + return mergeMR( + params.projectPath as string, + params.mrIid as number, + params.squash as boolean | undefined, + ); +}); diff --git a/src/gadgets/gitlab/PostMRNote.ts b/src/gadgets/gitlab/PostMRNote.ts new file mode 100644 index 00000000..1b40a84b --- /dev/null +++ b/src/gadgets/gitlab/PostMRNote.ts @@ -0,0 +1,7 @@ +import { createGadgetClass } from '../shared/gadgetFactory.js'; +import { postMRNote } from './core/postMRNote.js'; +import { postMRNoteDef } from './definitions.js'; + +export const PostMRNote = createGadgetClass(postMRNoteDef, async (params) => { + return postMRNote(params.projectPath as string, params.mrIid as number, params.body as string); +}); diff --git a/src/gadgets/gitlab/UpdateMRNote.ts b/src/gadgets/gitlab/UpdateMRNote.ts new file mode 100644 index 00000000..0698c186 --- /dev/null +++ b/src/gadgets/gitlab/UpdateMRNote.ts @@ -0,0 +1,12 @@ +import { createGadgetClass } from '../shared/gadgetFactory.js'; +import { updateMRNote } from './core/updateMRNote.js'; +import { updateMRNoteDef } from './definitions.js'; + +export const UpdateMRNote = createGadgetClass(updateMRNoteDef, async (params) => { + return updateMRNote( + params.projectPath as string, + params.mrIid as number, + params.noteId as number, + params.body as string, + ); +}); diff --git a/src/gadgets/gitlab/core/approveMR.ts b/src/gadgets/gitlab/core/approveMR.ts new file mode 100644 index 00000000..cf8f4ada --- /dev/null +++ b/src/gadgets/gitlab/core/approveMR.ts @@ -0,0 +1,20 @@ +import { gitlabClient } from '../../../gitlab/client.js'; + +export async function approveMR( + projectPath: string, + mrIid: number, + action: 'approve' | 'unapprove', +): Promise { + try { + if (action === 'approve') { + await gitlabClient.approveMR(projectPath, mrIid); + return `MR !${mrIid} approved`; + } else { + await gitlabClient.unapproveMR(projectPath, mrIid); + return `MR !${mrIid} unapproved`; + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return `Error ${action === 'approve' ? 'approving' : 'unapproving'} MR: ${message}`; + } +} diff --git a/src/gadgets/gitlab/core/createMR.ts b/src/gadgets/gitlab/core/createMR.ts new file mode 100644 index 00000000..6fcc5841 --- /dev/null +++ b/src/gadgets/gitlab/core/createMR.ts @@ -0,0 +1,136 @@ +import { gitlabClient } from '../../../gitlab/client.js'; +import { runCommand } from '../../../utils/repo.js'; +import { buildRunLinkFooterFromEnv } from '../../../utils/runLink.js'; + +export interface CreateMRFullParams { + title: string; + body: string; + head: string; + base: string; + draft?: boolean; + commit?: boolean; + commitMessage?: string; + push?: boolean; +} + +export interface CreateMRResult { + mrIid: number; + mrUrl: string; + projectPath: string; + alreadyExisted: boolean; +} + +async function detectProjectPath(): Promise { + const result = await runCommand('git', ['remote', 'get-url', 'origin'], process.cwd()); + if (result.exitCode !== 0) { + throw new Error('Failed to detect repository: no git remote "origin" found'); + } + const url = result.stdout.trim(); + // Match gitlab.com (or self-hosted) paths: git@gitlab.com:group/repo.git or https://...@gitlab.com/group/repo.git + // SSH: git@host:group/repo.git + const sshMatch = url.match(/@[^:]+:(.+?)(?:\.git)?$/); + if (sshMatch) return sshMatch[1]; + // HTTPS: https://oauth2:token@host/group/repo.git + const httpsMatch = url.match(/https?:\/\/[^/]+\/(.+?)(?:\.git)?$/); + if (httpsMatch) return httpsMatch[1]; + throw new Error(`Cannot parse project path from git remote URL: ${url}`); +} + +async function stageAndCommit(commitMessage: string): Promise { + const addResult = await runCommand('git', ['add', '-u'], process.cwd()); + if (addResult.exitCode !== 0) { + throw new Error(`Failed to stage changes: ${addResult.stderr || addResult.stdout}`.trim()); + } + + const untrackedResult = await runCommand( + 'git', + ['ls-files', '--others', '--exclude-standard'], + process.cwd(), + ); + if (untrackedResult.exitCode === 0 && untrackedResult.stdout.trim()) { + const newFiles = untrackedResult.stdout.trim().split('\n'); + const addNewResult = await runCommand('git', ['add', '--', ...newFiles], process.cwd()); + if (addNewResult.exitCode !== 0) { + throw new Error( + `Failed to stage new files: ${addNewResult.stderr || addNewResult.stdout}`.trim(), + ); + } + } + + const statusResult = await runCommand('git', ['status', '--porcelain'], process.cwd()); + if (statusResult.stdout.trim() === '') { + return; + } + + const commitResult = await runCommand('git', ['commit', '-m', commitMessage], process.cwd()); + if (commitResult.exitCode !== 0) { + const output = [commitResult.stdout, commitResult.stderr].filter(Boolean).join('\n').trim(); + throw new Error( + `COMMIT FAILED (pre-commit hooks may have failed)\n\n--- OUTPUT ---\n${output}`, + ); + } +} + +async function pushBranch(branch: string): Promise { + const pushResult = await runCommand('git', ['push', '-u', 'origin', branch], process.cwd()); + if (pushResult.exitCode !== 0) { + const output = [pushResult.stdout, pushResult.stderr].filter(Boolean).join('\n').trim(); + throw new Error( + `PUSH FAILED for branch '${branch}' (pre-push hooks may have failed)\n\n--- OUTPUT ---\n${output}`, + ); + } +} + +async function verifyBranchOnRemote(branch: string): Promise { + const result = await runCommand('git', ['ls-remote', '--heads', 'origin', branch], process.cwd()); + return result.exitCode === 0 && result.stdout.trim().length > 0; +} + +export async function createMR(params: CreateMRFullParams): Promise { + const projectPath = await detectProjectPath(); + const commitMessage = params.commitMessage || params.title; + + if (params.commit !== false) { + await stageAndCommit(commitMessage); + } + + if (params.push !== false) { + await pushBranch(params.head); + } + + const branchExists = await verifyBranchOnRemote(params.head); + if (!branchExists) { + throw new Error( + `Branch '${params.head}' does not exist on remote. Push the branch first or set push=true.`, + ); + } + + const runLinkFooter = buildRunLinkFooterFromEnv(); + const mrBody = runLinkFooter ? params.body + runLinkFooter : params.body; + + // Check if an MR already exists for this branch + const existingMR = await gitlabClient.getOpenMRByBranch(projectPath, params.head); + if (existingMR) { + return { + mrIid: existingMR.iid, + mrUrl: existingMR.webUrl, + projectPath, + alreadyExisted: true, + }; + } + + const mr = await gitlabClient.createMR(projectPath, { + title: params.title, + description: mrBody, + sourceBranch: params.head, + targetBranch: params.base, + draft: params.draft, + }); + + return { + mrIid: mr.iid, + mrUrl: mr.webUrl, + projectPath, + alreadyExisted: false, + }; +} diff --git a/src/gadgets/gitlab/core/createMRReview.ts b/src/gadgets/gitlab/core/createMRReview.ts new file mode 100644 index 00000000..e9b52521 --- /dev/null +++ b/src/gadgets/gitlab/core/createMRReview.ts @@ -0,0 +1,31 @@ +import { gitlabClient } from '../../../gitlab/client.js'; +import { buildRunLinkFooterFromEnv } from '../../../utils/runLink.js'; + +export interface CreateMRReviewParams { + projectPath: string; + mrIid: number; + event: 'APPROVE' | 'REQUEST_CHANGES' | 'COMMENT'; + body: string; +} + +export interface CreateMRReviewResult { + event: string; +} + +export async function createMRReview(params: CreateMRReviewParams): Promise { + const runLinkFooter = buildRunLinkFooterFromEnv(); + const body = runLinkFooter ? params.body + runLinkFooter : params.body; + + if (params.event === 'APPROVE') { + await gitlabClient.approveMR(params.projectPath, params.mrIid); + } else if (params.event === 'REQUEST_CHANGES') { + await gitlabClient.unapproveMR(params.projectPath, params.mrIid); + } + + // Always post the review body as a note + if (body) { + await gitlabClient.createMRNote(params.projectPath, params.mrIid, body); + } + + return { event: params.event }; +} diff --git a/src/gadgets/gitlab/core/getFailedPipelineJobs.ts b/src/gadgets/gitlab/core/getFailedPipelineJobs.ts new file mode 100644 index 00000000..a1602a7a --- /dev/null +++ b/src/gadgets/gitlab/core/getFailedPipelineJobs.ts @@ -0,0 +1,63 @@ +import { gitlabClient } from '../../../gitlab/client.js'; +import { logger } from '../../../utils/logging.js'; + +/** Max characters of job log to include per job (tail). */ +const MAX_LOG_CHARS = 8000; + +/** + * Fetch failed jobs from a GitLab pipeline, including job logs. + * Returns formatted output with failed job details and the tail of each log. + */ +export async function getFailedPipelineJobs( + projectPath: string, + pipelineId: number, +): Promise { + try { + const { pipeline, failedJobs } = await gitlabClient.getFailedPipelineJobs( + projectPath, + pipelineId, + ); + + if (failedJobs.length === 0) { + return `No failed jobs in pipeline #${pipeline.id} (status: ${pipeline.status}).`; + } + + const sections: string[] = []; + sections.push( + `Found ${failedJobs.length} failed job(s) in pipeline #${pipeline.id} (${pipeline.status}):`, + ); + + for (const job of failedJobs) { + sections.push(''); + sections.push(`## ${job.name} (stage: ${job.stage})`); + if (job.failureReason) { + sections.push(`Failure reason: ${job.failureReason}`); + } + sections.push(`URL: ${job.webUrl}`); + + // Fetch and include the job log + try { + let log = await gitlabClient.getJobLog(projectPath, job.id); + // Strip ANSI escape codes for readability + // biome-ignore lint/suspicious/noControlCharactersInRegex: ANSI escape codes use ESC (0x1b) + log = log.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, ''); + // Take the tail if too long + if (log.length > MAX_LOG_CHARS) { + log = `... (truncated, showing last ${MAX_LOG_CHARS} chars)\n${log.slice(-MAX_LOG_CHARS)}`; + } + sections.push(''); + sections.push('```'); + sections.push(log.trim()); + sections.push('```'); + } catch (err) { + logger.debug('Failed to fetch job log', { jobId: job.id, error: String(err) }); + sections.push('(Job log unavailable)'); + } + } + + return sections.join('\n'); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return `Error fetching failed pipeline jobs: ${message}`; + } +} diff --git a/src/gadgets/gitlab/core/getMRDetails.ts b/src/gadgets/gitlab/core/getMRDetails.ts new file mode 100644 index 00000000..7e14a899 --- /dev/null +++ b/src/gadgets/gitlab/core/getMRDetails.ts @@ -0,0 +1,22 @@ +import { gitlabClient } from '../../../gitlab/client.js'; + +export async function getMRDetails(projectPath: string, mrIid: number): Promise { + try { + const mr = await gitlabClient.getMR(projectPath, mrIid); + + return [ + `MR !${mr.iid}: ${mr.title}`, + `State: ${mr.state}`, + `Branch: ${mr.sourceBranch} -> ${mr.targetBranch}`, + `Author: ${mr.author.username}`, + `URL: ${mr.webUrl}`, + `Has conflicts: ${mr.hasConflicts}`, + '', + 'Description:', + mr.description || '(no description)', + ].join('\n'); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return `Error fetching MR details: ${message}`; + } +} diff --git a/src/gadgets/gitlab/core/getMRDiff.ts b/src/gadgets/gitlab/core/getMRDiff.ts new file mode 100644 index 00000000..16b2f4b2 --- /dev/null +++ b/src/gadgets/gitlab/core/getMRDiff.ts @@ -0,0 +1,34 @@ +import { gitlabClient } from '../../../gitlab/client.js'; + +export async function getMRDiff(projectPath: string, mrIid: number): Promise { + try { + const files = await gitlabClient.getMRDiff(projectPath, mrIid); + + if (files.length === 0) { + return 'No files changed in this MR.'; + } + + const formatted = files.map((f) => { + const status = f.newFile + ? 'added' + : f.deletedFile + ? 'deleted' + : f.renamedFile + ? `renamed (${f.oldPath} -> ${f.newPath})` + : 'modified'; + + const lines = [`## ${f.newPath}`, `Status: ${status}`]; + if (f.diff) { + lines.push('```diff', f.diff, '```'); + } else { + lines.push('[Binary file or too large to display]'); + } + return lines.join('\n'); + }); + + return `${files.length} file(s) changed:\n\n${formatted.join('\n\n')}`; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return `Error fetching MR diff: ${message}`; + } +} diff --git a/src/gadgets/gitlab/core/getMRNotes.ts b/src/gadgets/gitlab/core/getMRNotes.ts new file mode 100644 index 00000000..a875aedb --- /dev/null +++ b/src/gadgets/gitlab/core/getMRNotes.ts @@ -0,0 +1,24 @@ +import { gitlabClient } from '../../../gitlab/client.js'; + +export async function getMRNotes(projectPath: string, mrIid: number): Promise { + try { + const notes = await gitlabClient.getMRNotes(projectPath, mrIid); + + // Filter out system notes for cleaner output + const userNotes = notes.filter((n) => !n.system); + + if (userNotes.length === 0) { + return 'No user comments on this MR.'; + } + + const formatted = userNotes.map((n) => { + const resolvedTag = n.resolvable ? (n.resolved ? ' [resolved]' : ' [unresolved]') : ''; + return [`**@${n.author.username}** (${n.createdAt})${resolvedTag}:`, n.body].join('\n'); + }); + + return `${userNotes.length} comment(s):\n\n${formatted.join('\n\n---\n\n')}`; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return `Error fetching MR notes: ${message}`; + } +} diff --git a/src/gadgets/gitlab/core/getPipelineStatus.ts b/src/gadgets/gitlab/core/getPipelineStatus.ts new file mode 100644 index 00000000..1b92570d --- /dev/null +++ b/src/gadgets/gitlab/core/getPipelineStatus.ts @@ -0,0 +1,18 @@ +import { gitlabClient } from '../../../gitlab/client.js'; + +export async function getPipelineStatus(projectPath: string, pipelineId: number): Promise { + try { + const pipeline = await gitlabClient.getPipelineStatus(projectPath, pipelineId); + + return [ + `Pipeline #${pipeline.id}`, + `Status: ${pipeline.status}`, + `Ref: ${pipeline.ref}`, + `SHA: ${pipeline.sha.slice(0, 7)}`, + `URL: ${pipeline.webUrl}`, + ].join('\n'); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return `Error fetching pipeline status: ${message}`; + } +} diff --git a/src/gadgets/gitlab/core/mergeMR.ts b/src/gadgets/gitlab/core/mergeMR.ts new file mode 100644 index 00000000..657a3b6e --- /dev/null +++ b/src/gadgets/gitlab/core/mergeMR.ts @@ -0,0 +1,15 @@ +import { gitlabClient } from '../../../gitlab/client.js'; + +export async function mergeMR( + projectPath: string, + mrIid: number, + squash?: boolean, +): Promise { + try { + await gitlabClient.mergeMR(projectPath, mrIid, { squash }); + return `MR !${mrIid} merged successfully${squash ? ' (squashed)' : ''}`; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return `Error merging MR: ${message}`; + } +} diff --git a/src/gadgets/gitlab/core/postMRNote.ts b/src/gadgets/gitlab/core/postMRNote.ts new file mode 100644 index 00000000..1b4a38ff --- /dev/null +++ b/src/gadgets/gitlab/core/postMRNote.ts @@ -0,0 +1,18 @@ +import { gitlabClient } from '../../../gitlab/client.js'; +import { buildRunLinkFooterFromEnv } from '../../../utils/runLink.js'; + +export async function postMRNote( + projectPath: string, + mrIid: number, + body: string, +): Promise { + try { + const runLinkFooter = buildRunLinkFooterFromEnv(); + const fullBody = runLinkFooter ? body + runLinkFooter : body; + const result = await gitlabClient.createMRNote(projectPath, mrIid, fullBody); + return `Note posted (id: ${result.id})`; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return `Error posting MR note: ${message}`; + } +} diff --git a/src/gadgets/gitlab/core/updateMRNote.ts b/src/gadgets/gitlab/core/updateMRNote.ts new file mode 100644 index 00000000..c828082e --- /dev/null +++ b/src/gadgets/gitlab/core/updateMRNote.ts @@ -0,0 +1,16 @@ +import { gitlabClient } from '../../../gitlab/client.js'; + +export async function updateMRNote( + projectPath: string, + mrIid: number, + noteId: number, + body: string, +): Promise { + try { + const result = await gitlabClient.updateMRNote(projectPath, mrIid, noteId, body); + return `Note updated (id: ${result.id})`; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return `Error updating MR note: ${message}`; + } +} diff --git a/src/gadgets/gitlab/definitions.ts b/src/gadgets/gitlab/definitions.ts new file mode 100644 index 00000000..e4bbfa8a --- /dev/null +++ b/src/gadgets/gitlab/definitions.ts @@ -0,0 +1,598 @@ +/** + * Unified ToolDefinition objects for GitLab SCM tools. + * + * These definitions are the single source of truth for: + * - Gadget classes (generated via createGadgetClass) + * - CLI commands (generated via createCLICommand) + * - JSON Schema manifests (generated via buildManifest) + */ + +import type { ToolDefinition } from '../shared/toolDefinition.js'; + +/** + * Shared projectPath auto-resolved param used by most GitLab SCM tools. + */ +const projectPathAutoResolved = [ + { + paramName: 'projectPath', + envVar: 'CASCADE_GITLAB_PROJECT_PATH', + resolvedFrom: 'git-remote' as const, + description: 'GitLab project path (auto-detected from git remote)', + }, +]; + +export const createMRDef: ToolDefinition = { + name: 'CreateMR', + description: `Create a GitLab merge request. Handles the full workflow: commit > push > create MR. + +By default, this gadget will: +1. Stage and commit all changes (using the MR title as commit message) +2. Push the branch to remote +3. Create the merge request + +The project path is auto-detected from the git remote — you do not need to specify it. + +Set commit=false if you have already committed your changes. +Set push=false if you have already pushed the branch. + +The MR description supports full GitLab-flavored markdown including: +- Headers, lists, code blocks +- Task lists with checkboxes +- Links and mentions +- Tables + +NOTE: Pre-commit and pre-push hooks may run tests which can take time. +If hooks fail or timeout, the full output will be shown.`, + timeoutMs: 240000, // 4 minutes - hooks may run test suites + parameters: { + comment: { + type: 'string', + describe: 'Brief rationale for this gadget call', + required: true, + gadgetOnly: true, + }, + title: { + type: 'string', + describe: 'The merge request title (also used as commit message if committing)', + required: true, + }, + body: { + type: 'string', + describe: 'The merge request description (supports GitLab markdown)', + required: true, + }, + head: { + type: 'string', + describe: 'The name of the branch where your changes are implemented', + required: true, + }, + base: { + type: 'string', + describe: 'Target branch name (defaults to CASCADE_BASE_BRANCH env var)', + optional: true, + cliEnvVar: 'CASCADE_BASE_BRANCH', + }, + draft: { + type: 'boolean', + describe: 'Create as a draft merge request (default: false)', + optional: true, + }, + commit: { + type: 'boolean', + describe: 'Stage and commit all changes before pushing (default: true)', + optional: true, + default: true, + allowNo: true, + }, + commitMessage: { + type: 'string', + describe: 'Custom commit message (default: uses MR title)', + optional: true, + }, + push: { + type: 'boolean', + describe: 'Push the branch to remote before creating MR (default: true)', + optional: true, + default: true, + allowNo: true, + }, + }, + examples: [ + { + params: { + comment: 'Creating MR for completed auth feature', + title: 'feat: add user authentication', + body: '## Summary\n\nAdds OAuth2 authentication flow.\n\n## Changes\n\n- Added login page\n- Integrated with auth provider\n- Added session management', + head: 'feature/auth', + }, + comment: + 'Full workflow: commits all changes, pushes, and creates MR (base branch is auto-resolved)', + }, + { + params: { + comment: 'Creating draft MR for early feedback', + title: 'fix: resolve null pointer in checkout', + body: 'Fixes #123\n\nAdded null check before accessing cart items.', + head: 'fix/checkout-null', + draft: true, + commitMessage: 'fix(checkout): add null check for cart items', + }, + comment: 'Create a draft MR with custom commit message', + }, + { + params: { + comment: 'Creating MR - already committed and pushed', + title: 'chore: update dependencies', + body: 'Updated all dependencies to latest versions.', + head: 'chore/deps', + commit: false, + push: false, + }, + comment: 'Skip commit and push if already done manually', + }, + ], + cli: { + fileInputAlternatives: [ + { + paramName: 'body', + fileFlag: 'body-file', + description: 'Read MR body from file (use - for stdin)', + }, + ], + }, +}; + +export const createMRReviewDef: ToolDefinition = { + name: 'CreateMRReview', + description: + 'Submit a review on a GitLab merge request. Approves, unapproves, or posts a review comment on the MR.', + timeoutMs: 30000, + parameters: { + comment: { + type: 'string', + describe: 'Brief rationale for this gadget call', + required: true, + gadgetOnly: true, + }, + projectPath: { + type: 'string', + describe: 'GitLab project path (e.g. group/repo)', + required: true, + cliEnvVar: 'CASCADE_GITLAB_PROJECT_PATH', + }, + mrIid: { + type: 'number', + describe: 'The merge request IID', + required: true, + }, + event: { + type: 'enum', + options: ['APPROVE', 'REQUEST_CHANGES', 'COMMENT'], + describe: 'The review action: APPROVE, REQUEST_CHANGES (unapprove), or COMMENT', + required: true, + }, + body: { + type: 'string', + describe: 'Overall review summary (supports markdown)', + required: true, + }, + }, + examples: [ + { + params: { + comment: 'Approving MR after thorough review', + projectPath: 'acme/myapp', + mrIid: 42, + event: 'APPROVE', + body: 'LGTM! The implementation is clean and well-tested.', + }, + comment: 'Approve an MR with a summary note', + }, + { + params: { + comment: 'Requesting changes for identified issues', + projectPath: 'acme/myapp', + mrIid: 42, + event: 'REQUEST_CHANGES', + body: 'Good progress, but a few issues need to be addressed before merging.', + }, + comment: 'Unapprove and post review feedback', + }, + ], + cli: { + autoResolved: projectPathAutoResolved, + }, +}; + +export const getMRDetailsDef: ToolDefinition = { + name: 'GetMRDetails', + description: + 'Get details about a GitLab merge request including title, description, branch info, and conflict status.', + timeoutMs: 30000, + parameters: { + comment: { + type: 'string', + describe: 'Brief rationale for this gadget call', + required: true, + gadgetOnly: true, + }, + projectPath: { + type: 'string', + describe: 'GitLab project path (e.g. group/repo)', + required: true, + cliEnvVar: 'CASCADE_GITLAB_PROJECT_PATH', + }, + mrIid: { + type: 'number', + describe: 'The merge request IID', + required: true, + }, + }, + examples: [ + { + params: { + comment: 'Fetching MR details to understand changes', + projectPath: 'acme/myapp', + mrIid: 42, + }, + comment: 'Get details for MR !42', + }, + ], + cli: { + autoResolved: projectPathAutoResolved, + }, +}; + +export const getMRDiffDef: ToolDefinition = { + name: 'GetMRDiff', + description: + 'Get the diff of all file changes in a GitLab merge request. Shows each file with the patch content.', + timeoutMs: 30000, + parameters: { + comment: { + type: 'string', + describe: 'Brief rationale for this gadget call', + required: true, + gadgetOnly: true, + }, + projectPath: { + type: 'string', + describe: 'GitLab project path (e.g. group/repo)', + required: true, + cliEnvVar: 'CASCADE_GITLAB_PROJECT_PATH', + }, + mrIid: { + type: 'number', + describe: 'The merge request IID', + required: true, + }, + }, + examples: [ + { + params: { + comment: 'Reviewing file changes for code review', + projectPath: 'acme/myapp', + mrIid: 42, + }, + comment: 'Get all file changes in MR !42', + }, + ], + cli: { + autoResolved: projectPathAutoResolved, + }, +}; + +export const getMRNotesDef: ToolDefinition = { + name: 'GetMRNotes', + description: + 'Get notes (comments) on a GitLab merge request. Shows all discussion comments including system notes.', + timeoutMs: 30000, + parameters: { + comment: { + type: 'string', + describe: 'Brief rationale for this gadget call', + required: true, + gadgetOnly: true, + }, + projectPath: { + type: 'string', + describe: 'GitLab project path (e.g. group/repo)', + required: true, + cliEnvVar: 'CASCADE_GITLAB_PROJECT_PATH', + }, + mrIid: { + type: 'number', + describe: 'The merge request IID', + required: true, + }, + }, + examples: [ + { + params: { + comment: 'Fetching review comments to understand feedback', + projectPath: 'acme/myapp', + mrIid: 42, + }, + comment: 'Get all notes on MR !42', + }, + ], + cli: { + autoResolved: projectPathAutoResolved, + }, +}; + +export const postMRNoteDef: ToolDefinition = { + name: 'PostMRNote', + description: 'Post a note (comment) on a GitLab merge request. Use this for general MR comments.', + timeoutMs: 30000, + parameters: { + comment: { + type: 'string', + describe: 'Brief rationale for this gadget call', + required: true, + gadgetOnly: true, + }, + projectPath: { + type: 'string', + describe: 'GitLab project path (e.g. group/repo)', + required: true, + cliEnvVar: 'CASCADE_GITLAB_PROJECT_PATH', + }, + mrIid: { + type: 'number', + describe: 'The merge request IID', + required: true, + }, + body: { + type: 'string', + describe: 'The note body (supports markdown)', + required: true, + }, + }, + examples: [ + { + params: { + comment: 'Acknowledging review feedback', + projectPath: 'acme/myapp', + mrIid: 42, + body: 'Working on addressing the review feedback...', + }, + comment: 'Post a status note on the MR', + }, + ], + cli: { + autoResolved: projectPathAutoResolved, + fileInputAlternatives: [ + { + paramName: 'body', + fileFlag: 'body-file', + description: 'Read note body from file (use - for stdin)', + }, + ], + }, +}; + +export const updateMRNoteDef: ToolDefinition = { + name: 'UpdateMRNote', + description: + 'Update an existing note on a GitLab merge request. Use this to update a previously posted note with new information.', + timeoutMs: 30000, + parameters: { + comment: { + type: 'string', + describe: 'Brief rationale for this gadget call', + required: true, + gadgetOnly: true, + }, + projectPath: { + type: 'string', + describe: 'GitLab project path (e.g. group/repo)', + required: true, + cliEnvVar: 'CASCADE_GITLAB_PROJECT_PATH', + }, + mrIid: { + type: 'number', + describe: 'The merge request IID', + required: true, + }, + noteId: { + type: 'number', + describe: 'The ID of the note to update', + required: true, + }, + body: { + type: 'string', + describe: 'The new note body (supports markdown)', + required: true, + }, + }, + examples: [ + { + params: { + comment: 'Updating status after addressing feedback', + projectPath: 'acme/myapp', + mrIid: 42, + noteId: 123456789, + body: 'All review feedback has been addressed. Changes pushed.', + }, + comment: 'Update an existing note with completion status', + }, + ], + cli: { + autoResolved: projectPathAutoResolved, + }, +}; + +export const approveMRDef: ToolDefinition = { + name: 'ApproveMR', + description: 'Approve or unapprove a GitLab merge request.', + timeoutMs: 30000, + parameters: { + comment: { + type: 'string', + describe: 'Brief rationale for this gadget call', + required: true, + gadgetOnly: true, + }, + projectPath: { + type: 'string', + describe: 'GitLab project path (e.g. group/repo)', + required: true, + cliEnvVar: 'CASCADE_GITLAB_PROJECT_PATH', + }, + mrIid: { + type: 'number', + describe: 'The merge request IID', + required: true, + }, + action: { + type: 'enum', + options: ['approve', 'unapprove'], + describe: 'Approval action: approve or unapprove', + required: true, + }, + }, + examples: [ + { + params: { + comment: 'Approving MR after successful review', + projectPath: 'acme/myapp', + mrIid: 42, + action: 'approve', + }, + comment: 'Approve MR !42', + }, + ], + cli: { + autoResolved: projectPathAutoResolved, + }, +}; + +export const getPipelineStatusDef: ToolDefinition = { + name: 'GetPipelineStatus', + description: 'Get the status of a GitLab CI/CD pipeline including ref, SHA, and web URL.', + timeoutMs: 30000, + parameters: { + comment: { + type: 'string', + describe: 'Brief rationale for this gadget call', + required: true, + gadgetOnly: true, + }, + projectPath: { + type: 'string', + describe: 'GitLab project path (e.g. group/repo)', + required: true, + cliEnvVar: 'CASCADE_GITLAB_PROJECT_PATH', + }, + pipelineId: { + type: 'number', + describe: 'The pipeline ID', + required: true, + }, + }, + examples: [ + { + params: { + comment: 'Checking CI status before merge', + projectPath: 'acme/myapp', + pipelineId: 12345, + }, + comment: 'Get status for pipeline #12345', + }, + ], + cli: { + autoResolved: projectPathAutoResolved, + }, +}; + +export const getFailedPipelineJobsDef: ToolDefinition = { + name: 'GetFailedPipelineJobs', + description: + 'Get failed jobs from a GitLab CI/CD pipeline. Shows job name, stage, failure reason, and web URL for each failed job.', + timeoutMs: 60000, + parameters: { + comment: { + type: 'string', + describe: 'Brief rationale for this gadget call', + required: true, + gadgetOnly: true, + }, + projectPath: { + type: 'string', + describe: 'GitLab project path (e.g. group/repo)', + required: true, + cliEnvVar: 'CASCADE_GITLAB_PROJECT_PATH', + }, + pipelineId: { + type: 'number', + describe: 'The pipeline ID', + required: true, + }, + }, + examples: [ + { + params: { + comment: 'Fetching failed CI jobs to diagnose test failures', + projectPath: 'acme/myapp', + pipelineId: 12345, + }, + comment: 'Get failed jobs for pipeline #12345', + }, + ], + cli: { + autoResolved: projectPathAutoResolved, + }, +}; + +export const mergeMRDef: ToolDefinition = { + name: 'MergeMR', + description: 'Merge a GitLab merge request. Optionally squash commits.', + timeoutMs: 30000, + parameters: { + comment: { + type: 'string', + describe: 'Brief rationale for this gadget call', + required: true, + gadgetOnly: true, + }, + projectPath: { + type: 'string', + describe: 'GitLab project path (e.g. group/repo)', + required: true, + cliEnvVar: 'CASCADE_GITLAB_PROJECT_PATH', + }, + mrIid: { + type: 'number', + describe: 'The merge request IID', + required: true, + }, + squash: { + type: 'boolean', + describe: 'Whether to squash commits (default: false)', + optional: true, + }, + }, + examples: [ + { + params: { + comment: 'Merging approved MR', + projectPath: 'acme/myapp', + mrIid: 42, + }, + comment: 'Merge MR !42', + }, + { + params: { + comment: 'Merging with squash', + projectPath: 'acme/myapp', + mrIid: 42, + squash: true, + }, + comment: 'Merge MR !42 with squashed commits', + }, + ], + cli: { + autoResolved: projectPathAutoResolved, + }, +}; diff --git a/src/gadgets/gitlab/index.ts b/src/gadgets/gitlab/index.ts new file mode 100644 index 00000000..b6d8035d --- /dev/null +++ b/src/gadgets/gitlab/index.ts @@ -0,0 +1,24 @@ +export { ApproveMR } from './ApproveMR.js'; +export { CreateMR } from './CreateMR.js'; +export { CreateMRReview } from './CreateMRReview.js'; +export { + approveMRDef, + createMRDef, + createMRReviewDef, + getFailedPipelineJobsDef, + getMRDetailsDef, + getMRDiffDef, + getMRNotesDef, + getPipelineStatusDef, + mergeMRDef, + postMRNoteDef, + updateMRNoteDef, +} from './definitions.js'; +export { GetFailedPipelineJobs } from './GetFailedPipelineJobs.js'; +export { GetMRDetails } from './GetMRDetails.js'; +export { GetMRDiff } from './GetMRDiff.js'; +export { GetMRNotes } from './GetMRNotes.js'; +export { GetPipelineStatus } from './GetPipelineStatus.js'; +export { MergeMR } from './MergeMR.js'; +export { PostMRNote } from './PostMRNote.js'; +export { UpdateMRNote } from './UpdateMRNote.js'; diff --git a/src/gadgets/shared/manifestGenerator.ts b/src/gadgets/shared/manifestGenerator.ts index d4a33e5e..084a7257 100644 --- a/src/gadgets/shared/manifestGenerator.ts +++ b/src/gadgets/shared/manifestGenerator.ts @@ -173,9 +173,17 @@ export function generateToolManifest( ): ToolManifest { const parameters: Record = {}; + // Collect auto-resolved param names so we can exclude them from the manifest. + // These are auto-detected at runtime (e.g. owner/repo from git remote) — + // showing them to the agent causes it to construct complex shell expressions + // to fill values that are already resolved automatically. + const autoResolvedNames = new Set((def.cli?.autoResolved ?? []).map((a) => a.paramName)); + for (const [name, paramDef] of Object.entries(def.parameters)) { // Skip gadgetOnly params if (paramDef.gadgetOnly) continue; + // Skip auto-resolved params (owner, repo — resolved from git remote) + if (autoResolvedNames.has(name)) continue; const isRequired = paramDef.required === true; const entry = buildManifestParam(paramDef, isRequired); diff --git a/src/gitlab/client.ts b/src/gitlab/client.ts new file mode 100644 index 00000000..8bec8436 --- /dev/null +++ b/src/gitlab/client.ts @@ -0,0 +1,365 @@ +import { AsyncLocalStorage } from 'node:async_hooks'; +import { Gitlab } from '@gitbeaker/rest'; +import { logger } from '../utils/logging.js'; + +type GitlabClient = InstanceType; + +const clientStorage = new AsyncLocalStorage(); + +function getClient(): GitlabClient { + const scopedClient = clientStorage.getStore(); + if (!scopedClient) { + throw new Error( + 'No GitLab client in scope. Wrap the call with withGitLabToken() or ensure per-project GITLAB_TOKEN is set in the database.', + ); + } + return scopedClient; +} + +export function withGitLabToken( + token: string, + fn: () => Promise, + host: string = 'https://gitlab.com', +): Promise { + const scopedClient = new Gitlab({ token, host }); + return clientStorage.run(scopedClient, fn); +} + +// ============================================================================ +// Types +// ============================================================================ + +export interface MRDetails { + iid: number; + title: string; + description: string | null; + state: string; + webUrl: string; + sourceBranch: string; + targetBranch: string; + sha: string; + merged: boolean; + hasConflicts: boolean; + author: { username: string }; +} + +export interface MRNote { + id: number; + body: string; + author: { username: string }; + createdAt: string; + system: boolean; + resolvable: boolean; + resolved: boolean; +} + +export interface MRDiffFile { + newPath: string; + oldPath: string; + newFile: boolean; + renamedFile: boolean; + deletedFile: boolean; + diff: string; +} + +export interface MRApprovalState { + approved: boolean; + approvedBy: Array<{ username: string }>; +} + +export interface PipelineStatus { + id: number; + status: string; + ref: string; + sha: string; + webUrl: string; +} + +export interface FailedJob { + id: number; + name: string; + stage: string; + status: string; + webUrl: string; + failureReason: string | null; +} + +export interface FailedPipelineJobs { + pipeline: PipelineStatus; + failedJobs: FailedJob[]; +} + +export interface CreateMRParams { + title: string; + description: string; + sourceBranch: string; + targetBranch: string; + draft?: boolean; +} + +export interface CreatedMR { + iid: number; + webUrl: string; + title: string; +} + +// ============================================================================ +// Client +// ============================================================================ + +export const gitlabClient = { + async getMR(projectId: string, mrIid: number): Promise { + logger.debug('Fetching MR', { projectId, mrIid }); + const data = await getClient().MergeRequests.show(projectId, mrIid); + const d = data as Record; + return { + iid: d.iid as number, + title: d.title as string, + description: (d.description as string) ?? null, + state: d.state as string, + webUrl: d.web_url as string, + sourceBranch: d.source_branch as string, + targetBranch: d.target_branch as string, + sha: (d.sha as string) ?? '', + merged: d.state === 'merged', + hasConflicts: (d.has_conflicts as boolean) ?? false, + author: { + username: ((d.author as Record)?.username as string) ?? 'unknown', + }, + }; + }, + + async getMRDiff(projectId: string, mrIid: number): Promise { + logger.debug('Fetching MR diff', { projectId, mrIid }); + const data = await getClient().MergeRequests.allDiffs(projectId, mrIid); + return (data as Array>).map((f) => ({ + newPath: (f.new_path as string) ?? '', + oldPath: (f.old_path as string) ?? '', + newFile: (f.new_file as boolean) ?? false, + renamedFile: (f.renamed_file as boolean) ?? false, + deletedFile: (f.deleted_file as boolean) ?? false, + diff: (f.diff as string) ?? '', + })); + }, + + async getMRNotes(projectId: string, mrIid: number): Promise { + logger.debug('Fetching MR notes', { projectId, mrIid }); + const data = await getClient().MergeRequestNotes.all(projectId, mrIid); + return (data as Array>).map((n) => ({ + id: n.id as number, + body: (n.body as string) ?? '', + author: { + username: ((n.author as Record)?.username as string) ?? 'unknown', + }, + createdAt: (n.created_at as string) ?? '', + system: (n.system as boolean) ?? false, + resolvable: (n.resolvable as boolean) ?? false, + resolved: (n.resolved as boolean) ?? false, + })); + }, + + async createMR(projectId: string, params: CreateMRParams): Promise { + logger.debug('Creating MR', { + projectId, + sourceBranch: params.sourceBranch, + targetBranch: params.targetBranch, + }); + const data = await getClient().MergeRequests.create( + projectId, + params.sourceBranch, + params.targetBranch, + params.title, + { description: params.description }, + ); + const d = data as Record; + return { + iid: d.iid as number, + webUrl: d.web_url as string, + title: d.title as string, + }; + }, + + async createMRNote(projectId: string, mrIid: number, body: string): Promise<{ id: number }> { + logger.debug('Creating MR note', { projectId, mrIid }); + const data = await getClient().MergeRequestNotes.create(projectId, mrIid, body); + return { id: (data as { id: number }).id }; + }, + + async updateMRNote( + projectId: string, + mrIid: number, + noteId: number, + body: string, + ): Promise<{ id: number }> { + logger.debug('Updating MR note', { projectId, mrIid, noteId }); + const data = await getClient().MergeRequestNotes.edit(projectId, mrIid, noteId, { body }); + return { id: (data as { id: number }).id }; + }, + + async deleteMRNote(projectId: string, mrIid: number, noteId: number): Promise { + logger.debug('Deleting MR note', { projectId, mrIid, noteId }); + await getClient().MergeRequestNotes.remove(projectId, mrIid, noteId); + }, + + async getMRApprovals(projectId: string, mrIid: number): Promise { + logger.debug('Fetching MR approval state', { projectId, mrIid }); + const data = await getClient().MergeRequestApprovals.showApprovalState(projectId, mrIid); + const rules = (data as { rules?: Array> }).rules ?? []; + const approvedBy: Array<{ username: string }> = []; + for (const rule of rules) { + const users = (rule.approved_by as Array>) ?? []; + for (const user of users) { + approvedBy.push({ username: (user.username as string) ?? 'unknown' }); + } + } + return { + approved: approvedBy.length > 0, + approvedBy, + }; + }, + + async approveMR(projectId: string, mrIid: number): Promise { + logger.debug('Approving MR', { projectId, mrIid }); + await getClient().MergeRequestApprovals.approve(projectId, mrIid); + }, + + async unapproveMR(projectId: string, mrIid: number): Promise { + logger.debug('Unapproving MR', { projectId, mrIid }); + await getClient().MergeRequestApprovals.unapprove(projectId, mrIid); + }, + + async getPipelineStatus(projectId: string, pipelineId: number): Promise { + logger.debug('Fetching pipeline status', { projectId, pipelineId }); + const data = await getClient().Pipelines.show(projectId, pipelineId); + const d = data as Record; + return { + id: d.id as number, + status: d.status as string, + ref: d.ref as string, + sha: d.sha as string, + webUrl: d.web_url as string, + }; + }, + + async getFailedPipelineJobs(projectId: string, pipelineId: number): Promise { + logger.debug('Fetching failed pipeline jobs', { projectId, pipelineId }); + const client = getClient(); + const pipeline = await this.getPipelineStatus(projectId, pipelineId); + const allJobs = await client.Jobs.all(projectId, { pipelineId }); + const failedJobs = (allJobs as Array>) + .filter((j) => j.status === 'failed') + .map((j) => ({ + id: j.id as number, + name: (j.name as string) ?? '', + stage: (j.stage as string) ?? '', + status: (j.status as string) ?? 'failed', + webUrl: (j.web_url as string) ?? '', + failureReason: (j.failure_reason as string) ?? null, + })); + return { pipeline, failedJobs }; + }, + + async getJobLog(projectId: string, jobId: number): Promise { + logger.debug('Fetching job log', { projectId, jobId }); + const trace = await getClient().Jobs.showLog(projectId, jobId); + // showLog returns the raw log as a string + return typeof trace === 'string' ? trace : String(trace); + }, + + async mergeMR(projectId: string, mrIid: number, options?: { squash?: boolean }): Promise { + logger.debug('Merging MR', { projectId, mrIid, squash: options?.squash }); + await getClient().MergeRequests.accept(projectId, mrIid, { + squash: options?.squash, + }); + }, + + async getOpenMRByBranch(projectId: string, sourceBranch: string): Promise { + logger.debug('Looking up open MR by branch', { projectId, sourceBranch }); + const data = await getClient().MergeRequests.all({ + projectId, + sourceBranch, + state: 'opened', + perPage: 1, + }); + const mrs = data as Array>; + if (mrs.length === 0) return null; + return { + iid: mrs[0].iid as number, + webUrl: mrs[0].web_url as string, + title: mrs[0].title as string, + }; + }, + + async createMRReview( + projectId: string, + mrIid: number, + body: string, + action: 'approve' | 'unapprove', + ): Promise { + logger.debug('Creating MR review', { projectId, mrIid, action }); + if (action === 'approve') { + await this.approveMR(projectId, mrIid); + } else { + await this.unapproveMR(projectId, mrIid); + } + if (body) { + await this.createMRNote(projectId, mrIid, body); + } + }, + + async listPipelines( + projectId: string, + ref: string, + ): Promise> { + logger.debug('Listing pipelines for ref', { projectId, ref }); + const client = getClient(); + + // Try as branch name first + let data = await client.Pipelines.all(projectId, { + ref, + perPage: 5, + orderBy: 'id', + sort: 'desc', + }); + + // If no results and ref looks like a SHA (hex, 7-40 chars), try sha filter + if ((data as Array).length === 0 && /^[0-9a-f]{7,40}$/i.test(ref)) { + logger.debug('No pipelines for ref as branch, trying as SHA', { projectId, ref }); + data = await client.Pipelines.all(projectId, { + sha: ref, + perPage: 5, + orderBy: 'id', + sort: 'desc', + }); + } + + const pipelines = data as Array>; + return pipelines.map((p) => ({ + id: p.id as number, + status: p.status as string, + ref: p.ref as string, + sha: p.sha as string, + webUrl: p.web_url as string, + })); + }, +}; + +// ============================================================================ +// Token Identity Resolution +// ============================================================================ + +export async function getGitLabUserForToken( + token: string | null, + host: string = 'https://gitlab.com', +): Promise { + if (!token) return null; + + try { + const tempClient = new Gitlab({ token, host }); + const user = await tempClient.Users.showCurrentUser(); + return (user as { username?: string }).username ?? null; + } catch (err) { + logger.warn('Failed to resolve GitLab identity for token', { error: String(err) }); + return null; + } +} diff --git a/src/gitlab/index.ts b/src/gitlab/index.ts new file mode 100644 index 00000000..4589241a --- /dev/null +++ b/src/gitlab/index.ts @@ -0,0 +1,27 @@ +export { + type CreatedMR, + type CreateMRParams, + type FailedJob, + type FailedPipelineJobs, + getGitLabUserForToken, + gitlabClient, + type MRApprovalState, + type MRDetails, + type MRDiffFile, + type MRNote, + type PipelineStatus, + withGitLabToken, +} from './client.js'; + +export { + _resetPersonaIdentityCache, + type GitLabPersona, + getPersonaForAgentType, + getPersonaForLogin, + getPersonaToken, + isCascadeBot, + type PersonaIdentities, + resolvePersonaIdentities, +} from './personas.js'; + +export { GitLabSCMIntegration } from './scm-integration.js'; diff --git a/src/gitlab/personas.ts b/src/gitlab/personas.ts new file mode 100644 index 00000000..c1a5c831 --- /dev/null +++ b/src/gitlab/personas.ts @@ -0,0 +1,157 @@ +import { getIntegrationCredential } from '../config/provider.js'; +import { logger } from '../utils/logging.js'; +import { getGitLabUserForToken } from './client.js'; + +// ============================================================================ +// Types +// ============================================================================ + +export type GitLabPersona = 'implementer' | 'reviewer'; + +export interface PersonaIdentities { + implementer: string; + reviewer: string; +} + +// ============================================================================ +// Agent → Persona Mapping +// ============================================================================ + +/** + * Maps agent types to their GitLab personas. + * + * This is the canonical registration point for agent persona assignments. + * - `'implementer'` — uses the implementer GitLab token for all SCM operations + * - `'reviewer'` — uses the reviewer GitLab token, appropriate for agents + * that submit MR reviews (e.g. the built-in `review` agent) + * + * To add a custom agent with reviewer behaviour, add an entry here: + * ```ts + * 'my-custom-reviewer': 'reviewer', + * ``` + * Any agent type not listed here defaults to `'implementer'`. + */ +const AGENT_PERSONA_MAP: Record = { + splitting: 'implementer', + planning: 'implementer', + implementation: 'implementer', + 'respond-to-review': 'implementer', + 'respond-to-ci': 'implementer', + 'respond-to-pr-comment': 'implementer', + 'respond-to-planning-comment': 'implementer', + review: 'reviewer', + debug: 'implementer', +}; + +export function getPersonaForAgentType(agentType: string): GitLabPersona { + return AGENT_PERSONA_MAP[agentType] ?? 'implementer'; +} + +// ============================================================================ +// Token Resolution +// ============================================================================ + +/** + * Resolve the correct GitLab token for a project + agent type based on persona. + * Uses integration credentials linked to the SCM integration. + * Throws if no token is found. + */ +export async function getPersonaToken(projectId: string, agentType: string): Promise { + const persona = getPersonaForAgentType(agentType); + const role = persona === 'implementer' ? 'implementer_token' : 'reviewer_token'; + + return getIntegrationCredential(projectId, 'scm', role); +} + +// ============================================================================ +// Identity Resolution +// ============================================================================ + +const PERSONA_CACHE_TTL_MS = 60_000; // 60 seconds — matches the Trello/JIRA BotIdentityCache TTL + +interface CacheEntry { + value: PersonaIdentities; + expiresAt: number; +} + +// Per-project TTL cache for persona identities. +// Unlike BotIdentityCache, errors are re-thrown so callers retain error semantics. +const personaIdentityCache = new Map(); + +/** + * Resolve both persona GitLab usernames for a project. + * Results are cached per-project with a 60s TTL to avoid redundant DB + API calls + * on rapid successive webhooks (e.g. multiple events within the same request batch). + * Errors are re-thrown so callers can handle credential failures. + */ +export async function resolvePersonaIdentities(projectId: string): Promise { + const cached = personaIdentityCache.get(projectId); + if (cached && Date.now() < cached.expiresAt) return cached.value; + + // Parallelize credential lookups to halve round-trip latency + const [implementerToken, reviewerToken] = await Promise.all([ + getIntegrationCredential(projectId, 'scm', 'implementer_token'), + getIntegrationCredential(projectId, 'scm', 'reviewer_token'), + ]); + + const [implementerLogin, reviewerLogin] = await Promise.all([ + getGitLabUserForToken(implementerToken), + getGitLabUserForToken(reviewerToken), + ]); + + if (!implementerLogin) { + throw new Error( + `Failed to resolve GitLab identity for implementer token in project '${projectId}'`, + ); + } + if (!reviewerLogin) { + throw new Error( + `Failed to resolve GitLab identity for reviewer token in project '${projectId}'`, + ); + } + + const identities: PersonaIdentities = { + implementer: implementerLogin, + reviewer: reviewerLogin, + }; + + logger.info('Resolved persona identities', { + projectId, + implementer: implementerLogin, + reviewer: reviewerLogin, + }); + + personaIdentityCache.set(projectId, { + value: identities, + expiresAt: Date.now() + PERSONA_CACHE_TTL_MS, + }); + return identities; +} + +/** @internal Visible for testing only */ +export function _resetPersonaIdentityCache(): void { + personaIdentityCache.clear(); +} + +// ============================================================================ +// Bot Detection +// ============================================================================ + +/** + * Check if a GitLab username belongs to either CASCADE persona. + */ +export function isCascadeBot(username: string, identities: PersonaIdentities): boolean { + return username === identities.implementer || username === identities.reviewer; +} + +/** + * Get the persona for a GitLab username, or null if not a known persona. + */ +export function getPersonaForLogin( + username: string, + identities: PersonaIdentities, +): GitLabPersona | null { + if (username === identities.implementer) return 'implementer'; + if (username === identities.reviewer) return 'reviewer'; + return null; +} diff --git a/src/gitlab/scm-integration.ts b/src/gitlab/scm-integration.ts new file mode 100644 index 00000000..7717ce7d --- /dev/null +++ b/src/gitlab/scm-integration.ts @@ -0,0 +1,56 @@ +/** + * GitLabSCMIntegration — implements SCMIntegration for GitLab. + * + * Encapsulates GitLab SCM credential resolution and validation + * into a unified integration class following the IntegrationModule pattern. + * + * Provides: + * - `hasIntegration()` — checks if at least one token (implementer or reviewer) is configured + * - `hasPersonaToken()` — checks if a specific persona token is configured + * - `withCredentials()` — runs a function within the implementer token credential scope + */ + +import { getIntegrationCredential, getIntegrationCredentialOrNull } from '../config/provider.js'; +import { getIntegrationProvider } from '../db/repositories/credentialsRepository.js'; +import type { SCMIntegration } from '../integrations/scm.js'; +import { withGitLabToken } from './client.js'; + +export class GitLabSCMIntegration implements SCMIntegration { + readonly type = 'gitlab'; + readonly category = 'scm' as const; + + /** + * Check if GitLab SCM integration is configured for a project. + * Returns true if the integration exists and has at least one token linked. + */ + async hasIntegration(projectId: string): Promise { + const provider = await getIntegrationProvider(projectId, 'scm'); + if (!provider) return false; + + // Check if either token is available (some agents only need one) + const [impl, rev] = await Promise.all([ + getIntegrationCredentialOrNull(projectId, 'scm', 'implementer_token'), + getIntegrationCredentialOrNull(projectId, 'scm', 'reviewer_token'), + ]); + + return impl !== null || rev !== null; + } + + /** + * Check if a specific SCM persona token is configured for a project. + */ + async hasPersonaToken(projectId: string, persona: 'implementer' | 'reviewer'): Promise { + const role = persona === 'implementer' ? 'implementer_token' : 'reviewer_token'; + const token = await getIntegrationCredentialOrNull(projectId, 'scm', role); + return token !== null; + } + + /** + * Resolve the implementer token from credentials and run `fn` within that + * GitLab credential scope. Follows the same pattern as GitHubSCMIntegration.withCredentials(). + */ + async withCredentials(projectId: string, fn: () => Promise): Promise { + const token = await getIntegrationCredential(projectId, 'scm', 'implementer_token'); + return withGitLabToken(token, fn); + } +} diff --git a/src/integrations/bootstrap.ts b/src/integrations/bootstrap.ts index dd93db4e..f0aef4f0 100644 --- a/src/integrations/bootstrap.ts +++ b/src/integrations/bootstrap.ts @@ -24,6 +24,7 @@ */ import { GitHubSCMIntegration } from '../github/scm-integration.js'; +import { GitLabSCMIntegration } from '../gitlab/scm-integration.js'; import { integrationRegistry } from '../integrations/registry.js'; import { JiraIntegration } from '../pm/jira/integration.js'; import { pmRegistry } from '../pm/registry.js'; @@ -43,6 +44,9 @@ if (!pmRegistry.getOrNull('jira')) { if (!integrationRegistry.getOrNull('github')) { integrationRegistry.register(new GitHubSCMIntegration()); } +if (!integrationRegistry.getOrNull('gitlab')) { + integrationRegistry.register(new GitLabSCMIntegration()); +} if (!integrationRegistry.getOrNull('sentry')) { integrationRegistry.register(new SentryAlertingIntegration()); } diff --git a/src/router/adapters/gitlab.ts b/src/router/adapters/gitlab.ts new file mode 100644 index 00000000..f80ab64b --- /dev/null +++ b/src/router/adapters/gitlab.ts @@ -0,0 +1,239 @@ +/** + * GitLabRouterAdapter — platform-specific logic for the router-side + * GitLab webhook processing pipeline. + * + * Follows the same `RouterPlatformAdapter` pattern as the GitHub adapter + * but tailored for GitLab webhook events (Merge Request Hook, Note Hook, + * Pipeline Hook, Push Hook). + */ + +import { getIntegrationCredential } from '../../config/provider.js'; +import { withGitLabToken } from '../../gitlab/client.js'; +import { type PersonaIdentities, resolvePersonaIdentities } from '../../gitlab/personas.js'; +import { withPMCredentials, withPMProvider } from '../../pm/context.js'; +import { pmRegistry } from '../../pm/registry.js'; +import type { TriggerRegistry } from '../../triggers/registry.js'; +import type { TriggerContext, TriggerResult } from '../../types/index.js'; +import { logger } from '../../utils/logging.js'; +import { loadProjectConfig, type RouterProjectConfig } from '../config.js'; +import type { AckResult, ParsedWebhookEvent, RouterPlatformAdapter } from '../platform-adapter.js'; +import { GitLabPlatformClient } from '../platformClients/gitlab.js'; +import type { CascadeJob, GitLabJob } from '../queue.js'; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +/** GitLab event types that CASCADE can process. */ +const PROCESSABLE_EVENTS = ['Merge Request Hook', 'Note Hook', 'Pipeline Hook', 'Push Hook']; + +// --------------------------------------------------------------------------- +// Parsed event — extends the base with GitLab-specific fields +// --------------------------------------------------------------------------- + +interface GitLabParsedEvent extends ParsedWebhookEvent { + /** GitLab project path (e.g. "group/subgroup/repo"). */ + projectPath: string; +} + +// --------------------------------------------------------------------------- +// Adapter +// --------------------------------------------------------------------------- + +export class GitLabRouterAdapter implements RouterPlatformAdapter { + readonly type = 'gitlab' as const; + + async parseWebhook(payload: unknown): Promise { + const p = payload as Record; + const eventType = (p._eventType as string) || 'unknown'; + + if (!PROCESSABLE_EVENTS.includes(eventType)) return null; + + // GitLab sends project info at payload.project.path_with_namespace + const project = p.project as Record | undefined; + const projectPath = (project?.path_with_namespace as string) || 'unknown'; + + const isCommentEvent = eventType === 'Note Hook'; + + // Extract MR IID if available + let workItemId: string | undefined; + const objectAttributes = p.object_attributes as Record | undefined; + if (eventType === 'Merge Request Hook' && objectAttributes?.iid) { + workItemId = String(objectAttributes.iid); + } else if (eventType === 'Note Hook') { + const mr = p.merge_request as Record | undefined; + if (mr?.iid) workItemId = String(mr.iid); + } else if (eventType === 'Pipeline Hook') { + const mr = p.merge_request as Record | undefined; + if (mr?.iid) workItemId = String(mr.iid); + } + + return { + projectIdentifier: projectPath, + eventType, + workItemId, + isCommentEvent, + projectPath, + }; + } + + isProcessableEvent(event: ParsedWebhookEvent): boolean { + return PROCESSABLE_EVENTS.includes(event.eventType); + } + + async isSelfAuthored(event: ParsedWebhookEvent, payload: unknown): Promise { + // Only relevant for comment (Note Hook) events + if (!event.isCommentEvent) return false; + const p = payload as Record; + const user = p.user as Record | undefined; + const username = user?.username as string | undefined; + if (!username) return false; + + // Look up the project to check if the commenter is a CASCADE bot. + // GitLab persona detection will be implemented when the GitLab integration + // module ships; for now return false (no self-authored detection). + try { + const projectPath = (event as GitLabParsedEvent).projectPath; + const config = await loadProjectConfig(); + const fullProject = config.fullProjects.find((fp) => fp.repo === projectPath); + if (!fullProject) return false; + // TODO: Implement GitLab persona detection (isCascadeBot equivalent) + return false; + } catch { + return false; + } + } + + sendReaction(_event: ParsedWebhookEvent, _payload: unknown): void { + // GitLab emoji reactions could be implemented here in the future. + // For now, no-op. + } + + async resolveProject(event: ParsedWebhookEvent): Promise { + const projectPath = (event as GitLabParsedEvent).projectPath; + const config = await loadProjectConfig(); + // Match by GitLab project path stored in the project's repo field + return config.projects.find((p) => p.repo === projectPath) ?? null; + } + + async dispatchWithCredentials( + event: ParsedWebhookEvent, + payload: unknown, + _project: RouterProjectConfig, + triggerRegistry: TriggerRegistry, + ): Promise { + const projectPath = (event as GitLabParsedEvent).projectPath; + const config = await loadProjectConfig(); + const fullProject = config.fullProjects.find((fp) => fp.repo === projectPath); + if (!fullProject) { + logger.info('No project for GitLab path, skipping dispatch', { projectPath }); + return null; + } + + // Resolve persona identities for author-mode checks in triggers + let personaIdentities: PersonaIdentities | undefined; + try { + personaIdentities = await resolvePersonaIdentities(fullProject.id); + } catch (err) { + logger.warn('Failed to resolve GitLab persona identities', { + projectId: fullProject.id, + error: String(err), + }); + } + + const gitlabToken = await getIntegrationCredential(fullProject.id, 'scm', 'implementer_token'); + const pmProvider = pmRegistry.createProvider(fullProject); + + const ctx: TriggerContext = { + project: fullProject, + source: 'gitlab', + payload, + personaIdentities, + }; + + return withPMCredentials( + fullProject.id, + fullProject.pm?.type, + (t) => pmRegistry.getOrNull(t), + () => + withPMProvider(pmProvider, () => + withGitLabToken(gitlabToken, () => triggerRegistry.dispatch(ctx)), + ), + ); + } + + async postAck( + event: ParsedWebhookEvent, + _payload: unknown, + project: RouterProjectConfig, + agentType: string, + _triggerResult?: TriggerResult, + ): Promise { + try { + const mrIid = event.workItemId; + if (!mrIid) return undefined; + + const projectPath = (event as GitLabParsedEvent).projectPath; + let token: string; + try { + token = await getIntegrationCredential(project.id, 'scm', 'implementer_token'); + } catch { + logger.warn('GitLab ack: missing implementer_token, skipping', { + projectId: project.id, + }); + return undefined; + } + + // Default to gitlab.com; self-hosted instances would need host + // configuration via integration config (resolved at the DB level). + const host = 'https://gitlab.com'; + + const client = new GitLabPlatformClient(projectPath, token, host); + const message = `CASCADE \`${agentType}\` agent is processing this merge request...`; + const noteId = await client.postComment(Number(mrIid), message); + if (noteId != null) return { commentId: noteId, message }; + } catch (err) { + logger.warn('GitLab ack comment failed (non-fatal)', { error: String(err) }); + } + return undefined; + } + + buildJob( + event: ParsedWebhookEvent, + payload: unknown, + _project: RouterProjectConfig, + result: TriggerResult, + ackResult?: AckResult, + ): CascadeJob { + const job: GitLabJob = { + type: 'gitlab', + source: 'gitlab', + payload, + eventType: event.eventType, + projectPath: (event as GitLabParsedEvent).projectPath, + receivedAt: new Date().toISOString(), + triggerResult: result, + ackCommentId: ackResult?.commentId as number | undefined, + ackMessage: ackResult?.message, + }; + return job; + } + + firePreActions(_job: CascadeJob, _payload: unknown): void { + // No pre-actions for GitLab currently + } +} + +/** + * Inject the event type into the payload object for the adapter's parseWebhook. + * GitLab event type comes from the X-Gitlab-Event header, not the body. + */ +export function injectGitLabEventType( + payload: unknown, + eventType: string, +): Record { + return { + ...(payload as Record), + _eventType: eventType, + }; +} diff --git a/src/router/index.ts b/src/router/index.ts index 7ad590d3..e6908f69 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -14,11 +14,13 @@ import { logger } from '../utils/logging.js'; import { createWebhookHandler, parseGitHubPayload, + parseGitLabPayload, parseJiraPayload, parseSentryPayload, parseTrelloPayload, } from '../webhook/webhookHandlers.js'; import { GitHubRouterAdapter, injectEventType } from './adapters/github.js'; +import { GitLabRouterAdapter, injectGitLabEventType } from './adapters/gitlab.js'; import { JiraRouterAdapter } from './adapters/jira.js'; import { SentryRouterAdapter } from './adapters/sentry.js'; import { TrelloRouterAdapter } from './adapters/trello.js'; @@ -27,6 +29,7 @@ import { getQueueStats } from './queue.js'; import { processRouterWebhook } from './webhook-processor.js'; import { verifyGitHubWebhookSignature, + verifyGitLabWebhookSignature, verifyJiraWebhookSignature, verifySentryWebhookSignature, verifyTrelloWebhookSignature, @@ -146,6 +149,31 @@ app.post( }), ); +// GitLab webhook verification +app.get('/gitlab/webhook', (c) => { + return c.text('OK', 200); +}); + +// GitLab webhook handler +app.post( + '/gitlab/webhook', + createWebhookHandler({ + source: 'gitlab', + parsePayload: parseGitLabPayload, + verifySignature: verifyGitLabWebhookSignature, + processWebhook: async (payload, eventType) => { + const adapter = new GitLabRouterAdapter(); + const augmented = injectGitLabEventType(payload, eventType ?? 'unknown'); + const result = await processRouterWebhook(adapter, augmented, triggerRegistry); + return { + processed: result.shouldProcess, + projectId: result.projectId, + decisionReason: result.decisionReason, + }; + }, + }), +); + // Sentry webhook handler (alerting integration) // Uses project-specific URLs: /sentry/webhook/:projectId // The projectId in the URL is the CASCADE project ID, making routing unambiguous. diff --git a/src/router/notifications.ts b/src/router/notifications.ts index a885216f..839f362f 100644 --- a/src/router/notifications.ts +++ b/src/router/notifications.ts @@ -147,6 +147,9 @@ export async function notifyTimeout(job: CascadeJob, info: TimeoutInfo): Promise await notifyGitHubTimeout(job, info); } else if (job.type === 'jira') { await notifyJiraTimeout(job, info); + } else if (job.type === 'gitlab') { + // GitLab timeout notifications not yet implemented + logger.info('[Notifications] GitLab timeout notification not yet implemented, skipping'); } else { logger.warn('[Notifications] Unknown job type, skipping notification'); } diff --git a/src/router/platformClients/credentials.ts b/src/router/platformClients/credentials.ts index eef48516..bd1a8446 100644 --- a/src/router/platformClients/credentials.ts +++ b/src/router/platformClients/credentials.ts @@ -64,8 +64,11 @@ export async function resolveJiraCredentials( */ export async function resolveWebhookSecret( projectId: string, - provider: 'github' | 'trello' | 'jira' | 'sentry', + provider: 'github' | 'gitlab' | 'trello' | 'jira' | 'sentry', ): Promise { + if (provider === 'gitlab') { + return getIntegrationCredentialOrNull(projectId, 'scm', 'webhook_secret'); + } if (provider === 'github') { return getIntegrationCredentialOrNull(projectId, 'scm', 'webhook_secret'); } diff --git a/src/router/platformClients/gitlab.ts b/src/router/platformClients/gitlab.ts new file mode 100644 index 00000000..db6b42d1 --- /dev/null +++ b/src/router/platformClients/gitlab.ts @@ -0,0 +1,53 @@ +/** + * GitLab platform client for MR note (comment) operations. + * + * Uses raw `fetch()` against the GitLab REST API — the router Docker image + * does not bundle heavy SDK dependencies. + */ + +import { logger } from '../../utils/logging.js'; + +export class GitLabPlatformClient { + constructor( + private readonly projectPath: string, + private readonly token: string, + private readonly host: string = 'https://gitlab.com', + ) {} + + async postComment(mrIid: number, message: string): Promise { + try { + const encodedPath = encodeURIComponent(this.projectPath); + const url = `${this.host}/api/v4/projects/${encodedPath}/merge_requests/${mrIid}/notes`; + const response = await fetch(url, { + method: 'POST', + headers: { + 'PRIVATE-TOKEN': this.token, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ body: message }), + }); + if (!response.ok) { + logger.warn('GitLab postComment failed', { status: response.status }); + return null; + } + const data = (await response.json()) as Record; + return data.id as number; + } catch (err) { + logger.warn('GitLab postComment error', { error: String(err) }); + return null; + } + } + + async deleteComment(mrIid: number, noteId: number): Promise { + try { + const encodedPath = encodeURIComponent(this.projectPath); + const url = `${this.host}/api/v4/projects/${encodedPath}/merge_requests/${mrIid}/notes/${noteId}`; + await fetch(url, { + method: 'DELETE', + headers: { 'PRIVATE-TOKEN': this.token }, + }); + } catch (err) { + logger.warn('GitLab deleteComment error', { error: String(err) }); + } + } +} diff --git a/src/router/platformClients/index.ts b/src/router/platformClients/index.ts index e9b4f9b9..b7cac6d4 100644 --- a/src/router/platformClients/index.ts +++ b/src/router/platformClients/index.ts @@ -14,6 +14,7 @@ export { resolveTrelloCredentials, } from './credentials.js'; export { GitHubPlatformClient } from './github.js'; +export { GitLabPlatformClient } from './gitlab.js'; export { _resetJiraCloudIdCache, JiraPlatformClient } from './jira.js'; export { TrelloPlatformClient } from './trello.js'; export type { JiraCredentialsWithAuth, PlatformCommentClient, TrelloCredentials } from './types.js'; diff --git a/src/router/queue.ts b/src/router/queue.ts index f35035cb..369de1a5 100644 --- a/src/router/queue.ts +++ b/src/router/queue.ts @@ -58,7 +58,19 @@ export interface SentryJob { triggerResult?: TriggerResult; } -export type CascadeJob = TrelloJob | GitHubJob | JiraJob | SentryJob; +export interface GitLabJob { + type: 'gitlab'; + source: 'gitlab'; + payload: unknown; + eventType: string; + projectPath: string; + receivedAt: string; + ackCommentId?: number; + ackMessage?: string; + triggerResult?: TriggerResult; +} + +export type CascadeJob = TrelloJob | GitHubJob | JiraJob | SentryJob | GitLabJob; // Create the job queue export const jobQueue = new Queue('cascade-jobs', { diff --git a/src/router/reactions.ts b/src/router/reactions.ts index 010f844d..55e4e57b 100644 --- a/src/router/reactions.ts +++ b/src/router/reactions.ts @@ -189,6 +189,8 @@ export async function sendAcknowledgeReaction( await sendGitHubReaction(projectId, payload, personaIdentities, project); } else if (source === 'jira') { await sendJiraReaction(projectId, payload); + } else if (source === 'gitlab') { + // GitLab emoji reactions not yet implemented } } catch (err) { logger.error('[Reactions] Unexpected error sending reaction:', String(err)); diff --git a/src/router/webhookVerification.ts b/src/router/webhookVerification.ts index da279c79..debeb08b 100644 --- a/src/router/webhookVerification.ts +++ b/src/router/webhookVerification.ts @@ -9,6 +9,7 @@ import type { Context } from 'hono'; import { logger } from '../utils/logging.js'; import { verifyGitHubSignature, + verifyGitLabSignature, verifyJiraSignature, verifySentrySignature, verifyTrelloSignature, @@ -17,7 +18,7 @@ import { loadProjectConfig, routerConfig } from './config.js'; import { resolveWebhookSecret } from './platformClients/credentials.js'; /** The set of platforms that have a webhook secret in {@link resolveWebhookSecret}. */ -type WebhookPlatform = 'github' | 'trello' | 'jira' | 'sentry'; +type WebhookPlatform = 'github' | 'gitlab' | 'trello' | 'jira' | 'sentry'; // --------------------------------------------------------------------------- // Helpers @@ -235,6 +236,32 @@ export const verifySentryWebhookSignature = createWebhookVerifier({ verify: (rawBody, sig, secret) => verifySentrySignature(rawBody, sig, secret), }); +/** + * verifySignature callback for the GitLab webhook handler. + * Returns null to skip verification when no secret is configured (backwards compat). + * + * GitLab sends a pre-shared secret token in the `X-Gitlab-Token` header. + * Verification is a timing-safe direct comparison (not HMAC). + */ +export const verifyGitLabWebhookSignature = createWebhookVerifier({ + headerName: 'X-Gitlab-Token', + platform: 'gitlab', + platformLabel: 'GitLab', + extractIdentifier: (_c, rawBody) => { + try { + const parsed = JSON.parse(rawBody) as Record; + return (parsed?.project as Record)?.path_with_namespace as + | string + | undefined; + } catch { + return undefined; + } + }, + findProject: (projectPath, projects) => + projects.find((p) => p.repo === projectPath) as { id: string } | undefined, + verify: (_rawBody, sig, secret) => verifyGitLabSignature('', sig, secret), +}); + /** * Extract the JIRA project key from a raw webhook payload. * JIRA sends the project key at `issue.fields.project.key`. diff --git a/src/router/worker-env.ts b/src/router/worker-env.ts index d7b7f055..31f777a2 100644 --- a/src/router/worker-env.ts +++ b/src/router/worker-env.ts @@ -7,6 +7,7 @@ import type { Job } from 'bullmq'; import { findProjectByRepo, getAllProjectCredentials } from '../config/provider.js'; +import { getIntegrationProvider } from '../db/repositories/credentialsRepository.js'; import { captureException } from '../sentry.js'; import { logger } from '../utils/logging.js'; import { routerConfig } from './config.js'; @@ -26,9 +27,12 @@ export async function extractProjectIdFromJob(data: CascadeJob): Promise { + logger.debug('GitLab ack note posting not yet implemented'); + return null; +} + +/** + * Update an existing MR note with an error message when the agent fails. + * Currently a no-op stub — requires GitLab API client. + */ +export async function updateNoteWithError( + _projectPath: string, + _mrIid: number, + _noteId: number, + _error: string, +): Promise { + logger.debug('GitLab note update not yet implemented'); +} + +/** + * Delete the progress note after a successful agent run. + * Currently a no-op stub — requires GitLab API client. + */ +export async function deleteProgressNoteOnSuccess( + _result: TriggerResult, + _agentResult: AgentResult, +): Promise { + logger.debug('GitLab progress note deletion not yet implemented'); +} + +/** + * Update the initial MR note with an error message when the agent fails. + * Currently a no-op stub — requires GitLab API client. + */ +export async function updateInitialNoteWithError( + _result: TriggerResult, + _agentResult: { success: boolean; error?: string }, +): Promise { + logger.debug('GitLab initial note error update not yet implemented'); +} diff --git a/src/triggers/gitlab/index.ts b/src/triggers/gitlab/index.ts new file mode 100644 index 00000000..8331f789 --- /dev/null +++ b/src/triggers/gitlab/index.ts @@ -0,0 +1,12 @@ +export { MRApprovalTrigger } from './mr-approval.js'; +export { MRCommentMentionTrigger } from './mr-comment-mention.js'; +export { MRConflictDetectedTrigger } from './mr-conflict-detected.js'; +export { MRMergedTrigger } from './mr-merged.js'; +export { MROpenedTrigger } from './mr-opened.js'; +export { MRReadyToMergeTrigger } from './mr-ready-to-merge.js'; +export { PipelineFailureTrigger } from './pipeline-failure.js'; +export { PipelineSuccessTrigger } from './pipeline-success.js'; +export { registerGitLabTriggers } from './register.js'; +export * from './types.js'; +export * from './utils.js'; +export { processGitLabWebhook } from './webhook-handler.js'; diff --git a/src/triggers/gitlab/integration.ts b/src/triggers/gitlab/integration.ts new file mode 100644 index 00000000..0fc7a46e --- /dev/null +++ b/src/triggers/gitlab/integration.ts @@ -0,0 +1,126 @@ +/** + * GitLabWebhookIntegration — adapts GitLab webhooks to the PMIntegration interface. + * + * Mirrors GitHubWebhookIntegration: project lookup by path_with_namespace, + * persona token credential scoping, and GitLab-specific AgentExecutionConfig. + */ + +import { loadProjectConfigByRepo } from '../../config/provider.js'; +import { withGitHubToken } from '../../github/client.js'; +import { getPersonaToken } from '../../github/personas.js'; +import type { PMIntegration, PMWebhookEvent } from '../../pm/integration.js'; +import type { ProjectPMConfig } from '../../pm/lifecycle.js'; +import type { PMProvider } from '../../pm/types.js'; +import type { CascadeConfig, ProjectConfig } from '../../types/index.js'; +import type { AgentExecutionConfig } from '../shared/agent-execution.js'; +import { deleteProgressNoteOnSuccess, updateInitialNoteWithError } from './ack-comments.js'; + +export class GitLabWebhookIntegration implements PMIntegration { + readonly type = 'gitlab'; + readonly category = 'pm' as const; + + async hasIntegration(_projectId: string): Promise { + return false; + } + + createProvider(_project: ProjectConfig): PMProvider { + throw new Error( + 'GitLabWebhookIntegration does not use a PM provider. ' + + 'Use integration.withCredentials() and runAgentExecutionPipeline() directly.', + ); + } + + async withCredentials(projectId: string, fn: () => Promise): Promise { + const githubToken = await getPersonaToken(projectId, 'implementation'); + return withGitHubToken(githubToken, fn); + } + + resolveLifecycleConfig(_project: ProjectConfig): ProjectPMConfig { + return { + labels: {}, + statuses: {}, + }; + } + + parseWebhookPayload(raw: unknown): PMWebhookEvent | null { + if (!raw || typeof raw !== 'object') return null; + const p = raw as Record; + const project = p.project as Record | undefined; + const pathWithNamespace = project?.path_with_namespace as string | undefined; + + if (!pathWithNamespace) { + return null; + } + + const eventType = this.detectEventType(p); + + return { + eventType, + projectIdentifier: pathWithNamespace, + workItemId: undefined, + raw, + }; + } + + async isSelfAuthored(_event: PMWebhookEvent, _projectId: string): Promise { + return false; + } + + async postAckComment( + _projectId: string, + _workItemId: string, + _message: string, + ): Promise { + return null; + } + + async deleteAckComment( + _projectId: string, + _workItemId: string, + _commentId: string, + ): Promise { + // No-op + } + + async sendReaction(_projectId: string, _event: PMWebhookEvent): Promise { + // No-op + } + + async lookupProject( + identifier: string, + ): Promise<{ project: ProjectConfig; config: CascadeConfig } | null> { + const result = await loadProjectConfigByRepo(identifier); + return result ?? null; + } + + extractWorkItemId(_text: string): string | null { + return null; + } + + resolveExecutionConfig(): AgentExecutionConfig { + return { + skipPrepareForAgent: true, + skipHandleFailure: true, + handleSuccessOnlyForAgentType: 'implementation', + onSuccess: deleteProgressNoteOnSuccess, + onFailure: updateInitialNoteWithError, + logLabel: 'GitLab agent', + }; + } + + // --------------------------------------------------------------------------- + // Private helpers + // --------------------------------------------------------------------------- + + private detectEventType(p: Record): string { + const objectKind = p.object_kind as string | undefined; + if (objectKind === 'merge_request') { + const attrs = p.object_attributes as Record | undefined; + const action = attrs?.action as string | undefined; + return action ? `merge_request.${action}` : 'merge_request'; + } + if (objectKind === 'pipeline') return 'pipeline'; + if (objectKind === 'note') return 'note'; + return objectKind ?? 'unknown'; + } +} diff --git a/src/triggers/gitlab/mr-approval.ts b/src/triggers/gitlab/mr-approval.ts new file mode 100644 index 00000000..721856d1 --- /dev/null +++ b/src/triggers/gitlab/mr-approval.ts @@ -0,0 +1,90 @@ +/** + * GitLab MR Approval trigger. + * + * Triggers respond-to-review when a MR is unapproved (equivalent to + * GitHub's changes_requested review). Approved MRs are handled by + * MRReadyToMergeTrigger instead. + * + * GitLab fires merge_request hooks with action 'approved' or 'unapproved'. + */ + +import type { TriggerContext, TriggerHandler, TriggerResult } from '../../types/index.js'; +import { logger } from '../../utils/logging.js'; +import { checkTriggerEnabled } from '../shared/trigger-check.js'; +import { type GitLabMergeRequestPayload, isGitLabMergeRequestPayload } from './types.js'; +import { resolveWorkItemId } from './utils.js'; + +export class MRApprovalTrigger implements TriggerHandler { + name = 'gitlab:mr-approval'; + description = 'Triggers respond-to-review when a GitLab MR is unapproved'; + + matches(ctx: TriggerContext): boolean { + if (ctx.source !== 'gitlab') return false; + if (!isGitLabMergeRequestPayload(ctx.payload)) return false; + + const action = ctx.payload.object_attributes.action; + + // Only respond to unapproved (changes requested) — approved is handled by ready-to-merge + if (action !== 'unapproved') return false; + + return true; + } + + async handle(ctx: TriggerContext): Promise { + // Check trigger config via DB-driven system + if ( + !(await checkTriggerEnabled( + ctx.project.id, + 'respond-to-review', + 'scm:pr-review-submitted', + this.name, + )) + ) { + return null; + } + + const payload = ctx.payload as GitLabMergeRequestPayload; + const mrIid = payload.object_attributes.iid; + const reviewAuthor = payload.user.username; + + // Only respond to reviews from the reviewer persona + if (!ctx.personaIdentities) { + logger.warn('No persona identities available, skipping MR approval trigger', { mrIid }); + return null; + } + + if (reviewAuthor !== ctx.personaIdentities.reviewer) { + logger.info('Skipping MR unapproval not from reviewer persona', { + mrIid, + reviewAuthor, + expectedReviewer: ctx.personaIdentities.reviewer, + }); + return null; + } + + // Resolve work item from DB + const workItemId = await resolveWorkItemId(ctx.project.id, mrIid); + + logger.info('MR unapproved by reviewer persona, triggering respond-to-review', { + mrIid, + reviewAuthor, + workItemId, + }); + + return { + agentType: 'respond-to-review', + agentInput: { + prNumber: mrIid, + prBranch: payload.object_attributes.source_branch, + repoFullName: payload.project.path_with_namespace, + triggerEvent: 'scm:pr-review-submitted', + triggerCommentBody: 'MR unapproved (changes requested)', + triggerCommentPath: '', + }, + prNumber: mrIid, + prUrl: payload.object_attributes.url, + prTitle: payload.object_attributes.title, + workItemId, + }; + } +} diff --git a/src/triggers/gitlab/mr-comment-mention.ts b/src/triggers/gitlab/mr-comment-mention.ts new file mode 100644 index 00000000..44c6029c --- /dev/null +++ b/src/triggers/gitlab/mr-comment-mention.ts @@ -0,0 +1,107 @@ +/** + * GitLab MR Comment Mention trigger. + * + * Triggers respond-to-pr-comment when someone @mentions the implementer bot + * in a Note on a Merge Request. Returns null (falls through) when there's + * no @mention, allowing existing triggers to handle the event. + */ + +import type { TriggerContext, TriggerHandler, TriggerResult } from '../../types/index.js'; +import { logger } from '../../utils/logging.js'; +import { checkTriggerEnabled } from '../shared/trigger-check.js'; +import { type GitLabNotePayload, isGitLabNotePayload } from './types.js'; +import { resolveWorkItemId } from './utils.js'; + +export class MRCommentMentionTrigger implements TriggerHandler { + name = 'gitlab:mr-comment-mention'; + description = 'Triggers respond-to-pr-comment when someone @mentions the bot in a GitLab MR note'; + + matches(ctx: TriggerContext): boolean { + if (ctx.source !== 'gitlab') return false; + if (!isGitLabNotePayload(ctx.payload)) return false; + + // Only match notes on merge requests + if (ctx.payload.object_attributes.noteable_type !== 'MergeRequest') return false; + + // Must have an associated merge request + if (!ctx.payload.merge_request) return false; + + return true; + } + + async handle(ctx: TriggerContext): Promise { + // Check trigger config via DB-driven system + if ( + !(await checkTriggerEnabled( + ctx.project.id, + 'respond-to-pr-comment', + 'scm:pr-comment-mention', + this.name, + )) + ) { + return null; + } + + // Require persona identities for @mention detection + if (!ctx.personaIdentities) { + logger.warn('No persona identities available, skipping @mention trigger'); + return null; + } + + const payload = ctx.payload as GitLabNotePayload; + const commentBody = payload.object_attributes.note; + const commentAuthor = payload.user.username; + const mr = payload.merge_request!; + const mrIid = mr.iid; + + // The implementer persona is who humans @mention (it writes code and responds) + const mentionTarget = ctx.personaIdentities.implementer; + + // Check for @mention of the implementer persona (case-insensitive) + const mentionPattern = new RegExp(`@${mentionTarget}\\b`, 'i'); + if (!mentionPattern.test(commentBody)) { + logger.debug('No @mention in note, skipping', { mrIid, mentionTarget }); + return null; + } + + // Skip @mentions from the implementer persona (loop prevention — it's the one + // that responds to comments). The reviewer persona commenting is fine — that's + // a human using the reviewer account. + if (commentAuthor === ctx.personaIdentities.implementer) { + logger.info('Skipping @mention from implementer persona (loop prevention)', { + mrIid, + commentAuthor, + }); + return null; + } + + // Resolve work item from DB + const workItemId = await resolveWorkItemId(ctx.project.id, mrIid); + + logger.info('MR note @mention detected, triggering respond-to-pr-comment agent', { + mrIid, + commentAuthor, + mentionTarget, + workItemId, + }); + + return { + agentType: 'respond-to-pr-comment', + agentInput: { + prNumber: mrIid, + prBranch: mr.source_branch, + repoFullName: payload.project.path_with_namespace, + triggerEvent: 'scm:pr-comment-mention', + triggerCommentId: payload.object_attributes.id, + triggerCommentBody: commentBody, + triggerCommentPath: '', + triggerCommentUrl: payload.object_attributes.url, + commentAuthor, + }, + prNumber: mrIid, + prUrl: mr.url, + prTitle: mr.title, + workItemId, + }; + } +} diff --git a/src/triggers/gitlab/mr-conflict-detected.ts b/src/triggers/gitlab/mr-conflict-detected.ts new file mode 100644 index 00000000..12475cfc --- /dev/null +++ b/src/triggers/gitlab/mr-conflict-detected.ts @@ -0,0 +1,127 @@ +/** + * GitLab MR Conflict Detected trigger. + * + * Triggers the resolve-conflicts agent when a MR update reveals merge conflicts. + * GitLab provides `has_conflicts` in the MR payload, unlike GitHub which requires + * an API call to check mergeability. + */ + +import type { TriggerContext, TriggerHandler, TriggerResult } from '../../types/index.js'; +import { logger } from '../../utils/logging.js'; +import { checkTriggerEnabled } from '../shared/trigger-check.js'; +import { type GitLabMergeRequestPayload, isGitLabMergeRequestPayload } from './types.js'; +import { resolveWorkItemId } from './utils.js'; + +// Track conflict resolution attempts per MR to prevent infinite loops +const conflictAttempts = new Map(); +const MAX_ATTEMPTS = 2; + +// Export for cleanup when conflicts are resolved +export function resetConflictAttempts(mrIid: number): void { + conflictAttempts.delete(mrIid); +} + +export class MRConflictDetectedTrigger implements TriggerHandler { + name = 'gitlab:mr-conflict-detected'; + description = 'Triggers resolve-conflicts agent when a GitLab MR has merge conflicts'; + + matches(ctx: TriggerContext): boolean { + if (ctx.source !== 'gitlab') return false; + if (!isGitLabMergeRequestPayload(ctx.payload)) return false; + + const payload = ctx.payload; + + // Only trigger on update actions (when MR head is pushed/rebased) + if (payload.object_attributes.action !== 'update') return false; + + // Only fire if MR has conflicts + if (!payload.object_attributes.has_conflicts) return false; + + return true; + } + + async handle(ctx: TriggerContext): Promise { + // Check trigger config via DB-driven system + if ( + !(await checkTriggerEnabled( + ctx.project.id, + 'resolve-conflicts', + 'scm:conflict-resolution', + this.name, + )) + ) { + return null; + } + + const payload = ctx.payload as GitLabMergeRequestPayload; + const mrIid = payload.object_attributes.iid; + const mrAuthor = payload.user.username; + const repoFullName = payload.project.path_with_namespace; + + // Gate on MR author being the implementer persona + if (!ctx.personaIdentities) { + logger.info('No persona identities available, skipping', { + handler: this.name, + mrIid, + }); + return null; + } + const implLogin = ctx.personaIdentities.implementer; + if (mrAuthor !== implLogin && mrAuthor !== `${implLogin}[bot]`) { + logger.info('MR not authored by implementer persona, skipping conflict detection trigger', { + mrIid, + mrAuthor, + }); + return null; + } + + // Only trigger for MRs targeting the project's base branch + if (payload.object_attributes.target_branch !== ctx.project.baseBranch) { + logger.info('MR targets non-base branch, skipping conflict detection trigger', { + mrIid, + targetBranch: payload.object_attributes.target_branch, + projectBaseBranch: ctx.project.baseBranch, + }); + return null; + } + + // Check attempt limit to prevent infinite loops + const attempts = conflictAttempts.get(mrIid) || 0; + if (attempts >= MAX_ATTEMPTS) { + logger.warn('Max conflict resolution attempts reached for MR', { + mrIid, + attempts, + }); + return null; + } + + // Increment attempt counter + conflictAttempts.set(mrIid, attempts + 1); + + // Resolve work item from DB + const workItemId = await resolveWorkItemId(ctx.project.id, mrIid); + + logger.info('MR has merge conflicts -- triggering resolve-conflicts agent', { + mrIid, + workItemId, + attempt: attempts + 1, + }); + + return { + agentType: 'resolve-conflicts', + agentInput: { + prNumber: mrIid, + prBranch: payload.object_attributes.source_branch, + repoFullName, + headSha: payload.object_attributes.last_commit.id, + triggerType: 'conflict-resolution', + triggerEvent: 'scm:pr-conflict-detected', + workItemId: workItemId, + }, + prNumber: mrIid, + prUrl: payload.object_attributes.url, + prTitle: payload.object_attributes.title, + workItemId, + }; + } +} diff --git a/src/triggers/gitlab/mr-merged.ts b/src/triggers/gitlab/mr-merged.ts new file mode 100644 index 00000000..86e0fb3c --- /dev/null +++ b/src/triggers/gitlab/mr-merged.ts @@ -0,0 +1,109 @@ +/** + * GitLab MR Merged trigger. + * + * Moves work item to MERGED status when a MR is merged, then optionally + * chains to the backlog-manager agent. Mirrors the GitHub pr-merged trigger. + */ + +import { getPMProvider } from '../../pm/context.js'; +import { resolveProjectPMConfig } from '../../pm/lifecycle.js'; +import { invalidateSnapshot } from '../../router/snapshot-manager.js'; +import type { TriggerContext, TriggerHandler, TriggerResult } from '../../types/index.js'; +import { logger } from '../../utils/logging.js'; +import { isPipelineAtCapacity } from '../shared/backlog-check.js'; +import { isLifecycleTriggerEnabled } from '../shared/lifecycle-check.js'; +import { checkTriggerEnabled } from '../shared/trigger-check.js'; +import { type GitLabMergeRequestPayload, isGitLabMergeRequestPayload } from './types.js'; +import { resolveWorkItemId } from './utils.js'; + +export class MRMergedTrigger implements TriggerHandler { + name = 'gitlab:mr-merged'; + description = 'Moves work item to MERGED status when a GitLab MR is merged'; + + matches(ctx: TriggerContext): boolean { + if (ctx.source !== 'gitlab') return false; + if (!isGitLabMergeRequestPayload(ctx.payload)) return false; + + // GitLab fires a merge_request hook with action 'merge' when MR is merged + return ctx.payload.object_attributes.action === 'merge'; + } + + async handle(ctx: TriggerContext): Promise { + // Check lifecycle trigger config + if (!(await isLifecycleTriggerEnabled(ctx.project.id, 'prMerged', this.name))) { + return null; + } + + const payload = ctx.payload as GitLabMergeRequestPayload; + const mrIid = payload.object_attributes.iid; + + // Resolve work item from DB + const workItemId = await resolveWorkItemId(ctx.project.id, mrIid); + if (!workItemId) { + logger.info('No work item linked to MR, skipping mr-merged', { mrIid }); + return null; + } + + // Invalidate any stale snapshot for this work item + invalidateSnapshot(ctx.project.id, workItemId); + + const pmConfig = resolveProjectPMConfig(ctx.project); + const mergedStatus = pmConfig.statuses.merged; + + if (!mergedStatus) { + logger.warn('No merged status configured for project', { + projectId: ctx.project.id, + }); + return null; + } + + const provider = getPMProvider(); + + // Idempotency: skip move/comment if work item is already in MERGED status + const workItem = await provider.getWorkItem(workItemId); + const alreadyMerged = workItem.status === mergedStatus; + + if (alreadyMerged) { + logger.info('Work item already in MERGED status, skipping duplicate move', { + workItemId, + mrIid, + }); + } else { + await provider.moveWorkItem(workItemId, mergedStatus); + await provider.addComment( + workItemId, + `MR !${mrIid} has been merged to ${payload.object_attributes.target_branch}`, + ); + logger.info('Moved work item to merged status', { workItemId, mrIid }); + } + + // Chain to backlog-manager if enabled + if (await checkTriggerEnabled(ctx.project.id, 'backlog-manager', 'scm:pr-merged', this.name)) { + const capacityResult = await isPipelineAtCapacity(ctx.project, provider); + if (capacityResult.atCapacity) { + logger.info('Skipping backlog-manager: pipeline at capacity after MR merge', { + workItemId, + mrIid, + reason: capacityResult.reason, + inFlightCount: capacityResult.inFlightCount, + limit: capacityResult.limit, + }); + } else { + logger.info('Chaining to backlog-manager after MR merge', { workItemId, mrIid }); + return { + agentType: 'backlog-manager', + agentInput: { triggerEvent: 'scm:pr-merged', workItemId: workItemId }, + workItemId, + prNumber: mrIid, + }; + } + } + + return { + agentType: null, + agentInput: {}, + workItemId, + prNumber: mrIid, + }; + } +} diff --git a/src/triggers/gitlab/mr-opened.ts b/src/triggers/gitlab/mr-opened.ts new file mode 100644 index 00000000..ecc410f4 --- /dev/null +++ b/src/triggers/gitlab/mr-opened.ts @@ -0,0 +1,95 @@ +/** + * GitLab MR Opened trigger. + * + * Triggers the review agent when a new Merge Request is opened in GitLab. + * Skips WIP/draft MRs. Resolves work item from DB; fires even without a + * linked work item. + */ + +import type { TriggerContext, TriggerHandler, TriggerResult } from '../../types/index.js'; +import { logger } from '../../utils/logging.js'; +import { checkTriggerEnabledWithParams } from '../shared/trigger-check.js'; +import { type GitLabMergeRequestPayload, isGitLabMergeRequestPayload } from './types.js'; +import { evaluateAuthorMode, resolveWorkItemId } from './utils.js'; + +export class MROpenedTrigger implements TriggerHandler { + name = 'gitlab:mr-opened'; + description = 'Triggers review agent when a new MR is opened in GitLab'; + + matches(ctx: TriggerContext): boolean { + if (ctx.source !== 'gitlab') return false; + if (!isGitLabMergeRequestPayload(ctx.payload)) return false; + + // Only trigger on newly opened MRs + if (ctx.payload.object_attributes.action !== 'open') return false; + + // Skip WIP/draft MRs — wait until they're ready for review + if (ctx.payload.object_attributes.work_in_progress) return false; + + return true; + } + + async handle(ctx: TriggerContext): Promise { + // Check trigger config + get parameters in a single DB call + const triggerConfig = await checkTriggerEnabledWithParams( + ctx.project.id, + 'review', + 'scm:pr-opened', + this.name, + ); + if (!triggerConfig.enabled) { + return null; + } + + const payload = ctx.payload as GitLabMergeRequestPayload; + const mrIid = payload.object_attributes.iid; + const mrAuthor = payload.user.username; + + // Gate on MR author based on configured authorMode parameter + const authorResult = evaluateAuthorMode( + mrAuthor, + ctx.personaIdentities, + triggerConfig.parameters, + this.name, + ); + if (!authorResult) { + return null; + } + if (!authorResult.shouldTrigger) { + logger.info('MR author does not match configured authorMode, skipping', { + handler: this.name, + mrIid, + mrAuthor, + isImplementerMR: authorResult.isImplementerMR, + authorMode: authorResult.authorMode, + }); + return null; + } + + // Resolve work item from DB + const workItemId = await resolveWorkItemId(ctx.project.id, mrIid); + + logger.info('New MR opened, triggering review agent', { + mrIid, + mrTitle: payload.object_attributes.title, + workItemId, + }); + + return { + agentType: 'review', + agentInput: { + prNumber: mrIid, + prBranch: payload.object_attributes.source_branch, + repoFullName: payload.project.path_with_namespace, + headSha: payload.object_attributes.last_commit.id, + triggerType: 'pr-opened', + triggerEvent: 'scm:pr-opened', + workItemId: workItemId, + }, + prNumber: mrIid, + prUrl: payload.object_attributes.url, + prTitle: payload.object_attributes.title, + workItemId, + }; + } +} diff --git a/src/triggers/gitlab/mr-ready-to-merge.ts b/src/triggers/gitlab/mr-ready-to-merge.ts new file mode 100644 index 00000000..6a00605e --- /dev/null +++ b/src/triggers/gitlab/mr-ready-to-merge.ts @@ -0,0 +1,135 @@ +/** + * GitLab MR Ready to Merge trigger. + * + * Moves work item to DONE when a MR is approved and the latest pipeline has + * succeeded, or auto-merges when the auto label is present. + * + * Fires on merge_request 'approved' action. Unlike GitHub's version which + * reacts to both check_suite and review events, GitLab's approval action is + * sufficient because GitLab enforces pipeline status at the MR level. + */ + +import { getPMProvider } from '../../pm/context.js'; +import type { ProjectPMConfig } from '../../pm/lifecycle.js'; +import { hasAutoLabel, resolveProjectPMConfig } from '../../pm/lifecycle.js'; +import type { TriggerContext, TriggerHandler, TriggerResult } from '../../types/index.js'; +import { logger } from '../../utils/logging.js'; +import { isLifecycleTriggerEnabled } from '../shared/lifecycle-check.js'; +import { type GitLabMergeRequestPayload, isGitLabMergeRequestPayload } from './types.js'; +import { resolveWorkItemId } from './utils.js'; + +export class MRReadyToMergeTrigger implements TriggerHandler { + name = 'gitlab:mr-ready-to-merge'; + description = 'Moves work item to DONE (or auto-merges) when GitLab MR is approved'; + + matches(ctx: TriggerContext): boolean { + if (ctx.source !== 'gitlab') return false; + if (!isGitLabMergeRequestPayload(ctx.payload)) return false; + + // Only trigger on approved MRs + if (ctx.payload.object_attributes.action !== 'approved') return false; + + return true; + } + + async handle(ctx: TriggerContext): Promise { + // Check lifecycle trigger config + if (!(await isLifecycleTriggerEnabled(ctx.project.id, 'prReadyToMerge', this.name))) { + return null; + } + + const payload = ctx.payload as GitLabMergeRequestPayload; + const mrIid = payload.object_attributes.iid; + + // Resolve work item from DB + const workItemId = await resolveWorkItemId(ctx.project.id, mrIid); + if (!workItemId) { + logger.info('No work item linked to MR, skipping mr-ready-to-merge', { mrIid }); + return null; + } + + const pmConfig = resolveProjectPMConfig(ctx.project); + const provider = getPMProvider(); + const workItem = await provider.getWorkItem(workItemId); + + // Check for auto label to determine MERGED vs DONE path + if (hasAutoLabel(workItem.labels, pmConfig)) { + return this.handleAutoMerge(mrIid, workItemId, pmConfig, payload); + } + + // Standard path: move to DONE + const doneStatus = pmConfig.statuses.done; + if (!doneStatus) { + logger.warn('No done status configured for project', { projectId: ctx.project.id }); + return null; + } + + // Idempotency: skip if already in DONE status + if (workItem.status === doneStatus) { + logger.info('Work item already in DONE status, skipping duplicate move', { + workItemId, + mrIid, + }); + return { agentType: null, agentInput: {}, workItemId, prNumber: mrIid }; + } + + logger.info('Moving work item to DONE — MR approved', { + workItemId, + mrIid, + }); + + await provider.moveWorkItem(workItemId, doneStatus); + await provider.addComment(workItemId, `MR !${mrIid} approved — moved to DONE`); + + return { agentType: null, agentInput: {}, workItemId, prNumber: mrIid }; + } + + private async handleAutoMerge( + mrIid: number, + workItemId: string, + pmConfig: ProjectPMConfig, + _payload: GitLabMergeRequestPayload, + ): Promise { + const mergedStatus = pmConfig.statuses.merged; + const provider = getPMProvider(); + + if (!mergedStatus) { + logger.warn( + 'No merged status configured for project (auto label present), falling back to DONE', + { workItemId }, + ); + const doneStatus = pmConfig.statuses.done; + if (!doneStatus) { + await provider.addComment( + workItemId, + 'Auto-merge requested (auto label present), but no MERGED or DONE status configured. Manual action required.', + ); + return null; + } + await provider.moveWorkItem(workItemId, doneStatus); + await provider.addComment( + workItemId, + 'Auto-merge requested (auto label present), but no MERGED status configured. Moved to DONE instead.', + ); + return { agentType: null, agentInput: {}, workItemId, prNumber: mrIid }; + } + + // For GitLab, we note the auto-merge intent. The actual merge API call + // would require a GitLab API client (not yet implemented). For now, move + // the work item to MERGED status and let the user merge manually or + // configure GitLab's built-in auto-merge. + logger.info('MR approved with auto label — moving work item to MERGED', { + workItemId, + mrIid, + }); + + await provider.moveWorkItem(workItemId, mergedStatus); + await provider.addComment( + workItemId, + `MR !${mrIid} approved with auto label — moved to MERGED. ` + + 'Use GitLab auto-merge or merge manually.', + ); + + return { agentType: null, agentInput: {}, workItemId, prNumber: mrIid }; + } +} diff --git a/src/triggers/gitlab/mr-reviewer-added.ts b/src/triggers/gitlab/mr-reviewer-added.ts new file mode 100644 index 00000000..3aa652fb --- /dev/null +++ b/src/triggers/gitlab/mr-reviewer-added.ts @@ -0,0 +1,118 @@ +/** + * GitLab MR Reviewer Added trigger. + * + * Triggers the review agent when a CASCADE persona is added as reviewer + * on a merge request. This is the GitLab equivalent of GitHub's + * ReviewRequestedTrigger. + * + * Fires on MR `update` action when `changes.reviewers` shows a new + * reviewer that matches a CASCADE persona (implementer or reviewer). + */ + +import { isCascadeBot } from '../../gitlab/personas.js'; +import type { TriggerContext, TriggerHandler, TriggerResult } from '../../types/index.js'; +import { logger } from '../../utils/logging.js'; +import { checkTriggerEnabled } from '../shared/trigger-check.js'; +import { type GitLabMergeRequestPayload, isGitLabMergeRequestPayload } from './types.js'; +import { resolveWorkItemId } from './utils.js'; + +export class MRReviewerAddedTrigger implements TriggerHandler { + name = 'gitlab:mr-reviewer-added'; + description = 'Triggers review agent when a CASCADE persona is added as reviewer on a GitLab MR'; + + matches(ctx: TriggerContext): boolean { + if (ctx.source !== 'gitlab') return false; + if (!isGitLabMergeRequestPayload(ctx.payload)) return false; + + // Only trigger on update action (reviewer changes come as updates) + if (ctx.payload.object_attributes.action !== 'update') return false; + + // Must have reviewer changes + const changes = ctx.payload.changes as Record | undefined; + const reviewerChanges = changes?.reviewers as + | { previous?: unknown[]; current?: unknown[] } + | undefined; + if (!reviewerChanges?.current || !reviewerChanges?.previous) return false; + + // Only trigger when reviewers were added (current > previous) + if (reviewerChanges.current.length <= reviewerChanges.previous.length) return false; + + return true; + } + + async handle(ctx: TriggerContext): Promise { + if (!(await checkTriggerEnabled(ctx.project.id, 'review', 'scm:review-requested', this.name))) { + return null; + } + + const payload = ctx.payload as GitLabMergeRequestPayload; + const mrIid = payload.object_attributes.iid; + const headSha = payload.object_attributes.last_commit.id; + + if (!ctx.personaIdentities) { + logger.warn('No persona identities available, skipping mr-reviewer-added trigger', { + mrIid, + }); + return null; + } + + // Skip if the implementer persona is adding reviewers (loop prevention). + // Only the implementer can cause a loop (e.g. implementation agent requesting + // its own review). The reviewer persona acting as sender is fine — that's a + // human using the reviewer account to assign review. + const senderUsername = payload.user.username; + if (senderUsername === ctx.personaIdentities.implementer) { + logger.info('Skipping reviewer addition from implementer persona (loop prevention)', { + mrIid, + sender: senderUsername, + }); + return null; + } + + // Check if any newly added reviewer is a CASCADE persona + const changes = payload.changes as Record; + const reviewerChanges = changes.reviewers as { + previous: Array<{ username: string }>; + current: Array<{ username: string }>; + }; + const previousUsernames = new Set(reviewerChanges.previous.map((r) => r.username)); + const newReviewers = reviewerChanges.current.filter((r) => !previousUsernames.has(r.username)); + + const cascadeReviewer = newReviewers.find((r) => + isCascadeBot(r.username, ctx.personaIdentities!), + ); + if (!cascadeReviewer) { + logger.debug('No CASCADE persona among newly added reviewers, skipping', { + mrIid, + newReviewers: newReviewers.map((r) => r.username), + }); + return null; + } + + const workItemId = await resolveWorkItemId(ctx.project.id, mrIid); + + logger.info('CASCADE persona added as reviewer, triggering review agent', { + mrIid, + reviewer: cascadeReviewer.username, + workItemId, + headSha, + }); + + return { + agentType: 'review', + agentInput: { + prNumber: mrIid, + prBranch: payload.object_attributes.source_branch, + repoFullName: payload.project.path_with_namespace, + headSha, + triggerType: 'review-requested', + triggerEvent: 'scm:review-requested', + workItemId, + }, + prNumber: mrIid, + prUrl: payload.object_attributes.url, + prTitle: payload.object_attributes.title, + workItemId, + }; + } +} diff --git a/src/triggers/gitlab/pipeline-failure.ts b/src/triggers/gitlab/pipeline-failure.ts new file mode 100644 index 00000000..4689be69 --- /dev/null +++ b/src/triggers/gitlab/pipeline-failure.ts @@ -0,0 +1,138 @@ +/** + * GitLab Pipeline Failure trigger. + * + * Triggers the respond-to-ci agent when a pipeline fails on a MR authored + * by the implementer persona. Includes attempt limiting to prevent infinite + * fix loops. + */ + +import type { TriggerContext, TriggerHandler, TriggerResult } from '../../types/index.js'; +import { logger } from '../../utils/logging.js'; +import { checkTriggerEnabled } from '../shared/trigger-check.js'; +import { type GitLabPipelinePayload, isGitLabPipelinePayload } from './types.js'; +import { resolveMergeRequestForPipeline, resolveWorkItemId } from './utils.js'; + +// Track fix attempts per MR to prevent infinite loops +const fixAttempts = new Map(); +const MAX_ATTEMPTS = 3; + +// Export for cleanup +export function resetFixAttempts(mrIid: number): void { + fixAttempts.delete(mrIid); +} + +export class PipelineFailureTrigger implements TriggerHandler { + name = 'gitlab:pipeline-failure'; + description = + 'Triggers respond-to-ci agent when pipeline fails on a GitLab MR by the implementer persona'; + + matches(ctx: TriggerContext): boolean { + if (ctx.source !== 'gitlab') return false; + if (!isGitLabPipelinePayload(ctx.payload)) return false; + + // Only trigger on failed pipelines + if (ctx.payload.object_attributes.status !== 'failed') return false; + + // MR association is checked in handle() via API fallback + return true; + } + + async handle(ctx: TriggerContext): Promise { + // Check trigger config via DB-driven system + if ( + !(await checkTriggerEnabled( + ctx.project.id, + 'respond-to-ci', + 'scm:check-suite-failure', + this.name, + )) + ) { + return null; + } + + const payload = ctx.payload as GitLabPipelinePayload; + + // Resolve MR — from payload or by looking up open MR for the branch + const mr = await resolveMergeRequestForPipeline(payload); + if (!mr) { + logger.debug('No MR associated with failed pipeline, skipping', { + handler: this.name, + ref: payload.object_attributes.ref, + }); + return null; + } + const mrIid = mr.iid; + const mrAuthor = payload.user.username; + const headSha = payload.object_attributes.sha; + + // Gate on MR author being the implementer persona + if (!ctx.personaIdentities) { + logger.info('No persona identities available, skipping', { handler: this.name, mrIid }); + return null; + } + const implLogin = ctx.personaIdentities.implementer; + if (mrAuthor !== implLogin && mrAuthor !== `${implLogin}[bot]`) { + logger.info('MR not authored by implementer persona, skipping pipeline failure trigger', { + mrIid, + mrAuthor, + }); + return null; + } + + // Only trigger for MRs targeting the project's base branch + if (mr.target_branch !== ctx.project.baseBranch) { + logger.info('MR targets non-base branch, skipping pipeline failure trigger', { + mrIid, + targetBranch: mr.target_branch, + projectBaseBranch: ctx.project.baseBranch, + }); + return null; + } + + // Resolve work item from DB + const workItemId = await resolveWorkItemId(ctx.project.id, mrIid); + + // Check attempt limit to prevent infinite loops + const attempts = fixAttempts.get(mrIid) || 0; + if (attempts >= MAX_ATTEMPTS) { + logger.warn('Max auto-fix attempts reached for MR', { + mrIid, + attempts, + }); + return null; + } + + // Increment attempt counter + fixAttempts.set(mrIid, attempts + 1); + + // Collect failed build info for agent context + const failedBuilds = (payload.builds ?? []) + .filter((b) => b.status === 'failed' || b.status === 'canceled') + .map((b) => b.name); + + logger.info('Pipeline failure on implementer MR — triggering respond-to-ci', { + mrIid, + workItemId, + attempt: attempts + 1, + pipelineId: payload.object_attributes.id, + failedBuilds, + }); + + return { + agentType: 'respond-to-ci', + agentInput: { + prNumber: mrIid, + prBranch: mr.source_branch, + repoFullName: payload.project.path_with_namespace, + headSha, + triggerType: 'check-failure', + triggerEvent: 'scm:check-suite-failure', + workItemId: workItemId, + }, + prNumber: mrIid, + prUrl: mr.url, + prTitle: mr.title, + workItemId, + }; + } +} diff --git a/src/triggers/gitlab/pipeline-success.ts b/src/triggers/gitlab/pipeline-success.ts new file mode 100644 index 00000000..a609a64d --- /dev/null +++ b/src/triggers/gitlab/pipeline-success.ts @@ -0,0 +1,118 @@ +/** + * GitLab Pipeline Success trigger. + * + * Triggers the review agent when a pipeline succeeds on a MR. + * Unlike GitHub's check_suite which fires per individual suite, GitLab pipelines + * are atomic — a single pipeline webhook fires when the entire pipeline completes. + * This means waitForChecks is not needed. + */ + +import type { TriggerContext, TriggerHandler, TriggerResult } from '../../types/index.js'; +import { logger } from '../../utils/logging.js'; +import { checkTriggerEnabledWithParams } from '../shared/trigger-check.js'; +import { type GitLabPipelinePayload, isGitLabPipelinePayload } from './types.js'; +import { evaluateAuthorMode, resolveMergeRequestForPipeline, resolveWorkItemId } from './utils.js'; + +export class PipelineSuccessTrigger implements TriggerHandler { + name = 'gitlab:pipeline-success'; + description = 'Triggers review agent when pipeline succeeds on a GitLab MR'; + + matches(ctx: TriggerContext): boolean { + if (ctx.source !== 'gitlab') return false; + if (!isGitLabPipelinePayload(ctx.payload)) return false; + + // Only trigger on successful pipelines + if (ctx.payload.object_attributes.status !== 'success') return false; + + // MR association is checked in handle() via API fallback + return true; + } + + async handle(ctx: TriggerContext): Promise { + // Check trigger config + get parameters in a single DB call + const triggerConfig = await checkTriggerEnabledWithParams( + ctx.project.id, + 'review', + 'scm:check-suite-success', + this.name, + ); + if (!triggerConfig.enabled) { + return null; + } + + const payload = ctx.payload as GitLabPipelinePayload; + + // Resolve MR — from payload or by looking up open MR for the branch + const mr = await resolveMergeRequestForPipeline(payload); + if (!mr) { + logger.debug('No MR associated with successful pipeline, skipping', { + handler: this.name, + ref: payload.object_attributes.ref, + }); + return null; + } + const mrIid = mr.iid; + const mrAuthor = payload.user.username; + const headSha = payload.object_attributes.sha; + + // Gate on MR author based on configured authorMode parameter + const authorResult = evaluateAuthorMode( + mrAuthor, + ctx.personaIdentities, + triggerConfig.parameters, + this.name, + ); + if (!authorResult) { + return null; + } + if (!authorResult.shouldTrigger) { + logger.info('MR author does not match configured authorMode, skipping', { + handler: this.name, + mrIid, + mrAuthor, + isImplementerMR: authorResult.isImplementerMR, + authorMode: authorResult.authorMode, + }); + return null; + } + + // Only trigger for MRs targeting the project's base branch + if (mr.target_branch !== ctx.project.baseBranch) { + logger.info('MR targets non-base branch, skipping pipeline success trigger', { + mrIid, + targetBranch: mr.target_branch, + projectBaseBranch: ctx.project.baseBranch, + }); + return null; + } + + // Resolve work item from DB + const workItemId = await resolveWorkItemId(ctx.project.id, mrIid); + + logger.info('Pipeline succeeded on MR, triggering review agent', { + mrIid, + mrTitle: mr.title, + workItemId, + pipelineId: payload.object_attributes.id, + }); + + return { + agentType: 'review', + agentInput: { + prNumber: mrIid, + prBranch: mr.source_branch, + repoFullName: payload.project.path_with_namespace, + headSha, + triggerType: 'ci-success', + triggerEvent: 'scm:check-suite-success', + workItemId: workItemId, + }, + prNumber: mrIid, + prUrl: mr.url, + prTitle: mr.title, + workItemId, + // GitLab pipelines are atomic — no need to poll for additional checks + waitForChecks: false, + }; + } +} diff --git a/src/triggers/gitlab/register.ts b/src/triggers/gitlab/register.ts new file mode 100644 index 00000000..dc9cfaa9 --- /dev/null +++ b/src/triggers/gitlab/register.ts @@ -0,0 +1,48 @@ +/** + * GitLab trigger registration. + * + * This module only imports trigger handler classes (no webhook handlers, + * no agent execution pipeline) so it is safe to import from the router. + * + * `registerGitLabTriggers` is the single call-site for wiring all built-in + * GitLab triggers into a registry. Adding a new GitLab trigger only + * requires updating this file, not `builtins.ts`. + */ + +import type { TriggerRegistry } from '../registry.js'; +import { MRApprovalTrigger } from './mr-approval.js'; +import { MRCommentMentionTrigger } from './mr-comment-mention.js'; +import { MRConflictDetectedTrigger } from './mr-conflict-detected.js'; +import { MRMergedTrigger } from './mr-merged.js'; +import { MROpenedTrigger } from './mr-opened.js'; +import { MRReadyToMergeTrigger } from './mr-ready-to-merge.js'; +import { MRReviewerAddedTrigger } from './mr-reviewer-added.js'; +import { PipelineFailureTrigger } from './pipeline-failure.js'; +import { PipelineSuccessTrigger } from './pipeline-success.js'; + +/** + * Register all built-in GitLab triggers into the given registry. + * + * Order matters: + * - MRCommentMentionTrigger before MRApprovalTrigger (intercept mentions first) + * - MRConflictDetectedTrigger before PipelineSuccessTrigger (handle conflicts first) + * - PipelineSuccessTrigger before MRReadyToMergeTrigger (review before moving to DONE) + */ +export function registerGitLabTriggers(registry: TriggerRegistry): void { + // Opt-in: disabled by default via trigger config + registry.register(new MROpenedTrigger()); + + // Must be registered before other comment triggers + registry.register(new MRCommentMentionTrigger()); + + registry.register(new MRApprovalTrigger()); + + // Opt-in: disabled by default via trigger config (scm:review-requested) + registry.register(new MRReviewerAddedTrigger()); + + registry.register(new PipelineFailureTrigger()); + registry.register(new MRConflictDetectedTrigger()); + registry.register(new PipelineSuccessTrigger()); + registry.register(new MRReadyToMergeTrigger()); + registry.register(new MRMergedTrigger()); +} diff --git a/src/triggers/gitlab/types.ts b/src/triggers/gitlab/types.ts new file mode 100644 index 00000000..13f9864a --- /dev/null +++ b/src/triggers/gitlab/types.ts @@ -0,0 +1,137 @@ +/** + * GitLab webhook payload interfaces and type guards. + * + * GitLab uses `object_kind` to identify the event type in webhook payloads. + * MR IIDs are project-scoped (equivalent to GitHub PR numbers). + */ + +// --------------------------------------------------------------------------- +// Merge Request Hook +// --------------------------------------------------------------------------- + +export interface GitLabMergeRequestPayload { + object_kind: 'merge_request'; + event_type: 'merge_request'; + user: { username: string }; + project: { path_with_namespace: string; id: number }; + object_attributes: { + iid: number; + title: string; + description: string | null; + source_branch: string; + target_branch: string; + state: string; // 'opened', 'closed', 'merged' + action: string; // 'open', 'close', 'reopen', 'update', 'merge', 'approved', 'unapproved' + work_in_progress: boolean; + url: string; + last_commit: { id: string }; + author_id: number; + has_conflicts?: boolean; + }; + repository: { name: string; url: string }; + labels?: Array<{ title: string }>; + changes?: Record; + reviewers?: Array<{ username: string }>; +} + +// --------------------------------------------------------------------------- +// Pipeline Hook +// --------------------------------------------------------------------------- + +export interface GitLabPipelinePayload { + object_kind: 'pipeline'; + object_attributes: { + id: number; + ref: string; + sha: string; + status: string; // 'success', 'failed', 'running', 'pending', 'canceled' + stages: string[]; + }; + user: { username: string }; + project: { path_with_namespace: string; id: number }; + merge_request?: { + iid: number; + title: string; + url: string; + source_branch: string; + target_branch: string; + state: string; + }; + builds?: Array<{ + id: number; + name: string; + stage: string; + status: string; + failure_reason?: string; + }>; +} + +// --------------------------------------------------------------------------- +// Note Hook (comments on MRs, Issues, Commits, Snippets) +// --------------------------------------------------------------------------- + +export interface GitLabNotePayload { + object_kind: 'note'; + event_type: 'note'; + user: { username: string }; + project: { path_with_namespace: string; id: number }; + object_attributes: { + id: number; + note: string; + noteable_type: string; // 'MergeRequest', 'Issue', 'Commit', 'Snippet' + author_id: number; + url: string; + }; + merge_request?: { + iid: number; + title: string; + url: string; + source_branch: string; + target_branch: string; + state: string; + last_commit: { id: string }; + }; + repository: { name: string; url: string }; +} + +// --------------------------------------------------------------------------- +// Type Guards +// --------------------------------------------------------------------------- + +export function isGitLabMergeRequestPayload( + payload: unknown, +): payload is GitLabMergeRequestPayload { + if (typeof payload !== 'object' || payload === null) return false; + const p = payload as Record; + return ( + p.object_kind === 'merge_request' && + typeof p.object_attributes === 'object' && + p.object_attributes !== null && + typeof p.project === 'object' && + p.project !== null + ); +} + +export function isGitLabPipelinePayload(payload: unknown): payload is GitLabPipelinePayload { + if (typeof payload !== 'object' || payload === null) return false; + const p = payload as Record; + return ( + p.object_kind === 'pipeline' && + typeof p.object_attributes === 'object' && + p.object_attributes !== null && + typeof p.project === 'object' && + p.project !== null + ); +} + +export function isGitLabNotePayload(payload: unknown): payload is GitLabNotePayload { + if (typeof payload !== 'object' || payload === null) return false; + const p = payload as Record; + return ( + p.object_kind === 'note' && + typeof p.object_attributes === 'object' && + p.object_attributes !== null && + typeof p.project === 'object' && + p.project !== null + ); +} diff --git a/src/triggers/gitlab/utils.ts b/src/triggers/gitlab/utils.ts new file mode 100644 index 00000000..81d81fa7 --- /dev/null +++ b/src/triggers/gitlab/utils.ts @@ -0,0 +1,127 @@ +/** + * Shared utilities for GitLab trigger handlers. + * + * Mirrors the GitHub utils pattern — author mode evaluation and work item resolution. + */ + +import { lookupWorkItemForPR } from '../../db/repositories/prWorkItemsRepository.js'; +import type { PersonaIdentities } from '../../github/personas.js'; +import { gitlabClient } from '../../gitlab/client.js'; +import { logger } from '../../utils/logging.js'; +import type { GitLabPipelinePayload } from './types.js'; + +export interface AuthorModeResult { + shouldTrigger: boolean; + authorMode: string; + isImplementerMR: boolean; +} + +/** + * Evaluate whether a trigger should fire based on the MR author and the + * configured `authorMode` parameter. + * + * Returns `null` when personaIdentities is missing (caller should return null). + * Validates authorMode against known values and falls back to 'own'. + */ +export function evaluateAuthorMode( + mrAuthorUsername: string, + personaIdentities: PersonaIdentities | undefined, + parameters: Record, + handlerName: string, +): AuthorModeResult | null { + if (!personaIdentities) { + logger.info('No persona identities available, skipping', { handler: handlerName }); + return null; + } + const implLogin = personaIdentities.implementer; + const isImplementerMR = + mrAuthorUsername === implLogin || mrAuthorUsername === `${implLogin}[bot]`; + + const rawMode = parameters.authorMode; + const authorMode = + typeof rawMode === 'string' && ['own', 'external', 'all'].includes(rawMode) ? rawMode : 'own'; + + if (typeof rawMode === 'string' && authorMode !== rawMode) { + logger.warn('Invalid authorMode value, falling back to "own"', { + handler: handlerName, + configuredValue: rawMode, + }); + } + + const shouldTrigger = + authorMode === 'all' || + (authorMode === 'own' && isImplementerMR) || + (authorMode === 'external' && !isImplementerMR); + + return { shouldTrigger, authorMode, isImplementerMR }; +} + +/** + * Resolve work item ID for a MR using DB lookup only (pr_work_items table). + * Returns undefined when DB returns null or throws. + * + * GitLab MR IIDs are project-scoped (like GitHub PR numbers), so the same + * pr_work_items table and lookup function works for both platforms. + */ +export async function resolveWorkItemId( + projectId: string, + mrIid: number, +): Promise { + try { + const dbResult = await lookupWorkItemForPR(projectId, mrIid); + if (dbResult) return dbResult; + } catch (err) { + logger.warn('Failed to look up work item from DB', { + projectId, + mrIid, + error: String(err), + }); + } + + return undefined; +} + +/** + * Resolve merge request info for a pipeline payload. + * + * GitLab only populates `merge_request` in Pipeline Hook payloads when the + * pipeline runs in a merge-request context (e.g. `source: "merge_request_event"`). + * Branch pushes (`source: "push"`) have `merge_request: null` even when an + * open MR exists for the branch. + * + * This helper checks the payload first, then falls back to querying the + * GitLab API for an open MR matching the pipeline's ref (branch name). + */ +export async function resolveMergeRequestForPipeline( + payload: GitLabPipelinePayload, +): Promise { + // If GitLab already included the MR, use it + if (payload.merge_request) return payload.merge_request; + + // Look up open MR by source branch + const ref = payload.object_attributes.ref; + const projectPath = payload.project.path_with_namespace; + + try { + const mr = await gitlabClient.getOpenMRByBranch(projectPath, ref); + if (!mr) return null; + + // Fetch full MR details to get target_branch, state, etc. + const details = await gitlabClient.getMR(projectPath, mr.iid); + return { + iid: details.iid, + title: details.title, + url: details.webUrl, + source_branch: details.sourceBranch, + target_branch: details.targetBranch, + state: details.state, + }; + } catch (err) { + logger.debug('Failed to look up MR for pipeline branch', { + ref, + projectPath, + error: String(err), + }); + return null; + } +} diff --git a/src/triggers/gitlab/webhook-handler.ts b/src/triggers/gitlab/webhook-handler.ts new file mode 100644 index 00000000..c965cbaa --- /dev/null +++ b/src/triggers/gitlab/webhook-handler.ts @@ -0,0 +1,208 @@ +/** + * GitLab webhook handler. + * + * Thin orchestrator that delegates to focused modules, mirroring the + * GitHub webhook handler pattern: + * - Ack comment management -> ./ack-comments.ts + * - Credential scoping + agent execution -> ../shared/webhook-execution.ts + * - GitLab-specific AgentExecutionConfig -> ./integration.ts + * - Agent-type concurrency -> ../shared/concurrency.ts + * - PM credential scope -> ../shared/credential-scope.ts + * - PM ack posting -> ../shared/pm-ack.ts + */ + +import { isPMFocusedAgent } from '../../agents/definitions/loader.js'; +import { withGitLabToken } from '../../gitlab/client.js'; +import { getPersonaToken, resolvePersonaIdentities } from '../../gitlab/personas.js'; +import type { CascadeConfig, ProjectConfig, TriggerContext } from '../../types/index.js'; +import { logger, startWatchdog } from '../../utils/index.js'; +import type { TriggerRegistry } from '../registry.js'; +import { withAgentTypeConcurrency } from '../shared/concurrency.js'; +import { withPMScope } from '../shared/credential-scope.js'; +import { postPMAckComment } from '../shared/pm-ack.js'; +import { runAgentWithCredentials } from '../shared/webhook-execution.js'; +import type { TriggerResult } from '../types.js'; +import { GitLabWebhookIntegration } from './integration.js'; + +const integration = new GitLabWebhookIntegration(); + +function requireProjectId(project: ProjectConfig): string { + if (!project.id) { + throw new Error('Project id is required for GitLab webhook processing'); + } + return project.id; +} + +/** Dispatch to trigger registry within PM credential + provider scope. */ +async function dispatchTrigger( + registry: TriggerRegistry, + payload: unknown, + project: ProjectConfig, +): Promise { + const projectId = requireProjectId(project); + const personaIdentities = await resolvePersonaIdentities(projectId); + const gitlabToken = await getPersonaToken(projectId, 'implementation'); + const ctx: TriggerContext = { project, source: 'gitlab', payload, personaIdentities }; + return withPMScope(project, () => withGitLabToken(gitlabToken, () => registry.dispatch(ctx))); +} + +/** Post ack comment on PM card for PM-focused agents. */ +async function maybePostPmAckComment( + result: TriggerResult, + project: ProjectConfig, + workItemId: string, +): Promise { + const projectId = requireProjectId(project); + const pmType = project.pm?.type; + const message = `GitLab ${result.agentType ?? 'agent'} triggered`; + + const commentId = await postPMAckComment( + projectId, + workItemId, + pmType, + message, + result.agentType ?? undefined, + ); + + if (commentId) { + result.agentInput.ackCommentId = commentId; + result.agentInput.ackMessage = message; + } +} + +function resolveGitLabExecutionConfig(pmFocused: boolean) { + if (!pmFocused) { + return integration.resolveExecutionConfig(); + } + + return { + skipPrepareForAgent: false, + skipHandleFailure: false, + logLabel: 'GitLab (PM-focused agent)', + }; +} + +/** Run the agent with GitLab-specific (or PM-appropriate) execution config. */ +async function runGitLabAgent( + result: TriggerResult, + project: ProjectConfig, + config: CascadeConfig, +): Promise { + const pmFocused = result.agentType ? await isPMFocusedAgent(result.agentType) : false; + const agentType = result.agentType; + + const execute = async () => { + startWatchdog(project.watchdogTimeoutMs); + const projectId = requireProjectId(project); + const gitlabToken = await getPersonaToken(projectId, agentType ?? 'implementation'); + + await withPMScope(project, () => + withGitLabToken(gitlabToken, () => + runAgentWithCredentials( + integration, + result, + project, + config, + resolveGitLabExecutionConfig(pmFocused), + ), + ), + ); + }; + + try { + if (agentType) { + await withAgentTypeConcurrency(project.id, agentType, execute, 'GitLab agent'); + } else { + await execute(); + } + } catch (err) { + logger.error('Failed to process GitLab webhook', { error: String(err) }); + } +} + +/** Post PM ack comment if the agent is PM-focused and has a work item. */ +async function maybePostPmAck(result: TriggerResult, project: ProjectConfig): Promise { + if (!result.agentType) return; + if (!(await isPMFocusedAgent(result.agentType))) return; + + const workItemId = result.workItemId; + if (!workItemId) return; + + try { + await maybePostPmAckComment(result, project, workItemId); + } catch (err) { + logger.warn('PM ack comment failed for PM-focused agent (non-fatal)', { + error: String(err), + agentType: result.agentType, + }); + } +} + +/** Resolve the trigger result from either a pre-resolved value or the registry. */ +async function resolveTriggerResult( + registry: TriggerRegistry, + payload: unknown, + project: ProjectConfig, + triggerResult: TriggerResult | undefined, +): Promise { + if (triggerResult) { + logger.info('Using pre-resolved trigger result for GitLab webhook', { + agentType: triggerResult.agentType, + }); + return triggerResult; + } + return dispatchTrigger(registry, payload, project); +} + +export async function processGitLabWebhook( + payload: unknown, + eventType: string, + registry: TriggerRegistry, + ackCommentId?: number, + ackMessage?: string, + triggerResult?: TriggerResult, +): Promise { + logger.info('Processing GitLab webhook', { eventType, hasTriggerResult: !!triggerResult }); + + const event = integration.parseWebhookPayload(payload); + if (!event) { + logger.warn('GitLab webhook missing project info'); + return; + } + + const projectConfig = await integration.lookupProject(event.projectIdentifier); + if (!projectConfig) { + logger.warn('No project configured for repository', { + pathWithNamespace: event.projectIdentifier, + }); + return; + } + const { project, config } = projectConfig; + + const result = await resolveTriggerResult(registry, payload, project, triggerResult); + + if (!result) { + logger.info('No trigger matched for GitLab webhook', { + eventType, + pathWithNamespace: event.projectIdentifier, + }); + return; + } + + // Inject ack comment info from router into agent input + if (ackCommentId) result.agentInput.ackCommentId = ackCommentId; + if (ackMessage) result.agentInput.ackMessage = ackMessage; + + logger.info('GitLab trigger matched', { + agentType: result.agentType || '(no agent)', + mrIid: result.prNumber, + }); + + if (!result.agentType) { + logger.info('Trigger completed without agent', { mrIid: result.prNumber }); + return; + } + + await maybePostPmAck(result, project); + await runGitLabAgent(result, project, config); +} diff --git a/src/triggers/index.ts b/src/triggers/index.ts index 89ab4418..875530ce 100644 --- a/src/triggers/index.ts +++ b/src/triggers/index.ts @@ -1,5 +1,6 @@ export { registerBuiltInTriggers } from './builtins.js'; export { processGitHubWebhook } from './github/webhook-handler.js'; +export { processGitLabWebhook } from './gitlab/webhook-handler.js'; export { processJiraWebhook } from './jira/webhook-handler.js'; export { createTriggerRegistry, type TriggerRegistry } from './registry.js'; export { processTrelloWebhook } from './trello/webhook-handler.js'; diff --git a/src/triggers/shared/agent-execution.ts b/src/triggers/shared/agent-execution.ts index 156b66ef..029753bf 100644 --- a/src/triggers/shared/agent-execution.ts +++ b/src/triggers/shared/agent-execution.ts @@ -184,16 +184,27 @@ async function linkPRPostExecution( const prNumber = extractPRNumber(agentResult.prUrl); if (!prNumber) return; - // Fetch PR title from GitHub API (best-effort, resolves the prTitle gap) + // Fetch PR/MR title from SCM API (best-effort, resolves the prTitle gap) let prTitle: string | undefined; try { - const { githubClient } = await import('../../github/client.js'); - const { parseRepoFullName } = await import('../../utils/repo.js'); - const { owner, repo } = parseRepoFullName(project.repo); - const pr = await githubClient.getPR(owner, repo, prNumber); - prTitle = pr.title; + const { getIntegrationProvider } = await import( + '../../db/repositories/credentialsRepository.js' + ); + const scmProvider = await getIntegrationProvider(project.id, 'scm'); + + if (scmProvider === 'gitlab') { + const { gitlabClient } = await import('../../gitlab/client.js'); + const mr = await gitlabClient.getMR(project.repo, prNumber); + prTitle = mr.title; + } else { + const { githubClient } = await import('../../github/client.js'); + const { parseRepoFullName } = await import('../../utils/repo.js'); + const { owner, repo } = parseRepoFullName(project.repo); + const pr = await githubClient.getPR(owner, repo, prNumber); + prTitle = pr.title; + } } catch (err) { - logger.warn('Failed to fetch PR title from GitHub', { + logger.warn('Failed to fetch PR/MR title', { projectId: project.id, prNumber, error: String(err), diff --git a/src/utils/prUrl.ts b/src/utils/prUrl.ts index 26390565..56d85451 100644 --- a/src/utils/prUrl.ts +++ b/src/utils/prUrl.ts @@ -1,25 +1,35 @@ /** - * Shared utility for extracting GitHub PR URLs from text. + * Shared utility for extracting PR/MR URLs from text. + * Supports both GitHub PRs (/pull/NNN) and GitLab MRs (/merge_requests/NNN). * Used by the Claude Code backend (backends/claude-code/index.ts) to extract - * PR URLs from agent output and assistant messages. + * PR/MR URLs from agent output and assistant messages. */ /** - * Extract a GitHub PR URL from arbitrary text output. - * Matches the first occurrence of a GitHub pull request URL. + * Extract a GitHub PR or GitLab MR URL from arbitrary text output. + * Matches the first occurrence of either URL pattern. * - * @param text - The text to search for a PR URL - * @returns The PR URL if found, or undefined + * @param text - The text to search for a PR/MR URL + * @returns The PR/MR URL if found, or undefined */ export function extractPRUrl(text: string): string | undefined { - const match = text.match(/https:\/\/github\.com\/[^\s"')\]]+\/pull\/\d+/); - return match ? match[0] : undefined; + // GitHub: https://github.com/owner/repo/pull/123 + const ghMatch = text.match(/https:\/\/github\.com\/[^\s"')\]]+\/pull\/\d+/); + if (ghMatch) return ghMatch[0]; + // GitLab: https://gitlab.example.com/group/repo/-/merge_requests/123 + const glMatch = text.match(/https?:\/\/[^\s"')\]]+\/-\/merge_requests\/\d+/); + return glMatch ? glMatch[0] : undefined; } /** - * Extract the PR number from a URL or arbitrary text containing a `/pull/NNN` path. + * Extract the PR/MR number from a URL or arbitrary text. + * Matches GitHub `/pull/NNN` and GitLab `/merge_requests/NNN` patterns. */ export function extractPRNumber(text: string): number | undefined { - const match = text.match(/\/pull\/(\d+)/); - return match ? Number(match[1]) : undefined; + // GitHub: /pull/123 + const ghMatch = text.match(/\/pull\/(\d+)/); + if (ghMatch) return Number(ghMatch[1]); + // GitLab: /merge_requests/123 (also /-/merge_requests/123) + const glMatch = text.match(/\/merge_requests\/(\d+)/); + return glMatch ? Number(glMatch[1]) : undefined; } diff --git a/src/utils/repo.ts b/src/utils/repo.ts index 3e68195e..a9688775 100644 --- a/src/utils/repo.ts +++ b/src/utils/repo.ts @@ -1,6 +1,7 @@ import { execSync, spawn } from 'node:child_process'; import { existsSync, mkdirSync, rmSync } from 'node:fs'; import { getProjectGitHubToken } from '../config/projects.js'; +import { getIntegrationProvider } from '../db/repositories/credentialsRepository.js'; import type { ProjectConfig } from '../types/index.js'; import { logger } from './logging.js'; @@ -33,6 +34,23 @@ export function createTempDir(projectId: string): string { return tempDir; } +/** + * Build the authenticated clone URL for the project's SCM provider. + * GitHub: https://@github.com//.git + * GitLab: https://oauth2:@gitlab.com//.git + * + * For self-hosted GitLab, set GITLAB_HOST env var (e.g. "gitlab.mycompany.com"). + */ +async function buildCloneUrl(project: ProjectConfig, token: string): Promise { + const provider = await getIntegrationProvider(project.id, 'scm'); + if (provider === 'gitlab') { + const host = process.env.GITLAB_HOST ?? 'gitlab.com'; + return `https://oauth2:${token}@${host}/${project.repo}.git`; + } + // Default: GitHub + return `https://${token}@github.com/${project.repo}.git`; +} + export async function cloneRepo( project: ProjectConfig, targetDir: string, @@ -42,7 +60,7 @@ export async function cloneRepo( throw new Error(`Cannot clone repository: project '${project.id}' has no repo configured`); } const cloneToken = token ?? (await getProjectGitHubToken(project)); - const cloneUrl = `https://${cloneToken}@github.com/${project.repo}.git`; + const cloneUrl = await buildCloneUrl(project, cloneToken); const branch = project.baseBranch ?? 'main'; logger.info('Cloning repository', { repo: project.repo, targetDir, branch }); diff --git a/src/webhook/signatureVerification.ts b/src/webhook/signatureVerification.ts index f20189c9..e7ea8435 100644 --- a/src/webhook/signatureVerification.ts +++ b/src/webhook/signatureVerification.ts @@ -144,6 +144,30 @@ export function verifySentrySignature(rawBody: string, signature: string, secret }); } +/** + * Verify a GitLab webhook token. + * + * GitLab does not use HMAC — it sends a pre-shared secret token verbatim in + * the `X-Gitlab-Token` header. Verification is a timing-safe comparison of + * the header value against the configured secret. + * + * @param _rawBody - Unused (GitLab does not sign the body). + * @param tokenHeader - The value of the `X-Gitlab-Token` header. + * @param secret - The webhook secret token configured in GitLab. + * @returns `true` if the token matches, `false` otherwise. + */ +export function verifyGitLabSignature( + _rawBody: string, + tokenHeader: string, + secret: string, +): boolean { + if (!tokenHeader || !secret) return false; + const expected = Buffer.from(secret, 'utf8'); + const actual = Buffer.from(tokenHeader, 'utf8'); + if (expected.length !== actual.length) return false; + return timingSafeEqual(expected, actual); +} + /** * Verify a JIRA webhook signature. * diff --git a/src/webhook/webhookHandlers.ts b/src/webhook/webhookHandlers.ts index b5e9f00a..10cc90cf 100644 --- a/src/webhook/webhookHandlers.ts +++ b/src/webhook/webhookHandlers.ts @@ -22,6 +22,7 @@ import { handleProcessingError, logSuccessfulWebhook } from './webhookLogging.js export { parseGitHubPayload, + parseGitLabPayload, parseJiraPayload, parseSentryPayload, parseTrelloPayload, diff --git a/src/webhook/webhookParsers.ts b/src/webhook/webhookParsers.ts index e4b52a24..eda0b4d5 100644 --- a/src/webhook/webhookParsers.ts +++ b/src/webhook/webhookParsers.ts @@ -94,6 +94,26 @@ export async function parseSentryPayload( } } +/** + * Parse a GitLab webhook request (plain JSON). + * Event type comes from the `X-Gitlab-Event` header. + */ +export async function parseGitLabPayload(c: Context): Promise { + try { + const rawBody = await c.req.text(); + const payload = JSON.parse(rawBody); + const eventType = c.req.header('X-Gitlab-Event') || 'unknown'; + logger.info('Received GitLab webhook', { + event: eventType, + project: ((payload as Record)?.project as Record) + ?.path_with_namespace, + }); + return { ok: true, payload, eventType, rawBody }; + } catch (err) { + return { ok: false, error: String(err) }; + } +} + /** * Parse a JIRA webhook request (plain JSON). * Extracts `webhookEvent` as the event type. diff --git a/src/worker-entry.ts b/src/worker-entry.ts index 2072559e..2bbe64e9 100644 --- a/src/worker-entry.ts +++ b/src/worker-entry.ts @@ -3,12 +3,12 @@ * * This is the entry point for Cascade worker containers. It: * 1. Reads job data from environment variables - * 2. Processes the job (Trello, GitHub, or JIRA webhook) + * 2. Processes the job (Trello, GitHub, GitLab, or JIRA webhook) * 3. Exits when complete * * Environment variables: * - JOB_ID: Unique job identifier - * - JOB_TYPE: 'trello', 'github', or 'jira' + * - JOB_TYPE: 'trello', 'github', 'gitlab', or 'jira' * - JOB_DATA: JSON-encoded job payload * - DATABASE_URL: PostgreSQL connection string for config */ @@ -20,6 +20,7 @@ import { loadEnvConfigSafe } from './config/env.js'; import { loadConfig } from './config/provider.js'; import { getDb } from './db/client.js'; import { captureException, flush, setTag } from './sentry.js'; +import { processGitLabWebhook } from './triggers/gitlab/webhook-handler.js'; import { createTriggerRegistry, processGitHubWebhook, @@ -69,6 +70,18 @@ export interface JiraJobData { triggerResult?: TriggerResult; } +export interface GitLabJobData { + type: 'gitlab'; + source: 'gitlab'; + payload: unknown; + eventType: string; + projectPath: string; + receivedAt: string; + ackCommentId?: number; + ackMessage?: string; + triggerResult?: TriggerResult; +} + export interface SentryJobData { type: 'sentry'; source: 'sentry'; @@ -111,6 +124,7 @@ export type DashboardJobData = ManualRunJobData | RetryRunJobData | DebugAnalysi export type JobData = | TrelloJobData | GitHubJobData + | GitLabJobData | JiraJobData | SentryJobData | DashboardJobData; @@ -197,6 +211,23 @@ export async function dispatchJob( jobData.triggerResult, ); break; + case 'gitlab': + logger.info('[Worker] Processing GitLab job', { + jobId, + eventType: jobData.eventType, + projectPath: jobData.projectPath, + ackCommentId: jobData.ackCommentId, + hasTriggerResult: !!jobData.triggerResult, + }); + await processGitLabWebhook( + jobData.payload, + jobData.eventType, + triggerRegistry, + jobData.ackCommentId, + jobData.ackMessage, + jobData.triggerResult, + ); + break; case 'jira': logger.info('[Worker] Processing JIRA job', { jobId, diff --git a/tests/unit/agents/definitions/schema.test.ts b/tests/unit/agents/definitions/schema.test.ts index 51594277..688c0f8c 100644 --- a/tests/unit/agents/definitions/schema.test.ts +++ b/tests/unit/agents/definitions/schema.test.ts @@ -228,8 +228,11 @@ describe.concurrent('KnownProviderSchema', () => { expect(KnownProviderSchema.safeParse('github').success).toBe(true); }); + it('accepts gitlab', () => { + expect(KnownProviderSchema.safeParse('gitlab').success).toBe(true); + }); + it('rejects unknown providers', () => { - expect(KnownProviderSchema.safeParse('gitlab').success).toBe(false); expect(KnownProviderSchema.safeParse('asana').success).toBe(false); expect(KnownProviderSchema.safeParse('imap').success).toBe(false); expect(KnownProviderSchema.safeParse('gmail').success).toBe(false); diff --git a/tests/unit/api/routers/_shared/triggerTypes.test.ts b/tests/unit/api/routers/_shared/triggerTypes.test.ts index f2914175..71cd649f 100644 --- a/tests/unit/api/routers/_shared/triggerTypes.test.ts +++ b/tests/unit/api/routers/_shared/triggerTypes.test.ts @@ -152,7 +152,7 @@ describe('triggerTypes', () => { }); it('all provider values are valid KnownProviders', () => { - const validProviders = new Set(['trello', 'jira', 'github']); + const validProviders = new Set(['trello', 'jira', 'github', 'gitlab']); const allProviders = Object.values(TRIGGER_REGISTRY) .flat() .flatMap((t) => t.providers ?? []); diff --git a/tests/unit/api/routers/webhooks.test.ts b/tests/unit/api/routers/webhooks.test.ts index 18b27735..46cfc2c1 100644 --- a/tests/unit/api/routers/webhooks.test.ts +++ b/tests/unit/api/routers/webhooks.test.ts @@ -708,6 +708,7 @@ describe('webhooksRouter', () => { expect(result.errors).toEqual({ trello: null, github: null, + gitlab: null, jira: null, }); }); diff --git a/tests/unit/cli/scm/create-pr-review-sidecar.test.ts b/tests/unit/cli/scm/create-pr-review-sidecar.test.ts index e433017b..ae94ab2b 100644 --- a/tests/unit/cli/scm/create-pr-review-sidecar.test.ts +++ b/tests/unit/cli/scm/create-pr-review-sidecar.test.ts @@ -43,6 +43,8 @@ vi.mock('../../../../src/cli/base.js', () => ({ exit = vi.fn(); }, resolveOwnerRepo: vi.fn((owner: string, repo: string) => ({ owner, repo })), + detectSCMProvider: vi.fn(() => 'github'), + resolveProjectPath: vi.fn(() => 'owner/repo'), })); import CreatePRReviewCommand from '../../../../src/cli/scm/create-pr-review.js'; diff --git a/tests/unit/cli/scm/create-pr-review.test.ts b/tests/unit/cli/scm/create-pr-review.test.ts index d279bdd3..e809f449 100644 --- a/tests/unit/cli/scm/create-pr-review.test.ts +++ b/tests/unit/cli/scm/create-pr-review.test.ts @@ -34,8 +34,11 @@ vi.mock('../../../../src/cli/base.js', () => ({ CredentialScopedCommand: class { log = vi.fn(); parse = vi.fn(); + exit = vi.fn(); }, resolveOwnerRepo: vi.fn((owner: string, repo: string) => ({ owner, repo })), + detectSCMProvider: vi.fn(() => 'github'), + resolveProjectPath: vi.fn(() => 'owner/repo'), })); import CreatePRReviewCommand from '../../../../src/cli/scm/create-pr-review.js'; diff --git a/tests/unit/cli/scm/create-pr-sidecar.test.ts b/tests/unit/cli/scm/create-pr-sidecar.test.ts index 84ee1494..030ac5a5 100644 --- a/tests/unit/cli/scm/create-pr-sidecar.test.ts +++ b/tests/unit/cli/scm/create-pr-sidecar.test.ts @@ -35,6 +35,8 @@ vi.mock('../../../../src/cli/base.js', () => ({ exit = vi.fn(); }, resolveOwnerRepo: vi.fn((owner: string, repo: string) => ({ owner, repo })), + detectSCMProvider: vi.fn(() => 'github'), + resolveProjectPath: vi.fn(() => 'owner/repo'), })); import CreatePRCommand from '../../../../src/cli/scm/create-pr.js'; diff --git a/tests/unit/config/projects.test.ts b/tests/unit/config/projects.test.ts index 06b1a295..0ed52e17 100644 --- a/tests/unit/config/projects.test.ts +++ b/tests/unit/config/projects.test.ts @@ -11,6 +11,10 @@ vi.mock('../../../src/db/repositories/configRepository.js', () => ({ vi.mock('../../../src/db/repositories/credentialsRepository.js', () => ({ resolveProjectCredential: vi.fn(), resolveAllProjectCredentials: vi.fn(), + getIntegrationProvider: vi.fn((_projectId: string, category: string) => { + const map: Record = { scm: 'github', pm: 'trello', alerting: 'sentry' }; + return Promise.resolve(map[category] ?? null); + }), })); import { getProjectGitHubToken } from '../../../src/config/projects.js'; diff --git a/tests/unit/config/provider.test.ts b/tests/unit/config/provider.test.ts index 35e0a815..4fe2d119 100644 --- a/tests/unit/config/provider.test.ts +++ b/tests/unit/config/provider.test.ts @@ -12,6 +12,12 @@ vi.mock('../../../src/db/repositories/configRepository.js', () => ({ vi.mock('../../../src/db/repositories/credentialsRepository.js', () => ({ resolveProjectCredential: vi.fn(), resolveAllProjectCredentials: vi.fn(), + getIntegrationProvider: vi.fn().mockImplementation((_projectId: string, category: string) => { + if (category === 'scm') return Promise.resolve('github'); + if (category === 'pm') return Promise.resolve('trello'); + if (category === 'alerting') return Promise.resolve('sentry'); + return Promise.resolve(null); + }), })); // Mock configCache diff --git a/tests/unit/gitlab/personas.test.ts b/tests/unit/gitlab/personas.test.ts new file mode 100644 index 00000000..91b718b9 --- /dev/null +++ b/tests/unit/gitlab/personas.test.ts @@ -0,0 +1,207 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// Mock dependencies before importing +vi.mock('../../../src/config/provider.js', () => ({ + getIntegrationCredential: vi.fn(), +})); + +vi.mock('../../../src/gitlab/client.js', () => ({ + getGitLabUserForToken: vi.fn(), + withGitLabToken: vi.fn(), + gitlabClient: {}, +})); + +vi.mock('../../../src/utils/logging.js', () => ({ + logger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})); + +import { getIntegrationCredential } from '../../../src/config/provider.js'; +import { getGitLabUserForToken } from '../../../src/gitlab/client.js'; +import type { PersonaIdentities } from '../../../src/gitlab/personas.js'; +import { + _resetPersonaIdentityCache, + getPersonaForAgentType, + getPersonaForLogin, + getPersonaToken, + isCascadeBot, + resolvePersonaIdentities, +} from '../../../src/gitlab/personas.js'; + +describe('GitLab personas', () => { + beforeEach(() => { + _resetPersonaIdentityCache(); + }); + + afterEach(() => { + _resetPersonaIdentityCache(); + }); + + // ======================================================================== + // getPersonaForAgentType + // ======================================================================== + + describe('getPersonaForAgentType', () => { + it('maps implementation agents to implementer', () => { + expect(getPersonaForAgentType('implementation')).toBe('implementer'); + expect(getPersonaForAgentType('splitting')).toBe('implementer'); + expect(getPersonaForAgentType('planning')).toBe('implementer'); + expect(getPersonaForAgentType('respond-to-review')).toBe('implementer'); + expect(getPersonaForAgentType('respond-to-ci')).toBe('implementer'); + expect(getPersonaForAgentType('respond-to-pr-comment')).toBe('implementer'); + expect(getPersonaForAgentType('respond-to-planning-comment')).toBe('implementer'); + expect(getPersonaForAgentType('debug')).toBe('implementer'); + }); + + it('maps review agent to reviewer', () => { + expect(getPersonaForAgentType('review')).toBe('reviewer'); + }); + + it('defaults unknown agent types to implementer', () => { + expect(getPersonaForAgentType('unknown-agent')).toBe('implementer'); + expect(getPersonaForAgentType('custom-agent')).toBe('implementer'); + }); + }); + + // ======================================================================== + // getPersonaToken + // ======================================================================== + + describe('getPersonaToken', () => { + it('resolves implementer_token for implementer agents', async () => { + vi.mocked(getIntegrationCredential).mockResolvedValue('glpat-impl-token'); + + const token = await getPersonaToken('proj-1', 'implementation'); + + expect(getIntegrationCredential).toHaveBeenCalledWith('proj-1', 'scm', 'implementer_token'); + expect(token).toBe('glpat-impl-token'); + }); + + it('resolves reviewer_token for review agent', async () => { + vi.mocked(getIntegrationCredential).mockResolvedValue('glpat-review-token'); + + const token = await getPersonaToken('proj-1', 'review'); + + expect(getIntegrationCredential).toHaveBeenCalledWith('proj-1', 'scm', 'reviewer_token'); + expect(token).toBe('glpat-review-token'); + }); + }); + + // ======================================================================== + // isCascadeBot + // ======================================================================== + + describe('isCascadeBot', () => { + const identities: PersonaIdentities = { + implementer: 'gl-cascade-impl', + reviewer: 'gl-cascade-review', + }; + + it('returns true for implementer username', () => { + expect(isCascadeBot('gl-cascade-impl', identities)).toBe(true); + }); + + it('returns true for reviewer username', () => { + expect(isCascadeBot('gl-cascade-review', identities)).toBe(true); + }); + + it('returns false for unrelated username', () => { + expect(isCascadeBot('random-user', identities)).toBe(false); + }); + + it('returns false for username with [bot] suffix (GitLab does not use this)', () => { + expect(isCascadeBot('gl-cascade-impl[bot]', identities)).toBe(false); + }); + }); + + // ======================================================================== + // getPersonaForLogin + // ======================================================================== + + describe('getPersonaForLogin', () => { + const identities: PersonaIdentities = { + implementer: 'gl-cascade-impl', + reviewer: 'gl-cascade-review', + }; + + it('returns implementer for implementer username', () => { + expect(getPersonaForLogin('gl-cascade-impl', identities)).toBe('implementer'); + }); + + it('returns reviewer for reviewer username', () => { + expect(getPersonaForLogin('gl-cascade-review', identities)).toBe('reviewer'); + }); + + it('returns null for unknown username', () => { + expect(getPersonaForLogin('random-user', identities)).toBeNull(); + }); + }); + + // ======================================================================== + // resolvePersonaIdentities + // ======================================================================== + + describe('resolvePersonaIdentities', () => { + it('resolves both persona usernames from tokens', async () => { + vi.mocked(getIntegrationCredential) + .mockResolvedValueOnce('glpat-impl-token') + .mockResolvedValueOnce('glpat-review-token'); + vi.mocked(getGitLabUserForToken) + .mockResolvedValueOnce('gl-impl-user') + .mockResolvedValueOnce('gl-review-user'); + + const result = await resolvePersonaIdentities('proj-1'); + + expect(result).toEqual({ + implementer: 'gl-impl-user', + reviewer: 'gl-review-user', + }); + }); + + it('caches results per project', async () => { + vi.mocked(getIntegrationCredential) + .mockResolvedValueOnce('glpat-impl-token') + .mockResolvedValueOnce('glpat-review-token'); + vi.mocked(getGitLabUserForToken) + .mockResolvedValueOnce('gl-impl-user') + .mockResolvedValueOnce('gl-review-user'); + + const result1 = await resolvePersonaIdentities('proj-1'); + const result2 = await resolvePersonaIdentities('proj-1'); + + expect(result1).toEqual(result2); + // Only called once due to caching + expect(getIntegrationCredential).toHaveBeenCalledTimes(2); // 2 calls for the first resolution + }); + + it('throws when implementer login cannot be resolved', async () => { + vi.mocked(getIntegrationCredential) + .mockResolvedValueOnce('glpat-impl-token') + .mockResolvedValueOnce('glpat-review-token'); + vi.mocked(getGitLabUserForToken) + .mockResolvedValueOnce(null) // implementer fails + .mockResolvedValueOnce('gl-review-user'); + + await expect(resolvePersonaIdentities('proj-1')).rejects.toThrow( + /Failed to resolve GitLab identity for implementer token/, + ); + }); + + it('throws when reviewer login cannot be resolved', async () => { + vi.mocked(getIntegrationCredential) + .mockResolvedValueOnce('glpat-impl-token') + .mockResolvedValueOnce('glpat-review-token'); + vi.mocked(getGitLabUserForToken) + .mockResolvedValueOnce('gl-impl-user') + .mockResolvedValueOnce(null); // reviewer fails + + await expect(resolvePersonaIdentities('proj-1')).rejects.toThrow( + /Failed to resolve GitLab identity for reviewer token/, + ); + }); + }); +}); diff --git a/tests/unit/router/adapters/gitlab.test.ts b/tests/unit/router/adapters/gitlab.test.ts new file mode 100644 index 00000000..d9caaa23 --- /dev/null +++ b/tests/unit/router/adapters/gitlab.test.ts @@ -0,0 +1,331 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { mockLogger } from '../../../helpers/sharedMocks.js'; + +vi.mock('../../../../src/utils/logging.js', () => ({ logger: mockLogger })); + +vi.mock('../../../../src/router/config.js', () => ({ + loadProjectConfig: vi.fn(), +})); + +vi.mock('../../../../src/config/provider.js', () => ({ + getIntegrationCredential: vi.fn(), +})); + +vi.mock('../../../../src/gitlab/personas.js', () => ({ + resolvePersonaIdentities: vi.fn().mockResolvedValue({}), +})); + +vi.mock('../../../../src/gitlab/client.js', () => ({ + withGitLabToken: vi.fn().mockImplementation((_token: string, fn: () => unknown) => fn()), +})); + +vi.mock('../../../../src/pm/context.js', () => ({ + withPMProvider: vi.fn().mockImplementation((_p: unknown, fn: () => unknown) => fn()), + withPMCredentials: vi + .fn() + .mockImplementation((_id: unknown, _type: unknown, _get: unknown, fn: () => unknown) => fn()), +})); + +vi.mock('../../../../src/pm/registry.js', () => ({ + pmRegistry: { + getOrNull: vi.fn().mockReturnValue(null), + createProvider: vi.fn().mockReturnValue({}), + register: vi.fn(), + }, +})); + +vi.mock('../../../../src/router/platformClients/gitlab.js', () => ({ + GitLabPlatformClient: vi.fn().mockImplementation(() => ({ + postComment: vi.fn().mockResolvedValue(999), + })), +})); + +import { getIntegrationCredential } from '../../../../src/config/provider.js'; +import { + GitLabRouterAdapter, + injectGitLabEventType, +} from '../../../../src/router/adapters/gitlab.js'; +import type { RouterProjectConfig } from '../../../../src/router/config.js'; +import { loadProjectConfig } from '../../../../src/router/config.js'; +import type { TriggerRegistry } from '../../../../src/triggers/registry.js'; + +const mockProject: RouterProjectConfig = { + id: 'p1', + repo: 'group/repo', + pmType: 'trello', +}; + +const mockTriggerRegistry = { + dispatch: vi.fn().mockResolvedValue(null), +} as unknown as TriggerRegistry; + +beforeEach(() => { + vi.mocked(loadProjectConfig).mockResolvedValue({ + projects: [mockProject], + fullProjects: [{ id: 'p1', repo: 'group/repo', pm: { type: 'trello' } } as never], + }); + vi.mocked(getIntegrationCredential).mockResolvedValue('glpat-mock-token'); +}); + +// --------------------------------------------------------------------------- +// injectGitLabEventType +// --------------------------------------------------------------------------- + +describe('injectGitLabEventType', () => { + it('injects _eventType into payload', () => { + const result = injectGitLabEventType({ object_kind: 'merge_request' }, 'Merge Request Hook'); + expect(result._eventType).toBe('Merge Request Hook'); + expect(result.object_kind).toBe('merge_request'); + }); +}); + +// --------------------------------------------------------------------------- +// GitLabRouterAdapter +// --------------------------------------------------------------------------- + +describe('GitLabRouterAdapter', () => { + let adapter: GitLabRouterAdapter; + + beforeEach(() => { + adapter = new GitLabRouterAdapter(); + }); + + // ====================================================================== + // parseWebhook + // ====================================================================== + + describe('parseWebhook', () => { + it('returns null for non-processable events', async () => { + const payload = injectGitLabEventType( + { project: { path_with_namespace: 'group/repo' } }, + 'Push Hook', + ); + // Push Hook is processable, so test with something unknown + const result = await adapter.parseWebhook( + injectGitLabEventType({ project: { path_with_namespace: 'group/repo' } }, 'Tag Push Hook'), + ); + expect(result).toBeNull(); + }); + + it('returns parsed event for Merge Request Hook', async () => { + const payload = injectGitLabEventType( + { + object_kind: 'merge_request', + project: { path_with_namespace: 'group/repo', id: 1 }, + object_attributes: { iid: 42, action: 'open' }, + }, + 'Merge Request Hook', + ); + const result = await adapter.parseWebhook(payload); + + expect(result).not.toBeNull(); + expect(result!.eventType).toBe('Merge Request Hook'); + expect(result!.projectIdentifier).toBe('group/repo'); + expect(result!.workItemId).toBe('42'); + expect(result!.isCommentEvent).toBe(false); + }); + + it('returns parsed event for Note Hook with MR', async () => { + const payload = injectGitLabEventType( + { + object_kind: 'note', + project: { path_with_namespace: 'group/repo', id: 1 }, + object_attributes: { id: 200, note: 'comment' }, + merge_request: { iid: 42 }, + }, + 'Note Hook', + ); + const result = await adapter.parseWebhook(payload); + + expect(result).not.toBeNull(); + expect(result!.eventType).toBe('Note Hook'); + expect(result!.isCommentEvent).toBe(true); + expect(result!.workItemId).toBe('42'); + }); + + it('returns parsed event for Pipeline Hook with MR', async () => { + const payload = injectGitLabEventType( + { + object_kind: 'pipeline', + project: { path_with_namespace: 'group/repo', id: 1 }, + object_attributes: { id: 100, status: 'success', ref: 'main', sha: 'a', stages: [] }, + merge_request: { iid: 55 }, + }, + 'Pipeline Hook', + ); + const result = await adapter.parseWebhook(payload); + + expect(result).not.toBeNull(); + expect(result!.eventType).toBe('Pipeline Hook'); + expect(result!.workItemId).toBe('55'); + }); + + it('returns parsed event for Pipeline Hook without MR', async () => { + const payload = injectGitLabEventType( + { + object_kind: 'pipeline', + project: { path_with_namespace: 'group/repo', id: 1 }, + object_attributes: { id: 100, status: 'success', ref: 'main', sha: 'a', stages: [] }, + }, + 'Pipeline Hook', + ); + const result = await adapter.parseWebhook(payload); + + expect(result).not.toBeNull(); + expect(result!.workItemId).toBeUndefined(); + }); + + it('extracts project path correctly', async () => { + const payload = injectGitLabEventType( + { + object_kind: 'merge_request', + project: { path_with_namespace: 'my-org/sub-group/my-repo', id: 1 }, + object_attributes: { iid: 1, action: 'open' }, + }, + 'Merge Request Hook', + ); + const result = await adapter.parseWebhook(payload); + expect(result!.projectIdentifier).toBe('my-org/sub-group/my-repo'); + }); + }); + + // ====================================================================== + // isProcessableEvent + // ====================================================================== + + describe('isProcessableEvent', () => { + it('returns true for Merge Request Hook', () => { + expect( + adapter.isProcessableEvent({ + projectIdentifier: 'group/repo', + eventType: 'Merge Request Hook', + isCommentEvent: false, + }), + ).toBe(true); + }); + + it('returns true for Note Hook', () => { + expect( + adapter.isProcessableEvent({ + projectIdentifier: 'group/repo', + eventType: 'Note Hook', + isCommentEvent: true, + }), + ).toBe(true); + }); + + it('returns true for Pipeline Hook', () => { + expect( + adapter.isProcessableEvent({ + projectIdentifier: 'group/repo', + eventType: 'Pipeline Hook', + isCommentEvent: false, + }), + ).toBe(true); + }); + + it('returns true for Push Hook', () => { + expect( + adapter.isProcessableEvent({ + projectIdentifier: 'group/repo', + eventType: 'Push Hook', + isCommentEvent: false, + }), + ).toBe(true); + }); + + it('returns false for unknown event', () => { + expect( + adapter.isProcessableEvent({ + projectIdentifier: 'group/repo', + eventType: 'Tag Push Hook', + isCommentEvent: false, + }), + ).toBe(false); + }); + }); + + // ====================================================================== + // isSelfAuthored + // ====================================================================== + + describe('isSelfAuthored', () => { + it('returns false for non-comment events', async () => { + const result = await adapter.isSelfAuthored( + { projectIdentifier: 'group/repo', eventType: 'Merge Request Hook', isCommentEvent: false }, + {}, + ); + expect(result).toBe(false); + }); + + it('returns false for comment events (not yet implemented)', async () => { + const result = await adapter.isSelfAuthored( + { projectIdentifier: 'group/repo', eventType: 'Note Hook', isCommentEvent: true }, + { user: { username: 'cascade-impl' } }, + ); + // The adapter currently returns false for all cases (TODO in source) + expect(result).toBe(false); + }); + }); + + // ====================================================================== + // resolveProject + // ====================================================================== + + describe('resolveProject', () => { + it('resolves project by GitLab project path', async () => { + const event = { + projectIdentifier: 'group/repo', + eventType: 'Merge Request Hook', + isCommentEvent: false, + projectPath: 'group/repo', + }; + + const result = await adapter.resolveProject(event); + expect(result).toEqual(mockProject); + }); + + it('returns null when no project matches', async () => { + const event = { + projectIdentifier: 'other/repo', + eventType: 'Merge Request Hook', + isCommentEvent: false, + projectPath: 'other/repo', + }; + + const result = await adapter.resolveProject(event); + expect(result).toBeNull(); + }); + }); + + // ====================================================================== + // buildJob + // ====================================================================== + + describe('buildJob', () => { + it('builds a GitLab job with correct structure', () => { + const event = { + projectIdentifier: 'group/repo', + eventType: 'Merge Request Hook', + isCommentEvent: false, + projectPath: 'group/repo', + }; + const payload = { object_kind: 'merge_request' }; + const triggerResult = { + agentType: 'review', + agentInput: { prNumber: 42 }, + prNumber: 42, + }; + const ackResult = { commentId: 999, message: 'Processing...' }; + + const job = adapter.buildJob(event, payload, mockProject, triggerResult, ackResult); + + expect(job.type).toBe('gitlab'); + expect(job.source).toBe('gitlab'); + expect(job.eventType).toBe('Merge Request Hook'); + expect(job.triggerResult).toEqual(triggerResult); + expect((job as { ackCommentId?: number }).ackCommentId).toBe(999); + expect((job as { ackMessage?: string }).ackMessage).toBe('Processing...'); + }); + }); +}); diff --git a/tests/unit/triggers/builtins.test.ts b/tests/unit/triggers/builtins.test.ts index 3425a031..61f23045 100644 --- a/tests/unit/triggers/builtins.test.ts +++ b/tests/unit/triggers/builtins.test.ts @@ -55,6 +55,34 @@ vi.mock('../../../src/triggers/trello/label-added.js', () => ({ .mockImplementation(() => ({ name: 'ready-to-process-label' })), })); +vi.mock('../../../src/triggers/gitlab/mr-approval.js', () => ({ + MRApprovalTrigger: vi.fn().mockImplementation(() => ({ name: 'mr-approval' })), +})); +vi.mock('../../../src/triggers/gitlab/mr-comment-mention.js', () => ({ + MRCommentMentionTrigger: vi.fn().mockImplementation(() => ({ name: 'mr-comment-mention' })), +})); +vi.mock('../../../src/triggers/gitlab/mr-conflict-detected.js', () => ({ + MRConflictDetectedTrigger: vi.fn().mockImplementation(() => ({ name: 'mr-conflict-detected' })), +})); +vi.mock('../../../src/triggers/gitlab/mr-merged.js', () => ({ + MRMergedTrigger: vi.fn().mockImplementation(() => ({ name: 'mr-merged' })), +})); +vi.mock('../../../src/triggers/gitlab/mr-opened.js', () => ({ + MROpenedTrigger: vi.fn().mockImplementation(() => ({ name: 'mr-opened' })), +})); +vi.mock('../../../src/triggers/gitlab/mr-ready-to-merge.js', () => ({ + MRReadyToMergeTrigger: vi.fn().mockImplementation(() => ({ name: 'mr-ready-to-merge' })), +})); +vi.mock('../../../src/triggers/gitlab/pipeline-failure.js', () => ({ + PipelineFailureTrigger: vi.fn().mockImplementation(() => ({ name: 'pipeline-failure' })), +})); +vi.mock('../../../src/triggers/gitlab/pipeline-success.js', () => ({ + PipelineSuccessTrigger: vi.fn().mockImplementation(() => ({ name: 'pipeline-success' })), +})); +vi.mock('../../../src/triggers/gitlab/mr-reviewer-added.js', () => ({ + MRReviewerAddedTrigger: vi.fn().mockImplementation(() => ({ name: 'mr-reviewer-added' })), +})); + vi.mock('../../../src/triggers/sentry/alerting-issue.js', () => ({ SentryIssueAlertTrigger: vi.fn().mockImplementation(() => ({ name: 'sentry-issue-alert' })), })); @@ -88,8 +116,8 @@ describe('registerBuiltInTriggers', () => { registerBuiltInTriggers(registry as unknown as TriggerRegistry); - // Should have registered all 21 built-in triggers (19 + 2 Sentry alerting triggers) - expect(registry.register).toHaveBeenCalledTimes(21); + // Should have registered all 30 built-in triggers (19 GitHub/Trello/JIRA + 9 GitLab + 2 Sentry alerting triggers) + expect(registry.register).toHaveBeenCalledTimes(30); }); it('registers TrelloCommentMentionTrigger first', () => { diff --git a/tests/unit/triggers/gitlab/mr-opened.test.ts b/tests/unit/triggers/gitlab/mr-opened.test.ts new file mode 100644 index 00000000..58af0e30 --- /dev/null +++ b/tests/unit/triggers/gitlab/mr-opened.test.ts @@ -0,0 +1,245 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { mockConfigResolverModule, mockTriggerCheckModule } from '../../../helpers/sharedMocks.js'; + +vi.mock('../../../../src/triggers/config-resolver.js', () => mockConfigResolverModule); +vi.mock('../../../../src/triggers/shared/trigger-check.js', () => mockTriggerCheckModule); + +vi.mock('../../../../src/utils/logging.js', () => ({ + logger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})); + +vi.mock('../../../../src/db/repositories/prWorkItemsRepository.js', () => ({ + lookupWorkItemForPR: vi.fn(), +})); + +vi.mock('../../../../src/gitlab/client.js', () => ({ + gitlabClient: {}, + withGitLabToken: vi.fn(), +})); + +import { lookupWorkItemForPR } from '../../../../src/db/repositories/prWorkItemsRepository.js'; +import { MROpenedTrigger } from '../../../../src/triggers/gitlab/mr-opened.js'; +import { checkTriggerEnabledWithParams } from '../../../../src/triggers/shared/trigger-check.js'; +import type { TriggerContext } from '../../../../src/types/index.js'; +import { createMockProject } from '../../../helpers/factories.js'; +import { mockPersonaIdentities } from '../../../helpers/mockPersonas.js'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeMRPayload(overrides: Record = {}) { + return { + object_kind: 'merge_request', + event_type: 'merge_request', + user: { username: 'cascade-impl' }, + project: { path_with_namespace: 'group/repo', id: 1 }, + object_attributes: { + iid: 42, + title: 'Test MR', + description: null, + source_branch: 'feature/test', + target_branch: 'main', + state: 'opened', + action: 'open', + work_in_progress: false, + url: 'https://gitlab.com/group/repo/-/merge_requests/42', + last_commit: { id: 'abc123' }, + author_id: 1, + }, + repository: { name: 'repo', url: 'https://gitlab.com/group/repo.git' }, + ...overrides, + }; +} + +const mockProject = createMockProject({ repo: 'group/repo' }); + +describe('MROpenedTrigger', () => { + const trigger = new MROpenedTrigger(); + + beforeEach(() => { + vi.mocked(checkTriggerEnabledWithParams).mockResolvedValue({ + enabled: true, + parameters: { authorMode: 'own' }, + }); + vi.mocked(lookupWorkItemForPR).mockResolvedValue('work-item-1'); + }); + + // ======================================================================== + // matches + // ======================================================================== + + describe('matches', () => { + it('matches gitlab source with MR open action', () => { + const ctx: TriggerContext = { + project: mockProject, + source: 'gitlab', + payload: makeMRPayload(), + }; + expect(trigger.matches(ctx)).toBe(true); + }); + + it('does not match non-gitlab source', () => { + const ctx: TriggerContext = { + project: mockProject, + source: 'github', + payload: makeMRPayload(), + }; + expect(trigger.matches(ctx)).toBe(false); + }); + + it('does not match non-MR payload', () => { + const ctx: TriggerContext = { + project: mockProject, + source: 'gitlab', + payload: { + object_kind: 'pipeline', + object_attributes: { id: 1, status: 'success', ref: 'main', sha: 'a', stages: [] }, + project: { path_with_namespace: 'a/b', id: 1 }, + user: { username: 'u' }, + }, + }; + expect(trigger.matches(ctx)).toBe(false); + }); + + it('does not match close action', () => { + const ctx: TriggerContext = { + project: mockProject, + source: 'gitlab', + payload: makeMRPayload({ + object_attributes: { + ...makeMRPayload().object_attributes, + action: 'close', + }, + }), + }; + expect(trigger.matches(ctx)).toBe(false); + }); + + it('does not match update action', () => { + const ctx: TriggerContext = { + project: mockProject, + source: 'gitlab', + payload: makeMRPayload({ + object_attributes: { + ...makeMRPayload().object_attributes, + action: 'update', + }, + }), + }; + expect(trigger.matches(ctx)).toBe(false); + }); + + it('does not match WIP/draft MR', () => { + const ctx: TriggerContext = { + project: mockProject, + source: 'gitlab', + payload: makeMRPayload({ + object_attributes: { + ...makeMRPayload().object_attributes, + work_in_progress: true, + }, + }), + }; + expect(trigger.matches(ctx)).toBe(false); + }); + }); + + // ======================================================================== + // handle + // ======================================================================== + + describe('handle', () => { + it('returns review trigger result for opened MR', async () => { + const ctx: TriggerContext = { + project: mockProject, + source: 'gitlab', + payload: makeMRPayload(), + personaIdentities: mockPersonaIdentities, + }; + + const result = await trigger.handle(ctx); + + expect(result).not.toBeNull(); + expect(result!.agentType).toBe('review'); + expect(result!.prNumber).toBe(42); + expect(result!.agentInput.prBranch).toBe('feature/test'); + expect(result!.agentInput.headSha).toBe('abc123'); + expect(result!.agentInput.triggerEvent).toBe('scm:pr-opened'); + expect(result!.agentInput.triggerType).toBe('pr-opened'); + expect(result!.workItemId).toBe('work-item-1'); + }); + + it('returns null when trigger is disabled', async () => { + vi.mocked(checkTriggerEnabledWithParams).mockResolvedValue({ + enabled: false, + parameters: {}, + }); + + const ctx: TriggerContext = { + project: mockProject, + source: 'gitlab', + payload: makeMRPayload(), + personaIdentities: mockPersonaIdentities, + }; + + const result = await trigger.handle(ctx); + expect(result).toBeNull(); + }); + + it('returns null when no persona identities are available', async () => { + const ctx: TriggerContext = { + project: mockProject, + source: 'gitlab', + payload: makeMRPayload(), + }; + + const result = await trigger.handle(ctx); + expect(result).toBeNull(); + }); + + it('returns null when author does not match authorMode', async () => { + vi.mocked(checkTriggerEnabledWithParams).mockResolvedValue({ + enabled: true, + parameters: { authorMode: 'own' }, + }); + + const ctx: TriggerContext = { + project: mockProject, + source: 'gitlab', + payload: makeMRPayload({ + user: { username: 'external-user' }, + }), + personaIdentities: mockPersonaIdentities, + }; + + const result = await trigger.handle(ctx); + expect(result).toBeNull(); + }); + + it('fires for external MR when authorMode is "all"', async () => { + vi.mocked(checkTriggerEnabledWithParams).mockResolvedValue({ + enabled: true, + parameters: { authorMode: 'all' }, + }); + + const ctx: TriggerContext = { + project: mockProject, + source: 'gitlab', + payload: makeMRPayload({ + user: { username: 'external-user' }, + }), + personaIdentities: mockPersonaIdentities, + }; + + const result = await trigger.handle(ctx); + expect(result).not.toBeNull(); + expect(result!.agentType).toBe('review'); + }); + }); +}); diff --git a/tests/unit/triggers/gitlab/mr-reviewer-added.test.ts b/tests/unit/triggers/gitlab/mr-reviewer-added.test.ts new file mode 100644 index 00000000..66e606d0 --- /dev/null +++ b/tests/unit/triggers/gitlab/mr-reviewer-added.test.ts @@ -0,0 +1,268 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { mockConfigResolverModule, mockTriggerCheckModule } from '../../../helpers/sharedMocks.js'; + +vi.mock('../../../../src/triggers/config-resolver.js', () => mockConfigResolverModule); +vi.mock('../../../../src/triggers/shared/trigger-check.js', () => mockTriggerCheckModule); + +vi.mock('../../../../src/utils/logging.js', () => ({ + logger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})); + +vi.mock('../../../../src/db/repositories/prWorkItemsRepository.js', () => ({ + lookupWorkItemForPR: vi.fn(), +})); + +vi.mock('../../../../src/gitlab/personas.js', () => ({ + isCascadeBot: vi.fn(), +})); + +vi.mock('../../../../src/gitlab/client.js', () => ({ + gitlabClient: {}, + withGitLabToken: vi.fn(), +})); + +import { lookupWorkItemForPR } from '../../../../src/db/repositories/prWorkItemsRepository.js'; +import { isCascadeBot } from '../../../../src/gitlab/personas.js'; +import { MRReviewerAddedTrigger } from '../../../../src/triggers/gitlab/mr-reviewer-added.js'; +import { checkTriggerEnabled } from '../../../../src/triggers/shared/trigger-check.js'; +import type { TriggerContext } from '../../../../src/types/index.js'; +import { createMockProject } from '../../../helpers/factories.js'; +import { + IMPLEMENTER_USERNAME, + mockPersonaIdentities, + REVIEWER_USERNAME, +} from '../../../helpers/mockPersonas.js'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeMRPayload(overrides: Record = {}) { + return { + object_kind: 'merge_request', + event_type: 'merge_request', + user: { username: 'external-user' }, + project: { path_with_namespace: 'group/repo', id: 1 }, + object_attributes: { + iid: 42, + title: 'Test MR', + description: null, + source_branch: 'feature/test', + target_branch: 'main', + state: 'opened', + action: 'update', + work_in_progress: false, + url: 'https://gitlab.com/group/repo/-/merge_requests/42', + last_commit: { id: 'abc123' }, + author_id: 1, + }, + repository: { name: 'repo', url: 'https://gitlab.com/group/repo.git' }, + changes: { + reviewers: { + previous: [{ username: 'existing-reviewer' }], + current: [{ username: 'existing-reviewer' }, { username: REVIEWER_USERNAME }], + }, + }, + ...overrides, + }; +} + +const mockProject = createMockProject({ repo: 'group/repo' }); + +describe('MRReviewerAddedTrigger', () => { + const trigger = new MRReviewerAddedTrigger(); + + beforeEach(() => { + vi.mocked(checkTriggerEnabled).mockResolvedValue(true); + vi.mocked(lookupWorkItemForPR).mockResolvedValue('work-item-1'); + // Default: external-user is not a bot, REVIEWER_USERNAME is + vi.mocked(isCascadeBot).mockImplementation((username: string) => { + return username === IMPLEMENTER_USERNAME || username === REVIEWER_USERNAME; + }); + }); + + // ======================================================================== + // matches + // ======================================================================== + + describe('matches', () => { + it('matches gitlab source with MR update action and reviewer changes', () => { + const ctx: TriggerContext = { + project: mockProject, + source: 'gitlab', + payload: makeMRPayload(), + }; + expect(trigger.matches(ctx)).toBe(true); + }); + + it('does not match non-gitlab source', () => { + const ctx: TriggerContext = { + project: mockProject, + source: 'github', + payload: makeMRPayload(), + }; + expect(trigger.matches(ctx)).toBe(false); + }); + + it('does not match non-MR payload', () => { + const ctx: TriggerContext = { + project: mockProject, + source: 'gitlab', + payload: { + object_kind: 'pipeline', + object_attributes: { id: 1, status: 'success', ref: 'main', sha: 'a', stages: [] }, + project: { path_with_namespace: 'a/b', id: 1 }, + user: { username: 'u' }, + }, + }; + expect(trigger.matches(ctx)).toBe(false); + }); + + it('does not match open action', () => { + const ctx: TriggerContext = { + project: mockProject, + source: 'gitlab', + payload: makeMRPayload({ + object_attributes: { + ...makeMRPayload().object_attributes, + action: 'open', + }, + }), + }; + expect(trigger.matches(ctx)).toBe(false); + }); + + it('does not match when no reviewer changes', () => { + const ctx: TriggerContext = { + project: mockProject, + source: 'gitlab', + payload: makeMRPayload({ + changes: { + title: { previous: 'Old', current: 'New' }, + }, + }), + }; + expect(trigger.matches(ctx)).toBe(false); + }); + + it('does not match when reviewers were removed (current < previous)', () => { + const ctx: TriggerContext = { + project: mockProject, + source: 'gitlab', + payload: makeMRPayload({ + changes: { + reviewers: { + previous: [{ username: 'a' }, { username: 'b' }], + current: [{ username: 'a' }], + }, + }, + }), + }; + expect(trigger.matches(ctx)).toBe(false); + }); + + it('does not match when reviewer count is unchanged', () => { + const ctx: TriggerContext = { + project: mockProject, + source: 'gitlab', + payload: makeMRPayload({ + changes: { + reviewers: { + previous: [{ username: 'a' }], + current: [{ username: 'b' }], + }, + }, + }), + }; + expect(trigger.matches(ctx)).toBe(false); + }); + }); + + // ======================================================================== + // handle + // ======================================================================== + + describe('handle', () => { + it('returns review trigger result when CASCADE persona is added as reviewer', async () => { + const ctx: TriggerContext = { + project: mockProject, + source: 'gitlab', + payload: makeMRPayload(), + personaIdentities: mockPersonaIdentities, + }; + + const result = await trigger.handle(ctx); + + expect(result).not.toBeNull(); + expect(result!.agentType).toBe('review'); + expect(result!.prNumber).toBe(42); + expect(result!.agentInput.triggerEvent).toBe('scm:review-requested'); + expect(result!.agentInput.triggerType).toBe('review-requested'); + }); + + it('returns null when trigger is disabled', async () => { + vi.mocked(checkTriggerEnabled).mockResolvedValue(false); + + const ctx: TriggerContext = { + project: mockProject, + source: 'gitlab', + payload: makeMRPayload(), + personaIdentities: mockPersonaIdentities, + }; + + const result = await trigger.handle(ctx); + expect(result).toBeNull(); + }); + + it('returns null when no persona identities are available', async () => { + const ctx: TriggerContext = { + project: mockProject, + source: 'gitlab', + payload: makeMRPayload(), + }; + + const result = await trigger.handle(ctx); + expect(result).toBeNull(); + }); + + it('returns null when sender is a CASCADE persona (loop prevention)', async () => { + const ctx: TriggerContext = { + project: mockProject, + source: 'gitlab', + payload: makeMRPayload({ + user: { username: IMPLEMENTER_USERNAME }, + }), + personaIdentities: mockPersonaIdentities, + }; + + const result = await trigger.handle(ctx); + expect(result).toBeNull(); + }); + + it('returns null when newly added reviewer is not a CASCADE persona', async () => { + vi.mocked(isCascadeBot).mockReturnValue(false); + + const ctx: TriggerContext = { + project: mockProject, + source: 'gitlab', + payload: makeMRPayload({ + changes: { + reviewers: { + previous: [{ username: 'existing-reviewer' }], + current: [{ username: 'existing-reviewer' }, { username: 'another-human-reviewer' }], + }, + }, + }), + personaIdentities: mockPersonaIdentities, + }; + + const result = await trigger.handle(ctx); + expect(result).toBeNull(); + }); + }); +}); diff --git a/tests/unit/triggers/gitlab/pipeline-failure.test.ts b/tests/unit/triggers/gitlab/pipeline-failure.test.ts new file mode 100644 index 00000000..969fc69a --- /dev/null +++ b/tests/unit/triggers/gitlab/pipeline-failure.test.ts @@ -0,0 +1,300 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { mockConfigResolverModule, mockTriggerCheckModule } from '../../../helpers/sharedMocks.js'; + +vi.mock('../../../../src/triggers/config-resolver.js', () => mockConfigResolverModule); +vi.mock('../../../../src/triggers/shared/trigger-check.js', () => mockTriggerCheckModule); + +vi.mock('../../../../src/utils/logging.js', () => ({ + logger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})); + +vi.mock('../../../../src/db/repositories/prWorkItemsRepository.js', () => ({ + lookupWorkItemForPR: vi.fn(), +})); + +vi.mock('../../../../src/gitlab/client.js', () => ({ + gitlabClient: { + getOpenMRByBranch: vi.fn(), + getMR: vi.fn(), + }, + withGitLabToken: vi.fn(), +})); + +import { lookupWorkItemForPR } from '../../../../src/db/repositories/prWorkItemsRepository.js'; +import { + PipelineFailureTrigger, + resetFixAttempts, +} from '../../../../src/triggers/gitlab/pipeline-failure.js'; +import { checkTriggerEnabled } from '../../../../src/triggers/shared/trigger-check.js'; +import type { TriggerContext } from '../../../../src/types/index.js'; +import { createMockProject } from '../../../helpers/factories.js'; +import { IMPLEMENTER_USERNAME, mockPersonaIdentities } from '../../../helpers/mockPersonas.js'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makePipelinePayload(overrides: Record = {}) { + return { + object_kind: 'pipeline', + object_attributes: { + id: 100, + ref: 'feature/test', + sha: 'abc123', + status: 'failed', + stages: ['build', 'test'], + }, + user: { username: IMPLEMENTER_USERNAME }, + project: { path_with_namespace: 'group/repo', id: 1 }, + merge_request: { + iid: 42, + title: 'Test MR', + url: 'https://gitlab.com/group/repo/-/merge_requests/42', + source_branch: 'feature/test', + target_branch: 'main', + state: 'opened', + }, + builds: [ + { id: 1, name: 'build-job', stage: 'build', status: 'success' }, + { + id: 2, + name: 'test-job', + stage: 'test', + status: 'failed', + failure_reason: 'script_failure', + }, + ], + ...overrides, + }; +} + +const mockProject = createMockProject({ repo: 'group/repo' }); + +describe('PipelineFailureTrigger', () => { + const trigger = new PipelineFailureTrigger(); + + beforeEach(() => { + vi.mocked(checkTriggerEnabled).mockResolvedValue(true); + vi.mocked(lookupWorkItemForPR).mockResolvedValue('work-item-1'); + // Reset the fix attempt counter between tests + resetFixAttempts(42); + }); + + // ======================================================================== + // matches + // ======================================================================== + + describe('matches', () => { + it('matches gitlab source with failed pipeline payload', () => { + const ctx: TriggerContext = { + project: mockProject, + source: 'gitlab', + payload: makePipelinePayload(), + }; + expect(trigger.matches(ctx)).toBe(true); + }); + + it('does not match non-gitlab source', () => { + const ctx: TriggerContext = { + project: mockProject, + source: 'github', + payload: makePipelinePayload(), + }; + expect(trigger.matches(ctx)).toBe(false); + }); + + it('does not match successful pipeline status', () => { + const ctx: TriggerContext = { + project: mockProject, + source: 'gitlab', + payload: makePipelinePayload({ + object_attributes: { + id: 100, + ref: 'feature/test', + sha: 'abc123', + status: 'success', + stages: ['build', 'test'], + }, + }), + }; + expect(trigger.matches(ctx)).toBe(false); + }); + + it('does not match running pipeline status', () => { + const ctx: TriggerContext = { + project: mockProject, + source: 'gitlab', + payload: makePipelinePayload({ + object_attributes: { + id: 100, + ref: 'feature/test', + sha: 'abc123', + status: 'running', + stages: ['build', 'test'], + }, + }), + }; + expect(trigger.matches(ctx)).toBe(false); + }); + + it('does not match non-pipeline payload', () => { + const ctx: TriggerContext = { + project: mockProject, + source: 'gitlab', + payload: { + object_kind: 'merge_request', + object_attributes: { action: 'open' }, + project: { path_with_namespace: 'a/b', id: 1 }, + }, + }; + expect(trigger.matches(ctx)).toBe(false); + }); + }); + + // ======================================================================== + // handle + // ======================================================================== + + describe('handle', () => { + it('returns respond-to-ci trigger result for failed pipeline on implementer MR', async () => { + const ctx: TriggerContext = { + project: mockProject, + source: 'gitlab', + payload: makePipelinePayload(), + personaIdentities: mockPersonaIdentities, + }; + + const result = await trigger.handle(ctx); + + expect(result).not.toBeNull(); + expect(result!.agentType).toBe('respond-to-ci'); + expect(result!.prNumber).toBe(42); + expect(result!.agentInput.triggerEvent).toBe('scm:check-suite-failure'); + expect(result!.agentInput.triggerType).toBe('check-failure'); + }); + + it('returns null when trigger is disabled', async () => { + vi.mocked(checkTriggerEnabled).mockResolvedValue(false); + + const ctx: TriggerContext = { + project: mockProject, + source: 'gitlab', + payload: makePipelinePayload(), + personaIdentities: mockPersonaIdentities, + }; + + const result = await trigger.handle(ctx); + expect(result).toBeNull(); + }); + + it('returns null when no MR is associated with the pipeline', async () => { + const { gitlabClient } = await import('../../../../src/gitlab/client.js'); + vi.mocked(gitlabClient.getOpenMRByBranch).mockResolvedValue(null as never); + + const ctx: TriggerContext = { + project: mockProject, + source: 'gitlab', + payload: makePipelinePayload({ merge_request: undefined }), + personaIdentities: mockPersonaIdentities, + }; + + const result = await trigger.handle(ctx); + expect(result).toBeNull(); + }); + + it('returns null when no persona identities are available', async () => { + const ctx: TriggerContext = { + project: mockProject, + source: 'gitlab', + payload: makePipelinePayload(), + // no personaIdentities + }; + + const result = await trigger.handle(ctx); + expect(result).toBeNull(); + }); + + it('returns null when MR author is not the implementer persona', async () => { + const ctx: TriggerContext = { + project: mockProject, + source: 'gitlab', + payload: makePipelinePayload({ + user: { username: 'external-user' }, + }), + personaIdentities: mockPersonaIdentities, + }; + + const result = await trigger.handle(ctx); + expect(result).toBeNull(); + }); + + it('returns null when MR targets non-base branch', async () => { + const ctx: TriggerContext = { + project: mockProject, + source: 'gitlab', + payload: makePipelinePayload({ + merge_request: { + iid: 42, + title: 'Test MR', + url: 'https://gitlab.com/group/repo/-/merge_requests/42', + source_branch: 'feature/test', + target_branch: 'develop', + state: 'opened', + }, + }), + personaIdentities: mockPersonaIdentities, + }; + + const result = await trigger.handle(ctx); + expect(result).toBeNull(); + }); + + it('enforces max 3 fix attempts per MR', async () => { + const makeCtx = (): TriggerContext => ({ + project: mockProject, + source: 'gitlab', + payload: makePipelinePayload(), + personaIdentities: mockPersonaIdentities, + }); + + // First 3 attempts should succeed + const r1 = await trigger.handle(makeCtx()); + expect(r1).not.toBeNull(); + + const r2 = await trigger.handle(makeCtx()); + expect(r2).not.toBeNull(); + + const r3 = await trigger.handle(makeCtx()); + expect(r3).not.toBeNull(); + + // 4th attempt should be blocked + const r4 = await trigger.handle(makeCtx()); + expect(r4).toBeNull(); + }); + + it('resets attempt counter via resetFixAttempts', async () => { + const makeCtx = (): TriggerContext => ({ + project: mockProject, + source: 'gitlab', + payload: makePipelinePayload(), + personaIdentities: mockPersonaIdentities, + }); + + // Use up all 3 attempts + await trigger.handle(makeCtx()); + await trigger.handle(makeCtx()); + await trigger.handle(makeCtx()); + expect(await trigger.handle(makeCtx())).toBeNull(); + + // Reset and verify we can trigger again + resetFixAttempts(42); + const result = await trigger.handle(makeCtx()); + expect(result).not.toBeNull(); + }); + }); +}); diff --git a/tests/unit/triggers/gitlab/pipeline-success.test.ts b/tests/unit/triggers/gitlab/pipeline-success.test.ts new file mode 100644 index 00000000..0c17df65 --- /dev/null +++ b/tests/unit/triggers/gitlab/pipeline-success.test.ts @@ -0,0 +1,282 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { mockConfigResolverModule, mockTriggerCheckModule } from '../../../helpers/sharedMocks.js'; + +vi.mock('../../../../src/triggers/config-resolver.js', () => mockConfigResolverModule); +vi.mock('../../../../src/triggers/shared/trigger-check.js', () => mockTriggerCheckModule); + +vi.mock('../../../../src/utils/logging.js', () => ({ + logger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})); + +vi.mock('../../../../src/db/repositories/prWorkItemsRepository.js', () => ({ + lookupWorkItemForPR: vi.fn(), +})); + +vi.mock('../../../../src/gitlab/client.js', () => ({ + gitlabClient: { + getOpenMRByBranch: vi.fn(), + getMR: vi.fn(), + }, + withGitLabToken: vi.fn(), +})); + +import { lookupWorkItemForPR } from '../../../../src/db/repositories/prWorkItemsRepository.js'; +import { gitlabClient } from '../../../../src/gitlab/client.js'; +import { PipelineSuccessTrigger } from '../../../../src/triggers/gitlab/pipeline-success.js'; +import { checkTriggerEnabledWithParams } from '../../../../src/triggers/shared/trigger-check.js'; +import type { TriggerContext } from '../../../../src/types/index.js'; +import { createMockProject } from '../../../helpers/factories.js'; +import { mockPersonaIdentities } from '../../../helpers/mockPersonas.js'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makePipelinePayload(overrides: Record = {}) { + return { + object_kind: 'pipeline', + object_attributes: { + id: 100, + ref: 'feature/test', + sha: 'abc123', + status: 'success', + stages: ['build', 'test'], + }, + user: { username: 'cascade-impl' }, + project: { path_with_namespace: 'group/repo', id: 1 }, + merge_request: { + iid: 42, + title: 'Test MR', + url: 'https://gitlab.com/group/repo/-/merge_requests/42', + source_branch: 'feature/test', + target_branch: 'main', + state: 'opened', + }, + ...overrides, + }; +} + +const mockProject = createMockProject({ repo: 'group/repo' }); + +describe('PipelineSuccessTrigger', () => { + const trigger = new PipelineSuccessTrigger(); + + beforeEach(() => { + vi.mocked(checkTriggerEnabledWithParams).mockResolvedValue({ + enabled: true, + parameters: { authorMode: 'own' }, + }); + vi.mocked(lookupWorkItemForPR).mockResolvedValue('work-item-1'); + }); + + // ======================================================================== + // matches + // ======================================================================== + + describe('matches', () => { + it('matches gitlab source with successful pipeline payload', () => { + const ctx: TriggerContext = { + project: mockProject, + source: 'gitlab', + payload: makePipelinePayload(), + }; + expect(trigger.matches(ctx)).toBe(true); + }); + + it('does not match non-gitlab source', () => { + const ctx: TriggerContext = { + project: mockProject, + source: 'github', + payload: makePipelinePayload(), + }; + expect(trigger.matches(ctx)).toBe(false); + }); + + it('does not match non-pipeline payload', () => { + const ctx: TriggerContext = { + project: mockProject, + source: 'gitlab', + payload: { + object_kind: 'merge_request', + object_attributes: { action: 'open' }, + project: { path_with_namespace: 'a/b', id: 1 }, + }, + }; + expect(trigger.matches(ctx)).toBe(false); + }); + + it('does not match failed pipeline status', () => { + const ctx: TriggerContext = { + project: mockProject, + source: 'gitlab', + payload: makePipelinePayload({ + object_attributes: { + id: 100, + ref: 'feature/test', + sha: 'abc123', + status: 'failed', + stages: ['build', 'test'], + }, + }), + }; + expect(trigger.matches(ctx)).toBe(false); + }); + + it('does not match running pipeline status', () => { + const ctx: TriggerContext = { + project: mockProject, + source: 'gitlab', + payload: makePipelinePayload({ + object_attributes: { + id: 100, + ref: 'feature/test', + sha: 'abc123', + status: 'running', + stages: ['build', 'test'], + }, + }), + }; + expect(trigger.matches(ctx)).toBe(false); + }); + }); + + // ======================================================================== + // handle + // ======================================================================== + + describe('handle', () => { + it('returns review trigger result for successful pipeline on MR', async () => { + const ctx: TriggerContext = { + project: mockProject, + source: 'gitlab', + payload: makePipelinePayload(), + personaIdentities: mockPersonaIdentities, + }; + + const result = await trigger.handle(ctx); + + expect(result).not.toBeNull(); + expect(result!.agentType).toBe('review'); + expect(result!.prNumber).toBe(42); + expect(result!.agentInput.prBranch).toBe('feature/test'); + expect(result!.agentInput.triggerEvent).toBe('scm:check-suite-success'); + expect(result!.waitForChecks).toBe(false); + }); + + it('returns null when trigger is disabled', async () => { + vi.mocked(checkTriggerEnabledWithParams).mockResolvedValue({ + enabled: false, + parameters: {}, + }); + + const ctx: TriggerContext = { + project: mockProject, + source: 'gitlab', + payload: makePipelinePayload(), + personaIdentities: mockPersonaIdentities, + }; + + const result = await trigger.handle(ctx); + expect(result).toBeNull(); + }); + + it('returns null when no MR is associated with the pipeline', async () => { + vi.mocked(gitlabClient.getOpenMRByBranch).mockResolvedValue(null as never); + + const ctx: TriggerContext = { + project: mockProject, + source: 'gitlab', + payload: makePipelinePayload({ merge_request: undefined }), + personaIdentities: mockPersonaIdentities, + }; + + const result = await trigger.handle(ctx); + expect(result).toBeNull(); + }); + + it('returns null when no persona identities are available', async () => { + const ctx: TriggerContext = { + project: mockProject, + source: 'gitlab', + payload: makePipelinePayload(), + // no personaIdentities + }; + + const result = await trigger.handle(ctx); + expect(result).toBeNull(); + }); + + it('returns null when author does not match authorMode', async () => { + vi.mocked(checkTriggerEnabledWithParams).mockResolvedValue({ + enabled: true, + parameters: { authorMode: 'own' }, + }); + + const ctx: TriggerContext = { + project: mockProject, + source: 'gitlab', + payload: makePipelinePayload({ + user: { username: 'external-user' }, + }), + personaIdentities: mockPersonaIdentities, + }; + + const result = await trigger.handle(ctx); + expect(result).toBeNull(); + }); + + it('returns null when MR targets non-base branch', async () => { + const ctx: TriggerContext = { + project: mockProject, + source: 'gitlab', + payload: makePipelinePayload({ + merge_request: { + iid: 42, + title: 'Test MR', + url: 'https://gitlab.com/group/repo/-/merge_requests/42', + source_branch: 'feature/test', + target_branch: 'develop', // not main + state: 'opened', + }, + }), + personaIdentities: mockPersonaIdentities, + }; + + const result = await trigger.handle(ctx); + expect(result).toBeNull(); + }); + + it('resolves MR from API when payload merge_request is null', async () => { + vi.mocked(gitlabClient.getOpenMRByBranch).mockResolvedValue({ + iid: 42, + } as never); + vi.mocked(gitlabClient.getMR).mockResolvedValue({ + iid: 42, + title: 'API-resolved MR', + webUrl: 'https://gitlab.com/group/repo/-/merge_requests/42', + sourceBranch: 'feature/test', + targetBranch: 'main', + state: 'opened', + } as never); + + const ctx: TriggerContext = { + project: mockProject, + source: 'gitlab', + payload: makePipelinePayload({ merge_request: undefined }), + personaIdentities: mockPersonaIdentities, + }; + + const result = await trigger.handle(ctx); + + expect(result).not.toBeNull(); + expect(result!.agentType).toBe('review'); + expect(result!.prNumber).toBe(42); + expect(gitlabClient.getOpenMRByBranch).toHaveBeenCalledWith('group/repo', 'feature/test'); + }); + }); +}); diff --git a/tests/unit/triggers/gitlab/types.test.ts b/tests/unit/triggers/gitlab/types.test.ts new file mode 100644 index 00000000..cd8b5fa5 --- /dev/null +++ b/tests/unit/triggers/gitlab/types.test.ts @@ -0,0 +1,187 @@ +import { describe, expect, it } from 'vitest'; + +import { + isGitLabMergeRequestPayload, + isGitLabNotePayload, + isGitLabPipelinePayload, +} from '../../../../src/triggers/gitlab/types.js'; + +// --------------------------------------------------------------------------- +// Test payloads +// --------------------------------------------------------------------------- + +function makeMRPayload(overrides: Record = {}) { + return { + object_kind: 'merge_request', + event_type: 'merge_request', + user: { username: 'author' }, + project: { path_with_namespace: 'group/repo', id: 1 }, + object_attributes: { + iid: 42, + title: 'Test MR', + description: null, + source_branch: 'feature/test', + target_branch: 'main', + state: 'opened', + action: 'open', + work_in_progress: false, + url: 'https://gitlab.com/group/repo/-/merge_requests/42', + last_commit: { id: 'abc123' }, + author_id: 1, + }, + repository: { name: 'repo', url: 'https://gitlab.com/group/repo.git' }, + ...overrides, + }; +} + +function makePipelinePayload(overrides: Record = {}) { + return { + object_kind: 'pipeline', + object_attributes: { + id: 100, + ref: 'feature/test', + sha: 'abc123', + status: 'success', + stages: ['build', 'test'], + }, + user: { username: 'author' }, + project: { path_with_namespace: 'group/repo', id: 1 }, + ...overrides, + }; +} + +function makeNotePayload(overrides: Record = {}) { + return { + object_kind: 'note', + event_type: 'note', + user: { username: 'commenter' }, + project: { path_with_namespace: 'group/repo', id: 1 }, + object_attributes: { + id: 200, + note: 'A comment', + noteable_type: 'MergeRequest', + author_id: 2, + url: 'https://gitlab.com/group/repo/-/merge_requests/42#note_200', + }, + repository: { name: 'repo', url: 'https://gitlab.com/group/repo.git' }, + ...overrides, + }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('GitLab type guards', () => { + describe('isGitLabMergeRequestPayload', () => { + it('returns true for a valid merge request payload', () => { + expect(isGitLabMergeRequestPayload(makeMRPayload())).toBe(true); + }); + + it('returns false for a pipeline payload', () => { + expect(isGitLabMergeRequestPayload(makePipelinePayload())).toBe(false); + }); + + it('returns false for a note payload', () => { + expect(isGitLabMergeRequestPayload(makeNotePayload())).toBe(false); + }); + + it('returns false for null', () => { + expect(isGitLabMergeRequestPayload(null)).toBe(false); + }); + + it('returns false for a non-object', () => { + expect(isGitLabMergeRequestPayload('string')).toBe(false); + }); + + it('returns false when object_attributes is missing', () => { + expect( + isGitLabMergeRequestPayload({ + object_kind: 'merge_request', + project: { path_with_namespace: 'a/b', id: 1 }, + }), + ).toBe(false); + }); + + it('returns false when project is missing', () => { + expect( + isGitLabMergeRequestPayload({ + object_kind: 'merge_request', + object_attributes: { iid: 1 }, + }), + ).toBe(false); + }); + }); + + describe('isGitLabPipelinePayload', () => { + it('returns true for a valid pipeline payload', () => { + expect(isGitLabPipelinePayload(makePipelinePayload())).toBe(true); + }); + + it('returns false for a merge request payload', () => { + expect(isGitLabPipelinePayload(makeMRPayload())).toBe(false); + }); + + it('returns false for null', () => { + expect(isGitLabPipelinePayload(null)).toBe(false); + }); + + it('returns false for a non-object', () => { + expect(isGitLabPipelinePayload(42)).toBe(false); + }); + + it('returns false when object_attributes is missing', () => { + expect( + isGitLabPipelinePayload({ + object_kind: 'pipeline', + project: { path_with_namespace: 'a/b', id: 1 }, + }), + ).toBe(false); + }); + + it('returns false when project is missing', () => { + expect( + isGitLabPipelinePayload({ + object_kind: 'pipeline', + object_attributes: { id: 1 }, + }), + ).toBe(false); + }); + }); + + describe('isGitLabNotePayload', () => { + it('returns true for a valid note payload', () => { + expect(isGitLabNotePayload(makeNotePayload())).toBe(true); + }); + + it('returns false for a merge request payload', () => { + expect(isGitLabNotePayload(makeMRPayload())).toBe(false); + }); + + it('returns false for null', () => { + expect(isGitLabNotePayload(null)).toBe(false); + }); + + it('returns false for undefined', () => { + expect(isGitLabNotePayload(undefined)).toBe(false); + }); + + it('returns false when object_attributes is missing', () => { + expect( + isGitLabNotePayload({ + object_kind: 'note', + project: { path_with_namespace: 'a/b', id: 1 }, + }), + ).toBe(false); + }); + + it('returns false when project is missing', () => { + expect( + isGitLabNotePayload({ + object_kind: 'note', + object_attributes: { id: 1 }, + }), + ).toBe(false); + }); + }); +}); diff --git a/tests/unit/triggers/shared/agent-execution.test.ts b/tests/unit/triggers/shared/agent-execution.test.ts index 5c8fc22d..ca72c9f2 100644 --- a/tests/unit/triggers/shared/agent-execution.test.ts +++ b/tests/unit/triggers/shared/agent-execution.test.ts @@ -162,6 +162,10 @@ vi.mock('../../../../src/agents/definitions/profiles.js', () => ({ getAgentProfile: mockGetAgentProfile, })); +vi.mock('../../../../src/db/repositories/credentialsRepository.js', () => ({ + getIntegrationProvider: vi.fn().mockResolvedValue('github'), +})); + import { linkPRToWorkItem } from '../../../../src/db/repositories/prWorkItemsRepository.js'; import { runAgentExecutionPipeline } from '../../../../src/triggers/shared/agent-execution.js'; @@ -706,7 +710,7 @@ describe('linkPRPostExecution PR title backfill (via runAgentExecutionPipeline)' expect.objectContaining({ prTitle: undefined }), ); expect(mockLogger.warn).toHaveBeenCalledWith( - 'Failed to fetch PR title from GitHub', + 'Failed to fetch PR/MR title', expect.objectContaining({ prNumber: 42 }), ); }); diff --git a/tests/unit/utils/repo.test.ts b/tests/unit/utils/repo.test.ts index 0bd327e2..7b1135fc 100644 --- a/tests/unit/utils/repo.test.ts +++ b/tests/unit/utils/repo.test.ts @@ -15,6 +15,10 @@ vi.mock('node:fs', () => ({ rmSync: vi.fn(), })); +vi.mock('../../../src/db/repositories/credentialsRepository.js', () => ({ + getIntegrationProvider: vi.fn(() => Promise.resolve('github')), +})); + vi.mock('../../../src/config/projects.js', () => ({ getProjectGitHubToken: vi.fn(() => Promise.resolve('test-token')), })); diff --git a/vitest.config.ts b/vitest.config.ts index 2e17c567..8ba2d0aa 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -116,6 +116,7 @@ export default defineConfig({ 'tests/unit/pm/**/*.test.ts', 'tests/unit/integrations/**/*.test.ts', 'tests/unit/github/**/*.test.ts', + 'tests/unit/gitlab/**/*.test.ts', 'tests/unit/jira/**/*.test.ts', 'tests/unit/trello/**/*.test.ts', 'tests/unit/web/**/*.test.ts', diff --git a/web/src/components/projects/integration-form.tsx b/web/src/components/projects/integration-form.tsx index 328bed69..eceb6be0 100644 --- a/web/src/components/projects/integration-form.tsx +++ b/web/src/components/projects/integration-form.tsx @@ -63,6 +63,8 @@ export function IntegrationForm({ projectId }: { projectId: string }) { const integrations = integrationsQuery.data ?? []; const pmIntegration = findIntegrationByCategory(integrations, 'pm'); const pmProvider = (pmIntegration?.provider as string) ?? 'trello'; + const scmIntegration = findIntegrationByCategory(integrations, 'scm'); + const scmProvider = (scmIntegration?.provider as string) ?? 'github'; const alertingIntegration = findIntegrationByCategory(integrations, 'alerting'); return ( @@ -96,7 +98,13 @@ export function IntegrationForm({ projectId }: { projectId: string }) { /> )} - {activeTab === 'scm' && } + {activeTab === 'scm' && ( + + )} {activeTab === 'alerting' && ( diff --git a/web/src/components/projects/integration-scm-tab.tsx b/web/src/components/projects/integration-scm-tab.tsx index 81233f54..a6373398 100644 --- a/web/src/components/projects/integration-scm-tab.tsx +++ b/web/src/components/projects/integration-scm-tab.tsx @@ -1,6 +1,7 @@ /** - * SCM (GitHub) integration tab components. - * Contains: CopyButton, GitHubCredentialSlots, GitHubWebhookSection, SCMTab. + * SCM (GitHub/GitLab) integration tab components. + * Contains: CopyButton, GitHubCredentialSlots, GitLabCredentialSlots, + * GitHubWebhookSection, GitLabWebhookSection, SCMTab. * CopyButton is co-located here and also exported for use by AlertingTab. */ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; @@ -22,6 +23,8 @@ import { API_URL } from '@/lib/api.js'; import { trpc, trpcClient } from '@/lib/trpc.js'; import { ProjectSecretField } from './project-secret-field.js'; +type SCMProvider = 'github' | 'gitlab'; + // ============================================================================ // CopyButton (shared with AlertingTab) // ============================================================================ @@ -119,6 +122,40 @@ function GitHubCredentialSlots({ projectId }: { projectId: string }) { ); } +// ============================================================================ +// GitLab Credential Slots +// ============================================================================ + +function GitLabCredentialSlots({ projectId }: { projectId: string }) { + const credentialsQuery = useQuery(trpc.projects.credentials.list.queryOptions({ projectId })); + + const credentials = credentialsQuery.data ?? []; + const implementerCred = credentials.find((c) => c.envVarKey === 'GITLAB_TOKEN_IMPLEMENTER'); + const reviewerCred = credentials.find((c) => c.envVarKey === 'GITLAB_TOKEN_REVIEWER'); + + return ( +
+ + + +
+ ); +} + // ============================================================================ // GitHub Webhook Management // ============================================================================ @@ -305,7 +342,97 @@ function GitHubWebhookSection({ projectId }: { projectId: string }) { } // ============================================================================ -// SCM Tab (GitHub) +// GitLab Webhook Management +// ============================================================================ + +function GitLabWebhookSection({ projectId }: { projectId: string }) { + const callbackBaseUrl = + API_URL || + (typeof window !== 'undefined' ? window.location.origin.replace(':5173', ':3000') : ''); + + const webhookCallbackUrl = callbackBaseUrl + ? `${callbackBaseUrl}/gitlab/webhook` + : '/gitlab/webhook'; + + return ( +
+
+ +

+ Configure GitLab webhooks for receiving push events, MR updates, and pipeline + notifications. +

+
+ +
+
+ +
+

+ GitLab webhook setup +

+

+ Configure the webhook in your GitLab project under Settings > Webhooks. Use the URL + below and enable the following triggers: Push events, Merge request events, Pipeline + events. +

+
+ + {webhookCallbackUrl} + + +
+
+
+
+
+ ); +} + +// ============================================================================ +// SCM Provider Selector +// ============================================================================ + +function SCMProviderSelector({ + value, + onChange, +}: { + value: SCMProvider; + onChange: (provider: SCMProvider) => void; +}) { + return ( +
+ +
+ + +
+
+ ); +} + +// ============================================================================ +// SCM Tab (GitHub / GitLab) // ============================================================================ interface SCMTabProject { @@ -314,9 +441,19 @@ interface SCMTabProject { branchPrefix?: string | null; } -export function SCMTab({ projectId, project }: { projectId: string; project?: SCMTabProject }) { +export function SCMTab({ + projectId, + project, + initialProvider = 'github', +}: { + projectId: string; + project?: SCMTabProject; + initialProvider?: SCMProvider; +}) { const queryClient = useQueryClient(); + const [scmProvider, setScmProvider] = useState(initialProvider); + // Project-level SCM fields const [repo, setRepo] = useState(project?.repo ?? ''); const [baseBranch, setBaseBranch] = useState(project?.baseBranch ?? 'main'); @@ -328,6 +465,10 @@ export function SCMTab({ projectId, project }: { projectId: string; project?: SC setBranchPrefix(project?.branchPrefix ?? 'feature/'); }, [project?.repo, project?.baseBranch, project?.branchPrefix]); + useEffect(() => { + setScmProvider(initialProvider); + }, [initialProvider]); + const saveMutation = useMutation({ mutationFn: async () => { // Save project-level SCM fields @@ -342,7 +483,7 @@ export function SCMTab({ projectId, project }: { projectId: string; project?: SC const result = await trpcClient.projects.integrations.upsert.mutate({ projectId, category: 'scm', - provider: 'github', + provider: scmProvider, config: {}, }); @@ -361,8 +502,15 @@ export function SCMTab({ projectId, project }: { projectId: string; project?: SC }, }); + const repoPlaceholder = scmProvider === 'gitlab' ? 'group/subgroup/repo' : 'owner/repo'; + const providerLabel = scmProvider === 'gitlab' ? 'GitLab' : 'GitHub'; + return (
+ + +
+ {/* Repository Settings */}
@@ -372,7 +520,7 @@ export function SCMTab({ projectId, project }: { projectId: string; project?: SC id="scm-repo" value={repo} onChange={(e) => setRepo(e.target.value)} - placeholder="owner/repo" + placeholder={repoPlaceholder} />
@@ -400,12 +548,17 @@ export function SCMTab({ projectId, project }: { projectId: string; project?: SC

- CASCADE uses two separate GitHub bot accounts to prevent feedback loops. The{' '} - implementer writes code and creates PRs. The reviewer{' '} - reviews PRs and can approve or request changes. + CASCADE uses two separate {providerLabel} bot accounts to prevent feedback loops. The{' '} + implementer writes code and creates{' '} + {scmProvider === 'gitlab' ? 'MRs' : 'PRs'}. The reviewer reviews{' '} + {scmProvider === 'gitlab' ? 'MRs' : 'PRs'} and can approve or request changes.

- + {scmProvider === 'github' ? ( + + ) : ( + + )}

Trigger configuration has moved to the Agents tab. @@ -428,7 +581,11 @@ export function SCMTab({ projectId, project }: { projectId: string; project?: SC


- + {scmProvider === 'github' ? ( + + ) : ( + + )}
); } diff --git a/web/src/components/settings/agent-definition-shared.tsx b/web/src/components/settings/agent-definition-shared.tsx index ffa9b6a9..2bec5623 100644 --- a/web/src/components/settings/agent-definition-shared.tsx +++ b/web/src/components/settings/agent-definition-shared.tsx @@ -49,7 +49,7 @@ export const CAPABILITY_GROUPS: RecordAll sources +