diff --git a/.gitignore b/.gitignore
index 486b12dbb..bb3a2f03b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -160,10 +160,11 @@ dist
# End of https://www.toptal.com/developers/gitignore/api/yarn,node
+.claude
.sourcebot
/bin
/config.json
.DS_Store
oss-licenses.json
oss-license-summary.json
-license-audit-result.json
\ No newline at end of file
+license-audit-result.json
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e105f22da..dd4a9b026 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Added `thinkingLevel` and `thinkingBudget` configuration options for Google Generative AI and Google Vertex providers. [#1110](https://github.com/sourcebot-dev/sourcebot/pull/1110)
+- Support Gitlab MRs in the AI Code Review Agent [#1104](https://github.com/sourcebot-dev/sourcebot/pull/1104)
### Changed
- Deprecated `GOOGLE_VERTEX_THINKING_BUDGET_TOKENS` environment variable in favor of per-model `thinkingBudget` config. [#1110](https://github.com/sourcebot-dev/sourcebot/pull/1110)
diff --git a/docs/docs/features/agents/review-agent.mdx b/docs/docs/features/agents/review-agent.mdx
index 41416f759..b4dc9c2f6 100644
--- a/docs/docs/features/agents/review-agent.mdx
+++ b/docs/docs/features/agents/review-agent.mdx
@@ -3,64 +3,55 @@ title: AI Code Review Agent
sidebarTitle: AI code review agent
---
-
-This agent sends data to OpenAI (through an API key you supply) to perform code reviews. This data includes code from the PR being reviewed, as well as additional relevant context from your
-codebase that the agent may fetch to perform the review.
-
+This agent provides codebase-aware reviews for your GitHub PRs and GitLab MRs. For each diff, the agent fetches relevant context from your indexed codebase and feeds it into a configured language model for a detailed review.
-This agent provides codebase-aware reviews for your PRs. For each diff, this agent fetches relevant context from Sourcebot and feeds it into an LLM for a detailed review of your changes.
-
-The AI Code Review Agent is [fair source](https://github.com/sourcebot-dev/sourcebot/tree/main/packages/web/src/features/agents/review-agent) and packaged in [Sourcebot](https://github.com/sourcebot-dev/sourcebot). To get started using this agent, [deploy Sourcebot](/docs/deployment/docker-compose)
-and then follow the configuration instructions below.
+The AI Code Review Agent is [fair source](https://github.com/sourcebot-dev/sourcebot/tree/main/packages/web/src/features/agents/review-agent) and packaged in [Sourcebot](https://github.com/sourcebot-dev/sourcebot). To get started, [deploy Sourcebot](/docs/deployment/docker-compose) and follow the configuration instructions below.

-# Configure
+# Language model
+
+The review agent uses whichever language model you have configured in your `config.json`. All providers supported by Sourcebot (OpenAI, Anthropic, AWS Bedrock, Azure OpenAI, and others) work out of the box.
-This agent currently only supports reviewing GitHub PRs. You configure the agent by creating a GitHub app, installing it into your GitHub organization, and then giving your app info to Sourcebot.
+If you have multiple models configured, set `REVIEW_AGENT_MODEL` to the `displayName` of the model you want the agent to use. If this variable is unset, the agent uses the first configured model.
-Before you get started, make sure you have an OpenAPI account that you can create an OpenAPI key with.
+# GitHub
- Follow the official GitHub guide for [registering a GitHub app](https://docs.github.com/en/apps/creating-github-apps/registering-a-github-app/registering-a-github-app)
-
- - GitHub App name: You can make this whatever you want (ex. Sourcebot Review Agent)
- - Homepage URL: You can make this whatever you want (ex. https://www.sourcebot.dev/)
- - Webhook URL (**IMPORTANT**): You must set this to point to your Sourcebot deployment at /api/webhook (ex. https://sourcebot.aperture.com/api/webhook). Your Sourcebot deployment must be able to accept requests from GitHub
- (either github.com or your self-hosted enterprise server) for this to work. If you're running Sourcebot locally, you can [use smee](https://docs.github.com/en/apps/creating-github-apps/writing-code-for-a-github-app/quickstart#step-2-get-a-webhook-proxy-url) to [forward webhooks](https://docs.github.com/en/apps/creating-github-apps/writing-code-for-a-github-app/quickstart#step-6-start-your-server) to your local deployment.
- - Webook Secret: This can be any string (ex. generate a random string `python -c "import secrets; print(secrets.token_hex(10))"`). You'll provide this to Sourcebot to be able to read webhook events from your app.
- - Permissions
+ Follow the official GitHub guide for [registering a GitHub app](https://docs.github.com/en/apps/creating-github-apps/registering-a-github-app/registering-a-github-app).
+
+ - **GitHub App name**: Any name you choose (e.g. Sourcebot Review Agent)
+ - **Homepage URL**: Any URL you choose (e.g. `https://www.sourcebot.dev/`)
+ - **Webhook URL** (required): Your Sourcebot deployment URL at `/api/webhook` (e.g. `https://sourcebot.example.com/api/webhook`). Your deployment must be reachable from GitHub. If you are running Sourcebot locally, use [smee](https://docs.github.com/en/apps/creating-github-apps/writing-code-for-a-github-app/quickstart#step-2-get-a-webhook-proxy-url) to [forward webhooks](https://docs.github.com/en/apps/creating-github-apps/writing-code-for-a-github-app/quickstart#step-6-start-your-server) to your local deployment.
+ - **Webhook Secret**: Any string (e.g. generate one with `python -c "import secrets; print(secrets.token_hex(10))"`)
+ - **Permissions**
- Pull requests: Read & Write
- Issues: Read & Write
- Contents: Read
- - Events:
+ - **Events**
- Pull request
- Issue comment
- Navigate to your new [GitHub app's page](https://docs.github.com/en/apps/creating-github-apps/writing-code-for-a-github-app/quickstart#navigate-to-your-app-settings) and press `Install`
+ Navigate to your new [GitHub app's page](https://docs.github.com/en/apps/creating-github-apps/writing-code-for-a-github-app/quickstart#navigate-to-your-app-settings) and press **Install**.
-
- Sourcebot requires the following environment variables to begin reviewing PRs through your new GitHub app:
-
- - `GITHUB_REVIEW_AGENT_APP_ID`: The client ID of your GitHub app. Can be found in your [app settings](https://docs.github.com/en/apps/creating-github-apps/writing-code-for-a-github-app/quickstart#navigate-to-your-app-settings)
- - `GITHUB_REVIEW_AGENT_APP_WEBHOOK_SECRET`: The webhook secret you defined in your GitHub app. Can be found in your [app settings](https://docs.github.com/en/apps/creating-github-apps/writing-code-for-a-github-app/quickstart#navigate-to-your-app-settings)
- - `GITHUB_REVIEW_AGENT_APP_PRIVATE_KEY_PATH`: The path to your app's private key. If you're running Sourcebot from a container, this is the path to this file from within your container
- (ex `/data/review-agent-key.pem`). You must copy the private key file into the directory you mount to Sourcebot (similar to the config file).
-
- You can generate a private key file for your app in the [app settings](https://docs.github.com/en/apps/creating-github-apps/writing-code-for-a-github-app/quickstart#navigate-to-your-app-settings). You must copy this private key file into the
- directory that you mount to Sourcebot
- 
- - `OPENAI_API_KEY`: Your OpenAI API key
- - `REVIEW_AGENT_API_KEY`: The Sourcebot API key that the review agent uses to hit the Sourcebot API to fetch code context
- - `REVIEW_AGENT_AUTO_REVIEW_ENABLED` (default: `false`): If enabled, the review agent will automatically review any new or updated PR. If disabled, you must invoke it using the command defined by `REVIEW_AGENT_REVIEW_COMMAND`
- - `REVIEW_AGENT_REVIEW_COMMAND` (default: `review`): The command that invokes the review agent (ex. `/review`) when a user comments on the PR. Don't include the slash character in this value.
-
- You can find an example docker compose file below.
- - This docker compose file is placed in `~/sourcebot_review_agent_workspace`, and I'm mounting that directory to Sourcebot
- - The config and the app private key files are placed in this directory
- - The paths to these files are given to Sourcebot relative to `/data` since that's the directory in Sourcebot that I'm mounting to
+
+ Set the following environment variables in your Sourcebot deployment:
+
+ | Variable | Description |
+ |---|---|
+ | `GITHUB_REVIEW_AGENT_APP_ID` | The client ID of your GitHub app, found in your [app settings](https://docs.github.com/en/apps/creating-github-apps/writing-code-for-a-github-app/quickstart#navigate-to-your-app-settings) |
+ | `GITHUB_REVIEW_AGENT_APP_WEBHOOK_SECRET` | The webhook secret you set when registering the app |
+ | `GITHUB_REVIEW_AGENT_APP_PRIVATE_KEY_PATH` | Path to your app's private key file inside the container (e.g. `/data/review-agent-key.pem`). Copy the key file into the directory you mount to Sourcebot. |
+
+ You can generate a private key in your [app settings](https://docs.github.com/en/apps/creating-github-apps/writing-code-for-a-github-app/quickstart#navigate-to-your-app-settings).
+
+
+
+
+
+ Example `docker-compose.yml`:
```yaml
services:
@@ -71,26 +62,86 @@ Before you get started, make sure you have an OpenAPI account that you can creat
ports:
- "3000:3000"
volumes:
- - "/Users/michael/sourcebot_review_agent_workspace:/data"
+ - "/home/user/sourcebot_workspace:/data"
environment:
CONFIG_PATH: "/data/config.json"
GITHUB_REVIEW_AGENT_APP_ID: "my-github-app-id"
- GITHUB_REVIEW_AGENT_APP_WEBHOOK_SECRET: "my-github-app-webhook-secret"
+ GITHUB_REVIEW_AGENT_APP_WEBHOOK_SECRET: "my-webhook-secret"
GITHUB_REVIEW_AGENT_APP_PRIVATE_KEY_PATH: "/data/review-agent-key.pem"
- REVIEW_AGENT_API_KEY: "sourcebot-my-key"
- OPENAI_API_KEY: "sk-proj-my-open-api-key"
```
- Navigate to the agents page by pressing `Agents` in the Sourcebot nav menu. If you've configured your environment variables correctly you'll see the following:
+ Navigate to **Agents** in the Sourcebot nav menu. If your environment variables are set correctly, the GitHub Review Agent card shows a confirmation that the agent is configured and accepting requests.
- 
+
+
+
+
+
+
+# GitLab
+
+
+
+ Create a [personal access token](https://docs.gitlab.com/ee/user/profile/personal_access_tokens.html) or [project access token](https://docs.gitlab.com/ee/user/project/settings/project_access_tokens.html) with the following scope:
+
+ - `api`
+
+ Keep a note of the token value — you will need it in the next step.
+
+
+ In your GitLab project, go to **Settings → Webhooks** and add a new webhook:
+
+ - **URL**: Your Sourcebot deployment URL at `/api/webhook` (e.g. `https://sourcebot.example.com/api/webhook`)
+ - **Secret token**: Any string (e.g. generate one with `python -c "import secrets; print(secrets.token_hex(10))"`)
+ - **Trigger events**: Merge request events, Comments
+
+ Save the webhook.
+
+
+ Set the following environment variables in your Sourcebot deployment:
+
+ | Variable | Description |
+ |---|---|
+ | `GITLAB_REVIEW_AGENT_WEBHOOK_SECRET` | The secret token you set on the GitLab webhook |
+ | `GITLAB_REVIEW_AGENT_TOKEN` | The GitLab personal or project access token |
+ | `GITLAB_REVIEW_AGENT_HOST` | Your GitLab hostname. Defaults to `gitlab.com`. Set this for self-hosted GitLab instances (e.g. `gitlab.example.com`). |
+
+ Example `docker-compose.yml`:
+
+ ```yaml
+ services:
+ sourcebot:
+ image: ghcr.io/sourcebot-dev/sourcebot:latest
+ pull_policy: always
+ container_name: sourcebot
+ ports:
+ - "3000:3000"
+ volumes:
+ - "/home/user/sourcebot_workspace:/data"
+ environment:
+ CONFIG_PATH: "/data/config.json"
+ GITLAB_REVIEW_AGENT_WEBHOOK_SECRET: "my-webhook-secret"
+ GITLAB_REVIEW_AGENT_TOKEN: "glpat-my-token"
+ GITLAB_REVIEW_AGENT_HOST: "gitlab.example.com"
+ ```
+
+
+ Navigate to **Agents** in the Sourcebot nav menu. If your environment variables are set correctly, the GitLab Review Agent card shows a confirmation that the agent is configured and accepting requests.
# Using the agent
-The review agent will not automatically review your PRs by default. To enable this feature, set the `REVIEW_AGENT_AUTO_REVIEW_ENABLED` environment variable to true.
+By default, the agent does not review PRs and MRs automatically. To enable automatic reviews on every new or updated PR/MR, set `REVIEW_AGENT_AUTO_REVIEW_ENABLED` to `true`.
+
+You can also trigger a review manually by commenting `/review` on any PR or MR. To use a different command, set `REVIEW_AGENT_REVIEW_COMMAND` to your preferred value (without the leading slash).
+
+# Environment variable reference
-You can invoke the review agent manually by commenting `/review` on the PR you'd like it to review. You can configure the command that triggers the agent by changing
-the `REVIEW_AGENT_REVIEW_COMMAND` environment variable.
\ No newline at end of file
+| Variable | Default | Description |
+|---|---|---|
+| `REVIEW_AGENT_AUTO_REVIEW_ENABLED` | `false` | Automatically review new and updated PRs/MRs |
+| `REVIEW_AGENT_REVIEW_COMMAND` | `review` | Comment command that triggers a manual review (without the `/`) |
+| `REVIEW_AGENT_MODEL` | first configured model | `displayName` of the language model to use for reviews |
+| `REVIEW_AGENT_LOGGING_ENABLED` | unset | Write prompt and response logs to disk for debugging |
diff --git a/packages/shared/src/env.server.ts b/packages/shared/src/env.server.ts
index 22bf2242e..0b28f5602 100644
--- a/packages/shared/src/env.server.ts
+++ b/packages/shared/src/env.server.ts
@@ -195,6 +195,12 @@ const options = {
GITHUB_REVIEW_AGENT_APP_ID: z.string().optional(),
GITHUB_REVIEW_AGENT_APP_WEBHOOK_SECRET: z.string().optional(),
GITHUB_REVIEW_AGENT_APP_PRIVATE_KEY_PATH: z.string().optional(),
+ // GitLab for review agent
+ GITLAB_REVIEW_AGENT_WEBHOOK_SECRET: z.string().optional(),
+ GITLAB_REVIEW_AGENT_TOKEN: z.string().optional(),
+ GITLAB_REVIEW_AGENT_HOST: z.string().default('gitlab.com').transform(s => s.replace(/^https?:\/\//, '').replace(/\/+$/, '')).refine(s => /^[a-z0-9.-]+$/i.test(s), { message: 'invalid hostname' }),
+ // Review agent config
+ REVIEW_AGENT_MODEL: z.string().optional(),
REVIEW_AGENT_API_KEY: z.string().optional(),
REVIEW_AGENT_LOGGING_ENABLED: booleanSchema.default('true'),
REVIEW_AGENT_AUTO_REVIEW_ENABLED: booleanSchema.default('false'),
@@ -438,4 +444,4 @@ const options = {
// See: https://github.com/microsoft/TypeScript/issues/62309
export const env = createEnv(options) as unknown as {
[K in keyof typeof options['server']]: z.output<(typeof options['server'])[K]>
-}
\ No newline at end of file
+}
diff --git a/packages/web/package.json b/packages/web/package.json
index d9533df10..f3a246b64 100644
--- a/packages/web/package.json
+++ b/packages/web/package.json
@@ -54,6 +54,7 @@
"@codemirror/state": "^6.4.1",
"@codemirror/view": "^6.33.0",
"@floating-ui/react": "^0.27.2",
+ "@gitbeaker/rest": "^40.5.1",
"@grpc/grpc-js": "^1.14.1",
"@grpc/proto-loader": "^0.8.0",
"@hookform/resolvers": "^3.9.0",
diff --git a/packages/web/src/app/(app)/agents/page.tsx b/packages/web/src/app/(app)/agents/page.tsx
index 523198ef6..1c6d4b2e8 100644
--- a/packages/web/src/app/(app)/agents/page.tsx
+++ b/packages/web/src/app/(app)/agents/page.tsx
@@ -5,10 +5,17 @@ import { env } from "@sourcebot/shared";
const agents = [
{
- id: "review-agent",
- name: "Review Agent",
- description: "An AI code review agent that reviews your PRs. Uses the code indexed on Sourcebot to provide codebase-wide context.",
- requiredEnvVars: ["GITHUB_REVIEW_AGENT_APP_ID", "GITHUB_REVIEW_AGENT_APP_WEBHOOK_SECRET", "GITHUB_REVIEW_AGENT_APP_PRIVATE_KEY_PATH", "OPENAI_API_KEY"],
+ id: "github-review-agent",
+ name: "GitHub Review Agent",
+ description: "An AI code review agent that reviews your GitHub PRs. Uses the code indexed on Sourcebot to provide codebase-wide context.",
+ requiredEnvVars: ["GITHUB_REVIEW_AGENT_APP_ID", "GITHUB_REVIEW_AGENT_APP_WEBHOOK_SECRET", "GITHUB_REVIEW_AGENT_APP_PRIVATE_KEY_PATH"],
+ configureUrl: "https://docs.sourcebot.dev/docs/features/agents/review-agent"
+ },
+ {
+ id: "gitlab-review-agent",
+ name: "GitLab Review Agent",
+ description: "An AI code review agent that reviews your GitLab MRs. Uses the code indexed on Sourcebot to provide codebase-wide context.",
+ requiredEnvVars: ["GITLAB_REVIEW_AGENT_WEBHOOK_SECRET", "GITLAB_REVIEW_AGENT_TOKEN"],
configureUrl: "https://docs.sourcebot.dev/docs/features/agents/review-agent"
},
];
@@ -18,21 +25,11 @@ export default async function AgentsPage() {
-
+
{agents.map((agent) => (
{/* Name and description */}
diff --git a/packages/web/src/app/(app)/components/navigationMenu/index.tsx b/packages/web/src/app/(app)/components/navigationMenu/index.tsx
index dc4b4b1b9..3295f3288 100644
--- a/packages/web/src/app/(app)/components/navigationMenu/index.tsx
+++ b/packages/web/src/app/(app)/components/navigationMenu/index.tsx
@@ -7,6 +7,7 @@ import { Separator } from "@/components/ui/separator";
import { ServiceErrorException } from "@/lib/serviceError";
import { isServiceError } from "@/lib/utils";
import { OrgRole, RepoIndexingJobStatus, RepoIndexingJobType } from "@sourcebot/db";
+import { env } from "@sourcebot/shared";
import Link from "next/link";
import { MeControlDropdownMenu } from "../meControlDropdownMenu";
import WhatsNewIndicator from "../whatsNewIndicator";
@@ -103,6 +104,10 @@ export const NavigationMenu = async () => {
) : false
}
isAuthenticated={isAuthenticated}
+ isAgentsVisible={isAuthenticated && (
+ !!(env.GITHUB_REVIEW_AGENT_APP_ID && env.GITHUB_REVIEW_AGENT_APP_WEBHOOK_SECRET && env.GITHUB_REVIEW_AGENT_APP_PRIVATE_KEY_PATH) ||
+ !!(env.GITLAB_REVIEW_AGENT_WEBHOOK_SECRET && env.GITLAB_REVIEW_AGENT_TOKEN)
+ )}
/>
diff --git a/packages/web/src/app/(app)/components/navigationMenu/navigationItems.tsx b/packages/web/src/app/(app)/components/navigationMenu/navigationItems.tsx
index 78ddbd7e4..3a9b7b22c 100644
--- a/packages/web/src/app/(app)/components/navigationMenu/navigationItems.tsx
+++ b/packages/web/src/app/(app)/components/navigationMenu/navigationItems.tsx
@@ -3,7 +3,7 @@
import { NavigationMenuItem, NavigationMenuLink, NavigationMenuList, navigationMenuTriggerStyle } from "@/components/ui/navigation-menu";
import { Badge } from "@/components/ui/badge";
import { cn, getShortenedNumberDisplayString } from "@/lib/utils";
-import { SearchIcon, MessageCircleIcon, BookMarkedIcon, SettingsIcon } from "lucide-react";
+import { SearchIcon, MessageCircleIcon, BookMarkedIcon, SettingsIcon, BotIcon } from "lucide-react";
import { usePathname } from "next/navigation";
import { NotificationDot } from "../notificationDot";
@@ -12,6 +12,7 @@ interface NavigationItemsProps {
isReposButtonNotificationDotVisible: boolean;
isSettingsButtonNotificationDotVisible: boolean;
isAuthenticated: boolean;
+ isAgentsVisible: boolean;
}
export const NavigationItems = ({
@@ -19,6 +20,7 @@ export const NavigationItems = ({
isReposButtonNotificationDotVisible,
isSettingsButtonNotificationDotVisible,
isAuthenticated,
+ isAgentsVisible,
}: NavigationItemsProps) => {
const pathname = usePathname();
@@ -65,6 +67,18 @@ export const NavigationItems = ({
{isActive('/repos') &&
}
+ {isAgentsVisible && (
+
+
+
+ Agents
+
+ {isActive('/agents') && }
+
+ )}
{isAuthenticated && (
[0], "Octokit"> & { throttle: ThrottlingOptions };
@@ -95,6 +96,40 @@ function isIssueCommentEvent(eventHeader: string, payload: unknown): payload is
return eventHeader === "issue_comment" && typeof payload === "object" && payload !== null && "action" in payload && typeof payload.action === "string" && payload.action === "created";
}
+function isGitLabMergeRequestEvent(eventHeader: string, payload: unknown): boolean {
+ return (
+ eventHeader === "Merge Request Hook" &&
+ typeof payload === "object" &&
+ payload !== null &&
+ "object_attributes" in payload &&
+ typeof (payload as Record & { object_attributes: { action?: unknown } }).object_attributes?.action === "string" &&
+ ["open", "update", "reopen"].includes((payload as Record & { object_attributes: { action: string } }).object_attributes.action)
+ );
+}
+
+function isGitLabNoteEvent(eventHeader: string, payload: unknown): boolean {
+ return (
+ eventHeader === "Note Hook" &&
+ typeof payload === "object" &&
+ payload !== null &&
+ "object_attributes" in payload &&
+ (payload as Record & { object_attributes: { noteable_type?: unknown } }).object_attributes?.noteable_type === "MergeRequest"
+ );
+}
+
+let gitlabClient: InstanceType | undefined;
+
+if (env.GITLAB_REVIEW_AGENT_TOKEN) {
+ try {
+ gitlabClient = new Gitlab({
+ host: `https://${env.GITLAB_REVIEW_AGENT_HOST}`,
+ token: env.GITLAB_REVIEW_AGENT_TOKEN,
+ });
+ } catch (error) {
+ logger.error(`Error initializing GitLab client: ${error}`);
+ }
+}
+
export const POST = async (request: NextRequest) => {
const body = await request.json();
const headers = Object.fromEntries(Array.from(request.headers.entries(), ([key, value]) => [key.toLowerCase(), value]));
@@ -161,5 +196,82 @@ export const POST = async (request: NextRequest) => {
}
}
+ const gitlabEvent = headers['x-gitlab-event'];
+ if (gitlabEvent) {
+ logger.info('GitLab event received:', gitlabEvent);
+
+ const token = headers['x-gitlab-token'];
+ if (!env.GITLAB_REVIEW_AGENT_WEBHOOK_SECRET || token !== env.GITLAB_REVIEW_AGENT_WEBHOOK_SECRET) {
+ logger.warn('GitLab webhook token is invalid or GITLAB_REVIEW_AGENT_WEBHOOK_SECRET is not set');
+ return Response.json({ status: 'ok' });
+ }
+
+ if (!gitlabClient) {
+ logger.warn('Received GitLab webhook event but GITLAB_REVIEW_AGENT_TOKEN is not set');
+ return Response.json({ status: 'ok' });
+ }
+
+ if (isGitLabMergeRequestEvent(gitlabEvent, body)) {
+ if (env.REVIEW_AGENT_AUTO_REVIEW_ENABLED === "false") {
+ logger.info('Review agent auto review (REVIEW_AGENT_AUTO_REVIEW_ENABLED) is disabled, skipping');
+ return Response.json({ status: 'ok' });
+ }
+
+ const parsed = gitLabMergeRequestPayloadSchema.safeParse(body);
+ if (!parsed.success) {
+ logger.warn(`GitLab MR webhook payload failed validation: ${parsed.error.message}`);
+ return Response.json({ status: 'ok' });
+ }
+
+ try {
+ await processGitLabMergeRequest(
+ gitlabClient,
+ parsed.data.project.id,
+ parsed.data,
+ env.GITLAB_REVIEW_AGENT_HOST,
+ );
+ } catch (error) {
+ logger.error(`Error in processGitLabMergeRequest for project ${parsed.data.project.id} (${gitlabEvent}):`, error);
+ }
+ }
+
+ if (isGitLabNoteEvent(gitlabEvent, body)) {
+ const parsed = gitLabNotePayloadSchema.safeParse(body);
+ if (!parsed.success) {
+ logger.warn(`GitLab Note webhook payload failed validation: ${parsed.error.message}`);
+ return Response.json({ status: 'ok' });
+ }
+
+ const noteBody = parsed.data.object_attributes.note;
+ if (noteBody === `/${env.REVIEW_AGENT_REVIEW_COMMAND}`) {
+ logger.info('Review agent review command received on GitLab MR, processing');
+
+ const mrPayload: GitLabMergeRequestPayload = {
+ object_kind: "merge_request",
+ object_attributes: {
+ iid: parsed.data.merge_request.iid,
+ title: parsed.data.merge_request.title,
+ description: parsed.data.merge_request.description,
+ action: "update",
+ last_commit: parsed.data.merge_request.last_commit,
+ diff_refs: parsed.data.merge_request.diff_refs,
+ },
+ project: parsed.data.project,
+ };
+
+ try {
+ await processGitLabMergeRequest(
+ gitlabClient,
+ parsed.data.project.id,
+ mrPayload,
+ env.GITLAB_REVIEW_AGENT_HOST,
+ );
+ } catch (error) {
+ logger.error(`Error in processGitLabMergeRequest for project ${parsed.data.project.id} (${gitlabEvent}):`, error);
+ }
+ }
+ }
+ }
+
return Response.json({ status: 'ok' });
}
diff --git a/packages/web/src/features/agents/review-agent/app.ts b/packages/web/src/features/agents/review-agent/app.ts
index 80d5a2f38..5b2ef2bbd 100644
--- a/packages/web/src/features/agents/review-agent/app.ts
+++ b/packages/web/src/features/agents/review-agent/app.ts
@@ -1,9 +1,12 @@
import { Octokit } from "octokit";
+import { Gitlab } from "@gitbeaker/rest";
import { generatePrReviews } from "@/features/agents/review-agent/nodes/generatePrReview";
import { githubPushPrReviews } from "@/features/agents/review-agent/nodes/githubPushPrReviews";
import { githubPrParser } from "@/features/agents/review-agent/nodes/githubPrParser";
+import { gitlabMrParser } from "@/features/agents/review-agent/nodes/gitlabMrParser";
+import { gitlabPushMrReviews } from "@/features/agents/review-agent/nodes/gitlabPushMrReviews";
+import { GitHubPullRequest, GitLabMergeRequestPayload } from "@/features/agents/review-agent/types";
import { env } from "@sourcebot/shared";
-import { GitHubPullRequest } from "@/features/agents/review-agent/types";
import path from "path";
import fs from "fs";
import { createLogger } from "@sourcebot/shared";
@@ -20,35 +23,51 @@ const rules = [
const logger = createLogger('review-agent');
-export async function processGitHubPullRequest(octokit: Octokit, pullRequest: GitHubPullRequest) {
- logger.info(`Received a pull request event for #${pullRequest.number}`);
-
- if (!env.OPENAI_API_KEY) {
- logger.error("OPENAI_API_KEY is not set, skipping review agent");
- return;
+function getReviewAgentLogPath(identifier: string): string | undefined {
+ if (!env.REVIEW_AGENT_LOGGING_ENABLED) {
+ return undefined;
}
- let reviewAgentLogPath: string | undefined;
- if (env.REVIEW_AGENT_LOGGING_ENABLED) {
- const reviewAgentLogDir = path.join(env.DATA_CACHE_DIR, "review-agent");
- if (!fs.existsSync(reviewAgentLogDir)) {
- fs.mkdirSync(reviewAgentLogDir, { recursive: true });
- }
-
- const timestamp = new Date().toLocaleString('en-US', {
- year: 'numeric',
- month: '2-digit',
- day: '2-digit',
- hour: '2-digit',
- minute: '2-digit',
- second: '2-digit',
- hour12: false
- }).replace(/(\d+)\/(\d+)\/(\d+), (\d+):(\d+):(\d+)/, '$3_$1_$2_$4_$5_$6');
- reviewAgentLogPath = path.join(reviewAgentLogDir, `review-agent-${pullRequest.number}-${timestamp}.log`);
- logger.info(`Review agent logging to ${reviewAgentLogPath}`);
+ const reviewAgentLogDir = path.join(env.DATA_CACHE_DIR, "review-agent");
+ if (!fs.existsSync(reviewAgentLogDir)) {
+ fs.mkdirSync(reviewAgentLogDir, { recursive: true });
}
+ const timestamp = new Date().toLocaleString('en-US', {
+ year: 'numeric',
+ month: '2-digit',
+ day: '2-digit',
+ hour: '2-digit',
+ minute: '2-digit',
+ second: '2-digit',
+ hour12: false
+ }).replace(/(\d+)\/(\d+)\/(\d+), (\d+):(\d+):(\d+)/, '$3_$1_$2_$4_$5_$6');
+ const logPath = path.join(reviewAgentLogDir, `review-agent-${identifier}-${timestamp}.log`);
+ logger.info(`Review agent logging to ${logPath}`);
+ return logPath;
+}
+
+export async function processGitHubPullRequest(octokit: Octokit, pullRequest: GitHubPullRequest) {
+ logger.info(`Received a pull request event for #${pullRequest.number}`);
+
+ const reviewAgentLogPath = getReviewAgentLogPath(String(pullRequest.number));
+
const prPayload = await githubPrParser(octokit, pullRequest);
const fileDiffReviews = await generatePrReviews(reviewAgentLogPath, prPayload, rules);
- await githubPushPrReviews(octokit, prPayload, fileDiffReviews);
+ await githubPushPrReviews(octokit, prPayload, fileDiffReviews);
+}
+
+export async function processGitLabMergeRequest(
+ gitlabClient: InstanceType,
+ projectId: number,
+ mrPayload: GitLabMergeRequestPayload,
+ hostDomain: string,
+) {
+ logger.info(`Received a merge request event for !${mrPayload.object_attributes.iid}`);
+
+ const reviewAgentLogPath = getReviewAgentLogPath(`mr-${mrPayload.object_attributes.iid}`);
+
+ const prPayload = await gitlabMrParser(gitlabClient, mrPayload, hostDomain);
+ const fileDiffReviews = await generatePrReviews(reviewAgentLogPath, prPayload, rules);
+ await gitlabPushMrReviews(gitlabClient, projectId, prPayload, fileDiffReviews);
}
\ No newline at end of file
diff --git a/packages/web/src/features/agents/review-agent/nodes/fetchFileContent.ts b/packages/web/src/features/agents/review-agent/nodes/fetchFileContent.ts
index 926339aca..41e5a6e58 100644
--- a/packages/web/src/features/agents/review-agent/nodes/fetchFileContent.ts
+++ b/packages/web/src/features/agents/review-agent/nodes/fetchFileContent.ts
@@ -1,21 +1,41 @@
import { sourcebot_context, sourcebot_pr_payload } from "@/features/agents/review-agent/types";
-import { fileSourceResponseSchema, getFileSource } from '@/features/git';
+import { fileSourceResponseSchema, getFileSourceForRepo } from '@/features/git';
+import { SINGLE_TENANT_ORG_ID } from "@/lib/constants";
import { isServiceError } from "@/lib/utils";
+import { __unsafePrisma } from "@/prisma";
import { createLogger } from "@sourcebot/shared";
const logger = createLogger('fetch-file-content');
+type Org = Awaited>;
+let cachedOrg: Org | undefined;
+
+const getOrg = async (): Promise> => {
+ if (!cachedOrg) {
+ cachedOrg = await __unsafePrisma.org.findUnique({
+ where: { id: SINGLE_TENANT_ORG_ID },
+ });
+ }
+ if (!cachedOrg) {
+ throw new Error("Organization not found");
+ }
+ return cachedOrg;
+};
+
export const fetchFileContent = async (pr_payload: sourcebot_pr_payload, filename: string): Promise => {
logger.debug("Executing fetch_file_content");
+ const org = await getOrg();
+
const repoPath = pr_payload.hostDomain + "/" + pr_payload.owner + "/" + pr_payload.repo;
const fileSourceRequest = {
path: filename,
repo: repoPath,
- }
+ ref: pr_payload.head_sha,
+ };
logger.debug(JSON.stringify(fileSourceRequest, null, 2));
- const response = await getFileSource(fileSourceRequest);
+ const response = await getFileSourceForRepo(fileSourceRequest, { org, prisma: __unsafePrisma });
if (isServiceError(response)) {
throw new Error(`Failed to fetch file content for ${filename} from ${repoPath}: ${response.message}`);
}
@@ -27,8 +47,8 @@ export const fetchFileContent = async (pr_payload: sourcebot_pr_payload, filenam
type: "file_content",
description: `The content of the file ${filename}`,
context: fileContent,
- }
+ };
logger.debug("Completed fetch_file_content");
return fileContentContext;
-}
\ No newline at end of file
+};
diff --git a/packages/web/src/features/agents/review-agent/nodes/githubPrParser.test.ts b/packages/web/src/features/agents/review-agent/nodes/githubPrParser.test.ts
new file mode 100644
index 000000000..66175a03e
--- /dev/null
+++ b/packages/web/src/features/agents/review-agent/nodes/githubPrParser.test.ts
@@ -0,0 +1,192 @@
+import { expect, test, vi, describe } from 'vitest';
+import { githubPrParser } from './githubPrParser';
+import { GitHubPullRequest } from '../types';
+
+vi.mock('@sourcebot/shared', () => ({
+ createLogger: () => ({
+ debug: vi.fn(),
+ info: vi.fn(),
+ warn: vi.fn(),
+ error: vi.fn(),
+ }),
+}));
+
+// Minimal shape satisfying the fields accessed by githubPrParser
+function makePullRequest(overrides: Partial<{
+ number: number;
+ title: string;
+ body: string | null;
+ head_sha: string;
+ owner: string;
+ repo: string;
+ diff_url: string;
+}> = {}): GitHubPullRequest {
+ const opts = {
+ number: 7,
+ title: 'My PR title',
+ body: 'My PR description',
+ head_sha: 'sha_abc123',
+ owner: 'my-org',
+ repo: 'my-repo',
+ diff_url: 'https://github.com/my-org/my-repo/pull/7.diff',
+ ...overrides,
+ };
+
+ return {
+ number: opts.number,
+ title: opts.title,
+ body: opts.body,
+ diff_url: opts.diff_url,
+ head: { sha: opts.head_sha, repo: {} },
+ base: {
+ repo: {
+ name: opts.repo,
+ owner: { login: opts.owner },
+ },
+ },
+ } as unknown as GitHubPullRequest;
+}
+
+function makeMockOctokit(diffText: string) {
+ return {
+ request: vi.fn().mockResolvedValue({ data: diffText }),
+ } as any;
+}
+
+describe('githubPrParser', () => {
+ test('maps pull request metadata correctly', async () => {
+ const octokit = makeMockOctokit('');
+ const pr = makePullRequest();
+
+ const result = await githubPrParser(octokit, pr);
+
+ expect(result.title).toBe('My PR title');
+ expect(result.description).toBe('My PR description');
+ expect(result.number).toBe(7);
+ expect(result.head_sha).toBe('sha_abc123');
+ expect(result.owner).toBe('my-org');
+ expect(result.repo).toBe('my-repo');
+ expect(result.hostDomain).toBe('github.com');
+ });
+
+ test('uses empty string when body is null', async () => {
+ const octokit = makeMockOctokit('');
+ const pr = makePullRequest({ body: null });
+
+ const result = await githubPrParser(octokit, pr);
+
+ expect(result.description).toBe('');
+ });
+
+ test('fetches diff using the pull request diff_url', async () => {
+ const mockRequest = vi.fn().mockResolvedValue({ data: '' });
+ const octokit = { request: mockRequest } as any;
+ const pr = makePullRequest({ diff_url: 'https://github.com/my-org/my-repo/pull/7.diff' });
+
+ await githubPrParser(octokit, pr);
+
+ expect(mockRequest).toHaveBeenCalledWith('https://github.com/my-org/my-repo/pull/7.diff');
+ });
+
+ test('returns empty file_diffs for an empty diff', async () => {
+ const octokit = makeMockOctokit('');
+ const pr = makePullRequest();
+
+ const result = await githubPrParser(octokit, pr);
+
+ expect(result.file_diffs).toEqual([]);
+ });
+
+ test('parses a unified diff with added and context lines', async () => {
+ const unifiedDiff = [
+ 'diff --git a/src/foo.ts b/src/foo.ts',
+ '--- a/src/foo.ts',
+ '+++ b/src/foo.ts',
+ '@@ -1,2 +1,3 @@',
+ ' context line',
+ '+added line',
+ ' another context',
+ ].join('\n');
+ const octokit = makeMockOctokit(unifiedDiff);
+ const pr = makePullRequest();
+
+ const result = await githubPrParser(octokit, pr);
+
+ expect(result.file_diffs).toHaveLength(1);
+ expect(result.file_diffs[0].from).toBe('src/foo.ts');
+ expect(result.file_diffs[0].to).toBe('src/foo.ts');
+ expect(result.file_diffs[0].diffs).toHaveLength(1);
+ });
+
+ test('newSnippet contains added lines and context', async () => {
+ const unifiedDiff = [
+ 'diff --git a/src/foo.ts b/src/foo.ts',
+ '--- a/src/foo.ts',
+ '+++ b/src/foo.ts',
+ '@@ -1,1 +1,2 @@',
+ ' context',
+ '+new line here',
+ ].join('\n');
+ const octokit = makeMockOctokit(unifiedDiff);
+ const pr = makePullRequest();
+
+ const result = await githubPrParser(octokit, pr);
+ const diff = result.file_diffs[0].diffs[0];
+
+ expect(diff.newSnippet).toContain('+new line here');
+ expect(diff.oldSnippet).not.toContain('+new line here');
+ });
+
+ test('oldSnippet contains deleted lines', async () => {
+ const unifiedDiff = [
+ 'diff --git a/src/foo.ts b/src/foo.ts',
+ '--- a/src/foo.ts',
+ '+++ b/src/foo.ts',
+ '@@ -1,2 +1,1 @@',
+ ' context',
+ '-removed line',
+ ].join('\n');
+ const octokit = makeMockOctokit(unifiedDiff);
+ const pr = makePullRequest();
+
+ const result = await githubPrParser(octokit, pr);
+ const diff = result.file_diffs[0].diffs[0];
+
+ expect(diff.oldSnippet).toContain('-removed line');
+ expect(diff.newSnippet).not.toContain('-removed line');
+ });
+
+ test('parses multiple files from a diff', async () => {
+ const unifiedDiff = [
+ 'diff --git a/src/a.ts b/src/a.ts',
+ '--- a/src/a.ts',
+ '+++ b/src/a.ts',
+ '@@ -1,1 +1,2 @@',
+ ' ctx',
+ '+add',
+ 'diff --git a/src/b.ts b/src/b.ts',
+ '--- a/src/b.ts',
+ '+++ b/src/b.ts',
+ '@@ -1,2 +1,1 @@',
+ ' ctx',
+ '-remove',
+ ].join('\n');
+ const octokit = makeMockOctokit(unifiedDiff);
+ const pr = makePullRequest();
+
+ const result = await githubPrParser(octokit, pr);
+
+ expect(result.file_diffs).toHaveLength(2);
+ expect(result.file_diffs[0].to).toBe('src/a.ts');
+ expect(result.file_diffs[1].to).toBe('src/b.ts');
+ });
+
+ test('throws when the diff request fails', async () => {
+ const octokit = {
+ request: vi.fn().mockRejectedValue(new Error('Network error')),
+ } as any;
+ const pr = makePullRequest();
+
+ await expect(githubPrParser(octokit, pr)).rejects.toThrow('Network error');
+ });
+});
diff --git a/packages/web/src/features/agents/review-agent/nodes/githubPushPrReviews.test.ts b/packages/web/src/features/agents/review-agent/nodes/githubPushPrReviews.test.ts
new file mode 100644
index 000000000..9e80d31a9
--- /dev/null
+++ b/packages/web/src/features/agents/review-agent/nodes/githubPushPrReviews.test.ts
@@ -0,0 +1,148 @@
+import { expect, test, vi, describe } from 'vitest';
+import { githubPushPrReviews } from './githubPushPrReviews';
+import { sourcebot_pr_payload, sourcebot_file_diff_review } from '../types';
+
+vi.mock('@sourcebot/shared', () => ({
+ createLogger: () => ({
+ debug: vi.fn(),
+ info: vi.fn(),
+ warn: vi.fn(),
+ error: vi.fn(),
+ }),
+}));
+
+const MOCK_PAYLOAD: sourcebot_pr_payload = {
+ title: 'Test PR',
+ description: 'desc',
+ hostDomain: 'github.com',
+ owner: 'my-org',
+ repo: 'my-repo',
+ file_diffs: [],
+ number: 7,
+ head_sha: 'sha_abc123',
+};
+
+const SINGLE_REVIEW: sourcebot_file_diff_review[] = [
+ {
+ filename: 'src/foo.ts',
+ reviews: [{ line_start: 10, line_end: 10, review: 'Missing null check' }],
+ },
+];
+
+function makeMockOctokit(createReviewCommentResult: 'resolve' | 'reject' = 'resolve') {
+ return {
+ rest: {
+ pulls: {
+ createReviewComment: createReviewCommentResult === 'resolve'
+ ? vi.fn().mockResolvedValue({})
+ : vi.fn().mockRejectedValue(new Error('Unprocessable Entity')),
+ },
+ },
+ } as any;
+}
+
+describe('githubPushPrReviews', () => {
+ test('posts a review comment for each review', async () => {
+ const octokit = makeMockOctokit();
+
+ await githubPushPrReviews(octokit, MOCK_PAYLOAD, SINGLE_REVIEW);
+
+ expect(octokit.rest.pulls.createReviewComment).toHaveBeenCalledOnce();
+ expect(octokit.rest.pulls.createReviewComment).toHaveBeenCalledWith(
+ expect.objectContaining({
+ owner: 'my-org',
+ repo: 'my-repo',
+ pull_number: 7,
+ commit_id: 'sha_abc123',
+ body: 'Missing null check',
+ path: 'src/foo.ts',
+ side: 'RIGHT',
+ line: 10,
+ }),
+ );
+ });
+
+ test('uses line for a single-line review', async () => {
+ const octokit = makeMockOctokit();
+
+ await githubPushPrReviews(octokit, MOCK_PAYLOAD, SINGLE_REVIEW);
+
+ const call = octokit.rest.pulls.createReviewComment.mock.calls[0][0];
+ expect(call).toHaveProperty('line', 10);
+ expect(call).not.toHaveProperty('start_line');
+ });
+
+ test('uses start_line and line for a multi-line review', async () => {
+ const multiLineReviews: sourcebot_file_diff_review[] = [
+ {
+ filename: 'src/bar.ts',
+ reviews: [{ line_start: 5, line_end: 15, review: 'Refactor this block' }],
+ },
+ ];
+ const octokit = makeMockOctokit();
+
+ await githubPushPrReviews(octokit, MOCK_PAYLOAD, multiLineReviews);
+
+ const call = octokit.rest.pulls.createReviewComment.mock.calls[0][0];
+ expect(call).toHaveProperty('start_line', 5);
+ expect(call).toHaveProperty('line', 15);
+ expect(call).toHaveProperty('start_side', 'RIGHT');
+ });
+
+ test('posts multiple reviews across multiple files', async () => {
+ const multiFileReviews: sourcebot_file_diff_review[] = [
+ {
+ filename: 'src/a.ts',
+ reviews: [
+ { line_start: 1, line_end: 1, review: 'Comment A1' },
+ { line_start: 5, line_end: 5, review: 'Comment A2' },
+ ],
+ },
+ {
+ filename: 'src/b.ts',
+ reviews: [{ line_start: 3, line_end: 3, review: 'Comment B1' }],
+ },
+ ];
+ const octokit = makeMockOctokit();
+
+ await githubPushPrReviews(octokit, MOCK_PAYLOAD, multiFileReviews);
+
+ expect(octokit.rest.pulls.createReviewComment).toHaveBeenCalledTimes(3);
+ });
+
+ test('continues posting remaining reviews when one fails', async () => {
+ const twoReviews: sourcebot_file_diff_review[] = [
+ {
+ filename: 'src/foo.ts',
+ reviews: [
+ { line_start: 1, line_end: 1, review: 'First' },
+ { line_start: 2, line_end: 2, review: 'Second' },
+ ],
+ },
+ ];
+ const mockCreate = vi.fn()
+ .mockRejectedValueOnce(new Error('422'))
+ .mockResolvedValueOnce({});
+ const octokit = { rest: { pulls: { createReviewComment: mockCreate } } } as any;
+
+ await githubPushPrReviews(octokit, MOCK_PAYLOAD, twoReviews);
+
+ expect(mockCreate).toHaveBeenCalledTimes(2);
+ });
+
+ test('does not throw when all review comments fail', async () => {
+ const octokit = makeMockOctokit('reject');
+
+ await expect(
+ githubPushPrReviews(octokit, MOCK_PAYLOAD, SINGLE_REVIEW),
+ ).resolves.toBeUndefined();
+ });
+
+ test('does nothing when file_diff_reviews is empty', async () => {
+ const octokit = makeMockOctokit();
+
+ await githubPushPrReviews(octokit, MOCK_PAYLOAD, []);
+
+ expect(octokit.rest.pulls.createReviewComment).not.toHaveBeenCalled();
+ });
+});
diff --git a/packages/web/src/features/agents/review-agent/nodes/gitlabMrParser.test.ts b/packages/web/src/features/agents/review-agent/nodes/gitlabMrParser.test.ts
new file mode 100644
index 000000000..ba9fd3eae
--- /dev/null
+++ b/packages/web/src/features/agents/review-agent/nodes/gitlabMrParser.test.ts
@@ -0,0 +1,251 @@
+import { expect, test, vi, describe } from 'vitest';
+import { gitlabMrParser } from './gitlabMrParser';
+import { GitLabMergeRequestPayload } from '../types';
+
+vi.mock('@sourcebot/shared', () => ({
+ createLogger: () => ({
+ debug: vi.fn(),
+ info: vi.fn(),
+ warn: vi.fn(),
+ error: vi.fn(),
+ }),
+}));
+
+const MOCK_MR_PAYLOAD: GitLabMergeRequestPayload = {
+ object_kind: 'merge_request',
+ object_attributes: {
+ iid: 42,
+ title: 'My MR title',
+ description: 'My MR description',
+ action: 'open',
+ last_commit: { id: 'abc123def456' },
+ diff_refs: {
+ base_sha: 'base_sha_value',
+ head_sha: 'head_sha_value',
+ start_sha: 'start_sha_value',
+ },
+ },
+ project: {
+ id: 101,
+ name: 'my-repo',
+ path_with_namespace: 'my-group/my-repo',
+ web_url: 'https://gitlab.com/my-group/my-repo',
+ namespace: 'my-group',
+ },
+};
+
+const MOCK_MR_API_RESPONSE = {
+ title: 'My MR title',
+ description: 'My MR description',
+ sha: 'abc123def456',
+ diff_refs: {
+ base_sha: 'base_sha_value',
+ head_sha: 'head_sha_value',
+ start_sha: 'start_sha_value',
+ },
+};
+
+function makeMockGitlabClient(allDiffsResult: unknown, mrOverrides: Partial = {}) {
+ return {
+ MergeRequests: {
+ show: vi.fn().mockResolvedValue({ ...MOCK_MR_API_RESPONSE, ...mrOverrides }),
+ allDiffs: vi.fn().mockResolvedValue(allDiffsResult),
+ },
+ } as any;
+}
+
+describe('gitlabMrParser', () => {
+ test('maps MR payload metadata correctly to sourcebot_pr_payload', async () => {
+ const client = makeMockGitlabClient([]);
+
+ const result = await gitlabMrParser(client, MOCK_MR_PAYLOAD, 'gitlab.com');
+
+ expect(result.title).toBe('My MR title');
+ expect(result.description).toBe('My MR description');
+ expect(result.number).toBe(42);
+ expect(result.head_sha).toBe('abc123def456');
+ expect(result.hostDomain).toBe('gitlab.com');
+ expect(result.owner).toBe('my-group');
+ expect(result.repo).toBe('my-repo');
+ });
+
+ test('maps diff_refs from payload', async () => {
+ const client = makeMockGitlabClient([]);
+
+ const result = await gitlabMrParser(client, MOCK_MR_PAYLOAD, 'gitlab.com');
+
+ expect(result.diff_refs).toEqual({
+ base_sha: 'base_sha_value',
+ head_sha: 'head_sha_value',
+ start_sha: 'start_sha_value',
+ });
+ });
+
+ test('uses custom hostDomain', async () => {
+ const client = makeMockGitlabClient([]);
+
+ const result = await gitlabMrParser(client, MOCK_MR_PAYLOAD, 'gitlab.example.com');
+
+ expect(result.hostDomain).toBe('gitlab.example.com');
+ });
+
+ test('uses empty string when description is null', async () => {
+ const client = makeMockGitlabClient([], { description: null });
+
+ const result = await gitlabMrParser(client, MOCK_MR_PAYLOAD, 'gitlab.com');
+
+ expect(result.description).toBe('');
+ });
+
+ test('calls show and allDiffs with the correct project id and MR iid', async () => {
+ const mockShow = vi.fn().mockResolvedValue(MOCK_MR_API_RESPONSE);
+ const mockAllDiffs = vi.fn().mockResolvedValue([]);
+ const client = { MergeRequests: { show: mockShow, allDiffs: mockAllDiffs } } as any;
+
+ await gitlabMrParser(client, MOCK_MR_PAYLOAD, 'gitlab.com');
+
+ expect(mockShow).toHaveBeenCalledWith(101, 42);
+ expect(mockAllDiffs).toHaveBeenCalledWith(101, 42);
+ });
+
+ test('returns empty file_diffs when allDiffs returns no files', async () => {
+ const client = makeMockGitlabClient([]);
+
+ const result = await gitlabMrParser(client, MOCK_MR_PAYLOAD, 'gitlab.com');
+
+ expect(result.file_diffs).toEqual([]);
+ });
+
+ test('parses a file diff with added and context lines', async () => {
+ const client = makeMockGitlabClient([
+ {
+ old_path: 'src/foo.ts',
+ new_path: 'src/foo.ts',
+ diff: '@@ -1,2 +1,3 @@\n context line\n+added line\n',
+ new_file: false,
+ renamed_file: false,
+ deleted_file: false,
+ a_mode: '100644',
+ b_mode: '100644',
+ },
+ ]);
+
+ const result = await gitlabMrParser(client, MOCK_MR_PAYLOAD, 'gitlab.com');
+
+ expect(result.file_diffs).toHaveLength(1);
+ expect(result.file_diffs[0].from).toBe('src/foo.ts');
+ expect(result.file_diffs[0].to).toBe('src/foo.ts');
+ expect(result.file_diffs[0].diffs).toHaveLength(1);
+ });
+
+ test('diff newSnippet contains added lines', async () => {
+ const client = makeMockGitlabClient([
+ {
+ old_path: 'src/foo.ts',
+ new_path: 'src/foo.ts',
+ diff: '@@ -1,1 +1,2 @@\n context\n+added line\n',
+ new_file: false,
+ renamed_file: false,
+ deleted_file: false,
+ a_mode: '100644',
+ b_mode: '100644',
+ },
+ ]);
+
+ const result = await gitlabMrParser(client, MOCK_MR_PAYLOAD, 'gitlab.com');
+ const diff = result.file_diffs[0].diffs[0];
+
+ expect(diff.newSnippet).toContain('+added line');
+ expect(diff.oldSnippet).not.toContain('+added line');
+ });
+
+ test('diff oldSnippet contains deleted lines', async () => {
+ const client = makeMockGitlabClient([
+ {
+ old_path: 'src/foo.ts',
+ new_path: 'src/foo.ts',
+ diff: '@@ -1,2 +1,1 @@\n context\n-removed line\n',
+ new_file: false,
+ renamed_file: false,
+ deleted_file: false,
+ a_mode: '100644',
+ b_mode: '100644',
+ },
+ ]);
+
+ const result = await gitlabMrParser(client, MOCK_MR_PAYLOAD, 'gitlab.com');
+ const diff = result.file_diffs[0].diffs[0];
+
+ expect(diff.oldSnippet).toContain('-removed line');
+ expect(diff.newSnippet).not.toContain('-removed line');
+ });
+
+ test('skips files with empty diff strings', async () => {
+ const client = makeMockGitlabClient([
+ {
+ old_path: 'binary.png',
+ new_path: 'binary.png',
+ diff: '',
+ new_file: false,
+ renamed_file: false,
+ deleted_file: false,
+ a_mode: '100644',
+ b_mode: '100644',
+ },
+ ]);
+
+ const result = await gitlabMrParser(client, MOCK_MR_PAYLOAD, 'gitlab.com');
+
+ expect(result.file_diffs).toHaveLength(0);
+ });
+
+ test('handles multiple files in allDiffs response', async () => {
+ const client = makeMockGitlabClient([
+ {
+ old_path: 'src/a.ts',
+ new_path: 'src/a.ts',
+ diff: '@@ -1,1 +1,2 @@\n ctx\n+add\n',
+ new_file: false, renamed_file: false, deleted_file: false, a_mode: '100644', b_mode: '100644',
+ },
+ {
+ old_path: 'src/b.ts',
+ new_path: 'src/b.ts',
+ diff: '@@ -1,2 +1,1 @@\n ctx\n-remove\n',
+ new_file: false, renamed_file: false, deleted_file: false, a_mode: '100644', b_mode: '100644',
+ },
+ ]);
+
+ const result = await gitlabMrParser(client, MOCK_MR_PAYLOAD, 'gitlab.com');
+
+ expect(result.file_diffs).toHaveLength(2);
+ expect(result.file_diffs[0].to).toBe('src/a.ts');
+ expect(result.file_diffs[1].to).toBe('src/b.ts');
+ });
+
+ test('extracts owner from path_with_namespace with nested groups', async () => {
+ const client = makeMockGitlabClient([]);
+ const payload = {
+ ...MOCK_MR_PAYLOAD,
+ project: {
+ ...MOCK_MR_PAYLOAD.project,
+ path_with_namespace: 'top-group/sub-group/my-repo',
+ },
+ };
+
+ const result = await gitlabMrParser(client, payload, 'gitlab.com');
+
+ expect(result.owner).toBe('top-group/sub-group');
+ expect(result.repo).toBe('my-repo');
+ });
+
+ test('throws when an API call fails', async () => {
+ const client = {
+ MergeRequests: {
+ show: vi.fn().mockResolvedValue(MOCK_MR_API_RESPONSE),
+ allDiffs: vi.fn().mockRejectedValue(new Error('Network error')),
+ },
+ } as any;
+
+ await expect(gitlabMrParser(client, MOCK_MR_PAYLOAD, 'gitlab.com')).rejects.toThrow('Network error');
+ });
+});
diff --git a/packages/web/src/features/agents/review-agent/nodes/gitlabMrParser.ts b/packages/web/src/features/agents/review-agent/nodes/gitlabMrParser.ts
new file mode 100644
index 000000000..89cd57613
--- /dev/null
+++ b/packages/web/src/features/agents/review-agent/nodes/gitlabMrParser.ts
@@ -0,0 +1,105 @@
+import { sourcebot_pr_payload, sourcebot_file_diff, sourcebot_diff } from "@/features/agents/review-agent/types";
+import { GitLabMergeRequestPayload } from "@/features/agents/review-agent/types";
+import parse from "parse-diff";
+import { Gitlab } from "@gitbeaker/rest";
+import { createLogger } from "@sourcebot/shared";
+
+const logger = createLogger('gitlab-mr-parser');
+
+export const gitlabMrParser = async (
+ gitlabClient: InstanceType,
+ mrPayload: GitLabMergeRequestPayload,
+ hostDomain: string,
+): Promise => {
+ logger.debug("Executing gitlab_mr_parser");
+
+ const projectId = mrPayload.project.id;
+ const mrIid = mrPayload.object_attributes.iid;
+
+ // Fetch the full MR from the API to guarantee diff_refs and last_commit are present.
+ // The webhook payload omits or nulls diff_refs on some action types (e.g. "update").
+ let mr: Awaited>;
+ let fileDiffs: Awaited> = [];
+ try {
+ [mr, fileDiffs] = await Promise.all([
+ gitlabClient.MergeRequests.show(projectId, mrIid),
+ gitlabClient.MergeRequests.allDiffs(projectId, mrIid),
+ ]);
+ } catch (error) {
+ logger.error("Error fetching MR data: ", error);
+ throw error;
+ }
+
+ const pathParts = mrPayload.project.path_with_namespace.split('/');
+ const namespace = pathParts.slice(0, -1).join('/');
+ const repoName = pathParts[pathParts.length - 1];
+
+ const sourcebotFileDiffs: (sourcebot_file_diff | null)[] = fileDiffs.map((fileDiff) => {
+ const fromPath = fileDiff.old_path as string;
+ const toPath = fileDiff.new_path as string;
+
+ if (!fromPath || !toPath) {
+ logger.debug(`Skipping file due to missing old_path (${fromPath}) or new_path (${toPath})`);
+ return null;
+ }
+
+ if (!fileDiff.diff) {
+ logger.debug(`Skipping file ${toPath} due to empty diff`);
+ return null;
+ }
+
+ // Construct a standard unified diff header so parse-diff can process it
+ const unifiedDiff = `--- a/${fromPath}\n+++ b/${toPath}\n${fileDiff.diff}`;
+ const parsed = parse(unifiedDiff);
+ if (parsed.length === 0) {
+ return null;
+ }
+
+ const parsedFile = parsed[0];
+ const diffs: sourcebot_diff[] = parsedFile.chunks.map((chunk) => {
+ let oldSnippet = `@@ -${chunk.oldStart},${chunk.oldLines} +${chunk.newStart},${chunk.newLines} @@\n`;
+ let newSnippet = `@@ -${chunk.oldStart},${chunk.oldLines} +${chunk.newStart},${chunk.newLines} @@\n`;
+
+ for (const change of chunk.changes) {
+ if (change.type === "normal") {
+ oldSnippet += change.ln1 + ":" + change.content + "\n";
+ newSnippet += change.ln2 + ":" + change.content + "\n";
+ } else if (change.type === "add") {
+ newSnippet += change.ln + ":" + change.content + "\n";
+ } else if (change.type === "del") {
+ oldSnippet += change.ln + ":" + change.content + "\n";
+ }
+ }
+
+ return {
+ oldSnippet,
+ newSnippet,
+ };
+ });
+
+ return {
+ from: fromPath,
+ to: toPath,
+ diffs,
+ };
+ });
+
+ const filteredSourcebotFileDiffs: sourcebot_file_diff[] = sourcebotFileDiffs.filter(
+ (file): file is sourcebot_file_diff => file !== null,
+ );
+
+ logger.debug("Completed gitlab_mr_parser");
+ return {
+ title: mr.title,
+ description: mr.description ?? "",
+ hostDomain,
+ owner: namespace,
+ repo: repoName,
+ file_diffs: filteredSourcebotFileDiffs,
+ number: mrIid,
+ head_sha: mr.sha ?? "",
+ diff_refs: mr.diff_refs != null
+ ? mr.diff_refs as { base_sha: string; head_sha: string; start_sha: string }
+ : undefined,
+ };
+};
diff --git a/packages/web/src/features/agents/review-agent/nodes/gitlabPushMrReviews.test.ts b/packages/web/src/features/agents/review-agent/nodes/gitlabPushMrReviews.test.ts
new file mode 100644
index 000000000..b26ecc53b
--- /dev/null
+++ b/packages/web/src/features/agents/review-agent/nodes/gitlabPushMrReviews.test.ts
@@ -0,0 +1,187 @@
+import { expect, test, vi, describe } from 'vitest';
+import { gitlabPushMrReviews } from './gitlabPushMrReviews';
+import { sourcebot_pr_payload, sourcebot_file_diff_review } from '../types';
+
+vi.mock('@sourcebot/shared', () => ({
+ createLogger: () => ({
+ debug: vi.fn(),
+ info: vi.fn(),
+ warn: vi.fn(),
+ error: vi.fn(),
+ }),
+}));
+
+const MOCK_PAYLOAD: sourcebot_pr_payload = {
+ title: 'Test MR',
+ description: 'desc',
+ hostDomain: 'gitlab.com',
+ owner: 'my-group',
+ repo: 'my-repo',
+ file_diffs: [],
+ number: 42,
+ head_sha: 'head_sha_value',
+ diff_refs: {
+ base_sha: 'base_sha_value',
+ head_sha: 'head_sha_value',
+ start_sha: 'start_sha_value',
+ },
+};
+
+const SINGLE_REVIEW: sourcebot_file_diff_review[] = [
+ {
+ filename: 'src/foo.ts',
+ reviews: [{ line_start: 5, line_end: 5, review: 'Avoid this pattern' }],
+ },
+];
+
+function makeMockClient(discussionResult: 'resolve' | 'reject' = 'resolve') {
+ return {
+ MergeRequestDiscussions: {
+ create: discussionResult === 'resolve'
+ ? vi.fn().mockResolvedValue({})
+ : vi.fn().mockRejectedValue(new Error('400 Bad Request')),
+ },
+ MergeRequestNotes: {
+ create: vi.fn().mockResolvedValue({}),
+ },
+ } as any;
+}
+
+describe('gitlabPushMrReviews', () => {
+ test('posts an inline discussion for each review', async () => {
+ const client = makeMockClient();
+
+ await gitlabPushMrReviews(client, 101, MOCK_PAYLOAD, SINGLE_REVIEW);
+
+ expect(client.MergeRequestDiscussions.create).toHaveBeenCalledOnce();
+ expect(client.MergeRequestDiscussions.create).toHaveBeenCalledWith(
+ 101,
+ 42,
+ 'Avoid this pattern',
+ expect.objectContaining({
+ position: expect.objectContaining({
+ positionType: 'text',
+ baseSha: 'base_sha_value',
+ headSha: 'head_sha_value',
+ startSha: 'start_sha_value',
+ newPath: 'src/foo.ts',
+ newLine: '5',
+ }),
+ }),
+ );
+ });
+
+ test('does not post a fallback note when inline comment succeeds', async () => {
+ const client = makeMockClient('resolve');
+
+ await gitlabPushMrReviews(client, 101, MOCK_PAYLOAD, SINGLE_REVIEW);
+
+ expect(client.MergeRequestNotes.create).not.toHaveBeenCalled();
+ });
+
+ test('falls back to MR note when inline discussion create fails', async () => {
+ const client = makeMockClient('reject');
+
+ await gitlabPushMrReviews(client, 101, MOCK_PAYLOAD, SINGLE_REVIEW);
+
+ expect(client.MergeRequestNotes.create).toHaveBeenCalledOnce();
+ expect(client.MergeRequestNotes.create).toHaveBeenCalledWith(
+ 101,
+ 42,
+ expect.stringContaining('Avoid this pattern'),
+ );
+ });
+
+ test('fallback note body includes the filename', async () => {
+ const client = makeMockClient('reject');
+
+ await gitlabPushMrReviews(client, 101, MOCK_PAYLOAD, SINGLE_REVIEW);
+
+ const noteBody = client.MergeRequestNotes.create.mock.calls[0][2] as string;
+ expect(noteBody).toContain('src/foo.ts');
+ });
+
+ test('fallback note body includes the line range', async () => {
+ const multiLineReviews: sourcebot_file_diff_review[] = [
+ {
+ filename: 'src/bar.ts',
+ reviews: [{ line_start: 10, line_end: 20, review: 'Refactor this block' }],
+ },
+ ];
+ const client = makeMockClient('reject');
+
+ await gitlabPushMrReviews(client, 101, MOCK_PAYLOAD, multiLineReviews);
+
+ const noteBody = client.MergeRequestNotes.create.mock.calls[0][2] as string;
+ expect(noteBody).toContain('10');
+ expect(noteBody).toContain('20');
+ expect(noteBody).toContain('Refactor this block');
+ });
+
+ test('returns early and does not post when diff_refs is missing', async () => {
+ const payloadWithoutRefs: sourcebot_pr_payload = { ...MOCK_PAYLOAD, diff_refs: undefined };
+ const client = makeMockClient();
+
+ await gitlabPushMrReviews(client, 101, payloadWithoutRefs, SINGLE_REVIEW);
+
+ expect(client.MergeRequestDiscussions.create).not.toHaveBeenCalled();
+ expect(client.MergeRequestNotes.create).not.toHaveBeenCalled();
+ });
+
+ test('posts multiple reviews across multiple files', async () => {
+ const multiFileReviews: sourcebot_file_diff_review[] = [
+ {
+ filename: 'src/a.ts',
+ reviews: [
+ { line_start: 1, line_end: 1, review: 'Comment A1' },
+ { line_start: 5, line_end: 5, review: 'Comment A2' },
+ ],
+ },
+ {
+ filename: 'src/b.ts',
+ reviews: [{ line_start: 3, line_end: 3, review: 'Comment B1' }],
+ },
+ ];
+ const client = makeMockClient();
+
+ await gitlabPushMrReviews(client, 101, MOCK_PAYLOAD, multiFileReviews);
+
+ expect(client.MergeRequestDiscussions.create).toHaveBeenCalledTimes(3);
+ });
+
+ test('continues posting remaining reviews when one inline comment fails', async () => {
+ const twoReviews: sourcebot_file_diff_review[] = [
+ {
+ filename: 'src/foo.ts',
+ reviews: [
+ { line_start: 1, line_end: 1, review: 'First comment' },
+ { line_start: 10, line_end: 10, review: 'Second comment' },
+ ],
+ },
+ ];
+ const mockCreate = vi.fn()
+ .mockRejectedValueOnce(new Error('400'))
+ .mockResolvedValueOnce({});
+ const client = {
+ MergeRequestDiscussions: { create: mockCreate },
+ MergeRequestNotes: { create: vi.fn().mockResolvedValue({}) },
+ } as any;
+
+ await gitlabPushMrReviews(client, 101, MOCK_PAYLOAD, twoReviews);
+
+ expect(mockCreate).toHaveBeenCalledTimes(2);
+ // First failed → fallback note; second succeeded → no note
+ expect(client.MergeRequestNotes.create).toHaveBeenCalledOnce();
+ });
+
+ test('does not throw when both discussion and note creation fail', async () => {
+ const client = {
+ MergeRequestDiscussions: { create: vi.fn().mockRejectedValue(new Error('500')) },
+ MergeRequestNotes: { create: vi.fn().mockRejectedValue(new Error('500')) },
+ } as any;
+
+ await expect(
+ gitlabPushMrReviews(client, 101, MOCK_PAYLOAD, SINGLE_REVIEW),
+ ).resolves.not.toThrow();
+ });
+});
diff --git a/packages/web/src/features/agents/review-agent/nodes/gitlabPushMrReviews.ts b/packages/web/src/features/agents/review-agent/nodes/gitlabPushMrReviews.ts
new file mode 100644
index 000000000..ff98ecb3c
--- /dev/null
+++ b/packages/web/src/features/agents/review-agent/nodes/gitlabPushMrReviews.ts
@@ -0,0 +1,61 @@
+import { sourcebot_pr_payload, sourcebot_file_diff_review } from "@/features/agents/review-agent/types";
+import { Gitlab } from "@gitbeaker/rest";
+import { createLogger } from "@sourcebot/shared";
+
+const logger = createLogger('gitlab-push-mr-reviews');
+
+export const gitlabPushMrReviews = async (
+ gitlabClient: InstanceType,
+ projectId: number,
+ prPayload: sourcebot_pr_payload,
+ fileDiffReviews: sourcebot_file_diff_review[],
+): Promise => {
+ logger.info("Executing gitlab_push_mr_reviews");
+
+ if (!prPayload.diff_refs) {
+ logger.error("diff_refs is missing from pr_payload, cannot post inline GitLab MR reviews");
+ return;
+ }
+
+ const { base_sha, head_sha, start_sha } = prPayload.diff_refs;
+
+ for (const fileDiffReview of fileDiffReviews) {
+ for (const review of fileDiffReview.reviews) {
+ try {
+ await gitlabClient.MergeRequestDiscussions.create(
+ projectId,
+ prPayload.number,
+ review.review,
+ {
+ position: {
+ positionType: "text",
+ baseSha: base_sha,
+ headSha: head_sha,
+ startSha: start_sha,
+ newPath: fileDiffReview.filename,
+ newLine: String(review.line_end),
+ },
+ },
+ );
+ } catch (error) {
+ // Inline comment failed (e.g. line not in diff) — fall back to a general MR note
+ logger.warn(
+ `Inline comment failed for ${fileDiffReview.filename}:${review.line_start}-${review.line_end}, falling back to general note: ${error}`,
+ );
+ try {
+ await gitlabClient.MergeRequestNotes.create(
+ projectId,
+ prPayload.number,
+ `**${fileDiffReview.filename}** (lines ${review.line_start}–${review.line_end}):\n\n${review.review}`,
+ );
+ } catch (fallbackError) {
+ logger.error(
+ `Error posting fallback note for ${fileDiffReview.filename}: ${fallbackError}`,
+ );
+ }
+ }
+ }
+ }
+
+ logger.info("Completed gitlab_push_mr_reviews");
+};
diff --git a/packages/web/src/features/agents/review-agent/nodes/invokeDiffReviewLlm.ts b/packages/web/src/features/agents/review-agent/nodes/invokeDiffReviewLlm.ts
index f3f41be80..7701c4de6 100644
--- a/packages/web/src/features/agents/review-agent/nodes/invokeDiffReviewLlm.ts
+++ b/packages/web/src/features/agents/review-agent/nodes/invokeDiffReviewLlm.ts
@@ -1,6 +1,7 @@
-import OpenAI from "openai";
import { sourcebot_file_diff_review, sourcebot_file_diff_review_schema } from "@/features/agents/review-agent/types";
+import { getAISDKLanguageModelAndOptions, getConfiguredLanguageModels } from "@/features/chat/utils.server";
import { env } from "@sourcebot/shared";
+import { generateText } from "ai";
import fs from "fs";
import { createLogger } from "@sourcebot/shared";
@@ -8,34 +9,43 @@ const logger = createLogger('invoke-diff-review-llm');
export const invokeDiffReviewLlm = async (reviewAgentLogPath: string | undefined, prompt: string): Promise => {
logger.debug("Executing invoke_diff_review_llm");
-
- if (!env.OPENAI_API_KEY) {
- logger.error("OPENAI_API_KEY is not set, skipping review agent");
- throw new Error("OPENAI_API_KEY is not set, skipping review agent");
+
+ const models = await getConfiguredLanguageModels();
+ if (models.length === 0) {
+ throw new Error("No language models are configured");
+ }
+
+ let selectedModel = models[0];
+ if (env.REVIEW_AGENT_MODEL) {
+ const match = models.find((m) => m.displayName === env.REVIEW_AGENT_MODEL);
+ if (match) {
+ selectedModel = match;
+ } else {
+ logger.warn(`REVIEW_AGENT_MODEL="${env.REVIEW_AGENT_MODEL}" did not match any configured model displayName. Falling back to the first configured model.`);
+ }
}
-
- const openai = new OpenAI({
- apiKey: env.OPENAI_API_KEY,
- });
+
+ const { model, providerOptions, temperature } = await getAISDKLanguageModelAndOptions(selectedModel);
if (reviewAgentLogPath) {
fs.appendFileSync(reviewAgentLogPath, `\n\nPrompt:\n${prompt}`);
}
try {
- const completion = await openai.chat.completions.create({
- model: "gpt-4.1-mini",
- messages: [
- { role: "system", content: prompt }
- ]
+ const result = await generateText({
+ model,
+ system: "You are a code review assistant. Respond only with valid JSON matching the expected schema.",
+ prompt,
+ providerOptions,
+ temperature,
});
-
- const openaiResponse = completion.choices[0].message.content;
+
+ const responseText = result.text;
if (reviewAgentLogPath) {
- fs.appendFileSync(reviewAgentLogPath, `\n\nResponse:\n${openaiResponse}`);
+ fs.appendFileSync(reviewAgentLogPath, `\n\nResponse:\n${responseText}`);
}
-
- const diffReviewJson = JSON.parse(openaiResponse || '{}');
+
+ const diffReviewJson = JSON.parse(responseText || '{}');
const diffReview = sourcebot_file_diff_review_schema.safeParse(diffReviewJson);
if (!diffReview.success) {
@@ -45,7 +55,7 @@ export const invokeDiffReviewLlm = async (reviewAgentLogPath: string | undefined
logger.debug("Completed invoke_diff_review_llm");
return diffReview.data;
} catch (error) {
- logger.error('Error calling OpenAI:', error);
+ logger.error('Error invoking language model:', error);
throw error;
}
-}
\ No newline at end of file
+}
diff --git a/packages/web/src/features/agents/review-agent/types.ts b/packages/web/src/features/agents/review-agent/types.ts
index d3264fc1c..2a020d6a7 100644
--- a/packages/web/src/features/agents/review-agent/types.ts
+++ b/packages/web/src/features/agents/review-agent/types.ts
@@ -16,6 +16,13 @@ export const sourcebot_file_diff_schema = z.object({
});
export type sourcebot_file_diff = z.infer;
+export const sourcebot_diff_refs_schema = z.object({
+ base_sha: z.string(),
+ head_sha: z.string(),
+ start_sha: z.string(),
+});
+export type sourcebot_diff_refs = z.infer;
+
export const sourcebot_pr_payload_schema = z.object({
title: z.string(),
description: z.string(),
@@ -24,7 +31,8 @@ export const sourcebot_pr_payload_schema = z.object({
repo: z.string(),
file_diffs: z.array(sourcebot_file_diff_schema),
number: z.number(),
- head_sha: z.string()
+ head_sha: z.string(),
+ diff_refs: sourcebot_diff_refs_schema.optional(),
});
export type sourcebot_pr_payload = z.infer;
@@ -48,3 +56,48 @@ export const sourcebot_file_diff_review_schema = z.object({
});
export type sourcebot_file_diff_review = z.infer;
+const gitLabProjectSchema = z.object({
+ id: z.number(),
+ name: z.string(),
+ path_with_namespace: z.string(),
+ web_url: z.string(),
+ namespace: z.string(),
+});
+
+const gitLabDiffRefsSchema = z.object({
+ base_sha: z.string(),
+ head_sha: z.string(),
+ start_sha: z.string(),
+}).nullable().optional();
+
+export const gitLabMergeRequestPayloadSchema = z.object({
+ object_kind: z.string(),
+ object_attributes: z.object({
+ iid: z.number(),
+ title: z.string(),
+ description: z.string().nullable(),
+ action: z.string(),
+ last_commit: z.object({ id: z.string() }),
+ diff_refs: gitLabDiffRefsSchema,
+ }),
+ project: gitLabProjectSchema,
+});
+export type GitLabMergeRequestPayload = z.infer;
+
+export const gitLabNotePayloadSchema = z.object({
+ object_kind: z.string(),
+ object_attributes: z.object({
+ note: z.string(),
+ noteable_type: z.string(),
+ }),
+ merge_request: z.object({
+ iid: z.number(),
+ title: z.string(),
+ description: z.string().nullable(),
+ last_commit: z.object({ id: z.string() }),
+ diff_refs: gitLabDiffRefsSchema,
+ }),
+ project: gitLabProjectSchema,
+});
+export type GitLabNotePayload = z.infer;
+
diff --git a/packages/web/src/features/git/getFileSourceApi.ts b/packages/web/src/features/git/getFileSourceApi.ts
index f5787e86e..2ba2b57cb 100644
--- a/packages/web/src/features/git/getFileSourceApi.ts
+++ b/packages/web/src/features/git/getFileSourceApi.ts
@@ -7,6 +7,7 @@ import { ServiceError, notFound, fileNotFound, invalidGitRef, unexpectedError }
import { getCodeHostBrowseFileAtBranchUrl } from '@/lib/utils';
import { withOptionalAuth } from '@/middleware/withAuth';
import { env, getRepoPath } from '@sourcebot/shared';
+import { Org, PrismaClient } from '@sourcebot/db';
import { headers } from 'next/headers';
import simpleGit from 'simple-git';
import type z from 'zod';
@@ -17,18 +18,15 @@ export { fileSourceRequestSchema, fileSourceResponseSchema } from './schemas';
export type FileSourceRequest = z.infer;
export type FileSourceResponse = z.infer;
-export const getFileSource = async ({ path: filePath, repo: repoName, ref }: FileSourceRequest, { source }: { source?: string } = {}): Promise => sew(() => withOptionalAuth(async ({ org, prisma, user }) => {
- if (user) {
- const resolvedSource = source ?? (await headers()).get('X-Sourcebot-Client-Source') ?? undefined;
- getAuditService().createAudit({
- action: 'user.fetched_file_source',
- actor: { id: user.id, type: 'user' },
- target: { id: org.id.toString(), type: 'org' },
- orgId: org.id,
- metadata: { source: resolvedSource },
- });
- }
-
+/**
+ * Fetches file source without an auth layer. Intended for privileged server-side
+ * callers (e.g. the review agent webhook handler) that have already been
+ * authenticated via their own mechanism and need direct repo access.
+ */
+export const getFileSourceForRepo = async (
+ { path: filePath, repo: repoName, ref }: FileSourceRequest,
+ { org, prisma }: { org: Org; prisma: PrismaClient },
+): Promise => {
const repo = await prisma.repo.findFirst({
where: { name: repoName, orgId: org.id },
});
@@ -47,9 +45,7 @@ export const getFileSource = async ({ path: filePath, repo: repoName, ref }: Fil
const { path: repoPath } = getRepoPath(repo);
const git = simpleGit().cwd(repoPath);
- const gitRef = ref ??
- repo.defaultBranch ??
- 'HEAD';
+ const gitRef = ref ?? repo.defaultBranch ?? 'HEAD';
let fileContent: string;
try {
@@ -102,4 +98,19 @@ export const getFileSource = async ({ path: filePath, repo: repoName, ref }: Fil
webUrl,
externalWebUrl,
} satisfies FileSourceResponse;
+};
+
+export const getFileSource = async ({ path: filePath, repo: repoName, ref }: FileSourceRequest, { source }: { source?: string } = {}): Promise => sew(() => withOptionalAuth(async ({ org, prisma, user }) => {
+ if (user) {
+ const resolvedSource = source ?? (await headers()).get('X-Sourcebot-Client-Source') ?? undefined;
+ getAuditService().createAudit({
+ action: 'user.fetched_file_source',
+ actor: { id: user.id, type: 'user' },
+ target: { id: org.id.toString(), type: 'org' },
+ orgId: org.id,
+ metadata: { source: resolvedSource },
+ });
+ }
+
+ return getFileSourceForRepo({ path: filePath, repo: repoName, ref }, { org, prisma });
}));
diff --git a/yarn.lock b/yarn.lock
index e4cfb7148..775b1b73c 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -9018,6 +9018,7 @@ __metadata:
"@codemirror/view": "npm:^6.33.0"
"@eslint/eslintrc": "npm:^3"
"@floating-ui/react": "npm:^0.27.2"
+ "@gitbeaker/rest": "npm:^40.5.1"
"@grpc/grpc-js": "npm:^1.14.1"
"@grpc/proto-loader": "npm:^0.8.0"
"@hookform/resolvers": "npm:^3.9.0"