Copilot Orchestrator #46
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: Copilot Orchestrator | |
| # Automates the Copilot PR lifecycle: | |
| # | |
| # Actual flow observed: | |
| # 1. Copilot finishes work → opens PR (non-draft) immediately | |
| # 2. Copilot auto-reviews the PR (state: COMMENTED, with inline suggestions) | |
| # 3. CI checks run in parallel | |
| # | |
| # What this workflow does: | |
| # - PR opened → enable auto-merge (squash) | |
| # - CI fails → tell @copilot to fix the failures | |
| # - Review has comments → tell @copilot to address them | |
| # - CI passes + no outstanding comments → auto-merge kicks in | |
| # - PR merged → suggest next issues on tracker #216 | |
| on: | |
| pull_request: | |
| types: [opened, ready_for_review, closed] | |
| pull_request_review: | |
| types: [submitted] | |
| check_run: | |
| types: [completed] | |
| permissions: | |
| contents: write | |
| pull-requests: write | |
| issues: write | |
| checks: read | |
| jobs: | |
| # ─── When Copilot opens or readies a PR → just enable auto-merge ────── | |
| on-pr-ready: | |
| name: Setup Copilot PR | |
| if: >- | |
| github.event_name == 'pull_request' && | |
| github.event.action != 'closed' && | |
| !github.event.pull_request.draft && | |
| github.event.pull_request.user.login == 'Copilot' | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Enable auto-merge | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| run: | | |
| gh pr merge ${{ github.event.pull_request.number }} \ | |
| --repo ${{ github.repository }} \ | |
| --auto --squash || true | |
| echo "Auto-merge enabled for PR #${{ github.event.pull_request.number }}" | |
| # ─── When CI checks complete on a Copilot PR ───────────────────────── | |
| on-checks-complete: | |
| name: Handle check results | |
| if: >- | |
| github.event_name == 'check_run' && | |
| github.event.check_run.conclusion != '' && | |
| github.event.check_run.conclusion != 'skipped' | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Check if this is a Copilot PR and handle failures | |
| uses: actions/github-script@v7 | |
| with: | |
| script: | | |
| const checkRun = context.payload.check_run; | |
| // Find PRs associated with this check run | |
| const prs = checkRun.pull_requests || []; | |
| if (prs.length === 0) return; | |
| for (const prRef of prs) { | |
| const pr = await github.rest.pulls.get({ | |
| ...context.repo, | |
| pull_number: prRef.number, | |
| }); | |
| // Only act on Copilot PRs | |
| if (pr.data.user.login !== 'Copilot') continue; | |
| if (pr.data.draft) continue; | |
| if (pr.data.state !== 'open') continue; | |
| // Only act on failures | |
| if (checkRun.conclusion !== 'failure') continue; | |
| // Skip our own workflow checks | |
| const skipNames = [ | |
| 'Setup Copilot PR', | |
| 'Handle check results', | |
| 'Address review comments', | |
| 'Post-merge actions', | |
| ]; | |
| if (skipNames.includes(checkRun.name)) continue; | |
| // ── Gate: wait for review before acting on CI failures ── | |
| // | |
| // Flow: | |
| // 1. No review yet? → WAIT for auto-review. | |
| // 2. Review has comments, not yet sent to Copilot? → WAIT for review handler. | |
| // 3. Review comments sent, not yet addressed? → WAIT for Copilot push. | |
| // 4. Review comments addressed but CI still fails? → ASK Copilot to fix CI. | |
| // 5. Review clean (no inline comments)? → ASK Copilot to fix CI. | |
| const reviews = await github.rest.pulls.listReviews({ | |
| ...context.repo, | |
| pull_number: prRef.number, | |
| }); | |
| // Gate 1: No review submitted yet — wait for auto-review | |
| if (reviews.data.length === 0) { | |
| core.info( | |
| `CI failure on PR #${prRef.number} but no review has been ` + | |
| `submitted yet. Waiting for Copilot auto-review.` | |
| ); | |
| continue; | |
| } | |
| const reviewComments = await github.rest.pulls.listReviewComments({ | |
| ...context.repo, | |
| pull_number: prRef.number, | |
| per_page: 100, | |
| }); | |
| const prComments = await github.rest.issues.listComments({ | |
| ...context.repo, | |
| issue_number: prRef.number, | |
| per_page: 50, | |
| }); | |
| const hasReviewFixRequest = prComments.data.some( | |
| c => c.body.includes('<!-- copilot-address-review-') | |
| ); | |
| // If there are unresolved review comments and we've asked Copilot | |
| // to address them, wait for Copilot to push before nagging about CI. | |
| // We detect "addressed" by checking if Copilot pushed after the | |
| // review fix request (the fix request comment timestamp vs the | |
| // check run's head_sha commit timestamp). | |
| if (reviewComments.data.length > 0 && hasReviewFixRequest) { | |
| // Find the latest review fix request timestamp | |
| const fixRequests = prComments.data.filter( | |
| c => c.body.includes('<!-- copilot-address-review-') | |
| ); | |
| const latestFixRequest = fixRequests.sort( | |
| (a, b) => new Date(b.created_at) - new Date(a.created_at) | |
| )[0]; | |
| // Get the commit that triggered this check run | |
| const headSha = checkRun.head_sha; | |
| const commits = await github.rest.pulls.listCommits({ | |
| ...context.repo, | |
| pull_number: prRef.number, | |
| per_page: 50, | |
| }); | |
| const triggeringCommit = commits.data.find(c => c.sha === headSha); | |
| if (triggeringCommit) { | |
| const commitTime = new Date(triggeringCommit.commit.committer.date); | |
| const fixRequestTime = new Date(latestFixRequest.created_at); | |
| if (commitTime < fixRequestTime) { | |
| core.info( | |
| `CI failure on PR #${prRef.number} but review comments are still pending. ` + | |
| `Waiting for Copilot to address review first (commit ${headSha.slice(0,7)} ` + | |
| `predates fix request).` | |
| ); | |
| continue; | |
| } | |
| } | |
| // If the commit is AFTER the fix request, Copilot already pushed | |
| // a fix for the review comments but CI still fails → fall through | |
| // and ask for CI fix. | |
| } | |
| // If there are review comments but we haven't asked Copilot to | |
| // address them yet, skip — the on-review-submitted job will handle it, | |
| // and the review fix might also fix CI. | |
| if (reviewComments.data.length > 0 && !hasReviewFixRequest) { | |
| core.info( | |
| `CI failure on PR #${prRef.number} but review comments exist and ` + | |
| `haven't been sent to Copilot yet. Waiting for review handler.` | |
| ); | |
| continue; | |
| } | |
| // No outstanding review comments (or already addressed). Ask Copilot | |
| // to fix the CI failure. | |
| const failureTag = `<!-- copilot-fix-${checkRun.name}-${checkRun.head_sha.slice(0,7)} -->`; | |
| const alreadyPosted = prComments.data.some( | |
| c => c.body.includes(failureTag) | |
| ); | |
| if (alreadyPosted) { | |
| core.info(`Already asked Copilot to fix ${checkRun.name} for this commit, skipping`); | |
| continue; | |
| } | |
| const detailsUrl = checkRun.html_url || checkRun.details_url || ''; | |
| await github.rest.issues.createComment({ | |
| ...context.repo, | |
| issue_number: prRef.number, | |
| body: [ | |
| failureTag, | |
| `@copilot The **${checkRun.name}** check has failed.`, | |
| ``, | |
| `Details: ${detailsUrl}`, | |
| ``, | |
| `Please investigate and push a fix. Common issues:`, | |
| `- **Lint**: run \`golangci-lint run ./...\` locally and fix warnings`, | |
| `- **Test**: run \`go test -race ./...\` and fix failing tests`, | |
| `- **Build**: run \`go build ./...\` and fix compilation errors`, | |
| `- **Vulnerability Check**: update affected dependencies`, | |
| ].join('\n'), | |
| }); | |
| core.info(`Asked Copilot to fix ${checkRun.name} failure on PR #${prRef.number}`); | |
| } | |
| # ─── When a review is submitted with comments/changes ───────────────── | |
| on-review-submitted: | |
| name: Address review comments | |
| if: >- | |
| github.event_name == 'pull_request_review' && | |
| github.event.pull_request.user.login == 'Copilot' && | |
| !github.event.pull_request.draft | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Ask Copilot to address review comments | |
| uses: actions/github-script@v7 | |
| with: | |
| script: | | |
| const pr = context.payload.pull_request; | |
| const review = context.payload.review; | |
| // Act on CHANGES_REQUESTED or COMMENTED (Copilot auto-review uses COMMENTED) | |
| if (review.state !== 'changes_requested' && review.state !== 'COMMENTED') { | |
| core.info(`Review state is ${review.state}, no action needed`); | |
| return; | |
| } | |
| // Check if review actually has inline comments (not just a summary) | |
| const reviewComments = await github.rest.pulls.listReviewComments({ | |
| ...context.repo, | |
| pull_number: pr.number, | |
| per_page: 50, | |
| }); | |
| // Filter to comments from this review | |
| const relevantComments = reviewComments.data.filter( | |
| c => c.pull_request_review_id === review.id | |
| ); | |
| if (relevantComments.length === 0) { | |
| core.info('Review has no inline comments, skipping'); | |
| return; | |
| } | |
| // Check if we already asked about this review | |
| const issueComments = await github.rest.issues.listComments({ | |
| ...context.repo, | |
| issue_number: pr.number, | |
| per_page: 30, | |
| }); | |
| const reviewTag = `<!-- copilot-address-review-${review.id} -->`; | |
| const alreadyPosted = issueComments.data.some( | |
| c => c.body.includes(reviewTag) | |
| ); | |
| if (alreadyPosted) { | |
| core.info('Already asked Copilot to address this review, skipping'); | |
| return; | |
| } | |
| // Summarize the comments | |
| const commentSummary = relevantComments | |
| .map(c => `- **${c.path}:${c.line || '?'}**: ${c.body.slice(0, 150)}`) | |
| .join('\n'); | |
| await github.rest.issues.createComment({ | |
| ...context.repo, | |
| issue_number: pr.number, | |
| body: [ | |
| reviewTag, | |
| `@copilot The code review found ${relevantComments.length} suggestions. Please address them:`, | |
| ``, | |
| commentSummary, | |
| ``, | |
| `Review: ${review.html_url}`, | |
| ``, | |
| `Please apply the suggested changes and push a new commit.`, | |
| ].join('\n'), | |
| }); | |
| core.info(`Asked Copilot to address ${relevantComments.length} review comments on PR #${pr.number}`); | |
| # ─── When a Copilot PR is merged → suggest next issues ──────────────── | |
| on-merge: | |
| name: Post-merge actions | |
| if: >- | |
| github.event_name == 'pull_request' && | |
| github.event.action == 'closed' && | |
| github.event.pull_request.merged == true && | |
| github.event.pull_request.user.login == 'Copilot' | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v4 | |
| with: | |
| sparse-checkout: .github | |
| - name: Find next assignable issues | |
| id: next | |
| uses: actions/github-script@v7 | |
| with: | |
| script: | | |
| // Get open Phase 3 issues with agent-ready label | |
| const issues = await github.rest.issues.listForRepo({ | |
| ...context.repo, | |
| state: 'open', | |
| labels: 'agent-ready,phase:3-core', | |
| per_page: 100, | |
| sort: 'created', | |
| direction: 'asc', | |
| }); | |
| // Filter to unassigned issues | |
| const unassigned = issues.data.filter( | |
| i => !i.assignees.some(a => a.login === 'Copilot') | |
| ); | |
| // Check dependencies: skip issues with open blockers | |
| const ready = []; | |
| for (const issue of unassigned) { | |
| const body = issue.body || ''; | |
| // Extract issue numbers from the Dependencies section | |
| const depSection = body.match(/## Dependencies[\s\S]*?(?=\n## |$)/i); | |
| if (!depSection) { | |
| ready.push(issue); | |
| if (ready.length >= 4) break; | |
| continue; | |
| } | |
| const depNums = [...depSection[0].matchAll(/#(\d+)/g)].map(m => parseInt(m[1])); | |
| let blocked = false; | |
| for (const depNum of depNums) { | |
| if (depNum === issue.number) continue; | |
| try { | |
| const dep = await github.rest.issues.get({ | |
| ...context.repo, | |
| issue_number: depNum, | |
| }); | |
| if (dep.data.state === 'open') { | |
| blocked = true; | |
| break; | |
| } | |
| } catch (e) { | |
| // Dep not found, assume resolved | |
| } | |
| } | |
| if (!blocked) { | |
| ready.push(issue); | |
| } | |
| if (ready.length >= 4) break; | |
| } | |
| if (ready.length === 0) { | |
| core.info('No unblocked issues found'); | |
| core.setOutput('found', 'false'); | |
| return; | |
| } | |
| core.setOutput('found', 'true'); | |
| const suggestions = ready.map(i => { | |
| const labels = i.labels.map(l => l.name); | |
| const size = labels.find(l => l.startsWith('size:')) || 'size:?'; | |
| const ws = labels.find(l => l.startsWith('ws:')) || ''; | |
| const priority = labels.find(l => l.startsWith('priority:')) || ''; | |
| return `| #${i.number} | ${i.title} | ${size.replace('size:', '')} | ${priority.replace('priority:', '')} | ${ws.replace('ws:', '')} |`; | |
| }); | |
| const body = [ | |
| `## Ready for next Copilot assignment`, | |
| ``, | |
| `PR #${context.payload.pull_request.number} has been merged. The following issues are unblocked and ready:`, | |
| ``, | |
| `| Issue | Title | Size | Priority | Workstream |`, | |
| `|-------|-------|------|----------|------------|`, | |
| ...suggestions, | |
| ``, | |
| `**To assign:** Open each issue in GitHub and assign Copilot with your preferred model.`, | |
| ``, | |
| `> Recommended: assign 2 issues at a time for parallel progress.`, | |
| ].join('\n'); | |
| core.setOutput('body', body); | |
| - name: Post next-assignment notification | |
| if: steps.next.outputs.found == 'true' | |
| uses: actions/github-script@v7 | |
| with: | |
| script: | | |
| await github.rest.issues.createComment({ | |
| ...context.repo, | |
| issue_number: 216, | |
| body: ${{ toJSON(steps.next.outputs.body) }}, | |
| }); |