diff --git a/.github/workflows/amber-issue-handler.yml b/.github/workflows/amber-issue-handler.yml index b4d1e7fd4..8e9f83c5b 100644 --- a/.github/workflows/amber-issue-handler.yml +++ b/.github/workflows/amber-issue-handler.yml @@ -100,16 +100,14 @@ jobs: first line of the PR body (read your session ID from the AGENTIC_SESSION_NAME environment variable): + At the bottom of the PR body, add a session link: + --- + 🤖 [Ambient Session]($PLATFORM_HOST/projects/$AGENTIC_SESSION_NAMESPACE/sessions/$AGENTIC_SESSION_NAME) 6. Add the `ambient-code:managed` label to the PR. - 7. After creating the PR, send a Slack notification: - ```bash - curl -X POST -H 'Content-type: application/json' \ - --data '{"text":"PR created for #${{ steps.issue.outputs.number }}\n*PR*: \n*Session*: '"$PLATFORM_HOST/projects/$AGENTIC_SESSION_NAMESPACE/sessions/$AGENTIC_SESSION_NAME"'"}' \ - "$SLACK_WEBHOOK_URL" - ``` - Only send if SLACK_WEBHOOK_URL is set. - 8. Ensure CI passes. If it fails, investigate and fix. - 9. Do not merge. Leave the PR open for human review. + 7. Ensure CI passes. If it fails, investigate and fix. + 8. Do not merge. Leave the PR open for human review. + 9. When you comment on the PR, include this footer at the end: + _🤖 [Session]($PLATFORM_HOST/projects/$AGENTIC_SESSION_NAMESPACE/sessions/$AGENTIC_SESSION_NAME)_ repos: >- [{"url": "https://github.com/${{ github.repository }}", "branch": "main"}] model: claude-opus-4-6 @@ -231,13 +229,17 @@ jobs: 4. Ensure the PR body contains this frontmatter as the first line (read your session ID from the AGENTIC_SESSION_NAME environment variable): - Increment retry_count from whatever it was before. If retry_count reaches 3 or more, - stop working, add `ambient-code:needs-human` label, remove `ambient-code:managed` label, + 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]($PLATFORM_HOST/projects/$AGENTIC_SESSION_NAMESPACE/sessions/$AGENTIC_SESSION_NAME)_ ## Slack Notifications @@ -293,16 +295,14 @@ jobs: first line of the PR body (read your session ID from the AGENTIC_SESSION_NAME environment variable): + At the bottom of the PR body, add a session link: + --- + 🤖 [Ambient Session]($PLATFORM_HOST/projects/$AGENTIC_SESSION_NAMESPACE/sessions/$AGENTIC_SESSION_NAME) 6. Add the `ambient-code:managed` label to the PR. - 7. After creating the PR, send a Slack notification: - ```bash - curl -X POST -H 'Content-type: application/json' \ - --data '{"text":"PR created for #${{ steps.context.outputs.number }}\n*PR*: \n*Session*: '"$PLATFORM_HOST/projects/$AGENTIC_SESSION_NAMESPACE/sessions/$AGENTIC_SESSION_NAME"'"}' \ - "$SLACK_WEBHOOK_URL" - ``` - Only send if SLACK_WEBHOOK_URL is set. - 8. Ensure CI passes. If it fails, investigate and fix. - 9. Do not merge. Leave the PR open for human review. + 7. Ensure CI passes. If it fails, investigate and fix. + 8. Do not merge. Leave the PR open for human review. + 9. When you comment on the PR, include this footer at the end: + _🤖 [Session]($PLATFORM_HOST/projects/$AGENTIC_SESSION_NAMESPACE/sessions/$AGENTIC_SESSION_NAME)_ repos: >- [{"url": "https://github.com/${{ github.repository }}", "branch": "main"}] model: claude-opus-4-6 @@ -552,10 +552,49 @@ jobs: print(f" Failed to create session: {e}") return None - # Get all open ambient-code:managed PRs (include updatedAt to avoid per-PR API calls) + 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,updatedAt") + "--json", "number,body,title") prs = json.loads(prs_json) if prs_json else [] print(f"Found {len(prs)} ambient-code:managed PRs") @@ -576,12 +615,13 @@ jobs: session_id = fm["session_id"] source = fm["source"] - # Check for changes using updatedAt from gh pr list (no extra API call) - updated_at = pr.get("updatedAt", "") - if updated_at and updated_at <= fm["last_action"]: - print(f"PR #{number}: no changes since {fm['last_action']} (updatedAt={updated_at}), skipping") + # Only trigger if the PR actually needs work + needs_work, reason = needs_attention(number) + if not needs_work: + print(f"PR #{number}: {reason}, skipping") 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'})") @@ -602,12 +642,17 @@ jobs: 4. Ensure the PR body contains this frontmatter as the first line (read your session ID from the AGENTIC_SESSION_NAME environment variable): - The current retry_count is {current_retry}. Increment it by 1. + 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]($PLATFORM_HOST/projects/$AGENTIC_SESSION_NAMESPACE/sessions/$AGENTIC_SESSION_NAME)_ ## Slack Notifications diff --git a/.github/workflows/pr-merge-review.yml b/.github/workflows/pr-merge-review.yml index 938943996..f64c6fbc1 100644 --- a/.github/workflows/pr-merge-review.yml +++ b/.github/workflows/pr-merge-review.yml @@ -3,51 +3,185 @@ name: Review Queue on: workflow_dispatch: schedule: - - cron: '0 13 * * 1-5' # weekdays 1pm UTC + - cron: '0 * * * 1-5' # hourly on weekdays permissions: contents: read + issues: write + pull-requests: write jobs: - create-session: + review-queue: runs-on: ubuntu-latest timeout-minutes: 15 + concurrency: + group: review-queue + cancel-in-progress: false steps: - - name: Create review queue session - id: session - uses: ambient-code/ambient-action@v0.0.2 - with: - api-url: ${{ secrets.AMBIENT_API_URL }} - api-token: ${{ secrets.AMBIENT_BOT_TOKEN }} - project: ${{ secrets.AMBIENT_PROJECT }} - prompt: >- - Review all open PRs in https://github.com/${{ github.repository }} - and generate a prioritized review queue. Evaluate each PR via - sub-agents, classify by type (bug-fix, feature, refactor, etc.), - rank by urgency, test merge order, manage the "Review Queue" - milestone, and update the milestone description with the report. - repos: >- - [{"url": "https://github.com/${{ github.repository }}", "branch": "main"}] - workflow: >- - {"gitUrl": "https://github.com/ambient-code/workflows", "branch": "main", "path": "internal-workflows/pr-overview"} - model: claude-sonnet-4-5 - wait: 'true' - timeout: '10' - - - name: Session summary - if: always() + - name: Evaluate PRs and update milestone + id: evaluate env: - SESSION_NAME: ${{ steps.session.outputs.session-name }} - SESSION_UID: ${{ steps.session.outputs.session-uid }} - SESSION_PHASE: ${{ steps.session.outputs.session-phase }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} + run: | + pip install --quiet 'requests>=2.31.0' + + python3 - << 'PYEOF' + import json + import os + import subprocess + + REPO = os.environ.get("GITHUB_REPOSITORY", "") + SLACK_URL = os.environ.get("SLACK_WEBHOOK_URL", "") + MILESTONE_NAME = "Review Queue" + + def gh(*args, input_text=None): + result = subprocess.run(["gh"] + list(args), capture_output=True, text=True, input=input_text) + return result.stdout.strip() + + def get_or_create_milestone(): + """Get the Review Queue milestone number, creating it if needed.""" + milestones = gh("api", f"repos/{REPO}/milestones", "--jq", + f'[.[] | select(.title == "{MILESTONE_NAME}")] | .[0].number') + if milestones and milestones != "null": + return int(milestones) + # Create it + result = gh("api", f"repos/{REPO}/milestones", "-X", "POST", + "-f", f"title={MILESTONE_NAME}", + "-f", "description=PRs ready for human review and merge", + "--jq", ".number") + return int(result) if result else None + + def get_milestone_prs(milestone_number): + """Get PR numbers currently in the milestone.""" + prs = gh("pr", "list", "--repo", REPO, "--state", "open", + "--json", "number,milestone", + "--jq", f'[.[] | select(.milestone.number == {milestone_number})] | [.[].number]') + return set(json.loads(prs)) if prs else set() + + def is_ready_to_review(pr): + """Check if a PR is ready for human review.""" + number = pr["number"] + + # Skip drafts + if pr.get("isDraft", False): + return False, "draft" + + # Check CI status + status_checks = gh("pr", "checks", str(number), "--repo", REPO, + "--json", "name,state", + "--jq", '[.[] | .state] | unique') + try: + states = json.loads(status_checks) if status_checks else [] + except json.JSONDecodeError: + states = [] + + if not states: + return False, "CI unknown" + + if "FAILURE" in states: + return False, "CI failing" + if "PENDING" in states: + return False, "CI pending" + + # Check for unresolved critical/major review comments + bot_logins = ["github-actions[bot]", "ambient-code[bot]", "ambient-bot"] + bot_filter = " and ".join([f'.user.login != "{b}"' for b in bot_logins]) + reviews = gh("api", f"repos/{REPO}/pulls/{number}/reviews", + "--jq", f'[.[] | select(.state == "CHANGES_REQUESTED" and {bot_filter})] | length') + try: + changes_requested = int(reviews) if reviews else 0 + except ValueError: + changes_requested = 0 + + if changes_requested > 0: + return False, "changes requested" + + return True, "ready" + + def slack_notify(text): + """Send a Slack notification.""" + if not SLACK_URL: + return + import requests + try: + requests.post(SLACK_URL, json={"text": text}, timeout=10) + except Exception as e: + print(f" Slack notification failed: {e}") + + # Get milestone + milestone_number = get_or_create_milestone() + if not milestone_number: + print("Failed to get/create milestone") + exit(1) + + current_milestone_prs = get_milestone_prs(milestone_number) + print(f"Current milestone PRs: {current_milestone_prs}") + + # Get all open non-draft PRs (not just ambient-code:managed) + prs_json = gh("pr", "list", "--repo", REPO, "--state", "open", + "--limit", "200", "--json", "number,title,isDraft,url,headRefName") + prs = json.loads(prs_json) if prs_json else [] + print(f"Found {len(prs)} open PRs") + + ready_prs = [] + not_ready = [] + newly_added = [] + + for pr in prs: + number = pr["number"] + ready, reason = is_ready_to_review(pr) + + if ready: + ready_prs.append(pr) + if number not in current_milestone_prs: + # Add to milestone + gh("api", f"repos/{REPO}/issues/{number}", + "-X", "PATCH", "-f", f"milestone={milestone_number}") + newly_added.append(pr) + print(f" PR #{number}: added to milestone (ready)") + else: + print(f" PR #{number}: already in milestone") + else: + # Remove from milestone if it was there + if number in current_milestone_prs: + gh("api", f"repos/{REPO}/issues/{number}", + "-X", "PATCH", "--input", "-", + input_text=json.dumps({"milestone": None})) + print(f" PR #{number}: removed from milestone ({reason})") + else: + print(f" PR #{number}: not ready ({reason})") + not_ready.append({"number": number, "reason": reason}) + + # Send Slack notification only for newly added PRs + if newly_added: + lines = [f"📋 *{len(newly_added)} PR{'s' if len(newly_added) > 1 else ''} ready for review*\n"] + for pr in newly_added: + lines.append(f"• <{pr['url']}|#{pr['number']}> — {pr['title']}") + lines.append(f"\n_Total in : {len(ready_prs)}_") + slack_notify("\n".join(lines)) + print(f"\nSlack: notified about {len(newly_added)} new PR(s)") + else: + print("\nNo new PRs added to milestone — no Slack notification") + + # Update milestone description + ready_list = "\n".join([f"- #{pr['number']} — {pr['title']}" for pr in ready_prs]) + not_ready_list = "\n".join([f"- #{item['number']} ({item['reason']})" for item in not_ready[:10]]) + description = f"## Ready for Review ({len(ready_prs)})\n\n{ready_list or 'None'}" + if not_ready_list: + description += f"\n\n## Not Ready ({len(not_ready)})\n\n{not_ready_list}" + if len(not_ready) > 10: + description += f"\n- ... and {len(not_ready) - 10} more" + + gh("api", f"repos/{REPO}/milestones/{milestone_number}", + "-X", "PATCH", "-f", f"description={description}") + + print(f"\nSummary: {len(ready_prs)} ready, {len(not_ready)} not ready, {len(newly_added)} newly added") + PYEOF + + - name: Summary + if: always() run: | echo "### Review Queue" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - if [ -n "$SESSION_NAME" ]; then - echo "- **Session**: \`$SESSION_NAME\`" >> $GITHUB_STEP_SUMMARY - echo "- **UID**: \`$SESSION_UID\`" >> $GITHUB_STEP_SUMMARY - echo "- **Phase**: \`$SESSION_PHASE\`" >> $GITHUB_STEP_SUMMARY - else - echo "- **Status**: Failed to create session" >> $GITHUB_STEP_SUMMARY - fi + echo "See step logs for details" >> $GITHUB_STEP_SUMMARY