mergegate/.github/workflows/decompose.yml
林 駿甫 (Shunsuke Hayashi) 2d500c3654 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>
2026-03-29 21:48:22 +09:00

210 lines
9.6 KiB
YAML
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.');