mirror of
https://github.com/anthropics/claude-code-action.git
synced 2026-01-23 23:14:13 +08:00
Compare commits
4 Commits
v0.0.13
...
claude/iss
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d100f7832 | ||
|
|
e0dcb85d34 | ||
|
|
be799cbe7b | ||
|
|
bd71ac0e8f |
26
.github/workflows/issue-triage.yml
vendored
26
.github/workflows/issue-triage.yml
vendored
@@ -23,18 +23,20 @@ jobs:
|
|||||||
mkdir -p /tmp/mcp-config
|
mkdir -p /tmp/mcp-config
|
||||||
cat > /tmp/mcp-config/mcp-servers.json << 'EOF'
|
cat > /tmp/mcp-config/mcp-servers.json << 'EOF'
|
||||||
{
|
{
|
||||||
"github": {
|
"mcpServers": {
|
||||||
"command": "docker",
|
"github": {
|
||||||
"args": [
|
"command": "docker",
|
||||||
"run",
|
"args": [
|
||||||
"-i",
|
"run",
|
||||||
"--rm",
|
"-i",
|
||||||
"-e",
|
"--rm",
|
||||||
"GITHUB_PERSONAL_ACCESS_TOKEN",
|
"-e",
|
||||||
"ghcr.io/github/github-mcp-server:sha-7aced2b"
|
"GITHUB_PERSONAL_ACCESS_TOKEN",
|
||||||
],
|
"ghcr.io/github/github-mcp-server:sha-7aced2b"
|
||||||
"env": {
|
],
|
||||||
"GITHUB_PERSONAL_ACCESS_TOKEN": "${{ secrets.GITHUB_TOKEN }}"
|
"env": {
|
||||||
|
"GITHUB_PERSONAL_ACCESS_TOKEN": "${{ secrets.GITHUB_TOKEN }}"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -160,6 +160,7 @@ runs:
|
|||||||
IS_PR: ${{ github.event.issue.pull_request != null || github.event_name == 'pull_request_review_comment' }}
|
IS_PR: ${{ github.event.issue.pull_request != null || github.event_name == 'pull_request_review_comment' }}
|
||||||
BASE_BRANCH: ${{ steps.prepare.outputs.BASE_BRANCH }}
|
BASE_BRANCH: ${{ steps.prepare.outputs.BASE_BRANCH }}
|
||||||
CLAUDE_SUCCESS: ${{ steps.claude-code.outputs.conclusion == 'success' }}
|
CLAUDE_SUCCESS: ${{ steps.claude-code.outputs.conclusion == 'success' }}
|
||||||
|
CLAUDE_CANCELLED: ${{ cancelled() }}
|
||||||
OUTPUT_FILE: ${{ steps.claude-code.outputs.execution_file || '' }}
|
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 || '' }}
|
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' }}
|
PREPARE_SUCCESS: ${{ steps.prepare.outcome == 'success' }}
|
||||||
|
|||||||
@@ -146,8 +146,12 @@ async function run() {
|
|||||||
duration_api_ms?: number;
|
duration_api_ms?: number;
|
||||||
} | null = null;
|
} | null = null;
|
||||||
let actionFailed = false;
|
let actionFailed = false;
|
||||||
|
let actionCancelled = false;
|
||||||
let errorDetails: string | undefined;
|
let errorDetails: string | undefined;
|
||||||
|
|
||||||
|
// Check if the workflow was cancelled
|
||||||
|
const isCancelled = process.env.CLAUDE_CANCELLED === "true";
|
||||||
|
|
||||||
// First check if prepare step failed
|
// First check if prepare step failed
|
||||||
const prepareSuccess = process.env.PREPARE_SUCCESS !== "false";
|
const prepareSuccess = process.env.PREPARE_SUCCESS !== "false";
|
||||||
const prepareError = process.env.PREPARE_ERROR;
|
const prepareError = process.env.PREPARE_ERROR;
|
||||||
@@ -155,8 +159,18 @@ async function run() {
|
|||||||
if (!prepareSuccess && prepareError) {
|
if (!prepareSuccess && prepareError) {
|
||||||
actionFailed = true;
|
actionFailed = true;
|
||||||
errorDetails = prepareError;
|
errorDetails = prepareError;
|
||||||
|
} else if (isCancelled) {
|
||||||
|
// If the workflow was cancelled, set the cancelled flag
|
||||||
|
actionCancelled = true;
|
||||||
} else {
|
} else {
|
||||||
// Check for existence of output file and parse it if available
|
// 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
|
||||||
try {
|
try {
|
||||||
const outputFile = process.env.OUTPUT_FILE;
|
const outputFile = process.env.OUTPUT_FILE;
|
||||||
if (outputFile) {
|
if (outputFile) {
|
||||||
@@ -179,14 +193,10 @@ async function run() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if the Claude action failed
|
|
||||||
const claudeSuccess = process.env.CLAUDE_SUCCESS !== "false";
|
|
||||||
actionFailed = !claudeSuccess;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error reading output file:", error);
|
console.error("Error reading output file:", error);
|
||||||
// If we can't read the file, check for any failure markers
|
// Error reading output file doesn't change the action status
|
||||||
actionFailed = process.env.CLAUDE_SUCCESS === "false";
|
// We already determined actionFailed based on CLAUDE_SUCCESS
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -194,6 +204,7 @@ async function run() {
|
|||||||
const commentInput: CommentUpdateInput = {
|
const commentInput: CommentUpdateInput = {
|
||||||
currentBody,
|
currentBody,
|
||||||
actionFailed,
|
actionFailed,
|
||||||
|
actionCancelled,
|
||||||
executionDetails,
|
executionDetails,
|
||||||
jobUrl,
|
jobUrl,
|
||||||
branchLink,
|
branchLink,
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
import { execSync } from "child_process";
|
import { execSync } from "child_process";
|
||||||
|
import type { Octokits } from "../api/client";
|
||||||
|
import { ISSUE_QUERY, PR_QUERY } from "../api/queries/github";
|
||||||
import type {
|
import type {
|
||||||
GitHubPullRequest,
|
|
||||||
GitHubIssue,
|
|
||||||
GitHubComment,
|
GitHubComment,
|
||||||
GitHubFile,
|
GitHubFile,
|
||||||
|
GitHubIssue,
|
||||||
|
GitHubPullRequest,
|
||||||
GitHubReview,
|
GitHubReview,
|
||||||
PullRequestQueryResponse,
|
|
||||||
IssueQueryResponse,
|
IssueQueryResponse,
|
||||||
|
PullRequestQueryResponse,
|
||||||
} from "../types";
|
} from "../types";
|
||||||
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";
|
import type { CommentWithImages } from "../utils/image-downloader";
|
||||||
|
import { downloadCommentImages } from "../utils/image-downloader";
|
||||||
|
|
||||||
type FetchDataParams = {
|
type FetchDataParams = {
|
||||||
octokits: Octokits;
|
octokits: Octokits;
|
||||||
@@ -101,6 +101,14 @@ export async function fetchGitHubData({
|
|||||||
let changedFilesWithSHA: GitHubFileWithSHA[] = [];
|
let changedFilesWithSHA: GitHubFileWithSHA[] = [];
|
||||||
if (isPR && changedFiles.length > 0) {
|
if (isPR && changedFiles.length > 0) {
|
||||||
changedFilesWithSHA = changedFiles.map((file) => {
|
changedFilesWithSHA = changedFiles.map((file) => {
|
||||||
|
// Don't compute SHA for deleted files
|
||||||
|
if (file.changeType === "DELETED") {
|
||||||
|
return {
|
||||||
|
...file,
|
||||||
|
sha: "deleted",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Use git hash-object to compute the SHA for the current file content
|
// Use git hash-object to compute the SHA for the current file content
|
||||||
const sha = execSync(`git hash-object "${file.path}"`, {
|
const sha = execSync(`git hash-object "${file.path}"`, {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export type ExecutionDetails = {
|
|||||||
export type CommentUpdateInput = {
|
export type CommentUpdateInput = {
|
||||||
currentBody: string;
|
currentBody: string;
|
||||||
actionFailed: boolean;
|
actionFailed: boolean;
|
||||||
|
actionCancelled?: boolean;
|
||||||
executionDetails: ExecutionDetails | null;
|
executionDetails: ExecutionDetails | null;
|
||||||
jobUrl: string;
|
jobUrl: string;
|
||||||
branchLink?: string;
|
branchLink?: string;
|
||||||
@@ -74,6 +75,7 @@ export function updateCommentBody(input: CommentUpdateInput): string {
|
|||||||
branchLink,
|
branchLink,
|
||||||
prLink,
|
prLink,
|
||||||
actionFailed,
|
actionFailed,
|
||||||
|
actionCancelled,
|
||||||
branchName,
|
branchName,
|
||||||
triggerUsername,
|
triggerUsername,
|
||||||
errorDetails,
|
errorDetails,
|
||||||
@@ -112,7 +114,13 @@ export function updateCommentBody(input: CommentUpdateInput): string {
|
|||||||
// Build the header
|
// Build the header
|
||||||
let header = "";
|
let header = "";
|
||||||
|
|
||||||
if (actionFailed) {
|
if (actionCancelled) {
|
||||||
|
header = "**Claude's task was cancelled";
|
||||||
|
if (durationStr) {
|
||||||
|
header += ` after ${durationStr}`;
|
||||||
|
}
|
||||||
|
header += "**";
|
||||||
|
} else if (actionFailed) {
|
||||||
header = "**Claude encountered an error";
|
header = "**Claude encountered an error";
|
||||||
if (durationStr) {
|
if (durationStr) {
|
||||||
header += ` after ${durationStr}`;
|
header += ` after ${durationStr}`;
|
||||||
|
|||||||
@@ -418,4 +418,48 @@ 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
160
test/update-comment-link-logic.test.ts
Normal file
160
test/update-comment-link-logic.test.ts
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user