Skip to content
Merged
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
26 changes: 24 additions & 2 deletions __tests__/api/webhook/github/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
() => ({
Expand Down Expand Up @@ -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 },
}
Expand Down
34 changes: 29 additions & 5 deletions app/api/webhook/github/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}

Expand Down
4 changes: 4 additions & 0 deletions lib/utils/utils-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>
Expand Down
Original file line number Diff line number Diff line change
@@ -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 }
}
30 changes: 27 additions & 3 deletions lib/webhook/github/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,10 +95,34 @@ export const StatusPayloadSchema = z.object({
})
export type StatusPayload = z.infer<typeof StatusPayloadSchema>

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,
})
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
24 changes: 24 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.