Amber Handler #2928
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # 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 |