Compare commits

...

9 Commits

Author SHA1 Message Date
GitHub Actions
1f6e3225b0 chore: bump Claude Code version to 1.0.64 2025-07-30 21:49:34 +00:00
atsushi-ishibashi
6672e9b357 Remove empty XML tags in Issue context to reduce token usage (#369)
* chore: remove empty xml tags

* format
2025-07-30 07:30:32 -07:00
aki77
950bdc01df fix: update GitHub MCP server tool name for PR review comments (#363)
Update add_pull_request_review_comment_to_pending_review to add_comment_to_pending_review
  following upstream change in github/github-mcp-server#697

  - Update .github/workflows/claude-review.yml
  - Update examples/claude-auto-review.yml
2025-07-30 07:20:20 -07:00
atsushi-ishibashi
15dd796e97 use total_cost_usd (#366) 2025-07-30 07:19:29 -07:00
atsushi-ishibashi
fd012347a2 feat: exclude hidden (minimized) comments from GitHub Issues and PRs (#368)
* feat: ignore minimized comments

* fix tests
2025-07-30 07:18:34 -07:00
Ashwin Bhat
5bdc533a52 docs: enhance CLAUDE.md with comprehensive architecture overview (#362)
* docs: enhance CLAUDE.md with comprehensive architecture overview

- Add detailed two-phase execution architecture documentation
- Document mode system (tag/agent) and extensible registry pattern
- Include comprehensive GitHub integration layer breakdown
- Add MCP server architecture and authentication flow details
- Document branch strategy, comment threading, and code conventions
- Provide complete project structure with component descriptions

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* docs: clarify base-action dual purpose and remove branch strategy

- Explain base-action serves as both standalone published action and internal logic
- Remove branch strategy section as requested
- Improve architecture documentation clarity

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-07-29 18:03:45 -07:00
YutaSaito
d45539c118 fix: move env var before image name in docker run for github-mcp-server (#361)
In the previous commit (e07ea013bd), the GITHUB_HOST variable was placed after the image name in the Docker run command, which caused a runtime error. This commit moves the -e option before the image name so it is correctly passed into the container.
2025-07-29 16:22:39 -07:00
km-anthropic
daac7e353f refactor: implement discriminated unions for GitHub contexts (#360)
* feat: add agent mode for automation scenarios

- Add agent mode that always triggers without checking for mentions
- Implement Mode interface with support for mode-specific tool configuration
- Add getAllowedTools() and getDisallowedTools() methods to Mode interface
- Simplify tests by combining related test cases
- Update documentation and examples to include agent mode
- Fix TypeScript imports to prevent circular dependencies

Agent mode is designed for automation and workflow_dispatch scenarios
where Claude should always run without requiring trigger phrases.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Minor update to readme (from @main to @beta)

* Since workflow_dispatch isn't in the base action, update the examples accordingly

* minor formatting issue

* Update to say beta instead of main

* Fix missed tracking comment to be false

* add schedule & workflow dispatch paths. Also make prepare logic conditional

* tests

* Add test workflow for workflow_dispatch functionality

* Update workflow to use correct branch reference

* remove test workflow dispatch file

* minor lint update

* update workflow dispatch agent example

* minor lint update

* refactor: simplify prepare logic with mode-specific implementations

* ensure tag mode can't work with workflow dispatch and schedule tasks

* simplify: remove workflow_dispatch/schedule from create-prompt

- Remove workflow_dispatch and schedule event handling from create-prompt
  since agent mode doesn't use the standard prompt generation flow
- Enforce mode compatibility at selection time in the registry instead
  of runtime validation in tag mode
- Add explanatory comment in agent mode about why prompt file is needed
- Update tests to reflect simplified event handling

This reduces code duplication and makes the separation between tag mode
(entity-based events) and agent mode (automation events) clearer.

* simplify PR by making agent mode only work with workflow dispatch and schedule events

* remove unnecessary changes

* remove unnecessary changes from PR

- Revert update-comment-link.ts changes (agent mode doesn't use this)
- Revert create-initial.ts changes (agent mode doesn't create comments)
- Remove unused default-branch.ts file
- Revert install-mcp-server.ts changes (agent mode uses minimal MCP)

These files are only used by tag mode for entity-based events, not needed
for workflow_dispatch/schedule support via agent mode.

* fix: handle optional entityNumber for TypeScript

- Add runtime checks in files that require entityNumber
- These files are only used by tag mode which always has entityNumber
- Agent mode (workflow_dispatch/schedule) doesn't use these files

* linting update

* refactor: implement discriminated unions for GitHub contexts

Split ParsedGitHubContext into entity-specific and automation contexts:
- ParsedGitHubContext: For entity events (issues/PRs) with required entityNumber and isPR
- AutomationContext: For workflow_dispatch/schedule events without entity fields
- GitHubContext: Union type for all contexts

This eliminates ~20 null checks throughout the codebase and provides better type safety.
Entity-specific code paths are now guaranteed to have the required fields.

Co-Authored-By: Claude <noreply@anthropic.com>

* update comment

* More robust type checking

* refactor: improve discriminated union implementation based on review feedback

- Use eventName checks instead of 'in' operator for more robust type guards
- Remove unnecessary type assertions - TypeScript's control flow analysis works correctly
- Remove redundant runtime checks for entityNumber and isPR
- Simplify code by using context directly after type guard

Co-Authored-By: Claude <noreply@anthropic.com>

* some structural simplification

* refactor: further simplify discriminated union implementation

- Add event name constants to reduce duplication
- Derive EntityEventName and AutomationEventName types from constants
- Use isAutomationContext consistently in agent mode and registry
- Simplify parseGitHubContext by removing redundant type assertions
- Extract payload casts to variables for cleaner code

Co-Authored-By: Claude <noreply@anthropic.com>

* bun format

* specify the type

* minor linting update again

---------

Co-authored-by: km-anthropic <km-anthropic@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
2025-07-29 14:58:59 -07:00
GitHub Actions
bdfdd1f788 chore: bump Claude Code version to 1.0.63 2025-07-29 21:43:48 +00:00
27 changed files with 515 additions and 178 deletions

View File

@@ -30,4 +30,4 @@ jobs:
Be constructive and specific in your feedback. Give inline comments where applicable.
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
allowed_tools: "mcp__github__create_pending_pull_request_review,mcp__github__add_pull_request_review_comment_to_pending_review,mcp__github__submit_pending_pull_request_review,mcp__github__get_pull_request_diff"
allowed_tools: "mcp__github__create_pending_pull_request_review,mcp__github__add_comment_to_pending_review,mcp__github__submit_pending_pull_request_review,mcp__github__get_pull_request_diff"

128
CLAUDE.md
View File

@@ -1,10 +1,11 @@
# CLAUDE.md
This file provides guidance to Claude Code when working with code in this repository.
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Development Tools
- Runtime: Bun 1.2.11
- TypeScript with strict configuration
## Common Development Tasks
@@ -17,42 +18,119 @@ bun test
# Formatting
bun run format # Format code with prettier
bun run format:check # Check code formatting
# Type checking
bun run typecheck # Run TypeScript type checker
```
## Architecture Overview
This is a GitHub Action that enables Claude to interact with GitHub PRs and issues. The action:
This is a GitHub Action that enables Claude to interact with GitHub PRs and issues. The action operates in two main phases:
1. **Trigger Detection**: Uses `check-trigger.ts` to determine if Claude should respond based on comment/issue content
2. **Context Gathering**: Fetches GitHub data (PRs, issues, comments) via `github-data-fetcher.ts` and formats it using `github-data-formatter.ts`
3. **AI Integration**: Supports multiple Claude providers (Anthropic API, AWS Bedrock, Google Vertex AI)
4. **Prompt Creation**: Generates context-rich prompts using `create-prompt.ts`
5. **MCP Server Integration**: Installs and configures GitHub MCP server for extended functionality
### Phase 1: Preparation (`src/entrypoints/prepare.ts`)
### Key Components
1. **Authentication Setup**: Establishes GitHub token via OIDC or GitHub App
2. **Permission Validation**: Verifies actor has write permissions
3. **Trigger Detection**: Uses mode-specific logic to determine if Claude should respond
4. **Context Creation**: Prepares GitHub context and initial tracking comment
- **Trigger System**: Responds to `/claude` comments or issue assignments
- **Authentication**: OIDC-based token exchange for secure GitHub interactions
- **Cloud Integration**: Supports direct Anthropic API, AWS Bedrock, and Google Vertex AI
- **GitHub Operations**: Creates branches, posts comments, and manages PRs/issues
### Phase 2: Execution (`base-action/`)
The `base-action/` directory contains the core Claude Code execution logic, which serves a dual purpose:
- **Standalone Action**: Published separately as `@anthropic-ai/claude-code-base-action` for direct use
- **Inner Logic**: Used internally by this GitHub Action after preparation phase completes
Execution steps:
1. **MCP Server Setup**: Installs and configures GitHub MCP server for tool access
2. **Prompt Generation**: Creates context-rich prompts from GitHub data
3. **Claude Integration**: Executes via multiple providers (Anthropic API, AWS Bedrock, Google Vertex AI)
4. **Result Processing**: Updates comments and creates branches/PRs as needed
### Key Architectural Components
#### Mode System (`src/modes/`)
- **Tag Mode** (`tag/`): Responds to `@claude` mentions and issue assignments
- **Agent Mode** (`agent/`): Automated execution without trigger checking
- Extensible registry pattern in `modes/registry.ts`
#### GitHub Integration (`src/github/`)
- **Context Parsing** (`context.ts`): Unified GitHub event handling
- **Data Fetching** (`data/fetcher.ts`): Retrieves PR/issue data via GraphQL/REST
- **Data Formatting** (`data/formatter.ts`): Converts GitHub data to Claude-readable format
- **Branch Operations** (`operations/branch.ts`): Handles branch creation and cleanup
- **Comment Management** (`operations/comments/`): Creates and updates tracking comments
#### MCP Server Integration (`src/mcp/`)
- **GitHub Actions Server** (`github-actions-server.ts`): Workflow and CI access
- **GitHub Comment Server** (`github-comment-server.ts`): Comment operations
- **GitHub File Operations** (`github-file-ops-server.ts`): File system access
- Auto-installation and configuration in `install-mcp-server.ts`
#### Authentication & Security (`src/github/`)
- **Token Management** (`token.ts`): OIDC token exchange and GitHub App authentication
- **Permission Validation** (`validation/permissions.ts`): Write access verification
- **Actor Validation** (`validation/actor.ts`): Human vs bot detection
### Project Structure
```
src/
├── check-trigger.ts # Determines if Claude should respond
├── create-prompt.ts # Generates contextual prompts
├── github-data-fetcher.ts # Retrieves GitHub data
├── github-data-formatter.ts # Formats GitHub data for prompts
├── install-mcp-server.ts # Sets up GitHub MCP server
├── update-comment-with-link.ts # Updates comments with job links
└── types/
── github.ts # TypeScript types for GitHub data
├── entrypoints/ # Action entry points
├── prepare.ts # Main preparation logic
│ ├── update-comment-link.ts # Post-execution comment updates
│ └── format-turns.ts # Claude conversation formatting
├── github/ # GitHub integration layer
│ ├── api/ # REST/GraphQL clients
│ ├── data/ # Data fetching and formatting
── operations/ # Branch, comment, git operations
│ ├── validation/ # Permission and trigger validation
│ └── utils/ # Image downloading, sanitization
├── modes/ # Execution modes
│ ├── tag/ # @claude mention mode
│ ├── agent/ # Automation mode
│ └── registry.ts # Mode selection logic
├── mcp/ # MCP server implementations
├── prepare/ # Preparation orchestration
└── utils/ # Shared utilities
```
## Important Notes
## Important Implementation Notes
- Actions are triggered by `@claude` comments or issue assignment unless a different trigger_phrase is specified
- The action creates branches for issues and pushes to PR branches directly
- All actions create OIDC tokens for secure authentication
- Progress is tracked through dynamic comment updates with checkboxes
### Authentication Flow
- Uses GitHub OIDC token exchange for secure authentication
- Supports custom GitHub Apps via `APP_ID` and `APP_PRIVATE_KEY`
- Falls back to official Claude GitHub App if no custom app provided
### MCP Server Architecture
- Each MCP server has specific GitHub API access patterns
- Servers are auto-installed in `~/.claude/mcp/github-{type}-server/`
- Configuration merged with user-provided MCP config via `mcp_config` input
### Mode System Design
- Modes implement `Mode` interface with `shouldTrigger()` and `prepare()` methods
- Registry validates mode compatibility with GitHub event types
- Agent mode bypasses all trigger checking for automation scenarios
### Comment Threading
- Single tracking comment updated throughout execution
- Progress indicated via dynamic checkboxes
- Links to job runs and created branches/PRs
- Sticky comment option for consolidated PR comments
## Code Conventions
- Use Bun-specific TypeScript configuration with `moduleResolution: "bundler"`
- Strict TypeScript with `noUnusedLocals` and `noUnusedParameters` enabled
- Prefer explicit error handling with detailed error messages
- Use discriminated unions for GitHub context types
- Implement retry logic for GitHub API operations via `utils/retry.ts`

View File

@@ -172,7 +172,7 @@ runs:
echo "Base-action dependencies installed"
cd -
# Install Claude Code globally
bun install -g @anthropic-ai/claude-code@1.0.62
bun install -g @anthropic-ai/claude-code@1.0.64
- name: Setup Network Restrictions
if: steps.prepare.outputs.contains_trigger == 'true' && inputs.experimental_allowed_domains != ''

View File

@@ -115,7 +115,7 @@ runs:
- name: Install Claude Code
shell: bash
run: bun install -g @anthropic-ai/claude-code@1.0.62
run: bun install -g @anthropic-ai/claude-code@1.0.64
- name: Run Claude Code Action
shell: bash

View File

@@ -35,4 +35,4 @@ jobs:
Provide constructive feedback with specific suggestions for improvement.
Use inline comments to highlight specific areas of concern.
# allowed_tools: "mcp__github__create_pending_pull_request_review,mcp__github__add_pull_request_review_comment_to_pending_review,mcp__github__submit_pending_pull_request_review,mcp__github__get_pull_request_diff"
# allowed_tools: "mcp__github__create_pending_pull_request_review,mcp__github__add_comment_to_pending_review,mcp__github__submit_pending_pull_request_review,mcp__github__get_pull_request_diff"

View File

@@ -125,10 +125,8 @@ export function prepareContext(
const isPR = context.isPR;
// Get PR/Issue number from entityNumber
const prNumber =
isPR && context.entityNumber ? context.entityNumber.toString() : undefined;
const issueNumber =
!isPR && context.entityNumber ? context.entityNumber.toString() : undefined;
const prNumber = isPR ? context.entityNumber.toString() : undefined;
const issueNumber = !isPR ? context.entityNumber.toString() : undefined;
// Extract trigger username and comment data based on event type
let triggerUsername: string | undefined;
@@ -589,23 +587,28 @@ ${formattedBody}
${formattedComments || "No comments"}
</comments>
<review_comments>
${eventData.isPR ? formattedReviewComments || "No review comments" : ""}
</review_comments>
${
eventData.isPR
? `<review_comments>
${formattedReviewComments || "No review comments"}
</review_comments>`
: ""
}
<changed_files>
${eventData.isPR ? formattedChangedFiles || "No files changed" : ""}
</changed_files>${imagesInfo}
${
eventData.isPR
? `<changed_files>
${formattedChangedFiles || "No files changed"}
</changed_files>`
: ""
}${imagesInfo}
<event_type>${eventType}</event_type>
<is_pr>${eventData.isPR ? "true" : "false"}</is_pr>
<trigger_context>${triggerContext}</trigger_context>
<repository>${context.repository}</repository>
${
eventData.isPR
? `<pr_number>${eventData.prNumber}</pr_number>`
: `<issue_number>${eventData.issueNumber ?? ""}</issue_number>`
}
${eventData.isPR && eventData.prNumber ? `<pr_number>${eventData.prNumber}</pr_number>` : ""}
${!eventData.isPR && eventData.issueNumber ? `<issue_number>${eventData.issueNumber}</issue_number>` : ""}
<claude_comment_id>${context.claudeCommentId}</claude_comment_id>
<trigger_username>${context.triggerUsername ?? "Unknown"}</trigger_username>
<trigger_display_name>${githubData.triggerDisplayName ?? context.triggerUsername ?? "Unknown"}</trigger_display_name>

View File

@@ -393,7 +393,7 @@ export function formatGroupedContent(groupedContent: GroupedContent[]): string {
markdown += "---\n\n";
} else if (itemType === "final_result") {
const data = item.data || {};
const cost = (data as any).cost_usd || 0;
const cost = (data as any).total_cost_usd || (data as any).cost_usd || 0;
const duration = (data as any).duration_ms || 0;
const resultText = (data as any).result || "";

View File

@@ -9,7 +9,7 @@ import * as core from "@actions/core";
import { setupGitHubToken } from "../github/token";
import { checkWritePermissions } from "../github/validation/permissions";
import { createOctokit } from "../github/api/client";
import { parseGitHubContext } from "../github/context";
import { parseGitHubContext, isEntityContext } from "../github/context";
import { getMode } from "../modes/registry";
import { prepare } from "../prepare";
@@ -22,15 +22,17 @@ async function run() {
// Step 2: Parse GitHub context (once for all operations)
const context = parseGitHubContext();
// Step 3: Check write permissions
const hasWritePermissions = await checkWritePermissions(
octokit.rest,
context,
);
if (!hasWritePermissions) {
throw new Error(
"Actor does not have write permissions to the repository",
// Step 3: Check write permissions (only for entity contexts)
if (isEntityContext(context)) {
const hasWritePermissions = await checkWritePermissions(
octokit.rest,
context,
);
if (!hasWritePermissions) {
throw new Error(
"Actor does not have write permissions to the repository",
);
}
}
// Step 4: Get mode and check trigger conditions

View File

@@ -9,6 +9,7 @@ import {
import {
parseGitHubContext,
isPullRequestReviewCommentEvent,
isEntityContext,
} from "../github/context";
import { GITHUB_SERVER_URL } from "../github/api/config";
import { checkAndCommitOrDeleteBranch } from "../github/operations/branch-cleanup";
@@ -23,13 +24,13 @@ async function run() {
const triggerUsername = process.env.TRIGGER_USERNAME;
const context = parseGitHubContext();
const { owner, repo } = context.repository;
// This script is only called for entity-based events
if (!context.entityNumber) {
throw new Error("update-comment-link requires an entity number");
if (!isEntityContext(context)) {
throw new Error("update-comment-link requires an entity context");
}
const entityNumber = context.entityNumber;
const { owner, repo } = context.repository;
const octokit = createOctokit(githubToken);
@@ -80,7 +81,7 @@ async function run() {
const { data: pr } = await octokit.rest.pulls.get({
owner,
repo,
pull_number: entityNumber,
pull_number: context.entityNumber,
});
console.log(`PR state: ${pr.state}`);
console.log(`PR comments count: ${pr.comments}`);

View File

@@ -46,6 +46,7 @@ export const PR_QUERY = `
login
}
createdAt
isMinimized
}
}
reviews(first: 100) {
@@ -69,6 +70,7 @@ export const PR_QUERY = `
login
}
createdAt
isMinimized
}
}
}
@@ -98,6 +100,7 @@ export const ISSUE_QUERY = `
login
}
createdAt
isMinimized
}
}
}

View File

@@ -37,9 +37,24 @@ export type ScheduleEvent = {
import type { ModeName } from "../modes/types";
import { DEFAULT_MODE, isValidMode } from "../modes/registry";
export type ParsedGitHubContext = {
// Event name constants for better maintainability
const ENTITY_EVENT_NAMES = [
"issues",
"issue_comment",
"pull_request",
"pull_request_review",
"pull_request_review_comment",
] as const;
const AUTOMATION_EVENT_NAMES = ["workflow_dispatch", "schedule"] as const;
// Derive types from constants for better maintainability
type EntityEventName = (typeof ENTITY_EVENT_NAMES)[number];
type AutomationEventName = (typeof AUTOMATION_EVENT_NAMES)[number];
// Common fields shared by all context types
type BaseContext = {
runId: string;
eventName: string;
eventAction?: string;
repository: {
owner: string;
@@ -47,16 +62,6 @@ export type ParsedGitHubContext = {
full_name: string;
};
actor: string;
payload:
| IssuesEvent
| IssueCommentEvent
| PullRequestEvent
| PullRequestReviewEvent
| PullRequestReviewCommentEvent
| WorkflowDispatchEvent
| ScheduleEvent;
entityNumber?: number;
isPR?: boolean;
inputs: {
mode: ModeName;
triggerPhrase: string;
@@ -75,7 +80,29 @@ export type ParsedGitHubContext = {
};
};
export function parseGitHubContext(): ParsedGitHubContext {
// Context for entity-based events (issues, PRs, comments)
export type ParsedGitHubContext = BaseContext & {
eventName: EntityEventName;
payload:
| IssuesEvent
| IssueCommentEvent
| PullRequestEvent
| PullRequestReviewEvent
| PullRequestReviewCommentEvent;
entityNumber: number;
isPR: boolean;
};
// Context for automation events (workflow_dispatch, schedule)
export type AutomationContext = BaseContext & {
eventName: AutomationEventName;
payload: WorkflowDispatchEvent | ScheduleEvent;
};
// Union type for all contexts
export type GitHubContext = ParsedGitHubContext | AutomationContext;
export function parseGitHubContext(): GitHubContext {
const context = github.context;
const modeInput = process.env.MODE ?? DEFAULT_MODE;
@@ -85,7 +112,6 @@ export function parseGitHubContext(): ParsedGitHubContext {
const commonFields = {
runId: process.env.GITHUB_RUN_ID!,
eventName: context.eventName,
eventAction: context.payload.action,
repository: {
owner: context.repo.owner,
@@ -115,61 +141,67 @@ export function parseGitHubContext(): ParsedGitHubContext {
switch (context.eventName) {
case "issues": {
const payload = context.payload as IssuesEvent;
return {
...commonFields,
payload: context.payload as IssuesEvent,
entityNumber: (context.payload as IssuesEvent).issue.number,
eventName: "issues",
payload,
entityNumber: payload.issue.number,
isPR: false,
};
}
case "issue_comment": {
const payload = context.payload as IssueCommentEvent;
return {
...commonFields,
payload: context.payload as IssueCommentEvent,
entityNumber: (context.payload as IssueCommentEvent).issue.number,
isPR: Boolean(
(context.payload as IssueCommentEvent).issue.pull_request,
),
eventName: "issue_comment",
payload,
entityNumber: payload.issue.number,
isPR: Boolean(payload.issue.pull_request),
};
}
case "pull_request": {
const payload = context.payload as PullRequestEvent;
return {
...commonFields,
payload: context.payload as PullRequestEvent,
entityNumber: (context.payload as PullRequestEvent).pull_request.number,
eventName: "pull_request",
payload,
entityNumber: payload.pull_request.number,
isPR: true,
};
}
case "pull_request_review": {
const payload = context.payload as PullRequestReviewEvent;
return {
...commonFields,
payload: context.payload as PullRequestReviewEvent,
entityNumber: (context.payload as PullRequestReviewEvent).pull_request
.number,
eventName: "pull_request_review",
payload,
entityNumber: payload.pull_request.number,
isPR: true,
};
}
case "pull_request_review_comment": {
const payload = context.payload as PullRequestReviewCommentEvent;
return {
...commonFields,
payload: context.payload as PullRequestReviewCommentEvent,
entityNumber: (context.payload as PullRequestReviewCommentEvent)
.pull_request.number,
eventName: "pull_request_review_comment",
payload,
entityNumber: payload.pull_request.number,
isPR: true,
};
}
case "workflow_dispatch": {
return {
...commonFields,
eventName: "workflow_dispatch",
payload: context.payload as unknown as WorkflowDispatchEvent,
// No entityNumber or isPR for workflow_dispatch
};
}
case "schedule": {
return {
...commonFields,
eventName: "schedule",
payload: context.payload as unknown as ScheduleEvent,
// No entityNumber or isPR for schedule
};
}
default:
@@ -205,37 +237,53 @@ export function parseAdditionalPermissions(s: string): Map<string, string> {
}
export function isIssuesEvent(
context: ParsedGitHubContext,
context: GitHubContext,
): context is ParsedGitHubContext & { payload: IssuesEvent } {
return context.eventName === "issues";
}
export function isIssueCommentEvent(
context: ParsedGitHubContext,
context: GitHubContext,
): context is ParsedGitHubContext & { payload: IssueCommentEvent } {
return context.eventName === "issue_comment";
}
export function isPullRequestEvent(
context: ParsedGitHubContext,
context: GitHubContext,
): context is ParsedGitHubContext & { payload: PullRequestEvent } {
return context.eventName === "pull_request";
}
export function isPullRequestReviewEvent(
context: ParsedGitHubContext,
context: GitHubContext,
): context is ParsedGitHubContext & { payload: PullRequestReviewEvent } {
return context.eventName === "pull_request_review";
}
export function isPullRequestReviewCommentEvent(
context: ParsedGitHubContext,
context: GitHubContext,
): context is ParsedGitHubContext & { payload: PullRequestReviewCommentEvent } {
return context.eventName === "pull_request_review_comment";
}
export function isIssuesAssignedEvent(
context: ParsedGitHubContext,
context: GitHubContext,
): context is ParsedGitHubContext & { payload: IssuesAssignedEvent } {
return isIssuesEvent(context) && context.eventAction === "assigned";
}
// Type guard to check if context is an entity context (has entityNumber and isPR)
export function isEntityContext(
context: GitHubContext,
): context is ParsedGitHubContext {
return ENTITY_EVENT_NAMES.includes(context.eventName as EntityEventName);
}
// Type guard to check if context is an automation context
export function isAutomationContext(
context: GitHubContext,
): context is AutomationContext {
return AUTOMATION_EVENT_NAMES.includes(
context.eventName as AutomationEventName,
);
}

View File

@@ -134,7 +134,7 @@ export async function fetchGitHubData({
// Prepare all comments for image processing
const issueComments: CommentWithImages[] = comments
.filter((c) => c.body)
.filter((c) => c.body && !c.isMinimized)
.map((c) => ({
type: "issue_comment" as const,
id: c.databaseId,
@@ -154,7 +154,7 @@ export async function fetchGitHubData({
const reviewComments: CommentWithImages[] =
reviewData?.nodes
?.flatMap((r) => r.comments?.nodes ?? [])
.filter((c) => c.body)
.filter((c) => c.body && !c.isMinimized)
.map((c) => ({
type: "review_comment" as const,
id: c.databaseId,

View File

@@ -50,6 +50,7 @@ export function formatComments(
imageUrlMap?: Map<string, string>,
): string {
return comments
.filter((comment) => !comment.isMinimized)
.map((comment) => {
let body = comment.body;
@@ -96,6 +97,7 @@ export function formatReviewComments(
review.comments.nodes.length > 0
) {
const comments = review.comments.nodes
.filter((comment) => !comment.isMinimized)
.map((comment) => {
let body = comment.body;
@@ -110,7 +112,9 @@ export function formatReviewComments(
return ` [Comment on ${comment.path}:${comment.line || "?"}]: ${body}`;
})
.join("\n");
reviewOutput += `\n${comments}`;
if (comments) {
reviewOutput += `\n${comments}`;
}
}
return reviewOutput;

View File

@@ -22,12 +22,6 @@ export async function createInitialComment(
) {
const { owner, repo } = context.repository;
// This function is only called for entity-based events
if (!context.entityNumber) {
throw new Error("createInitialComment requires an entity number");
}
const entityNumber = context.entityNumber;
const jobRunLink = createJobRunLink(owner, repo, context.runId);
const initialBody = createCommentBody(jobRunLink);
@@ -42,7 +36,7 @@ export async function createInitialComment(
const comments = await octokit.rest.issues.listComments({
owner,
repo,
issue_number: entityNumber,
issue_number: context.entityNumber,
});
const existingComment = comments.data.find((comment) => {
const idMatch = comment.user?.id === CLAUDE_APP_BOT_ID;
@@ -65,7 +59,7 @@ export async function createInitialComment(
response = await octokit.rest.issues.createComment({
owner,
repo,
issue_number: entityNumber,
issue_number: context.entityNumber,
body: initialBody,
});
}
@@ -74,7 +68,7 @@ export async function createInitialComment(
response = await octokit.rest.pulls.createReplyForReviewComment({
owner,
repo,
pull_number: entityNumber,
pull_number: context.entityNumber,
comment_id: context.payload.comment.id,
body: initialBody,
});
@@ -83,7 +77,7 @@ export async function createInitialComment(
response = await octokit.rest.issues.createComment({
owner,
repo,
issue_number: entityNumber,
issue_number: context.entityNumber,
body: initialBody,
});
}
@@ -101,7 +95,7 @@ export async function createInitialComment(
const response = await octokit.rest.issues.createComment({
owner,
repo,
issue_number: entityNumber,
issue_number: context.entityNumber,
body: initialBody,
});

View File

@@ -10,6 +10,7 @@ export type GitHubComment = {
body: string;
author: GitHubAuthor;
createdAt: string;
isMinimized?: boolean;
};
export type GitHubReviewComment = GitHubComment & {

View File

@@ -1,5 +1,5 @@
import * as core from "@actions/core";
import { GITHUB_API_URL } from "../github/api/config";
import { GITHUB_API_URL, GITHUB_SERVER_URL } from "../github/api/config";
import type { ParsedGitHubContext } from "../github/context";
import { Octokit } from "@octokit/rest";
@@ -156,10 +156,13 @@ export async function prepareMcpConfig(
"--rm",
"-e",
"GITHUB_PERSONAL_ACCESS_TOKEN",
"-e",
"GITHUB_HOST",
"ghcr.io/github/github-mcp-server:sha-efef8ae", // https://github.com/github/github-mcp-server/releases/tag/v0.9.0
],
env: {
GITHUB_PERSONAL_ACCESS_TOKEN: githubToken,
GITHUB_HOST: GITHUB_SERVER_URL,
},
};
}

View File

@@ -1,6 +1,7 @@
import * as core from "@actions/core";
import { mkdir, writeFile } from "fs/promises";
import type { Mode, ModeOptions, ModeResult } from "../types";
import { isAutomationContext } from "../../github/context";
/**
* Agent mode implementation.
@@ -15,10 +16,7 @@ export const agentMode: Mode = {
shouldTrigger(context) {
// Only trigger for automation events
return (
context.eventName === "workflow_dispatch" ||
context.eventName === "schedule"
);
return isAutomationContext(context);
},
prepareContext(context) {

View File

@@ -13,7 +13,8 @@
import type { Mode, ModeName } from "./types";
import { tagMode } from "./tag";
import { agentMode } from "./agent";
import type { ParsedGitHubContext } from "../github/context";
import type { GitHubContext } from "../github/context";
import { isAutomationContext } from "../github/context";
export const DEFAULT_MODE = "tag" as const;
export const VALID_MODES = ["tag", "agent"] as const;
@@ -34,7 +35,7 @@ const modes = {
* @returns The requested mode
* @throws Error if the mode is not found or cannot handle the event
*/
export function getMode(name: ModeName, context: ParsedGitHubContext): Mode {
export function getMode(name: ModeName, context: GitHubContext): Mode {
const mode = modes[name];
if (!mode) {
const validModes = VALID_MODES.join("', '");
@@ -44,11 +45,7 @@ export function getMode(name: ModeName, context: ParsedGitHubContext): Mode {
}
// Validate mode can handle the event type
if (
name === "tag" &&
(context.eventName === "workflow_dispatch" ||
context.eventName === "schedule")
) {
if (name === "tag" && isAutomationContext(context)) {
throw new Error(
`Tag mode cannot handle ${context.eventName} events. Use 'agent' mode for automation events.`,
);

View File

@@ -8,6 +8,7 @@ import { configureGitAuth } from "../../github/operations/git-config";
import { prepareMcpConfig } from "../../mcp/install-mcp-server";
import { fetchGitHubData } from "../../github/data/fetcher";
import { createPrompt } from "../../create-prompt";
import { isEntityContext } from "../../github/context";
/**
* Tag mode implementation.
@@ -21,6 +22,10 @@ export const tagMode: Mode = {
description: "Traditional implementation mode triggered by @claude mentions",
shouldTrigger(context) {
// Tag mode only handles entity events
if (!isEntityContext(context)) {
return false;
}
return checkContainsTrigger(context);
},
@@ -51,7 +56,10 @@ export const tagMode: Mode = {
octokit,
githubToken,
}: ModeOptions): Promise<ModeResult> {
// Tag mode handles entity-based events (issues, PRs, comments)
// Tag mode only handles entity-based events
if (!isEntityContext(context)) {
throw new Error("Tag mode requires entity context");
}
// Check if actor is human
await checkHumanActor(octokit.rest, context);
@@ -60,11 +68,6 @@ export const tagMode: Mode = {
const commentData = await createInitialComment(octokit.rest, context);
const commentId = commentData.id;
// Fetch GitHub data - entity events always have entityNumber and isPR
if (!context.entityNumber || context.isPR === undefined) {
throw new Error("Entity events must have entityNumber and isPR defined");
}
const githubData = await fetchGitHubData({
octokits: octokit,
repository: `${context.repository.owner}/${context.repository.repo}`,

View File

@@ -1,10 +1,10 @@
import type { ParsedGitHubContext } from "../github/context";
import type { GitHubContext } from "../github/context";
export type ModeName = "tag" | "agent";
export type ModeContext = {
mode: ModeName;
githubContext: ParsedGitHubContext;
githubContext: GitHubContext;
commentId?: number;
baseBranch?: string;
claudeBranch?: string;
@@ -32,12 +32,12 @@ export type Mode = {
/**
* Determines if this mode should trigger based on the GitHub context
*/
shouldTrigger(context: ParsedGitHubContext): boolean;
shouldTrigger(context: GitHubContext): boolean;
/**
* Prepares the mode context with any additional data needed for prompt generation
*/
prepareContext(context: ParsedGitHubContext, data?: ModeData): ModeContext;
prepareContext(context: GitHubContext, data?: ModeData): ModeContext;
/**
* Returns the list of tools that should be allowed for this mode
@@ -64,7 +64,7 @@ export type Mode = {
// Define types for mode prepare method to avoid circular dependencies
export type ModeOptions = {
context: ParsedGitHubContext;
context: GitHubContext;
octokit: any; // We'll use any to avoid circular dependency with Octokits
githubToken: string;
};

View File

@@ -1,4 +1,4 @@
import type { ParsedGitHubContext } from "../github/context";
import type { GitHubContext } from "../github/context";
import type { Octokits } from "../github/api/client";
import type { Mode } from "../modes/types";
@@ -13,7 +13,7 @@ export type PrepareResult = {
};
export type PrepareOptions = {
context: ParsedGitHubContext;
context: GitHubContext;
octokit: Octokits;
mode: Mode;
githubToken: string;

View File

@@ -252,6 +252,63 @@ describe("formatComments", () => {
`[user1 at 2023-01-01T00:00:00Z]: Image: ![](https://github.com/user-attachments/assets/test.png)`,
);
});
test("filters out minimized comments", () => {
const comments: GitHubComment[] = [
{
id: "1",
databaseId: "100001",
body: "Normal comment",
author: { login: "user1" },
createdAt: "2023-01-01T00:00:00Z",
isMinimized: false,
},
{
id: "2",
databaseId: "100002",
body: "Minimized comment",
author: { login: "user2" },
createdAt: "2023-01-02T00:00:00Z",
isMinimized: true,
},
{
id: "3",
databaseId: "100003",
body: "Another normal comment",
author: { login: "user3" },
createdAt: "2023-01-03T00:00:00Z",
},
];
const result = formatComments(comments);
expect(result).toBe(
`[user1 at 2023-01-01T00:00:00Z]: Normal comment\n\n[user3 at 2023-01-03T00:00:00Z]: Another normal comment`,
);
});
test("returns empty string when all comments are minimized", () => {
const comments: GitHubComment[] = [
{
id: "1",
databaseId: "100001",
body: "Minimized comment 1",
author: { login: "user1" },
createdAt: "2023-01-01T00:00:00Z",
isMinimized: true,
},
{
id: "2",
databaseId: "100002",
body: "Minimized comment 2",
author: { login: "user2" },
createdAt: "2023-01-02T00:00:00Z",
isMinimized: true,
},
];
const result = formatComments(comments);
expect(result).toBe("");
});
});
describe("formatReviewComments", () => {
@@ -517,6 +574,159 @@ describe("formatReviewComments", () => {
`[Review by reviewer1 at 2023-01-01T00:00:00Z]: APPROVED\nReview body\n [Comment on src/index.ts:42]: Image: ![](https://github.com/user-attachments/assets/test.png)`,
);
});
test("filters out minimized review comments", () => {
const reviewData = {
nodes: [
{
id: "review1",
databaseId: "300001",
author: { login: "reviewer1" },
body: "Review with mixed comments",
state: "APPROVED",
submittedAt: "2023-01-01T00:00:00Z",
comments: {
nodes: [
{
id: "comment1",
databaseId: "200001",
body: "Normal review comment",
author: { login: "reviewer1" },
createdAt: "2023-01-01T00:00:00Z",
path: "src/index.ts",
line: 42,
isMinimized: false,
},
{
id: "comment2",
databaseId: "200002",
body: "Minimized review comment",
author: { login: "reviewer1" },
createdAt: "2023-01-01T00:00:00Z",
path: "src/utils.ts",
line: 15,
isMinimized: true,
},
{
id: "comment3",
databaseId: "200003",
body: "Another normal comment",
author: { login: "reviewer1" },
createdAt: "2023-01-01T00:00:00Z",
path: "src/main.ts",
line: 10,
},
],
},
},
],
};
const result = formatReviewComments(reviewData);
expect(result).toBe(
`[Review by reviewer1 at 2023-01-01T00:00:00Z]: APPROVED\nReview with mixed comments\n [Comment on src/index.ts:42]: Normal review comment\n [Comment on src/main.ts:10]: Another normal comment`,
);
});
test("returns review with only body when all review comments are minimized", () => {
const reviewData = {
nodes: [
{
id: "review1",
databaseId: "300001",
author: { login: "reviewer1" },
body: "Review body only",
state: "APPROVED",
submittedAt: "2023-01-01T00:00:00Z",
comments: {
nodes: [
{
id: "comment1",
databaseId: "200001",
body: "Minimized comment 1",
author: { login: "reviewer1" },
createdAt: "2023-01-01T00:00:00Z",
path: "src/index.ts",
line: 42,
isMinimized: true,
},
{
id: "comment2",
databaseId: "200002",
body: "Minimized comment 2",
author: { login: "reviewer1" },
createdAt: "2023-01-01T00:00:00Z",
path: "src/utils.ts",
line: 15,
isMinimized: true,
},
],
},
},
],
};
const result = formatReviewComments(reviewData);
expect(result).toBe(
`[Review by reviewer1 at 2023-01-01T00:00:00Z]: APPROVED\nReview body only`,
);
});
test("handles multiple reviews with mixed minimized comments", () => {
const reviewData = {
nodes: [
{
id: "review1",
databaseId: "300001",
author: { login: "reviewer1" },
body: "First review",
state: "APPROVED",
submittedAt: "2023-01-01T00:00:00Z",
comments: {
nodes: [
{
id: "comment1",
databaseId: "200001",
body: "Good comment",
author: { login: "reviewer1" },
createdAt: "2023-01-01T00:00:00Z",
path: "src/index.ts",
line: 42,
isMinimized: false,
},
],
},
},
{
id: "review2",
databaseId: "300002",
author: { login: "reviewer2" },
body: "Second review",
state: "COMMENTED",
submittedAt: "2023-01-02T00:00:00Z",
comments: {
nodes: [
{
id: "comment2",
databaseId: "200002",
body: "Spam comment",
author: { login: "reviewer2" },
createdAt: "2023-01-02T00:00:00Z",
path: "src/utils.ts",
line: 15,
isMinimized: true,
},
],
},
},
],
};
const result = formatReviewComments(reviewData);
expect(result).toBe(
`[Review by reviewer1 at 2023-01-01T00:00:00Z]: APPROVED\nFirst review\n [Comment on src/index.ts:42]: Good comment\n\n[Review by reviewer2 at 2023-01-02T00:00:00Z]: COMMENTED\nSecond review`,
);
});
});
describe("formatChangedFiles", () => {

View File

@@ -1,4 +1,7 @@
import type { ParsedGitHubContext } from "../src/github/context";
import type {
ParsedGitHubContext,
AutomationContext,
} from "../src/github/context";
import type {
IssuesEvent,
IssueCommentEvent,
@@ -38,7 +41,7 @@ export const createMockContext = (
): ParsedGitHubContext => {
const baseContext: ParsedGitHubContext = {
runId: "1234567890",
eventName: "",
eventName: "issue_comment", // Default to a valid entity event
eventAction: "",
repository: defaultRepository,
actor: "test-actor",
@@ -55,6 +58,22 @@ export const createMockContext = (
return { ...baseContext, ...overrides };
};
export const createMockAutomationContext = (
overrides: Partial<AutomationContext> = {},
): AutomationContext => {
const baseContext: AutomationContext = {
runId: "1234567890",
eventName: "workflow_dispatch",
eventAction: undefined,
repository: defaultRepository,
actor: "test-actor",
payload: {} as any,
inputs: defaultInputs,
};
return { ...baseContext, ...overrides };
};
export const mockIssueOpenedContext: ParsedGitHubContext = {
runId: "1234567890",
eventName: "issues",

View File

@@ -1,15 +1,14 @@
import { describe, test, expect, beforeEach } from "bun:test";
import { agentMode } from "../../src/modes/agent";
import type { ParsedGitHubContext } from "../../src/github/context";
import { createMockContext } from "../mockContext";
import type { GitHubContext } from "../../src/github/context";
import { createMockContext, createMockAutomationContext } from "../mockContext";
describe("Agent Mode", () => {
let mockContext: ParsedGitHubContext;
let mockContext: GitHubContext;
beforeEach(() => {
mockContext = createMockContext({
mockContext = createMockAutomationContext({
eventName: "workflow_dispatch",
isPR: false,
});
});
@@ -34,30 +33,26 @@ describe("Agent Mode", () => {
test("agent mode only triggers for workflow_dispatch and schedule events", () => {
// Should trigger for automation events
const workflowDispatchContext = createMockContext({
const workflowDispatchContext = createMockAutomationContext({
eventName: "workflow_dispatch",
isPR: false,
});
expect(agentMode.shouldTrigger(workflowDispatchContext)).toBe(true);
const scheduleContext = createMockContext({
const scheduleContext = createMockAutomationContext({
eventName: "schedule",
isPR: false,
});
expect(agentMode.shouldTrigger(scheduleContext)).toBe(true);
// Should NOT trigger for other events
const otherEvents = [
"push",
"repository_dispatch",
// Should NOT trigger for entity events
const entityEvents = [
"issue_comment",
"pull_request",
"pull_request_review",
"issues",
];
] as const;
otherEvents.forEach((eventName) => {
const context = createMockContext({ eventName, isPR: false });
entityEvents.forEach((eventName) => {
const context = createMockContext({ eventName });
expect(agentMode.shouldTrigger(context)).toBe(false);
});
});

View File

@@ -3,18 +3,18 @@ import { getMode, isValidMode } from "../../src/modes/registry";
import type { ModeName } from "../../src/modes/types";
import { tagMode } from "../../src/modes/tag";
import { agentMode } from "../../src/modes/agent";
import { createMockContext } from "../mockContext";
import { createMockContext, createMockAutomationContext } from "../mockContext";
describe("Mode Registry", () => {
const mockContext = createMockContext({
eventName: "issue_comment",
});
const mockWorkflowDispatchContext = createMockContext({
const mockWorkflowDispatchContext = createMockAutomationContext({
eventName: "workflow_dispatch",
});
const mockScheduleContext = createMockContext({
const mockScheduleContext = createMockAutomationContext({
eventName: "schedule",
});

View File

@@ -299,15 +299,4 @@ describe("parseEnvVarsWithContext", () => {
expect(result.allowedTools).toBe("Tool1,Tool2");
});
});
test("should throw error for unsupported event type", () => {
process.env = BASE_ENV;
const unsupportedContext = createMockContext({
eventName: "unsupported_event",
eventAction: "whatever",
});
expect(() => prepareContext(unsupportedContext, "12345")).toThrow(
"Unsupported event type: unsupported_event",
);
});
});

View File

@@ -474,17 +474,6 @@ describe("checkContainsTrigger", () => {
});
});
});
describe("non-matching events", () => {
it("should return false for non-matching event type", () => {
const context = createMockContext({
eventName: "push",
eventAction: "created",
payload: {} as any,
});
expect(checkContainsTrigger(context)).toBe(false);
});
});
});
describe("escapeRegExp", () => {