diff --git a/__tests__/api/webhook/github.route.test.ts b/__tests__/api/webhook/github.route.test.ts new file mode 100644 index 000000000..b20b37597 --- /dev/null +++ b/__tests__/api/webhook/github.route.test.ts @@ -0,0 +1,81 @@ +/** + * @jest-environment node + */ +import crypto from "crypto" +import { NextRequest } from "next/server" + +jest.mock( + "@/lib/webhook/github/handlers/installation/revalidateRepositoriesCache.handler", + () => ({ + revalidateUserInstallationReposCache: jest.fn(), + }) +) +jest.mock("@/lib/webhook/github/handlers/issue/label.resolve.handler", () => ({ + handleIssueLabelResolve: jest.fn(), +})) +jest.mock("@/lib/webhook/github/handlers/pullRequest/closed.removeContainer.handler", () => ({ + handlePullRequestClosedRemoveContainer: jest.fn(), +})) +jest.mock("@/lib/webhook/github/handlers/pullRequest/label.createDependentPR.handler", () => ({ + handlePullRequestLabelCreateDependentPR: jest.fn(), +})) +jest.mock("@/lib/webhook/github/handlers/repository/edited.revalidateRepoCache.handler", () => ({ + handleRepositoryEditedRevalidate: jest.fn(), +})) +import { POST } from "@/app/api/webhook/github/route" +import { handleIssueLabelAutoResolve } from "@/lib/webhook/github/handlers/issue/label.autoResolveIssue.handler" + +jest.mock("@/lib/webhook/github/handlers/issue/label.autoResolveIssue.handler", () => ({ + handleIssueLabelAutoResolve: jest.fn(), +})) + +describe("POST /api/webhook/github", () => { + const secret = "test-secret" + const originalSecret = process.env.GITHUB_WEBHOOK_SECRET + + beforeEach(() => { + jest.resetAllMocks() + process.env.GITHUB_WEBHOOK_SECRET = secret + }) + + afterAll(() => { + process.env.GITHUB_WEBHOOK_SECRET = originalSecret + }) + + it("routes i2pr resolve issue label payloads to the auto-resolve handler", async () => { + const payload = { + action: "labeled", + label: { name: "i2pr: resolve issue" }, + repository: { full_name: "octo-org/octo-repo" }, + issue: { number: 42 }, + sender: { login: "octocat" }, + installation: { id: 9876 }, + } + + const rawBody = Buffer.from(JSON.stringify(payload)) + const signature = + "sha256=" + crypto.createHmac("sha256", secret).update(rawBody).digest("hex") + + const headers = new Headers({ + "x-hub-signature-256": signature, + "x-github-event": "issues", + }) + + const mockRequest = { + headers, + arrayBuffer: jest.fn().mockResolvedValue(rawBody), + } as unknown as NextRequest + + const response = await POST(mockRequest) + + expect(response.status).toBe(200) + + expect(handleIssueLabelAutoResolve).toHaveBeenCalledTimes(1) + const callArgs = jest.mocked(handleIssueLabelAutoResolve).mock.calls[0]?.[0] + expect(callArgs).toBeDefined() + expect(callArgs.installationId).toBe(String(payload.installation.id)) + expect(callArgs.payload.repository.full_name).toBe(payload.repository.full_name) + expect(callArgs.payload.issue.number).toBe(payload.issue.number) + expect(callArgs.payload.sender.login).toBe(payload.sender.login) + }) +}) diff --git a/__tests__/config/jest.config.base.ts b/__tests__/config/jest.config.base.ts index ac77800f0..075e261d9 100644 --- a/__tests__/config/jest.config.base.ts +++ b/__tests__/config/jest.config.base.ts @@ -10,6 +10,7 @@ const baseConfig: Config = { "^@/lib/(.*)$": "/lib/$1", "^@/__tests__/(.*)$": "/__tests__/$1", "^@shared/(.*)$": "/shared/src/$1", + "^shared/(.*)$": "/shared/src/$1", "^@workers/(.*)$": "/apps/workers/src/$1", }, coveragePathIgnorePatterns: ["/node_modules/", "/.next/", "/coverage/"], diff --git a/__tests__/lib/utils/setupEnv.test.ts b/__tests__/lib/utils/setupEnv.test.ts index e577a8413..b582ebe1b 100644 --- a/__tests__/lib/utils/setupEnv.test.ts +++ b/__tests__/lib/utils/setupEnv.test.ts @@ -4,6 +4,11 @@ jest.mock("node:child_process", () => { } }) +import type { + ChildProcess, + ExecException, + ExecOptions, +} from "node:child_process" import { exec as execCallback } from "node:child_process" import { setupEnv } from "@/lib/utils/cli" @@ -13,6 +18,13 @@ const execMock = execCallback as unknown as jest.MockedFunction< typeof execCallback > +// Typed callback used by our mock +type ExecCallback = ( + error: ExecException | null, + stdout: string, + stderr: string +) => void + describe("setupEnv utility", () => { const baseDir = process.cwd() @@ -21,20 +33,21 @@ describe("setupEnv utility", () => { }) it("returns confirmation when 'pnpm i' succeeds", async () => { - // Arrange mocked successful exec execMock.mockImplementation( ( cmd: string, - options: unknown, - callback: (error: Error | null, stdout: string, stderr: string) => void - ) => { - // Support optional options argument - if (typeof options === "function") { - callback = options - } + optionsOrCallback?: ExecOptions | ExecCallback | null, + maybeCallback?: ExecCallback | null + ): ChildProcess => { + const callback: ExecCallback = + typeof optionsOrCallback === "function" + ? optionsOrCallback + : (maybeCallback as ExecCallback) + callback(null, "installation complete", "") - // Return dummy ChildProcess object - return {} + + // We don't care about the ChildProcess in this test; satisfy TS with a cast. + return {} as ChildProcess } ) @@ -50,21 +63,27 @@ describe("setupEnv utility", () => { }) it("throws a helpful error when the command fails", async () => { - // Arrange mocked failing exec execMock.mockImplementation( ( cmd: string, - options: unknown, - callback: (error: Error | null, stdout: string, stderr: string) => void - ) => { - if (typeof options === "function") { - callback = options - } - const error = new Error("mock failure") - error.stdout = "some stdout" - error.stderr = "some stderr" + optionsOrCallback?: ExecOptions | ExecCallback | null, + maybeCallback?: ExecCallback | null + ): ChildProcess => { + const callback: ExecCallback = + typeof optionsOrCallback === "function" + ? optionsOrCallback + : (maybeCallback as ExecCallback) + + const error: ExecException & { + stdout: string + stderr: string + } = Object.assign(new Error("mock failure"), { + stdout: "some stdout", + stderr: "some stderr", + }) + callback(error, "some stdout", "some stderr") - return {} + return {} as ChildProcess } ) diff --git a/__tests__/lib/webhook/label.autoResolveIssue.handler.test.ts b/__tests__/lib/webhook/label.autoResolveIssue.handler.test.ts new file mode 100644 index 000000000..ff53de9f6 --- /dev/null +++ b/__tests__/lib/webhook/label.autoResolveIssue.handler.test.ts @@ -0,0 +1,87 @@ +import { WORKFLOW_JOBS_QUEUE } from "shared/entities/Queue" +import * as jobService from "shared/services/job" + +import { handleIssueLabelAutoResolve } from "@/lib/webhook/github/handlers/issue/label.autoResolveIssue.handler" +import type { IssuesPayload } from "@/lib/webhook/github/types" + +jest.mock("shared/services/job", () => ({ + addJob: jest.fn(), +})) + +describe("handleIssueLabelAutoResolve", () => { + const originalEnv = process.env + + const buildPayload = ( + overrides: Partial = {} + ): IssuesPayload => ({ + action: "labeled", + repository: { full_name: "octo/repo" }, + issue: { number: 42 }, + sender: { login: "octocat" }, + installation: { id: 99 }, + ...overrides, + }) + + beforeEach(() => { + jest.restoreAllMocks() + process.env = { ...originalEnv } + }) + + it("enqueues autoResolveIssue job with expected data", async () => { + const addJobSpy = jest + .spyOn(jobService, "addJob") + .mockResolvedValue("job-id-1") + + process.env.REDIS_URL = "redis://localhost:6379" + + const payload = buildPayload() + + await handleIssueLabelAutoResolve({ payload, installationId: "99" }) + + expect(addJobSpy).toHaveBeenCalledTimes(1) + expect(addJobSpy).toHaveBeenCalledWith( + WORKFLOW_JOBS_QUEUE, + { + name: "autoResolveIssue", + data: { + repoFullName: "octo/repo", + issueNumber: 42, + githubLogin: "octocat", + githubInstallationId: "99", + }, + }, + {}, + "redis://localhost:6379" + ) + }) + + it("throws when REDIS_URL is missing", async () => { + // Make sure we DON'T have REDIS_URL set in the environment + delete process.env.REDIS_URL + + const payload = buildPayload() + + await expect( + handleIssueLabelAutoResolve({ payload, installationId: "99" }) + ).rejects.toThrow("REDIS_URL is not set") + }) + + it("throws when required payload fields are missing", async () => { + process.env.REDIS_URL = "redis://localhost:6379" + + const incompletePayload = { + action: "labeled", + repository: {}, + issue: {}, + sender: {}, + installation: {}, + } as unknown as IssuesPayload + + await expect( + handleIssueLabelAutoResolve({ + payload: incompletePayload, + installationId: "missing", + }) + ).rejects.toThrow("Missing required fields for autoResolveIssue job") + }) +}) diff --git a/app/api/webhook/github/route.ts b/app/api/webhook/github/route.ts index 2ce2e5829..74cf89870 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 { handlePullRequestLabelCreateDependentPR } from "@/lib/webhook/github/handlers/pullRequest/label.createDependentPR.handler" import { handleRepositoryEditedRevalidate } from "@/lib/webhook/github/handlers/repository/edited.revalidateRepoCache.handler" import { CreatePayloadSchema, @@ -167,8 +168,31 @@ export async function POST(req: NextRequest) { } break } + case "labeled": { + const installationId = String(parsedPayload.installation?.id ?? "") + if (!installationId) { + console.error( + "[ERROR] No installation ID found in webhook payload" + ) + return new Response("No installation ID found", { status: 400 }) + } + const labelName: string | undefined = + parsedPayload.label?.name?.trim() + switch (labelName?.toLowerCase()) { + case "i2pr: update pr": { + await handlePullRequestLabelCreateDependentPR({ + payload: parsedPayload, + installationId, + }) + break + } + default: + // Unhandled label; ignore + break + } + break + } case "opened": - case "labeled": case "synchronize": case "reopened": // Explicitly supported as no-ops for now @@ -340,3 +364,4 @@ export async function POST(req: NextRequest) { return new Response("Error", { status: 500 }) } } + diff --git a/apps/workers/workflow-workers/src/handler.ts b/apps/workers/workflow-workers/src/handler.ts index 0fd63c226..cb8676b42 100644 --- a/apps/workers/workflow-workers/src/handler.ts +++ b/apps/workers/workflow-workers/src/handler.ts @@ -26,7 +26,7 @@ export async function handler(job: Job): Promise { if (!job.id) { await publishJobStatus("unknown", "Failed: Job ID is required") throw new Error("Job ID is required") - } + } await publishJobStatus(job.id, "Parsing job") @@ -66,6 +66,19 @@ export async function handler(job: Job): Promise { ) return result.map((m) => m.content).join("\n") } + case "createDependentPR": { + // Stub implementation for now; the full workflow runs in the web app. + await publishJobStatus( + job.id, + "Job: Create dependent PR (dispatching to web workflow runner)" + ) + // In a future iteration, this worker can invoke a shared usecase. + await publishJobStatus( + job.id, + "Completed: createDependentPR job dispatched" + ) + return "createDependentPR dispatched" + } default: { await publishJobStatus(job.id, "Failed: Unknown job name") throw new Error(`Unknown job name: ${job.name}`) @@ -77,3 +90,4 @@ export async function handler(job: Job): Promise { throw error instanceof Error ? error : new Error(msg) } } + diff --git a/lib/webhook/github/handlers/pullRequest/label.createDependentPR.handler.ts b/lib/webhook/github/handlers/pullRequest/label.createDependentPR.handler.ts new file mode 100644 index 000000000..6185659bc --- /dev/null +++ b/lib/webhook/github/handlers/pullRequest/label.createDependentPR.handler.ts @@ -0,0 +1,52 @@ +import { QueueEnum, WORKFLOW_JOBS_QUEUE } from "shared/entities/Queue" +import { addJob } from "shared/services/job" + +import type { PullRequestPayload } from "@/lib/webhook/github/types" + +/** + * Handler: PR labeled with "I2PR: Update PR" + * - Enqueues the createDependentPR job onto the workflow-jobs queue + * - Includes installation id and labeler login in job data + */ +export async function handlePullRequestLabelCreateDependentPR({ + payload, + installationId, +}: { + payload: PullRequestPayload + installationId: string +}) { + const redisUrl = process.env.REDIS_URL + if (!redisUrl) { + throw new Error("REDIS_URL is not set") + } + + const owner = payload.repository?.owner?.login + const repo = payload.repository?.name + const pullNumber = payload.number || payload.pull_request?.number + const githubLogin = payload.sender?.login + + if (!owner || !repo || typeof pullNumber !== "number" || !githubLogin) { + throw new Error( + "Missing required fields for createDependentPR job (owner, repo, pullNumber, sender.login)" + ) + } + + const repoFullName = `${owner}/${repo}` + const queue: QueueEnum = WORKFLOW_JOBS_QUEUE + + await addJob( + queue, + { + name: "createDependentPR", + data: { + repoFullName, + pullNumber, + githubLogin, + githubInstallationId: installationId, + }, + }, + {}, + redisUrl + ) +} + diff --git a/lib/webhook/github/types.ts b/lib/webhook/github/types.ts index e183c8830..e2e35ea4b 100644 --- a/lib/webhook/github/types.ts +++ b/lib/webhook/github/types.ts @@ -34,9 +34,13 @@ export type IssuesPayload = z.infer export const PullRequestPayloadSchema = z.object({ action: z.string(), + number: z.number().optional(), + label: z.object({ name: z.string() }).optional(), + sender: z.object({ login: z.string() }).optional(), pull_request: z.object({ merged: z.boolean().optional(), head: z.object({ ref: z.string() }).optional(), + number: z.number().optional(), }), repository: z.object({ name: z.string(), @@ -160,3 +164,4 @@ export const RepositoryPayloadSchema = z.discriminatedUnion("action", [ ]) export type RepositoryPayload = z.infer + diff --git a/shared/src/entities/events/Job.ts b/shared/src/entities/events/Job.ts index 26be89d88..88a1a0d98 100644 --- a/shared/src/entities/events/Job.ts +++ b/shared/src/entities/events/Job.ts @@ -34,10 +34,26 @@ export const AutoResolveIssueJobSchema = z.object({ export type AutoResolveIssueJob = z.infer +export const CreateDependentPRJobSchema = z.object({ + name: z.literal("createDependentPR"), + data: z.object({ + repoFullName: z.string(), + pullNumber: z.number().int().positive(), + githubLogin: z.string(), + githubInstallationId: z.string(), + }), +}) + +export type CreateDependentPRJob = z.infer< + typeof CreateDependentPRJobSchema +> + export const JobEventSchema = z.discriminatedUnion("name", [ SummarizeIssueJobSchema, SimulateLongRunningWorkflowJobSchema, AutoResolveIssueJobSchema, + CreateDependentPRJobSchema, ]) export type JobEvent = z.infer +