- 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>
210 lines
9.6 KiB
YAML
210 lines
9.6 KiB
YAML
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.');
|