- copilot-assign.yml: GraphQL agentAssignment - ai-review.yml: Claude Opus auto-review - auto-merge.yml: CI + APPROVE squash merge - decompose.yml: Issue decomposition + sub-issues Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
178 lines
6.9 KiB
YAML
178 lines
6.9 KiB
YAML
name: Auto-merge Copilot PRs
|
||
|
||
# Copilot Coding Agent が作成した PR が
|
||
# 1) CI 通過(Type Check + Build Check)かつ
|
||
# 2) AI レビュー (ai-review.yml) で APPROVE された
|
||
# 場合に自動マージする。
|
||
|
||
on:
|
||
pull_request_review:
|
||
types: [submitted]
|
||
pull_request:
|
||
types: [opened, synchronize, ready_for_review]
|
||
check_run:
|
||
types: [completed]
|
||
|
||
jobs:
|
||
auto-merge:
|
||
name: Auto-merge if CI + AI Review passed
|
||
runs-on: ubuntu-latest
|
||
if: |
|
||
github.actor == 'Copilot' ||
|
||
github.event.pull_request.user.login == 'Copilot' ||
|
||
github.event.review.user.login == 'github-actions[bot]' ||
|
||
github.event_name == 'check_run'
|
||
permissions:
|
||
pull-requests: write
|
||
contents: write
|
||
steps:
|
||
- name: Check CI status and AI review approval
|
||
uses: actions/github-script@v7
|
||
with:
|
||
script: |
|
||
let prNumber;
|
||
|
||
// イベント種別に応じて PR 番号を取得
|
||
if (context.eventName === 'check_run') {
|
||
const runName = context.payload.check_run.name;
|
||
if (runName === 'Auto-merge if CI + AI Review passed') {
|
||
console.log('Skipping self-triggered check_run');
|
||
return;
|
||
}
|
||
const prs = context.payload.check_run.pull_requests;
|
||
if (!prs || prs.length === 0) {
|
||
console.log('check_run: no associated PRs, skipping');
|
||
return;
|
||
}
|
||
prNumber = prs[0].number;
|
||
console.log(`check_run "${runName}" completed: checking PR #${prNumber}`);
|
||
} else {
|
||
prNumber = context.payload.pull_request?.number
|
||
|| context.payload.review?.pull_request_url?.match(/\/pulls\/(\d+)$/)?.[1];
|
||
}
|
||
|
||
if (!prNumber) {
|
||
console.log('No PR number found, skipping');
|
||
return;
|
||
}
|
||
|
||
const pr = await github.rest.pulls.get({
|
||
owner: context.repo.owner,
|
||
repo: context.repo.repo,
|
||
pull_number: Number(prNumber),
|
||
});
|
||
|
||
// すでに closed/merged の PR はスキップ
|
||
if (pr.data.state !== 'open') {
|
||
console.log(`PR #${prNumber} is ${pr.data.state}, skipping`);
|
||
return;
|
||
}
|
||
|
||
// Draft PR はスキップ
|
||
if (pr.data.draft) {
|
||
console.log(`PR #${prNumber} is still Draft, skipping`);
|
||
return;
|
||
}
|
||
|
||
// Copilot 作成でなければスキップ
|
||
if (pr.data.user.login !== 'Copilot') {
|
||
console.log(`PR #${prNumber} is not from Copilot (${pr.data.user.login}), skipping`);
|
||
return;
|
||
}
|
||
|
||
// ── CI チェックの状態を確認 ──────────────────────────────
|
||
const checks = await github.rest.checks.listForRef({
|
||
owner: context.repo.owner,
|
||
repo: context.repo.repo,
|
||
ref: pr.data.head.sha,
|
||
per_page: 100,
|
||
});
|
||
|
||
// 同名の check_run が複数ある場合(re-run)は最新のものだけを使う
|
||
const latestByName = {};
|
||
for (const c of checks.data.check_runs) {
|
||
if (c.name === 'Type Check' || c.name === 'Build Check') {
|
||
const prev = latestByName[c.name];
|
||
if (!prev || new Date(c.started_at) > new Date(prev.started_at)) {
|
||
latestByName[c.name] = c;
|
||
}
|
||
}
|
||
}
|
||
const ciChecks = Object.values(latestByName);
|
||
|
||
const ciPassed = ciChecks.length >= 2 &&
|
||
ciChecks.every(c => c.status === 'completed' && c.conclusion === 'success');
|
||
|
||
const ciStatus = ciChecks.map(c => `${c.name}:${c.conclusion ?? c.status}`).join(', ');
|
||
console.log(`PR #${prNumber} CI (latest runs): ${ciStatus || 'no checks found'}`);
|
||
|
||
if (!ciPassed) {
|
||
console.log(`PR #${prNumber}: CI not fully passed yet, skipping`);
|
||
return;
|
||
}
|
||
|
||
// ── AI レビューの APPROVE を確認 ─────────────────────────
|
||
const reviews = await github.rest.pulls.listReviews({
|
||
owner: context.repo.owner,
|
||
repo: context.repo.repo,
|
||
pull_number: Number(prNumber),
|
||
per_page: 100,
|
||
});
|
||
|
||
const latestByReviewer = {};
|
||
for (const r of reviews.data) {
|
||
if (r.state !== 'COMMENTED') {
|
||
latestByReviewer[r.user.login] = r.state;
|
||
}
|
||
}
|
||
|
||
const hasApproval = Object.values(latestByReviewer).includes('APPROVED');
|
||
const hasChangesRequested = Object.values(latestByReviewer).includes('CHANGES_REQUESTED');
|
||
|
||
console.log(`PR #${prNumber} reviews: ${JSON.stringify(latestByReviewer)}`);
|
||
|
||
if (hasChangesRequested) {
|
||
console.log(`PR #${prNumber}: AI review requested changes, skipping merge`);
|
||
return;
|
||
}
|
||
|
||
if (!hasApproval) {
|
||
console.log(`PR #${prNumber}: No APPROVE review yet, waiting for AI review`);
|
||
return;
|
||
}
|
||
|
||
console.log(`PR #${prNumber}: CI passed + AI review approved. Proceeding to merge.`);
|
||
|
||
// ── マージ実行(squash) ──────────────────────────────────
|
||
try {
|
||
await github.rest.pulls.merge({
|
||
owner: context.repo.owner,
|
||
repo: context.repo.repo,
|
||
pull_number: Number(prNumber),
|
||
merge_method: 'squash',
|
||
});
|
||
console.log(`✅ Merged PR #${prNumber}`);
|
||
} catch (err) {
|
||
if (err.status === 405) {
|
||
// Branch protection: GitHub native auto-merge を有効化
|
||
try {
|
||
await github.graphql(`
|
||
mutation EnableAutoMerge($prId: ID!) {
|
||
enablePullRequestAutoMerge(input: {
|
||
pullRequestId: $prId
|
||
mergeMethod: SQUASH
|
||
}) {
|
||
pullRequest { number state }
|
||
}
|
||
}
|
||
`, { prId: pr.data.node_id });
|
||
console.log(`Enabled GitHub auto-merge for PR #${prNumber}`);
|
||
} catch (graphqlErr) {
|
||
console.log(`Auto-merge enable failed: ${graphqlErr.message}`);
|
||
}
|
||
} else if (err.status === 404 || err.status === 422) {
|
||
console.log(`PR #${prNumber} not mergeable (${err.status}): ${err.message}`);
|
||
} else {
|
||
throw err;
|
||
}
|
||
}
|