開いている PR と CI の状態を gh で定期的に取り、ステータス行とペインに出す

English | 日本語
A marketplace of mods for Claude Code. A mod is a plugin that adds features to Claude Code through function hooks.
| Mod | What it does |
|---|---|
| branch-watch | Stops commits on a branch another session switched to, and lists each session's worktree and branch |
| push-gate | Holds git push and similar commands until a person approves |
| auto-worktree | Creates a worktree on the first prompt and starts the work there |
| session-diff | Shows the files this session edited and their diffs |
| pr-pane | Shows open pull requests and their CI results |
| side-question | /btw asks a side question without stopping the work |
| done-chime | Plays a sound when a long turn ends and when Claude waits for permission |
| ja-check | Tells you when a reply starts in English |
| usage-meter | Shows each turn's token counts and prompt cache rate |
claude plugin marketplace add yut0takagi/mods
claude plugin install branch-watch@yut0takagi-mods # one line per mod you want
Developed with Claude Code 2.1.289. Mods are an early access feature, so a Claude Code update may break them. The VS Code and Cursor extension draws no mod UI, so some mods are used differently there.
To change a mod, read CONTRIBUTING. Changes are listed in the CHANGELOG. Report security problems as described in SECURITY.
hooks/register.tsx 115 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Check } from '../types'
5import { causeOf, clockOf, detailOf, FIELDS, firstLine, MARK, parsePrs, summaryOf, textOf } from './prs'
6
7const PANE = 'pr-pane'
8const board = atom({ plugin: 'pr-pane', key: 'board' } as const, null)
9
10const COLOR: Record<Check, string | undefined> = { pass: 'green', fail: 'red', pending: 'yellow', none: undefined }
11
12const TIMEOUT_MS = 20_000
13
14/** gh pr list を呼び直して、ペインとステータス行を新しくする。 */
15async function refresh($: EngineInterface, base: string) {
16 const argv = ['gh', 'pr', 'list', '--state', 'open', '--limit', '30', '--json', FIELDS]
17
18 if (base !== '') {
19 argv.push('--base', base)
20 }
21
22 const startedAt = await $.clock.now()
23 const ran = await $.process
24 .run(argv, { timeoutMs: TIMEOUT_MS })
25 .catch((error: unknown) => ({ exitCode: 1, stdout: '', stderr: String(error), isRejected: true as const }))
26 const fetchedAt = await $.clock.now()
27
28 if (ran.exitCode !== 0) {
29 // 起動できなかったときと時間切れのときは reject される。どちらかは、かかった時間で見分ける
30 const cause = 'isRejected' in ran ? (fetchedAt - startedAt >= TIMEOUT_MS / 2 ? 'timeout' : 'no-gh') : causeOf(ran.stderr)
31 await update($, board, () => ({ prs: [], fetchedAt, error: firstLine(ran.stderr) || 'gh pr list が失敗しました', cause }))
32 $.ui.status(undefined)
33
34 return
35 }
36
37 const prs = parsePrs(ran.stdout)
38 await update($, board, () => ({ prs, fetchedAt }))
39 $.ui.status(summaryOf(prs))
40}
41
42/** 描く画面があるか。読めないときは、あるとみなして今までどおりに動く。 */
43const hasScreen = async ($: EngineInterface) => (await $.session.surfaces().catch(() => undefined))?.length !== 0
44
45export const register: Register = (on, options) => {
46 const base = typeof options.base === 'string' ? options.base.trim() : ''
47 const intervalMs = Math.max(30, Number(options.intervalSeconds) || 120) * 1_000
48 const title = base === '' ? 'PR' : `PR → ${base}`
49
50 on('session.start', async ($, e, next) => {
51 const screen = await hasScreen($)
52 await $.command.register({
53 name: 'prs',
54 description: screen ? 'PR と CI の状態をペインに出して、取り直す' : 'PR と CI の状態を取り直して、返答に出す',
55 })
56
57 // 画面がない(VS Code の拡張機能など)と、取っても描く先がない。そのあいだは /prs で頼まれたときだけ取る。
58 // あとから画面がつながることもあるので、定期の取得は登録しておき、そのたびに画面の有無を見る
59 if (screen) {
60 void refresh($, base)
61 }
62
63 $.clock.every(intervalMs, () => void hasScreen($).then(isShown => (isShown ? refresh($, base) : undefined)))
64
65 return next(e)
66 })
67
68 on('command.run', { command: 'prs' }, async $ => {
69 // 画面がないセッション(VS Code の拡張機能など)では、ペインもステータス行も描かれない。
70 // ui.open は isPlaced: true を返すのに描かれないので、isPlaced ではなく surfaces で見分け、取り直した中身を返答のテキストで返す
71 if ((await $.session.surfaces()).length === 0) {
72 await refresh($, base)
73
74 return { text: textOf(await read($, board), base) }
75 }
76
77 const opened = await $.ui.open({ id: PANE, title })
78 await refresh($, base)
79
80 return { text: opened.isPlaced ? 'PR のペインを開きました。' : `PR のペインを開けませんでした: ${opened.reason}` }
81 })
82
83 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
84 const { Box, Button, Link, Text } = $.ui.resolve(e)
85 const now = await read($, board)
86
87 return (
88 <Box flexDirection="column">
89 <Box gap={1}>
90 <Text dimColor>{now === null ? '取得中…' : `${clockOf(now.fetchedAt)} 時点`}</Text>
91 <Button key="refresh" label="更新" onPress={() => refresh($, base)} />
92 </Box>
93 {now?.error !== undefined && <Text color="red">{now.error}</Text>}
94 {now !== null && now.error === undefined && now.prs.length === 0 && (
95 <Text dimColor>開いている PR はありません。</Text>
96 )}
97 {now?.prs.map(pr => (
98 <Box key={`pr-${pr.number}`} flexDirection="column" marginTop={1}>
99 <Box gap={1}>
100 <Text color={COLOR[pr.check]} dimColor={pr.check === 'none'}>
101 {MARK[pr.check]}
102 </Text>
103 <Link href={pr.url} label={`#${pr.number}`} />
104 <Text wrap="truncate-end">{pr.title}</Text>
105 </Box>
106 <Text dimColor wrap="truncate-end">
107 {detailOf(pr)}
108 </Text>
109 </Box>
110 ))}
111 </Box>
112 )
113 })
114}
115hooks/prs.ts 145 lines1/** gh pr list の出力を読み、PR ごとの CI をまとめる。$ に触れない部分。 */
2
3import type { Board, Cause, Check, Pr } from '../types'
4
5export const FIELDS = 'number,title,headRefName,baseRefName,isDraft,reviewDecision,url,author,statusCheckRollup'
6
7/** statusCheckRollup の 1 件。CheckRun は status と conclusion、StatusContext は state を持つ。 */
8type Rollup = { status?: string; conclusion?: string; state?: string }
9
10type Raw = {
11 number: number
12 title: string
13 headRefName: string
14 baseRefName: string
15 isDraft: boolean
16 reviewDecision: string | null
17 url: string
18 author: { login: string } | null
19 statusCheckRollup: Rollup[] | null
20}
21
22const FAILED = new Set(['FAILURE', 'TIMED_OUT', 'CANCELLED', 'ACTION_REQUIRED', 'STARTUP_FAILURE', 'ERROR'])
23
24export function checkOf(rollup: readonly Rollup[] | null): Check {
25 if (rollup === null || rollup.length === 0) {
26 return 'none'
27 }
28
29 let isPending = false
30
31 for (const one of rollup) {
32 const result = one.conclusion || one.state || ''
33
34 if (FAILED.has(result)) {
35 return 'fail'
36 }
37
38 if ((one.status !== undefined && one.status !== 'COMPLETED') || result === '' || result === 'PENDING' || result === 'EXPECTED') {
39 isPending = true
40 }
41 }
42
43 return isPending ? 'pending' : 'pass'
44}
45
46export function parsePrs(stdout: string): Pr[] {
47 return (JSON.parse(stdout) as Raw[]).map(raw => ({
48 number: raw.number,
49 title: raw.title,
50 head: raw.headRefName,
51 base: raw.baseRefName,
52 author: raw.author?.login ?? '',
53 isDraft: raw.isDraft,
54 review: raw.reviewDecision ?? '',
55 url: raw.url,
56 check: checkOf(raw.statusCheckRollup),
57 }))
58}
59
60export const MARK: Record<Check, string> = { pass: '✓', fail: '✗', pending: '○', none: '·' }
61
62export const REVIEW: Record<string, string> = {
63 APPROVED: '承認済み',
64 CHANGES_REQUESTED: '修正依頼',
65 REVIEW_REQUIRED: 'レビュー待ち',
66}
67
68/** ステータス行の 1 行。CI の結果ごとの件数を、ある分だけ並べる。 */
69export function summaryOf(prs: readonly Pr[]): string {
70 const counts = (['fail', 'pending', 'pass'] as const)
71 .map(check => [check, prs.filter(pr => pr.check === check).length] as const)
72 .filter(([, n]) => n > 0)
73 .map(([check, n]) => `${MARK[check]}${n}`)
74
75 return [`PR ${prs.length}`, ...counts].join(' ')
76}
77
78/** PR の 2 行目。ブランチ・作者・draft・レビューの状態を、ある分だけ並べる。 */
79export function detailOf(pr: Pr): string {
80 return [`${pr.head} → ${pr.base}`, `@${pr.author}`, pr.isDraft ? 'draft' : '', REVIEW[pr.review] ?? '']
81 .filter(part => part !== '')
82 .join(' · ')
83}
84
85/** gh のエラー出力の最初の行。 */
86export const firstLine = (text: string) => text.trim().split('\n')[0] ?? ''
87
88/** gh pr list が 0 以外で終わったわけを、エラー出力から見分ける。 */
89export function causeOf(stderr: string): Cause {
90 // remote が GitHub を向いていないときの文言にも gh auth login が入るので、リポジトリを先に見る
91 if (/not a git repository|no git remotes found|known GitHub host/i.test(stderr)) {
92 return 'no-repo'
93 }
94
95 if (/gh auth login|GH_TOKEN|HTTP 401|could not find any host configurations/i.test(stderr)) {
96 return 'no-auth'
97 }
98
99 return 'other'
100}
101
102/** 時刻を HH:MM で。 */
103export function clockOf(ms: number): string {
104 const at = new Date(ms)
105
106 return `${String(at.getHours()).padStart(2, '0')}:${String(at.getMinutes()).padStart(2, '0')}`
107}
108
109/** 画面がないときの返答で、色の代わりに CI の結果を言葉で添える。 */
110export const CI: Record<Check, string> = { pass: 'CI 成功', fail: 'CI 失敗', pending: 'CI 実行中', none: 'CI なし' }
111
112const HINT: Record<Cause, string> = {
113 'no-gh': 'gh(GitHub CLI)を起動できませんでした。gh が入っていて PATH が通っているかを確かめてください(https://cli.github.com)。',
114 timeout: 'gh pr list が時間内に終わりませんでした。少し待ってから /prs で取り直してください。',
115 'no-auth': 'gh にログインしていません。ターミナルで gh auth login を実行してから、/prs で取り直してください。',
116 'no-repo': 'GitHub のリポジトリではないため、PR を取れません。git のリポジトリの中で、remote が GitHub を向いているかを確かめてください。',
117 other: 'gh pr list が失敗しました。',
118}
119
120/**
121 * 画面がないセッションでの /prs の返答。ステータス行の要約を先頭に置き、ペインと同じ中身を行で並べる。
122 * Markdown として描かれても崩れないよう、まとまりの間は空行で区切り、PR は箇条書きにする。
123 */
124export function textOf(board: Board | null, base: string): string {
125 if (board === null) {
126 return 'PR をまだ取れていません。少し待ってから /prs をもう一度実行してください。'
127 }
128
129 const when = [base === '' ? '' : `宛先 ${base}`, `${clockOf(board.fetchedAt)} 時点`].filter(part => part !== '').join('・')
130
131 if (board.error !== undefined) {
132 return [`PR を取れませんでした(${when})`, HINT[board.cause ?? 'other'], `詳細: ${board.error}`].join('\n\n')
133 }
134
135 const head = `${summaryOf(board.prs)}(${when})`
136
137 if (board.prs.length === 0) {
138 return `${head}\n\n開いている PR はありません。`
139 }
140
141 const items = board.prs.map(pr => `- ${MARK[pr.check]} #${pr.number} ${pr.title}(${CI[pr.check]} · ${detailOf(pr)})\n ${pr.url}`)
142
143 return [head, '', ...items].join('\n')
144}
145types/index.d.ts 38 lines1/** CI の結果をまとめたもの。`none` は CI が走っていない PR。 */
2export type Check = 'pass' | 'fail' | 'pending' | 'none'
3
4export type Pr = {
5 number: number
6 title: string
7 head: string
8 base: string
9 author: string
10 isDraft: boolean
11 /** gh の reviewDecision(APPROVED / CHANGES_REQUESTED / REVIEW_REQUIRED / 空)。 */
12 review: string
13 url: string
14 check: Check
15}
16
17/**
18 * gh が失敗したわけ。画面がないときの /prs の返答で、次に何をすればよいかを添えるのに使う。
19 * `no-gh` は gh を起動できなかったとき、`timeout` は時間内に終わらなかったとき。
20 */
21export type Cause = 'no-gh' | 'timeout' | 'no-auth' | 'no-repo' | 'other'
22
23export type Board = {
24 prs: Pr[]
25 /** 取った時刻(エポックからのミリ秒)。 */
26 fetchedAt: number
27 /** gh が失敗したときの理由。 */
28 error?: string
29 /** gh が失敗したときの、理由の分類。 */
30 cause?: Cause
31}
32
33declare module 'claude-code' {
34 interface PluginState {
35 'pr-pane': { board: Board | null }
36 }
37}
38