Skip to content

Commit d2014d8

Browse files
sudaclaude
andcommitted
feat(integrations): add GitLab as alternative SCM provider
Add GitLab as a second SCM integration alongside GitHub, following the existing IntegrationModule/SCMIntegration architecture. This enables CASCADE to process GitLab merge request webhooks and run agents against GitLab repositories. Key additions: - Core GitLab module (client via @gitbeaker/rest, dual-persona model, SCMIntegration implementation) - Router layer (webhook route, signature verification, adapter, queue types) - 9 trigger handlers (MR opened, pipeline success/failure, approval, reviewer added, comment mention, merged, conflict detected, ready to merge) - 11 GitLab gadgets for agent MR operations - SCM-provider-aware context pipeline, CLI commands, tool manifests, and agent system prompts - Frontend SCM tab with GitHub/GitLab provider selector - CLI webhook commands with --gitlab-only support - GitLab webhook CRUD via API - Worker entry GitLab job dispatch with CASCADE_SCM_PROVIDER env var - PR/MR URL extraction supports both /pull/NNN and /merge_requests/NNN - Post-execution work-item linking works for GitLab MRs - Database migration for gitlab SCM provider CHECK constraint - glab CLI installed in worker Docker image - 100 unit tests across 7 test files Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
1 parent 58fa89f commit d2014d8

133 files changed

Lines changed: 7099 additions & 106 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

Dockerfile.worker

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,12 @@ RUN ARCH=$(dpkg --print-architecture) && \
6969
rm /tmp/ast-grep.zip && \
7070
chmod +x /usr/local/bin/sg
7171

72+
# Install glab (GitLab CLI)
73+
RUN ARCH=$(dpkg --print-architecture) && \
74+
curl -L "https://gitlab.com/gitlab-org/cli/-/releases/v1.52.0/downloads/glab_1.52.0_linux_${ARCH}.deb" -o /tmp/glab.deb && \
75+
dpkg -i /tmp/glab.deb && \
76+
rm /tmp/glab.deb
77+
7278
# Install agent CLIs used by headless engines in worker jobs.
7379
# All three are explicitly pinned so worker image rebuilds are reproducible —
7480
# upstream CLI changes can introduce subtle behavioural drift in headless mode.

package-lock.json

Lines changed: 67 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@
5555
"license": "MIT",
5656
"dependencies": {
5757
"@anthropic-ai/claude-agent-sdk": "^0.2.119",
58+
"@gitbeaker/rest": "^43.8.0",
5859
"@hono/node-server": "^1.19.13",
5960
"@hono/trpc-server": "^0.4.2",
6061
"@llmist/cli": "^16.0.3",

src/agents/definitions/contextSteps.ts

Lines changed: 159 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
* These are the building blocks composed by the YAML contextPipeline arrays.
66
*/
77

8+
import { getIntegrationProvider } from '../../db/repositories/credentialsRepository.js';
89
import { formatCheckStatus } from '../../gadgets/github/core/getPRChecks.js';
910
import { ListDirectory } from '../../gadgets/ListDirectory.js';
1011
import {
@@ -20,7 +21,8 @@ import {
2021
initTodoSession,
2122
saveTodos,
2223
} from '../../gadgets/todo/storage.js';
23-
import { githubClient } from '../../github/client.js';
24+
import { githubClient, type PRDiffFile } from '../../github/client.js';
25+
import { gitlabClient } from '../../gitlab/client.js';
2426
import { getJiraConfig, getLinearConfig, getTrelloConfig } from '../../pm/config.js';
2527
import type {
2628
Attachment,
@@ -153,6 +155,24 @@ export async function fetchPRContextStep(params: FetchContextParams): Promise<Co
153155
if (!repoFullName || !prNumber) {
154156
throw new Error('fetchPRContextStep requires repoFullName and prNumber in input');
155157
}
158+
159+
// Check if the project uses GitLab
160+
const scmProvider = params.project?.id
161+
? await getIntegrationProvider(params.project.id, 'scm')
162+
: null;
163+
164+
if (scmProvider === 'gitlab') {
165+
return fetchGitLabMRContextStep(params, repoFullName, prNumber);
166+
}
167+
168+
return fetchGitHubPRContextStep(params, repoFullName, prNumber);
169+
}
170+
171+
async function fetchGitHubPRContextStep(
172+
params: FetchContextParams,
173+
repoFullName: string,
174+
prNumber: number,
175+
): Promise<ContextInjection[]> {
156176
const injections: ContextInjection[] = [];
157177
const { owner, repo } = parseRepoFullName(repoFullName);
158178

@@ -237,13 +257,114 @@ export async function fetchPRContextStep(params: FetchContextParams): Promise<Co
237257
return injections;
238258
}
239259

260+
async function fetchGitLabMRContextStep(
261+
params: FetchContextParams,
262+
projectPath: string,
263+
mrIid: number,
264+
): Promise<ContextInjection[]> {
265+
const injections: ContextInjection[] = [];
266+
267+
params.logWriter('INFO', 'Fetching MR details and diff from GitLab', {
268+
projectPath,
269+
mrIid,
270+
});
271+
272+
const mrDetails = await gitlabClient.getMR(projectPath, mrIid);
273+
const mrDiff = await gitlabClient.getMRDiff(projectPath, mrIid);
274+
275+
// Format MR details
276+
const detailsFormatted = [
277+
`MR #${mrDetails.iid}: ${mrDetails.title}`,
278+
`State: ${mrDetails.state}`,
279+
`Author: ${mrDetails.author.username}`,
280+
`Source: ${mrDetails.sourceBranch} → Target: ${mrDetails.targetBranch}`,
281+
`URL: ${mrDetails.webUrl}`,
282+
mrDetails.description ? `\nDescription:\n${mrDetails.description}` : '',
283+
]
284+
.filter(Boolean)
285+
.join('\n');
286+
287+
injections.push({
288+
toolName: 'GetMRDetails',
289+
params: { comment: 'Pre-fetching MR details for review context', projectPath, mrIid },
290+
result: detailsFormatted,
291+
description: 'Pre-fetched MR details',
292+
});
293+
294+
// Compact per-file diffs sourced from the checked-out local MR workspace,
295+
// mirroring the GitHub PR context path. Files that don't fit the budget or
296+
// can't be diffed are surfaced in a separate SKIPPED FILES injection so the
297+
// agent can fetch them on demand.
298+
const prDiffCompat: PRDiffFile[] = mrDiff.map((f) => ({
299+
filename: f.newPath,
300+
previousFilename: f.renamedFile ? f.oldPath : undefined,
301+
status: f.newFile
302+
? 'added'
303+
: f.deletedFile
304+
? 'removed'
305+
: f.renamedFile
306+
? 'renamed'
307+
: 'modified',
308+
additions: 0,
309+
deletions: 0,
310+
changes: 0,
311+
patch: f.diff,
312+
}));
313+
const localDiffSource = await sourceLocalPRDiffs({
314+
files: prDiffCompat,
315+
repoDir: params.repoDir,
316+
baseBranch: mrDetails.targetBranch,
317+
logWriter: params.logWriter,
318+
});
319+
const diffContext = extractPRDiffs(localDiffSource.files);
320+
const skipReasons = countSkipsByReason(diffContext.skipped);
321+
params.logWriter('INFO', 'MR context prepared', {
322+
included: diffContext.included.length,
323+
skipped: diffContext.skipped.length,
324+
skipReasons,
325+
totalDiffTokens: diffContext.totalDiffTokens,
326+
perFileTokenCap: diffContext.perFileTokenCap,
327+
});
328+
329+
injections.push({
330+
toolName: 'GetMRDiff',
331+
params: { comment: 'Pre-fetching compact per-file diffs for review', projectPath, mrIid },
332+
result: formatPRDiffContext(diffContext),
333+
description: 'Pre-fetched MR diff context',
334+
});
335+
336+
if (diffContext.skipped.length > 0) {
337+
injections.push({
338+
toolName: 'SkippedFiles',
339+
params: {
340+
comment: 'MR files omitted from the compact context — fetch on demand if relevant',
341+
prNumber: mrIid,
342+
},
343+
result: formatSkippedFilesInjection(diffContext.skipped, mrIid),
344+
description: 'Skipped files',
345+
});
346+
}
347+
348+
return injections;
349+
}
350+
240351
export async function fetchPRConversationStep(
241352
params: FetchContextParams,
242353
): Promise<ContextInjection[]> {
243354
const { repoFullName, prNumber } = params.input;
244355
if (!repoFullName || !prNumber) {
245356
throw new Error('fetchPRConversationStep requires repoFullName and prNumber in input');
246357
}
358+
359+
// Check if the project uses GitLab
360+
const scmProvider = params.project?.id
361+
? await getIntegrationProvider(params.project.id, 'scm')
362+
: null;
363+
364+
if (scmProvider === 'gitlab') {
365+
return fetchGitLabMRConversationStep(params, repoFullName, prNumber);
366+
}
367+
247368
const injections: ContextInjection[] = [];
248369
const { owner, repo } = parseRepoFullName(repoFullName);
249370

@@ -294,6 +415,43 @@ export async function fetchPRConversationStep(
294415
return injections;
295416
}
296417

418+
async function fetchGitLabMRConversationStep(
419+
params: FetchContextParams,
420+
projectPath: string,
421+
mrIid: number,
422+
): Promise<ContextInjection[]> {
423+
const injections: ContextInjection[] = [];
424+
425+
params.logWriter('INFO', 'Fetching MR conversation context from GitLab', {
426+
projectPath,
427+
mrIid,
428+
});
429+
430+
const notes = await gitlabClient.getMRNotes(projectPath, mrIid);
431+
432+
// Filter to non-system notes (user comments only)
433+
const userNotes = notes.filter((n) => !n.system);
434+
435+
const formatted = userNotes
436+
.map(
437+
(n) => `[${n.createdAt}] @${n.author.username}${n.resolved ? ' (resolved)' : ''}:\n${n.body}`,
438+
)
439+
.join('\n\n---\n\n');
440+
441+
injections.push({
442+
toolName: 'GetMRNotes',
443+
params: {
444+
comment: 'Pre-fetching MR notes for conversation context',
445+
projectPath,
446+
mrIid,
447+
},
448+
result: formatted || '(No comments on this MR)',
449+
description: 'Pre-fetched MR notes',
450+
});
451+
452+
return injections;
453+
}
454+
297455
export async function prepopulateTodosStep(
298456
params: FetchContextParams,
299457
): Promise<ContextInjection[]> {

src/agents/definitions/resolve-conflicts.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ triggers:
3030
label: PR Conflict Detected
3131
description: Trigger when a PR has merge conflicts with the base branch
3232
defaultEnabled: false
33-
providers: [github]
33+
providers: [github, gitlab]
3434
contextPipeline: [prContext, directoryListing, contextFiles, workItem]
3535

3636
strategies: {}

src/agents/definitions/respond-to-ci.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ triggers:
3131
label: Check Suite Failure
3232
description: Trigger when CI checks fail
3333
defaultEnabled: false
34-
providers: [github]
34+
providers: [github, gitlab]
3535
contextPipeline: [prContext, directoryListing, contextFiles, workItem]
3636

3737
strategies: {}

src/agents/definitions/respond-to-pr-comment.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ triggers:
2929
label: PR Comment @mention
3030
description: Trigger when the implementer bot is @mentioned in a PR comment
3131
defaultEnabled: false
32-
providers: [github]
32+
providers: [github, gitlab]
3333
contextPipeline: [prContext, prConversation, directoryListing, contextFiles]
3434

3535
strategies:

src/agents/definitions/respond-to-review.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ triggers:
3030
label: PR Review Submitted
3131
description: Trigger when a review with changes requested or comments is submitted
3232
defaultEnabled: false
33-
providers: [github]
33+
providers: [github, gitlab]
3434
contextPipeline: [prContext, prConversation, directoryListing, contextFiles]
3535

3636
strategies:
@@ -48,7 +48,7 @@ prompts:
4848
<%= it.commentBody %>
4949
---
5050
51-
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.
51+
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.
5252
5353
hooks:
5454
trailing:

0 commit comments

Comments
 (0)