Skip to content

Amber Handler

Amber Handler #2928

# Amber Handler
#
# Single workflow for all Amber AI automation.
#
# TRIGGERS:
# - Issue labeled with: ambient-code:auto-fix → fresh session prompt
# - Comment "@ambient-code" alone on issue/PR → follow-up/fix prompt
# - Comment "@ambient-code <instruction>" on issue/PR → custom prompt
# - Cron every 30 min → shell-driven batch for all ambient-code:managed PRs
# - Manual dispatch → same as cron
#
# SESSION REUSE:
# All paths check PR frontmatter for existing session ID.
# If found, sends message to that session. If not, creates new.
name: Amber Handler
on:
issues:
types: [labeled]
issue_comment:
types: [created]
schedule:
- cron: '*/30 * * * 1-5'
workflow_dispatch:
permissions:
contents: read
issues: write
pull-requests: write
checks: write
jobs:
# -- Issue: labeled ambient-code:auto-fix → fresh session prompt --
handle-issue-label:
if: >-
github.event_name == 'issues'
&& github.event.label.name == 'ambient-code:auto-fix'
concurrency:
group: amber-${{ github.event.issue.number }}
cancel-in-progress: false
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Acknowledge issue
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh api repos/${{ github.repository }}/issues/${{ github.event.issue.number }}/reactions \
-X POST -f content=eyes --silent || true
- name: Resolve issue details
id: issue
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
NUMBER="${{ github.event.issue.number }}"
TITLE=$(gh issue view "$NUMBER" --repo "${{ github.repository }}" --json title --jq '.title')
echo "number=$NUMBER" >> $GITHUB_OUTPUT
{
echo "title<<EOF"
echo "$TITLE"
echo "EOF"
} >> $GITHUB_OUTPUT
- name: Check for existing PR
id: existing
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
NUMBER="${{ steps.issue.outputs.number }}"
EXISTING=$(gh pr list --repo "${{ github.repository }}" --state open --label "ambient-code:managed" --limit 200 --json number,body --jq ".[] | select((.body // \"\") | test(\"source=#${NUMBER}(\\\\s|$)\")) | .number" | head -1 || echo "")
if [ -n "$EXISTING" ]; then
echo "Found existing ambient-code:managed PR #$EXISTING for issue #$NUMBER — skipping"
echo "skip=true" >> $GITHUB_OUTPUT
else
echo "skip=false" >> $GITHUB_OUTPUT
fi
- name: Post immediate acknowledgement
if: steps.existing.outputs.skip != 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh issue comment ${{ steps.issue.outputs.number }} --repo "${{ github.repository }}" \
--body "🤖 Amber is working on this issue. A PR will be created shortly with the \`ambient-code:managed\` label."
- name: Create session
if: steps.existing.outputs.skip != 'true'
id: session
uses: ambient-code/ambient-action@v0.0.5
with:
api-url: ${{ secrets.AMBIENT_API_URL }}
api-token: ${{ secrets.AMBIENT_BOT_TOKEN }}
project: ${{ secrets.AMBIENT_PROJECT }}
prompt: |
You are investigating and fixing a GitHub issue.
Source: #${{ steps.issue.outputs.number }} — ${{ steps.issue.outputs.title }}
URL: https://github.com/${{ github.repository }}/issues/${{ steps.issue.outputs.number }}
## Instructions
1. Read the issue body and all comments for full context (`gh issue view --comments`).
2. Explore the codebase to find the relevant code.
3. Create a plan for how to fix the issue. If there is any ambiguity —
unclear requirements, multiple valid approaches, missing context — you
MUST ask for clarification. IMPORTANT: Before calling AskUserQuestion,
ALWAYS send a Slack notification first (the session stops streaming when
AskUserQuestion is called, so the notification must go out before).
Use Slack mrkdwn link format: <URL|display text>. Example:
```bash
TITLE=$(gh issue view ${{ steps.issue.outputs.number }} --repo ${{ github.repository }} --json title --jq '.title')
PAYLOAD=$(jq -nc --arg text "❓ *Question about <https://github.com/${{ github.repository }}/issues/${{ steps.issue.outputs.number }}|#${{ steps.issue.outputs.number }} — $TITLE>*
<your question here>
<$PLATFORM_HOST/projects/$AGENTIC_SESSION_NAMESPACE/sessions/$AGENTIC_SESSION_NAME|View Session>" '{text: $text}')
curl -X POST -H 'Content-type: application/json' --data "$PAYLOAD" "$SLACK_WEBHOOK_URL"
```
Only send if SLACK_WEBHOOK_URL is set. Then call AskUserQuestion.
4. Implement the fix. Write tests if the area has existing test coverage.
5. Create a PR with a clear description. Include this frontmatter as the
first line of the PR body (read your session ID from the
AGENTIC_SESSION_NAME environment variable):
<!-- acp:session_id=$AGENTIC_SESSION_NAME source=#${{ steps.issue.outputs.number }} last_action=<ISO8601_NOW> retry_count=0 -->
At the bottom of the PR body, add a session link:
---
🤖 [Ambient Session](<SESSION_URL>)
6. Add the `ambient-code:managed` label to the PR.
7. Add the `ambient-code:self-reviewed` label to the PR.
8. After creating the PR, send a Slack notification with a brief summary
of what you changed. Use Slack mrkdwn link format: <URL|display text>. Example:
```bash
TITLE=$(gh issue view ${{ steps.issue.outputs.number }} --repo ${{ github.repository }} --json title --jq '.title')
PAYLOAD=$(jq -nc --arg text "🔧 *PR created for <https://github.com/${{ github.repository }}/issues/${{ steps.issue.outputs.number }}|#${{ steps.issue.outputs.number }} — $TITLE>*
<PR_URL|View PR> · <SESSION_URL|View Session>
<1-2 sentence summary of what you changed>" '{text: $text}')
curl -X POST -H 'Content-type: application/json' --data "$PAYLOAD" "$SLACK_WEBHOOK_URL"
```
Only send if SLACK_WEBHOOK_URL is set.
9. Ensure CI passes. If it fails, investigate and fix.
10. Do not merge. Leave the PR open for human review.
11. When you comment on the PR, include this footer at the end:
_🤖 [Session](<SESSION_URL>)_
## Session URL
IMPORTANT: Wherever a session link is needed (PR body, comments, Slack),
you MUST first resolve the URL by reading the environment variables. Run:
```bash
echo "$PLATFORM_HOST/projects/$AGENTIC_SESSION_NAMESPACE/sessions/$AGENTIC_SESSION_NAME"
```
Use the resolved output as the actual URL. Do NOT write `$PLATFORM_HOST`,
`$AGENTIC_SESSION_NAMESPACE`, or `$AGENTIC_SESSION_NAME` literally in any
markdown link — always substitute their actual values.
repos: >-
[{"url": "https://github.com/${{ github.repository }}", "branch": "main"}]
model: claude-opus-4-6
wait: 'true'
timeout: '0'
environment-variables: >-
{"SLACK_WEBHOOK_URL": "${{ secrets.SLACK_WEBHOOK_URL }}", "PLATFORM_HOST": "${{ secrets.PLATFORM_HOST }}", "GITHUB_TOKEN": "${{ secrets.GITHUB_TOKEN }}"}
- name: Post-session update comment
if: steps.existing.outputs.skip != 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SESSION_NAME: ${{ steps.session.outputs.session-name }}
SESSION_PHASE: ${{ steps.session.outputs.session-phase }}
SESSION_URL: ${{ steps.session.outputs.session-url }}
PLATFORM_HOST: ${{ secrets.PLATFORM_HOST }}
AMBIENT_PROJECT: ${{ secrets.AMBIENT_PROJECT }}
run: |
if [ -n "$SESSION_NAME" ]; then
# Build session link — prefer session-url output, fall back to constructed URL
if [ -n "$SESSION_URL" ]; then
LINK="$SESSION_URL"
elif [ -n "$PLATFORM_HOST" ] && [ -n "$AMBIENT_PROJECT" ]; then
LINK="${PLATFORM_HOST%/}/projects/$AMBIENT_PROJECT/sessions/$SESSION_NAME"
else
LINK=""
fi
if [ -n "$LINK" ]; then
BODY="🤖 Amber session completed (phase: $SESSION_PHASE). [View session]($LINK)"
else
BODY="🤖 Amber session \`$SESSION_NAME\` completed (phase: $SESSION_PHASE)."
fi
gh issue comment ${{ steps.issue.outputs.number }} --repo "${{ github.repository }}" --body "$BODY"
fi
- name: Session summary
if: always() && steps.existing.outputs.skip != 'true'
env:
SESSION_NAME: ${{ steps.session.outputs.session-name }}
SESSION_PHASE: ${{ steps.session.outputs.session-phase }}
run: |
echo "### Amber — Issue #${{ steps.issue.outputs.number }}" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
if [ -n "$SESSION_NAME" ]; then
echo "- **Session**: \`$SESSION_NAME\`" >> $GITHUB_STEP_SUMMARY
echo "- **Phase**: \`$SESSION_PHASE\`" >> $GITHUB_STEP_SUMMARY
else
echo "- **Status**: Failed to create session" >> $GITHUB_STEP_SUMMARY
fi
# -- @ambient-code comment on an issue or PR --
handle-comment:
if: >-
github.event_name == 'issue_comment'
&& contains(github.event.comment.body, '@ambient-code')
&& (github.event.comment.author_association == 'MEMBER'
|| github.event.comment.author_association == 'OWNER'
|| github.event.comment.author_association == 'COLLABORATOR')
concurrency:
group: amber-${{ github.event.issue.number }}
cancel-in-progress: false
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Acknowledge comment
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh api repos/${{ github.repository }}/issues/comments/${{ github.event.comment.id }}/reactions \
-X POST -f content=eyes --silent || true
- name: Resolve context
id: context
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
COMMENT_BODY: ${{ github.event.comment.body }}
IS_PR: ${{ github.event.issue.pull_request && 'true' || 'false' }}
run: |
NUMBER="${{ github.event.issue.number }}"
echo "number=$NUMBER" >> $GITHUB_OUTPUT
# Determine if @ambient-code is alone (fix prompt) or has instruction text (custom prompt)
STRIPPED=$(echo "$COMMENT_BODY" | sed 's/@ambient-code//g' | tr -d '[:space:]')
if [ -z "$STRIPPED" ]; then
echo "prompt_type=fix" >> $GITHUB_OUTPUT
else
echo "prompt_type=custom" >> $GITHUB_OUTPUT
fi
if [ "$IS_PR" = "true" ]; then
echo "type=pr" >> $GITHUB_OUTPUT
echo "url=https://github.com/${{ github.repository }}/pull/$NUMBER" >> $GITHUB_OUTPUT
TITLE=$(gh pr view "$NUMBER" --repo "${{ github.repository }}" --json title --jq '.title')
{
echo "title<<EOF"
echo "$TITLE"
echo "EOF"
} >> $GITHUB_OUTPUT
IS_FORK=$(gh pr view "$NUMBER" --repo "${{ github.repository }}" --json isCrossRepository --jq '.isCrossRepository')
echo "is_fork=$IS_FORK" >> $GITHUB_OUTPUT
# Check for existing session in PR frontmatter
BODY=$(gh pr view "$NUMBER" --repo "${{ github.repository }}" --json body --jq '.body')
SESSION_ID=$(echo "$BODY" | grep -oP 'acp:session_id=\K[^ ]+' | head -1 || echo "")
echo "session_id=$SESSION_ID" >> $GITHUB_OUTPUT
else
echo "type=issue" >> $GITHUB_OUTPUT
echo "url=https://github.com/${{ github.repository }}/issues/$NUMBER" >> $GITHUB_OUTPUT
TITLE=$(gh issue view "$NUMBER" --repo "${{ github.repository }}" --json title --jq '.title')
{
echo "title<<EOF"
echo "$TITLE"
echo "EOF"
} >> $GITHUB_OUTPUT
echo "is_fork=false" >> $GITHUB_OUTPUT
# Check for existing ambient-code:managed PR for this issue and get its session ID
EXISTING_PR=$(gh pr list --repo "${{ github.repository }}" --state open --label "ambient-code:managed" --limit 200 --json number,body --jq ".[] | select((.body // \"\") | test(\"source=#${NUMBER}(\\\\s|$)\"))" | head -1 || echo "")
if [ -n "$EXISTING_PR" ]; then
SESSION_ID=$(echo "$EXISTING_PR" | jq -r '.body' | grep -oP 'acp:session_id=\K[^ ]+' | head -1 || echo "")
echo "session_id=$SESSION_ID" >> $GITHUB_OUTPUT
else
echo "session_id=" >> $GITHUB_OUTPUT
fi
fi
# Fix prompt on a PR: @ambient-code alone — assess and fix CI/conflicts/reviews
- name: Run fix prompt (PR)
if: >-
steps.context.outputs.is_fork != 'true'
&& steps.context.outputs.prompt_type == 'fix'
&& steps.context.outputs.type == 'pr'
id: fix-session
uses: ambient-code/ambient-action@v0.0.5
with:
api-url: ${{ secrets.AMBIENT_API_URL }}
api-token: ${{ secrets.AMBIENT_BOT_TOKEN }}
project: ${{ secrets.AMBIENT_PROJECT }}
session-name: ${{ steps.context.outputs.session_id }}
prompt: |
You are maintaining a pull request.
URL: ${{ steps.context.outputs.url }}
## Instructions
1. Assess the current state:
- Are there merge conflicts? Resolve them.
- Is CI failing? Read the logs and fix the failures.
- Are there review comments (human or bot like CodeRabbit)? Address each comment.
2. For each issue you fix, call `log_correction` to record what went wrong and how you fixed it.
3. Push fixes.
4. Ensure the PR body contains this frontmatter as the first line
(read your session ID from the AGENTIC_SESSION_NAME environment variable):
<!-- acp:session_id=$AGENTIC_SESSION_NAME source=#${{ steps.context.outputs.number }} last_action=<ISO8601_NOW> retry_count=<N> -->
Only increment retry_count if you actually had to fix something (CI failure,
conflict, review comment). If the PR is already healthy, do NOT increment —
just update last_action. If retry_count reaches 3 or more, stop working,
add `ambient-code:needs-human` label, remove `ambient-code:managed` label,
comment "AI was unable to resolve after 3 attempts. Needs human attention.",
and send a Slack notification (see below).
5. Add the `ambient-code:managed` label.
6. Do not merge. Do not close. Do not force-push.
7. If fundamentally broken beyond repair, add a comment explaining and stop.
8. When you comment on the PR, include this footer at the end:
_🤖 [Session](<SESSION_URL>)_
## Session URL
IMPORTANT: Wherever a session link is needed (PR body, comments, Slack),
you MUST first resolve the URL by reading the environment variables. Run:
```bash
echo "$PLATFORM_HOST/projects/$AGENTIC_SESSION_NAMESPACE/sessions/$AGENTIC_SESSION_NAME"
```
Use the resolved output as the actual URL. Do NOT write `$PLATFORM_HOST`,
`$AGENTIC_SESSION_NAMESPACE`, or `$AGENTIC_SESSION_NAME` literally in any
markdown link — always substitute their actual values.
## Slack Notifications
When you need human attention — circuit breaker, stuck, or before calling
AskUserQuestion — send a Slack notification. IMPORTANT: Always send BEFORE
calling AskUserQuestion (the session stops streaming on that call).
Use Slack mrkdwn link format: <URL|display text>. Example:
```bash
TITLE=$(gh pr view ${{ steps.context.outputs.number }} --repo ${{ github.repository }} --json title --jq '.title')
PAYLOAD=$(jq -nc --arg text "🚨 *Need help with <${{ steps.context.outputs.url }}|PR #${{ steps.context.outputs.number }} — $TITLE>*
<reason — what you tried and why you're stuck>
<$PLATFORM_HOST/projects/$AGENTIC_SESSION_NAMESPACE/sessions/$AGENTIC_SESSION_NAME|View Session>" '{text: $text}')
curl -X POST -H 'Content-type: application/json' --data "$PAYLOAD" "$SLACK_WEBHOOK_URL"
```
The environment variables SLACK_WEBHOOK_URL, PLATFORM_HOST, AGENTIC_SESSION_NAMESPACE,
and AGENTIC_SESSION_NAME are available in your environment. Only send if SLACK_WEBHOOK_URL is set.
# Fix prompt on an issue: @ambient-code alone — investigate and create PR (same as fresh prompt)
- name: Run fix prompt (issue)
if: >-
steps.context.outputs.is_fork != 'true'
&& steps.context.outputs.prompt_type == 'fix'
&& steps.context.outputs.type == 'issue'
id: fix-issue-session
uses: ambient-code/ambient-action@v0.0.5
with:
api-url: ${{ secrets.AMBIENT_API_URL }}
api-token: ${{ secrets.AMBIENT_BOT_TOKEN }}
project: ${{ secrets.AMBIENT_PROJECT }}
session-name: ${{ steps.context.outputs.session_id }}
prompt: |
You are investigating and fixing a GitHub issue.
Source: #${{ steps.context.outputs.number }}
URL: ${{ steps.context.outputs.url }}
## Instructions
1. Read the issue body and all comments for full context (`gh issue view --comments`).
2. Explore the codebase to find the relevant code.
3. Create a plan for how to fix the issue. If there is any ambiguity —
unclear requirements, multiple valid approaches, missing context — you
MUST ask for clarification. IMPORTANT: Before calling AskUserQuestion,
ALWAYS send a Slack notification first (the session stops streaming when
AskUserQuestion is called, so the notification must go out before).
Use Slack mrkdwn link format: <URL|display text>. Example:
```bash
TITLE=$(gh issue view ${{ steps.context.outputs.number }} --repo ${{ github.repository }} --json title --jq '.title')
PAYLOAD=$(jq -nc --arg text "❓ *Question about <${{ steps.context.outputs.url }}|#${{ steps.context.outputs.number }} — $TITLE>*
<your question here>
<$PLATFORM_HOST/projects/$AGENTIC_SESSION_NAMESPACE/sessions/$AGENTIC_SESSION_NAME|View Session>" '{text: $text}')
curl -X POST -H 'Content-type: application/json' --data "$PAYLOAD" "$SLACK_WEBHOOK_URL"
```
Only send if SLACK_WEBHOOK_URL is set. Then call AskUserQuestion.
4. Implement the fix. Write tests if the area has existing test coverage.
5. Create a PR with a clear description. Include this frontmatter as the
first line of the PR body (read your session ID from the
AGENTIC_SESSION_NAME environment variable):
<!-- acp:session_id=$AGENTIC_SESSION_NAME source=#${{ steps.context.outputs.number }} last_action=<ISO8601_NOW> retry_count=0 -->
At the bottom of the PR body, add a session link:
---
🤖 [Ambient Session](<SESSION_URL>)
6. Add the `ambient-code:managed` label to the PR.
7. Add the `ambient-code:self-reviewed` label to the PR.
8. After creating the PR, send a Slack notification with a brief summary
of what you changed. Use Slack mrkdwn link format: <URL|display text>. Example:
```bash
TITLE=$(gh issue view ${{ steps.context.outputs.number }} --repo ${{ github.repository }} --json title --jq '.title')
PAYLOAD=$(jq -nc --arg text "🔧 *PR created for <${{ steps.context.outputs.url }}|#${{ steps.context.outputs.number }} — $TITLE>*
<PR_URL|View PR> · <SESSION_URL|View Session>
<1-2 sentence summary of what you changed>" '{text: $text}')
curl -X POST -H 'Content-type: application/json' --data "$PAYLOAD" "$SLACK_WEBHOOK_URL"
```
Only send if SLACK_WEBHOOK_URL is set.
9. Ensure CI passes. If it fails, investigate and fix.
10. Do not merge. Leave the PR open for human review.
11. When you comment on the PR, include this footer at the end:
_🤖 [Session](<SESSION_URL>)_
## Session URL
IMPORTANT: Wherever a session link is needed (PR body, comments, Slack),
you MUST first resolve the URL by reading the environment variables. Run:
```bash
echo "$PLATFORM_HOST/projects/$AGENTIC_SESSION_NAMESPACE/sessions/$AGENTIC_SESSION_NAME"
```
Use the resolved output as the actual URL. Do NOT write `$PLATFORM_HOST`,
`$AGENTIC_SESSION_NAMESPACE`, or `$AGENTIC_SESSION_NAME` literally in any
markdown link — always substitute their actual values.
repos: >-
[{"url": "https://github.com/${{ github.repository }}", "branch": "main"}]
model: claude-opus-4-6
wait: 'true'
timeout: '0'
environment-variables: >-
{"SLACK_WEBHOOK_URL": "${{ secrets.SLACK_WEBHOOK_URL }}", "PLATFORM_HOST": "${{ secrets.PLATFORM_HOST }}", "GITHUB_TOKEN": "${{ secrets.GITHUB_TOKEN }}"}
# Custom prompt: @ambient-code <instruction> — pass user's text
- name: Run custom prompt
if: >-
steps.context.outputs.is_fork != 'true'
&& steps.context.outputs.prompt_type == 'custom'
id: custom-session
uses: ambient-code/ambient-action@v0.0.5
with:
api-url: ${{ secrets.AMBIENT_API_URL }}
api-token: ${{ secrets.AMBIENT_BOT_TOKEN }}
project: ${{ secrets.AMBIENT_PROJECT }}
session-name: ${{ steps.context.outputs.session_id }}
prompt: |
Context: ${{ steps.context.outputs.type }} #${{ steps.context.outputs.number }}
URL: ${{ steps.context.outputs.url }}
${{ github.event.comment.body }}
repos: >-
[{"url": "https://github.com/${{ github.repository }}", "branch": "main"}]
model: claude-opus-4-6
wait: 'true'
timeout: '0'
environment-variables: >-
{"SLACK_WEBHOOK_URL": "${{ secrets.SLACK_WEBHOOK_URL }}", "PLATFORM_HOST": "${{ secrets.PLATFORM_HOST }}", "GITHUB_TOKEN": "${{ secrets.GITHUB_TOKEN }}"}
- name: Post check run on PR
if: >-
always()
&& steps.context.outputs.is_fork != 'true'
&& steps.context.outputs.type == 'pr'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
SESSION_NAME="${{ steps.fix-session.outputs.session-name || steps.fix-issue-session.outputs.session-name || steps.custom-session.outputs.session-name }}"
SESSION_URL="${{ steps.fix-session.outputs.session-url || steps.fix-issue-session.outputs.session-url || steps.custom-session.outputs.session-url }}"
SESSION_PHASE="${{ steps.fix-session.outputs.session-phase || steps.fix-issue-session.outputs.session-phase || steps.custom-session.outputs.session-phase }}"
if [ -z "$SESSION_NAME" ]; then
exit 0
fi
# Get PR head SHA
HEAD_SHA=$(gh pr view ${{ steps.context.outputs.number }} --repo "${{ github.repository }}" --json headRefOid --jq '.headRefOid')
# Look for an existing in_progress "Amber Session" check run to update
EXISTING_CHECK_ID=$(gh api "repos/${{ github.repository }}/commits/${HEAD_SHA}/check-runs" \
--jq '.check_runs[] | select(.name == "Amber Session" and .status == "in_progress") | .id' \
2>/dev/null | head -1 || echo "")
# Build check run arguments
CHECK_ARGS=(
-f "name=Amber Session"
-f "output[title]=Amber — ${{ steps.context.outputs.prompt_type }} prompt"
-f "output[summary]=Session \`$SESSION_NAME\` (phase: $SESSION_PHASE)"
)
if [ -n "$SESSION_URL" ]; then
CHECK_ARGS+=(-f "details_url=$SESSION_URL")
fi
case "$SESSION_PHASE" in
Running)
CHECK_ARGS+=(-f "status=in_progress") ;;
Completed)
CHECK_ARGS+=(-f "status=completed" -f "conclusion=success") ;;
Error|Failed)
CHECK_ARGS+=(-f "status=completed" -f "conclusion=failure") ;;
*)
CHECK_ARGS+=(-f "status=completed" -f "conclusion=neutral") ;;
esac
if [ -n "$EXISTING_CHECK_ID" ]; then
# Update the existing check run (PATCH)
if ! CHECK_OUTPUT=$(gh api "repos/${{ github.repository }}/check-runs/${EXISTING_CHECK_ID}" \
-X PATCH "${CHECK_ARGS[@]}" 2>&1); then
echo "::warning::Failed to update check run: $CHECK_OUTPUT"
fi
else
# Create a new check run (POST)
CHECK_ARGS+=(-f "head_sha=$HEAD_SHA")
if ! CHECK_OUTPUT=$(gh api "repos/${{ github.repository }}/check-runs" \
-X POST "${CHECK_ARGS[@]}" 2>&1); then
echo "::warning::Failed to create check run: $CHECK_OUTPUT"
fi
fi
- name: Session summary
if: always() && steps.context.outputs.is_fork != 'true'
run: |
SESSION_NAME="${{ steps.fix-session.outputs.session-name || steps.fix-issue-session.outputs.session-name || steps.custom-session.outputs.session-name }}"
SESSION_PHASE="${{ steps.fix-session.outputs.session-phase || steps.fix-issue-session.outputs.session-phase || steps.custom-session.outputs.session-phase }}"
echo "### Amber — ${{ steps.context.outputs.type }} #${{ steps.context.outputs.number }}" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
if [ -n "$SESSION_NAME" ]; then
echo "- **Session**: \`$SESSION_NAME\`" >> $GITHUB_STEP_SUMMARY
echo "- **Phase**: \`$SESSION_PHASE\`" >> $GITHUB_STEP_SUMMARY
echo "- **Prompt**: ${{ steps.context.outputs.prompt_type }}" >> $GITHUB_STEP_SUMMARY
else
echo "- **Status**: Failed to create session" >> $GITHUB_STEP_SUMMARY
fi
# -- Batch: manage all ambient-code:managed PRs (shell-driven) --
batch-pr-fixer:
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
concurrency:
group: amber-batch
cancel-in-progress: false
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- name: Find and process ambient-code:managed PRs
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
AMBIENT_API_URL: ${{ secrets.AMBIENT_API_URL }}
AMBIENT_API_TOKEN: ${{ secrets.AMBIENT_BOT_TOKEN }}
AMBIENT_PROJECT: ${{ secrets.AMBIENT_PROJECT }}
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
PLATFORM_HOST: ${{ secrets.PLATFORM_HOST }}
run: |
pip install --quiet 'requests>=2.31.0'
python3 - << 'PYEOF'
import json
import os
import re
import subprocess
import time
import uuid
import requests
from datetime import datetime, timezone
REPO = os.environ.get("GITHUB_REPOSITORY", "")
API_URL = os.environ["AMBIENT_API_URL"]
API_TOKEN = os.environ["AMBIENT_API_TOKEN"]
PROJECT = os.environ["AMBIENT_PROJECT"]
def gh(*args):
result = subprocess.run(["gh"] + list(args), capture_output=True, text=True)
return result.stdout.strip()
def parse_frontmatter(body):
"""Extract session_id, source, last_action, retry_count from PR body frontmatter."""
match = re.search(r'acp:session_id=(\S+)\s+source=(\S+)\s+last_action=(\S+)\s+retry_count=(\d+)', body or "")
if not match:
return None
# Validate last_action is ISO8601-ish to prevent injection
la = match.group(3)
if not re.match(r'^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}', la):
print(f" Warning: invalid last_action format: {la}")
la = "1970-01-01T00:00:00Z"
return {
"session_id": match.group(1),
"source": match.group(2),
"last_action": la,
"retry_count": int(match.group(4)),
}
def get_session_phase(session_name):
"""Get the current phase of a session."""
url = f"{API_URL.rstrip('/')}/projects/{PROJECT}/agentic-sessions/{session_name}"
try:
resp = requests.get(url, headers={"Authorization": f"Bearer {API_TOKEN}"}, timeout=15)
if resp.status_code == 404:
return None
resp.raise_for_status()
return resp.json().get("status", {}).get("phase", "Unknown")
except Exception as e:
print(f" Failed to get session phase: {e}")
return None
def start_session_api(session_name):
"""Start/restart a stopped session."""
url = f"{API_URL.rstrip('/')}/projects/{PROJECT}/agentic-sessions/{session_name}/start"
try:
resp = requests.post(url, headers={"Authorization": f"Bearer {API_TOKEN}"}, timeout=30)
resp.raise_for_status()
return True
except Exception as e:
print(f" Failed to start session: {e}")
return False
def get_head_sha(pr_number):
"""Get the head SHA of a PR."""
return gh("pr", "view", str(pr_number), "--repo", REPO, "--json", "headRefOid", "--jq", ".headRefOid")
def get_existing_check_run_id(head_sha):
"""Find an existing in_progress 'Amber Session' check run for the given SHA."""
try:
result = gh("api", f"repos/{REPO}/commits/{head_sha}/check-runs",
"--jq", '.check_runs[] | select(.name == "Amber Session" and .status == "in_progress") | .id')
ids = result.strip().split("\n") if result.strip() else []
return ids[0] if ids else None
except Exception:
return None
def update_check_run(check_run_id, status, conclusion=None, title="Amber — batch fix", summary=""):
"""Update an existing check run by ID."""
args = ["api", f"repos/{REPO}/check-runs/{check_run_id}",
"-X", "PATCH",
"-f", f"status={status}",
"-f", f"output[title]={title}",
"-f", f"output[summary]={summary}"]
if conclusion:
args.extend(["-f", f"conclusion={conclusion}"])
proc = subprocess.run(["gh"] + args, capture_output=True, text=True)
if proc.returncode != 0:
print(f" Warning: failed to update check run {check_run_id}: {proc.stderr or proc.stdout}")
def resolve_stale_checks(pr_number, session_name=None):
"""Find all in_progress 'Amber Session' check runs on a PR and complete them."""
head_sha = get_head_sha(pr_number)
if not head_sha:
return
try:
result = gh("api", f"repos/{REPO}/commits/{head_sha}/check-runs",
"--jq", '.check_runs[] | select(.name == "Amber Session" and .status == "in_progress") | .id')
ids = [x.strip() for x in result.strip().split("\n") if x.strip()]
except Exception:
ids = []
if not ids:
return
# Check session phase if we have a session name
conclusion = "neutral"
summary = "Session completed (resolved by batch cycle)"
if session_name:
phase = get_session_phase(session_name)
if phase == "Completed":
conclusion = "success"
summary = f"Session `{session_name}` completed successfully"
elif phase in ("Error", "Failed"):
conclusion = "failure"
summary = f"Session `{session_name}` failed (phase: {phase})"
else:
summary = f"Session `{session_name}` resolved (phase: {phase or 'unknown'})"
for check_id in ids:
print(f" Resolving stale check run {check_id} on PR #{pr_number}")
update_check_run(check_id, "completed", conclusion=conclusion, summary=summary)
def post_check_run(pr_number, session_name):
"""Post an in_progress check run on the PR linking to the Amber session.
If an existing in_progress check exists, update it instead of creating a duplicate."""
head_sha = get_head_sha(pr_number)
if not head_sha:
return
host = os.environ.get("PLATFORM_HOST", "").rstrip("/")
session_url = f"{host}/projects/{PROJECT}/sessions/{session_name}" if host else ""
# Resolve any existing stale checks before posting a new one
existing_id = get_existing_check_run_id(head_sha)
if existing_id:
args = ["api", f"repos/{REPO}/check-runs/{existing_id}",
"-X", "PATCH",
"-f", "status=in_progress",
"-f", "output[title]=Amber — batch fix",
"-f", f"output[summary]=Session `{session_name}` triggered for PR #{pr_number}"]
if session_url:
args.extend(["-f", f"details_url={session_url}"])
proc = subprocess.run(["gh"] + list(args), capture_output=True, text=True)
if proc.returncode != 0:
print(f" Warning: failed to update check run for PR #{pr_number}: {proc.stderr or proc.stdout}")
return
args = ["api", f"repos/{REPO}/check-runs",
"-X", "POST",
"-f", "name=Amber Session",
"-f", f"head_sha={head_sha}",
"-f", "status=in_progress",
"-f", "output[title]=Amber — batch fix",
"-f", f"output[summary]=Session `{session_name}` triggered for PR #{pr_number}"]
if session_url:
args.extend(["-f", f"details_url={session_url}"])
proc = subprocess.run(["gh"] + list(args), capture_output=True, text=True)
if proc.returncode != 0:
print(f" Warning: failed to create check run for PR #{pr_number}: {proc.stderr or proc.stdout}")
def create_session_api(prompt, session_name="", model="claude-opus-4-6"):
"""Create a new session or send message to existing one."""
if session_name:
# Ensure session is running
phase = get_session_phase(session_name)
if phase is None:
print(f" Session {session_name} not found, creating new")
session_name = "" # Fall through to create
elif phase != "Running":
print(f" Session {session_name} is {phase}, starting...")
start_session_api(session_name)
for _ in range(20):
time.sleep(3)
if get_session_phase(session_name) == "Running":
break
if session_name:
# Send message
url = f"{API_URL.rstrip('/')}/projects/{PROJECT}/agentic-sessions/{session_name}/agui/run"
body = {"threadId": session_name, "runId": str(uuid.uuid4()),
"messages": [{"id": str(uuid.uuid4()), "role": "user", "content": prompt}]}
try:
resp = requests.post(url, headers={"Authorization": f"Bearer {API_TOKEN}", "Content-Type": "application/json"},
json=body, timeout=30)
resp.raise_for_status()
print(f" Message sent to session {session_name}")
return session_name
except Exception as e:
print(f" Failed to send message: {e}")
return None
# Create new session
url = f"{API_URL.rstrip('/')}/projects/{PROJECT}/agentic-sessions"
slack_url = os.environ.get("SLACK_WEBHOOK_URL", "")
platform_host = os.environ.get("PLATFORM_HOST", "").rstrip("/")
env_vars = {}
if slack_url:
env_vars["SLACK_WEBHOOK_URL"] = slack_url
if platform_host:
env_vars["PLATFORM_HOST"] = platform_host
body = {"initialPrompt": prompt,
"llmSettings": {"model": model},
"repos": [{"url": f"https://github.com/{REPO}", "branch": "main"}],
**({"environmentVariables": env_vars} if env_vars else {})}
try:
resp = requests.post(url, headers={"Authorization": f"Bearer {API_TOKEN}", "Content-Type": "application/json"},
json=body, timeout=30)
resp.raise_for_status()
name = resp.json().get("name", "")
print(f" Created session {name}")
return name
except Exception as e:
print(f" Failed to create session: {e}")
return None
BOT_LOGINS = ["github-actions[bot]", "ambient-code[bot]", "ambient-bot"]
def needs_attention(pr_number):
"""Check if a PR has actionable issues that need the fixer's attention.
Returns (needs_work, reason) tuple."""
# Check CI status
checks_json = gh("pr", "checks", str(pr_number), "--repo", REPO,
"--json", "name,state",
"--jq", "[.[] | .state] | unique")
try:
states = json.loads(checks_json) if checks_json else []
except json.JSONDecodeError:
states = []
if not states:
pass # No CI checks — nothing to fix
elif "FAILURE" in states:
return True, "CI failing"
# Check for merge conflicts
mergeable = gh("pr", "view", str(pr_number), "--repo", REPO,
"--json", "mergeable", "--jq", ".mergeable")
if mergeable == "CONFLICTING":
return True, "merge conflicts"
# Check for changes_requested from non-bot users
bot_filter = " and ".join([f'.user.login != "{b}"' for b in BOT_LOGINS])
try:
reviews_raw = gh("api", f"repos/{REPO}/pulls/{pr_number}/reviews",
"--jq", f'[.[] | select(.state == "CHANGES_REQUESTED" and {bot_filter})] | length')
changes_requested = int(reviews_raw) if reviews_raw else 0
except (ValueError, TypeError):
changes_requested = 0
if changes_requested > 0:
return True, "changes requested"
return False, "healthy"
# Get all open ambient-code:managed PRs
prs_json = gh("pr", "list", "--repo", REPO, "--state", "open",
"--label", "ambient-code:managed", "--limit", "200",
"--json", "number,body,title")
prs = json.loads(prs_json) if prs_json else []
print(f"Found {len(prs)} ambient-code:managed PRs")
processed = 0
skipped = 0
pending_sessions = []
for pr in prs:
number = pr["number"]
body = pr.get("body", "")
fm = parse_frontmatter(body)
session_id = ""
source = f"#{number}"
if not fm:
print(f"PR #{number}: no frontmatter, will create new session")
else:
session_id = fm["session_id"]
source = fm["source"]
# Only trigger if the PR actually needs work
needs_work, reason = needs_attention(number)
if not needs_work:
print(f"PR #{number}: {reason}, skipping")
# Resolve any stale in_progress checks on healthy PRs
resolve_stale_checks(number, session_name=session_id)
skipped += 1
continue
print(f"PR #{number}: {reason}")
# Trigger fix — reuse session if exists, create new if not
print(f"PR #{number}: triggering fix (session_id={session_id or 'new'})")
current_retry = fm["retry_count"] if fm else 0
prompt = f"""You are maintaining a pull request.
URL: https://github.com/{REPO}/pull/{number}
## Instructions
1. Assess the current state:
- Are there merge conflicts? Resolve them.
- Is CI failing? Read the logs and fix the failures.
- Are there review comments (human or bot like CodeRabbit)? Address each comment.
2. For each issue you fix, call `log_correction` to record what went wrong and how you fixed it.
3. Push fixes.
4. Ensure the PR body contains this frontmatter as the first line
(read your session ID from the AGENTIC_SESSION_NAME environment variable):
<!-- acp:session_id=$AGENTIC_SESSION_NAME source={source} last_action=<ISO8601_NOW> retry_count=<N> -->
The current retry_count is {current_retry}. Only increment retry_count if
you actually had to fix something (CI failure, conflict, review comment).
If the PR is already healthy (CI green, no conflicts, no open reviews),
do NOT increment — just update last_action.
If retry_count reaches 3 or more, stop working, add `ambient-code:needs-human` label,
remove `ambient-code:managed` label, comment on the PR, and send a Slack notification.
5. Add the `ambient-code:managed` label.
6. Do not merge. Do not close. Do not force-push.
7. If fundamentally broken beyond repair, add a comment explaining and stop.
8. When you comment on the PR, include this footer at the end:
_🤖 [Session](<SESSION_URL>)_
## Session URL
IMPORTANT: Wherever a session link is needed (PR body, comments, Slack),
you MUST first resolve the URL by reading the environment variables. Run:
```bash
echo "$PLATFORM_HOST/projects/$AGENTIC_SESSION_NAMESPACE/sessions/$AGENTIC_SESSION_NAME"
```
Use the resolved output as the actual URL. Do NOT write `$PLATFORM_HOST`,
`$AGENTIC_SESSION_NAMESPACE`, or `$AGENTIC_SESSION_NAME` literally in any
markdown link — always substitute their actual values.
## Slack Notifications
When you need human attention — circuit breaker, stuck, or before calling
AskUserQuestion — send a Slack notification. IMPORTANT: Always send BEFORE
calling AskUserQuestion. Use Slack mrkdwn link format: <URL|display text>.
TITLE=$(gh pr view {number} --repo {REPO} --json title --jq '.title')
PAYLOAD=$(jq -nc --arg text "🚨 *Need help with <https://github.com/{REPO}/pull/{number}|PR #{number} — $TITLE>*
<reason — what you tried and why you are stuck>
<$PLATFORM_HOST/projects/$AGENTIC_SESSION_NAMESPACE/sessions/$AGENTIC_SESSION_NAME|View Session>" '{{text: $text}}')
curl -X POST -H 'Content-type: application/json' --data "$PAYLOAD" "$SLACK_WEBHOOK_URL"
Only send if SLACK_WEBHOOK_URL is set."""
result_name = create_session_api(prompt, session_name=session_id)
if result_name:
post_check_run(number, result_name)
pending_sessions.append({"pr": number, "session": result_name})
processed += 1
# Poll pending sessions for completion and update their check runs
if pending_sessions:
print(f"\nPolling {len(pending_sessions)} pending session(s) for completion (max 30 min)...")
poll_deadline = time.time() + 1800 # 30 minutes
while pending_sessions and time.time() < poll_deadline:
time.sleep(30)
still_pending = []
for item in pending_sessions:
phase = get_session_phase(item["session"])
if phase in ("Completed", "Error", "Failed", None):
print(f" Session {item['session']} for PR #{item['pr']} finished (phase: {phase})")
resolve_stale_checks(item["pr"], session_name=item["session"])
elif phase == "Running":
still_pending.append(item)
else:
# Unknown/stopped phase — resolve as neutral
print(f" Session {item['session']} for PR #{item['pr']} in unexpected phase: {phase}")
resolve_stale_checks(item["pr"], session_name=item["session"])
pending_sessions = still_pending
if pending_sessions:
print(f" Still waiting on {len(pending_sessions)} session(s)...")
# Resolve any remaining sessions that timed out
for item in pending_sessions:
print(f" Session {item['session']} for PR #{item['pr']} timed out, resolving checks")
resolve_stale_checks(item["pr"], session_name=item["session"])
print(f"\nBatch complete: {processed} processed, {skipped} skipped")
PYEOF
- name: Batch summary
if: always()
run: |
echo "### Amber — Batch PR Fixer" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "See step logs for per-PR details" >> $GITHUB_STEP_SUMMARY