Compare commits

..

6 Commits

Author SHA1 Message Date
Ashwin Bhat
404e7dc841 clean up comments 2025-06-02 12:13:32 -07:00
Ashwin Bhat
787ba87628 tsc 2025-06-02 09:51:09 -07:00
Ashwin Bhat
547eb3464c prettier 2025-06-02 09:46:23 -07:00
claude[bot]
23a694ae90 refactor: extract update_claude_comment logic to standalone testable function
- Create new updateClaudeComment function in operations/comments
- Add comprehensive unit tests following image-downloader pattern
- Update MCP server to use extracted function
- Refactor update-comment-link.ts and update-with-branch.ts to eliminate duplication
- All tests passing (10 new tests for update-claude-comment)

Co-authored-by: ashwin-ant <ashwin-ant@users.noreply.github.com>
2025-06-02 09:46:05 -07:00
Ashwin Bhat
94c1288140 feat: add unified update_claude_comment tool
- Add new update_claude_comment tool that automatically handles both issue and PR comments
- Remove individual update_issue_comment and update_pull_request_comment tools
- Pass CLAUDE_COMMENT_ID, GITHUB_EVENT_NAME, and IS_PR to MCP server environment
- Use Octokit instead of raw fetch for better type safety and error handling
- Simplify Claude's comment update workflow by removing need for owner/repo/commentId params
- Update prompts and tests to use the new unified tool
2025-06-02 09:46:04 -07:00
Ashwin Bhat
722b06e99b feat: add unified update_claude_comment tool
- Add new update_claude_comment tool that automatically handles both issue and PR comments
- Remove individual update_issue_comment and update_pull_request_comment tools
- Pass CLAUDE_COMMENT_ID, GITHUB_EVENT_NAME, and IS_PR to MCP server environment
- Simplify Claude's comment update workflow by removing need for owner/repo/commentId params
- Update prompts and tests to use the new unified tool
2025-06-02 09:46:03 -07:00
8 changed files with 27 additions and 286 deletions

View File

@@ -23,20 +23,18 @@ jobs:
mkdir -p /tmp/mcp-config
cat > /tmp/mcp-config/mcp-servers.json << 'EOF'
{
"mcpServers": {
"github": {
"command": "docker",
"args": [
"run",
"-i",
"--rm",
"-e",
"GITHUB_PERSONAL_ACCESS_TOKEN",
"ghcr.io/github/github-mcp-server:sha-7aced2b"
],
"env": {
"GITHUB_PERSONAL_ACCESS_TOKEN": "${{ secrets.GITHUB_TOKEN }}"
}
"github": {
"command": "docker",
"args": [
"run",
"-i",
"--rm",
"-e",
"GITHUB_PERSONAL_ACCESS_TOKEN",
"ghcr.io/github/github-mcp-server:sha-7aced2b"
],
"env": {
"GITHUB_PERSONAL_ACCESS_TOKEN": "${{ secrets.GITHUB_TOKEN }}"
}
}
}

View File

@@ -65,11 +65,6 @@ jobs:
# trigger_phrase: "/claude"
# Optional: add assignee trigger for issues
# assignee_trigger: "claude"
# Optional: add custom environment variables (YAML format)
# claude_env: |
# NODE_ENV: test
# DEBUG: true
# API_URL: https://api.example.com
```
## Inputs
@@ -90,7 +85,6 @@ jobs:
| `mcp_config` | Additional MCP configuration (JSON string) that merges with the built-in GitHub MCP servers | No | "" |
| `assignee_trigger` | The assignee username that triggers the action (e.g. @claude). Only used for issue assignment | No | - |
| `trigger_phrase` | The trigger phrase to look for in comments, issue/PR bodies, and issue titles | No | `@claude` |
| `claude_env` | Custom environment variables to pass to Claude Code execution (YAML format) | No | "" |
\*Required when using direct Anthropic API (default and when not using Bedrock or Vertex)
@@ -295,22 +289,6 @@ This action is built on top of [`anthropics/claude-code-base-action`](https://gi
## Advanced Configuration
### Custom Environment Variables
You can pass custom environment variables to Claude Code execution using the `claude_env` input. This is useful for CI/test setups that require specific environment variables:
```yaml
- uses: anthropics/claude-code-action@beta
with:
claude_env: |
NODE_ENV: test
CI: true
DATABASE_URL: postgres://test:test@localhost:5432/test_db
# ... other inputs
```
The `claude_env` input accepts YAML format where each line defines a key-value pair. These environment variables will be available to Claude Code during execution, allowing it to run tests, build processes, or other commands that depend on specific environment configurations.
### Custom Tools
By default, Claude only has access to:

View File

@@ -41,8 +41,6 @@ inputs:
default: ""
mcp_config:
description: "Additional MCP configuration (JSON string) that merges with the built-in GitHub MCP servers"
claude_env:
description: "Custom environment variables to pass to Claude Code execution (YAML format)"
required: false
default: ""
@@ -105,7 +103,7 @@ runs:
- name: Run Claude Code
id: claude-code
if: steps.prepare.outputs.contains_trigger == 'true'
uses: anthropics/claude-code-base-action@1370ac97fbba9bddec20ea2924b5726bf10d8b94 # v0.0.9
uses: anthropics/claude-code-base-action@c8e31bd52d9a149b3f8309d7978c6edaa282688d # v0.0.8
with:
prompt_file: /tmp/claude-prompts/claude-prompt.txt
allowed_tools: ${{ env.ALLOWED_TOOLS }}
@@ -116,7 +114,6 @@ runs:
use_bedrock: ${{ inputs.use_bedrock }}
use_vertex: ${{ inputs.use_vertex }}
anthropic_api_key: ${{ inputs.anthropic_api_key }}
claude_env: ${{ inputs.claude_env }}
env:
# Model configuration
ANTHROPIC_MODEL: ${{ inputs.model || inputs.anthropic_model }}
@@ -160,7 +157,6 @@ runs:
IS_PR: ${{ github.event.issue.pull_request != null || github.event_name == 'pull_request_review_comment' }}
BASE_BRANCH: ${{ steps.prepare.outputs.BASE_BRANCH }}
CLAUDE_SUCCESS: ${{ steps.claude-code.outputs.conclusion == 'success' }}
CLAUDE_CANCELLED: ${{ cancelled() }}
OUTPUT_FILE: ${{ steps.claude-code.outputs.execution_file || '' }}
TRIGGER_USERNAME: ${{ github.event.comment.user.login || github.event.issue.user.login || github.event.pull_request.user.login || github.event.sender.login || github.triggering_actor || github.actor || '' }}
PREPARE_SUCCESS: ${{ steps.prepare.outcome == 'success' }}

View File

@@ -146,12 +146,8 @@ async function run() {
duration_api_ms?: number;
} | null = null;
let actionFailed = false;
let actionCancelled = false;
let errorDetails: string | undefined;
// Check if the workflow was cancelled
const isCancelled = process.env.CLAUDE_CANCELLED === "true";
// First check if prepare step failed
const prepareSuccess = process.env.PREPARE_SUCCESS !== "false";
const prepareError = process.env.PREPARE_ERROR;
@@ -159,18 +155,8 @@ async function run() {
if (!prepareSuccess && prepareError) {
actionFailed = true;
errorDetails = prepareError;
} else if (isCancelled) {
// If the workflow was cancelled, set the cancelled flag
actionCancelled = true;
} else {
// Check if the Claude action failed
// CLAUDE_SUCCESS is set to the result of: steps.claude-code.outputs.conclusion == 'success'
// If the step didn't run or didn't set outputs.conclusion, CLAUDE_SUCCESS will be "false"
// If the step succeeded, CLAUDE_SUCCESS will be "true"
const claudeSuccess = process.env.CLAUDE_SUCCESS === "true";
actionFailed = !claudeSuccess;
// Try to read execution details from output file
// Check for existence of output file and parse it if available
try {
const outputFile = process.env.OUTPUT_FILE;
if (outputFile) {
@@ -193,10 +179,14 @@ async function run() {
}
}
}
// Check if the Claude action failed
const claudeSuccess = process.env.CLAUDE_SUCCESS !== "false";
actionFailed = !claudeSuccess;
} catch (error) {
console.error("Error reading output file:", error);
// Error reading output file doesn't change the action status
// We already determined actionFailed based on CLAUDE_SUCCESS
// If we can't read the file, check for any failure markers
actionFailed = process.env.CLAUDE_SUCCESS === "false";
}
}
@@ -204,7 +194,6 @@ async function run() {
const commentInput: CommentUpdateInput = {
currentBody,
actionFailed,
actionCancelled,
executionDetails,
jobUrl,
branchLink,

View File

@@ -1,17 +1,17 @@
import { execSync } from "child_process";
import type { Octokits } from "../api/client";
import { ISSUE_QUERY, PR_QUERY } from "../api/queries/github";
import type {
GitHubPullRequest,
GitHubIssue,
GitHubComment,
GitHubFile,
GitHubIssue,
GitHubPullRequest,
GitHubReview,
IssueQueryResponse,
PullRequestQueryResponse,
IssueQueryResponse,
} from "../types";
import type { CommentWithImages } from "../utils/image-downloader";
import { PR_QUERY, ISSUE_QUERY } from "../api/queries/github";
import type { Octokits } from "../api/client";
import { downloadCommentImages } from "../utils/image-downloader";
import type { CommentWithImages } from "../utils/image-downloader";
type FetchDataParams = {
octokits: Octokits;
@@ -101,14 +101,6 @@ export async function fetchGitHubData({
let changedFilesWithSHA: GitHubFileWithSHA[] = [];
if (isPR && changedFiles.length > 0) {
changedFilesWithSHA = changedFiles.map((file) => {
// Don't compute SHA for deleted files
if (file.changeType === "DELETED") {
return {
...file,
sha: "deleted",
};
}
try {
// Use git hash-object to compute the SHA for the current file content
const sha = execSync(`git hash-object "${file.path}"`, {

View File

@@ -9,7 +9,6 @@ export type ExecutionDetails = {
export type CommentUpdateInput = {
currentBody: string;
actionFailed: boolean;
actionCancelled?: boolean;
executionDetails: ExecutionDetails | null;
jobUrl: string;
branchLink?: string;
@@ -75,7 +74,6 @@ export function updateCommentBody(input: CommentUpdateInput): string {
branchLink,
prLink,
actionFailed,
actionCancelled,
branchName,
triggerUsername,
errorDetails,
@@ -114,13 +112,7 @@ export function updateCommentBody(input: CommentUpdateInput): string {
// Build the header
let header = "";
if (actionCancelled) {
header = "**Claude's task was cancelled";
if (durationStr) {
header += ` after ${durationStr}`;
}
header += "**";
} else if (actionFailed) {
if (actionFailed) {
header = "**Claude encountered an error";
if (durationStr) {
header += ` after ${durationStr}`;

View File

@@ -418,48 +418,4 @@ describe("updateCommentBody", () => {
);
});
});
describe("cancellation handling", () => {
it("shows cancellation message when actionCancelled is true", () => {
const input = {
...baseInput,
currentBody: "Claude Code is working…",
actionCancelled: true,
executionDetails: { duration_ms: 30000 }, // 30s
triggerUsername: "test-user",
};
const result = updateCommentBody(input);
expect(result).toContain("**Claude's task was cancelled after 30s**");
expect(result).not.toContain("Claude encountered an error");
expect(result).not.toContain("Claude finished");
});
it("shows cancellation message without duration when duration is missing", () => {
const input = {
...baseInput,
currentBody: "Claude Code is working…",
actionCancelled: true,
triggerUsername: "test-user",
};
const result = updateCommentBody(input);
expect(result).toContain("**Claude's task was cancelled**");
expect(result).not.toContain("after");
});
it("prioritizes cancellation over failure", () => {
const input = {
...baseInput,
currentBody: "Claude Code is working…",
actionCancelled: true,
actionFailed: true, // Both are true, cancellation should take precedence
executionDetails: { duration_ms: 45000 },
};
const result = updateCommentBody(input);
expect(result).toContain("**Claude's task was cancelled after 45s**");
expect(result).not.toContain("Claude encountered an error");
});
});
});

View File

@@ -1,160 +0,0 @@
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
describe("update-comment-link workflow status detection", () => {
let originalEnv: NodeJS.ProcessEnv;
beforeEach(() => {
originalEnv = { ...process.env };
});
afterEach(() => {
process.env = originalEnv;
});
test("should detect prepare step failure", () => {
process.env.PREPARE_SUCCESS = "false";
process.env.PREPARE_ERROR = "Failed to fetch issue data";
const prepareSuccess = process.env.PREPARE_SUCCESS !== "false";
const prepareError = process.env.PREPARE_ERROR;
let actionFailed = false;
let errorDetails: string | undefined;
if (!prepareSuccess && prepareError) {
actionFailed = true;
errorDetails = prepareError;
}
expect(actionFailed).toBe(true);
expect(errorDetails).toBe("Failed to fetch issue data");
});
test("should detect claude-code step failure when prepare succeeds", () => {
process.env.PREPARE_SUCCESS = "true";
process.env.CLAUDE_SUCCESS = "false";
const prepareSuccess = process.env.PREPARE_SUCCESS !== "false";
const prepareError = process.env.PREPARE_ERROR;
let actionFailed = false;
if (!prepareSuccess && prepareError) {
actionFailed = true;
} else {
const claudeSuccess = process.env.CLAUDE_SUCCESS === "true";
actionFailed = !claudeSuccess;
}
expect(actionFailed).toBe(true);
});
test("should detect success when both steps succeed", () => {
process.env.PREPARE_SUCCESS = "true";
process.env.CLAUDE_SUCCESS = "true";
const prepareSuccess = process.env.PREPARE_SUCCESS !== "false";
const prepareError = process.env.PREPARE_ERROR;
let actionFailed = false;
if (!prepareSuccess && prepareError) {
actionFailed = true;
} else {
const claudeSuccess = process.env.CLAUDE_SUCCESS === "true";
actionFailed = !claudeSuccess;
}
expect(actionFailed).toBe(false);
});
test("should treat missing CLAUDE_SUCCESS env var as failure", () => {
process.env.PREPARE_SUCCESS = "true";
delete process.env.CLAUDE_SUCCESS;
const prepareSuccess = process.env.PREPARE_SUCCESS !== "false";
const prepareError = process.env.PREPARE_ERROR;
let actionFailed = false;
if (!prepareSuccess && prepareError) {
actionFailed = true;
} else {
// When CLAUDE_SUCCESS is undefined, it's not === "true", so claudeSuccess = false
const claudeSuccess = process.env.CLAUDE_SUCCESS === "true";
actionFailed = !claudeSuccess;
}
expect(actionFailed).toBe(true);
});
test("should handle undefined PREPARE_SUCCESS as success", () => {
delete process.env.PREPARE_SUCCESS;
delete process.env.PREPARE_ERROR;
process.env.CLAUDE_SUCCESS = "true";
const prepareSuccess = process.env.PREPARE_SUCCESS !== "false";
const prepareError = process.env.PREPARE_ERROR;
let actionFailed = false;
if (!prepareSuccess && prepareError) {
actionFailed = true;
} else {
const claudeSuccess = process.env.CLAUDE_SUCCESS === "true";
actionFailed = !claudeSuccess;
}
expect(actionFailed).toBe(false);
});
test("should detect cancellation when CLAUDE_CANCELLED is true", () => {
process.env.PREPARE_SUCCESS = "true";
process.env.CLAUDE_SUCCESS = "false";
process.env.CLAUDE_CANCELLED = "true";
const prepareSuccess = process.env.PREPARE_SUCCESS !== "false";
const prepareError = process.env.PREPARE_ERROR;
const isCancelled = process.env.CLAUDE_CANCELLED === "true";
let actionFailed = false;
let actionCancelled = false;
if (!prepareSuccess && prepareError) {
actionFailed = true;
} else if (isCancelled) {
actionCancelled = true;
} else {
const claudeSuccess = process.env.CLAUDE_SUCCESS === "true";
actionFailed = !claudeSuccess;
}
expect(actionFailed).toBe(false);
expect(actionCancelled).toBe(true);
});
test("should not detect cancellation when CLAUDE_CANCELLED is false", () => {
process.env.PREPARE_SUCCESS = "true";
process.env.CLAUDE_SUCCESS = "false";
process.env.CLAUDE_CANCELLED = "false";
const prepareSuccess = process.env.PREPARE_SUCCESS !== "false";
const prepareError = process.env.PREPARE_ERROR;
const isCancelled = process.env.CLAUDE_CANCELLED === "true";
let actionFailed = false;
let actionCancelled = false;
if (!prepareSuccess && prepareError) {
actionFailed = true;
} else if (isCancelled) {
actionCancelled = true;
} else {
const claudeSuccess = process.env.CLAUDE_SUCCESS === "true";
actionFailed = !claudeSuccess;
}
expect(actionFailed).toBe(true);
expect(actionCancelled).toBe(false);
});
});