feat: add workflow cancellation detection and update comment

When a workflow is cancelled, the comment now shows "Claude's task was cancelled" 
instead of the generic "Claude encountered an error" message. This provides clearer 
feedback to users about why the workflow stopped.

Changes:
- Add CLAUDE_CANCELLED environment variable using cancelled() function in action.yml
- Implement cancellation detection logic in update-comment-link.ts
- Update comment-logic.ts to show specific cancellation message
- Add comprehensive tests for cancellation handling

Fixes #123

Co-authored-by: ashwin-ant <ashwin-ant@users.noreply.github.com>
This commit is contained in:
claude[bot]
2025-06-04 01:03:26 +00:00
committed by GitHub
parent e0dcb85d34
commit 1d100f7832
5 changed files with 133 additions and 22 deletions

View File

@@ -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' }}

View File

@@ -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,6 +159,9 @@ 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 if the Claude action failed // Check if the Claude action failed
// CLAUDE_SUCCESS is set to the result of: steps.claude-code.outputs.conclusion == 'success' // CLAUDE_SUCCESS is set to the result of: steps.claude-code.outputs.conclusion == 'success'
@@ -197,6 +204,7 @@ async function run() {
const commentInput: CommentUpdateInput = { const commentInput: CommentUpdateInput = {
currentBody, currentBody,
actionFailed, actionFailed,
actionCancelled,
executionDetails, executionDetails,
jobUrl, jobUrl,
branchLink, branchLink,

View File

@@ -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}`;

View File

@@ -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");
});
});
}); });

View File

@@ -14,18 +14,18 @@ describe("update-comment-link workflow status detection", () => {
test("should detect prepare step failure", () => { test("should detect prepare step failure", () => {
process.env.PREPARE_SUCCESS = "false"; process.env.PREPARE_SUCCESS = "false";
process.env.PREPARE_ERROR = "Failed to fetch issue data"; process.env.PREPARE_ERROR = "Failed to fetch issue data";
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;
let actionFailed = false; let actionFailed = false;
let errorDetails: string | undefined; let errorDetails: string | undefined;
if (!prepareSuccess && prepareError) { if (!prepareSuccess && prepareError) {
actionFailed = true; actionFailed = true;
errorDetails = prepareError; errorDetails = prepareError;
} }
expect(actionFailed).toBe(true); expect(actionFailed).toBe(true);
expect(errorDetails).toBe("Failed to fetch issue data"); expect(errorDetails).toBe("Failed to fetch issue data");
}); });
@@ -33,50 +33,50 @@ describe("update-comment-link workflow status detection", () => {
test("should detect claude-code step failure when prepare succeeds", () => { test("should detect claude-code step failure when prepare succeeds", () => {
process.env.PREPARE_SUCCESS = "true"; process.env.PREPARE_SUCCESS = "true";
process.env.CLAUDE_SUCCESS = "false"; process.env.CLAUDE_SUCCESS = "false";
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;
let actionFailed = false; let actionFailed = false;
if (!prepareSuccess && prepareError) { if (!prepareSuccess && prepareError) {
actionFailed = true; actionFailed = true;
} else { } else {
const claudeSuccess = process.env.CLAUDE_SUCCESS === "true"; const claudeSuccess = process.env.CLAUDE_SUCCESS === "true";
actionFailed = !claudeSuccess; actionFailed = !claudeSuccess;
} }
expect(actionFailed).toBe(true); expect(actionFailed).toBe(true);
}); });
test("should detect success when both steps succeed", () => { test("should detect success when both steps succeed", () => {
process.env.PREPARE_SUCCESS = "true"; process.env.PREPARE_SUCCESS = "true";
process.env.CLAUDE_SUCCESS = "true"; process.env.CLAUDE_SUCCESS = "true";
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;
let actionFailed = false; let actionFailed = false;
if (!prepareSuccess && prepareError) { if (!prepareSuccess && prepareError) {
actionFailed = true; actionFailed = true;
} else { } else {
const claudeSuccess = process.env.CLAUDE_SUCCESS === "true"; const claudeSuccess = process.env.CLAUDE_SUCCESS === "true";
actionFailed = !claudeSuccess; actionFailed = !claudeSuccess;
} }
expect(actionFailed).toBe(false); expect(actionFailed).toBe(false);
}); });
test("should treat missing CLAUDE_SUCCESS env var as failure", () => { test("should treat missing CLAUDE_SUCCESS env var as failure", () => {
process.env.PREPARE_SUCCESS = "true"; process.env.PREPARE_SUCCESS = "true";
delete process.env.CLAUDE_SUCCESS; delete process.env.CLAUDE_SUCCESS;
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;
let actionFailed = false; let actionFailed = false;
if (!prepareSuccess && prepareError) { if (!prepareSuccess && prepareError) {
actionFailed = true; actionFailed = true;
} else { } else {
@@ -84,7 +84,7 @@ describe("update-comment-link workflow status detection", () => {
const claudeSuccess = process.env.CLAUDE_SUCCESS === "true"; const claudeSuccess = process.env.CLAUDE_SUCCESS === "true";
actionFailed = !claudeSuccess; actionFailed = !claudeSuccess;
} }
expect(actionFailed).toBe(true); expect(actionFailed).toBe(true);
}); });
@@ -92,19 +92,69 @@ describe("update-comment-link workflow status detection", () => {
delete process.env.PREPARE_SUCCESS; delete process.env.PREPARE_SUCCESS;
delete process.env.PREPARE_ERROR; delete process.env.PREPARE_ERROR;
process.env.CLAUDE_SUCCESS = "true"; process.env.CLAUDE_SUCCESS = "true";
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;
let actionFailed = false; let actionFailed = false;
if (!prepareSuccess && prepareError) { if (!prepareSuccess && prepareError) {
actionFailed = true; actionFailed = true;
} else { } else {
const claudeSuccess = process.env.CLAUDE_SUCCESS === "true"; const claudeSuccess = process.env.CLAUDE_SUCCESS === "true";
actionFailed = !claudeSuccess; actionFailed = !claudeSuccess;
} }
expect(actionFailed).toBe(false); 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);
});
});