Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
8 changes: 7 additions & 1 deletion .env.development
Original file line number Diff line number Diff line change
Expand Up @@ -78,4 +78,10 @@ SOURCEBOT_TELEMETRY_DISABLED=true # Disables telemetry collection
# NODE_ENV=
# SOURCEBOT_TENANCY_MODE=single

# NEXT_PUBLIC_SOURCEBOT_CLOUD_ENVIRONMENT=
# NEXT_PUBLIC_SOURCEBOT_CLOUD_ENVIRONMENT=

# Used for agents
# GITHUB_APP_ID=
# GITHUB_APP_PRIVATE_KEY_PATH=
# GITHUB_APP_WEBHOOK_SECRET=
# OPENAI_API_KEY=
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
{
"private": true,
"workspaces": [
"packages/*"
"packages/*",
"packages/agents/*"
],
"scripts": {
"build": "cross-env SKIP_ENV_VALIDATION=1 yarn workspaces foreach -A run build",
Expand All @@ -11,6 +12,7 @@
"dev:zoekt": "yarn with-env zoekt-webserver -index .sourcebot/index -rpc",
"dev:backend": "yarn with-env yarn workspace @sourcebot/backend dev:watch",
"dev:web": "yarn with-env yarn workspace @sourcebot/web dev",
"dev:review-agent": "yarn with-env yarn workspace @sourcebot/review-agent dev",
"dev:prisma:migrate:dev": "yarn with-env yarn workspace @sourcebot/db prisma:migrate:dev",
"dev:prisma:studio": "yarn with-env yarn workspace @sourcebot/db prisma:studio",
"dev:prisma:migrate:reset": "yarn with-env yarn workspace @sourcebot/db prisma:migrate:reset",
Expand Down
6 changes: 5 additions & 1 deletion packages/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,9 @@
"next-auth": "^5.0.0-beta.25",
"next-themes": "^0.3.0",
"nodemailer": "^6.10.0",
"octokit": "^4.1.3",
"openai": "^4.98.0",
"parse-diff": "^0.11.1",
"posthog-js": "^1.161.5",
"pretty-bytes": "^6.1.1",
"psl": "^1.15.0",
Expand All @@ -135,7 +138,8 @@
"tailwind-merge": "^2.5.2",
"tailwindcss-animate": "^1.0.7",
"usehooks-ts": "^3.1.0",
"zod": "^3.24.2"
"zod": "^3.24.2",
"zod-to-json-schema": "^3.24.5"
},
"devDependencies": {
"@types/micromatch": "^4.0.9",
Expand Down
70 changes: 70 additions & 0 deletions packages/web/src/app/[domain]/agents/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { Header } from "../components/header";
import Link from "next/link";
import Image from "next/image";
import { NavigationMenu } from "../components/navigationMenu";
import { FaRobot, FaCogs } from "react-icons/fa";
import { MdRocketLaunch } from "react-icons/md";

const agents = [
{
id: "review-agent",
name: "Review Agent",
description: "An agent that reviews your PRs. Uses the code indexed on Sourcebot to provide codebase wide context.",
deployUrl: "/agents/review-agent/deploy",
configureUrl: "/agents/review-agent/configure",
},
// Add more agents here as needed
];

export default function AgentsPage({ params: { domain } }: { params: { domain: string } }) {
return (
<div className="flex flex-col items-center overflow-hidden min-h-screen">
<NavigationMenu domain={domain} />
<div className="w-full max-w-6xl px-4 mt-12 mb-24">
<div
className={
agents.length === 1
? "flex justify-center items-center min-h-[60vh]"
: "grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-10"
}
>
{agents.map((agent) => (
<div
key={agent.id}
className={
agents.length === 1
? "relative flex flex-col items-center border border-border rounded-2xl p-8 bg-card shadow-xl w-full max-w-xl"
: "relative flex flex-col items-center border border-border rounded-2xl p-8 bg-card shadow-xl"
}
>
{/* Name and description */}
<div className="flex flex-col items-center w-full">
<h2 className="font-bold text-2xl mb-4 mt-2 text-center text-foreground drop-shadow-sm">
{agent.name}
</h2>
<p className="text-base text-muted-foreground text-center mb-4 min-h-[56px]">
{agent.description}
</p>
</div>
{/* Actions */}
<div className="flex flex-row gap-4 justify-center w-full mt-2">
<Link
href={agent.deployUrl}
className="flex items-center justify-center gap-2 px-5 py-2.5 rounded-md bg-primary text-primary-foreground font-mono font-semibold text-base border border-primary shadow-sm hover:bg-primary/80 focus:outline-none focus:ring-2 focus:ring-primary/60 transition w-1/2"
>
<MdRocketLaunch className="text-lg" /> Deploy
</Link>
<Link
href={agent.configureUrl}
className="flex items-center justify-center gap-2 px-5 py-2.5 rounded-md bg-muted text-foreground font-mono font-semibold text-base border border-border shadow-sm hover:bg-card/80 focus:outline-none focus:ring-2 focus:ring-border/60 transition w-1/2"
>
<FaCogs className="text-lg" /> Configure
</Link>
</div>
</div>
))}
</div>
</div>
</div>
);
}
3 changes: 2 additions & 1 deletion packages/web/src/app/[domain]/browse/[...path]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import { TopBar } from "@/app/[domain]/components/topBar";
import { Separator } from '@/components/ui/separator';
import { getFileSource } from '@/features/search/fileSourceApi';
import { listRepositories } from '@/features/search/listReposApi';
import { base64Decode, isServiceError } from "@/lib/utils";
import { isServiceError } from "@/lib/utils";
import { base64Decode } from "@/lib/utils";
import { CodePreview } from "./codePreview";
import { ErrorCode } from "@/lib/errorCodes";
import { LuFileX2, LuBookX } from "react-icons/lu";
Expand Down
57 changes: 57 additions & 0 deletions packages/web/src/app/api/(server)/webhook/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
'use server';

import { NextRequest } from "next/server";
import { App } from "octokit";
import { WebhookEventDefinition } from "@octokit/webhooks/types";
import { env } from "@/env.mjs";
import { processGitHubPullRequest } from "@/features/agents/review-agent/app";
import fs from "fs";

let githubApp: App | undefined;
if (env.GITHUB_APP_ID && env.GITHUB_APP_WEBHOOK_SECRET && env.GITHUB_APP_PRIVATE_KEY_PATH) {
try {
const privateKey = fs.readFileSync(env.GITHUB_APP_PRIVATE_KEY_PATH, "utf8");
githubApp = new App({
appId: env.GITHUB_APP_ID,
privateKey: privateKey,
webhooks: {
secret: env.GITHUB_APP_WEBHOOK_SECRET,
},
});
} catch (error) {
console.error(`Error initializing GitHub app: ${error}`);
}
}

function isPullRequestEvent(eventHeader: string, payload: unknown): payload is WebhookEventDefinition<"pull-request-opened"> | WebhookEventDefinition<"pull-request-synchronize"> {
return eventHeader === "pull_request" && typeof payload === "object" && payload !== null && "action" in payload && typeof payload.action === "string" && (payload.action === "opened" || payload.action === "synchronize");
}

export const POST = async (request: NextRequest) => {
const body = await request.json();
const headers = Object.fromEntries(request.headers.entries());

const githubEvent = headers['x-github-event'];
if (githubEvent) {
console.log('GitHub event received:', githubEvent);

if (!githubApp) {
console.warn('Received GitHub webhook event but GitHub app env vars are not set');
return Response.json({ status: 'ok' });
}

if (isPullRequestEvent(githubEvent, body)) {
if (!body.installation) {
console.error('Received github pull request event but installation is not present');
return Response.json({ status: 'ok' });
}

const installationId = body.installation.id;
const octokit = await githubApp.getInstallationOctokit(installationId);

await processGitHubPullRequest(octokit, body);
}
}

return Response.json({ status: 'ok' });
}
6 changes: 6 additions & 0 deletions packages/web/src/env.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,12 @@ export const env = createEnv({

// EE License
SOURCEBOT_EE_LICENSE_KEY: z.string().optional(),

// GitHub app for review agent
GITHUB_APP_ID: z.string().optional(),
GITHUB_APP_WEBHOOK_SECRET: z.string().optional(),
GITHUB_APP_PRIVATE_KEY_PATH: z.string().optional(),
OPENAI_API_KEY: z.string().optional(),
},
// @NOTE: Please make sure of the following:
// - Make sure you destructure all client variables in
Expand Down
29 changes: 29 additions & 0 deletions packages/web/src/features/agents/review-agent/app.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { Octokit } from "octokit";
import { WebhookEventDefinition } from "@octokit/webhooks/types";
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 { env } from "@/env.mjs";

const rules = [
"Do NOT provide general feedback, summaries, explanations of changes, or praises for making good additions.",
"Do NOT provide any advice that is not actionable or directly related to the changes.",
"Do NOT provide any comments or reviews on code that you believe is good, correct, or a good addition. Your job is only to identify issues and provide feedback on how to fix them.",
"If a review for a chunk contains different reviews at different line ranges, return a seperate review object for each line range.",
"Focus solely on offering specific, objective insights based on the given context and refrain from making broad comments about potential impacts on the system or question intentions behind the changes.",
"Keep comments concise and to the point. Every comment must highlight a specific issue and provide a clear and actionable solution to the developer.",
"If there are no issues found on a line range, do NOT respond with any comments. This includes comments such as \"No issues found\" or \"LGTM\"."
]

export async function processGitHubPullRequest(octokit: Octokit, payload: WebhookEventDefinition<"pull-request-opened"> | WebhookEventDefinition<"pull-request-synchronize">) {
console.log(`Received a pull request event for #${payload.pull_request.number}`);

if (!env.OPENAI_API_KEY) {
console.error("OPENAI_API_KEY is not set, skipping review agent");
return;
}

const prPayload = await githubPrParser(octokit, payload);
const fileDiffReviews = await generatePrReviews(prPayload, rules);
await githubPushPrReviews(octokit, prPayload, fileDiffReviews);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { sourcebot_context, sourcebot_pr_payload } from "@/features/agents/review-agent/types";
import { fileSourceResponseSchema } from "@/features/search/schemas";
import { base64Decode } from "@/lib/utils";

export const fetchFileContent = async (pr_payload: sourcebot_pr_payload, filename: string): Promise<sourcebot_context> => {
console.log("Executing fetch_file_content");

const repoPath = pr_payload.hostDomain + "/" + pr_payload.owner + "/" + pr_payload.repo;
const fileSourceRequest = {
fileName: filename,
repository: repoPath,
}
console.log(JSON.stringify(fileSourceRequest, null, 2));

const response = await fetch('http://localhost:3000/api/source', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Org-Domain': '~'
},
body: JSON.stringify(fileSourceRequest)
});

if (!response.ok) {
throw new Error(`Failed to fetch file content for ${filename} from ${repoPath}: ${response.statusText}`);
}

const responseData = await response.json();
const fileSourceResponse = fileSourceResponseSchema.parse(responseData);
const fileContent = base64Decode(fileSourceResponse.source);

const fileContentContext: sourcebot_context = {
type: "file_content",
description: `The content of the file ${filename}`,
context: fileContent,
}

console.log("Completed fetch_file_content");
return fileContentContext;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { sourcebot_diff, sourcebot_context, sourcebot_diff_review_schema } from "@/features/agents/review-agent/types";
import { zodToJsonSchema } from "zod-to-json-schema";

export const generateDiffReviewPrompt = async (diff: sourcebot_diff, context: sourcebot_context[], rules: string[]) => {
console.log("Executing generate_diff_review_prompt");

const prompt = `
You are an expert software engineer that excells at reviewing code changes. Given the input, additional context, and rules defined below, review the code changes and provide a detailed review. The review you provide
must conform to all of the rules defined below. The output format of your review must conform to the output format defined below.

# Input

The input is the old and new code snippets, which represent a single hunk from a git diff. The old code snippet is the code before the changes were made, and the new code snippet is the code after the changes were made. Each code snippet
is a sequence of lines each with a line number.

## Old Code Snippet

\`\`\`
${diff.oldSnippet}
\`\`\`

## New Code Snippet

\`\`\`
${diff.newSnippet}
\`\`\`

# Additional Context

${context.map(c => `${c.type}: ${c.description}\n\n${c.context}`).join("\n\n----------------------\n\n")}

# Rules

- ${rules.join("\n- ")}

# Output Format (JSON Schema)
The output must be a valid JSON object that conforms to the following JSON schema. Do NOT respond with anything other than the JSON object. Do NOT respond with
the JSON object in a markdown code block.
${JSON.stringify(zodToJsonSchema(sourcebot_diff_review_schema), null, 2)}
`;

console.log("Completed generate_diff_review_prompt");
return prompt;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { sourcebot_pr_payload, sourcebot_diff_review, sourcebot_file_diff_review, sourcebot_context } from "@/features/agents/review-agent/types";
import { generateDiffReviewPrompt } from "@/features/agents/review-agent/nodes/generateDiffReviewPrompt";
import { invokeDiffReviewLlm } from "@/features/agents/review-agent/nodes/invokeDiffReviewLlm";
import { fetchFileContent } from "@/features/agents/review-agent/nodes/fetchFileContent";

export const generatePrReviews = async (pr_payload: sourcebot_pr_payload, rules: string[]): Promise<sourcebot_file_diff_review[]> => {
console.log("Executing generate_pr_reviews");

const file_diff_reviews: sourcebot_file_diff_review[] = [];
for (const file_diff of pr_payload.file_diffs) {
const reviews: sourcebot_diff_review[] = [];

for (const diff of file_diff.diffs) {
try {
const fileContentContext = await fetchFileContent(pr_payload, file_diff.to);
const context: sourcebot_context[] = [
{
type: "pr_title",
description: "The title of the pull request",
context: pr_payload.title,
},
{
type: "pr_description",
description: "The description of the pull request",
context: pr_payload.description,
},
fileContentContext,
];

const prompt = await generateDiffReviewPrompt(diff, context, rules);

const diffReview = await invokeDiffReviewLlm(prompt);
reviews.push(diffReview);
} catch (error) {
console.error(`Error fetching file content for ${file_diff.to}: ${error}`);
}
}

if (reviews.length > 0) {
file_diff_reviews.push({
filename: file_diff.to,
reviews: reviews,
});
}
}

console.log("Completed generate_pr_reviews");
return file_diff_reviews;
}
Loading