Skip to content
Open
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
24 changes: 20 additions & 4 deletions src/entrypoints/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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<ReturnType<typeof prepareTagMode>>
| Awaited<ReturnType<typeof prepareAgentMode>>;

/**
* Install Claude Code CLI, handling retry logic and custom executable paths.
* Returns the absolute path to the claude executable.
Expand Down Expand Up @@ -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;
Expand Down
33 changes: 33 additions & 0 deletions src/github/validation/actor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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,
Expand Down Expand Up @@ -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.`,
Expand All @@ -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.`,
Expand Down
43 changes: 42 additions & 1 deletion test/actor.test.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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();
Expand Down