mirror of
https://github.com/codeflash-ai/codeflash-internal.git
synced 2026-05-04 18:25:18 +00:00
756 lines
31 KiB
TypeScript
756 lines
31 KiB
TypeScript
import { App } from "octokit"
|
|
import { createNodeMiddleware } from "@octokit/webhooks"
|
|
import fs from "fs"
|
|
import {
|
|
getGithubAppPrivateKey,
|
|
getGithubAppWebhookSecret,
|
|
} from "@codeflash-ai/common/dist/src/azure-keyvault.js"
|
|
|
|
import { posthog } from "../analytics.js"
|
|
import * as Sentry from "@sentry/node"
|
|
import { logger } from "../utils/logger.js"
|
|
import {
|
|
createAppInstallation,
|
|
createOrUpdateUser,
|
|
getAppInstallationByInstalltionId,
|
|
organizationMemberRepository,
|
|
organizationRepository,
|
|
prisma,
|
|
upsertRepository,
|
|
upsertRepositoryMember,
|
|
} from "@codeflash-ai/common"
|
|
import { getUserRole } from "./github-utils.js"
|
|
|
|
const APP_USER_ID: number = parseInt(process.env.GH_APP_USER_ID ?? "0") // GitHub App User ID
|
|
|
|
/** Extract common webhook context fields for structured logging */
|
|
function webhookContext(payload: any, operation: string) {
|
|
return {
|
|
repoOwner: payload?.repository?.owner?.login,
|
|
repoName: payload?.repository?.name,
|
|
prNumber: payload?.pull_request?.number,
|
|
installationId: payload?.installation?.id,
|
|
operation,
|
|
}
|
|
}
|
|
|
|
// Create a function to get config without initializing the app
|
|
async function getGithubAppConfig() {
|
|
const APP_ID: string = process.env.GH_APP_ID ?? ""
|
|
|
|
const PRIVATE_KEY: string =
|
|
process.env.NODE_ENV === "production"
|
|
? await getGithubAppPrivateKey()
|
|
: process.env.NODE_ENV === "test"
|
|
? "test-private-key"
|
|
: fs.readFileSync("github/Codeflash AI Dev GitHub App Private Key.pem", "utf8")
|
|
|
|
const WEBHOOK_SECRET: string =
|
|
process.env.NODE_ENV === "production"
|
|
? await getGithubAppWebhookSecret()
|
|
: (process.env.GH_APP_WEBHOOK_SECRET ?? "default-secret")
|
|
|
|
return { APP_ID, PRIVATE_KEY, WEBHOOK_SECRET }
|
|
}
|
|
|
|
// Initialize the app conditionally
|
|
const initializeApp = async () => {
|
|
const { APP_ID, PRIVATE_KEY, WEBHOOK_SECRET } = await getGithubAppConfig()
|
|
|
|
return new App({
|
|
appId: APP_ID,
|
|
privateKey: PRIVATE_KEY,
|
|
webhooks: {
|
|
secret: WEBHOOK_SECRET,
|
|
},
|
|
oauth: {
|
|
// OAuth details are currently unused by the app
|
|
clientId: "", // process.env.GH_APP_CLIENT_ID ?? "",
|
|
clientSecret: "", // process.env.GH_APP_CLIENT_SECRET ?? "",
|
|
},
|
|
})
|
|
}
|
|
|
|
// Export the actual App instance, initialized based on environment
|
|
export const githubApp = await (async () => {
|
|
// Check if GitHub App is configured
|
|
const GH_APP_ID = process.env.GH_APP_ID
|
|
|
|
if (!GH_APP_ID || GH_APP_ID === "" || process.env.NODE_ENV === "test") {
|
|
logger.warn("GitHub App not configured (GH_APP_ID missing)", { operation: "server_startup" })
|
|
logger.warn("PR creation and GitHub webhook features are disabled", { operation: "server_startup" })
|
|
logger.info("CLI and optimization features will continue to work", { operation: "server_startup" })
|
|
|
|
// Return a minimal mock that won't fail
|
|
return {
|
|
octokit: {
|
|
request: async () => ({ data: { name: "GitHub App Disabled" } }),
|
|
log: {
|
|
debug: () => {},
|
|
},
|
|
},
|
|
webhooks: {
|
|
on: () => {},
|
|
onAny: () => {},
|
|
onError: () => {},
|
|
},
|
|
getInstallationOctokit: async () => {
|
|
throw new Error("GitHub App not configured. Set GH_APP_ID to enable PR creation.")
|
|
},
|
|
} as any as App
|
|
}
|
|
|
|
// In other environments, initialize normally
|
|
logger.info(`GitHub App ID ${GH_APP_ID} detected, initializing...`, { operation: "server_startup" })
|
|
const app = await initializeApp()
|
|
|
|
logger.info("GitHub App initialized", { operation: "server_startup" })
|
|
|
|
const { data } = await app.octokit.request("/app")
|
|
// Read more about custom logging: https://github.com/octokit/core.js#logging
|
|
app.octokit.log.debug(`Authenticated as '${data.name}'`)
|
|
|
|
app.webhooks.onAny(async ({ id, name, payload }) => {
|
|
// Only log event type and ID, not full payload (too verbose)
|
|
logger.info("GitHub App: Received webhook event", {
|
|
operation: "webhook_received",
|
|
repoOwner: (payload as any)?.repository?.owner?.login,
|
|
repoName: (payload as any)?.repository?.name,
|
|
}, { eventType: name, eventId: id })
|
|
posthog?.capture({
|
|
distinctId: `github|${payload.sender?.id}`,
|
|
event: `cfapi-github-webhook-received`,
|
|
properties: {
|
|
event: name,
|
|
id,
|
|
},
|
|
})
|
|
})
|
|
|
|
logger.info(`GitHub App authenticated as '${data.name}'`, { operation: "server_startup" })
|
|
|
|
app.webhooks.on("installation", async ({ octokit, payload }) => {
|
|
const account = payload.installation.account
|
|
const accountName =
|
|
account && "login" in account
|
|
? account.login
|
|
: account && "slug" in account
|
|
? account.slug
|
|
: "unknown"
|
|
logger.info(`Received installation event: installation_id=${payload.installation.id}, account=${accountName}`, webhookContext(payload, "installation"))
|
|
// Create an installation access token
|
|
const installationAccessToken = await octokit.rest.apps.createInstallationAccessToken({
|
|
installation_id: payload.installation.id,
|
|
})
|
|
// Don't log the token for security reasons
|
|
})
|
|
|
|
app.webhooks.on("pull_request.opened", async ({ octokit, payload }) => {
|
|
logger.info(`Received pull_request.opened event: PR #${payload.pull_request?.number} in ${payload.repository?.full_name}`, webhookContext(payload, "pull_request_opened"))
|
|
})
|
|
|
|
app.webhooks.on("pull_request.edited", async ({ octokit, payload }) => {
|
|
logger.info(`Received pull_request.edited event: PR #${payload.pull_request?.number} in ${payload.repository?.full_name}`, webhookContext(payload, "pull_request_edited"))
|
|
})
|
|
|
|
app.webhooks.on("pull_request.closed", async ({ octokit, payload }) => {
|
|
if (payload.pull_request) {
|
|
const prId = String(payload.pull_request.id)
|
|
try {
|
|
const optimizationEvent = await prisma.optimization_events.findUnique({
|
|
where: { pr_id: prId },
|
|
select: { id: true, metadata: true },
|
|
})
|
|
|
|
if (optimizationEvent) {
|
|
const currentMetadata = (optimizationEvent.metadata ?? {}) as Record<string, unknown>
|
|
// Remove line profiler data from metadata
|
|
delete currentMetadata.originalLineProfiler
|
|
delete currentMetadata.optimizedLineProfiler
|
|
|
|
await prisma.optimization_events.update({
|
|
where: { id: optimizationEvent.id },
|
|
data: {
|
|
event_type: payload.pull_request.merged ? "pr_merged" : "pr_closed",
|
|
metadata: currentMetadata as object,
|
|
},
|
|
})
|
|
}
|
|
|
|
logger.info(`Updated optimization_event for PR ID ${prId} to ${payload.pull_request.merged ? "pr_merged" : "pr_closed"} and removed line profiler data`, webhookContext(payload, "pull_request_closed"))
|
|
} catch (err) {
|
|
logger.error(`Failed to update optimization_event for PR ID ${prId}:`, webhookContext(payload, "pull_request_closed"), {}, err as Error)
|
|
}
|
|
logger.info(`Received pull_request.closed event: PR #${payload.pull_request.number} by ${payload.pull_request.user.login} was closed`, webhookContext(payload, "pull_request_closed"))
|
|
|
|
// Check if the PR was merged and is a PR created by Codeflash
|
|
const is_user_code_flash = payload.pull_request.user.id === APP_USER_ID
|
|
try {
|
|
if (payload.pull_request.merged && is_user_code_flash) {
|
|
// Extract the original PR number from the branch name
|
|
const dependentBranchNamePattern = /codeflash.optimize-pr(\d+)-\d{4}-\d{2}-\d{2}T.+$/
|
|
const standaloneBranchNamePattern = /codeflash.optimize-(.+)-\d{4}-\d{2}-\d{2}T.+$/
|
|
const dependentPrMatch = dependentBranchNamePattern.exec(payload.pull_request.head.ref)
|
|
const standalonePrMatch = standaloneBranchNamePattern.exec(payload.pull_request.head.ref)
|
|
if (dependentPrMatch != null) {
|
|
const originalPrNumber = parseInt(dependentPrMatch[1])
|
|
let username = "You"
|
|
if (payload.pull_request.merged_by != null) {
|
|
// should not be null, but check anyway
|
|
username = `@${payload.pull_request.merged_by.login}`
|
|
}
|
|
// Comment on the original PR
|
|
await octokit.rest.issues.createComment({
|
|
owner: payload.repository.owner.login,
|
|
repo: payload.repository.name,
|
|
issue_number: originalPrNumber,
|
|
body: `This PR is now faster! 🚀 ${username} accepted my optimizations from:
|
|
- #${payload.pull_request.number}`,
|
|
})
|
|
|
|
posthog?.capture({
|
|
distinctId: `github|${payload.sender.id}`, // this is the user who merged the PR
|
|
event: `cfapi-github-dependent-pr-merged`,
|
|
properties: {
|
|
owner: payload.repository.owner.login,
|
|
repo: payload.repository.name,
|
|
originalPrNumber,
|
|
dependentPrNumber: payload.pull_request.number,
|
|
mergedBy: payload.pull_request.merged_by?.login,
|
|
},
|
|
})
|
|
logger.info(`Commented on original PR #${originalPrNumber} and logged the event to PostHog`, webhookContext(payload, "dependent_pr_merged"))
|
|
} else if (standalonePrMatch != null) {
|
|
posthog?.capture({
|
|
distinctId: `github|${payload.sender.id}`,
|
|
event: `cfapi-github-standalone-pr-merged`,
|
|
properties: {
|
|
owner: payload.repository.owner.login,
|
|
repo: payload.repository.name,
|
|
functionName: standalonePrMatch[1],
|
|
prNumber: payload.pull_request.number,
|
|
mergedBy: payload.pull_request.merged_by?.login,
|
|
},
|
|
})
|
|
logger.info(`Logged standalone PR #${payload.pull_request.number} merge event to PostHog`, webhookContext(payload, "standalone_pr_merged"))
|
|
}
|
|
}
|
|
} catch (mergedPrError) {
|
|
logger.errorWithSentry("Failed to process merged PR comment/analytics", webhookContext(payload, "pull_request_closed"), {}, mergedPrError as Error)
|
|
}
|
|
|
|
// Close any open optimization PRs targeting the branch of the closed PR
|
|
// Ensure we only close PRs that are targeting the branch of the PR that was just closed
|
|
const closedPrBranch = payload.pull_request.head.ref
|
|
// Logic to close any open optimization PRs targeting this branch
|
|
logger.info(`Closing optimization PRs targeting branch ${closedPrBranch}`, webhookContext(payload, "close_dependent_prs"))
|
|
if (payload.installation === undefined) {
|
|
logger.error("Installation ID is missing from payload. Cannot close PRs for this installation!", webhookContext(payload, "close_dependent_prs"))
|
|
return
|
|
}
|
|
try {
|
|
const installationOctokit = await app.getInstallationOctokit(payload.installation.id)
|
|
const openPrs = await installationOctokit.rest.pulls.list({
|
|
owner: payload.repository.owner.login,
|
|
repo: payload.repository.name,
|
|
state: "open",
|
|
base: closedPrBranch,
|
|
})
|
|
|
|
for (const pr of openPrs.data) {
|
|
// Check if the PR is opened by the Codeflash GitHub App and targets the same base branch as the closed PR
|
|
if (
|
|
pr.user?.type === "Bot" &&
|
|
pr.user?.id === APP_USER_ID &&
|
|
pr.base.ref === closedPrBranch
|
|
) {
|
|
await installationOctokit.rest.pulls.update({
|
|
owner: payload.repository.owner.login,
|
|
repo: payload.repository.name,
|
|
pull_number: pr.number,
|
|
state: "closed",
|
|
})
|
|
logger.info(`Closed optimization PR #${pr.number} targeting branch '${closedPrBranch}' because original PR #${payload.pull_request.number} by ${payload.pull_request.user.login} was closed`, webhookContext(payload, "close_dependent_prs"))
|
|
logger.info("Posting pull request comment...", webhookContext(payload, "close_dependent_prs"))
|
|
await octokit.rest.issues.createComment({
|
|
owner: payload.repository.owner.login,
|
|
repo: payload.repository.name,
|
|
issue_number: pr.number,
|
|
body:
|
|
`>This PR has been automatically closed because the original PR #${payload.pull_request.number} ` +
|
|
`by ${payload.pull_request.user.login} was closed.`,
|
|
})
|
|
|
|
// Delete the optimization PR's branch (safe: pr.user.id === APP_USER_ID verified above)
|
|
await deleteBranchIfExists(installationOctokit, payload, pr.head.ref)
|
|
}
|
|
}
|
|
|
|
// If the closed PR itself was created by Codeflash, delete its branch too.
|
|
// Guard is correct here: we must NOT delete a user's feature branch.
|
|
if (is_user_code_flash) {
|
|
await deleteBranchIfExists(installationOctokit, payload, closedPrBranch)
|
|
}
|
|
} catch (error) {
|
|
logger.errorWithSentry(`Failed to close optimization PRs targeting branch ${closedPrBranch}`, webhookContext(payload, "close_dependent_prs"), {}, error as Error)
|
|
}
|
|
}
|
|
})
|
|
|
|
app.webhooks.on("installation.created", async ({ octokit, payload }) => {
|
|
// TODO: check if it's organization
|
|
const account = payload.installation.account
|
|
const accountName =
|
|
account && "login" in account
|
|
? account.login
|
|
: account && "slug" in account
|
|
? account.slug
|
|
: "unknown"
|
|
logger.info(`Received installation.created event: installation_id=${payload.installation.id}, account=${accountName}`, webhookContext(payload, "installation_created"))
|
|
})
|
|
|
|
app.webhooks.on("installation_repositories.added", async ({ octokit, payload }) => {
|
|
const repoCount = payload.repositories_added?.length || 0
|
|
logger.info(`Received installation_repositories.added event: installation_id=${payload.installation.id}, repositories_added=${repoCount}`, webhookContext(payload, "installation_repositories_added"))
|
|
})
|
|
|
|
app.webhooks.on("marketplace_purchase", async ({ id, name, payload }) => {
|
|
logger.info(`Received marketplace purchase event: ${name} (${id})`, webhookContext(payload, "marketplace_purchase"))
|
|
posthog?.capture({
|
|
distinctId: `github|${payload.sender.id}`,
|
|
event: `cfapi-github-marketplace-purchase`,
|
|
properties: {
|
|
event: name,
|
|
id,
|
|
},
|
|
})
|
|
})
|
|
|
|
app.webhooks.on("pull_request.synchronize", async ({ octokit, payload }) => {
|
|
if (payload.pull_request) {
|
|
logger.info(`Received pull_request.synchronize event: PR #${payload.pull_request.number} by ${payload.pull_request?.user?.login} was updated with new commits`, webhookContext(payload, "pull_request_synchronize"))
|
|
// Retrieve the list of commits for the pull request
|
|
const commits = await octokit.rest.pulls.listCommits({
|
|
owner: payload.repository.owner.login,
|
|
repo: payload.repository.name,
|
|
pull_number: payload.pull_request.number,
|
|
})
|
|
|
|
// Check the latest commit for the co-authored-by line
|
|
const latestCommit = commits.data[commits.data.length - 1]
|
|
if (
|
|
latestCommit.commit.message.includes(
|
|
"Co-authored-by: codeflash-ai[bot] <148906541+codeflash-ai[bot]@users.noreply.github.com>",
|
|
)
|
|
) {
|
|
// Log the event to Posthog
|
|
posthog?.capture({
|
|
distinctId: `github|${payload.sender.id}`,
|
|
event: `cfapi-github-commit-coauthored-by-codeflash`,
|
|
properties: {
|
|
prNumber: payload.pull_request.number,
|
|
commitId: latestCommit.sha,
|
|
repository: payload.repository.full_name,
|
|
author: latestCommit.commit.author?.name,
|
|
},
|
|
})
|
|
logger.info(`Logged co-authored commit to PostHog: ${latestCommit.sha}`, webhookContext(payload, "pull_request_synchronize"))
|
|
|
|
// should not be null, but check anyway
|
|
const authorname = latestCommit.commit.author?.name ?? "You"
|
|
// Comment on the PR
|
|
await octokit.rest.issues.createComment({
|
|
owner: payload.repository.owner.login,
|
|
repo: payload.repository.name,
|
|
issue_number: payload.pull_request.number,
|
|
body: `This PR is now faster! 🚀 ${authorname} accepted my code suggestion above.`,
|
|
})
|
|
logger.info(`Commented on PR #${payload.pull_request.number} about the accepted review comment`, webhookContext(payload, "pull_request_synchronize"))
|
|
}
|
|
}
|
|
})
|
|
|
|
// Helper function to process feedback from PR comments
|
|
async function processFeedbackComment({
|
|
octokit,
|
|
commentBody,
|
|
commentAuthor,
|
|
commentId,
|
|
prNumber,
|
|
repository,
|
|
commentType,
|
|
}: {
|
|
octokit: any
|
|
commentBody: string
|
|
commentAuthor: { id: number; login: string; email?: string | null; name?: string | null }
|
|
commentId: number
|
|
prNumber: number
|
|
repository: { owner: { login: string }; name: string; full_name: string }
|
|
commentType: "issue_comment" | "review_comment"
|
|
}) {
|
|
// Check if the comment mentions the bot
|
|
// Support both @codeflash-ai and @codeflash-ai[bot] mentions
|
|
// Extract feedback as everything after the mention
|
|
const mentionPattern = /@codeflash-ai(?:\[bot\])?\s*([\s\S]*)/i
|
|
|
|
const mentionMatch = mentionPattern.exec(commentBody)
|
|
if (!mentionMatch) {
|
|
return
|
|
}
|
|
|
|
const feedbackContent = mentionMatch[1].trim()
|
|
if (!feedbackContent) {
|
|
logger.info(`Empty feedback received from ${commentAuthor.login}, ignoring`, { operation: "process_feedback", repoOwner: repository.owner.login, repoName: repository.name, prNumber })
|
|
return
|
|
}
|
|
|
|
logger.info(`Received feedback (${commentType}) from ${commentAuthor.login} on PR #${prNumber}: "${feedbackContent.substring(0, 100)}..."`, { operation: "process_feedback", repoOwner: repository.owner.login, repoName: repository.name, prNumber })
|
|
|
|
// Helper to add reaction based on comment type
|
|
const addReaction = async (content: "+1") => {
|
|
if (commentType === "issue_comment") {
|
|
await octokit.rest.reactions.createForIssueComment({
|
|
owner: repository.owner.login,
|
|
repo: repository.name,
|
|
comment_id: commentId,
|
|
content,
|
|
})
|
|
} else {
|
|
await octokit.rest.reactions.createForPullRequestReviewComment({
|
|
owner: repository.owner.login,
|
|
repo: repository.name,
|
|
comment_id: commentId,
|
|
content,
|
|
})
|
|
}
|
|
}
|
|
|
|
try {
|
|
// Find the optimization event associated with this PR
|
|
const pr = await octokit.rest.pulls.get({
|
|
owner: repository.owner.login,
|
|
repo: repository.name,
|
|
pull_number: prNumber,
|
|
})
|
|
|
|
const prId = String(pr.data.id)
|
|
const prUrl = pr.data.html_url
|
|
logger.info(`Looking for optimization event with pr_id=${prId} or pr_url=${prUrl}`, { operation: "process_feedback", repoOwner: repository.owner.login, repoName: repository.name, prNumber })
|
|
|
|
// Find optimization events by PR ID or by PR URL
|
|
const optimizationEvent = await prisma.optimization_events.findFirst({
|
|
where: {
|
|
OR: [
|
|
{ pr_id: prId },
|
|
{ pr_url: prUrl },
|
|
{
|
|
pr_url: {
|
|
contains: `/${repository.full_name}/pull/${prNumber}`,
|
|
},
|
|
},
|
|
],
|
|
},
|
|
orderBy: {
|
|
created_at: "desc",
|
|
},
|
|
})
|
|
|
|
if (!optimizationEvent) {
|
|
logger.info(`No optimization event found for PR #${prNumber} in ${repository.full_name} (pr_id=${prId})`, { operation: "process_feedback", repoOwner: repository.owner.login, repoName: repository.name, prNumber })
|
|
await addReaction("+1")
|
|
return
|
|
}
|
|
|
|
logger.info(`Found optimization event: id=${optimizationEvent.id}, trace_id=${optimizationEvent.trace_id}`, { operation: "process_feedback", repoOwner: repository.owner.login, repoName: repository.name, prNumber })
|
|
|
|
// Create or get the user
|
|
const user = await createOrUpdateUser(
|
|
`github|${commentAuthor.id}`,
|
|
commentAuthor.login,
|
|
commentAuthor.email ?? null,
|
|
commentAuthor.name ?? null,
|
|
)
|
|
|
|
// Atomically append feedback to optimization event's feedback field (JSON array)
|
|
// Using SELECT FOR UPDATE to lock the row and prevent race conditions
|
|
const newFeedbackEntry = {
|
|
content: feedbackContent,
|
|
authorLogin: commentAuthor.login,
|
|
authorId: commentAuthor.id,
|
|
commentType,
|
|
createdAt: new Date().toISOString(),
|
|
}
|
|
|
|
await prisma.$transaction(async tx => {
|
|
// Lock the row with FOR UPDATE to prevent concurrent modifications
|
|
const [lockedEvent] = await tx.$queryRaw<{ feedback: unknown[] }[]>`
|
|
SELECT feedback FROM optimization_events WHERE id = ${optimizationEvent.id} FOR UPDATE
|
|
`
|
|
const existingFeedback = (lockedEvent.feedback as Array<any>) || []
|
|
|
|
await tx.optimization_events.update({
|
|
where: { id: optimizationEvent.id },
|
|
data: {
|
|
feedback: [...existingFeedback, newFeedbackEntry],
|
|
},
|
|
})
|
|
})
|
|
|
|
logger.info(`Saved feedback from ${commentAuthor.login} for optimization event ${optimizationEvent.id} (trace_id: ${optimizationEvent.trace_id})`, { operation: "process_feedback", repoOwner: repository.owner.login, repoName: repository.name, prNumber })
|
|
|
|
// Log to PostHog
|
|
posthog?.capture({
|
|
distinctId: `github|${commentAuthor.id}`,
|
|
event: `cfapi-github-feedback-received`,
|
|
properties: {
|
|
prNumber,
|
|
repository: repository.full_name,
|
|
optimizationEventId: optimizationEvent.id,
|
|
traceId: optimizationEvent.trace_id,
|
|
feedbackContent,
|
|
feedbackLength: feedbackContent.length,
|
|
commentType,
|
|
functionName: optimizationEvent.function_name,
|
|
filePath: optimizationEvent.file_path,
|
|
speedupX: optimizationEvent.speedup_x,
|
|
speedupPct: optimizationEvent.speedup_pct,
|
|
authorLogin: commentAuthor.login,
|
|
},
|
|
})
|
|
|
|
// React with a thumbs up to acknowledge the feedback
|
|
await addReaction("+1")
|
|
} catch (error) {
|
|
logger.errorWithSentry(`Failed to process feedback from ${commentAuthor.login}`, { operation: "process_feedback", repoOwner: repository.owner.login, repoName: repository.name, prNumber }, {}, error as Error)
|
|
|
|
try {
|
|
await addReaction("+1")
|
|
} catch (reactionError) {
|
|
logger.error("Failed to add reaction:", { operation: "process_feedback", repoOwner: repository.owner.login, repoName: repository.name, prNumber }, {}, reactionError as Error)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Handle PR conversation thread comments (issue_comment)
|
|
app.webhooks.on("issue_comment.created", async ({ octokit, payload }) => {
|
|
// Only process comments on pull requests (not regular issues)
|
|
if (!payload.issue.pull_request) {
|
|
return
|
|
}
|
|
|
|
await processFeedbackComment({
|
|
octokit,
|
|
commentBody: payload.comment.body,
|
|
commentAuthor: payload.comment.user,
|
|
commentId: payload.comment.id,
|
|
prNumber: payload.issue.number,
|
|
repository: payload.repository,
|
|
commentType: "issue_comment",
|
|
})
|
|
})
|
|
|
|
// Handle PR review comments (comments on specific lines of code)
|
|
app.webhooks.on("pull_request_review_comment.created", async ({ octokit, payload }) => {
|
|
await processFeedbackComment({
|
|
octokit,
|
|
commentBody: payload.comment.body,
|
|
commentAuthor: payload.comment.user,
|
|
commentId: payload.comment.id,
|
|
prNumber: payload.pull_request.number,
|
|
repository: payload.repository,
|
|
commentType: "review_comment",
|
|
})
|
|
})
|
|
|
|
// Optional: Handle errors
|
|
app.webhooks.onError(error => {
|
|
const errorContext = { operation: "webhook_error" }
|
|
logger.error(`Error occurred in GitHub App's onError handler: ${error}`, errorContext)
|
|
if (error instanceof Error) {
|
|
// Check if it's an AggregateError, common for signature issues
|
|
if (error.name === "AggregateError" && Array.isArray((error as any).errors)) {
|
|
logger.error("AggregateError details (possible secret mismatch or multiple issues):", errorContext)
|
|
;(error as any).errors.forEach((subError: Error, i: number) => {
|
|
logger.error(` Sub-error ${i + 1}: ${subError.message}`, errorContext)
|
|
})
|
|
} else if (error.message.includes("content length")) {
|
|
logger.error("Content length mismatch detected by Octokit. Payload may be truncated or header incorrect.", errorContext)
|
|
const eventRequest = (error as any).event?.request
|
|
if (eventRequest && eventRequest.headers) {
|
|
logger.error("Request headers from error.event:", errorContext, { headers: JSON.stringify(eventRequest.headers, null, 2) })
|
|
}
|
|
}
|
|
// Log the full error structure for better debugging
|
|
logger.error("Full error object (onError):", errorContext, { errorDetails: JSON.stringify(error, Object.getOwnPropertyNames(error), 2) })
|
|
} else {
|
|
logger.error("Full error (onError, non-Error instance):", errorContext, { errorDetails: String(error) })
|
|
}
|
|
Sentry.captureException(error)
|
|
})
|
|
|
|
app.webhooks.on("installation_repositories", async ({ payload }) => {
|
|
const repoCount = payload.repositories_added?.length || 0
|
|
logger.info(`Received installation_repositories event: installation_id=${payload.installation?.id}, repositories_added=${repoCount}`, webhookContext(payload, "installation_repositories"))
|
|
const { repositories_added, installation, sender } = payload
|
|
// Check if required fields are missing
|
|
if (!repositories_added || !installation?.id) {
|
|
logger.warn("Missing repositories_added or installation.id", webhookContext(payload, "installation_repositories"))
|
|
return
|
|
}
|
|
const account = installation.account
|
|
let accountLogin: string | undefined
|
|
|
|
// Check if the account is a user or an organization
|
|
if ("login" in account) {
|
|
// It's a user account, use `login`
|
|
accountLogin = account.login
|
|
} else if ("slug" in account) {
|
|
// It's an organization account, use `slug`
|
|
accountLogin = account.slug
|
|
}
|
|
|
|
let accountType: string | undefined
|
|
if ("type" in account) {
|
|
accountType = account.type
|
|
} else {
|
|
accountType = "Organization" // fallback assumption
|
|
}
|
|
|
|
if (!accountLogin) {
|
|
logger.error("Account login or slug not found", webhookContext(payload, "installation_repositories"))
|
|
return
|
|
}
|
|
|
|
// Check if the installation exists, if not, create it
|
|
const installationExists = await getAppInstallationByInstalltionId(installation.id)
|
|
|
|
if (!installationExists) {
|
|
// Create the installation if it doesn't exist
|
|
await createAppInstallation({
|
|
installation_id: installation.id,
|
|
account_id: installation.account.id,
|
|
account_login: accountLogin,
|
|
account_type: accountType,
|
|
})
|
|
logger.info(`Installation created for ID: ${installation.id}`, webhookContext(payload, "installation_repositories"))
|
|
}
|
|
|
|
// Process each repository in the list of added repositories
|
|
for (const repo of repositories_added) {
|
|
try {
|
|
// Get the GitHub user ID of the sender
|
|
const githubUserId = sender?.id
|
|
|
|
if (githubUserId) {
|
|
logger.info(`GitHub User ID: ${githubUserId} triggered the event`, webhookContext(payload, "installation_repositories"))
|
|
// Fetch the user's role using the helper
|
|
// Use octokit from getInstallationOctokit for this installation
|
|
const installationOctokit = await app.getInstallationOctokit(installation.id)
|
|
const userRole = await getUserRole({
|
|
octokit: installationOctokit,
|
|
owner: accountLogin,
|
|
repo: repo.name,
|
|
username: sender.login,
|
|
isOrg: accountType === "Organization",
|
|
})
|
|
logger.info(`Fetched user role: ${userRole}`, webhookContext(payload, "installation_repositories"))
|
|
const user = await createOrUpdateUser(
|
|
`github|${githubUserId}`,
|
|
sender.login,
|
|
sender.email ?? null,
|
|
sender.name ?? null,
|
|
)
|
|
|
|
let orgId: string = undefined
|
|
if (accountType === "Organization") {
|
|
const ghOrgId = String(account.id)
|
|
const existingOrg = await prisma.organizations.findUnique({
|
|
where: { github_org_id: ghOrgId },
|
|
})
|
|
orgId = existingOrg?.id
|
|
if (!existingOrg) {
|
|
const organization = await organizationRepository.upsertOrganization({
|
|
github_org_id: ghOrgId,
|
|
name: account.name || accountLogin,
|
|
added_by: user.user_id,
|
|
})
|
|
|
|
await organizationMemberRepository.addMember({
|
|
organizationId: organization.id,
|
|
userId: user.user_id,
|
|
role: "admin",
|
|
addedBy: user.user_id, // Indicates that this user was the first to be added . If user_id equals addedBy, it means this user installed GitHub App for this repository.
|
|
})
|
|
|
|
logger.info(`Organization upserted: ${accountLogin}`, webhookContext(payload, "installation_repositories"))
|
|
orgId = organization.id
|
|
}
|
|
}
|
|
|
|
const savedRepo = await upsertRepository({
|
|
github_repo_id: String(repo.id),
|
|
installation_id: installation.id,
|
|
name: repo.name,
|
|
full_name: repo.full_name,
|
|
is_private: repo.private,
|
|
organization_id: orgId,
|
|
})
|
|
|
|
logger.info(`Repository upserted: ${savedRepo.full_name}`, webhookContext(payload, "installation_repositories"))
|
|
await upsertRepositoryMember({
|
|
repository_id: savedRepo.id,
|
|
user_id: user.user_id,
|
|
role: userRole,
|
|
})
|
|
} else {
|
|
logger.error("GitHub User ID not found in sender", webhookContext(payload, "installation_repositories"))
|
|
}
|
|
} catch (error) {
|
|
logger.errorWithSentry(`Failed to add/reactivate repository ${repo.full_name}`, webhookContext(payload, "installation_repositories"), {}, error as Error)
|
|
}
|
|
}
|
|
})
|
|
|
|
return app
|
|
})()
|
|
|
|
export const ghAppPathPrefix: string = "/cfapi/github"
|
|
|
|
// Path is already prefixed with /cfapi/github in the appExpress.postAsync handler
|
|
export const ghAppMiddleware = createNodeMiddleware(githubApp.webhooks, {
|
|
path: "/cfapi/github/webhooks",
|
|
})
|
|
|
|
const deleteBranchIfExists = async (installationOctokit: any, payload: any, branchName: string) => {
|
|
const ctx = {
|
|
...webhookContext(payload, "delete_branch"),
|
|
branchName,
|
|
}
|
|
try {
|
|
logger.info(`Deleting branch '${branchName}' associated with the closed PR`, ctx)
|
|
// Check if the branch exists by querying the reference
|
|
const ref = await installationOctokit.rest.git.getRef({
|
|
owner: payload.repository.owner.login,
|
|
repo: payload.repository.name,
|
|
ref: `heads/${branchName}`,
|
|
})
|
|
|
|
// If the ref is found, it means the branch exists, and you can delete it
|
|
logger.info(`Branch exists: ${ref.data.ref}`, ctx)
|
|
|
|
// Proceed to delete the branch
|
|
await installationOctokit.rest.git.deleteRef({
|
|
owner: payload.repository.owner.login,
|
|
repo: payload.repository.name,
|
|
ref: `heads/${branchName}`,
|
|
})
|
|
logger.info(`Branch '${branchName}' has been deleted`, ctx)
|
|
} catch (error: any) {
|
|
// If the branch doesn't exist or other errors occur, catch the error
|
|
if (error.status === 404) {
|
|
logger.info(`Branch '${branchName}' does not exist`, ctx)
|
|
} else {
|
|
logger.error(`Error checking branch existence or deleting '${branchName}':`, ctx, {}, error as Error)
|
|
}
|
|
}
|
|
}
|