Skip to content

Copilot Orchestrator #46

Copilot Orchestrator

Copilot Orchestrator #46

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) }},
});