-
Notifications
You must be signed in to change notification settings - Fork 0
Trigger createDependentPR workflow when PR is labeled "I2PR: Update PR" #1401
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 4 commits
6b75846
01889ec
68169ef
8c483dd
f9519e4
d8a489b
b088e57
93a522d
e9ac049
1ebcffb
1ff2cc0
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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) | ||
| }) | ||
| }) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 ?? "") | ||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think based on the PullRequestPayloadSchema, the installation ID should exist (it is NOT optional). So if it didn't exist in the payload, then our schema.safeParse will already catch that error. If the rest of our subscribers to the PullRequestPayloadSchema is also converting it to a String, then might as well just make the whole schema installataionId into a string, instead of number. |
||
| 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() | ||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think the whole structure of this file feels a bit jumbled and it's hard to read. Because we're mixing switch statements, and in between each switch statement, we're doing additional parsing. It would be nice if we could move all the parsing to 1 section, including the converting and trimming and extracting data, maybe to the top of the file somehow, before we start entering the switch statements. That way we can just collapse the nested switch statements into a more cohesive tree, without having to see all the parsing functionality interspersed within the tree. |
||
| switch (labelName?.toLowerCase()) { | ||
| case "i2pr: update pr": { | ||
| await handlePullRequestLabelCreateDependentPR({ | ||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It would be great if we could have a few different tests built out in the /tests folder somehow. I'm not sure how to best structure tests, but my initial thinking is we'd need:
How are tests often structured? I'm still new to testing. |
||
| 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 }) | ||
| } | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -26,7 +26,7 @@ export async function handler(job: Job): Promise<string> { | |
| 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<string> { | |
| ) | ||
| 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. | ||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. No, we need to actually implement this. I think previously for other workflows, like autoResolveIssue, we've had to migrate their implementations from the /lib folder into the /shared folder somehow. Once, I just copied the entire implementation into the /shared folder, with similar folder structure, and vowed to refactor in a future date. We can do something similar here, if needed. The worker must be able to run the createDependentPR job. |
||
| 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<string> { | |
| throw error instanceof Error ? error : new Error(msg) | ||
| } | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
| ) | ||
| } | ||
|
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This, along with the setupEnv.test.ts, seems unrelated to this PR. I would wish we could carve out these changes in a separate PR from main, and merge that carved out PR first, before reviewing this PR.