feat: display detailed error messages when prepare step fails

- Capture prepare step errors in action.yml (up to 2000 chars)
- Add error details to comment update with collapsible section
- Handle both prepare and Claude execution failures separately
- Add test coverage for error detail display

This helps users debug issues like git errors, permission problems,
and branch creation failures more easily.

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Ashwin Bhat
2025-05-28 15:52:16 -07:00
parent 176dbc369d
commit 81181ca658
4 changed files with 92 additions and 29 deletions

View File

@@ -81,7 +81,26 @@ runs:
id: prepare id: prepare
shell: bash shell: bash
run: | run: |
bun run ${{ github.action_path }}/src/entrypoints/prepare.ts set +e
# Create a temporary file to capture both stdout and stderr
TEMP_OUTPUT=$(mktemp)
bun run ${{ github.action_path }}/src/entrypoints/prepare.ts 2>&1 | tee "$TEMP_OUTPUT"
PREPARE_EXIT_CODE=${PIPESTATUS[0]}
# If the command failed, save the error output
if [ $PREPARE_EXIT_CODE -ne 0 ]; then
# Extract last 50 lines of output as error details
ERROR_DETAILS=$(tail -n 50 "$TEMP_OUTPUT")
echo "prepare_error<<EOF" >> $GITHUB_OUTPUT
echo "$ERROR_DETAILS" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
fi
# Clean up temp file
rm -f "$TEMP_OUTPUT"
# Exit with the original exit code
exit $PREPARE_EXIT_CODE
env: env:
TRIGGER_PHRASE: ${{ inputs.trigger_phrase }} TRIGGER_PHRASE: ${{ inputs.trigger_phrase }}
ASSIGNEE_TRIGGER: ${{ inputs.assignee_trigger }} ASSIGNEE_TRIGGER: ${{ inputs.assignee_trigger }}
@@ -147,6 +166,8 @@ runs:
CLAUDE_SUCCESS: ${{ steps.claude-code.outputs.conclusion == 'success' }} CLAUDE_SUCCESS: ${{ steps.claude-code.outputs.conclusion == 'success' }}
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_ERROR: ${{ steps.prepare.outputs.prepare_error || '' }}
- name: Display Claude Code Report - name: Display Claude Code Report
if: steps.prepare.outputs.contains_trigger == 'true' && steps.claude-code.outputs.execution_file != '' if: steps.prepare.outputs.contains_trigger == 'true' && steps.claude-code.outputs.execution_file != ''

View File

@@ -145,38 +145,48 @@ async function run() {
duration_api_ms?: number; duration_api_ms?: number;
} | null = null; } | null = null;
let actionFailed = false; let actionFailed = false;
let errorDetails: string | undefined;
// Check for existence of output file and parse it if available // First check if prepare step failed
try { const prepareSuccess = process.env.PREPARE_SUCCESS !== "false";
const outputFile = process.env.OUTPUT_FILE; const prepareError = process.env.PREPARE_ERROR;
if (outputFile) {
const fileContent = await fs.readFile(outputFile, "utf8");
const outputData = JSON.parse(fileContent);
// Output file is an array, get the last element which contains execution details if (!prepareSuccess && prepareError) {
if (Array.isArray(outputData) && outputData.length > 0) { actionFailed = true;
const lastElement = outputData[outputData.length - 1]; errorDetails = prepareError;
if ( } else {
lastElement.role === "system" && // Check for existence of output file and parse it if available
"cost_usd" in lastElement && try {
"duration_ms" in lastElement const outputFile = process.env.OUTPUT_FILE;
) { if (outputFile) {
executionDetails = { const fileContent = await fs.readFile(outputFile, "utf8");
cost_usd: lastElement.cost_usd, const outputData = JSON.parse(fileContent);
duration_ms: lastElement.duration_ms,
duration_api_ms: lastElement.duration_api_ms, // Output file is an array, get the last element which contains execution details
}; if (Array.isArray(outputData) && outputData.length > 0) {
const lastElement = outputData[outputData.length - 1];
if (
lastElement.role === "system" &&
"cost_usd" in lastElement &&
"duration_ms" in lastElement
) {
executionDetails = {
cost_usd: lastElement.cost_usd,
duration_ms: lastElement.duration_ms,
duration_api_ms: lastElement.duration_api_ms,
};
}
} }
} }
}
// Check if the action failed by looking at the exit code or error marker // Check if the Claude action failed
const claudeSuccess = process.env.CLAUDE_SUCCESS !== "false"; const claudeSuccess = process.env.CLAUDE_SUCCESS !== "false";
actionFailed = !claudeSuccess; 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 // If we can't read the file, check for any failure markers
actionFailed = process.env.CLAUDE_SUCCESS === "false"; actionFailed = process.env.CLAUDE_SUCCESS === "false";
}
} }
// Prepare input for updateCommentBody function // Prepare input for updateCommentBody function
@@ -189,6 +199,7 @@ async function run() {
prLink, prLink,
branchName: shouldDeleteBranch ? undefined : claudeBranch, branchName: shouldDeleteBranch ? undefined : claudeBranch,
triggerUsername, triggerUsername,
errorDetails,
}; };
const updatedBody = updateCommentBody(commentInput); const updatedBody = updateCommentBody(commentInput);

View File

@@ -15,6 +15,7 @@ export type CommentUpdateInput = {
prLink?: string; prLink?: string;
branchName?: string; branchName?: string;
triggerUsername?: string; triggerUsername?: string;
errorDetails?: string;
}; };
export function ensureProperlyEncodedUrl(url: string): string | null { export function ensureProperlyEncodedUrl(url: string): string | null {
@@ -75,6 +76,7 @@ export function updateCommentBody(input: CommentUpdateInput): string {
actionFailed, actionFailed,
branchName, branchName,
triggerUsername, triggerUsername,
errorDetails,
} = input; } = input;
// Extract content from the original comment body // Extract content from the original comment body
@@ -177,7 +179,14 @@ export function updateCommentBody(input: CommentUpdateInput): string {
} }
// Build the new body with blank line between header and separator // Build the new body with blank line between header and separator
let newBody = `${header}${links}\n\n---\n`; let newBody = `${header}${links}`;
// Add error details if available
if (actionFailed && errorDetails) {
newBody += `\n\n<details>\n<summary>Error details</summary>\n\n\`\`\`\n${errorDetails}\n\`\`\`\n\n</details>`;
}
newBody += `\n\n---\n`;
// Clean up the body content // Clean up the body content
// Remove any existing View job run, branch links from the bottom // Remove any existing View job run, branch links from the bottom

View File

@@ -39,6 +39,28 @@ describe("updateCommentBody", () => {
expect(result).toContain("**Claude encountered an error after 45s**"); expect(result).toContain("**Claude encountered an error after 45s**");
}); });
it("includes error details when provided", () => {
const input = {
...baseInput,
currentBody: "Claude Code is working...",
actionFailed: true,
executionDetails: { duration_ms: 45000 },
errorDetails:
"fatal: not a git repository (or any of the parent directories): .git",
};
const result = updateCommentBody(input);
expect(result).toContain("**Claude encountered an error after 45s**");
expect(result).toContain("[View job]");
expect(result).toContain("<details>");
expect(result).toContain("<summary>Error details</summary>");
expect(result).toContain("fatal: not a git repository");
// Ensure error details come after the header/links
const errorIndex = result.indexOf("<details>");
const headerIndex = result.indexOf("**Claude encountered an error");
expect(errorIndex).toBeGreaterThan(headerIndex);
});
it("handles username extraction from content when not provided", () => { it("handles username extraction from content when not provided", () => {
const input = { const input = {
...baseInput, ...baseInput,