Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/entities/reviewAction/reviewAction.gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export interface ExecutionContext {
mrNumber: number
localPath: string
diffMetadata?: DiffMetadata
baseUrl?: string
}

export interface ExecutionResult {
Expand Down
5 changes: 4 additions & 1 deletion src/frameworks/claude/claudeInvoker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -305,10 +305,13 @@ export async function invokeClaudeReview(
let stderr = '';
let cancelled = false;

const childEnv = { ...process.env };
// Remove CLAUDECODE to allow spawning Claude from within a Claude session
delete childEnv.CLAUDECODE;
const child = spawn(resolveClaudePath(), args, {
cwd: job.localPath,
env: {
...process.env,
...childEnv,
// Ensure non-interactive mode
TERM: 'dumb',
CI: 'true',
Expand Down
11 changes: 10 additions & 1 deletion src/frameworks/config/configLoader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,14 +75,23 @@ function loadProjectConfig(localPath: string): ProjectConfig | null {
}
}

function normalizeGitUrl(url: string): string {
// Convert SSH URLs (git@host:org/repo.git) to HTTPS (https://host/org/repo)
const sshMatch = url.match(/^git@([^:]+):(.+)$/);
if (sshMatch) {
return `https://${sshMatch[1]}/${sshMatch[2].replace(/\.git$/, '')}`;
}
return url.replace(/\.git$/, '');
}

function getGitRemoteUrl(localPath: string): string | null {
try {
const result = execSync('git remote get-url origin', {
cwd: localPath,
encoding: 'utf-8',
timeout: 5000,
});
return result.trim().replace(/\.git$/, '');
return normalizeGitUrl(result.trim());
} catch {
return null;
}
Expand Down
26 changes: 24 additions & 2 deletions src/interface-adapters/controllers/webhook/gitlab.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,24 @@ import type { ReviewContextGateway } from '@/entities/reviewContext/reviewContex
import type { ThreadFetchGateway } from '@/entities/threadFetch/threadFetch.gateway.js';
import type { DiffMetadataFetchGateway } from '@/entities/diffMetadata/diffMetadata.gateway.js';

function extractBaseUrl(remoteUrl: string): string | undefined {
try {
// Handle HTTPS URLs: https://gitlab.example.com/group/project.git
if (remoteUrl.startsWith('http')) {
const url = new URL(remoteUrl)
return `${url.protocol}//${url.host}`
}
// Handle SSH URLs: git@gitlab.example.com:group/project.git
const sshMatch = remoteUrl.match(/@([^:]+):/)
if (sshMatch) {
return `https://${sshMatch[1]}`
}
} catch {
// Invalid URL — return undefined
}
return undefined
}

export interface GitLabWebhookDependencies {
reviewContextGateway: ReviewContextGateway;
threadFetchGateway: ThreadFetchGateway;
Expand Down Expand Up @@ -299,11 +317,13 @@ export async function handleGitLabWebhook(
const reviewContext = contextGateway.read(j.localPath, mergeRequestId);
if (reviewContext && reviewContext.actions.length > 0) {
threadResolveCount = reviewContext.actions.filter(a => a.type === 'THREAD_RESOLVE').length;
const followupBaseUrl = extractBaseUrl(updateRepoConfig.remoteUrl);
const contextActionResult = await executeActionsFromContext(
reviewContext,
j.localPath,
logger,
defaultCommandExecutor
defaultCommandExecutor,
followupBaseUrl,
);
logger.info(
{ ...contextActionResult, threadResolveCount, mrNumber: j.mrNumber },
Expand Down Expand Up @@ -533,11 +553,13 @@ export async function handleGitLabWebhook(
// PRIMARY: Execute actions from context file (agent writes actions here)
const reviewContext = contextGateway.read(j.localPath, mergeRequestId);
if (reviewContext && reviewContext.actions.length > 0) {
const reviewBaseUrl = extractBaseUrl(repoConfig.remoteUrl);
const contextActionResult = await executeActionsFromContext(
reviewContext,
j.localPath,
logger,
defaultCommandExecutor
defaultCommandExecutor,
reviewBaseUrl,
);
logger.info(
{ ...contextActionResult, mrNumber: j.mrNumber },
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { ReviewAction } from '../../../entities/reviewAction/reviewAction.js'
import type { ReviewActionGateway, ExecutionContext } from '../../../entities/reviewAction/reviewAction.gateway.js'
import { ExecutionGatewayBase, type CommandInfo } from '../../../shared/foundation/executionGateway.base.js'
import { enrichCommentWithLinks } from '../../../services/commentLinkEnricher.js'

export class GitLabReviewActionCliGateway
extends ExecutionGatewayBase<ReviewAction, ExecutionContext>
Expand All @@ -17,11 +18,15 @@ export class GitLabReviewActionCliGateway
args: ['api', '--method', 'PUT', `${baseUrl}/discussions/${action.threadId}`, '--field', 'resolved=true'],
}

case 'POST_COMMENT':
case 'POST_COMMENT': {
const enrichedBody = context.baseUrl && context.diffMetadata
? enrichCommentWithLinks(action.body, context.baseUrl, context.projectPath, context.diffMetadata.headSha)
: action.body
return {
command: 'glab',
args: ['api', '--method', 'POST', `${baseUrl}/notes`, '--field', `body=${action.body}`],
args: ['api', '--method', 'POST', `${baseUrl}/notes`, '--field', `body=${enrichedBody}`],
}
}

case 'THREAD_REPLY':
return {
Expand Down
48 changes: 48 additions & 0 deletions src/services/commentLinkEnricher.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/**
* Replace file:line references in a comment body with clickable GitLab/GitHub blob links.
*
* Pattern matches references like:
* file.py:42
* `file.py:42`
* (file.py:42)
* users/api.py:247
*
* Produces markdown links:
* [`file.py:42`](baseUrl/project/-/blob/sha/file.py#L42)
*
* Does NOT match:
* - URLs (http://localhost:8000, https://example.com:443)
* - Bare filenames without line numbers (docker-compose.yml)
*/

// File path must start with a word char, contain at least one dot (extension), then :lineNumber.
// The path must NOT start with // (to exclude URLs).
const FILE_LINE_PATTERN =
/(?<prefix>[`(]?)(?<filePath>[\w][\w./-]*\.[\w]+):(?<line>\d+)(?<suffix>[`)]?)/g

export function enrichCommentWithLinks(
body: string,
baseUrl: string,
projectPath: string,
headSha: string,
): string {
return body.replace(FILE_LINE_PATTERN, (match, prefix, filePath, line, suffix, offset) => {
// Skip if preceded by :// (URL pattern like https://example.com:443)
const beforeMatch = body.slice(Math.max(0, offset - 10), offset)
if (/:\/{1,2}$/.test(beforeMatch) || /:\/{1,2}[\w.-]*$/.test(beforeMatch)) {
return match
}

// Skip if the filePath portion doesn't look like a real file (must contain a dot for extension)
// Already handled by regex, but double check for edge cases
if (!filePath.includes('.')) {
return match
}

const blobUrl = `${baseUrl}/${projectPath}/-/blob/${headSha}/${filePath}#L${line}`
// When wrapped in backticks, the link markdown already includes backticks — don't double them
const outerPrefix = prefix === '`' ? '' : prefix
const outerSuffix = suffix === '`' ? '' : suffix
return `${outerPrefix}[\`${filePath}:${line}\`](${blobUrl})${outerSuffix}`
})
}
4 changes: 3 additions & 1 deletion src/services/contextActionsExecutor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,15 @@ export async function executeActionsFromContext(
context: ReviewContext,
localPath: string,
_logger: Logger,
executor: CommandExecutor
executor: CommandExecutor,
baseUrl?: string,
): Promise<ExecutionResult> {
const gatewayContext = {
projectPath: context.projectPath,
mrNumber: context.mergeRequestNumber,
localPath,
diffMetadata: context.diffMetadata,
baseUrl,
}

const gateway =
Expand Down
86 changes: 86 additions & 0 deletions src/tests/units/services/commentLinkEnricher.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { describe, it, expect } from 'vitest'
import { enrichCommentWithLinks } from '../../../services/commentLinkEnricher.js'

const BASE_URL = 'https://gitlab.example.com'
const PROJECT_PATH = 'my-org/my-project'
const HEAD_SHA = 'abc123def456'

function enrich(body: string): string {
return enrichCommentWithLinks(body, BASE_URL, PROJECT_PATH, HEAD_SHA)
}

describe('enrichCommentWithLinks', () => {
it('should replace a simple file:line reference with a link', () => {
const result = enrich('See file.py:42 for details')
expect(result).toBe(
'See [`file.py:42`](https://gitlab.example.com/my-org/my-project/-/blob/abc123def456/file.py#L42) for details'
)
})

it('should replace a backtick-wrapped file:line reference', () => {
const result = enrich('Check `file.py:42` for the issue')
expect(result).toBe(
'Check [`file.py:42`](https://gitlab.example.com/my-org/my-project/-/blob/abc123def456/file.py#L42) for the issue'
)
})

it('should replace a parenthesized file:line reference', () => {
const result = enrich('See issue (file.py:42)')
expect(result).toBe(
'See issue ([`file.py:42`](https://gitlab.example.com/my-org/my-project/-/blob/abc123def456/file.py#L42))'
)
})

it('should handle nested path references', () => {
const result = enrich('Found at users/api.py:247')
expect(result).toBe(
'Found at [`users/api.py:247`](https://gitlab.example.com/my-org/my-project/-/blob/abc123def456/users/api.py#L247)'
)
})

it('should handle deeply nested paths', () => {
const result = enrich('See src/components/posts/post-card.js:15')
expect(result).toBe(
'See [`src/components/posts/post-card.js:15`](https://gitlab.example.com/my-org/my-project/-/blob/abc123def456/src/components/posts/post-card.js#L15)'
)
})

it('should NOT match http:// URLs', () => {
const body = 'Visit http://localhost:8000/api'
expect(enrich(body)).toBe(body)
})

it('should NOT match https:// URLs', () => {
const body = 'See https://example.com:443/path'
expect(enrich(body)).toBe(body)
})

it('should NOT match filenames without line numbers', () => {
const body = 'Edit docker-compose.yml for config'
expect(enrich(body)).toBe(body)
})

it('should handle multiple references in the same body', () => {
const result = enrich('Issues in file.py:10 and users/views.py:25 and lib/utils.py:100')
expect(result).toContain('[`file.py:10`]')
expect(result).toContain('[`users/views.py:25`]')
expect(result).toContain('[`lib/utils.py:100`]')
})

it('should return body unchanged when all parameters are provided but no matches exist', () => {
const body = 'No file references here, just plain text.'
expect(enrich(body)).toBe(body)
})

it('should handle file references at start and end of body', () => {
const result = enrich('file.py:1 is the start and end is file.py:99')
expect(result).toContain('[`file.py:1`]')
expect(result).toContain('[`file.py:99`]')
})

it('should handle references in markdown context', () => {
const result = enrich('- **Blocking**: Missing validation in `users/api.py:247`')
expect(result).toContain('[`users/api.py:247`]')
expect(result).toContain('https://gitlab.example.com/my-org/my-project/-/blob/abc123def456/users/api.py#L247')
})
})
Loading