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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added
- [Experimental] Added support for generating a summary comment to the AI Code review agent. [#1175](https://github.com/sourcebot-dev/sourcebot/pull/1175)

Comment thread
coderabbitai[bot] marked this conversation as resolved.
## [4.17.1] - 2026-05-04

### Added
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 @@ -74,6 +74,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 @@ -240,6 +240,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 @@ -29,14 +29,19 @@ const SINGLE_REVIEW: sourcebot_file_diff_review[] = [
},
];

function makeMockOctokit(createReviewCommentResult: 'resolve' | 'reject' = 'resolve') {
function makeMockOctokit(createReviewCommentResult: 'resolve' | 'reject' = 'resolve', existingComments: { id: number; body: string }[] = []) {
return {
rest: {
pulls: {
createReviewComment: createReviewCommentResult === 'resolve'
? vi.fn().mockResolvedValue({})
: vi.fn().mockRejectedValue(new Error('Unprocessable Entity')),
},
issues: {
listComments: vi.fn().mockResolvedValue({ data: existingComments }),
createComment: vi.fn().mockResolvedValue({}),
updateComment: vi.fn().mockResolvedValue({}),
},
},
} as any;
}
Expand Down Expand Up @@ -146,3 +151,62 @@ 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.rest.issues.listComments).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.rest.issues.listComments).toHaveBeenCalledWith({
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 listComments fails', async () => {
const octokit = makeMockOctokit();
octokit.rest.issues.listComments = 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 { data: comments } = await 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()}*`;
Comment on lines +13 to +20
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

cat -n packages/web/src/features/agents/review-agent/nodes/githubPushPrReviews.ts

Repository: sourcebot-dev/sourcebot

Length of output: 3544


🌐 Web query:

octokit rest issues listComments pagination behavior

💡 Result:

For the Octokit REST endpoint that lists issue comments (GET /repos/{owner}/{repo}/issues/{issue_number}/comments), pagination works via GitHub’s standard REST pagination parameters (page, per_page) and the Link response header. Octokit’s pagination helpers follow that Link header. 1) REST endpoint (what Octokit is calling) - The endpoint supports per_page (max 100) and page, with defaults per_page=30 and page=1.[1] - Issue comments are ordered by ascending ID.[1] 2) How pagination is signaled (Link header) - When a response is paginated, GitHub includes a link header containing URLs for next/prev/first/last pages.[2] - If the endpoint does not support pagination or everything fits on one page, the link header is omitted.[2] 3) Octokit pagination behavior (what to expect in code) - Octokit’s REST pagination plugin (octokit.paginate from @octokit/plugin-paginate-rest) paginates “List …” endpoints by repeatedly issuing requests as long as a rel="next" URL is present in the response Link header.[3] - If the response lacks the Link header, octokit.paginate resolves with an array based on the single response.[3] 4) Practical pagination options - With the endpoint directly (octokit.rest.issues.listComments), set per_page and page yourself; per_page max is 100.[1] - With Octokit’s paginator, pass per_page=100 to reduce number of requests; it will continue automatically using rel="next" links.[3][1] 5) Small gotcha: streaming - An Octokit discussion indicates that streaming (custom Accept header / streaming assets) is not supported for listComments; use the normal JSON pagination approach instead.[4] Example usage (paginating all comments for an issue) - Iterate pages automatically: - const comments = await octokit.paginate(octokit.rest.issues.listComments, { owner, repo, issue_number, per_page: 100 }); (Relies on the Link header rel="next" mechanism described by the paginator plugin.[3])

Citations:


Fetch all issue-comment pages before deciding create vs. update.

octokit.rest.issues.listComments() returns only the first page (default 30 comments), so the existing lookup at line 18 can miss a summary comment on later pages. Use octokit.paginate(octokit.rest.issues.listComments, {...}) or manually iterate through pages to reliably find existing comments and avoid creating duplicates.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/web/src/features/agents/review-agent/nodes/githubPushPrReviews.ts`
around lines 13 - 20, The code uses octokit.rest.issues.listComments and then
searches for SUMMARY_MARKER which only checks the first page of comments; update
the logic to fetch all comment pages (e.g. use
octokit.paginate(octokit.rest.issues.listComments, { owner: pr_payload.owner,
repo: pr_payload.repo, issue_number: pr_payload.number })) and then run the
existing = comments.find(...) check against the full aggregated list so you
reliably detect an existing SUMMARY_MARKER and avoid duplicate creates; keep the
rest of the body/ action logic (SUMMARY_MARKER, action, body) unchanged.

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 @@ -34,15 +34,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 any;
}
Expand Down Expand Up @@ -177,11 +179,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 any;

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