Skip to content

Commit 3fb014e

Browse files
natifridmanclaude
andauthored
fix(amber): Push to existing PR instead of creating duplicates (#453)
When an issue already has an open PR, Amber now: - Detects the existing PR via gh pr list search - Checks out the existing branch instead of creating a new one - Pushes additional commits to the existing PR - Adds comments to both PR and issue about the update This prevents duplicate PRs like #450, #442, #441, #438. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent cd930b9 commit 3fb014e

File tree

1 file changed

+234
-49
lines changed

1 file changed

+234
-49
lines changed

.github/workflows/amber-issue-handler.yml

Lines changed: 234 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,22 @@
1+
# Amber Issue-to-PR Handler
2+
#
3+
# This workflow automates issue resolution via the Amber background agent.
4+
#
5+
# TRIGGERS:
6+
# - Issue labeled with: amber:auto-fix, amber:refactor, amber:test-coverage
7+
# - Issue comment containing: /amber execute or @amber
8+
#
9+
# BEHAVIOR:
10+
# - Checks for existing open PR for the issue (prevents duplicate PRs)
11+
# - Creates or updates feature branch: amber/issue-{number}-{sanitized-title}
12+
# - Runs Claude Code to implement changes
13+
# - Creates PR or pushes to existing PR
14+
#
15+
# SECURITY:
16+
# - Validates branch names against injection attacks
17+
# - Uses strict regex matching for PR lookup
18+
# - Handles race conditions when PRs are closed during execution
19+
120
name: Amber Issue-to-PR Handler
221

322
on:
@@ -180,29 +199,121 @@ jobs:
180199
181200
echo "prompt_file=amber-prompt.md" >> $GITHUB_OUTPUT
182201
183-
- name: Create feature branch
184-
id: create-branch
202+
- name: Check for existing PR
203+
id: check-existing-pr
185204
env:
186205
ISSUE_NUMBER: ${{ steps.issue-details.outputs.issue_number }}
187-
ISSUE_TITLE: ${{ steps.issue-details.outputs.issue_title }}
206+
GH_TOKEN: ${{ github.token }}
188207
run: |
189-
# Improved sanitization (Issue #10) - handles special chars, spaces, consecutive dashes
190-
SANITIZED_TITLE=$(echo "$ISSUE_TITLE" \
191-
| tr '[:upper:]' '[:lower:]' \
192-
| sed 's/[^a-z0-9-]/-/g' \
193-
| sed 's/--*/-/g' \
194-
| sed 's/^-//' \
195-
| sed 's/-$//' \
196-
| cut -c1-50)
208+
# Validate issue number is numeric to prevent injection
209+
if ! [[ "$ISSUE_NUMBER" =~ ^[0-9]+$ ]]; then
210+
echo "Error: Invalid issue number format"
211+
exit 1
212+
fi
197213
198-
BRANCH_NAME="amber/issue-${ISSUE_NUMBER}-${SANITIZED_TITLE}"
214+
# Check if there's already an open PR for this issue using stricter matching
215+
# Search for PRs that reference this issue and filter by body containing exact "Closes #N" pattern
216+
EXISTING_PR=$(gh pr list --state open --json number,headRefName,body --jq \
217+
".[] | select(.body | test(\"Closes #${ISSUE_NUMBER}($|[^0-9])\")) | {number, headRefName}" \
218+
2>/dev/null | head -1 || echo "")
219+
220+
if [ -n "$EXISTING_PR" ] && [ "$EXISTING_PR" != "null" ] && [ "$EXISTING_PR" != "{}" ]; then
221+
PR_NUMBER=$(echo "$EXISTING_PR" | jq -r '.number')
222+
EXISTING_BRANCH=$(echo "$EXISTING_PR" | jq -r '.headRefName')
223+
224+
# Validate branch name format to prevent command injection
225+
if ! [[ "$EXISTING_BRANCH" =~ ^[a-zA-Z0-9/_.-]+$ ]]; then
226+
echo "Error: Invalid branch name format in existing PR"
227+
echo "existing_pr=false" >> $GITHUB_OUTPUT
228+
exit 0
229+
fi
230+
231+
echo "existing_pr=true" >> $GITHUB_OUTPUT
232+
echo "pr_number=$PR_NUMBER" >> $GITHUB_OUTPUT
233+
echo "existing_branch=$EXISTING_BRANCH" >> $GITHUB_OUTPUT
234+
echo "Found existing PR #$PR_NUMBER on branch $EXISTING_BRANCH"
235+
else
236+
echo "existing_pr=false" >> $GITHUB_OUTPUT
237+
echo "No existing PR found for issue #${ISSUE_NUMBER}"
238+
fi
199239
240+
- name: Create or checkout feature branch
241+
id: create-branch
242+
env:
243+
ISSUE_NUMBER: ${{ steps.issue-details.outputs.issue_number }}
244+
ISSUE_TITLE: ${{ steps.issue-details.outputs.issue_title }}
245+
EXISTING_PR: ${{ steps.check-existing-pr.outputs.existing_pr }}
246+
EXISTING_BRANCH: ${{ steps.check-existing-pr.outputs.existing_branch }}
247+
run: |
200248
git config user.name "Amber Agent"
201249
git config user.email "amber@ambient-code.ai"
202-
git checkout -b "$BRANCH_NAME"
250+
251+
# Validate issue number format
252+
if ! [[ "$ISSUE_NUMBER" =~ ^[0-9]+$ ]]; then
253+
echo "Error: Invalid issue number format"
254+
exit 1
255+
fi
256+
257+
checkout_branch() {
258+
local branch="$1"
259+
local is_existing="$2"
260+
261+
# Validate branch name format (alphanumeric, slashes, dashes, dots, underscores only)
262+
if ! [[ "$branch" =~ ^[a-zA-Z0-9/_.-]+$ ]]; then
263+
echo "Error: Invalid branch name format: $branch"
264+
return 1
265+
fi
266+
267+
echo "Attempting to checkout branch: $branch"
268+
if git fetch origin "$branch" 2>/dev/null; then
269+
git checkout -B "$branch" "origin/$branch"
270+
echo "Checked out existing remote branch: $branch"
271+
elif [ "$is_existing" == "true" ]; then
272+
# Race condition: PR existed but branch was deleted
273+
echo "Warning: Branch $branch no longer exists on remote (PR may have been closed)"
274+
return 1
275+
else
276+
echo "Creating new branch: $branch"
277+
git checkout -b "$branch"
278+
fi
279+
return 0
280+
}
281+
282+
if [ "$EXISTING_PR" == "true" ] && [ -n "$EXISTING_BRANCH" ]; then
283+
# Try to checkout existing PR branch with race condition handling
284+
if ! checkout_branch "$EXISTING_BRANCH" "true"; then
285+
echo "Existing PR branch unavailable, falling back to new branch creation"
286+
# Fall through to create new branch
287+
EXISTING_PR="false"
288+
else
289+
BRANCH_NAME="$EXISTING_BRANCH"
290+
fi
291+
fi
292+
293+
if [ "$EXISTING_PR" != "true" ]; then
294+
# Create new branch with sanitized title
295+
# Sanitization: lowercase, replace non-alphanumeric with dash, collapse dashes, trim
296+
SANITIZED_TITLE=$(echo "$ISSUE_TITLE" \
297+
| tr '[:upper:]' '[:lower:]' \
298+
| sed 's/[^a-z0-9-]/-/g' \
299+
| sed 's/--*/-/g' \
300+
| sed 's/^-//' \
301+
| sed 's/-$//' \
302+
| cut -c1-50)
303+
304+
BRANCH_NAME="amber/issue-${ISSUE_NUMBER}-${SANITIZED_TITLE}"
305+
306+
# Validate the generated branch name
307+
if ! [[ "$BRANCH_NAME" =~ ^[a-zA-Z0-9/_.-]+$ ]]; then
308+
echo "Error: Generated branch name is invalid: $BRANCH_NAME"
309+
exit 1
310+
fi
311+
312+
checkout_branch "$BRANCH_NAME" "false" || exit 1
313+
fi
203314
204315
echo "branch_name=$BRANCH_NAME" >> $GITHUB_OUTPUT
205-
echo "Created branch: $BRANCH_NAME"
316+
echo "Using branch: $BRANCH_NAME"
206317
207318
- name: Read prompt file
208319
id: read-prompt
@@ -297,6 +408,8 @@ jobs:
297408
RUN_ID: ${{ github.run_id }}
298409
GITHUB_SERVER_URL: ${{ github.server_url }}
299410
GITHUB_REPOSITORY: ${{ github.repository }}
411+
EXISTING_PR: ${{ steps.check-existing-pr.outputs.existing_pr }}
412+
EXISTING_PR_NUMBER: ${{ steps.check-existing-pr.outputs.pr_number }}
300413
uses: actions/github-script@v8
301414
with:
302415
script: |
@@ -308,18 +421,24 @@ jobs:
308421
const runId = process.env.RUN_ID;
309422
const serverUrl = process.env.GITHUB_SERVER_URL;
310423
const repository = process.env.GITHUB_REPOSITORY;
424+
const existingPr = process.env.EXISTING_PR === 'true';
425+
const existingPrNumber = process.env.EXISTING_PR_NUMBER;
311426
312427
// Safely get git diff (no shell injection risk with execFile)
313428
const { stdout: diff } = await execFileAsync('git', ['diff', 'HEAD~1', '--stat']);
314429
430+
const nextSteps = existingPr
431+
? `- Review that changes match the issue description\n- Verify no scope creep or unintended modifications\n- Changes pushed to existing PR #${existingPrNumber}`
432+
: `- 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`;
433+
315434
await github.rest.issues.createComment({
316435
owner: context.repo.owner,
317436
repo: context.repo.repo,
318437
issue_number: issueNumber,
319-
body: `## Amber Change Summary\n\nThe following files were modified:\n\n\`\`\`\n${diff}\n\`\`\`\n\n**Next Steps:**\n- 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\n\n---\n🔍 [View AI decision process](${serverUrl}/${repository}/actions/runs/${runId}) (logs available for 90 days)`
438+
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)`
320439
});
321440
322-
- name: Create Pull Request
441+
- name: Create or Update Pull Request
323442
if: steps.check-changes.outputs.has_changes == 'true'
324443
env:
325444
BRANCH_NAME: ${{ steps.check-changes.outputs.branch_name }}
@@ -330,6 +449,8 @@ jobs:
330449
GITHUB_REPOSITORY: ${{ github.repository }}
331450
RUN_ID: ${{ github.run_id }}
332451
GITHUB_SERVER_URL: ${{ github.server_url }}
452+
EXISTING_PR: ${{ steps.check-existing-pr.outputs.existing_pr }}
453+
EXISTING_PR_NUMBER: ${{ steps.check-existing-pr.outputs.pr_number }}
333454
uses: actions/github-script@v8
334455
with:
335456
script: |
@@ -341,6 +462,8 @@ jobs:
341462
const repository = process.env.GITHUB_REPOSITORY;
342463
const runId = process.env.RUN_ID;
343464
const serverUrl = process.env.GITHUB_SERVER_URL;
465+
const existingPr = process.env.EXISTING_PR === 'true';
466+
const existingPrNumber = process.env.EXISTING_PR_NUMBER ? parseInt(process.env.EXISTING_PR_NUMBER) : null;
344467
345468
// Helper function for retrying API calls with exponential backoff
346469
// Retries on: 5xx errors, network errors (no status), JSON parse errors
@@ -368,8 +491,57 @@ jobs:
368491
throw new Error('retryWithBackoff: max retries exceeded');
369492
}
370493
371-
// Create PR with error handling (Issue #3)
494+
// Helper function to safely add a comment with fallback logging
495+
async function safeComment(issueNum, body, description) {
496+
try {
497+
await retryWithBackoff(async () => {
498+
return await github.rest.issues.createComment({
499+
owner: context.repo.owner,
500+
repo: context.repo.repo,
501+
issue_number: issueNum,
502+
body: body
503+
});
504+
});
505+
console.log(`Successfully added comment: ${description}`);
506+
} catch (commentError) {
507+
// Log but don't fail the workflow for comment failures
508+
console.log(`Warning: Failed to add comment (${description}): ${commentError.message}`);
509+
console.log(`Comment body was: ${body.substring(0, 200)}...`);
510+
}
511+
}
512+
372513
try {
514+
// If PR already exists, just add a comment about the new push
515+
if (existingPr && existingPrNumber) {
516+
console.log(`PR #${existingPrNumber} already exists, adding update comment`);
517+
518+
// Add comment to PR about the new commit (with fallback)
519+
await safeComment(
520+
existingPrNumber,
521+
`🤖 **Amber pushed additional changes**
522+
523+
- **Commit:** ${commitSha.substring(0, 7)}
524+
- **Action Type:** ${actionType}
525+
526+
New changes have been pushed to this PR. Please review the updated code.
527+
528+
---
529+
🔍 [View AI decision process](${serverUrl}/${repository}/actions/runs/${runId}) (logs available for 90 days)`,
530+
`PR #${existingPrNumber} update notification`
531+
);
532+
533+
// Also notify on the issue (with fallback)
534+
await safeComment(
535+
issueNumber,
536+
`🤖 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)`,
537+
`Issue #${issueNumber} update notification`
538+
);
539+
540+
console.log(`Updated existing PR #${existingPrNumber}`);
541+
return;
542+
}
543+
544+
// Create new PR
373545
const pr = await github.rest.pulls.create({
374546
owner: context.repo.owner,
375547
repo: context.repo.repo,
@@ -403,36 +575,38 @@ jobs:
403575
Closes #${issueNumber}`
404576
});
405577
406-
// Add labels with retry logic for transient API failures
407-
await retryWithBackoff(async () => {
408-
return await github.rest.issues.addLabels({
409-
owner: context.repo.owner,
410-
repo: context.repo.repo,
411-
issue_number: pr.data.number,
412-
labels: ['amber-generated', 'auto-fix', actionType]
578+
// Add labels with retry logic for transient API failures (non-critical)
579+
try {
580+
await retryWithBackoff(async () => {
581+
return await github.rest.issues.addLabels({
582+
owner: context.repo.owner,
583+
repo: context.repo.repo,
584+
issue_number: pr.data.number,
585+
labels: ['amber-generated', 'auto-fix', actionType]
586+
});
413587
});
414-
});
588+
} catch (labelError) {
589+
console.log(`Warning: Failed to add labels to PR #${pr.data.number}: ${labelError.message}`);
590+
}
415591
416-
// Link PR back to issue
417-
await github.rest.issues.createComment({
418-
owner: context.repo.owner,
419-
repo: context.repo.repo,
420-
issue_number: issueNumber,
421-
body: `🤖 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)`
422-
});
592+
// Link PR back to issue (with fallback)
593+
await safeComment(
594+
issueNumber,
595+
`🤖 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)`,
596+
`Issue #${issueNumber} PR link notification`
597+
);
423598
424599
console.log('Created PR:', pr.data.html_url);
425600
} catch (error) {
426-
console.error('Failed to create PR:', error);
427-
core.setFailed(`PR creation failed: ${error.message}`);
428-
429-
// Notify on issue about failure
430-
await github.rest.issues.createComment({
431-
owner: context.repo.owner,
432-
repo: context.repo.repo,
433-
issue_number: issueNumber,
434-
body: `⚠️ 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.`
435-
});
601+
console.error('Failed to create/update PR:', error);
602+
core.setFailed(`PR creation/update failed: ${error.message}`);
603+
604+
// Notify on issue about failure (with fallback - best effort)
605+
await safeComment(
606+
issueNumber,
607+
`⚠️ 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.`,
608+
`Issue #${issueNumber} PR failure notification`
609+
);
436610
}
437611
438612
- name: Report failure
@@ -447,16 +621,23 @@ jobs:
447621
with:
448622
script: |
449623
const issueNumber = parseInt(process.env.ISSUE_NUMBER);
450-
const actionType = process.env.ACTION_TYPE;
624+
const actionType = process.env.ACTION_TYPE || 'unknown';
451625
const runId = process.env.RUN_ID;
452626
const serverUrl = process.env.GITHUB_SERVER_URL;
453627
const repository = process.env.GITHUB_REPOSITORY;
454628
455-
await github.rest.issues.createComment({
456-
owner: context.repo.owner,
457-
repo: context.repo.repo,
458-
issue_number: issueNumber,
459-
body: `⚠️ Amber encountered an error while processing this issue.
629+
// Validate issue number before attempting comment
630+
if (!issueNumber || isNaN(issueNumber)) {
631+
console.log('Error: Invalid issue number, cannot post failure comment');
632+
return;
633+
}
634+
635+
try {
636+
await github.rest.issues.createComment({
637+
owner: context.repo.owner,
638+
repo: context.repo.repo,
639+
issue_number: issueNumber,
640+
body: `⚠️ Amber encountered an error while processing this issue.
460641
461642
**Action Type:** ${actionType}
462643
**Workflow Run:** ${serverUrl}/${repository}/actions/runs/${runId}
@@ -467,4 +648,8 @@ jobs:
467648
3. Ensure the changes are feasible for automation
468649
469650
Manual intervention may be required for complex changes.`
470-
});
651+
});
652+
console.log(`Posted failure comment to issue #${issueNumber}`);
653+
} catch (commentError) {
654+
console.log(`Warning: Failed to post failure comment to issue #${issueNumber}: ${commentError.message}`);
655+
}

0 commit comments

Comments
 (0)