diff --git a/src/entrypoints/run.ts b/src/entrypoints/run.ts index c74527779..939fc8e39 100644 --- a/src/entrypoints/run.ts +++ b/src/entrypoints/run.ts @@ -26,6 +26,7 @@ import type { GitHubContext } from "../github/context"; import { detectMode } from "../modes/detector"; import { prepareTagMode } from "../modes/tag"; import { prepareAgentMode } from "../modes/agent"; +import { SelfTriggeringBotSkipError } from "../github/validation/actor"; import { checkContainsTrigger } from "../github/validation/trigger"; import { restoreConfigFromBase } from "../github/operations/restore-config"; import { validateBranchName } from "../github/operations/branch"; @@ -44,6 +45,10 @@ import { runClaude } from "../../base-action/src/run-claude"; import type { ClaudeRunResult } from "../../base-action/src/run-claude-sdk"; import { setExecutionFileOutputIfPresent } from "../../base-action/src/execution-file"; +type PrepareResult = + | Awaited> + | Awaited>; + /** * Install Claude Code CLI, handling retry logic and custom executable paths. * Returns the absolute path to the claude executable. @@ -215,10 +220,21 @@ async function run() { console.log( `Preparing with mode: ${modeName} for event: ${context.eventName}`, ); - const prepareResult = - modeName === "tag" - ? await prepareTagMode({ context, octokit, githubToken }) - : await prepareAgentMode({ context, octokit, githubToken }); + let prepareResult: PrepareResult; + try { + prepareResult = + modeName === "tag" + ? await prepareTagMode({ context, octokit, githubToken }) + : await prepareAgentMode({ context, octokit, githubToken }); + } catch (error) { + if (error instanceof SelfTriggeringBotSkipError) { + console.log(error.message); + core.setOutput("github_token", githubToken); + core.setOutput("conclusion", "skipped"); + return; + } + throw error; + } commentId = prepareResult.commentId; claudeBranch = prepareResult.branchInfo.claudeBranch; diff --git a/src/github/validation/actor.ts b/src/github/validation/actor.ts index 110048b33..505dc0a92 100644 --- a/src/github/validation/actor.ts +++ b/src/github/validation/actor.ts @@ -8,6 +8,15 @@ import type { Octokit } from "@octokit/rest"; import type { GitHubContext } from "../context"; +export class SelfTriggeringBotSkipError extends Error { + constructor(actor: string) { + super( + `Workflow triggered by ${actor}'s own pull_request synchronize event; skipping to avoid a bot feedback loop.`, + ); + this.name = "SelfTriggeringBotSkipError"; + } +} + function isAllowedBot(actor: string, allowedBots: string): boolean { const trimmed = allowedBots.trim(); if (trimmed === "*") return true; @@ -27,6 +36,24 @@ function isAllowedBot(actor: string, allowedBots: string): boolean { return allowedList.includes(normalizedActor); } +function normalizeBotName(name: string): string { + return name + .trim() + .toLowerCase() + .replace(/\[bot\]$/, ""); +} + +function isConfiguredBotPullRequestSync( + actor: string, + githubContext: GitHubContext, +): boolean { + return ( + githubContext.eventName === "pull_request" && + githubContext.eventAction === "synchronize" && + normalizeBotName(actor) === normalizeBotName(githubContext.inputs.botName) + ); +} + export async function checkHumanActor( octokit: Octokit, githubContext: GitHubContext, @@ -57,6 +84,9 @@ export async function checkHumanActor( ); return; } + if (isConfiguredBotPullRequestSync(actor, githubContext)) { + throw new SelfTriggeringBotSkipError(actor); + } const botName = actor.toLowerCase().replace(/\[bot\]$/, ""); throw new Error( `Workflow initiated by non-human actor: ${botName} (actor not found on GitHub). Add bot to allowed_bots list or use '*' to allow all bots.`, @@ -75,6 +105,9 @@ export async function checkHumanActor( ); return; } + if (isConfiguredBotPullRequestSync(actor, githubContext)) { + throw new SelfTriggeringBotSkipError(actor); + } const botName = actor.toLowerCase().replace(/\[bot\]$/, ""); throw new Error( `Workflow initiated by non-human actor: ${botName} (type: ${actorType}). Add bot to allowed_bots list or use '*' to allow all bots.`, diff --git a/test/actor.test.ts b/test/actor.test.ts index 408a6e7cc..96a2229a5 100644 --- a/test/actor.test.ts +++ b/test/actor.test.ts @@ -1,7 +1,10 @@ #!/usr/bin/env bun import { describe, test, expect } from "bun:test"; -import { checkHumanActor } from "../src/github/validation/actor"; +import { + checkHumanActor, + SelfTriggeringBotSkipError, +} from "../src/github/validation/actor"; import type { Octokit } from "@octokit/rest"; import { createMockContext } from "./mockContext"; @@ -39,6 +42,44 @@ describe("checkHumanActor", () => { ); }); + test("should skip configured bot pull_request synchronize events", async () => { + const mockOctokit = createMockOctokit("Bot"); + const context = createMockContext({ + eventName: "pull_request", + eventAction: "synchronize", + isPR: true, + }); + context.actor = "claude[bot]"; + context.inputs.allowedBots = ""; + + let error: unknown; + try { + await checkHumanActor(mockOctokit, context); + } catch (caught) { + error = caught; + } + + expect(error).toBeInstanceOf(SelfTriggeringBotSkipError); + expect((error as Error).message).toContain( + "skipping to avoid a bot feedback loop", + ); + }); + + test("should honor explicit allowed_bots for configured bot pull_request synchronize events", async () => { + const mockOctokit = createMockOctokit("Bot"); + const context = createMockContext({ + eventName: "pull_request", + eventAction: "synchronize", + isPR: true, + }); + context.actor = "claude[bot]"; + context.inputs.allowedBots = "claude"; + + await expect( + checkHumanActor(mockOctokit, context), + ).resolves.toBeUndefined(); + }); + test("should pass for bot actor when all bots allowed", async () => { const mockOctokit = createMockOctokit("Bot"); const context = createMockContext();