From 7fba183e057f0efcff698ec1147b2588caf7e707 Mon Sep 17 00:00:00 2001 From: jimcody1995 Date: Mon, 6 Jul 2026 15:37:08 +0200 Subject: [PATCH] feat(commands): add pure classifyPrCommandRequest preamble helper Extract the shared PR-comment webhook guards (created action, non-bot author, repo/PR/installation/actor present) into a testable classifier mirroring classifyPlanCommandRequest, without wiring handlers yet. Fixes #2161 Co-authored-by: Cursor --- src/github/classify-pr-command-request.ts | 42 +++++++++ test/unit/classify-pr-command-request.test.ts | 88 +++++++++++++++++++ 2 files changed, 130 insertions(+) create mode 100644 src/github/classify-pr-command-request.ts create mode 100644 test/unit/classify-pr-command-request.test.ts diff --git a/src/github/classify-pr-command-request.ts b/src/github/classify-pr-command-request.ts new file mode 100644 index 0000000000..b004212514 --- /dev/null +++ b/src/github/classify-pr-command-request.ts @@ -0,0 +1,42 @@ +import type { GitHubWebhookPayload } from "../types"; + +/** Discriminated union for PR-thread @gittensory command webhook preambles (#2161). Mirrors + * classifyPlanCommandRequest (src/review/planner.ts) but for PR comments: issue.pull_request must + * be present. Handlers consume the ok branch; skip reasons match maybeProcessGateOverrideCommand. */ +export type PrCommandRequest = + | { + ok: true; + repoFullName: string; + installationId: number; + actor: string; + issue: { number: number; title?: string | null | undefined; body?: string | null | undefined }; + } + | { ok: false; reason: string; repoFullName: string | null; actor: string | null; targetKey: string | null }; + +export function classifyPrCommandRequest( + payload: GitHubWebhookPayload, + installationId: number | null, +): PrCommandRequest { + const comment = payload.comment; + const repoFullName = payload.repository?.full_name ?? null; + const issue = payload.issue ?? null; + const actor = payload.sender?.login ?? comment?.user?.login ?? null; + const targetKey = repoFullName && issue ? `${repoFullName}#${issue.number}` : repoFullName; + + if (payload.action !== "created") { + return { ok: false, reason: "unsupported_comment_action", repoFullName, actor, targetKey }; + } + if (comment?.user?.type === "Bot" || payload.sender?.type === "Bot" || /\[bot\]$/i.test(actor ?? "")) { + return { ok: false, reason: "bot_author", repoFullName, actor, targetKey }; + } + if (!repoFullName || !issue?.pull_request || !installationId || !actor) { + return { ok: false, reason: "missing_repo_pr_installation_or_actor", repoFullName, actor, targetKey }; + } + return { + ok: true, + repoFullName, + installationId, + actor, + issue: { number: issue.number, title: issue.title, body: issue.body }, + }; +} diff --git a/test/unit/classify-pr-command-request.test.ts b/test/unit/classify-pr-command-request.test.ts new file mode 100644 index 0000000000..22a31f8f87 --- /dev/null +++ b/test/unit/classify-pr-command-request.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from "vitest"; +import { classifyPrCommandRequest } from "../../src/github/classify-pr-command-request"; +import type { GitHubWebhookPayload } from "../../src/types"; + +describe("classifyPrCommandRequest (#2161)", () => { + const base = (over: Record = {}): GitHubWebhookPayload => + ({ + action: "created", + repository: { full_name: "acme/widgets" }, + issue: { number: 42, title: "PR title", state: "open", body: "PR body", pull_request: {} }, + comment: { id: 1, body: "@gittensory gate-override", user: { login: "maint", type: "User" } }, + sender: { login: "maint", type: "User" }, + ...over, + }) as unknown as GitHubWebhookPayload; + + it("returns ok with validated PR-thread fields", () => { + expect(classifyPrCommandRequest(base(), 123)).toEqual({ + ok: true, + repoFullName: "acme/widgets", + installationId: 123, + actor: "maint", + issue: { number: 42, title: "PR title", body: "PR body" }, + }); + }); + + it("skips non-created comment actions with unsupported_comment_action", () => { + expect(classifyPrCommandRequest(base({ action: "edited" }), 123)).toMatchObject({ + ok: false, + reason: "unsupported_comment_action", + targetKey: "acme/widgets#42", + }); + expect(classifyPrCommandRequest(base({ action: "deleted" }), 123)).toMatchObject({ + ok: false, + reason: "unsupported_comment_action", + }); + }); + + it("skips bot authors with bot_author", () => { + expect( + classifyPrCommandRequest( + base({ comment: { id: 1, body: "@gittensory gate-override", user: { login: "bot", type: "Bot" } } }), + 123, + ), + ).toMatchObject({ ok: false, reason: "bot_author", targetKey: "acme/widgets#42" }); + expect(classifyPrCommandRequest(base({ sender: { login: "x", type: "Bot" } }), 123)).toMatchObject({ + ok: false, + reason: "bot_author", + }); + expect(classifyPrCommandRequest(base({ sender: { login: "renovate[bot]", type: "User" } }), 123)).toMatchObject({ + ok: false, + reason: "bot_author", + }); + }); + + it("skips when repo, PR issue, installation, or actor is missing", () => { + expect(classifyPrCommandRequest(base({ repository: undefined }), 123)).toMatchObject({ + ok: false, + reason: "missing_repo_pr_installation_or_actor", + repoFullName: null, + targetKey: null, + }); + expect(classifyPrCommandRequest(base({ issue: undefined }), 123)).toMatchObject({ + ok: false, + reason: "missing_repo_pr_installation_or_actor", + targetKey: "acme/widgets", + }); + expect( + classifyPrCommandRequest(base({ issue: { number: 42, title: "T", state: "open", body: "B" } }), 123), + ).toMatchObject({ + ok: false, + reason: "missing_repo_pr_installation_or_actor", + }); + expect(classifyPrCommandRequest(base(), null)).toMatchObject({ + ok: false, + reason: "missing_repo_pr_installation_or_actor", + }); + expect( + classifyPrCommandRequest( + base({ sender: undefined, comment: { id: 1, body: "@gittensory gate-override", user: undefined } }), + 123, + ), + ).toMatchObject({ + ok: false, + reason: "missing_repo_pr_installation_or_actor", + actor: null, + }); + }); +});