Claude PR Merge Gate #8586
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
| name: Claude PR Merge Gate | |
| # Read-only Claude assesses merge readiness; GH_TOKEN merges (no @claude, no gh pr review). | |
| # Does not overlap with merge-conflict-claude.yml or auto-pr-comment @claude chains. | |
| on: | |
| schedule: | |
| # Scan for merge-ready PRs every 10 minutes (assess runs only when candidates exist). | |
| - cron: '*/10 * * * *' | |
| workflow_dispatch: | |
| inputs: | |
| pr_number: | |
| description: 'Optional PR number (scan all open internal PRs if empty)' | |
| required: false | |
| type: string | |
| merge_method: | |
| description: 'Merge method' | |
| required: false | |
| type: choice | |
| default: merge | |
| options: | |
| - merge | |
| - squash | |
| - rebase | |
| workflow_run: | |
| workflows: | |
| - Claude Assistant | |
| - Core Tests | |
| types: | |
| - completed | |
| repository_dispatch: | |
| types: | |
| - claude-merge-gate | |
| concurrency: | |
| group: claude-merge-gate-${{ github.repository }}-${{ github.event.inputs.pr_number || github.event.client_payload.pr_number || github.event.workflow_run.id || 'scan' }} | |
| cancel-in-progress: false | |
| env: | |
| GH_TOKEN: ${{ secrets.GH_TOKEN || github.token }} | |
| MAX_CANDIDATES: 3 | |
| DISPATCH_PR_NUMBER: ${{ github.event.inputs.pr_number || github.event.client_payload.pr_number || '' }} | |
| permissions: | |
| contents: read | |
| pull-requests: read | |
| issues: read | |
| actions: read | |
| jobs: | |
| auto-dispatch: | |
| if: github.event_name == 'workflow_run' | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: read | |
| pull-requests: read | |
| issues: read | |
| actions: write | |
| id-token: write | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - name: Generate GitHub App token | |
| id: app-token | |
| uses: actions/create-github-app-token@v1 | |
| with: | |
| app-id: ${{ secrets.CLAUDE_APP_ID }} | |
| private-key: ${{ secrets.CLAUDE_APP_PRIVATE_KEY }} | |
| owner: ${{ github.repository_owner }} | |
| - name: Dispatch merge gate when PR pipeline is ready | |
| uses: actions/github-script@v7 | |
| with: | |
| github-token: ${{ steps.app-token.outputs.token }} | |
| script: | | |
| const path = require('path'); | |
| const mergeGate = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/merge-gate.js')); | |
| const wr = context.payload.workflow_run; | |
| const owner = context.repo.owner; | |
| const repo = context.repo.repo; | |
| if (wr.conclusion !== 'success') { | |
| core.info(`Skip dispatch: ${wr.name} conclusion=${wr.conclusion}`); | |
| return; | |
| } | |
| const allowed = ['Claude Assistant', 'Core Tests']; | |
| if (!allowed.includes(wr.name)) { | |
| core.info(`Skip dispatch: workflow ${wr.name}`); | |
| return; | |
| } | |
| const prNumber = await mergeGate.resolvePrNumberFromWorkflowRun(github, owner, repo, wr); | |
| if (!prNumber) { | |
| core.info('Skip dispatch: could not resolve PR from workflow run'); | |
| return; | |
| } | |
| const fromClaude = wr.name === 'Claude Assistant'; | |
| const check = await mergeGate.evaluatePipelineQuiescent( | |
| github, owner, repo, prNumber, core, { | |
| skipGlobalClaudeRunCheck: fromClaude, | |
| skipRecentClaudeCooldown: fromClaude, | |
| } | |
| ); | |
| if (!check.ready) { | |
| core.info(`PR #${prNumber} not ready: ${check.reasons.join(', ')}`); | |
| return; | |
| } | |
| await github.rest.repos.createDispatchEvent({ | |
| owner, | |
| repo, | |
| event_type: 'claude-merge-gate', | |
| client_payload: { pr_number: prNumber }, | |
| }); | |
| core.info(`Dispatched merge gate for PR #${prNumber} after ${wr.name}`); | |
| scan-candidates: | |
| if: github.event_name != 'workflow_run' | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: read | |
| pull-requests: read | |
| issues: write | |
| actions: read | |
| id-token: write | |
| outputs: | |
| matrix: ${{ steps.scan.outputs.matrix }} | |
| has_candidates: ${{ steps.scan.outputs.has_candidates }} | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - name: Generate GitHub App token | |
| id: app-token | |
| uses: actions/create-github-app-token@v1 | |
| with: | |
| app-id: ${{ secrets.CLAUDE_APP_ID }} | |
| private-key: ${{ secrets.CLAUDE_APP_PRIVATE_KEY }} | |
| owner: ${{ github.repository_owner }} | |
| - name: Scan open PRs for merge gate eligibility | |
| id: scan | |
| uses: actions/github-script@v7 | |
| with: | |
| github-token: ${{ steps.app-token.outputs.token }} | |
| script: | | |
| const path = require('path'); | |
| const mergeGate = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/merge-gate.js')); | |
| const ps = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/pipeline-status.js')); | |
| const owner = context.repo.owner; | |
| const repo = context.repo.repo; | |
| const maxCandidates = parseInt(process.env.MAX_CANDIDATES || '5', 10); | |
| const inputPr = String(process.env.DISPATCH_PR_NUMBER || '').trim(); | |
| const candidates = []; | |
| const skipped = []; | |
| if (inputPr) { | |
| const n = parseInt(inputPr, 10); | |
| if (Number.isNaN(n)) { | |
| throw new Error(`Invalid pr_number input: ${inputPr}`); | |
| } | |
| const result = await mergeGate.evaluatePipelineQuiescent(github, owner, repo, n, core); | |
| if (result.ready) { | |
| candidates.push({ pr_number: n, head_sha: result.headSha }); | |
| } else { | |
| skipped.push({ pr: n, reasons: result.reasons }); | |
| } | |
| } else { | |
| const prNumbers = await mergeGate.listPrNumbersForMergeGateScan( | |
| github, owner, repo, core | |
| ); | |
| const selected = await mergeGate.selectMergeGateCandidates( | |
| github, owner, repo, prNumbers, maxCandidates, core | |
| ); | |
| candidates.push(...selected.candidates); | |
| skipped.push(...selected.skipped); | |
| } | |
| core.info('Skipped: ' + JSON.stringify(skipped)); | |
| core.setOutput('matrix', JSON.stringify({ include: candidates })); | |
| core.setOutput('has_candidates', candidates.length > 0 ? 'true' : 'false'); | |
| core.summary.addRaw('## Merge gate scan\n'); | |
| core.summary.addRaw(`Candidates: ${candidates.length}\n`); | |
| core.summary.addRaw('```json\n' + JSON.stringify({ candidates, skipped }, null, 2) + '\n```'); | |
| await core.summary.write(); | |
| if (inputPr && skipped.length === 1 && skipped[0].pr === parseInt(inputPr, 10)) { | |
| const reasons = skipped[0].reasons; | |
| const body = [ | |
| '**Merge gate scan** β not eligible for auto-merge.', | |
| '', | |
| ...reasons.map((r) => `- \`${r}\``), | |
| '', | |
| '**Actions:** wait for CI and the Claude review chain, or add label `needs-manual-review` and merge manually.', | |
| '**Opt out:** label `no-auto-merge`.', | |
| ].join('\n'); | |
| await github.rest.issues.createComment({ | |
| owner, repo, issue_number: skipped[0].pr, body, | |
| }); | |
| } else if (inputPr && candidates.length === 1) { | |
| await github.rest.issues.createComment({ | |
| owner, repo, issue_number: candidates[0].pr_number, | |
| body: '**Merge gate scan** β eligible for assessment. Claude merge gate will assess and may auto-merge if `MERGE_GATE_VERDICT: APPROVE`.', | |
| }); | |
| } | |
| await ps.syncOpenPullRequests(github, owner, repo, { maxPrs: 10 }, core); | |
| claude-assess: | |
| needs: scan-candidates | |
| if: needs.scan-candidates.outputs.has_candidates == 'true' | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 20 | |
| strategy: | |
| fail-fast: false | |
| matrix: ${{ fromJson(needs.scan-candidates.outputs.matrix) }} | |
| permissions: | |
| contents: read | |
| pull-requests: read | |
| issues: write | |
| actions: read | |
| id-token: write | |
| steps: | |
| - name: Generate GitHub App Token | |
| id: app-token | |
| uses: actions/create-github-app-token@v1 | |
| with: | |
| app-id: ${{ secrets.CLAUDE_APP_ID }} | |
| private-key: ${{ secrets.CLAUDE_APP_PRIVATE_KEY }} | |
| owner: ${{ github.repository_owner }} | |
| - uses: actions/checkout@v4 | |
| with: | |
| fetch-depth: 0 | |
| token: ${{ steps.app-token.outputs.token }} | |
| - name: Mark merge gate active | |
| id: mark_active | |
| uses: actions/github-script@v7 | |
| with: | |
| github-token: ${{ steps.app-token.outputs.token }} | |
| script: | | |
| core.setOutput('started_at', new Date().toISOString()); | |
| await github.rest.issues.addLabels({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: ${{ matrix.pr_number }}, | |
| labels: ['claude-merge-gate-active'], | |
| }); | |
| - name: Stash merge gate helpers | |
| run: cp .github/scripts/merge-gate.js /tmp/merge-gate.js | |
| - name: Fetch PR branch | |
| env: | |
| PR_NUMBER: ${{ matrix.pr_number }} | |
| run: | | |
| git fetch origin "pull/${PR_NUMBER}/head:pr-${PR_NUMBER}" | |
| git checkout "pr-${PR_NUMBER}" | |
| - name: Claude merge gate assessment (read-only) | |
| id: assess | |
| uses: ./.github/actions/claude-code-action | |
| env: | |
| GH_TOKEN: ${{ steps.app-token.outputs.token }} | |
| CLAUDE_CODE_SUBAGENT_MODEL: inherit | |
| ANTHROPIC_DEFAULT_SONNET_MODEL: claude-sonnet-4-6 | |
| with: | |
| claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} | |
| github_token: ${{ steps.app-token.outputs.token }} | |
| model: claude-opus-4-8 | |
| trigger_phrase: '@claude-merge-gate-internal' | |
| direct_prompt: | | |
| You are the MERGE GATE reviewer for PraisonAI PR #${{ matrix.pr_number }}. | |
| READ-ONLY: do not modify code, commit, push, rebase, or create branches. | |
| CRITICAL OUTPUT RULE: | |
| Post exactly one PR comment via GitHub MCP containing either: | |
| MERGE_GATE_VERDICT: APPROVE | |
| or | |
| MERGE_GATE_VERDICT: BLOCK | |
| followed by a short rationale on the next lines. | |
| NEVER include @claude in any comment. NEVER mention merge conflict resolution. | |
| Review checklist: | |
| 1. Read AGENTS.md and the PR diff on the current branch. | |
| 2. Read all PR comments and reviews (FINAL architecture reviewer / Lead Engineer must have run). | |
| 3. Confirm SDK value gate: change strengthens praisonaiagents (no scope creep). | |
| 4. Repo routing: BLOCK if agent-callable tools (β PraisonAI-Tools), lifecycle plugins (β PraisonAI-Plugins), or optional sandbox backends (β PraisonAI-Plugins via `praisonai.sandbox`) were added to praisonaiagents/ instead of the external repo. | |
| 5. Do not bloat the Agent class with additional params β only if absolutely required; we already support many params. | |
| 6. If agent.py adds >100 lines or new Agent __init__ params, respond MERGE_GATE_VERDICT: BLOCK (manual review required). | |
| 7. BLOCK if labels security/breaking-change/needs-manual-review/release, sensitive paths (.github/workflows, auth, pyproject.toml), secrets in diff, PR >800 lines or >30 files, or SDK changes without tests. Label `merge-gate-ci-only` exempts CI-only changes under `.github/workflows/`, `.github/actions/`, or merge-gate scripts. | |
| 8. Confirm no CHANGES_REQUESTED reviews and CI green on HEAD ${{ matrix.head_sha }}. | |
| 9. If ready to merge: MERGE_GATE_VERDICT: APPROVE. Otherwise: MERGE_GATE_VERDICT: BLOCK with reasons. | |
| Do not approve or merge via GitHub UI/API β only post the verdict comment. | |
| disallowed_tools: | | |
| Bash | |
| Edit | |
| Write | |
| Replace | |
| allowed_tools: | | |
| View | |
| GlobTool | |
| GrepTool | |
| Read | |
| timeout_minutes: 15 | |
| - name: Post fallback BLOCK if Opus did not comment | |
| id: fallback_verdict | |
| if: steps.assess.outcome != 'success' | |
| uses: actions/github-script@v7 | |
| with: | |
| github-token: ${{ steps.app-token.outputs.token }} | |
| script: | | |
| const mergeGate = require('/tmp/merge-gate.js'); | |
| const owner = context.repo.owner; | |
| const repo = context.repo.repo; | |
| const prNumber = ${{ matrix.pr_number }}; | |
| const ctx = await mergeGate.loadPrContext(github, owner, repo, prNumber); | |
| const minCreatedAt = new Date(Date.now() - 25 * 60 * 1000).toISOString(); | |
| const existing = mergeGate.findMergeGateVerdict( | |
| ctx.comments, minCreatedAt, ctx.headPushedAt, { excludeAutomatedFallback: true } | |
| ); | |
| if (existing) { | |
| core.info(`Opus verdict already posted: ${existing}`); | |
| return; | |
| } | |
| const check = await mergeGate.evaluatePipelineQuiescent( | |
| github, owner, repo, prNumber, core, { forMergeStep: true } | |
| ); | |
| const lines = [ | |
| 'MERGE_GATE_VERDICT: BLOCK', | |
| '', | |
| 'Automated fallback β Opus merge gate assessment did not complete.', | |
| ]; | |
| if (check.ready) { | |
| lines.push('Deterministic gates passed, but Opus MERGE_GATE_VERDICT is required before merge.'); | |
| } else { | |
| lines.push('Blockers:', ...check.reasons.map((r) => `- ${r}`)); | |
| } | |
| await github.rest.issues.createComment({ | |
| owner, repo, issue_number: prNumber, | |
| body: lines.join('\n'), | |
| }); | |
| core.info('Posted fallback BLOCK β Opus verdict required'); | |
| - name: Require Opus MERGE_GATE_VERDICT | |
| uses: actions/github-script@v7 | |
| with: | |
| github-token: ${{ steps.app-token.outputs.token }} | |
| script: | | |
| const mergeGate = require('/tmp/merge-gate.js'); | |
| const comments = await mergeGate.listAllComments( | |
| github, context.repo.owner, context.repo.repo, ${{ matrix.pr_number }} | |
| ); | |
| const ctx = await mergeGate.loadPrContext( | |
| github, context.repo.owner, context.repo.repo, ${{ matrix.pr_number }} | |
| ); | |
| const minCreatedAt = new Date(Date.now() - 25 * 60 * 1000).toISOString(); | |
| const verdict = mergeGate.findMergeGateVerdict( | |
| comments, minCreatedAt, ctx.headPushedAt, { excludeAutomatedFallback: true } | |
| ); | |
| if (!verdict) { | |
| core.setFailed( | |
| 'Opus merge gate assessment must post MERGE_GATE_VERDICT on this PR (automated fallback APPROVE is not accepted).' | |
| ); | |
| return; | |
| } | |
| core.info(`Opus merge gate verdict: ${verdict}`); | |
| core.setOutput('verdict', verdict); | |
| - name: Remove merge gate active label | |
| if: always() | |
| uses: actions/github-script@v7 | |
| with: | |
| github-token: ${{ steps.app-token.outputs.token }} | |
| script: | | |
| const prNumber = ${{ matrix.pr_number }}; | |
| try { | |
| await github.rest.issues.removeLabel({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: prNumber, | |
| name: 'claude-merge-gate-active', | |
| }); | |
| core.info(`Removed claude-merge-gate-active from PR #${prNumber}`); | |
| } catch (e) { | |
| core.info(`Label claude-merge-gate-active already removed or missing on PR #${prNumber}`); | |
| } | |
| - name: Fail job if assessment failed | |
| if: steps.assess.outcome == 'failure' | |
| run: exit 1 | |
| merge-only: | |
| needs: [scan-candidates, claude-assess] | |
| if: | | |
| always() && | |
| needs.scan-candidates.outputs.has_candidates == 'true' && | |
| needs.claude-assess.result == 'success' | |
| runs-on: ubuntu-latest | |
| strategy: | |
| fail-fast: false | |
| matrix: ${{ fromJson(needs.scan-candidates.outputs.matrix) }} | |
| permissions: | |
| contents: write | |
| pull-requests: write | |
| issues: write | |
| actions: read | |
| id-token: write | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - name: Generate GitHub App token | |
| id: app-token | |
| uses: actions/create-github-app-token@v1 | |
| with: | |
| app-id: ${{ secrets.CLAUDE_APP_ID }} | |
| private-key: ${{ secrets.CLAUDE_APP_PRIVATE_KEY }} | |
| owner: ${{ github.repository_owner }} | |
| - name: Re-check gates and merge | |
| uses: actions/github-script@v7 | |
| env: | |
| MERGE_METHOD: ${{ github.event.inputs.merge_method || 'merge' }} | |
| SCAN_HEAD_SHA: ${{ matrix.head_sha }} | |
| with: | |
| github-token: ${{ steps.app-token.outputs.token }} | |
| script: | | |
| const path = require('path'); | |
| const mergeGate = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/merge-gate.js')); | |
| const owner = context.repo.owner; | |
| const repo = context.repo.repo; | |
| const prNumber = ${{ matrix.pr_number }}; | |
| const scanHeadSha = process.env.SCAN_HEAD_SHA; | |
| const removeLabels = async () => { | |
| try { | |
| await github.rest.issues.removeLabel({ | |
| owner, repo, issue_number: prNumber, name: 'claude-merge-gate-active', | |
| }); | |
| } catch (e) { | |
| core.info('Label claude-merge-gate-active already removed or missing'); | |
| } | |
| }; | |
| try { | |
| const ctx = await mergeGate.loadPrContext(github, owner, repo, prNumber); | |
| const comments = ctx.comments; | |
| const verdictMin = new Date(Date.now() - 25 * 60 * 1000).toISOString(); | |
| const verdict = mergeGate.findMergeGateVerdict( | |
| comments, verdictMin, ctx.headPushedAt, { excludeAutomatedFallback: true } | |
| ); | |
| if (verdict !== 'APPROVE') { | |
| core.info(`Skip merge: verdict=${verdict || 'NONE'}`); | |
| await removeLabels(); | |
| return; | |
| } | |
| const check = await mergeGate.evaluatePipelineQuiescent( | |
| github, owner, repo, prNumber, core, { forMergeStep: true } | |
| ); | |
| if (!check.ready) { | |
| core.info(`Skip merge: ${check.reasons.join(', ')}`); | |
| await removeLabels(); | |
| return; | |
| } | |
| if (check.headSha !== scanHeadSha) { | |
| core.info(`Skip merge: HEAD moved ${scanHeadSha.slice(0, 7)} β ${check.headSha.slice(0, 7)}`); | |
| await removeLabels(); | |
| return; | |
| } | |
| const method = process.env.MERGE_METHOD || 'merge'; | |
| const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); | |
| let merged = false; | |
| for (let attempt = 1; attempt <= 3; attempt++) { | |
| try { | |
| await github.rest.pulls.merge({ | |
| owner, repo, pull_number: prNumber, merge_method: method, | |
| }); | |
| merged = true; | |
| break; | |
| } catch (e) { | |
| const msg = (e.message || '').toLowerCase(); | |
| if (attempt < 3 && msg.includes('base branch was modified')) { | |
| core.info(`Merge attempt ${attempt} failed (base moved), retrying in 8s...`); | |
| await sleep(8000); | |
| const recheck = await mergeGate.evaluatePipelineQuiescent( | |
| github, owner, repo, prNumber, core, { forMergeStep: true } | |
| ); | |
| if (!recheck.ready) { | |
| core.info(`Skip merge retry: ${recheck.reasons.join(', ')}`); | |
| await removeLabels(); | |
| return; | |
| } | |
| continue; | |
| } | |
| throw e; | |
| } | |
| } | |
| if (!merged) { | |
| core.info('Skip merge: exhausted retries'); | |
| await removeLabels(); | |
| return; | |
| } | |
| await github.rest.issues.addLabels({ | |
| owner, repo, issue_number: prNumber, labels: ['auto-merged-by-gate'], | |
| }); | |
| await github.rest.issues.createComment({ | |
| owner, repo, issue_number: prNumber, | |
| body: [ | |
| 'Merged by **Claude PR merge gate** (`claude-merge-gate.yml`).', | |
| `Verdict: MERGE_GATE_VERDICT: APPROVE`, | |
| `SHA: \`${check.headSha.slice(0, 7)}\``, | |
| `Method: ${method}`, | |
| ].join('\n'), | |
| }); | |
| core.info(`Merged PR #${prNumber}`); | |
| } finally { | |
| await removeLabels(); | |
| } |