2025-05-12 19:10:01 +00:00
|
|
|
'use server';
|
|
|
|
|
|
|
|
|
|
import { NextRequest } from "next/server";
|
|
|
|
|
import { App, Octokit } from "octokit";
|
|
|
|
|
import { WebhookEventDefinition} from "@octokit/webhooks/types";
|
|
|
|
|
import { EndpointDefaults } from "@octokit/types";
|
2025-11-05 05:22:31 +00:00
|
|
|
import { env } from "@sourcebot/shared";
|
2025-05-12 19:10:01 +00:00
|
|
|
import { processGitHubPullRequest } from "@/features/agents/review-agent/app";
|
2025-12-05 06:08:24 +00:00
|
|
|
import { throttling, type ThrottlingOptions } from "@octokit/plugin-throttling";
|
2025-05-12 19:10:01 +00:00
|
|
|
import fs from "fs";
|
|
|
|
|
import { GitHubPullRequest } from "@/features/agents/review-agent/types";
|
2025-11-05 05:22:31 +00:00
|
|
|
import { createLogger } from "@sourcebot/shared";
|
2025-06-02 18:16:01 +00:00
|
|
|
|
|
|
|
|
const logger = createLogger('github-webhook');
|
2025-05-12 19:10:01 +00:00
|
|
|
|
2025-12-05 06:08:24 +00:00
|
|
|
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>();
|
|
|
|
|
|
2025-10-22 03:17:28 +00:00
|
|
|
if (env.GITHUB_REVIEW_AGENT_APP_ID && env.GITHUB_REVIEW_AGENT_APP_WEBHOOK_SECRET && env.GITHUB_REVIEW_AGENT_APP_PRIVATE_KEY_PATH) {
|
2025-05-12 19:10:01 +00:00
|
|
|
try {
|
2025-10-22 03:17:28 +00:00
|
|
|
const privateKey = fs.readFileSync(env.GITHUB_REVIEW_AGENT_APP_PRIVATE_KEY_PATH, "utf8");
|
2025-05-12 19:10:01 +00:00
|
|
|
|
2025-12-05 06:08:24 +00:00
|
|
|
githubAppBaseOptions = {
|
2025-10-22 03:17:28 +00:00
|
|
|
appId: env.GITHUB_REVIEW_AGENT_APP_ID,
|
2025-12-05 06:08:24 +00:00
|
|
|
privateKey,
|
2025-05-12 19:10:01 +00:00
|
|
|
webhooks: {
|
2025-10-22 03:17:28 +00:00
|
|
|
secret: env.GITHUB_REVIEW_AGENT_APP_WEBHOOK_SECRET,
|
2025-05-12 19:10:01 +00:00
|
|
|
},
|
|
|
|
|
throttle: {
|
2025-12-05 06:08:24 +00:00
|
|
|
enabled: true,
|
|
|
|
|
onRateLimit: (retryAfter, _options, _octokit, retryCount) => {
|
2025-05-12 19:10:01 +00:00
|
|
|
if (retryCount > 3) {
|
2025-06-02 18:16:01 +00:00
|
|
|
logger.warn(`Rate limit exceeded: ${retryAfter} seconds`);
|
2025-05-12 19:10:01 +00:00
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return true;
|
|
|
|
|
},
|
2025-12-05 06:08:24 +00:00
|
|
|
onSecondaryRateLimit: (_retryAfter, options) => {
|
|
|
|
|
// no retries on secondary rate limits
|
|
|
|
|
logger.warn(`SecondaryRateLimit detected for ${options.method} ${options.url}`);
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
};
|
2025-05-12 19:10:01 +00:00
|
|
|
} catch (error) {
|
2025-06-02 18:16:01 +00:00
|
|
|
logger.error(`Error initializing GitHub app: ${error}`);
|
2025-05-12 19:10:01 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-05 06:08:24 +00:00
|
|
|
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;
|
|
|
|
|
};
|
|
|
|
|
|
2025-05-12 19:10:01 +00:00
|
|
|
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");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function isIssueCommentEvent(eventHeader: string, payload: unknown): payload is WebhookEventDefinition<"issue-comment-created"> {
|
|
|
|
|
return eventHeader === "issue_comment" && typeof payload === "object" && payload !== null && "action" in payload && typeof payload.action === "string" && payload.action === "created";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export const POST = async (request: NextRequest) => {
|
|
|
|
|
const body = await request.json();
|
2025-12-05 06:08:24 +00:00
|
|
|
const headers = Object.fromEntries(Array.from(request.headers.entries(), ([key, value]) => [key.toLowerCase(), value]));
|
2025-05-12 19:10:01 +00:00
|
|
|
|
2025-12-05 06:08:24 +00:00
|
|
|
const githubEvent = headers['x-github-event'];
|
2025-05-12 19:10:01 +00:00
|
|
|
if (githubEvent) {
|
2025-06-02 18:16:01 +00:00
|
|
|
logger.info('GitHub event received:', githubEvent);
|
2025-05-12 19:10:01 +00:00
|
|
|
|
2025-12-05 06:08:24 +00:00
|
|
|
const githubApiBaseUrl = resolveGithubApiBaseUrl(headers);
|
|
|
|
|
logger.debug('Using GitHub API base URL for event', { githubApiBaseUrl });
|
|
|
|
|
const githubApp = getGithubAppForBaseUrl(githubApiBaseUrl);
|
|
|
|
|
|
2025-05-12 19:10:01 +00:00
|
|
|
if (!githubApp) {
|
2025-06-02 18:16:01 +00:00
|
|
|
logger.warn('Received GitHub webhook event but GitHub app env vars are not set');
|
2025-05-12 19:10:01 +00:00
|
|
|
return Response.json({ status: 'ok' });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (isPullRequestEvent(githubEvent, body)) {
|
|
|
|
|
if (env.REVIEW_AGENT_AUTO_REVIEW_ENABLED === "false") {
|
2025-06-02 18:16:01 +00:00
|
|
|
logger.info('Review agent auto review (REVIEW_AGENT_AUTO_REVIEW_ENABLED) is disabled, skipping');
|
2025-05-12 19:10:01 +00:00
|
|
|
return Response.json({ status: 'ok' });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!body.installation) {
|
2025-06-02 18:16:01 +00:00
|
|
|
logger.error('Received github pull request event but installation is not present');
|
2025-05-12 19:10:01 +00:00
|
|
|
return Response.json({ status: 'ok' });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const installationId = body.installation.id;
|
|
|
|
|
const octokit = await githubApp.getInstallationOctokit(installationId);
|
|
|
|
|
|
|
|
|
|
const pullRequest = body.pull_request as GitHubPullRequest;
|
|
|
|
|
await processGitHubPullRequest(octokit, pullRequest);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (isIssueCommentEvent(githubEvent, body)) {
|
|
|
|
|
const comment = body.comment.body;
|
|
|
|
|
if (!comment) {
|
2025-06-02 18:16:01 +00:00
|
|
|
logger.warn('Received issue comment event but comment body is empty');
|
2025-05-12 19:10:01 +00:00
|
|
|
return Response.json({ status: 'ok' });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (comment === `/${env.REVIEW_AGENT_REVIEW_COMMAND}`) {
|
2025-06-02 18:16:01 +00:00
|
|
|
logger.info('Review agent review command received, processing');
|
2025-05-12 19:10:01 +00:00
|
|
|
|
|
|
|
|
if (!body.installation) {
|
2025-06-02 18:16:01 +00:00
|
|
|
logger.error('Received github issue comment event but installation is not present');
|
2025-05-12 19:10:01 +00:00
|
|
|
return Response.json({ status: 'ok' });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const pullRequestNumber = body.issue.number;
|
|
|
|
|
const repositoryName = body.repository.name;
|
|
|
|
|
const owner = body.repository.owner.login;
|
|
|
|
|
|
|
|
|
|
const octokit = await githubApp.getInstallationOctokit(body.installation.id);
|
|
|
|
|
const { data: pullRequest } = await octokit.rest.pulls.get({
|
|
|
|
|
owner,
|
|
|
|
|
repo: repositoryName,
|
|
|
|
|
pull_number: pullRequestNumber,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await processGitHubPullRequest(octokit, pullRequest);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return Response.json({ status: 'ok' });
|
2025-12-05 06:08:24 +00:00
|
|
|
}
|