Skip to content

RFE: Make agent pluggable #199

RFE: Make agent pluggable

RFE: Make agent pluggable #199

# Amber Issue-to-PR Handler
#
# This workflow automates issue resolution via the Amber background agent.
#
# TRIGGERS:
# - Issue labeled with: amber:auto-fix, amber:refactor, amber:test-coverage
# - Issue comment containing: /amber execute or @amber
#
# BEHAVIOR:
# - Checks for existing open PR for the issue (prevents duplicate PRs)
# - Creates or updates feature branch: amber/issue-{number}-{sanitized-title}
# - Runs Claude Code to implement changes
# - Creates PR or pushes to existing PR
#
# SECURITY:
# - Validates branch names against injection attacks
# - Uses strict regex matching for PR lookup
# - Handles race conditions when PRs are closed during execution
name: Amber Issue-to-PR Handler
on:
issues:
types: [labeled, opened]
issue_comment:
types: [created]
permissions:
contents: write
issues: write
pull-requests: write
id-token: write # Required for OIDC token (Bedrock/Vertex/Foundry/OAuth)
jobs:
amber-handler:
runs-on: ubuntu-latest
timeout-minutes: 30 # Issue #7: Prevent runaway jobs
# Only run for specific labels, commands, or @amber mentions
if: |
(github.event.label.name == 'amber:auto-fix' ||
github.event.label.name == 'amber:refactor' ||
github.event.label.name == 'amber:test-coverage' ||
contains(github.event.comment.body, '/amber execute') ||
contains(github.event.comment.body, '@amber'))
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Determine Amber action type
id: action-type
env:
LABEL_NAME: ${{ github.event.label.name }}
COMMENT_BODY: ${{ github.event.comment.body }}
run: |
# Parse label or comment to determine action
if [[ "$LABEL_NAME" == "amber:auto-fix" ]]; then
echo "type=auto-fix" >> $GITHUB_OUTPUT
echo "severity=low" >> $GITHUB_OUTPUT
elif [[ "$LABEL_NAME" == "amber:refactor" ]]; then
echo "type=refactor" >> $GITHUB_OUTPUT
echo "severity=medium" >> $GITHUB_OUTPUT
elif [[ "$LABEL_NAME" == "amber:test-coverage" ]]; then
echo "type=test-coverage" >> $GITHUB_OUTPUT
echo "severity=medium" >> $GITHUB_OUTPUT
elif [[ "$COMMENT_BODY" == *"/amber execute"* ]] || [[ "$COMMENT_BODY" == *"@amber"* ]]; then
# Treat @amber mentions same as /amber execute - let Claude figure out the intent
echo "type=execute-proposal" >> $GITHUB_OUTPUT
echo "severity=medium" >> $GITHUB_OUTPUT
else
echo "type=unknown" >> $GITHUB_OUTPUT
exit 1
fi
- name: Extract issue details
id: issue-details
uses: actions/github-script@v8
with:
script: |
const issue = context.payload.issue;
// Parse issue body for Amber-compatible context
const body = issue.body || '';
// Extract file paths mentioned in issue
const filePattern = /(?:File|Path):\s*`?([^\s`]+)`?/gi;
const files = [...body.matchAll(filePattern)].map(m => m[1]);
// Extract specific instructions
const instructionPattern = /(?:Instructions?|Task):\s*\n([\s\S]*?)(?:\n#{2,}|\n---|\n\*\*|$)/i;
const instructionMatch = body.match(instructionPattern);
const instructions = instructionMatch ? instructionMatch[1].trim() : '';
// Set outputs
core.setOutput('issue_number', issue.number);
core.setOutput('issue_title', issue.title);
core.setOutput('issue_body', body);
core.setOutput('files', JSON.stringify(files));
core.setOutput('instructions', instructions || issue.title);
console.log('Parsed issue:', {
number: issue.number,
title: issue.title,
files: files,
instructions: instructions || issue.title
});
- name: Create Amber agent prompt
id: create-prompt
env:
ISSUE_NUMBER: ${{ steps.issue-details.outputs.issue_number }}
ISSUE_TITLE: ${{ steps.issue-details.outputs.issue_title }}
ISSUE_INSTRUCTIONS: ${{ steps.issue-details.outputs.instructions }}
ISSUE_FILES: ${{ steps.issue-details.outputs.files }}
ACTION_TYPE: ${{ steps.action-type.outputs.type }}
ACTION_SEVERITY: ${{ steps.action-type.outputs.severity }}
run: |
cat > /tmp/amber-prompt.md <<'EOF'
# Amber Agent Task: Issue #${ISSUE_NUMBER}
**Action Type:** ${ACTION_TYPE}
**Severity:** ${ACTION_SEVERITY}
## Issue Details
**Title:** ${ISSUE_TITLE}
**Instructions:**
${ISSUE_INSTRUCTIONS}
**Files to modify (if specified):**
${ISSUE_FILES}
## Your Mission
Based on the action type, perform the following:
### For `auto-fix` type:
1. Identify the specific linting/formatting issues mentioned
2. Run appropriate formatters (gofmt, black, prettier, etc.)
3. Fix any trivial issues (unused imports, spacing, etc.)
4. Ensure all changes pass existing tests
5. Create a clean commit with conventional format
### For `refactor` type:
1. Analyze the current code structure
2. Implement the refactoring as described in the issue
3. Ensure backward compatibility (no breaking changes)
4. Add/update tests to cover refactored code
5. Verify all existing tests still pass
### For `test-coverage` type:
1. Analyze current test coverage for specified files
2. Identify untested code paths
3. Write contract tests following project standards (see CLAUDE.md)
4. Ensure tests follow table-driven test pattern (Go) or pytest patterns (Python)
5. Verify all new tests pass
### For `execute-proposal` type:
1. Read the full issue body for the proposed implementation
2. Execute the changes as specified in the proposal
3. Follow the risk assessment and rollback plan provided
4. Ensure all testing strategies are implemented
## Requirements
- Follow all standards in `CLAUDE.md`
- Use conventional commit format: `type(scope): message`
- Run all linters BEFORE committing:
- Go: `gofmt -w .`, `golangci-lint run`
- Python: `black .`, `isort .`, `flake8`
- TypeScript: `npm run lint`
- Ensure ALL tests pass: `make test`
- Create branch following pattern: `amber/issue-${ISSUE_NUMBER}-{description}`
## Success Criteria
- All linters pass with 0 warnings
- All existing tests pass
- New code follows project conventions
- Commit message is clear and follows conventional format
- Changes are focused on issue scope (no scope creep)
## Output Format
After completing the work, provide:
1. **Summary of changes** (2-3 sentences)
2. **Files modified** (list with line count changes)
3. **Test results** (pass/fail for each test suite)
4. **Linting results** (confirm all pass)
5. **Commit SHA**
Ready to execute!
EOF
# Substitute environment variables
envsubst < /tmp/amber-prompt.md > amber-prompt.md
echo "prompt_file=amber-prompt.md" >> $GITHUB_OUTPUT
- name: Check for existing PR
id: check-existing-pr
env:
ISSUE_NUMBER: ${{ steps.issue-details.outputs.issue_number }}
GH_TOKEN: ${{ github.token }}
run: |
# Validate issue number is numeric to prevent injection
if ! [[ "$ISSUE_NUMBER" =~ ^[0-9]+$ ]]; then
echo "Error: Invalid issue number format"
exit 1
fi
# Check if there's already an open PR for this issue using stricter matching
# Search for PRs that reference this issue and filter by body containing exact "Closes #N" pattern
EXISTING_PR=$(gh pr list --state open --json number,headRefName,body --jq \
".[] | select(.body | test(\"Closes #${ISSUE_NUMBER}($|[^0-9])\")) | {number, headRefName}" \
2>/dev/null | head -1 || echo "")
if [ -n "$EXISTING_PR" ] && [ "$EXISTING_PR" != "null" ] && [ "$EXISTING_PR" != "{}" ]; then
PR_NUMBER=$(echo "$EXISTING_PR" | jq -r '.number')
EXISTING_BRANCH=$(echo "$EXISTING_PR" | jq -r '.headRefName')
# Validate branch name format to prevent command injection
if ! [[ "$EXISTING_BRANCH" =~ ^[a-zA-Z0-9/_.-]+$ ]]; then
echo "Error: Invalid branch name format in existing PR"
echo "existing_pr=false" >> $GITHUB_OUTPUT
exit 0
fi
echo "existing_pr=true" >> $GITHUB_OUTPUT
echo "pr_number=$PR_NUMBER" >> $GITHUB_OUTPUT
echo "existing_branch=$EXISTING_BRANCH" >> $GITHUB_OUTPUT
echo "Found existing PR #$PR_NUMBER on branch $EXISTING_BRANCH"
else
echo "existing_pr=false" >> $GITHUB_OUTPUT
echo "No existing PR found for issue #${ISSUE_NUMBER}"
fi
- name: Create or checkout feature branch
id: create-branch
env:
ISSUE_NUMBER: ${{ steps.issue-details.outputs.issue_number }}
ISSUE_TITLE: ${{ steps.issue-details.outputs.issue_title }}
EXISTING_PR: ${{ steps.check-existing-pr.outputs.existing_pr }}
EXISTING_BRANCH: ${{ steps.check-existing-pr.outputs.existing_branch }}
run: |
git config user.name "Amber Agent"
git config user.email "amber@ambient-code.ai"
# Validate issue number format
if ! [[ "$ISSUE_NUMBER" =~ ^[0-9]+$ ]]; then
echo "Error: Invalid issue number format"
exit 1
fi
checkout_branch() {
local branch="$1"
local is_existing="$2"
# Validate branch name format (alphanumeric, slashes, dashes, dots, underscores only)
if ! [[ "$branch" =~ ^[a-zA-Z0-9/_.-]+$ ]]; then
echo "Error: Invalid branch name format: $branch"
return 1
fi
echo "Attempting to checkout branch: $branch"
if git fetch origin "$branch" 2>/dev/null; then
git checkout -B "$branch" "origin/$branch"
echo "Checked out existing remote branch: $branch"
elif [ "$is_existing" == "true" ]; then
# Race condition: PR existed but branch was deleted
echo "Warning: Branch $branch no longer exists on remote (PR may have been closed)"
return 1
else
echo "Creating new branch: $branch"
git checkout -b "$branch"
fi
return 0
}
if [ "$EXISTING_PR" == "true" ] && [ -n "$EXISTING_BRANCH" ]; then
# Try to checkout existing PR branch with race condition handling
if ! checkout_branch "$EXISTING_BRANCH" "true"; then
echo "Existing PR branch unavailable, falling back to new branch creation"
# Fall through to create new branch
EXISTING_PR="false"
else
BRANCH_NAME="$EXISTING_BRANCH"
fi
fi
if [ "$EXISTING_PR" != "true" ]; then
# Create new branch with sanitized title
# Sanitization: lowercase, replace non-alphanumeric with dash, collapse dashes, trim
SANITIZED_TITLE=$(echo "$ISSUE_TITLE" \
| tr '[:upper:]' '[:lower:]' \
| sed 's/[^a-z0-9-]/-/g' \
| sed 's/--*/-/g' \
| sed 's/^-//' \
| sed 's/-$//' \
| cut -c1-50)
BRANCH_NAME="amber/issue-${ISSUE_NUMBER}-${SANITIZED_TITLE}"
# Validate the generated branch name
if ! [[ "$BRANCH_NAME" =~ ^[a-zA-Z0-9/_.-]+$ ]]; then
echo "Error: Generated branch name is invalid: $BRANCH_NAME"
exit 1
fi
checkout_branch "$BRANCH_NAME" "false" || exit 1
fi
echo "branch_name=$BRANCH_NAME" >> $GITHUB_OUTPUT
echo "Using branch: $BRANCH_NAME"
- name: Read prompt file
id: read-prompt
run: |
PROMPT_CONTENT=$(cat amber-prompt.md)
# Use heredoc to safely handle multiline content
echo "prompt<<EOF" >> $GITHUB_OUTPUT
cat amber-prompt.md >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
- name: Install Claude Code CLI
run: |
npm install -g @anthropic-ai/claude-code
- name: Execute Amber agent via Claude Code
id: amber-execute
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
run: |
# Run Claude Code with full tool access (you make the rules!)
cat amber-prompt.md | claude --print --dangerously-skip-permissions || true
echo "Claude Code execution completed"
- name: Check if changes were made
id: check-changes
run: |
# Check if there are any new commits on this branch vs main
CURRENT_BRANCH=$(git branch --show-current)
COMMITS_AHEAD=$(git rev-list --count origin/main.."$CURRENT_BRANCH" 2>/dev/null || echo "0")
if [ "$COMMITS_AHEAD" -eq 0 ]; then
echo "has_changes=false" >> $GITHUB_OUTPUT
echo "No changes made by Amber (no new commits)"
else
COMMIT_SHA=$(git rev-parse HEAD)
echo "has_changes=true" >> $GITHUB_OUTPUT
echo "branch_name=$CURRENT_BRANCH" >> $GITHUB_OUTPUT
echo "commit_sha=$COMMIT_SHA" >> $GITHUB_OUTPUT
echo "Changes committed on branch $CURRENT_BRANCH (commit: ${COMMIT_SHA:0:7})"
echo "Commits ahead of main: $COMMITS_AHEAD"
fi
- name: Report no changes
if: steps.check-changes.outputs.has_changes == 'false'
env:
ISSUE_NUMBER: ${{ steps.issue-details.outputs.issue_number }}
ACTION_TYPE: ${{ steps.action-type.outputs.type }}
RUN_ID: ${{ github.run_id }}
GITHUB_SERVER_URL: ${{ github.server_url }}
GITHUB_REPOSITORY: ${{ github.repository }}
uses: actions/github-script@v8
with:
script: |
const issueNumber = parseInt(process.env.ISSUE_NUMBER);
const actionType = process.env.ACTION_TYPE;
const runId = process.env.RUN_ID;
const serverUrl = process.env.GITHUB_SERVER_URL;
const repository = process.env.GITHUB_REPOSITORY;
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
body: `✅ Amber reviewed this issue but found no changes were needed.
**Action Type:** ${actionType}
**Possible reasons:**
- Files are already properly formatted
- No linting issues found
- The requested changes may have already been applied
If you believe changes are still needed, please provide more specific instructions or file paths in the issue description.
---
🔍 [View AI decision process](${serverUrl}/${repository}/actions/runs/${runId}) (logs available for 90 days)`
});
- name: Push branch to remote
if: steps.check-changes.outputs.has_changes == 'true'
env:
BRANCH_NAME: ${{ steps.check-changes.outputs.branch_name }}
run: |
git push -u origin "$BRANCH_NAME"
echo "Pushed branch $BRANCH_NAME to remote"
- name: Validate changes align with issue intent
if: steps.check-changes.outputs.has_changes == 'true'
env:
ISSUE_NUMBER: ${{ steps.issue-details.outputs.issue_number }}
RUN_ID: ${{ github.run_id }}
GITHUB_SERVER_URL: ${{ github.server_url }}
GITHUB_REPOSITORY: ${{ github.repository }}
EXISTING_PR: ${{ steps.check-existing-pr.outputs.existing_pr }}
EXISTING_PR_NUMBER: ${{ steps.check-existing-pr.outputs.pr_number }}
uses: actions/github-script@v8
with:
script: |
const { execFile } = require('child_process');
const { promisify } = require('util');
const execFileAsync = promisify(execFile);
const issueNumber = parseInt(process.env.ISSUE_NUMBER);
const runId = process.env.RUN_ID;
const serverUrl = process.env.GITHUB_SERVER_URL;
const repository = process.env.GITHUB_REPOSITORY;
const existingPr = process.env.EXISTING_PR === 'true';
const existingPrNumber = process.env.EXISTING_PR_NUMBER;
// Safely get git diff (no shell injection risk with execFile)
const { stdout: diff } = await execFileAsync('git', ['diff', 'HEAD~1', '--stat']);
const nextSteps = existingPr
? `- Review that changes match the issue description\n- Verify no scope creep or unintended modifications\n- Changes pushed to existing PR #${existingPrNumber}`
: `- Review that changes match the issue description\n- Verify no scope creep or unintended modifications\n- A PR will be created shortly for formal review`;
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
body: `## Amber Change Summary\n\nThe following files were modified:\n\n\`\`\`\n${diff}\n\`\`\`\n\n**Next Steps:**\n${nextSteps}\n\n---\n🔍 [View AI decision process](${serverUrl}/${repository}/actions/runs/${runId}) (logs available for 90 days)`
});
- name: Create or Update Pull Request
if: steps.check-changes.outputs.has_changes == 'true'
env:
BRANCH_NAME: ${{ steps.check-changes.outputs.branch_name }}
COMMIT_SHA: ${{ steps.check-changes.outputs.commit_sha }}
ISSUE_NUMBER: ${{ steps.issue-details.outputs.issue_number }}
ISSUE_TITLE: ${{ steps.issue-details.outputs.issue_title }}
ACTION_TYPE: ${{ steps.action-type.outputs.type }}
GITHUB_REPOSITORY: ${{ github.repository }}
RUN_ID: ${{ github.run_id }}
GITHUB_SERVER_URL: ${{ github.server_url }}
EXISTING_PR: ${{ steps.check-existing-pr.outputs.existing_pr }}
EXISTING_PR_NUMBER: ${{ steps.check-existing-pr.outputs.pr_number }}
uses: actions/github-script@v8
with:
script: |
const branchName = process.env.BRANCH_NAME;
const commitSha = process.env.COMMIT_SHA;
const issueNumber = parseInt(process.env.ISSUE_NUMBER);
const issueTitle = process.env.ISSUE_TITLE;
const actionType = process.env.ACTION_TYPE;
const repository = process.env.GITHUB_REPOSITORY;
const runId = process.env.RUN_ID;
const serverUrl = process.env.GITHUB_SERVER_URL;
const existingPr = process.env.EXISTING_PR === 'true';
const existingPrNumber = process.env.EXISTING_PR_NUMBER ? parseInt(process.env.EXISTING_PR_NUMBER) : null;
// Helper function for retrying API calls with exponential backoff
// Retries on: 5xx errors, network errors (no status), JSON parse errors
async function retryWithBackoff(fn, maxRetries = 3, initialDelay = 1000) {
for (let i = 0; i < maxRetries; i++) {
try {
return await fn();
} catch (error) {
const isLastAttempt = i === maxRetries - 1;
// Retry on: network errors (undefined status), 5xx errors, or specific error patterns
const isRetriable = !error.status || error.status >= 500;
if (isLastAttempt || !isRetriable) {
throw error;
}
const delay = initialDelay * Math.pow(2, i);
const errorMsg = error.message || 'Unknown error';
const errorStatus = error.status || 'network error';
console.log(`Attempt ${i + 1} failed (${errorStatus}: ${errorMsg}), retrying in ${delay}ms...`);
await new Promise(resolve => setTimeout(resolve, delay));
}
}
// Defensive: Should never reach here due to throw in loop, but explicit for clarity
throw new Error('retryWithBackoff: max retries exceeded');
}
// Helper function to safely add a comment with fallback logging
async function safeComment(issueNum, body, description) {
try {
await retryWithBackoff(async () => {
return await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNum,
body: body
});
});
console.log(`Successfully added comment: ${description}`);
} catch (commentError) {
// Log but don't fail the workflow for comment failures
console.log(`Warning: Failed to add comment (${description}): ${commentError.message}`);
console.log(`Comment body was: ${body.substring(0, 200)}...`);
}
}
try {
// If PR already exists, just add a comment about the new push
if (existingPr && existingPrNumber) {
console.log(`PR #${existingPrNumber} already exists, adding update comment`);
// Add comment to PR about the new commit (with fallback)
await safeComment(
existingPrNumber,
`🤖 **Amber pushed additional changes**
- **Commit:** ${commitSha.substring(0, 7)}
- **Action Type:** ${actionType}
New changes have been pushed to this PR. Please review the updated code.
---
🔍 [View AI decision process](${serverUrl}/${repository}/actions/runs/${runId}) (logs available for 90 days)`,
`PR #${existingPrNumber} update notification`
);
// Also notify on the issue (with fallback)
await safeComment(
issueNumber,
`🤖 Amber pushed additional changes to the existing PR #${existingPrNumber}.\n\n---\n🔍 [View AI decision process](${serverUrl}/${repository}/actions/runs/${runId}) (logs available for 90 days)`,
`Issue #${issueNumber} update notification`
);
console.log(`Updated existing PR #${existingPrNumber}`);
return;
}
// Create new PR
const pr = await github.rest.pulls.create({
owner: context.repo.owner,
repo: context.repo.repo,
title: `[Amber] Fix: ${issueTitle}`,
head: branchName,
base: 'main',
body: `## Automated Fix by Amber Agent
This PR addresses issue #${issueNumber} using the Amber background agent.
### Changes Summary
- **Action Type:** ${actionType}
- **Commit:** ${commitSha.substring(0, 7)}
- **Triggered by:** Issue label/command
### Pre-merge Checklist
- [ ] All linters pass
- [ ] All tests pass
- [ ] Changes follow project conventions (CLAUDE.md)
- [ ] No scope creep beyond issue description
### Reviewer Notes
This PR was automatically generated. Please review:
1. Code quality and adherence to standards
2. Test coverage for changes
3. No unintended side effects
---
🤖 Generated with [Amber Background Agent](https://github.com/${repository}/blob/main/docs/amber-automation.md)
Closes #${issueNumber}`
});
// Add labels with retry logic for transient API failures (non-critical)
try {
await retryWithBackoff(async () => {
return await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.data.number,
labels: ['amber-generated', 'auto-fix', actionType]
});
});
} catch (labelError) {
console.log(`Warning: Failed to add labels to PR #${pr.data.number}: ${labelError.message}`);
}
// Link PR back to issue (with fallback)
await safeComment(
issueNumber,
`🤖 Amber has created a pull request to address this issue: #${pr.data.number}\n\nThe changes are ready for review. All automated checks will run on the PR.\n\n---\n🔍 [View AI decision process](${serverUrl}/${repository}/actions/runs/${runId}) (logs available for 90 days)`,
`Issue #${issueNumber} PR link notification`
);
console.log('Created PR:', pr.data.html_url);
} catch (error) {
console.error('Failed to create/update PR:', error);
core.setFailed(`PR creation/update failed: ${error.message}`);
// Notify on issue about failure (with fallback - best effort)
await safeComment(
issueNumber,
`⚠️ Amber completed changes but failed to create a pull request.\n\n**Error:** ${error.message}\n\nChanges committed to \`${branchName}\`. A maintainer can manually create the PR.`,
`Issue #${issueNumber} PR failure notification`
);
}
- name: Report failure
if: failure()
env:
ISSUE_NUMBER: ${{ steps.issue-details.outputs.issue_number }}
ACTION_TYPE: ${{ steps.action-type.outputs.type }}
RUN_ID: ${{ github.run_id }}
GITHUB_SERVER_URL: ${{ github.server_url }}
GITHUB_REPOSITORY: ${{ github.repository }}
uses: actions/github-script@v8
with:
script: |
const issueNumber = parseInt(process.env.ISSUE_NUMBER);
const actionType = process.env.ACTION_TYPE || 'unknown';
const runId = process.env.RUN_ID;
const serverUrl = process.env.GITHUB_SERVER_URL;
const repository = process.env.GITHUB_REPOSITORY;
// Validate issue number before attempting comment
if (!issueNumber || isNaN(issueNumber)) {
console.log('Error: Invalid issue number, cannot post failure comment');
return;
}
try {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
body: `⚠️ Amber encountered an error while processing this issue.
**Action Type:** ${actionType}
**Workflow Run:** ${serverUrl}/${repository}/actions/runs/${runId}
Please review the workflow logs for details. You may need to:
1. Check if the issue description provides sufficient context
2. Verify the specified files exist
3. Ensure the changes are feasible for automation
Manual intervention may be required for complex changes.`
});
console.log(`Posted failure comment to issue #${issueNumber}`);
} catch (commentError) {
console.log(`Warning: Failed to post failure comment to issue #${issueNumber}: ${commentError.message}`);
}