Compare commits

..

10 Commits

Author SHA1 Message Date
Ashwin Bhat
142a77fa55 chore: bump Bun to 2.1.1 and setup-bun action to v2.1.2
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Generated-By: Claude Code (cli/claude=100%)
Claude-Steers: 6
Claude-Permission-Prompts: 2
Claude-Escapes: 1
2026-01-20 13:03:43 -08:00
Ashwin Bhat
ba60ef7ba2 Consolidate CI workflows into a single entry point (#836)
* refactor: consolidate CI workflows with ci-all.yml orchestrator

- Add ci-all.yml to orchestrate all CI workflows on push to main
- Update individual workflows to use workflow_call for reusability
- Remove redundant push triggers from individual test workflows
- Update release.yml to trigger on CI All workflow completion
- Auto-release on version bump commits after CI passes

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Generated-By: Claude Code (cli/claude-opus-4-5=100%)
Claude-Steers: 8
Claude-Permission-Prompts: 1
Claude-Escapes: 0

* address security review comments

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-01-20 11:58:13 -08:00
GitHub Actions
f3c892ca8d chore: bump Claude Code to 2.1.11 and Agent SDK to 0.2.11 2026-01-17 01:44:05 +00:00
Ashwin Bhat
6e896a06bb fix: ensure SSH signing key has trailing newline (#834)
ssh-keygen requires a trailing newline to parse private keys correctly.
Without it, git signing fails with the confusing error:
'Couldn't load public key: No such file or directory?'

This normalizes the key to always end with a newline before writing.
2026-01-16 14:44:22 -08:00
Ashwin Bhat
a017b830c0 chore: comment out release-base-action job in release workflow (#833)
Temporarily disable the release-base-action job that syncs releases
to the claude-code-base-action repository. The job checkout step
and subsequent tag/release creation steps are now commented out.


Claude-Generated-By: Claude Code (cli/claude-opus-4-5=100%)
Claude-Steers: 2
Claude-Permission-Prompts: 2
Claude-Escapes: 0

Co-authored-by: Claude <noreply@anthropic.com>
2026-01-16 14:16:27 -08:00
GitHub Actions
75f52e56b2 chore: bump Claude Code to 2.1.9 and Agent SDK to 0.2.9 2026-01-16 02:18:38 +00:00
Ashwin Bhat
1bbc9e7ff7 fix: add checkHumanActor to agent mode (#826)
Fixes issue #641 where users were getting banned due to rapid successive
Claude runs triggered by the synchronize event.

Changes:
- Add checkHumanActor call to agent mode's prepare() method to reject
  bot-triggered workflows unless explicitly allowed via allowed_bots
- Update checkHumanActor to accept GitHubContext (union type) instead
  of just ParsedGitHubContext
- Add tests for bot rejection/allowance in agent mode

Claude-Generated-By: Claude Code (cli/claude-opus-4-5=100%)
Claude-Steers: 1
Claude-Permission-Prompts: 3
Claude-Escapes: 0
2026-01-15 10:28:46 -08:00
Ashwin Bhat
625ea1519c docs: clarify that Claude does not auto-create PRs by default (#824)
Add a new section to security.md explaining that in the default
configuration, Claude commits to a branch and provides a link for
the user to create the PR themselves, ensuring human oversight.


Claude-Generated-By: Claude Code (cli/claude-opus-4-5=100%)
Claude-Steers: 2
Claude-Permission-Prompts: 2
Claude-Escapes: 0

Co-authored-by: Claude <noreply@anthropic.com>
2026-01-14 15:22:40 -08:00
GitHub Actions
a9171f0ced chore: bump Claude Code to 2.1.7 and Agent SDK to 0.2.7 2026-01-14 00:03:29 +00:00
GitHub Actions
4778aeae4c chore: bump Claude Code to 2.1.6 and Agent SDK to 0.2.6 2026-01-13 02:25:17 +00:00
23 changed files with 246 additions and 696 deletions

37
.github/workflows/ci-all.yml vendored Normal file
View File

@@ -0,0 +1,37 @@
# Orchestrates all CI workflows - runs on PRs, pushes to main, and manual dispatch
# Individual test workflows are called as reusable workflows
name: CI All
on:
push:
branches:
- main
pull_request:
workflow_dispatch:
permissions:
contents: read
jobs:
ci:
uses: ./.github/workflows/ci.yml
test-base-action:
uses: ./.github/workflows/test-base-action.yml
secrets: inherit # Required for ANTHROPIC_API_KEY
test-custom-executables:
uses: ./.github/workflows/test-custom-executables.yml
secrets: inherit
test-mcp-servers:
uses: ./.github/workflows/test-mcp-servers.yml
secrets: inherit
test-settings:
uses: ./.github/workflows/test-settings.yml
secrets: inherit
test-structured-output:
uses: ./.github/workflows/test-structured-output.yml
secrets: inherit

View File

@@ -1,9 +1,8 @@
name: CI
on:
push:
branches: [main]
pull_request:
workflow_call:
jobs:
test:

View File

@@ -8,10 +8,23 @@ on:
required: false
type: boolean
default: false
workflow_run:
workflows: ["CI All"]
types:
- completed
branches:
- main
jobs:
create-release:
runs-on: ubuntu-latest
# Run if: manual dispatch OR (CI All succeeded AND commit is a version bump)
if: |
github.event_name == 'workflow_dispatch' ||
(github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.head_branch == 'main' &&
github.event.workflow_run.event == 'push' &&
startsWith(github.event.workflow_run.head_commit.message, 'chore: bump Claude Code to'))
environment: production
permissions:
contents: write
@@ -84,7 +97,8 @@ jobs:
update-major-tag:
needs: create-release
if: ${{ !inputs.dry_run }}
# Skip for dry runs (workflow_run events are never dry runs)
if: github.event_name == 'workflow_run' || !inputs.dry_run
runs-on: ubuntu-latest
environment: production
permissions:
@@ -109,48 +123,48 @@ jobs:
echo "Updated $major_version tag to point to $next_version"
release-base-action:
needs: create-release
if: ${{ !inputs.dry_run }}
runs-on: ubuntu-latest
environment: production
steps:
- name: Checkout base-action repo
uses: actions/checkout@v5
with:
repository: anthropics/claude-code-base-action
token: ${{ secrets.CLAUDE_CODE_BASE_ACTION_PAT }}
fetch-depth: 0
# - name: Create and push tag
# run: |
# next_version="${{ needs.create-release.outputs.next_version }}"
# git config user.name "github-actions[bot]"
# git config user.email "github-actions[bot]@users.noreply.github.com"
# # Create the version tag
# git tag -a "$next_version" -m "Release $next_version - synced from claude-code-action"
# git push origin "$next_version"
# # Update the beta tag
# git tag -fa beta -m "Update beta tag to ${next_version}"
# git push origin beta --force
# - name: Create GitHub release
# env:
# GH_TOKEN: ${{ secrets.CLAUDE_CODE_BASE_ACTION_PAT }}
# run: |
# next_version="${{ needs.create-release.outputs.next_version }}"
# # Create the release
# gh release create "$next_version" \
# --repo anthropics/claude-code-base-action \
# --title "$next_version" \
# --notes "Release $next_version - synced from anthropics/claude-code-action" \
# --latest=false
# # Update beta release to be latest
# gh release edit beta \
# --repo anthropics/claude-code-base-action \
# --latest
# release-base-action:
# needs: create-release
# if: ${{ !inputs.dry_run }}
# runs-on: ubuntu-latest
# environment: production
# steps:
# - name: Checkout base-action repo
# uses: actions/checkout@v5
# with:
# repository: anthropics/claude-code-base-action
# token: ${{ secrets.CLAUDE_CODE_BASE_ACTION_PAT }}
# fetch-depth: 0
#
# - name: Create and push tag
# run: |
# next_version="${{ needs.create-release.outputs.next_version }}"
#
# git config user.name "github-actions[bot]"
# git config user.email "github-actions[bot]@users.noreply.github.com"
#
# # Create the version tag
# git tag -a "$next_version" -m "Release $next_version - synced from claude-code-action"
# git push origin "$next_version"
#
# # Update the beta tag
# git tag -fa beta -m "Update beta tag to ${next_version}"
# git push origin beta --force
#
# - name: Create GitHub release
# env:
# GH_TOKEN: ${{ secrets.CLAUDE_CODE_BASE_ACTION_PAT }}
# run: |
# next_version="${{ needs.create-release.outputs.next_version }}"
#
# # Create the release
# gh release create "$next_version" \
# --repo anthropics/claude-code-base-action \
# --title "$next_version" \
# --notes "Release $next_version - synced from anthropics/claude-code-action" \
# --latest=false
#
# # Update beta release to be latest
# gh release edit beta \
# --repo anthropics/claude-code-base-action \
# --latest

View File

@@ -1,9 +1,6 @@
name: Test Claude Code Action
on:
push:
branches:
- main
pull_request:
workflow_dispatch:
inputs:
@@ -11,6 +8,7 @@ on:
description: "Test prompt for Claude"
required: false
default: "List the files in the current directory starting with 'package'"
workflow_call:
jobs:
test-inline-prompt:

View File

@@ -1,11 +1,9 @@
name: Test Custom Executables
on:
push:
branches:
- main
pull_request:
workflow_dispatch:
workflow_call:
jobs:
test-custom-executables:

View File

@@ -1,11 +1,9 @@
name: Test MCP Servers
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:
workflow_call:
jobs:
test-mcp-integration:

View File

@@ -1,11 +1,9 @@
name: Test Settings Feature
on:
push:
branches:
- main
pull_request:
workflow_dispatch:
workflow_call:
jobs:
test-settings-inline-allow:

View File

@@ -1,11 +1,9 @@
name: Test Structured Outputs
on:
push:
branches:
- main
pull_request:
workflow_dispatch:
workflow_call:
permissions:
contents: read

View File

@@ -148,9 +148,9 @@ runs:
steps:
- name: Install Bun
if: inputs.path_to_bun_executable == ''
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # https://github.com/oven-sh/setup-bun/releases/tag/v2.0.2
uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # https://github.com/oven-sh/setup-bun/releases/tag/v2.1.2
with:
bun-version: 1.2.11
bun-version: 2.1.1
- name: Setup Custom Bun Path
if: inputs.path_to_bun_executable != ''
@@ -213,7 +213,7 @@ runs:
# Install Claude Code if no custom executable is provided
if [ -z "$PATH_TO_CLAUDE_CODE_EXECUTABLE" ]; then
CLAUDE_CODE_VERSION="2.1.4"
CLAUDE_CODE_VERSION="2.1.11"
echo "Installing Claude Code v${CLAUDE_CODE_VERSION}..."
for attempt in 1 2 3; do
echo "Installation attempt $attempt..."

View File

@@ -97,9 +97,9 @@ runs:
- name: Install Bun
if: inputs.path_to_bun_executable == ''
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # https://github.com/oven-sh/setup-bun/releases/tag/v2.0.2
uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # https://github.com/oven-sh/setup-bun/releases/tag/v2.1.2
with:
bun-version: 1.2.11
bun-version: 2.1.1
- name: Setup Custom Bun Path
if: inputs.path_to_bun_executable != ''
@@ -124,7 +124,7 @@ runs:
PATH_TO_CLAUDE_CODE_EXECUTABLE: ${{ inputs.path_to_claude_code_executable }}
run: |
if [ -z "$PATH_TO_CLAUDE_CODE_EXECUTABLE" ]; then
CLAUDE_CODE_VERSION="2.1.4"
CLAUDE_CODE_VERSION="2.1.11"
echo "Installing Claude Code v${CLAUDE_CODE_VERSION}..."
for attempt in 1 2 3; do
echo "Installation attempt $attempt..."

View File

@@ -6,7 +6,7 @@
"name": "@anthropic-ai/claude-code-base-action",
"dependencies": {
"@actions/core": "^1.10.1",
"@anthropic-ai/claude-agent-sdk": "^0.2.4",
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
"shell-quote": "^1.8.3",
},
"devDependencies": {
@@ -27,7 +27,7 @@
"@actions/io": ["@actions/io@1.1.3", "", {}, "sha512-wi9JjgKLYS7U/z8PPbco+PvTb/nRWjeoFlJ1Qer83k/3C5PHQi28hiVdeE2kHXmIL99mQFawx8qt/JPjZilJ8Q=="],
"@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.2.4", "", { "optionalDependencies": { "@img/sharp-darwin-arm64": "^0.33.5", "@img/sharp-darwin-x64": "^0.33.5", "@img/sharp-linux-arm": "^0.33.5", "@img/sharp-linux-arm64": "^0.33.5", "@img/sharp-linux-x64": "^0.33.5", "@img/sharp-linuxmusl-arm64": "^0.33.5", "@img/sharp-linuxmusl-x64": "^0.33.5", "@img/sharp-win32-x64": "^0.33.5" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-5RpMO8aLEwuAd8h7/QHMCKzdVSihZCtHGnouPp+Isvc7zPzQXKb6GvUitkbs3wIBgIbXA/vXQmIi126uw9qo0A=="],
"@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.2.11", "", { "optionalDependencies": { "@img/sharp-darwin-arm64": "^0.33.5", "@img/sharp-darwin-x64": "^0.33.5", "@img/sharp-linux-arm": "^0.33.5", "@img/sharp-linux-arm64": "^0.33.5", "@img/sharp-linux-x64": "^0.33.5", "@img/sharp-linuxmusl-arm64": "^0.33.5", "@img/sharp-linuxmusl-x64": "^0.33.5", "@img/sharp-win32-x64": "^0.33.5" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-50q4vfh57HYTUrwukULp3gvSaOZfRF5zukxhWvW6mmUHuD8+Xwhqn59sZhoDz9ZUw6Um+1lUCgWpJw5/TTMn5w=="],
"@fastify/busboy": ["@fastify/busboy@2.1.1", "", {}, "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA=="],

View File

@@ -11,7 +11,7 @@
},
"dependencies": {
"@actions/core": "^1.10.1",
"@anthropic-ai/claude-agent-sdk": "^0.2.4",
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
"shell-quote": "^1.8.3"
},
"devDependencies": {

View File

@@ -7,7 +7,7 @@
"dependencies": {
"@actions/core": "^1.10.1",
"@actions/github": "^6.0.1",
"@anthropic-ai/claude-agent-sdk": "^0.2.4",
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
"@modelcontextprotocol/sdk": "^1.11.0",
"@octokit/graphql": "^8.2.2",
"@octokit/rest": "^21.1.1",
@@ -37,7 +37,7 @@
"@actions/io": ["@actions/io@1.1.3", "", {}, "sha512-wi9JjgKLYS7U/z8PPbco+PvTb/nRWjeoFlJ1Qer83k/3C5PHQi28hiVdeE2kHXmIL99mQFawx8qt/JPjZilJ8Q=="],
"@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.2.4", "", { "optionalDependencies": { "@img/sharp-darwin-arm64": "^0.33.5", "@img/sharp-darwin-x64": "^0.33.5", "@img/sharp-linux-arm": "^0.33.5", "@img/sharp-linux-arm64": "^0.33.5", "@img/sharp-linux-x64": "^0.33.5", "@img/sharp-linuxmusl-arm64": "^0.33.5", "@img/sharp-linuxmusl-x64": "^0.33.5", "@img/sharp-win32-x64": "^0.33.5" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-5RpMO8aLEwuAd8h7/QHMCKzdVSihZCtHGnouPp+Isvc7zPzQXKb6GvUitkbs3wIBgIbXA/vXQmIi126uw9qo0A=="],
"@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.2.11", "", { "optionalDependencies": { "@img/sharp-darwin-arm64": "^0.33.5", "@img/sharp-darwin-x64": "^0.33.5", "@img/sharp-linux-arm": "^0.33.5", "@img/sharp-linux-arm64": "^0.33.5", "@img/sharp-linux-x64": "^0.33.5", "@img/sharp-linuxmusl-arm64": "^0.33.5", "@img/sharp-linuxmusl-x64": "^0.33.5", "@img/sharp-win32-x64": "^0.33.5" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-50q4vfh57HYTUrwukULp3gvSaOZfRF5zukxhWvW6mmUHuD8+Xwhqn59sZhoDz9ZUw6Um+1lUCgWpJw5/TTMn5w=="],
"@fastify/busboy": ["@fastify/busboy@2.1.1", "", {}, "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA=="],

View File

@@ -13,6 +13,16 @@
- **No Cross-Repository Access**: Each action invocation is limited to the repository where it was triggered
- **Limited Scope**: The token cannot access other repositories or perform actions beyond the configured permissions
## Pull Request Creation
In its default configuration, **Claude does not create pull requests automatically** when responding to `@claude` mentions. Instead:
- Claude commits code changes to a new branch
- Claude provides a **link to the GitHub PR creation page** in its response
- **The user must click the link and create the PR themselves**, ensuring human oversight before any code is proposed for merging
This design ensures that users retain full control over what pull requests are created and can review the changes before initiating the PR workflow.
## ⚠️ Prompt Injection Risks
**Beware of potential hidden markdown when tagging Claude on untrusted content.** External contributors may include hidden instructions through HTML comments, invisible characters, hidden attributes, or other techniques. The action sanitizes content by stripping HTML comments, invisible characters, markdown image alt text, hidden HTML attributes, and HTML entities, but new bypass techniques may emerge. We recommend reviewing the raw content of all input coming from external contributors before allowing Claude to process it.

View File

@@ -12,7 +12,7 @@
"dependencies": {
"@actions/core": "^1.10.1",
"@actions/github": "^6.0.1",
"@anthropic-ai/claude-agent-sdk": "^0.2.4",
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
"@modelcontextprotocol/sdk": "^1.11.0",
"@octokit/graphql": "^8.2.2",
"@octokit/rest": "^21.1.1",

View File

@@ -82,8 +82,13 @@ export async function setupSshSigning(sshSigningKey: string): Promise<void> {
const sshDir = join(homedir(), ".ssh");
await mkdir(sshDir, { recursive: true, mode: 0o700 });
// Ensure key ends with newline (required for ssh-keygen to parse it)
const normalizedKey = sshSigningKey.endsWith("\n")
? sshSigningKey
: sshSigningKey + "\n";
// Write the signing key atomically with secure permissions (600)
await writeFile(SSH_SIGNING_KEY_PATH, sshSigningKey, { mode: 0o600 });
await writeFile(SSH_SIGNING_KEY_PATH, normalizedKey, { mode: 0o600 });
console.log(`✓ SSH signing key written to ${SSH_SIGNING_KEY_PATH}`);
// Configure git to use SSH signing

View File

@@ -6,11 +6,11 @@
*/
import type { Octokit } from "@octokit/rest";
import type { ParsedGitHubContext } from "../context";
import type { GitHubContext } from "../context";
export async function checkHumanActor(
octokit: Octokit,
githubContext: ParsedGitHubContext,
githubContext: GitHubContext,
) {
// Fetch user information from GitHub API
const { data: userData } = await octokit.users.getByUsername({

View File

@@ -10,11 +10,6 @@ import {
isPullRequestReviewCommentEvent,
} from "../context";
import type { ParsedGitHubContext } from "../context";
import {
detectActionableSuggestion,
isCommentActionableForAutofix,
type ActionableSuggestionResult,
} from "../../utils/detect-actionable-suggestion";
export function checkContainsTrigger(context: ParsedGitHubContext): boolean {
const {
@@ -151,89 +146,3 @@ export async function checkTriggerAction(context: ParsedGitHubContext) {
core.setOutput("contains_trigger", containsTrigger.toString());
return containsTrigger;
}
/**
* Checks if the context contains an actionable suggestion that can be automatically fixed.
* This is useful for autofix workflows that want to respond to code review suggestions,
* even when they come from bot accounts like claude[bot].
*
* @param context - The parsed GitHub context
* @returns Detection result with confidence level and reason
*/
export function checkContainsActionableSuggestion(
context: ParsedGitHubContext,
): ActionableSuggestionResult {
// Extract comment body based on event type
let commentBody: string | undefined;
if (isPullRequestReviewCommentEvent(context)) {
commentBody = context.payload.comment.body;
} else if (isIssueCommentEvent(context)) {
commentBody = context.payload.comment.body;
} else if (isPullRequestReviewEvent(context)) {
commentBody = context.payload.review.body ?? undefined;
}
return detectActionableSuggestion(commentBody);
}
/**
* Enhanced trigger check that also considers actionable suggestions.
* This function first checks for the standard trigger phrase, and if not found,
* optionally checks for actionable suggestions when `checkSuggestions` is true.
*
* @param context - The parsed GitHub context
* @param checkSuggestions - Whether to also check for actionable suggestions (default: false)
* @returns Whether the action should be triggered
*/
export function checkContainsTriggerOrActionableSuggestion(
context: ParsedGitHubContext,
checkSuggestions: boolean = false,
): boolean {
// First, check for standard trigger
if (checkContainsTrigger(context)) {
return true;
}
// If checkSuggestions is enabled, also check for actionable suggestions
if (checkSuggestions) {
const suggestionResult = checkContainsActionableSuggestion(context);
if (suggestionResult.isActionable) {
console.log(
`Comment contains actionable suggestion: ${suggestionResult.reason} (confidence: ${suggestionResult.confidence})`,
);
return true;
}
}
return false;
}
/**
* Checks if a PR comment is actionable for autofix purposes.
* This is a convenience function for workflows that want to automatically
* apply suggestions from code review comments.
*
* @param context - The parsed GitHub context
* @returns Whether the comment should be treated as actionable for autofix
*/
export function checkIsActionableForAutofix(
context: ParsedGitHubContext,
): boolean {
// Only applicable to PR review comment events
if (!isPullRequestReviewCommentEvent(context)) {
return false;
}
const commentBody = context.payload.comment.body;
const authorUsername = context.payload.comment.user?.login;
return isCommentActionableForAutofix(commentBody, authorUsername);
}
// Re-export the types and functions from the utility module for convenience
export {
detectActionableSuggestion,
isCommentActionableForAutofix,
type ActionableSuggestionResult,
} from "../../utils/detect-actionable-suggestion";

View File

@@ -8,6 +8,7 @@ import {
configureGitAuth,
setupSshSigning,
} from "../../github/operations/git-config";
import { checkHumanActor } from "../../github/validation/actor";
import type { GitHubContext } from "../../github/context";
import { isEntityContext } from "../../github/context";
@@ -80,7 +81,14 @@ export const agentMode: Mode = {
return false;
},
async prepare({ context, githubToken }: ModeOptions): Promise<ModeResult> {
async prepare({
context,
octokit,
githubToken,
}: ModeOptions): Promise<ModeResult> {
// Check if actor is human (prevents bot-triggered loops)
await checkHumanActor(octokit.rest, context);
// Configure git authentication for agent mode (same as tag mode)
// SSH signing takes precedence if provided
const useSshSigning = !!context.inputs.sshSigningKey;

View File

@@ -1,213 +0,0 @@
#!/usr/bin/env bun
/**
* Detects if a PR comment contains actionable suggestions that can be automatically fixed.
*
* This module identifies:
* 1. GitHub inline committable suggestions (```suggestion blocks)
* 2. Clear bug fix suggestions with specific patterns
* 3. Code fix recommendations with explicit changes
*/
/**
* Patterns that indicate a comment contains a GitHub inline committable suggestion.
* These are code blocks that GitHub renders with a "Commit suggestion" button.
*/
const COMMITTABLE_SUGGESTION_PATTERN = /```suggestion\b[\s\S]*?```/i;
/**
* Patterns that indicate a clear, actionable bug fix suggestion.
* These phrases typically precede concrete fix recommendations.
*/
const BUG_FIX_PATTERNS = [
// Direct fix suggestions
/\bshould\s+(?:be|use|return|change\s+to)\b/i,
/\bchange\s+(?:this\s+)?to\b/i,
/\breplace\s+(?:this\s+)?with\b/i,
/\buse\s+(?:this\s+)?instead\b/i,
/\binstead\s+of\s+.*?,?\s*use\b/i,
// Bug identification with fix
/\b(?:bug|issue|error|problem):\s*.*(?:fix|change|update|replace)/i,
/\bfix(?:ed)?\s+by\s+(?:chang|replac|updat)/i,
/\bto\s+fix\s+(?:this|the)\b/i,
// Explicit code changes
/\bthe\s+(?:correct|proper|right)\s+(?:code|syntax|value|approach)\s+(?:is|would\s+be)\b/i,
/\bshould\s+(?:read|look\s+like)\b/i,
// Missing/wrong patterns
/\bmissing\s+(?:a\s+)?(?:semicolon|bracket|parenthesis|quote|import|return|await)\b/i,
/\bextra\s+(?:semicolon|bracket|parenthesis|quote)\b/i,
/\bwrong\s+(?:type|value|variable|import|parameter)\b/i,
/\btypo\s+(?:in|here)\b/i,
];
/**
* Patterns that suggest code alternatives (less strong than direct fixes but still actionable).
*/
const CODE_ALTERNATIVE_PATTERNS = [
/```[\w]*\n[\s\S]+?\n```/, // Any code block (might contain the fix)
/\b(?:try|consider)\s+(?:using|changing|replacing)\b/i,
/\bhere'?s?\s+(?:the|a)\s+(?:fix|solution|correction)\b/i,
/\b(?:correct|fixed|updated)\s+(?:version|code|implementation)\b/i,
];
export interface ActionableSuggestionResult {
/** Whether the comment contains an actionable suggestion */
isActionable: boolean;
/** Whether the comment contains a GitHub inline committable suggestion */
hasCommittableSuggestion: boolean;
/** Whether the comment contains clear bug fix language */
hasBugFixSuggestion: boolean;
/** Whether the comment contains code alternatives */
hasCodeAlternative: boolean;
/** Confidence level: 'high', 'medium', or 'low' */
confidence: "high" | "medium" | "low";
/** Reason for the determination */
reason: string;
}
/**
* Detects if a comment contains actionable suggestions that can be automatically fixed.
*
* @param commentBody - The body of the PR comment to analyze
* @returns Object with detection results and confidence level
*
* @example
* ```ts
* const result = detectActionableSuggestion("```suggestion\nfixed code\n```");
* // { isActionable: true, hasCommittableSuggestion: true, confidence: 'high', ... }
*
* const result2 = detectActionableSuggestion("You should use `const` instead of `let` here");
* // { isActionable: true, hasBugFixSuggestion: true, confidence: 'medium', ... }
* ```
*/
export function detectActionableSuggestion(
commentBody: string | undefined | null,
): ActionableSuggestionResult {
if (!commentBody) {
return {
isActionable: false,
hasCommittableSuggestion: false,
hasBugFixSuggestion: false,
hasCodeAlternative: false,
confidence: "low",
reason: "Empty or missing comment body",
};
}
// Check for GitHub inline committable suggestion (highest confidence)
const hasCommittableSuggestion =
COMMITTABLE_SUGGESTION_PATTERN.test(commentBody);
if (hasCommittableSuggestion) {
return {
isActionable: true,
hasCommittableSuggestion: true,
hasBugFixSuggestion: false,
hasCodeAlternative: false,
confidence: "high",
reason: "Contains GitHub inline committable suggestion (```suggestion)",
};
}
// Check for clear bug fix patterns (medium-high confidence)
const matchedBugFixPattern = BUG_FIX_PATTERNS.find((pattern) =>
pattern.test(commentBody),
);
if (matchedBugFixPattern) {
// Higher confidence if also contains a code block
const hasCodeBlock = CODE_ALTERNATIVE_PATTERNS[0].test(commentBody);
return {
isActionable: true,
hasCommittableSuggestion: false,
hasBugFixSuggestion: true,
hasCodeAlternative: hasCodeBlock,
confidence: hasCodeBlock ? "high" : "medium",
reason: hasCodeBlock
? "Contains clear bug fix suggestion with code example"
: "Contains clear bug fix suggestion",
};
}
// Check for code alternatives (medium confidence)
const matchedAlternativePattern = CODE_ALTERNATIVE_PATTERNS.find((pattern) =>
pattern.test(commentBody),
);
if (matchedAlternativePattern) {
return {
isActionable: true,
hasCommittableSuggestion: false,
hasBugFixSuggestion: false,
hasCodeAlternative: true,
confidence: "medium",
reason: "Contains code alternative or fix suggestion",
};
}
return {
isActionable: false,
hasCommittableSuggestion: false,
hasBugFixSuggestion: false,
hasCodeAlternative: false,
confidence: "low",
reason: "No actionable suggestion patterns detected",
};
}
/**
* Checks if a comment should be treated as actionable for autofix purposes,
* even if it comes from a bot account like claude[bot].
*
* This is particularly useful for workflows that want to automatically apply
* suggestions from code review comments.
*
* @param commentBody - The body of the PR comment
* @param authorUsername - The username of the comment author
* @returns Whether the comment should be treated as actionable
*/
export function isCommentActionableForAutofix(
commentBody: string | undefined | null,
authorUsername?: string,
): boolean {
const result = detectActionableSuggestion(commentBody);
// If it's already clearly actionable (high confidence), return true
if (result.confidence === "high") {
return true;
}
// For medium confidence, be more lenient
if (result.confidence === "medium" && result.isActionable) {
return true;
}
return false;
}
/**
* Extracts the suggested code from a GitHub inline committable suggestion block.
*
* @param commentBody - The body of the PR comment
* @returns The suggested code content, or null if no suggestion block found
*
* @example
* ```ts
* const code = extractSuggestionCode("```suggestion\nconst x = 1;\n```");
* // "const x = 1;"
* ```
*/
export function extractSuggestionCode(
commentBody: string | undefined | null,
): string | null {
if (!commentBody) {
return null;
}
const match = commentBody.match(/```suggestion\b\n?([\s\S]*?)```/i);
if (match && match[1] !== undefined) {
return match[1].trim();
}
return null;
}

View File

@@ -1,309 +0,0 @@
import { describe, expect, it } from "bun:test";
import {
detectActionableSuggestion,
isCommentActionableForAutofix,
extractSuggestionCode,
} from "../src/utils/detect-actionable-suggestion";
describe("detectActionableSuggestion", () => {
describe("GitHub inline committable suggestions", () => {
it("should detect suggestion blocks with high confidence", () => {
const comment = `Here's a fix:
\`\`\`suggestion
const x = 1;
\`\`\``;
const result = detectActionableSuggestion(comment);
expect(result.isActionable).toBe(true);
expect(result.hasCommittableSuggestion).toBe(true);
expect(result.confidence).toBe("high");
expect(result.reason).toContain("committable suggestion");
});
it("should detect suggestion blocks with multiple lines", () => {
const comment = `\`\`\`suggestion
function foo() {
return bar();
}
\`\`\``;
const result = detectActionableSuggestion(comment);
expect(result.isActionable).toBe(true);
expect(result.hasCommittableSuggestion).toBe(true);
expect(result.confidence).toBe("high");
});
it("should detect suggestion blocks case-insensitively", () => {
const comment = `\`\`\`SUGGESTION
const x = 1;
\`\`\``;
const result = detectActionableSuggestion(comment);
expect(result.isActionable).toBe(true);
expect(result.hasCommittableSuggestion).toBe(true);
});
it("should not confuse regular code blocks with suggestion blocks", () => {
const comment = `\`\`\`javascript
const x = 1;
\`\`\``;
const result = detectActionableSuggestion(comment);
expect(result.hasCommittableSuggestion).toBe(false);
// But it should still detect the code alternative
expect(result.hasCodeAlternative).toBe(true);
});
});
describe("bug fix suggestions", () => {
it('should detect "should be" patterns', () => {
const comment = "This should be `const` instead of `let`";
const result = detectActionableSuggestion(comment);
expect(result.isActionable).toBe(true);
expect(result.hasBugFixSuggestion).toBe(true);
expect(result.confidence).toBe("medium");
});
it('should detect "change to" patterns', () => {
const comment = "Change this to use async/await";
const result = detectActionableSuggestion(comment);
expect(result.isActionable).toBe(true);
expect(result.hasBugFixSuggestion).toBe(true);
});
it('should detect "replace with" patterns', () => {
const comment = "Replace this with Array.from()";
const result = detectActionableSuggestion(comment);
expect(result.isActionable).toBe(true);
expect(result.hasBugFixSuggestion).toBe(true);
});
it('should detect "use instead" patterns', () => {
const comment = "Use this instead of the deprecated method";
const result = detectActionableSuggestion(comment);
expect(result.isActionable).toBe(true);
expect(result.hasBugFixSuggestion).toBe(true);
});
it('should detect "instead of X, use Y" patterns', () => {
const comment = "Instead of forEach, use map";
const result = detectActionableSuggestion(comment);
expect(result.isActionable).toBe(true);
expect(result.hasBugFixSuggestion).toBe(true);
});
it('should detect "to fix this" patterns', () => {
const comment = "To fix this, you need to add the await keyword";
const result = detectActionableSuggestion(comment);
expect(result.isActionable).toBe(true);
expect(result.hasBugFixSuggestion).toBe(true);
});
it('should detect "the correct code is" patterns', () => {
const comment = "The correct code would be: return null;";
const result = detectActionableSuggestion(comment);
expect(result.isActionable).toBe(true);
expect(result.hasBugFixSuggestion).toBe(true);
});
it('should detect "missing semicolon" patterns', () => {
const comment = "Missing a semicolon at the end";
const result = detectActionableSuggestion(comment);
expect(result.isActionable).toBe(true);
expect(result.hasBugFixSuggestion).toBe(true);
});
it('should detect "typo" patterns', () => {
const comment = "Typo here: teh should be the";
const result = detectActionableSuggestion(comment);
expect(result.isActionable).toBe(true);
expect(result.hasBugFixSuggestion).toBe(true);
});
it('should detect "wrong type" patterns', () => {
const comment = "Wrong type here, should be string not number";
const result = detectActionableSuggestion(comment);
expect(result.isActionable).toBe(true);
expect(result.hasBugFixSuggestion).toBe(true);
});
it("should have high confidence when bug fix suggestion includes code block", () => {
const comment = `You should use const here:
\`\`\`javascript
const x = 1;
\`\`\``;
const result = detectActionableSuggestion(comment);
expect(result.isActionable).toBe(true);
expect(result.hasBugFixSuggestion).toBe(true);
expect(result.hasCodeAlternative).toBe(true);
expect(result.confidence).toBe("high");
});
});
describe("code alternatives", () => {
it('should detect "try using" patterns', () => {
const comment = "Try using Array.map() instead";
const result = detectActionableSuggestion(comment);
expect(result.isActionable).toBe(true);
});
it('should detect "here\'s the fix" patterns', () => {
const comment = "Here's the fix for this issue";
const result = detectActionableSuggestion(comment);
expect(result.isActionable).toBe(true);
});
it("should detect code blocks as potential alternatives", () => {
const comment = `Try this approach:
\`\`\`
const result = [];
\`\`\``;
const result = detectActionableSuggestion(comment);
expect(result.isActionable).toBe(true);
expect(result.hasCodeAlternative).toBe(true);
});
});
describe("non-actionable comments", () => {
it("should not flag general questions", () => {
const comment = "Why is this returning undefined?";
const result = detectActionableSuggestion(comment);
expect(result.isActionable).toBe(false);
expect(result.confidence).toBe("low");
});
it("should not flag simple observations", () => {
const comment = "This looks interesting";
const result = detectActionableSuggestion(comment);
expect(result.isActionable).toBe(false);
});
it("should not flag approval comments", () => {
const comment = "LGTM! :+1:";
const result = detectActionableSuggestion(comment);
expect(result.isActionable).toBe(false);
});
it("should handle empty comments", () => {
const result = detectActionableSuggestion("");
expect(result.isActionable).toBe(false);
expect(result.reason).toContain("Empty");
});
it("should handle null comments", () => {
const result = detectActionableSuggestion(null);
expect(result.isActionable).toBe(false);
});
it("should handle undefined comments", () => {
const result = detectActionableSuggestion(undefined);
expect(result.isActionable).toBe(false);
});
});
describe("edge cases", () => {
it("should handle comments with both suggestion block and bug fix language", () => {
const comment = `This should be fixed. Here's the suggestion:
\`\`\`suggestion
const x = 1;
\`\`\``;
const result = detectActionableSuggestion(comment);
// Suggestion block takes precedence (high confidence)
expect(result.isActionable).toBe(true);
expect(result.hasCommittableSuggestion).toBe(true);
expect(result.confidence).toBe("high");
});
it("should handle very long comments", () => {
const longContent = "a".repeat(10000);
const comment = `${longContent}
\`\`\`suggestion
const x = 1;
\`\`\``;
const result = detectActionableSuggestion(comment);
expect(result.isActionable).toBe(true);
expect(result.hasCommittableSuggestion).toBe(true);
});
it("should handle comments with special characters", () => {
const comment =
"You should be using `const` here! @#$%^&* Change this to `let`";
const result = detectActionableSuggestion(comment);
expect(result.isActionable).toBe(true);
});
});
});
describe("isCommentActionableForAutofix", () => {
it("should return true for high confidence suggestions", () => {
const comment = `\`\`\`suggestion
const x = 1;
\`\`\``;
expect(isCommentActionableForAutofix(comment)).toBe(true);
});
it("should return true for medium confidence suggestions", () => {
const comment = "You should use const here instead of let";
expect(isCommentActionableForAutofix(comment)).toBe(true);
});
it("should return false for non-actionable comments", () => {
const comment = "This looks fine to me";
expect(isCommentActionableForAutofix(comment)).toBe(false);
});
it("should handle bot authors correctly", () => {
const comment = `\`\`\`suggestion
const x = 1;
\`\`\``;
// Should still return true even for bot authors
expect(isCommentActionableForAutofix(comment, "claude[bot]")).toBe(true);
});
it("should handle empty comments", () => {
expect(isCommentActionableForAutofix("")).toBe(false);
expect(isCommentActionableForAutofix(null)).toBe(false);
expect(isCommentActionableForAutofix(undefined)).toBe(false);
});
});
describe("extractSuggestionCode", () => {
it("should extract code from suggestion block", () => {
const comment = `Here's a fix:
\`\`\`suggestion
const x = 1;
\`\`\``;
expect(extractSuggestionCode(comment)).toBe("const x = 1;");
});
it("should extract multi-line code from suggestion block", () => {
const comment = `\`\`\`suggestion
function foo() {
return bar();
}
\`\`\``;
expect(extractSuggestionCode(comment)).toBe(
"function foo() {\n return bar();\n}",
);
});
it("should handle empty suggestion blocks", () => {
const comment = `\`\`\`suggestion
\`\`\``;
expect(extractSuggestionCode(comment)).toBe("");
});
it("should return null for comments without suggestion blocks", () => {
const comment = "Just a regular comment";
expect(extractSuggestionCode(comment)).toBe(null);
});
it("should return null for empty comments", () => {
expect(extractSuggestionCode("")).toBe(null);
expect(extractSuggestionCode(null)).toBe(null);
expect(extractSuggestionCode(undefined)).toBe(null);
});
it("should not extract from regular code blocks", () => {
const comment = `\`\`\`javascript
const x = 1;
\`\`\``;
expect(extractSuggestionCode(comment)).toBe(null);
});
});

View File

@@ -145,12 +145,12 @@ describe("Agent Mode", () => {
users: {
getAuthenticated: mock(() =>
Promise.resolve({
data: { login: "test-user", id: 12345 },
data: { login: "test-user", id: 12345, type: "User" },
}),
),
getByUsername: mock(() =>
Promise.resolve({
data: { login: "test-user", id: 12345 },
data: { login: "test-user", id: 12345, type: "User" },
}),
),
},
@@ -187,6 +187,65 @@ describe("Agent Mode", () => {
process.env.GITHUB_REF_NAME = originalRefName;
});
test("prepare method rejects bot actors without allowed_bots", async () => {
const contextWithPrompts = createMockAutomationContext({
eventName: "workflow_dispatch",
});
contextWithPrompts.actor = "claude[bot]";
contextWithPrompts.inputs.allowedBots = "";
const mockOctokit = {
rest: {
users: {
getByUsername: mock(() =>
Promise.resolve({
data: { login: "claude[bot]", id: 12345, type: "Bot" },
}),
),
},
},
} as any;
await expect(
agentMode.prepare({
context: contextWithPrompts,
octokit: mockOctokit,
githubToken: "test-token",
}),
).rejects.toThrow(
"Workflow initiated by non-human actor: claude (type: Bot)",
);
});
test("prepare method allows bot actors when in allowed_bots list", async () => {
const contextWithPrompts = createMockAutomationContext({
eventName: "workflow_dispatch",
});
contextWithPrompts.actor = "dependabot[bot]";
contextWithPrompts.inputs.allowedBots = "dependabot";
const mockOctokit = {
rest: {
users: {
getByUsername: mock(() =>
Promise.resolve({
data: { login: "dependabot[bot]", id: 12345, type: "Bot" },
}),
),
},
},
} as any;
// Should not throw - bot is in allowed list
await expect(
agentMode.prepare({
context: contextWithPrompts,
octokit: mockOctokit,
githubToken: "test-token",
}),
).resolves.toBeDefined();
});
test("prepare method creates prompt file with correct content", async () => {
const contextWithPrompts = createMockAutomationContext({
eventName: "workflow_dispatch",
@@ -199,12 +258,12 @@ describe("Agent Mode", () => {
users: {
getAuthenticated: mock(() =>
Promise.resolve({
data: { login: "test-user", id: 12345 },
data: { login: "test-user", id: 12345, type: "User" },
}),
),
getByUsername: mock(() =>
Promise.resolve({
data: { login: "test-user", id: 12345 },
data: { login: "test-user", id: 12345, type: "User" },
}),
),
},

View File

@@ -55,6 +55,47 @@ describe("SSH Signing", () => {
expect(permissions).toBe(0o600);
});
test("should normalize key to have trailing newline", async () => {
// ssh-keygen requires a trailing newline to parse the key
const keyWithoutNewline =
"-----BEGIN OPENSSH PRIVATE KEY-----\ntest-key-content\n-----END OPENSSH PRIVATE KEY-----";
const keyWithNewline = keyWithoutNewline + "\n";
// Create directory
await mkdir(testSshDir, { recursive: true, mode: 0o700 });
// Normalize the key (same logic as setupSshSigning)
const normalizedKey = keyWithoutNewline.endsWith("\n")
? keyWithoutNewline
: keyWithoutNewline + "\n";
await writeFile(testKeyPath, normalizedKey, { mode: 0o600 });
// Verify the written key ends with newline
const keyContent = await readFile(testKeyPath, "utf-8");
expect(keyContent).toBe(keyWithNewline);
expect(keyContent.endsWith("\n")).toBe(true);
});
test("should not add extra newline if key already has one", async () => {
const keyWithNewline =
"-----BEGIN OPENSSH PRIVATE KEY-----\ntest-key-content\n-----END OPENSSH PRIVATE KEY-----\n";
await mkdir(testSshDir, { recursive: true, mode: 0o700 });
// Normalize the key (same logic as setupSshSigning)
const normalizedKey = keyWithNewline.endsWith("\n")
? keyWithNewline
: keyWithNewline + "\n";
await writeFile(testKeyPath, normalizedKey, { mode: 0o600 });
// Verify no double newline
const keyContent = await readFile(testKeyPath, "utf-8");
expect(keyContent).toBe(keyWithNewline);
expect(keyContent.endsWith("\n\n")).toBe(false);
});
test("should create .ssh directory with secure permissions", async () => {
// Clean up first
await rm(testSshDir, { recursive: true, force: true });