セッション開始時とターン終了時に GitHub の PR / Issue 一覧を右ペインに表示する — follow if you like : https://x.com/hawkieslab

セッション開始時と毎ターン終了時に、GitHub リポジトリの open な PR / Issue を gh CLI で取得し、右ペインに表示する Claude Code Mod。
follow if you like : https://x.com/hawkieslab
/plugin marketplace add hawkymisc/claude-plugins
/plugin install gh-board@hawkymisc-claude-plugins
session.start と turn.complete で取得(取得中に再要求が来たら終了後に 1 回だけ取り直す)。ペイン右上の ↻ で手動更新。#番号 タイトル [draft] @author 💬コメント数。Conflict でマージできない PR (mergeable: CONFLICTING) は赤字。#番号 タイトル @author 💬コメント数。ラベルごとにグループ化(複数ラベルは各グループに表示、ラベルなしは最後)。見出しクリックで折りたたみ / 展開。Pull Requests / Issues セクション自体も折りたためる。← 一覧へ で戻る。GitHub で開く リンク付き。gh の失敗理由をペインに表示(内容が変わったときだけ transcript にもログ)。| コマンド | 動作 |
|---|---|
/gh-board | ペインを開いて更新 |
/gh-board owner/repo | 表示対象を切替(https://github.com/owner/repo も可) |
/gh-board a/b c/d . | 複数リポジトリを同時に表示(空白かカンマ区切り、最大 10 件。. はセッションの cwd のリポジトリ) |
/gh-board reset | セッションの cwd のリポジトリに戻す |
gh CLI がログイン済み(gh auth status)。PATH に無い場合は /opt/homebrew/bin/gh, /usr/local/bin/gh を順に試す。gh repo view が解決するリポジトリ(= セッション cwd の git remote)。--limit 50)。claude plugin validate .
claude plugin test .
開発中のセッションに読み込むには claude --plugin-dir <このフォルダ>、またはデスクトップアプリなら ~/.claude/settings.json の env.CLAUDE_CODE_PLUGIN_DIRS にこのフォルダを追加する。
hooks/register.tsx 319 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderChildren } from 'claude-code'
3
4import type { Board, Item, RepoBoard, View } from '../types'
5import { CWD, ISSUE_FIELDS, PR_FIELDS, clip, detailMarkdown, groupByLabel, hhmm, parseItems, parseRepoArgs } from './logic'
6
7const PANE = 'gh-board'
8const TITLE = 'GitHub'
9/** PATH に gh が無い起動経路 (デスクトップアプリ等) 向けの候補 */
10const GH_CANDIDATES = ['gh', '/opt/homebrew/bin/gh', '/usr/local/bin/gh']
11/** 表示する最大件数。LIMIT+1 件取って、超えていれば「50+」と出す */
12const LIMIT = 50
13
14const EMPTY: Board = { repos: [], fetchedAt: null, isLoading: false }
15const emptyRepo = (target: string, repo: string | null, error: string | null = null): RepoBoard => ({
16 target,
17 repo,
18 prs: [],
19 issues: [],
20 prsMore: false,
21 issuesMore: false,
22 error,
23})
24const board = atom({ plugin: 'gh-board', key: 'boards' } as const, EMPTY)
25const view = atom({ plugin: 'gh-board', key: 'view' } as const, { kind: 'list' } as View)
26const collapsed = atom({ plugin: 'gh-board', key: 'collapsed' } as const, {} as Record<string, boolean>)
27const repoOverride = atom({ plugin: 'gh-board', key: 'repos' } as const, null as string[] | null)
28
29type $ = EngineInterface
30
31// モジュール変数はリロードで消えてよいもの (gh の場所のキャッシュ・取得中フラグ) だけ
32let gh: string | null = null
33let inflight: Promise<void> | null = null
34let again = false
35/** リポジトリごとに直近ログした失敗理由 (同じ失敗を毎ターン出さない) */
36const lastLogged = new Map<string, string>()
37
38const describe = (err: unknown): string => {
39 if (!(err instanceof Error)) return String(err)
40 return err.cause === undefined || err.cause === null ? err.message : `${err.message} (cause: ${describe(err.cause)})`
41}
42
43/** gh を実行する。起動できなければ次の候補を試す (見つかった候補は覚える)。タイムアウトは候補を変えても無駄なのでそのまま失敗 */
44async function runGh($: $, args: string[]) {
45 const candidates = gh === null ? GH_CANDIDATES : [gh]
46 let lastErr: unknown = null
47 for (const bin of candidates) {
48 try {
49 const r = await $.process.run([bin, ...args], { timeoutMs: 20_000 })
50 gh = bin
51 return r
52 } catch (err) {
53 if (/time(d)? ?out/i.test(describe(err))) throw new Error(`${bin} ${args.slice(0, 2).join(' ')} がタイムアウト`, { cause: err })
54 lastErr = err // 次の候補を試す。全滅したら cause として下で投げる
55 }
56 }
57 throw new Error(`gh を起動できない (${candidates.join(', ')})`, { cause: lastErr })
58}
59
60async function ghOut($: $, args: string[]): Promise<string> {
61 const r = await runGh($, args)
62 if (r.exitCode !== 0) {
63 const msg = (r.stderr.trim() || r.stdout.trim() || `exit ${r.exitCode}`).split('\n')[0] ?? ''
64 throw new Error(`gh ${args.slice(0, 2).join(' ')}: ${msg}`)
65 }
66 return r.stdout
67}
68
69/** 1 リポジトリ分を取る。失敗は投げずに error として返す (同じリポジトリの前回データは残す) */
70async function fetchRepo($: $, target: string, prev: readonly RepoBoard[]): Promise<RepoBoard> {
71 let repo: string | null = target === CWD ? null : target
72 try {
73 if (repo === null) repo = (await ghOut($, ['repo', 'view', '--json', 'nameWithOwner', '-q', '.nameWithOwner'])).trim()
74 const name = repo
75 const list = (kind: 'pr' | 'issue', fields: string) =>
76 ghOut($, [kind, 'list', '--repo', name, '--state', 'open', '--limit', String(LIMIT + 1), '--json', fields])
77 const [prsJson, issuesJson] = await Promise.all([list('pr', PR_FIELDS), list('issue', ISSUE_FIELDS)])
78 const prs = parseItems(prsJson)
79 const issues = parseItems(issuesJson)
80 lastLogged.delete(target)
81 return {
82 target,
83 repo: name,
84 prs: prs.slice(0, LIMIT),
85 issues: issues.slice(0, LIMIT),
86 prsMore: prs.length > LIMIT,
87 issuesMore: issues.length > LIMIT,
88 error: null,
89 }
90 } catch (err) {
91 const reason = describe(err)
92 if (lastLogged.get(target) !== reason) {
93 lastLogged.set(target, reason)
94 $.ui.log(`gh-board: ${repo ?? 'cwd のリポジトリ'} の取得失敗: ${reason}`)
95 }
96 // 同じ指定の前回データは残す (cwd の解決自体に失敗しても前回の一覧は見せる)
97 const old = prev.find(p => p.target === target)
98 return old === undefined ? emptyRepo(target, repo, reason) : { ...old, error: reason }
99 }
100}
101
102async function fetchOnce($: $) {
103 try {
104 const prev = await read($, board)
105 await update($, board, b => ({ ...b, isLoading: true }))
106 const targets = (await read($, repoOverride)) ?? [CWD]
107 const repos = await Promise.all(targets.map(t => fetchRepo($, t, Array.isArray(prev.repos) ? prev.repos : [])))
108 const now = await $.clock.now()
109 await update($, board, () => ({ repos, fetchedAt: now, isLoading: false }))
110 } catch (err) {
111 // 状態の読み書き自体の失敗。ペインは前回の表示のまま
112 $.ui.log(`gh-board: 更新に失敗: ${describe(err)}`)
113 await update($, board, b => ({ ...b, isLoading: false })).catch(e => $.ui.log(`gh-board: isLoading を戻せない: ${describe(e)}`))
114 }
115}
116
117/** 取得中に呼ばれたら、終わった後にもう 1 回だけ取り直す */
118function refresh($: $): Promise<void> {
119 if (inflight !== null) {
120 again = true
121 return inflight
122 }
123 inflight = (async () => {
124 try {
125 do {
126 again = false
127 await fetchOnce($)
128 } while (again)
129 } finally {
130 inflight = null
131 }
132 })()
133 return inflight
134}
135
136/** 待たずに更新する (session.start / turn.complete / ↻ から) */
137function kick($: $, from: string) {
138 refresh($).catch(err => $.ui.log(`gh-board: ${from} からの更新に失敗: ${describe(err)}`))
139}
140
141function openPane($: $) {
142 $.ui.open({ id: PANE, title: TITLE }).catch(err => $.ui.log(`gh-board: ペインを開けない: ${describe(err)}`))
143}
144
145export const register: Register = on => {
146 on('session.start', async ($, e, next) => {
147 const r = await next(e)
148 try {
149 await $.command.register({
150 name: 'gh-board',
151 description: 'GitHub の PR / Issue ペインを開いて更新 (owner/repo を空白区切りで複数指定可、. は cwd、reset で cwd に戻す)',
152 argumentHint: '[owner/repo ... | reset]',
153 })
154 } catch (err) {
155 $.ui.log(`gh-board: /gh-board を登録できない: ${describe(err)}`)
156 }
157 // リロード時に取得中のまま残った isLoading を戻す (取得中フラグはモジュール変数なので消えている)
158 await update($, board, b => ({ ...b, isLoading: false }))
159 openPane($)
160 kick($, 'session.start')
161 return r
162 })
163
164 on('turn.complete', async ($, e, next) => {
165 const r = await next(e)
166 if (e.agentId === undefined) kick($, 'turn.complete') // サブエージェントのターンでは取らない
167 return r
168 })
169
170 // 返す text の先頭にはエンジンが「gh-board:」を付けるので、自前では付けない
171 on('command.run', { command: 'gh-board' }, async ($, e) => {
172 const arg = parseRepoArgs(e.args)
173 if (arg.kind === 'invalid') return { text: arg.reason }
174 if (arg.kind === 'set') await update($, repoOverride, () => arg.repos)
175 if (arg.kind === 'reset') await update($, repoOverride, () => null)
176 if (arg.kind !== 'open') await update($, view, () => ({ kind: 'list' }) as View)
177 openPane($)
178 // 進行中の更新 (別のフックが始めたもの) は終わるのを待ち、このコマンドの $ で取り直す
179 if (inflight !== null) await inflight.catch(err => $.ui.log(`gh-board: 進行中の更新が失敗: ${describe(err)}`))
180 await refresh($)
181 const b = await read($, board)
182 const n = (len: number, more: boolean) => `${len}${more ? '+' : ''}`
183 const lines = b.repos.map(r =>
184 r.error !== null
185 ? `${r.repo ?? 'cwd のリポジトリ'} — 取得失敗: ${r.error}`
186 : `${r.repo} — PR ${n(r.prs.length, r.prsMore)} 件 / Issue ${n(r.issues.length, r.issuesMore)} 件`,
187 )
188 return { text: lines.join('\n') }
189 })
190
191 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
192 const { Box, Text, Button, Markdown } = $.ui.resolve(e)
193 const b = await read($, board)
194 const v = await read($, view)
195 const folded = await read($, collapsed)
196 const width = Math.max(20, (e.props.bodyColumns ?? 40) - 2)
197
198 // リロード前の古い形の状態が残っていても落ちないように
199 const repos = Array.isArray(b.repos) ? b.repos : []
200 const multi = repos.length > 1
201 const nameOf = (r: RepoBoard) => r.repo ?? CWD
202 /** 複数リポジトリのときは key をリポジトリ名で名前空間分けする (同じ番号が衝突しないように) */
203 const k = (r: RepoBoard, rest: string) => (multi ? `${nameOf(r)}:${rest}` : rest)
204
205 const toggle = (key: string) => () => update($, collapsed, c => ({ ...c, [key]: !c[key] }))
206 const open = (kind: 'pr' | 'issue', repo: string | null, number: number) => () =>
207 update($, view, () => ({ kind, repo, number }) as View)
208 const back = () => update($, view, () => ({ kind: 'list' }) as View)
209
210 const status = b.isLoading ? '更新中…' : b.fetchedAt !== null ? `更新 ${hhmm(b.fetchedAt)}` : ''
211 const title = multi ? `${repos.length} リポジトリ` : (repos[0]?.repo ?? '(リポジトリ未取得)')
212 const single = !multi ? repos[0] : undefined
213 const header = (
214 <Box flexDirection="column">
215 <Box flexDirection="row" justifyContent="space-between">
216 <Text bold>{clip(title, width - 14)}</Text>
217 <Button key="refresh" plain dimColor onPress={() => kick($, '↻')}>
218 {`↻ ${status}`}
219 </Button>
220 </Box>
221 {single !== undefined && single.error !== null && <Text color="red">{`⚠ ${single.error}`}</Text>}
222 </Box>
223 )
224
225 if (v.kind !== 'list') {
226 const r = repos.find(x => x.repo === v.repo) ?? (multi ? undefined : repos[0])
227 const it = r === undefined ? undefined : (v.kind === 'pr' ? r.prs : r.issues).find(x => x.number === v.number)
228 return (
229 <Box flexDirection="column">
230 <Box flexDirection="row">
231 <Button key="back" onPress={back}>
232 ← 一覧へ
233 </Button>
234 </Box>
235 {it === undefined ? (
236 <Text dimColor>{`#${v.number} は open な一覧に見つからない (close 済み?)`}</Text>
237 ) : (
238 <Markdown text={detailMarkdown(v.kind, it, multi && r?.repo ? r.repo : undefined)} />
239 )}
240 </Box>
241 )
242 }
243
244 /** 階層の深さぶん Box の余白で字下げする (ボタン先頭の空白は描画側で詰められるため使わない) */
245 const INDENT = 2
246 const nest = (level: number, el: RenderChildren): RenderChildren =>
247 level === 0 ? el : <Box paddingLeft={level * INDENT}>{el}</Box>
248
249 const row = (r: RepoBoard, kind: 'pr' | 'issue', it: Item, level: number) => {
250 const num = `#${it.number} `
251 const talk = it.comments > 0 ? ` 💬${it.comments}` : ''
252 const tail = `${it.isDraft ? ' draft' : ''} @${it.author}${talk}`
253 const room = width - level * INDENT - num.length - tail.length - 1
254 // Conflict でマージできない PR は番号とタイトルを赤に
255 const conflict = kind === 'pr' && it.mergeable === 'CONFLICTING'
256 return nest(
257 level,
258 <Button key={k(r, `${kind}-${it.number}`)} plain onPress={open(kind, r.repo, it.number)}>
259 <Text color={conflict ? 'red' : 'cyan'}>{num}</Text>
260 {conflict ? <Text color="red">{clip(it.title, Math.max(8, room))}</Text> : clip(it.title, Math.max(8, room))}
261 <Text dimColor>{tail}</Text>
262 </Button>,
263 )
264 }
265 /** 折りたたみ見出し。key がそのまま折りたたみ状態の key */
266 const section = (key: string, label: string, note: string, level: number) =>
267 nest(
268 level,
269 <Button key={key} plain onPress={toggle(key)}>
270 {`${folded[key] ? '▶' : '▼'} `}
271 <Text bold>{label}</Text>
272 <Text dimColor>{note}</Text>
273 </Button>,
274 )
275 const none = (level: number) => nest(level, <Text dimColor>なし</Text>)
276 const count = (n: number, more: boolean) => ` (${n}${more ? '+' : ''})`
277
278 /** 1 リポジトリ分。base は字下げの起点 (複数リポジトリならリポジトリ見出しの 1 段下) */
279 const repoBody = (r: RepoBoard, base: number): RenderChildren[] => {
280 const prsKey = k(r, 'toggle:prs')
281 const issuesKey = k(r, 'toggle:issues')
282 return [
283 section(prsKey, 'Pull Requests', count(r.prs.length, r.prsMore), base),
284 ...(folded[prsKey] ? [] : r.prs.length === 0 ? [none(base + 1)] : r.prs.map(it => row(r, 'pr', it, base + 1))),
285 section(issuesKey, 'Issues', count(r.issues.length, r.issuesMore), base),
286 ...(folded[issuesKey]
287 ? []
288 : r.issues.length === 0
289 ? [none(base + 1)]
290 : groupByLabel(r.issues).flatMap(g => {
291 const labelKey = k(r, `toggle:label:${g.label}`)
292 return [
293 section(labelKey, g.label, count(g.items.length, false), base + 1),
294 ...(folded[labelKey] ? [] : g.items.map(it => row(r, 'issue', it, base + 2))),
295 ]
296 })),
297 ]
298 }
299
300 return (
301 <Box flexDirection="column">
302 {header}
303 {multi
304 ? repos.flatMap(r => {
305 const key = `toggle:repo:${nameOf(r)}`
306 const note = r.error !== null ? ' 取得失敗' : ` (PR ${r.prs.length}${r.prsMore ? '+' : ''} / Issue ${r.issues.length}${r.issuesMore ? '+' : ''})`
307 return [
308 section(key, nameOf(r), note, 0),
309 ...(folded[key]
310 ? []
311 : [...(r.error !== null ? [nest(1, <Text color="red">{`⚠ ${r.error}`}</Text>)] : []), ...repoBody(r, 1)]),
312 ]
313 })
314 : single !== undefined && repoBody(single, 0)}
315 </Box>
316 )
317 })
318}
319hooks/logic.ts 173 lines1import type { Item } from '../types'
2
3/** ラベルが 1 つも無い Issue のグループ名 */
4export const NO_LABEL = '(ラベルなし)'
5
6export const PR_FIELDS = 'number,title,url,author,body,labels,updatedAt,isDraft,headRefName,mergeable,comments'
7export const ISSUE_FIELDS = 'number,title,url,author,body,labels,updatedAt,comments'
8
9type Raw = {
10 number?: number
11 title?: string
12 url?: string
13 author?: { login?: string } | null
14 body?: string | null
15 labels?: { name?: string }[] | null
16 updatedAt?: string
17 isDraft?: boolean
18 headRefName?: string
19 mergeable?: string
20 comments?: { author?: { login?: string } | null; body?: string | null; createdAt?: string }[] | null
21}
22
23const MERGEABLE = ['MERGEABLE', 'CONFLICTING', 'UNKNOWN'] as const
24
25// 端末エスケープ (ESC/OSC/CSI 等の C0・C1 制御) と bidi 制御。GitHub 由来の文字列は信頼しない
26const CONTROLS = /[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/g
27const BODY_CONTROLS = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/g
28const clean = (s: string | null | undefined) => (s ?? '').replace(CONTROLS, '')
29
30/** `gh pr list --json` / `gh issue list --json` の出力を Item[] にする。形が違えば例外 (出力の中身はエラー文に含めない)。 */
31export function parseItems(json: string): Item[] {
32 const raw: unknown = JSON.parse(json)
33 if (!Array.isArray(raw)) throw new Error('gh の出力が配列ではない')
34 return (raw as Raw[]).map((r, i) => {
35 if (typeof r?.number !== 'number') throw new Error(`gh の出力の ${i} 番目に number が無い`)
36 const item: Item = {
37 number: r.number,
38 title: clean(r.title),
39 url: clean(r.url),
40 author: clean(r.author?.login),
41 body: (r.body ?? '').replace(BODY_CONTROLS, ''),
42 labels: (r.labels ?? []).map(l => clean(l.name)).filter(n => n !== ''),
43 updatedAt: clean(r.updatedAt),
44 comments: r.comments?.length ?? 0,
45 }
46 if (r.isDraft !== undefined) item.isDraft = r.isDraft
47 if (r.headRefName !== undefined) item.branch = clean(r.headRefName)
48 const m = MERGEABLE.find(v => v === r.mergeable)
49 if (m !== undefined) item.mergeable = m
50 const last = item.comments > 0 ? r.comments?.[item.comments - 1] : undefined
51 if (last !== undefined) {
52 item.lastComment = {
53 author: clean(last.author?.login),
54 body: (last.body ?? '').replace(BODY_CONTROLS, ''),
55 createdAt: clean(last.createdAt),
56 }
57 }
58 return item
59 })
60}
61
62export type LabelGroup = { label: string; items: Item[] }
63
64/** ラベル名順にグループ化。複数ラベルの Issue は各グループに出し、ラベルなしは最後。 */
65export function groupByLabel(items: readonly Item[]): LabelGroup[] {
66 const map = new Map<string, Item[]>()
67 for (const it of items) {
68 for (const label of it.labels.length === 0 ? [NO_LABEL] : it.labels) {
69 const list = map.get(label) ?? []
70 list.push(it)
71 map.set(label, list)
72 }
73 }
74 return [...map.entries()]
75 .sort(([a], [b]) => (a === NO_LABEL ? 1 : b === NO_LABEL ? -1 : a.localeCompare(b)))
76 .map(([label, list]) => ({ label, items: list }))
77}
78
79/** `/gh-board` の引数で「セッションの cwd のリポジトリ」を表す */
80export const CWD = '.'
81/** 同時に表示できるリポジトリ数 (1 リポジトリにつき gh を 2 回呼ぶ) */
82export const MAX_REPOS = 10
83
84export type RepoArgs =
85 | { kind: 'open' }
86 | { kind: 'reset' }
87 | { kind: 'set'; repos: string[] }
88 | { kind: 'invalid'; reason: string }
89
90// 各セグメントは先頭 '-' と '.' / '..' を除く (gh の引数として紛らわしいもの)
91const REPO = /^(?:https?:\/\/github\.com\/)?((?!-)(?!\.\.?\/)[\w.-]+)\/((?!-)(?!\.\.?(?:\.git)?\/?$)[\w.-]+?)(?:\.git)?\/?$/
92
93/**
94 * `/gh-board` の引数: 空=開くだけ、reset=cwd のリポジトリに戻す、
95 * 空白/カンマ区切りの owner/repo・GitHub URL・`.` (cwd) = 表示対象をその並びにする
96 */
97export function parseRepoArgs(args: string): RepoArgs {
98 const tokens = args.split(/[\s,]+/).filter(t => t !== '')
99 if (tokens.length === 0) return { kind: 'open' }
100 if (tokens.length === 1 && tokens[0] === 'reset') return { kind: 'reset' }
101 const repos: string[] = []
102 for (const t of tokens) {
103 const m = t === CWD ? null : REPO.exec(t)
104 if (t !== CWD && m === null) return { kind: 'invalid', reason: `owner/repo の形式ではない: ${t}` }
105 const repo = m === null ? CWD : `${m[1]}/${m[2]}`
106 if (!repos.includes(repo)) repos.push(repo)
107 }
108 if (repos.length > MAX_REPOS) return { kind: 'invalid', reason: `リポジトリは ${MAX_REPOS} 件まで (${repos.length} 件指定)` }
109 return { kind: 'set', repos }
110}
111
112function charWidth(ch: string): number {
113 const c = ch.codePointAt(0) ?? 0
114 return c >= 0x1100 && (c <= 0x115f || (c >= 0x2e80 && c <= 0xa4cf) || (c >= 0xac00 && c <= 0xd7a3) || (c >= 0xf900 && c <= 0xfaff) || (c >= 0xfe30 && c <= 0xfe4f) || (c >= 0xff00 && c <= 0xff60) || (c >= 0xffe0 && c <= 0xffe6) || c >= 0x1f300)
115 ? 2
116 : 1
117}
118
119/** 表示幅 (全角=2) が width を超えるなら切って … を付ける */
120export function clip(text: string, width: number): string {
121 let w = 0
122 let out = ''
123 const chars = [...text]
124 const total = chars.reduce((n, ch) => n + charWidth(ch), 0)
125 if (total <= width) return text
126 for (const ch of chars) {
127 const cw = charWidth(ch)
128 if (w + cw > width - 1) break
129 out += ch
130 w += cw
131 }
132 return `${out}…`
133}
134
135const code = (s: string) => `\`${s.replace(/`/g, "'")}\``
136const escapeMd = (s: string) => s.replace(/[\\`*_[\]<>#|]/g, c => `\\${c}`)
137
138/** 詳細ビューの Markdown。メタ情報は書式を抜けられないようにし、リンクは github.com のみ */
139export function detailMarkdown(kind: 'pr' | 'issue', it: Item, repo?: string): string {
140 const meta = [
141 repo === undefined ? null : `**${escapeMd(repo)}**`,
142 kind === 'pr' && it.branch ? code(it.branch) : null,
143 it.author ? `@${escapeMd(it.author)}` : null,
144 it.isDraft ? 'Draft' : null,
145 it.mergeable === 'CONFLICTING' ? '**⚠ Conflict あり (マージ不可)**' : null,
146 `💬 ${it.comments}`,
147 it.labels.length > 0 ? it.labels.map(code).join(' ') : null,
148 ].filter((x): x is string => x !== null)
149 return [
150 `### ${kind === 'pr' ? 'PR' : 'Issue'} #${it.number} ${escapeMd(it.title)}`,
151 meta.join(' · '),
152 /^https:\/\/github\.com\//.test(it.url) ? `[GitHub で開く](${it.url})` : '',
153 '---',
154 it.body.trim() === '' ? '_(説明なし)_' : it.body,
155 ...(it.lastComment === undefined
156 ? []
157 : [
158 '---',
159 `#### 最新のコメント (${it.comments} 件中)`,
160 `@${escapeMd(it.lastComment.author)} · ${it.lastComment.createdAt.slice(0, 16).replace('T', ' ')}`,
161 it.lastComment.body.trim() === '' ? '_(本文なし)_' : it.lastComment.body,
162 ]),
163 ]
164 .filter(s => s !== '')
165 .join('\n\n')
166}
167
168/** epoch ms → HH:MM (ローカル時刻) */
169export function hhmm(ms: number): string {
170 const d = new Date(ms)
171 return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
172}
173types/index.d.ts 60 lines1export type Comment = { author: string; body: string; createdAt: string }
2
3export type Item = {
4 number: number
5 title: string
6 url: string
7 author: string
8 body: string
9 labels: string[]
10 updatedAt: string
11 /** PR のみ */
12 isDraft?: boolean
13 /** PR のみ: head ブランチ名 */
14 branch?: string
15 /** PR のみ: GitHub の mergeable。CONFLICTING ならマージできない。UNKNOWN は GitHub が計算中 */
16 mergeable?: 'MERGEABLE' | 'CONFLICTING' | 'UNKNOWN'
17 /** 会話コメントの件数 (PR のレビューコメントは含まない) */
18 comments: number
19 /** 最新 (最後) の会話コメント */
20 lastComment?: Comment
21}
22
23/** 1 リポジトリ分の一覧。取得はリポジトリごとに独立 */
24export type RepoBoard = {
25 /** 指定値 (owner/repo か '.' = cwd)。前回データとの照合に使う */
26 target: string
27 /** owner/repo。cwd のリポジトリを解決できなかったときは null */
28 repo: string | null
29 prs: Item[]
30 issues: Item[]
31 /** 上限 (50 件) を超えて存在する */
32 prsMore: boolean
33 issuesMore: boolean
34 /** 直近の取得失敗の理由。成功で null に戻る */
35 error: string | null
36}
37
38export type Board = {
39 /** 表示順どおりのリポジトリ */
40 repos: RepoBoard[]
41 /** 最後に取得を終えた時刻 (epoch ms) */
42 fetchedAt: number | null
43 isLoading: boolean
44}
45
46export type View = { kind: 'list' } | { kind: 'pr' | 'issue'; repo: string | null; number: number }
47
48declare module 'claude-code' {
49 interface PluginState {
50 'gh-board': {
51 boards: Board
52 view: View
53 /** 折りたたみ中のセクション (key: 見出しボタンの key) */
54 collapsed: Record<string, boolean>
55 /** /gh-board で指定した表示対象 (owner/repo、'.' は cwd)。null ならセッションの cwd のリポジトリだけ */
56 repos: string[] | null
57 }
58 }
59}
60