Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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 .devcontainer/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ services:
REVIEW_AGENT_LOGGING_ENABLED: "true"
REVIEW_AGENT_AUTO_REVIEW_ENABLED: "false"
REVIEW_AGENT_REVIEW_COMMAND: review
REVIEW_AGENT_SUMMARY_ENABLED: "false"
depends_on:
- postgres
- redis
Expand Down
1 change: 1 addition & 0 deletions .env.development
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ REDIS_URL="redis://localhost:6379"
REVIEW_AGENT_LOGGING_ENABLED=true
REVIEW_AGENT_AUTO_REVIEW_ENABLED=false
REVIEW_AGENT_REVIEW_COMMAND=review
REVIEW_AGENT_SUMMARY_ENABLED=false

# Misc

Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- [EE] Added prompt caching for Ask Sourcebot. For Anthropic models, the static prompt prefix (tool definitions, system prompt, and conversation history) is marked with a cache breakpoint so it is billed at the provider's discounted cache-read rate on subsequent agent steps and follow-up turns. Toggle with `SOURCEBOT_CHAT_PROMPT_CACHING_ENABLED` (default `true`). [#1278](https://github.com/sourcebot-dev/sourcebot/pull/1278)
- [EE] Added a cached-token breakdown to the Ask Sourcebot message details, showing what share of the input tokens were served from the model provider's prompt cache. [#1278](https://github.com/sourcebot-dev/sourcebot/pull/1278)
- [Experimental] Added support for generating a summary comment to the AI Code review agent. [#1175](https://github.com/sourcebot-dev/sourcebot/pull/1175)

### Fixed
- Upgraded `protobufjs` to `^7.6.2`. [#1281](https://github.com/sourcebot-dev/sourcebot/pull/1281)
Expand Down
2 changes: 2 additions & 0 deletions docs/docs/configuration/environment-variables.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,8 @@ The following environment variables allow you to configure your Sourcebot deploy
| `REVIEW_AGENT_AUTO_REVIEW_ENABLED` | `false` | <p>Enables/disables automatic code reviews by the review agent.</p> |
| `REVIEW_AGENT_LOGGING_ENABLED` | `true` | <p>Enables/disables logging for the review agent. Logs are saved in `DATA_CACHE_DIR/review-agent`</p> |
| `REVIEW_AGENT_REVIEW_COMMAND` | `review` | <p>The command used to trigger a code review by the review agent.</p> |
| `REVIEW_AGENT_SUMMARY_ENABLED` | `false` | <p>Enables/disables posting a concise summary comment on the PR/MR after each review.</p> |
| `REVIEW_AGENT_SUMMARY_MAX_LENGTH` | `250` | <p>Maximum character length of the summary comment posted by the review agent.</p> |

### Overriding environment variables from the config

Expand Down
2 changes: 2 additions & 0 deletions docs/docs/features/agents/review-agent.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -145,3 +145,5 @@ You can also trigger a review manually by commenting `/review` on any PR or MR.
| `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 |
| `REVIEW_AGENT_SUMMARY_ENABLED` | `false` | Post a concise summary comment on the PR/MR after each review |
| `REVIEW_AGENT_SUMMARY_MAX_LENGTH` | `250` | Maximum character length of the summary comment |
2 changes: 2 additions & 0 deletions packages/shared/src/env.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,8 @@ const options = {
REVIEW_AGENT_LOGGING_ENABLED: booleanSchema.default('true'),
REVIEW_AGENT_AUTO_REVIEW_ENABLED: booleanSchema.default('false'),
REVIEW_AGENT_REVIEW_COMMAND: z.string().default('review'),
REVIEW_AGENT_SUMMARY_ENABLED: booleanSchema.default('false'),
REVIEW_AGENT_SUMMARY_MAX_LENGTH: numberSchema.default(250),

ANTHROPIC_API_KEY: z.string().optional(),
ANTHROPIC_AUTH_TOKEN: z.string().optional(),
Expand Down
23 changes: 20 additions & 3 deletions packages/web/src/features/agents/review-agent/app.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { Octokit } from "octokit";
import { Gitlab } from "@gitbeaker/rest";
import { generatePrReviews } from "@/features/agents/review-agent/nodes/generatePrReview";
import { generatePrSummary } from "@/features/agents/review-agent/nodes/generatePrSummary";
import { githubPushPrReviews } from "@/features/agents/review-agent/nodes/githubPushPrReviews";
import { githubPrParser } from "@/features/agents/review-agent/nodes/githubPrParser";
import { getReviewAgentLogDir } from "@/features/agents/review-agent/nodes/invokeDiffReviewLlm";
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 { GitHubPullRequest, GitLabMergeRequestPayload, sourcebot_pr_payload } from "@/features/agents/review-agent/types";
import { env } from "@sourcebot/shared";
import path from "path";
import fs from "fs";
Expand All @@ -24,6 +25,18 @@ const rules = [

const logger = createLogger('review-agent');

async function generateSummarySafely(payload: sourcebot_pr_payload, label: string): Promise<string | undefined> {
if (!env.REVIEW_AGENT_SUMMARY_ENABLED) {
return undefined;
}
try {
return await generatePrSummary(payload);
} catch (error) {
logger.error(`Error generating ${label} summary: ${error}`);
return undefined;
}
}

function getReviewAgentLogPath(identifier: string): string | undefined {
if (!env.REVIEW_AGENT_LOGGING_ENABLED) {
return undefined;
Expand Down Expand Up @@ -55,7 +68,9 @@ export async function processGitHubPullRequest(octokit: Octokit, pullRequest: Gi

const prPayload = await githubPrParser(octokit, pullRequest);
const fileDiffReviews = await generatePrReviews(reviewAgentLogPath, prPayload, rules);
await githubPushPrReviews(octokit, prPayload, fileDiffReviews);

const summary = await generateSummarySafely(prPayload, "PR");
await githubPushPrReviews(octokit, prPayload, fileDiffReviews, summary);
}

export async function processGitLabMergeRequest(
Expand All @@ -70,5 +85,7 @@ export async function processGitLabMergeRequest(

const prPayload = await gitlabMrParser(gitlabClient, mrPayload, hostDomain);
const fileDiffReviews = await generatePrReviews(reviewAgentLogPath, prPayload, rules);
await gitlabPushMrReviews(gitlabClient, projectId, prPayload, fileDiffReviews);

const summary = await generateSummarySafely(prPayload, "MR");
await gitlabPushMrReviews(gitlabClient, projectId, prPayload, fileDiffReviews, summary);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { sourcebot_pr_payload } from "@/features/agents/review-agent/types";
import { getAISDKLanguageModelAndOptions, getConfiguredLanguageModels } from "@/features/chat/utils.server";
import { env } from "@sourcebot/shared";
import { generateText } from "ai";
import { createLogger } from "@sourcebot/shared";

const logger = createLogger('generate-pr-summary');

export const generatePrSummary = async (prPayload: sourcebot_pr_payload): Promise<string> => {
const maxSummaryLength = env.REVIEW_AGENT_SUMMARY_MAX_LENGTH;
logger.debug("Executing generate_pr_summary");

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 { model, providerOptions, temperature } = await getAISDKLanguageModelAndOptions(selectedModel);

const filesChanged = prPayload.file_diffs.map(f => f.to).join(", ");

const prompt = `Summarize the following pull request changes in ${maxSummaryLength} characters or fewer. Be concise and focus on what changed and why. You may use inline markdown (e.g. \`code\`, **bold**) but avoid headers, bullet lists, and block-level formatting.

PR Title: ${prPayload.title}
PR Description: ${prPayload.description}
Files changed: ${filesChanged}
`;

const result = await generateText({
model,
system: `You are a code review assistant. Generate a concise markdown-compatible summary of pull request changes. The summary must be ${maxSummaryLength} characters or fewer. Avoid headers, bullet lists, and block-level formatting.`,
prompt,
providerOptions,
temperature,
});

const summary = result.text.trim().slice(0, maxSummaryLength);

logger.debug("Completed generate_pr_summary");
return summary;
};
Original file line number Diff line number Diff line change
Expand Up @@ -30,16 +30,23 @@ const SINGLE_REVIEW: sourcebot_file_diff_review[] = [
},
];

function makeMockOctokit(createReviewCommentResult: 'resolve' | 'reject' = 'resolve') {
return {
function makeMockOctokit(createReviewCommentResult: 'resolve' | 'reject' = 'resolve', existingComments: { id: number; body: string }[] = []) {
const octokit = {
paginate: vi.fn().mockResolvedValue(existingComments),
rest: {
pulls: {
createReviewComment: createReviewCommentResult === 'resolve'
? vi.fn().mockResolvedValue({})
: vi.fn().mockRejectedValue(new Error('Unprocessable Entity')),
},
issues: {
listComments: vi.fn(),
createComment: vi.fn().mockResolvedValue({}),
updateComment: vi.fn().mockResolvedValue({}),
},
},
} as unknown as Octokit;
return octokit;
}

describe('githubPushPrReviews', () => {
Expand Down Expand Up @@ -147,3 +154,61 @@ describe('githubPushPrReviews', () => {
expect(octokit.rest.pulls.createReviewComment).not.toHaveBeenCalled();
});
});

describe('githubPushPrReviews – summary comment', () => {
const SUMMARY_MARKER = '<!-- sourcebot-review-summary -->';

test('does not call issues API when summary is undefined', async () => {
const octokit = makeMockOctokit();

await githubPushPrReviews(octokit, MOCK_PAYLOAD, [], undefined);

expect(octokit.paginate).not.toHaveBeenCalled();
expect(octokit.rest.issues.createComment).not.toHaveBeenCalled();
expect(octokit.rest.issues.updateComment).not.toHaveBeenCalled();
});

test('creates a new comment including the marker when no existing comment found', async () => {
const octokit = makeMockOctokit('resolve', []);

await githubPushPrReviews(octokit, MOCK_PAYLOAD, [], 'Summary text');

expect(octokit.paginate).toHaveBeenCalledWith(
octokit.rest.issues.listComments,
{ owner: 'my-org', repo: 'my-repo', issue_number: 7 },
);
expect(octokit.rest.issues.createComment).toHaveBeenCalledOnce();
const body = octokit.rest.issues.createComment.mock.calls[0][0].body as string;
expect(body).toContain(SUMMARY_MARKER);
expect(body).toContain('Summary text');
expect(body).toContain('Created:');
expect(body).not.toContain('Updated:');
expect(octokit.rest.issues.updateComment).not.toHaveBeenCalled();
});

test('updates the existing comment when the marker is already present', async () => {
const existingComments = [{ id: 99, body: `${SUMMARY_MARKER}\nOld summary` }];
const octokit = makeMockOctokit('resolve', existingComments);

await githubPushPrReviews(octokit, MOCK_PAYLOAD, [], 'New summary');

expect(octokit.rest.issues.updateComment).toHaveBeenCalledOnce();
expect(octokit.rest.issues.updateComment).toHaveBeenCalledWith(
expect.objectContaining({ comment_id: 99 }),
);
const body = octokit.rest.issues.updateComment.mock.calls[0][0].body as string;
expect(body).toContain('New summary');
expect(body).toContain('Updated:');
expect(body).not.toContain('Created:');
expect(octokit.rest.issues.createComment).not.toHaveBeenCalled();
});

test('does not throw when paginate fails', async () => {
const octokit = makeMockOctokit();
octokit.paginate = vi.fn().mockRejectedValue(new Error('403 Forbidden'));

await expect(
githubPushPrReviews(octokit, MOCK_PAYLOAD, [], 'Summary text'),
).resolves.not.toThrow();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,40 @@ import { createLogger } from "@sourcebot/shared";

const logger = createLogger('github-push-pr-reviews');

export const githubPushPrReviews = async (octokit: Octokit, pr_payload: sourcebot_pr_payload, file_diff_reviews: sourcebot_file_diff_review[]) => {
export const githubPushPrReviews = async (octokit: Octokit, pr_payload: sourcebot_pr_payload, file_diff_reviews: sourcebot_file_diff_review[], summary?: string) => {
logger.info("Executing github_push_pr_reviews");

if (summary) {
const SUMMARY_MARKER = "<!-- sourcebot-review-summary -->";
try {
const comments = await octokit.paginate(octokit.rest.issues.listComments, {
owner: pr_payload.owner,
repo: pr_payload.repo,
issue_number: pr_payload.number,
});
const existing = comments.find(c => c.body?.includes(SUMMARY_MARKER));
const action = existing ? "Updated" : "Created";
const body = `${SUMMARY_MARKER}\n${summary}\n\n---\n*${action}: ${new Date().toUTCString()}*`;
if (existing) {
await octokit.rest.issues.updateComment({
owner: pr_payload.owner,
repo: pr_payload.repo,
comment_id: existing.id,
body,
});
} else {
await octokit.rest.issues.createComment({
owner: pr_payload.owner,
repo: pr_payload.repo,
issue_number: pr_payload.number,
body,
});
}
} catch (error) {
logger.error(`Error posting PR summary comment: ${error}`);
}
}

try {
for (const file_diff_review of file_diff_reviews) {
for (const review of file_diff_review.reviews) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,15 +37,17 @@ const SINGLE_REVIEW: sourcebot_file_diff_review[] = [
},
];

function makeMockClient(discussionResult: 'resolve' | 'reject' = 'resolve') {
function makeMockClient(discussionResult: 'resolve' | 'reject' = 'resolve', existingNotes: { id: number; body: string }[] = []) {
return {
MergeRequestDiscussions: {
create: discussionResult === 'resolve'
? vi.fn().mockResolvedValue({})
: vi.fn().mockRejectedValue(new Error('400 Bad Request')),
},
MergeRequestNotes: {
all: vi.fn().mockResolvedValue(existingNotes),
create: vi.fn().mockResolvedValue({}),
edit: vi.fn().mockResolvedValue({}),
},
} as unknown as GitlabClient;
}
Expand Down Expand Up @@ -180,11 +182,63 @@ describe('gitlabPushMrReviews', () => {
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')) },
MergeRequestNotes: { all: vi.fn().mockResolvedValue([]), create: vi.fn().mockRejectedValue(new Error('500')), edit: vi.fn() },
} as unknown as GitlabClient;

await expect(
gitlabPushMrReviews(client, 101, MOCK_PAYLOAD, SINGLE_REVIEW),
).resolves.not.toThrow();
});
});

describe('gitlabPushMrReviews – summary note', () => {
const SUMMARY_MARKER = '<!-- sourcebot-review-summary -->';

test('does not call MergeRequestNotes API when summary is undefined', async () => {
const client = makeMockClient();

await gitlabPushMrReviews(client, 101, MOCK_PAYLOAD, [], undefined);

expect(client.MergeRequestNotes.all).not.toHaveBeenCalled();
expect(client.MergeRequestNotes.create).not.toHaveBeenCalled();
expect(client.MergeRequestNotes.edit).not.toHaveBeenCalled();
});

test('creates a new note including the marker when no existing note found', async () => {
const client = makeMockClient('resolve', []);

await gitlabPushMrReviews(client, 101, MOCK_PAYLOAD, [], 'Summary text');

expect(client.MergeRequestNotes.all).toHaveBeenCalledWith(101, 42);
expect(client.MergeRequestNotes.create).toHaveBeenCalledOnce();
const body = client.MergeRequestNotes.create.mock.calls[0][2] as string;
expect(body).toContain(SUMMARY_MARKER);
expect(body).toContain('Summary text');
expect(body).toContain('Created:');
expect(body).not.toContain('Updated:');
expect(client.MergeRequestNotes.edit).not.toHaveBeenCalled();
});

test('updates the existing note when the marker is already present', async () => {
const existingNotes = [{ id: 55, body: `${SUMMARY_MARKER}\nOld summary` }];
const client = makeMockClient('resolve', existingNotes);

await gitlabPushMrReviews(client, 101, MOCK_PAYLOAD, [], 'New summary');

expect(client.MergeRequestNotes.edit).toHaveBeenCalledOnce();
const editOptions = client.MergeRequestNotes.edit.mock.calls[0][3] as { body: string };
expect(editOptions.body).toContain('New summary');
expect(editOptions.body).toContain('Updated:');
expect(editOptions.body).not.toContain('Created:');
expect(client.MergeRequestNotes.create).not.toHaveBeenCalled();
});

test('does not throw when MergeRequestNotes.all fails', async () => {
const client = makeMockClient();
client.MergeRequestNotes.all = vi.fn().mockRejectedValue(new Error('403 Forbidden'));

await expect(
gitlabPushMrReviews(client, 101, MOCK_PAYLOAD, [], 'Summary text'),
).resolves.not.toThrow();
});
});
Loading
Loading