feat(ci): add Copilot full automation pipeline

- 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>
This commit is contained in:
林 駿甫 (Shunsuke Hayashi) 2026-03-29 21:48:22 +09:00
parent dfdd52ec1d
commit 2d500c3654
6 changed files with 836 additions and 0 deletions

210
.github/workflows/decompose.yml vendored Normal file
View file

@ -0,0 +1,210 @@
name: Decompose Issue for Copilot
# copilot ラベルが付いた Issue を Claude Opus で分析。
# 粗粒度vague / multi-part / doc-only になりそう)なら原子的サブタスクに分解して
# GitHub Sub-issues API で子 Issue として親に紐づける(サブイシュー化)。
# 粒度が適切atomicなら何もせず copilot-assign.yml に任せる。
on:
issues:
types: [labeled]
jobs:
decompose:
name: Analyze & Decompose
runs-on: ubuntu-latest
if: github.event.label.name == 'copilot'
permissions:
issues: write
contents: read
steps:
- name: Write issue data to file
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const issue = context.payload.issue;
fs.writeFileSync('/tmp/issue.json', JSON.stringify({
number: issue.number,
title: issue.title,
body: issue.body || '(no description provided)',
}));
console.log(`Issue #${issue.number} written to /tmp/issue.json`);
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Install Claude CLI
run: npm install -g @anthropic-ai/claude-code@latest
- name: Analyze Issue with Claude Opus
id: analyze
env:
CLAUDE_CODE_OAUTH_TOKEN_1: ${{ secrets.CLAUDE_CODE_TOKEN }}
CLAUDE_CODE_OAUTH_TOKEN_2: ${{ secrets.CLAUDE_CODE_TOKEN_2 }}
CLAUDE_CODE_OAUTH_TOKEN_3: ${{ secrets.CLAUDE_CODE_TOKEN_3 }}
run: |
set -euo pipefail
cd /tmp
ISSUE_NUM=$(python3 -c "import json; print(json.load(open('issue.json'))['number'])")
ISSUE_TITLE=$(python3 -c "import json; print(json.load(open('issue.json'))['title'])")
# Issue body を安全にファイルへ書き出してstdinでClaudeに渡す
python3 -c "import json; print(json.load(open('issue.json'))['body'])" > /tmp/issue_body.txt
SYSTEM_PROMPT="You are a software engineering task decomposer. Given a GitHub Issue, classify it as atomic or coarse, then return ONLY valid JSON with no extra text or markdown fences."
# USER_PROMPT をファイルに書き出すbase64 + Python env var 方式で YAML インデント問題を回避)
export _PROMPT_B64='Q2xhc3NpZnkgR2l0SHViIElzc3VlICNJU1NVRV9OVU06IElTU1VFX1RJVExFCgpBVE9NSUMgPSBzaW5nbGUgY29uY3JldGUgaW1wbGVtZW50YXRpb246IGNyZWF0ZXMvbW9kaWZpZXMgMS0zIHNwZWNpZmljIGNvZGUgZmlsZXMsIGNsZWFyIGFjY2VwdGFuY2UgY3JpdGVyaWEsIHByb2R1Y2VzIHJ1bm5hYmxlIGNvZGUgKG5vdCBqdXN0IC5tZCBkb2NzKS4KQ09BUlNFID0gdmFndWUsIGxhcmdlIHNjb3BlICg0KyBmaWxlcyksIG11bHRpLXN0ZXAsIG9yIHdvdWxkIHByb2R1Y2UgZG9jdW1lbnRhdGlvbi1vbmx5IG91dHB1dC4KClJldHVybiBPTkxZIHRoaXMgSlNPTiAobm8gb3RoZXIgdGV4dCk6CgpJZiBhdG9taWM6CnsiYXRvbWljIjp0cnVlLCJyZWFzb24iOiJvbmUgc2VudGVuY2UiLCJzdWJ0YXNrcyI6W119CgpJZiBjb2Fyc2UgKDItNCBzdWJ0YXNrcyk6CnsiYXRvbWljIjpmYWxzZSwicmVhc29uIjoib25lIHNlbnRlbmNlIiwic3VidGFza3MiOlt7InRpdGxlIjoiZmVhdDogc3BlY2lmaWMgdmVyYiArIG9iamVjdCArIHRhcmdldCBmaWxlIiwiYm9keSI6IiMjIFdoYXQgdG8gaW1wbGVtZW50XG4tIENyZWF0ZSBzcmMvWC5qcyB3aXRoIGZ1bmN0aW9uIFkgdGhhdCBkb2VzIFpcblxuIyMgQWNjZXB0YW5jZSBDcml0ZXJpYVxuLSBbIF0gc3JjL1guanMgZXhpc3RzIHdpdGggd29ya2luZyBpbXBsZW1lbnRhdGlvblxuLSBbIF0gVGVzdHMgcGFzc1xuXG4jIyBFeHBlY3RlZCBvdXRwdXQgZmlsZXNcbi0gc3JjL1guanNcblxuIyMgRG8gTk9UXG4tIENyZWF0ZSAubWQtb25seSBQUnNcbi0gV3JpdGUgcGxhY2Vob2xkZXIgaW1wbGVtZW50YXRpb25zIn1dfQoKVGhlIGlzc3VlIGJvZHkgZm9sbG93czo='
export _ISSUE_NUM="${ISSUE_NUM}"
export _ISSUE_TITLE="${ISSUE_TITLE}"
python3 -c "import base64,os; b=base64.b64decode(os.environ['_PROMPT_B64']).decode(); b=b.replace('ISSUE_NUM',os.environ['_ISSUE_NUM']); b=b.replace('ISSUE_TITLE',os.environ['_ISSUE_TITLE']); open('/tmp/user_prompt.txt','w').write(b)"
USER_PROMPT=$(cat /tmp/user_prompt.txt)
RESULT='{"atomic":true,"reason":"Defaulting to atomic (analysis unavailable)","subtasks":[]}'
for i in 1 2 3; do
case $i in
1) TOK="${CLAUDE_CODE_OAUTH_TOKEN_1:-}" ;;
2) TOK="${CLAUDE_CODE_OAUTH_TOKEN_2:-}" ;;
3) TOK="${CLAUDE_CODE_OAUTH_TOKEN_3:-}" ;;
esac
[ -z "${TOK:-}" ] && { echo "Token $i empty, skipping."; continue; }
echo "Trying token $i/3..."
set +e
OUT=$(cat /tmp/issue_body.txt | \
ANTHROPIC_API_KEY="" \
CLAUDE_CODE_OAUTH_TOKEN="$TOK" \
CLAUDE_NO_ANALYTICS=1 \
NO_UPDATE_NOTIFIER=1 \
claude \
--model claude-opus-4-6 \
--print "$USER_PROMPT" \
--system-prompt "$SYSTEM_PROMPT" \
--no-session-persistence \
2>/tmp/decompose_err.txt)
CODE=$?
set -e
if [ $CODE -eq 0 ] && python3 -c "import json,sys; json.loads(sys.stdin.read())" <<< "$OUT" 2>/dev/null; then
RESULT="$OUT"
echo "Token $i succeeded."
break
fi
echo "Token $i failed (exit=$CODE). stderr: $(head -3 /tmp/decompose_err.txt 2>/dev/null || true)"
if grep -q "429\|rate.limit" /tmp/decompose_err.txt 2>/dev/null; then
echo "Rate limited, waiting 10s..."
sleep 10
fi
done
echo "$RESULT" > /tmp/analysis.json
cat /tmp/analysis.json
IS_ATOMIC=$(python3 -c "import json; d=json.load(open('/tmp/analysis.json')); print('true' if d.get('atomic', True) else 'false')")
echo "atomic=$IS_ATOMIC" >> $GITHUB_OUTPUT
echo "Analysis complete: atomic=$IS_ATOMIC"
- name: Create atomic sub-issues
if: steps.analyze.outputs.atomic == 'false'
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const analysis = JSON.parse(fs.readFileSync('/tmp/analysis.json', 'utf8'));
const parent = context.payload.issue;
const created = [];
for (const task of (analysis.subtasks || [])) {
const body = [
`> Decomposed from: #${parent.number} — ${parent.title}`,
'',
task.body || '',
'',
'---',
`_Auto-decomposed from #${parent.number} by Claude Opus_`,
].join('\n');
const { data } = await github.rest.issues.create({
owner: context.repo.owner,
repo: context.repo.repo,
title: task.title,
body,
labels: ['copilot'],
});
created.push({ number: data.number, title: task.title });
console.log(`Created #${data.number}: ${task.title}`);
// GitHub Sub-issues API で親 Issue の子として紐づける
try {
await github.graphql(`
mutation AddSubIssue($issueId: ID!, $subIssueId: ID!) {
addSubIssue(input: {issueId: $issueId, subIssueId: $subIssueId}) {
issue { number }
subIssue { number }
}
}
`, {
issueId: parent.node_id,
subIssueId: data.node_id,
});
console.log(`Linked #${data.number} as sub-issue of #${parent.number}`);
} catch (e) {
// Sub-issues が未対応リポジトリの場合はスキップ(スタンドアロン Issue として残る)
console.log(`Sub-issue linking skipped: ${e.message}`);
}
}
// 親 Issue に decomposed ラベル追加・copilot ラベル除去
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: parent.number,
labels: ['decomposed'],
});
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: parent.number,
name: 'copilot',
}).catch(() => {});
// 親 Issue にサマリーコメント
const list = created.map(t => `- #${t.number}: ${t.title}`).join('\n');
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: parent.number,
body: [
'## Issue Decomposed by Claude Opus',
'',
`**Reason**: ${analysis.reason}`,
'',
`This issue was split into ${created.length} atomic subtasks:`,
list,
'',
'Each sub-issue has been labeled `copilot` and will be assigned to Copilot Coding Agent individually.',
'',
'_Tip: If a subtask is still too vague, edit its body to add specific file paths before adding the `copilot` label._',
].join('\n'),
});
- name: Mark as atomic (no decomposition needed)
if: steps.analyze.outputs.atomic == 'true'
uses: actions/github-script@v7
with:
script: |
// atomic ラベルを追加。copilot-assign.yml がそのまま処理する。
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.payload.issue.number,
labels: ['atomic'],
}).catch(() => {});
console.log('Issue is atomic, proceeding to copilot-assign.');