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 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
### Fixed
- Fixed incorrect shutdown of PostHog SDK in the worker. [#609](https://github.com/sourcebot-dev/sourcebot/pull/609)
- Fixed race condition in job schedulers. [#607](https://github.com/sourcebot-dev/sourcebot/pull/607)
- Fixed review agent so that it works with GHES instances [#611](https://github.com/sourcebot-dev/sourcebot/pull/611)

## [4.9.1] - 2025-11-07

Expand Down
74 changes: 62 additions & 12 deletions packages/web/src/app/api/(server)/webhook/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,42 +6,88 @@ import { WebhookEventDefinition} from "@octokit/webhooks/types";
import { EndpointDefaults } from "@octokit/types";
import { env } from "@sourcebot/shared";
import { processGitHubPullRequest } from "@/features/agents/review-agent/app";
import { throttling } from "@octokit/plugin-throttling";
import { throttling, type ThrottlingOptions } from "@octokit/plugin-throttling";
import fs from "fs";
import { GitHubPullRequest } from "@/features/agents/review-agent/types";
import { createLogger } from "@sourcebot/shared";

const logger = createLogger('github-webhook');

let githubApp: App | undefined;
const DEFAULT_GITHUB_API_BASE_URL = "https://api.github.com";
type GitHubAppBaseOptions = Omit<ConstructorParameters<typeof App>[0], "Octokit"> & { throttle: ThrottlingOptions };

let githubAppBaseOptions: GitHubAppBaseOptions | undefined;
const githubAppCache = new Map<string, App>();

if (env.GITHUB_REVIEW_AGENT_APP_ID && env.GITHUB_REVIEW_AGENT_APP_WEBHOOK_SECRET && env.GITHUB_REVIEW_AGENT_APP_PRIVATE_KEY_PATH) {
try {
const privateKey = fs.readFileSync(env.GITHUB_REVIEW_AGENT_APP_PRIVATE_KEY_PATH, "utf8");

const throttledOctokit = Octokit.plugin(throttling);
githubApp = new App({
githubAppBaseOptions = {
appId: env.GITHUB_REVIEW_AGENT_APP_ID,
privateKey: privateKey,
privateKey,
webhooks: {
secret: env.GITHUB_REVIEW_AGENT_APP_WEBHOOK_SECRET,
},
Octokit: throttledOctokit,
throttle: {
onRateLimit: (retryAfter: number, options: Required<EndpointDefaults>, octokit: Octokit, retryCount: number) => {
enabled: true,
onRateLimit: (retryAfter, _options, _octokit, retryCount) => {
if (retryCount > 3) {
logger.warn(`Rate limit exceeded: ${retryAfter} seconds`);
return false;
}

return true;
},
}
});
onSecondaryRateLimit: (_retryAfter, options) => {
// no retries on secondary rate limits
logger.warn(`SecondaryRateLimit detected for ${options.method} ${options.url}`);
}
},
};
} catch (error) {
logger.error(`Error initializing GitHub app: ${error}`);
}
}

const normalizeGithubApiBaseUrl = (baseUrl?: string) => {
if (!baseUrl) {
return DEFAULT_GITHUB_API_BASE_URL;
}

return baseUrl.replace(/\/+$/, "");
};

const resolveGithubApiBaseUrl = (headers: Record<string, string>) => {
const enterpriseHost = headers["x-github-enterprise-host"];
if (enterpriseHost) {
return normalizeGithubApiBaseUrl(`https://${enterpriseHost}/api/v3`);
}

return DEFAULT_GITHUB_API_BASE_URL;
};

const getGithubAppForBaseUrl = (baseUrl: string) => {
if (!githubAppBaseOptions) {
return undefined;
}

const normalizedBaseUrl = normalizeGithubApiBaseUrl(baseUrl);
const cachedApp = githubAppCache.get(normalizedBaseUrl);
if (cachedApp) {
return cachedApp;
}

const OctokitWithBaseUrl = Octokit.plugin(throttling).defaults({ baseUrl: normalizedBaseUrl });
const app = new App({
...githubAppBaseOptions,
Octokit: OctokitWithBaseUrl,
});

githubAppCache.set(normalizedBaseUrl, app);
return app;
};

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");
}
Expand All @@ -52,12 +98,16 @@ function isIssueCommentEvent(eventHeader: string, payload: unknown): payload is

export const POST = async (request: NextRequest) => {
const body = await request.json();
const headers = Object.fromEntries(request.headers.entries());
const headers = Object.fromEntries(Array.from(request.headers.entries(), ([key, value]) => [key.toLowerCase(), value]));

const githubEvent = headers['x-github-event'] || headers['X-GitHub-Event'];
const githubEvent = headers['x-github-event'];
if (githubEvent) {
logger.info('GitHub event received:', githubEvent);

const githubApiBaseUrl = resolveGithubApiBaseUrl(headers);
logger.debug('Using GitHub API base URL for event', { githubApiBaseUrl });
const githubApp = getGithubAppForBaseUrl(githubApiBaseUrl);

if (!githubApp) {
logger.warn('Received GitHub webhook event but GitHub app env vars are not set');
return Response.json({ status: 'ok' });
Expand Down Expand Up @@ -113,4 +163,4 @@ export const POST = async (request: NextRequest) => {
}

return Response.json({ status: 'ok' });
}
}
Loading