From 1e7ea93e80d6ef5713e47eb4f94ef14bbbe6c316 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=83=8F=E3=83=AB?= Date: Sat, 31 Oct 2026 12:29:43 +0900 Subject: [PATCH 1/2] =?UTF-8?q?=F0=9F=91=B7=20PR=20=E3=81=AE=20AI=20?= =?UTF-8?q?=E3=83=AC=E3=83=93=E3=83=A5=E3=83=BC=E3=82=92=20GitHub=20Action?= =?UTF-8?q?s=20=E3=81=A7=E8=A1=8C=E3=81=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/claude-review/collect.cjs | 242 ++++++++++++++++++++++ .github/claude-review/common.cjs | 57 ++++++ .github/claude-review/diffs.cjs | 34 ++++ .github/claude-review/post.cjs | 245 ++++++++++++++++++++++ .github/claude-review/prompt.md | 98 +++++++++ .github/claude-review/yomiyasu.py | 95 +++++++++ .github/tests/claude-review.test.cjs | 293 +++++++++++++++++++++++++++ .github/workflows/claude-review.yml | 203 +++++++++++++++++++ docs/knowledge/runtime-and-tools.md | 29 +++ 9 files changed, 1296 insertions(+) create mode 100644 .github/claude-review/collect.cjs create mode 100644 .github/claude-review/common.cjs create mode 100644 .github/claude-review/diffs.cjs create mode 100644 .github/claude-review/post.cjs create mode 100644 .github/claude-review/prompt.md create mode 100644 .github/claude-review/yomiyasu.py create mode 100644 .github/tests/claude-review.test.cjs create mode 100644 .github/workflows/claude-review.yml diff --git a/.github/claude-review/collect.cjs b/.github/claude-review/collect.cjs new file mode 100644 index 0000000000..9fb588b6bd --- /dev/null +++ b/.github/claude-review/collect.cjs @@ -0,0 +1,242 @@ +// PRの情報と既存のスレッドを集め、レビューするかを決める +// PRのコードはcheckoutせず、GitHubのAPIから読んだ内容をファイルへ書き出す +const fs = require('node:fs'); +const path = require('node:path'); +const { poster, login, isAiReview, parseMarker, diffHash } = require('./common.cjs'); + +const DATA_DIR = 'Asset/data/'; + +function prNumber(context) { + // pull_request_targetではイベントのPR、workflow_dispatchでは入力の番号を使う + if (context.payload.pull_request) { + return context.payload.pull_request.number; + } + return Number(context.payload.inputs.pull_request); +} + +// レビューの対象外ならその理由を返す +function skipReason(pr, repository, account) { + // 閉じたPRはレビューしない + if (pr.state !== 'open') { + return 'the pull request is not open'; + } + // DraftのPRはレビューしない + if (pr.draft) { + return 'the pull request is a draft'; + } + // master以外へのPRはレビューしない + if (pr.base.ref !== 'master') { + return 'the base branch is not master'; + } + // フォークからのPRはsecretを渡さないので対象外にする + if (pr.head.repo === null || pr.head.repo.full_name !== repository) { + return 'the head branch is not in this repository'; + } + // 投稿用のアカウントが作ったPRはレビューしない + if (login(pr) === account) { + return `the pull request was opened by ${account}`; + } + return null; +} + +// 変更がすべてデータパックの中にあるときだけ実機検証を許す +function isDataOnly(files, pr) { + // 一覧が欠けているときは、データパック以外の変更が隠れている可能性があるので許さない + if (files.length === 0 || files.length !== pr.changed_files) { + return false; + } + return files.every(file => { + // 名前を変えたファイルは、変更前の場所もデータパックの中にあることを求める + if (file.previous_filename && !file.previous_filename.startsWith(DATA_DIR)) { + return false; + } + return file.filename.startsWith(DATA_DIR); + }); +} + +// 最後にAIが投稿した時点のheadと差分のハッシュを求める +function lastReviewed(aiReviews, aiReplies) { + const posts = []; + // AIのレビューを、記録のない古いレビューの代わりに使う投稿時のcommit_idとともに並べる + for (const review of aiReviews) { + posts.push({ at: review.submitted_at, body: review.body, commit: review.commit_id }); + } + // AIの返信を並べる + // 返信にはcommit_idがないので、記録だけを手がかりにする + for (const reply of aiReplies) { + posts.push({ at: reply.created_at, body: reply.body, commit: null }); + } + posts.sort((a, b) => a.at.localeCompare(b.at)); + let head = null; + let diff = null; + let at = null; + // 新しい投稿ほど後に読み、記録のある投稿の値で上書きする + for (const post of posts) { + const recorded = parseMarker(post.body); + // 記録のある投稿は、その記録をそのまま使う + if (recorded !== null) { + head = recorded.head; + diff = recorded.diff; + at = post.at; + } else if (post.commit !== null && diff === null) { + // 記録のある投稿がまだないときだけ、記録のない古いレビューのcommit_idを最後に見たheadとみなす + head = post.commit; + at = post.at; + } + } + return { head, diff, at }; +} + +function compact(comment) { + return { + id: comment.id, + user: login(comment), + created_at: comment.created_at, + path: comment.path, + line: comment.line, + original_line: comment.original_line, + body: comment.body, + }; +} + +// 前回AIが見たheadから今のheadまでのコミットを集める +async function commitsSince(github, repo, number, lastHead, head) { + // 前回の記録があり、今のheadから辿れるなら差分だけを返す + if (lastHead !== null) { + try { + const { data } = await github.rest.repos.compareCommitsWithBasehead({ + ...repo, basehead: `${lastHead}...${head}`, + }); + // 強制pushで前回のheadが履歴から外れた場合は、PRの全コミットへ切り替える + if (data.status === 'ahead' || data.status === 'identical') { + return { since: lastHead, commits: data.commits.map(summarizeCommit) }; + } + } catch { + // 前回のheadが取得できないときも、PRの全コミットへ切り替える + } + } + const commits = await github.paginate(github.rest.pulls.listCommits, { + ...repo, pull_number: number, per_page: 100, + }); + return { since: null, commits: commits.map(summarizeCommit) }; +} + +function summarizeCommit(commit) { + return { + sha: commit.sha, + date: commit.commit.committer.date, + author: commit.commit.author.name, + message: commit.commit.message, + merge: commit.parents.length > 1, + }; +} + +function write(outDir, name, value) { + fs.writeFileSync(path.join(outDir, name), JSON.stringify(value, null, 2) + '\n'); +} + +async function collect({ github, context, core, outDir }) { + const account = poster(); + const repo = context.repo; + const repository = `${repo.owner}/${repo.repo}`; + const number = prNumber(context); + const params = { ...repo, pull_number: number }; + const { data: pr } = await github.rest.pulls.get(params); + + const reason = skipReason(pr, repository, account); + // 対象外のPRは後のジョブを動かさない + if (reason !== null) { + core.info(`Skip #${number}: ${reason}.`); + core.setOutput('run', 'false'); + return; + } + + const files = await github.paginate(github.rest.pulls.listFiles, { ...params, per_page: 100 }); + const reviews = await github.paginate(github.rest.pulls.listReviews, { ...params, per_page: 100 }); + const comments = await github.paginate(github.rest.pulls.listReviewComments, { ...params, per_page: 100 }); + const issueComments = await github.paginate(github.rest.issues.listComments, { + ...repo, issue_number: number, per_page: 100, + }); + + // AIのレビューに付いた行コメントをスレッドの起点とし、返信をまとめる + const aiReviews = reviews.filter(review => isAiReview(review, account)); + const aiReviewIds = new Set(aiReviews.map(review => review.id)); + const roots = comments.filter(comment => !comment.in_reply_to_id && aiReviewIds.has(comment.pull_request_review_id)); + const rootIds = new Set(roots.map(comment => comment.id)); + const threads = roots.map(root => ({ + ...compact(root), + replies: comments.filter(comment => comment.in_reply_to_id === root.id).map(compact), + })); + const aiReplies = comments.filter(comment => rootIds.has(comment.in_reply_to_id) && login(comment) === account); + + const diff = diffHash(files); + const last = lastReviewed(aiReviews, aiReplies); + let mode = 'initial'; + // AIのレビューがすでにあれば、前回の指摘の確認から始める + if (aiReviews.length > 0) { + mode = 'followup'; + // 前回の投稿から差分もheadも変わっていなければ、レビューし直さない + if (last.diff === diff) { + core.info(`Skip #${number}: the diff has not changed since the last AI post.`); + core.setOutput('run', 'false'); + return; + } + // 記録のない古いレビューしかないときは、headが同じかで判定する + if (last.head === pr.head.sha) { + core.info(`Skip #${number}: the head has not changed since the last AI review.`); + core.setOutput('run', 'false'); + return; + } + } + + const history = await commitsSince(github, repo, number, last.head, pr.head.sha); + + // AIのスレッド以外のレビュー・行コメント・PRコメントは、重複した指摘を避けるために渡す + const discussion = { + reviews: reviews.filter(review => !aiReviewIds.has(review.id) && review.body).map(review => ({ + id: review.id, user: login(review), state: review.state, submitted_at: review.submitted_at, body: review.body, + })), + comments: comments.filter(comment => !rootIds.has(comment.id) && !rootIds.has(comment.in_reply_to_id)).map(compact), + issueComments: issueComments.map(comment => ({ + id: comment.id, user: login(comment), created_at: comment.created_at, body: comment.body, + })), + }; + + fs.mkdirSync(outDir, { recursive: true }); + write(outDir, 'meta.json', { + repository, + number, + url: pr.html_url, + author: login(pr), + head: pr.head.sha, + base: pr.base.sha, + mode, + dataOnly: isDataOnly(files, pr), + filesComplete: files.length === pr.changed_files, + diffHash: diff, + lastReviewedHead: history.since, + lastReviewedAt: last.at, + }); + write(outDir, 'pr.json', { + title: pr.title, + body: pr.body, + labels: pr.labels.map(label => label.name), + }); + write(outDir, 'files.json', files.map(file => ({ + filename: file.filename, + previous_filename: file.previous_filename, + status: file.status, + additions: file.additions, + deletions: file.deletions, + }))); + write(outDir, 'threads.json', threads); + write(outDir, 'discussion.json', discussion); + write(outDir, 'commits.json', history.commits); + + core.info(`Review #${number} (${mode}) at ${pr.head.sha}.`); + core.setOutput('run', 'true'); + core.setOutput('head', pr.head.sha); + core.setOutput('base', pr.base.sha); +} + +module.exports = { collect, skipReason, isDataOnly, lastReviewed }; diff --git a/.github/claude-review/common.cjs b/.github/claude-review/common.cjs new file mode 100644 index 0000000000..4e1a9c19e6 --- /dev/null +++ b/.github/claude-review/common.cjs @@ -0,0 +1,57 @@ +// AIレビューの投稿の形式と、過去の投稿を見分ける規則 +const { createHash } = require('node:crypto'); + +const DISCLAIMER = 'このレビューはハルがAI自動レビューの動作テストのためにOpus5.5 Extra Highを使用してレビューを行なったものです。鵜呑みにせず、指摘を採用するかはお任せします。'; +const MARKER_PATTERN = //; + +// 投稿に埋め込む、レビューしたheadと差分を記録するHTMLコメント +function marker(head, diff) { + return ``; +} + +function parseMarker(body) { + const match = MARKER_PATTERN.exec(String(body)); + // 記録がない投稿(Macから投稿した分)はnullを返す + if (match === null) { + return null; + } + return { head: match[1], diff: match[2] }; +} + +function poster() { + const login = process.env.CLAUDE_REVIEW_POSTER; + // 投稿用のアカウントが指定されていなければ、AIの投稿を見分けられないので止める + if (!login) { + throw new Error('CLAUDE_REVIEW_POSTER is not set'); + } + return login; +} + +function login(item) { + // 削除されたアカウントの投稿はuserがnullになる + if (item.user === null || item.user === undefined) { + return ''; + } + return item.user.login; +} + +// 投稿用のアカウントが冒頭文から書いたレビューだけをAIのレビューとして扱う +function isAiReview(review, account) { + return login(review) === account && String(review.body).startsWith(DISCLAIMER); +} + +// PRの変更ファイルの一覧から、差分が変わったかを比べるためのハッシュを作る +function diffHash(files) { + const rows = files.map(file => { + let previous = ''; + // 名前を変えたファイルは変更前の名前も含める + if (file.previous_filename) { + previous = file.previous_filename; + } + return [file.filename, file.status, file.sha, previous]; + }); + rows.sort((a, b) => a[0].localeCompare(b[0])); + return createHash('sha256').update(JSON.stringify(rows)).digest('hex'); +} + +module.exports = { DISCLAIMER, marker, parseMarker, poster, login, isAiReview, diffHash }; diff --git a/.github/claude-review/diffs.cjs b/.github/claude-review/diffs.cjs new file mode 100644 index 0000000000..7a368144eb --- /dev/null +++ b/.github/claude-review/diffs.cjs @@ -0,0 +1,34 @@ +// PRの差分と、前回AIが見たheadからの差分をファイルへ書き出す +// PRから取り出したファイルの名前はシェルを通さずgitの引数として渡す +const fs = require('node:fs'); +const path = require('node:path'); +const { execFileSync } = require('node:child_process'); + +const [repoDir, contextDir] = process.argv.slice(2); +const meta = JSON.parse(fs.readFileSync(path.join(contextDir, 'meta.json'), 'utf8')); +const files = JSON.parse(fs.readFileSync(path.join(contextDir, 'files.json'), 'utf8')); + +function git(args) { + return execFileSync('git', ['-C', repoDir, ...args], { maxBuffer: 512 * 1024 * 1024 }); +} + +// PRが分岐した位置からheadまでの差分とコミットの一覧 +const mergeBase = git(['merge-base', 'HEAD', meta.base]).toString().trim(); +fs.writeFileSync(path.join(contextDir, 'pr.diff'), git(['diff', mergeBase, 'HEAD'])); +fs.writeFileSync(path.join(contextDir, 'pr-log.txt'), + git(['log', '--format=%H %ad %an%n %s', '--date=iso-strict', `${mergeBase}..HEAD`])); + +// 前回のheadから辿れるときは、PRが変えたファイルに絞った差分も書き出す +if (meta.lastReviewedHead) { + const paths = []; + // 変更ファイルのパスを、gitがパスの特殊な書き方として解釈しない形で並べる + for (const file of files) { + paths.push(`:(literal)${file.filename}`); + // 名前を変えたファイルは変更前の名前も加える + if (file.previous_filename) { + paths.push(`:(literal)${file.previous_filename}`); + } + } + fs.writeFileSync(path.join(contextDir, 'since-last.diff'), + git(['diff', meta.lastReviewedHead, 'HEAD', '--', ...paths])); +} diff --git a/.github/claude-review/post.cjs b/.github/claude-review/post.cjs new file mode 100644 index 0000000000..a48034df94 --- /dev/null +++ b/.github/claude-review/post.cjs @@ -0,0 +1,245 @@ +// Claudeが書いたreview.jsonを検査してから、PRへレビューと返信を投稿する +// Claudeが読んだPRの内容に指示が仕込まれていても、決まった形式の投稿しか通さない +const fs = require('node:fs'); +const path = require('node:path'); +const { DISCLAIMER, marker, poster } = require('./common.cjs'); + +const NO_FINDINGS = '指摘はありません。'; +const COUNT_LINE = /^指摘が (\d+) 件あります。/; +const TOKEN_PATTERN = /sk-ant-[A-Za-z0-9_-]{8,}|gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}/; +const MENTION_PATTERN = /(^|[^A-Za-z0-9_])@[A-Za-z0-9-]/m; + +// コードブロックとインラインコードの中の@はメンションにならないので、判定から外す +function withoutCode(text) { + return text.replace(/```[\s\S]*?```/g, '').replace(/`[^`\n]*`/g, ''); +} + +// 投稿する文章に共通する検査 +function checkText(text, where) { + const errors = []; + // 空の本文は投稿しない + if (typeof text !== 'string' || text.trim() === '') { + return [`${where}: the body is empty`]; + } + // コードの外にある@はユーザーへの通知になるので通さない + if (MENTION_PATTERN.test(withoutCode(text))) { + errors.push(`${where}: contains an @ mention outside code`); + } + // トークンらしい文字列は公開しない + if (TOKEN_PATTERN.test(text)) { + errors.push(`${where}: contains a token-like string`); + } + // 前回の確認位置の記録は投稿時にこの処理が付けるので、本文に含めさせない + if (/`, comments: [] } }); + bad({ review: null, replies: [{ inReplyTo: 99, body: '解消' }] }, 'followup'); + bad({ review: null, replies: [{ inReplyTo: 10, body: '確認しました' }] }, 'followup'); + bad({ review: null, replies: [{ inReplyTo: 10, body: '解消' }, { inReplyTo: 10, body: '解消' }] }, 'followup'); + assert.ok(validate({ headSha: OLD, review: { body: noFindings, comments: [] }, replies: [] }, meta(), ids).length > 0); +}); + +function writeRun(result, mode = 'initial') { + const contextDir = tmp(); + const outputDir = tmp(); + fs.writeFileSync(path.join(contextDir, 'meta.json'), JSON.stringify(meta(mode))); + fs.writeFileSync(path.join(contextDir, 'threads.json'), JSON.stringify([{ id: 10 }])); + fs.writeFileSync(path.join(outputDir, 'review.json'), JSON.stringify({ headSha: HEAD, replies: [], ...result })); + return { contextDir, outputDir }; +} + +test('dry run reports the payload without posting', async t => { + t.after(() => { delete process.env.POST_ENABLED; }); + const created = []; + const core = fakeCore(); + await post({ github: fakeGithub({ created }), context: { repo }, core, ...writeRun({ review: { body: noFindings, comments: [] } }) }); + assert.equal(core.failed, null); + assert.equal(created.length, 0); + assert.match(core.summaries[1], /claude-review head=/); +}); + +test('posting adds the marker, keeps COMMENT and checks the account', async t => { + process.env.POST_ENABLED = 'true'; + t.after(() => { delete process.env.POST_ENABLED; }); + const created = []; + const replies = []; + const run = writeRun({ review: { body: findings, comments: [{ ...inline, start_line: 2 }] }, + replies: [{ inReplyTo: 10, body: '修正を確認しましたが、弾の件は未解消です。' }] }, 'followup'); + await post({ github: fakeGithub({ created, replies }), context: { repo }, core: fakeCore(), ...run }); + assert.equal(created.length, 1); + assert.equal(created[0].event, 'COMMENT'); + assert.equal(created[0].commit_id, HEAD); + assert.ok(created[0].body.endsWith(common.marker(HEAD, 'e'.repeat(64)))); + assert.deepEqual([created[0].comments[0].side, created[0].comments[0].start_line, created[0].comments[0].start_side], ['RIGHT', 2, 'RIGHT']); + assert.equal(replies.length, 1); + assert.equal(replies[0].comment_id, 10); + + const core = fakeCore(); + const blocked = []; + await post({ github: fakeGithub({ created: blocked, user: 'someone' }), context: { repo }, core, ...writeRun({ review: { body: noFindings, comments: [] } }) }); + assert.equal(blocked.length, 0); + assert.match(core.failed, /belongs to someone/); +}); + +test('a new push or a rejected review.json is not posted', async () => { + const created = []; + await post({ github: fakeGithub({ created, pr: prData({ head: { sha: OLD, repo: null } }) }), context: { repo }, core: fakeCore(), ...writeRun({ review: { body: noFindings, comments: [] } }) }); + assert.equal(created.length, 0); + const core = fakeCore(); + await post({ github: fakeGithub({ created }), context: { repo }, core, ...writeRun({ review: { body: 'hello', comments: [] } }) }); + assert.equal(created.length, 0); + assert.match(core.failed, /rejected/); +}); + +test('the yomiyasu entry point only passes files under work/', () => { + const { spawnSync } = require('node:child_process'); + const root = tmp(); + const scripts = path.join(root, '.ci/yomiyasu/skills/yomiyasu/scripts'); + fs.mkdirSync(scripts, { recursive: true }); + fs.mkdirSync(path.join(root, 'work/drafts'), { recursive: true }); + for (const name of ['yomiyasu_lint.py', 'yomiyasu_check.py']) { + fs.writeFileSync(path.join(scripts, name), `import sys\nprint('${name}', *sys.argv[1:])\n`); + } + const entry = path.resolve(__dirname, '../claude-review/yomiyasu.py'); + const run = (...args) => spawnSync('python3', [entry, ...args], { cwd: root, encoding: 'utf8' }); + + assert.equal(run('lint', '--doc-type', 'pr', 'work/drafts/a.md').stdout.trim(), 'yomiyasu_lint.py --doc-type pr work/drafts/a.md'); + assert.equal(run('check', 'run', '--source', 'work/drafts/a.md', '--final=work/b.md', '--stance=説明', '--fact-gaps', + '--material', 'work/m.txt').status, 0); + assert.equal(run('check', 'verify', '--final', 'work/b.md').status, 0); + for (const args of [ + ['lint', '/proc/1/environ'], ['lint', 'work/../outside.md'], ['lint', '-'], ['lint'], + ['lint', '--words', 'work/x', 'work/a.md'], ['check', 'run', '--source', '/proc/1/environ', '--final', 'work/b.md'], + ['check', 'run', '--stdin'], ['check', 'start', '--source', 'work/a.md'], ['check', 'run', '--final'], + ['check', 'run', '--fact-gaps=1', '--final', 'work/b.md'], ['other'], + ]) { + const result = run(...args); + assert.equal(result.status, 2, args.join(' ')); + assert.equal(result.stdout, ''); + } +}); diff --git a/.github/workflows/claude-review.yml b/.github/workflows/claude-review.yml new file mode 100644 index 0000000000..f1bd625d16 --- /dev/null +++ b/.github/workflows/claude-review.yml @@ -0,0 +1,203 @@ +name: claude-review + +on: + pull_request_target: + types: [opened, reopened, synchronize, ready_for_review] + workflow_dispatch: + inputs: + pull_request: + description: Pull request number + required: true + type: number + +permissions: {} + +concurrency: + group: claude-review-${{ github.event.pull_request.number || inputs.pull_request }} + cancel-in-progress: false + +env: + # 投稿に使うアカウント + # このアカウントが作ったPRはレビューしない + CLAUDE_REVIEW_POSTER: tatuharu + +jobs: + # PRの情報と既存のスレッドを集め、レビューするかを決める + # PRのコードはcheckoutしない + collect: + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + pull-requests: read + outputs: + run: ${{ steps.collect.outputs.run }} + head: ${{ steps.collect.outputs.head }} + base: ${{ steps.collect.outputs.base }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + sparse-checkout: .github/claude-review + - id: collect + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + script: | + const { collect } = require(`${process.env.GITHUB_WORKSPACE}/.github/claude-review/collect.cjs`); + await collect({ github, context, core, outDir: 'context' }); + - if: steps.collect.outputs.run == 'true' + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: claude-review-context + path: context + retention-days: 14 + + # Claudeがレビューと実機検証をして、投稿内容をreview.jsonに書く + # GitHubへの書き込み権限は持たない + review: + needs: collect + if: needs.collect.outputs.run == 'true' + runs-on: ubuntu-latest + timeout-minutes: 150 + environment: claude-review + permissions: + contents: read + steps: + # DevSpaceは承認済みのコミットに固定し、検証ランナーとAGENTS.mdをここから使う + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: ProjectTSB/DevSpace + ref: 0a4877c30d9be852946a8ed1bdfa6ac3c5efa0c8 + path: devspace + persist-credentials: false + # このワークフローと同じmasterのコミットから、プロンプト・補助スクリプト・ナレッジだけを取り出す + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + path: devspace/.ci/asset-master + persist-credentials: false + sparse-checkout: | + .github/claude-review + docs + # PRのheadはデータとして取り出す + # ランナーでは実行せず、サーバーがデータパックとして読むだけにする + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ needs.collect.outputs.head }} + path: devspace/Asset + fetch-depth: 0 + persist-credentials: false + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: ProjectTSB/TheSkyBlessing + ref: master + path: devspace/TheSkyBlessing + persist-credentials: false + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: ProjectTSB/Asset-AnimatedJava + ref: dist + path: devspace/Asset-AnimatedJava + persist-credentials: false + # 文章の推敲に使うyomiyasu(MIT)を、固定したコミットで取り出す + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: nanaism/yomiyasu + ref: 68075bea91ae27958766effd6aaa56d42d6be125 + path: devspace/.ci/yomiyasu + persist-credentials: false + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: claude-review-context + path: devspace/work/context + - uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0 + with: + distribution: temurin + java-version: '17' + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: '22' + # サーバーのjarと検証用クライアントの依存を使い回す + - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + devspace/.cache/server-1.20.4.jar + devspace/.cache/verification-client + key: claude-review-runtime-${{ hashFiles('devspace/scripts/verification/package-lock.json') }} + - name: Prepare diffs and runner settings + run: | + node devspace/.ci/asset-master/.github/claude-review/diffs.cjs devspace/Asset devspace/work/context + printf 'ACCEPT_EULA=true\n' > devspace/devspace.local.conf + - name: Install Claude Code + run: npm install -g @anthropic-ai/claude-code@2.1.296 + # ファイルの道具は作業フォルダの中に限り、書き込みはwork/の下、コマンドは検証とyomiyasuだけを許す + # 許可していない操作は確認を求めずに拒否し、Bashの子プロセスからは認証情報の環境変数を消す + - name: Review + working-directory: devspace + env: + CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + CLAUDE_CODE_SUBPROCESS_ENV_SCRUB: '1' + run: | + claude -p \ + --model claude-opus-5-5 --effort xhigh \ + --restricted --tools Read,Glob,Grep,Write,Edit,Bash,TodoWrite \ + --permission-prompts none \ + --allowedTools Read Glob Grep TodoWrite 'Edit(./work/**)' \ + 'Bash(sh scripts/verify.sh work/scenarios/*)' \ + 'Bash(python3 .ci/asset-master/.github/claude-review/yomiyasu.py *)' \ + --disallowedTools WebFetch WebSearch \ + --no-session-persistence --output-format stream-json --verbose \ + < .ci/asset-master/.github/claude-review/prompt.md > work/claude.jsonl + test -f work/review.json + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: claude-review-output + path: devspace/work/review.json + retention-days: 14 + # 判断の根拠を残す + # 検証用のワールド・ライブラリは含めない + - if: always() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: claude-review-evidence + retention-days: 14 + if-no-files-found: ignore + path: | + devspace/work + devspace/.yomiyasu/runs + devspace/.runtime/verification-runs/*/result.json + devspace/.runtime/verification-runs/*/scenario.json + devspace/.runtime/verification-runs/*/*.log + + # review.jsonを検査してから投稿する + # Claudeは動かさない + post: + needs: [collect, review] + runs-on: ubuntu-latest + timeout-minutes: 10 + environment: claude-review + permissions: + contents: read + pull-requests: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + sparse-checkout: .github/claude-review + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: claude-review-context + path: context + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: claude-review-output + path: output + - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + # trueのときだけ投稿する + # それ以外は、投稿する内容を実行結果の概要に出す + POST_ENABLED: ${{ vars.CLAUDE_REVIEW_POST }} + with: + github-token: ${{ secrets.CLAUDE_REVIEW_POST_TOKEN || github.token }} + script: | + const { post } = require(`${process.env.GITHUB_WORKSPACE}/.github/claude-review/post.cjs`); + await post({ github, context, core, contextDir: 'context', outputDir: 'output' }); diff --git a/docs/knowledge/runtime-and-tools.md b/docs/knowledge/runtime-and-tools.md index 401e2606cd..d592cea601 100644 --- a/docs/knowledge/runtime-and-tools.md +++ b/docs/knowledge/runtime-and-tools.md @@ -55,6 +55,35 @@ workflowは `pull_request_target` と標準の `GITHUB_TOKEN` を使い、PRの 差分判定とCLI呼出しの回帰確認は `node --test .github/tests/auto-merge-docs-tests.test.cjs`。APIとCLIを置き換えたローカル検証であり、Rulesetや実際のGitHubマージ動作の検証とは区別する。 +## PR の AI レビュー + +[claude-review](../../.github/workflows/claude-review.yml) は master 向けの PR を Claude Code でレビューし、`CLAUDE_REVIEW_POSTER`(tatuharu)のアカウントでレビューと返信を投稿する。起動は `pull_request_target` の opened・reopened・synchronize・ready_for_review と、PR 番号を指定する `workflow_dispatch`。Draft、フォークからの PR、`CLAUDE_REVIEW_POSTER` のアカウントが作った PR は対象外。 + +| ジョブ | 権限・secret | 役割 | +| --- | --- | --- | +| collect | contents・pull-requests の読み取り | PR の情報と既存のスレッドを API から集め、レビューするかを決める。PR のコードは checkout しない | +| review | contents の読み取り、`CLAUDE_CODE_OAUTH_TOKEN` | [プロンプト](../../.github/claude-review/prompt.md) に従って Claude がレビュー・実機検証・推敲をし、`work/review.json` を書く | +| post | `CLAUDE_REVIEW_POST_TOKEN` | [post.cjs](../../.github/claude-review/post.cjs) が review.json の形式を検査してから投稿する | + +Claude が読む PR の本文・コメント・コードには、Claude への指示が仕込まれていることがある。review ジョブに GitHub への書き込み権限も投稿用のトークンも置かないのはそのためで、投稿の形式は決まった処理の post ジョブが検査する。検査するのは、冒頭文と「指摘はありません。」または指摘の一覧だけの本文、コードの外の `@` メンション、トークンらしい文字列、返信先が AI のスレッドか、レビューした head が今の head と同じか。 + +review ジョブの作業フォルダは DevSpace で、`Asset/` に PR の head をデータとして取り出す。DevSpace・yomiyasu・Claude Code はワークフローに書いた版に固定し、更新もこのワークフローの PR で行う。Claude の道具は次のように絞る。 + +- ファイルの道具は作業フォルダの中だけ(`--restricted`)で、書き込みは `work/` の下だけ +- コマンドは `scripts/verify.sh` と [yomiyasu の呼び出し口](../../.github/claude-review/yomiyasu.py) だけ。yomiyasu 自体は作業フォルダの外も読めるので、呼び出し口で渡すファイルを `work/` の下に限る +- `CLAUDE_CODE_SUBPROCESS_ENV_SCRUB` で、Bash の子プロセスから認証情報の環境変数を消す + +実機検証は、PR の変更がすべて `Asset/data/` の中にあるときだけ行う。シナリオ・`result.json`・ログ・yomiyasu の記録は、成果物 `claude-review-evidence` として14日間残る。 + +投稿の末尾にはレビューした head と変更ファイルの一覧のハッシュを `` として埋め込む。collect は、前回の投稿から差分が変わっていなければ review を動かさない。記録のない古い投稿(Mac から投稿した分)は、レビューの commit_id を前回の head とみなす。 + +ファイルの外の設定は次のとおり。 + +- environment `claude-review` の配備ブランチを master に限り、secret `CLAUDE_CODE_OAUTH_TOKEN`(`claude setup-token` で作る、発行した人のサブスクリプションの利用枠を使う1年有効のトークン)と `CLAUDE_REVIEW_POST_TOKEN`(`CLAUDE_REVIEW_POSTER` の fine-grained PAT。Asset の Pull requests の読み書きだけ)を置く。 +- リポジトリの変数 `CLAUDE_REVIEW_POST` が `true` のときだけ投稿する。それ以外では、投稿する内容を実行結果の概要に出すだけにする。 + +collect の判定と post の検査の回帰確認は `node --test .github/tests/claude-review.test.cjs`。API を置き換えたローカルの試験であり、environment の保護や Claude の実際の振る舞いは確かめない。 + ## NBTの整数カウンタを、1未満の倍率で減らす NBT内の残距離・残時間をscoreへ往復させずに減らす場合は、DevSpaceの `docs/mcfunction-idioms.md`「整数化を挟む演算」の値域・丸めを確認する。[abstract_projectile](../../Asset/data/asset/functions/object/0001.abstract_projectile/tick/rec.m.mcfunction) の `RemainingRange` は正の整数を減らし0を維持する実例で、係数は `0.9999999999`。係数を1へ丸めない。 From 6a15b444bb5a7de90f507ee83fdf22517884ca29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=83=8F=E3=83=AB?= Date: Sat, 31 Oct 2026 12:38:17 +0900 Subject: [PATCH 2/2] =?UTF-8?q?=F0=9F=93=9D=20PR=20=E3=81=AE=20AI=20?= =?UTF-8?q?=E3=83=AC=E3=83=93=E3=83=A5=E3=83=BC=E3=81=AE=E8=AA=AC=E6=98=8E?= =?UTF-8?q?=E3=82=92=E3=83=8A=E3=83=AC=E3=83=83=E3=82=B8=E3=81=8B=E3=82=89?= =?UTF-8?q?=E5=A4=96=E3=81=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/knowledge/runtime-and-tools.md | 29 ----------------------------- 1 file changed, 29 deletions(-) diff --git a/docs/knowledge/runtime-and-tools.md b/docs/knowledge/runtime-and-tools.md index d592cea601..401e2606cd 100644 --- a/docs/knowledge/runtime-and-tools.md +++ b/docs/knowledge/runtime-and-tools.md @@ -55,35 +55,6 @@ workflowは `pull_request_target` と標準の `GITHUB_TOKEN` を使い、PRの 差分判定とCLI呼出しの回帰確認は `node --test .github/tests/auto-merge-docs-tests.test.cjs`。APIとCLIを置き換えたローカル検証であり、Rulesetや実際のGitHubマージ動作の検証とは区別する。 -## PR の AI レビュー - -[claude-review](../../.github/workflows/claude-review.yml) は master 向けの PR を Claude Code でレビューし、`CLAUDE_REVIEW_POSTER`(tatuharu)のアカウントでレビューと返信を投稿する。起動は `pull_request_target` の opened・reopened・synchronize・ready_for_review と、PR 番号を指定する `workflow_dispatch`。Draft、フォークからの PR、`CLAUDE_REVIEW_POSTER` のアカウントが作った PR は対象外。 - -| ジョブ | 権限・secret | 役割 | -| --- | --- | --- | -| collect | contents・pull-requests の読み取り | PR の情報と既存のスレッドを API から集め、レビューするかを決める。PR のコードは checkout しない | -| review | contents の読み取り、`CLAUDE_CODE_OAUTH_TOKEN` | [プロンプト](../../.github/claude-review/prompt.md) に従って Claude がレビュー・実機検証・推敲をし、`work/review.json` を書く | -| post | `CLAUDE_REVIEW_POST_TOKEN` | [post.cjs](../../.github/claude-review/post.cjs) が review.json の形式を検査してから投稿する | - -Claude が読む PR の本文・コメント・コードには、Claude への指示が仕込まれていることがある。review ジョブに GitHub への書き込み権限も投稿用のトークンも置かないのはそのためで、投稿の形式は決まった処理の post ジョブが検査する。検査するのは、冒頭文と「指摘はありません。」または指摘の一覧だけの本文、コードの外の `@` メンション、トークンらしい文字列、返信先が AI のスレッドか、レビューした head が今の head と同じか。 - -review ジョブの作業フォルダは DevSpace で、`Asset/` に PR の head をデータとして取り出す。DevSpace・yomiyasu・Claude Code はワークフローに書いた版に固定し、更新もこのワークフローの PR で行う。Claude の道具は次のように絞る。 - -- ファイルの道具は作業フォルダの中だけ(`--restricted`)で、書き込みは `work/` の下だけ -- コマンドは `scripts/verify.sh` と [yomiyasu の呼び出し口](../../.github/claude-review/yomiyasu.py) だけ。yomiyasu 自体は作業フォルダの外も読めるので、呼び出し口で渡すファイルを `work/` の下に限る -- `CLAUDE_CODE_SUBPROCESS_ENV_SCRUB` で、Bash の子プロセスから認証情報の環境変数を消す - -実機検証は、PR の変更がすべて `Asset/data/` の中にあるときだけ行う。シナリオ・`result.json`・ログ・yomiyasu の記録は、成果物 `claude-review-evidence` として14日間残る。 - -投稿の末尾にはレビューした head と変更ファイルの一覧のハッシュを `` として埋め込む。collect は、前回の投稿から差分が変わっていなければ review を動かさない。記録のない古い投稿(Mac から投稿した分)は、レビューの commit_id を前回の head とみなす。 - -ファイルの外の設定は次のとおり。 - -- environment `claude-review` の配備ブランチを master に限り、secret `CLAUDE_CODE_OAUTH_TOKEN`(`claude setup-token` で作る、発行した人のサブスクリプションの利用枠を使う1年有効のトークン)と `CLAUDE_REVIEW_POST_TOKEN`(`CLAUDE_REVIEW_POSTER` の fine-grained PAT。Asset の Pull requests の読み書きだけ)を置く。 -- リポジトリの変数 `CLAUDE_REVIEW_POST` が `true` のときだけ投稿する。それ以外では、投稿する内容を実行結果の概要に出すだけにする。 - -collect の判定と post の検査の回帰確認は `node --test .github/tests/claude-review.test.cjs`。API を置き換えたローカルの試験であり、environment の保護や Claude の実際の振る舞いは確かめない。 - ## NBTの整数カウンタを、1未満の倍率で減らす NBT内の残距離・残時間をscoreへ往復させずに減らす場合は、DevSpaceの `docs/mcfunction-idioms.md`「整数化を挟む演算」の値域・丸めを確認する。[abstract_projectile](../../Asset/data/asset/functions/object/0001.abstract_projectile/tick/rec.m.mcfunction) の `RemainingRange` は正の整数を減らし0を維持する実例で、係数は `0.9999999999`。係数を1へ丸めない。