GitHub ActionsでZennの下書きをClaudeにレビューしてもらう
はじめに
GitHub ActionsとClaude APIを組み合わせて、Zennの下書きをChatGPTライクなAIモデルであるClaudeにレビューしてもらう仕組みを作ってみたので紹介します。
記事を書くときにPull Requestを作る
僕は普段Zennのリポジトリでは特にレビューしてもらったりということがないのですべてmainブランチで運用していましたが、PRでレビューしてもらうにあたって Pull Request を作るように変更しました。
mainブランチに比べてPR作ったりが面倒なので以下のスクリプトで記事の作成・PRを作成します。
#!/usr/bin/env bash
set -eux
# 記事名と記事slugを対話形式で入力させる
read -p "記事名を入力してください: " title
read -p "記事slugを入力してください: " slug
# 記事slugを使って `articles/${slug}.md` にmarkdownファイルを作成する
touch articles/${slug}.md
# frontmatterを記載する
echo "---" > articles/${slug}.md
echo "title: \"$title\"" >> articles/${slug}.md
# ランダムな絵文字を生成する
emojis=("😀" "😁" "😂" "🤣" "😃" "😄" "😅" "😆" "😉" "😊" "😋" "😎" "😍" "😘" "😗" "😙" "😚" "🙂" "🤗" "🤩" "🤔" "🤨" "😐" "😑" "😶" "🙄" "😏" "😣" "😥" "😮" "🤐" "😯" "😪" "😫" "😴" "😌" "😛" "😜" "😝" "🤤" "😒" "😓" "😔" "😕" "🙃" "🤑" "😲" "☹️" "🙁" "😖" "😞" "😟" "😤" "😢" "😭" "😦" "😧" "😨" "😩" "🤯" "😬" "😰" "😱" "🥵" "🥶" "😳" "🤪" "😵" "😡" "😠" "🤬" "😷" "🤒" "🤕" "🤢" "🤮" "🤧" "😇" "🤠" "🤡" "🥳" "🥴" "🥺" "🤥" "🤫" "🤭" "🧐" "🤓" "😈" "👿" "👹" "👺" "💀" "👻" "👽" "🤖" "💩" "😺" "😸" "😹" "😻" "😼" "😽" "🙀" "😿" "😾")
emoji=${emojis[$RANDOM % ${#emojis[@]}]}
echo "emoji: \"$emoji\"" >> articles/${slug}.md
echo "type: tech" >> articles/${slug}.md
echo "topics: []" >> articles/${slug}.md
echo "published: false" >> articles/${slug}.md
echo "---" >> articles/${slug}.md
# `articles/${slug}` のブランチを作成する
git switch -c articles/${slug}
# 新規作成したブランチでコミットする
git add articles/${slug}.md
git commit -m "新規記事追加: ${slug}"
# pushする
git push -u origin articles/${slug}
# gh cliを使ってPull Requestを作成する
gh pr create --title "新規記事: ${title}" --body "この PR は新規記事 ${title} を追加します。" --base main --head articles/${slug} --draft
このスクリプトを使うと、対話形式で記事名とslugを入力すれば自動的に
- ブランチ
- 記事ファイル
- Pull Request
が作成されます。
opened, ready_for_review, reopened に trigger して workflow を起動する
synchronizedだとdiffを投げるたびにレビューが飛んできてしまうので、opened, ready_for_review, reopenedのタイミングでレビューしてもらうようにします。
on:
pull_request:
types: [opened, ready_for_review, reopened]
依存するパッケージのインストール
スクリプトは GitHub Script で書いていくので、必要な型定義とパッケージをインストールしておきます。
$ pnpm add -D @actions/github @anthropic-ai/sdk @octokit/rest @octokit/types
Claude APIを呼び出す
次に、GitHub ActionsからClaude APIを呼び出してレビューコメントを生成していきます。
// @ts-check
const { default: Anthropic } = require("@anthropic-ai/sdk")
const { readFile } = require("node:fs/promises")
const anthropic = new Anthropic({
apiKey: process.env["ANTHROPIC_API_KEY"],
})
const systemPrompt = `
以下は技術ブログ記事の原稿です。この記事をレビューし、以下の項目についてコメントを JSON 形式で出力してください。
- 要約: 記事の概要を簡潔にまとめてください。
- 構成: 記事の全体的な構成について、改善点や提案があればコメントしてください。
- 細部指摘: 記事の細部について、気になる点や修正すべき点があれば行数とともにコメントしてください。
出力は以下の JSON 形式に従ってください。
{
"summary": "記事の要約",
"structure": "構成に関するコメント",
"details": [
{
"line": 行数,
"comment": "指摘内容"
},
...
]
}
文字数を節約するため、簡潔なレビューを心がけてください。`
const messageTemplate = `
記事のレビューをお願いします。
記事の原稿は以下の通りです
---
`
/** @type {(content: string) => Promise<{ summary: string, structure: string, details?: ReadonlyArray<{ line: number, comment: string }> }>} */
const reviewArticle = async (content) => {
const message = messageTemplate + "\n" + content
const result = await anthropic.messages.create({
model: "claude-3-opus-20240229",
max_tokens: 2048,
system: systemPrompt,
messages: [
{ role: "user", content: message },
{ role: "assistant", content: "{" },
],
})
const outputJson = `{${result.content.at(-1)?.text}`
return JSON.parse(outputJson)
}
machine readable なデータがほしいので json 形式の出力を指定し、assistant
側の {
を指定しておくのがポイントです。
レビュー内容を GitHub Review Comment として投げる
レビュー内容を GitHub に投げる部分を書いていきます。
/**
* @typedef {import("@octokit/rest").Octokit} OctokitClient
* @link https://octokit.github.io/rest.js/v20#usage
*/
/** @typedef {import("@actions/github").context} WorkflowRunContext */
/** @type {(arg: { github: { rest: OctokitClient }, context: WorkflowRunContext }, changedFiles: ReadonlyArray<string>) => Promise<void>} */
module.exports = async ({ github, context }, changedFiles) => {
const pr = context.payload.pull_request
if (pr === undefined) throw new Error("Unexpected.")
for (const file of changedFiles) {
const fileContent = await readFile(file, "utf-8")
const wordCount = fileContent.length
if (wordCount < 100) {
console.log(`File ${file} is too short (${wordCount} words)`)
continue
}
const { summary, structure, details } = await reviewArticle(fileContent)
console.log({
summary,
structure,
details,
})
if (details === undefined || details.length === 0) {
await github.rest.pulls.createReview({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: pr.number,
event: "APPROVE",
body: structure,
})
return
}
await Promise.all([
...details.map((detail) =>
github.rest.pulls.createReviewComment({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: pr.number,
body: detail.comment,
commit_id: pr.head.sha,
path: file,
line: detail.line,
})
),
])
const review = await github.rest.pulls
.createReview({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: pr.number,
event: "COMMENT",
body: structure,
})
.then((res) => res.data)
}
}
workflow から GitHub Script を呼び出す
最後に GitHub Script を呼び出す workflow を作ります。
name: AI Article Review
on:
pull_request:
types: [opened, ready_for_review, reopened]
jobs:
review:
runs-on: ubuntu-latest
if: startsWith(github.head_ref, 'articles')
env:
GH_TOKEN: ${{ github.token }}
PR_URL: ${{ github.event.pull_request.html_url }}
steps:
- uses: actions/checkout@v4
- name: Install Node.js
uses: actions/setup-node@v3
with:
node-version-file: ".node-version"
- uses: pnpm/action-setup@v2
name: Setup Pnpm
with:
run_install: false
- name: Get pnpm store directory
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- uses: actions/cache@v3
name: Setup pnpm cache
with:
path: ${{ env.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install dependencies
shell: bash
run: pnpm i --frozen-lockfile
- name: Get changed files
id: changed-files
uses: tj-actions/changed-files@v35
with:
files: articles/**
json: true
json_raw_format: true
- name: Review articles
if: steps.changed-files.outputs.any_changed == 'true'
uses: actions/github-script@v7
with:
script: |
return await require('./.github/scripts/ai-review/index.cjs')({ github, context }, ${{ steps.changed-files.outputs.all_changed_files }})
github-token: ${{ secrets.GITHUB_TOKEN }}
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
tj-actions/changed-files
で diff を取り記事ファイルが見つかれば対象のファイルを GitHub Script に渡してレビューさせます。
まとめ
簡易的ですが Claude を使って記事レビューをする仕組みを GHA で作ってみました。
改善の余地としては
- レビュー状態のより厳格な制御
- ちゃんとやるならラベルを使う等してApprove/Request Changes をちゃんとするほうが良いかも
- レビューコメントでのやり取り
等がありそうですが、そこまで厳格にやっても仕方ないかなと思うのでこれで運用してみようと思っています。
Discussion