diff --git a/__tests__/api/webhook/github/route.test.ts b/__tests__/api/webhook/github/route.test.ts index fa3c9553..09a2c081 100644 --- a/__tests__/api/webhook/github/route.test.ts +++ b/__tests__/api/webhook/github/route.test.ts @@ -4,6 +4,23 @@ import crypto from "crypto" import { NextRequest } from "next/server" +// Mock next-auth and GitHub modules to avoid ES module issues +jest.mock("@/auth", () => ({ + auth: jest.fn(), + signIn: jest.fn(), + signOut: jest.fn(), + handlers: {}, +})) + +jest.mock("@/lib/github", () => ({ + __esModule: true, + default: jest.fn(), + getOctokit: jest.fn(), + getUserOctokit: jest.fn(), + getInstallationOctokit: jest.fn(), + getAppOctokit: jest.fn(), +})) + jest.mock( "@/lib/webhook/github/handlers/installation/revalidateRepositoriesCache.handler", () => ({ @@ -625,8 +642,13 @@ describe("POST /api/webhook/github", () => { it("accepts issue_comment events without calling handlers", async () => { const payload = { action: "created", - comment: { id: 123 }, - issue: { number: 1 }, + comment: { + id: 123, + body: "test", + author_association: "OWNER", + user: { login: "octocat", type: "User" }, + }, + issue: { number: 1, author_association: "OWNER" }, repository: { full_name: "owner/repo" }, installation: { id: 9999 }, } diff --git a/app/api/webhook/github/route.ts b/app/api/webhook/github/route.ts index c4fc2bdb..63dc36d4 100644 --- a/app/api/webhook/github/route.ts +++ b/app/api/webhook/github/route.ts @@ -11,6 +11,7 @@ import { revalidateUserInstallationReposCache } from "@/lib/webhook/github/handl import { handleIssueLabelAutoResolve } from "@/lib/webhook/github/handlers/issue/label.autoResolveIssue.handler" import { handleIssueLabelResolve } from "@/lib/webhook/github/handlers/issue/label.resolve.handler" import { handlePullRequestClosedRemoveContainer } from "@/lib/webhook/github/handlers/pullRequest/closed.removeContainer.handler" +import { handlePullRequestComment } from "@/lib/webhook/github/handlers/pullRequest/comment.authorizeWorkflow.handler" import { handlePullRequestLabelCreateDependentPR } from "@/lib/webhook/github/handlers/pullRequest/label.createDependentPR.handler" import { handleRepositoryEditedRevalidate } from "@/lib/webhook/github/handlers/repository/edited.revalidateRepoCache.handler" import { @@ -242,13 +243,36 @@ export async function POST(req: NextRequest) { case "issue_comment": { const r = IssueCommentPayloadSchema.safeParse(payload) if (!r.success) { - console.error( - "[ERROR] Invalid issue_comment payload", - r.error.flatten() - ) + console.error("[ERROR] Invalid issue_comment payload", r.error) return new Response("Invalid payload", { status: 400 }) } - // Explicitly accept created/edited as no-ops for now + const parsedPayload = r.data + + // Only process "created" actions for PR comment workflow authorization + if (parsedPayload.action === "created") { + // Gate privileged actions for PR comment commands using author_association + // We intentionally fire-and-forget to keep webhook fast. + handlePullRequestComment({ + installationId: parsedPayload.installation.id, + commentId: parsedPayload.comment?.id ?? 0, + commentBody: parsedPayload.comment?.body ?? "", + commentUserType: parsedPayload.comment?.user.type ?? "User", + authorAssociation: + parsedPayload.comment?.author_association ?? + parsedPayload.issue.author_association, + issueNumber: parsedPayload.issue.number, + repoFullName: parsedPayload.repository.full_name, + isPullRequest: parsedPayload.issue.pull_request !== undefined, + }) + .then((response) => { + console.log("response", response) + }) + .catch((error) => { + console.error("[ERROR] Failed handling PR comment auth:", error) + }) + } + + // Explicitly accept created/edited/deleted as no-ops otherwise break } diff --git a/lib/utils/utils-server.ts b/lib/utils/utils-server.ts index a9f324fa..f13b8ae7 100644 --- a/lib/utils/utils-server.ts +++ b/lib/utils/utils-server.ts @@ -20,6 +20,10 @@ import { getCloneUrlWithAccessToken } from "@/lib/utils/utils-common" // For storing Github App installation ID in async context const asyncLocalStorage = new AsyncLocalStorage<{ installationId: string }>() +/** + * + * @deprecated Do not use asyncLocalStorage. It introduces bugs that I don't understand. + */ export function runWithInstallationId( installationId: string, fn: () => Promise diff --git a/lib/webhook/github/handlers/pullRequest/comment.authorizeWorkflow.handler.ts b/lib/webhook/github/handlers/pullRequest/comment.authorizeWorkflow.handler.ts new file mode 100644 index 00000000..d927c3bd --- /dev/null +++ b/lib/webhook/github/handlers/pullRequest/comment.authorizeWorkflow.handler.ts @@ -0,0 +1,111 @@ +import { getInstallationOctokit } from "@/lib/github" + +// Trigger keyword to activate the workflow +const TRIGGER_KEYWORD = "@issuetopr" + +interface HandlePullRequestCommentProps { + installationId: number + commentId: number + commentBody: string + commentUserType: "User" | "Bot" | "Organization" + authorAssociation: string + issueNumber: number + repoFullName: string + isPullRequest: boolean +} + +/** + * Handler: PR comment authorization gate (via author_association) + * - Only allows privileged actions when the commenter is an OWNER + * - For non-owners, responds with a helpful comment explaining the restriction + * - For owners, confirms authorization with a reply + * - Adds an emoji reaction to acknowledge the comment was received + * + * NOTE: This handler is intentionally conservative and does not execute any + * workflows. It only enforces the authorization model and provides feedback. + * Actual workflow execution (e.g. based on command text) should be added in a + * follow-up once the specific commands are defined. + */ +export async function handlePullRequestComment({ + installationId, + commentId, + commentBody, + commentUserType, + authorAssociation, + issueNumber, + repoFullName, + isPullRequest, +}: HandlePullRequestCommentProps) { + // Only consider PR comments (issue_comment also fires for issues) + if (!isPullRequest) { + return { status: "ignored", reason: "not_pr_comment" as const } + } + + // Only respond to human users to prevent bot loops + if (commentUserType === "Bot") { + return { status: "ignored", reason: "not_human_user" as const } + } + + const trimmedBody = commentBody.trim() + + // Only react to explicit commands to avoid noisy replies. + const looksLikeCommand = trimmedBody.toLowerCase().includes(TRIGGER_KEYWORD) + if (!looksLikeCommand) { + return { status: "ignored", reason: "no_command" as const } + } + + // Create authenticated Octokit using the installation ID from the webhook + const octokit = await getInstallationOctokit(installationId) + const [owner, repo] = repoFullName.split("/") + + // Add an emoji reaction to acknowledge we've seen the comment + if (commentId) { + try { + await octokit.rest.reactions.createForIssueComment({ + owner, + repo, + comment_id: commentId, + content: "eyes", + }) + } catch (e) { + console.error("[Webhook] Failed to add reaction to comment:", e) + } + } + + if (authorAssociation !== "OWNER") { + // Non-owner attempting to trigger a workflow. Post a helpful reply. + const reply = + "Only repository owners can trigger this workflow via PR comments. " + + "Please ask the repo owner to run this workflow." + + try { + await octokit.rest.issues.createComment({ + owner, + repo, + issue_number: issueNumber, + body: reply, + }) + } catch (e) { + console.error("[Webhook] Failed to post authorization reply:", e) + } + + return { status: "rejected", reason: "not_owner" as const } + } + + // OWNER is authorized. Confirm with a simple reply. + try { + await octokit.rest.issues.createComment({ + owner, + repo, + issue_number: issueNumber, + body: "Authorization confirmed. You are authorized to trigger workflows.", + }) + } catch (e) { + console.error("[Webhook] Failed to post confirmation reply:", e) + } + + console.log( + `[Webhook] PR comment authorized by OWNER for ${repoFullName}#${issueNumber}` + ) + return { status: "authorized" as const } +} diff --git a/lib/webhook/github/types.ts b/lib/webhook/github/types.ts index 8710f729..e7891cfb 100644 --- a/lib/webhook/github/types.ts +++ b/lib/webhook/github/types.ts @@ -95,10 +95,34 @@ export const StatusPayloadSchema = z.object({ }) export type StatusPayload = z.infer +const AuthorAssociationSchema = z.enum([ + "COLLABORATOR", + "CONTRIBUTOR", + "FIRST_TIME_CONTRIBUTOR", + "FIRST_TIMER", + "MANNEQUIN", + "MEMBER", + "NONE", + "OWNER", +]) + export const IssueCommentPayloadSchema = z.object({ - action: z.string(), - issue: z.object({ number: z.number() }).optional(), - comment: z.object({ id: z.number() }).optional(), + action: z.enum(["created", "edited", "deleted"]), + issue: z.object({ + number: z.number(), + // When the comment is on a PR, GitHub includes a pull_request field on the issue + pull_request: z.any().optional(), + author_association: AuthorAssociationSchema, + }), + comment: z.object({ + id: z.number(), + body: z.string(), + user: z.object({ + login: z.string(), + type: z.enum(["User", "Bot", "Organization"]), + }), + author_association: AuthorAssociationSchema, + }), repository: z.object({ full_name: z.string() }), installation: InstallationSchema, }) diff --git a/package.json b/package.json index c90ca7a7..c5851346 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "@octokit/graphql": "^8.2.1", "@octokit/openapi-types": "^24.0.0", "@octokit/rest": "^21.0.2", + "@octokit/webhooks": "^14.2.0", "@radix-ui/react-accordion": "^1.2.3", "@radix-ui/react-checkbox": "^1.1.4", "@radix-ui/react-collapsible": "^1.1.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9d417c02..0f80f0a7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -30,6 +30,9 @@ importers: '@octokit/rest': specifier: ^21.0.2 version: 21.1.1 + '@octokit/webhooks': + specifier: ^14.2.0 + version: 14.2.0 '@radix-ui/react-accordion': specifier: ^1.2.3 version: 1.2.12(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -2209,6 +2212,9 @@ packages: '@octokit/openapi-webhooks-types@11.0.0': resolution: {integrity: sha512-ZBzCFj98v3SuRM7oBas6BHZMJRadlnDoeFfvm1olVxZnYeU6Vh97FhPxyS5aLh5pN51GYv2I51l/hVUAVkGBlA==} + '@octokit/openapi-webhooks-types@12.1.0': + resolution: {integrity: sha512-WiuzhOsiOvb7W3Pvmhf8d2C6qaLHXrWiLBP4nJ/4kydu+wpagV5Fkz9RfQwV2afYzv3PB+3xYgp4mAdNGjDprA==} + '@octokit/plugin-paginate-graphql@5.2.4': resolution: {integrity: sha512-pLZES1jWaOynXKHOqdnwZ5ULeVR6tVVCMm+AUbp0htdcyXDU95WbkYdU4R2ej1wKj5Tu94Mee2Ne0PjPO9cCyA==} engines: {node: '>= 18'} @@ -2293,6 +2299,10 @@ packages: resolution: {integrity: sha512-NGlEHZDseJTCj8TMMFehzwa9g7On4KJMPVHDSrHxCQumL6uSQR8wIkP/qesv52fXqV1BPf4pTxwtS31ldAt9Xg==} engines: {node: '>= 18'} + '@octokit/webhooks-methods@6.0.0': + resolution: {integrity: sha512-MFlzzoDJVw/GcbfzVC1RLR36QqkTLUf79vLVO3D+xn7r0QgxnFoLZgtrzxiQErAjFUOdH6fas2KeQJ1yr/qaXQ==} + engines: {node: '>= 20'} + '@octokit/webhooks-types@7.6.1': resolution: {integrity: sha512-S8u2cJzklBC0FgTwWVLaM8tMrDuDMVE4xiTK4EYXM9GntyvrdbSoxqDQa+Fh57CCNApyIpyeqPhhFEmHPfrXgw==} @@ -2300,6 +2310,10 @@ packages: resolution: {integrity: sha512-Nss2b4Jyn4wB3EAqAPJypGuCJFalz/ZujKBQQ5934To7Xw9xjf4hkr/EAByxQY7hp7MKd790bWGz7XYSTsHmaw==} engines: {node: '>= 18'} + '@octokit/webhooks@14.2.0': + resolution: {integrity: sha512-da6KbdNCV5sr1/txD896V+6W0iamFWrvVl8cHkBSPT+YlvmT3DwXa4jxZnQc+gnuTEqSWbBeoSZYTayXH9wXcw==} + engines: {node: '>= 20'} + '@open-draft/deferred-promise@2.2.0': resolution: {integrity: sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==} @@ -10644,6 +10658,8 @@ snapshots: '@octokit/openapi-webhooks-types@11.0.0': {} + '@octokit/openapi-webhooks-types@12.1.0': {} + '@octokit/plugin-paginate-graphql@5.2.4(@octokit/core@6.1.6)': dependencies: '@octokit/core': 6.1.6 @@ -10734,6 +10750,8 @@ snapshots: '@octokit/webhooks-methods@5.1.1': {} + '@octokit/webhooks-methods@6.0.0': {} + '@octokit/webhooks-types@7.6.1': {} '@octokit/webhooks@13.9.1': @@ -10742,6 +10760,12 @@ snapshots: '@octokit/request-error': 6.1.8 '@octokit/webhooks-methods': 5.1.1 + '@octokit/webhooks@14.2.0': + dependencies: + '@octokit/openapi-webhooks-types': 12.1.0 + '@octokit/request-error': 7.1.0 + '@octokit/webhooks-methods': 6.0.0 + '@open-draft/deferred-promise@2.2.0': {} '@open-draft/logger@0.3.0':