SLOPSHOPPER

gh-board

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

panecommandprocess
v0.3.1no licenseupdated 2026-10-15hawkymisc/claude-plugins/plugins/gh-board
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · gh-board
│ ┃ GitHub ✕ › fix the failing auth test and add an audit log call │ ┃ ↻ 更新 08:53 │ ┃ ⚠ JSON Parse error: Unexpected EOF ● gh-board: gh-board: の取得失敗: JSON Parse error: Unexpected EOF │ ┃ ▼ ⏺ Read(src/auth.ts) │ ┃ なし ⎿ Read 6 lines │ ┃ ▼ ⏺ Update(src/auth.ts) │ ┃ なし ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /gh-board │ ⎿ gh-board: — 取得失敗: JSON Parse error: Unexpected EOF │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · GitHub
↻ 更新 08:53 ⚠ JSON Parse error: Unexpected EOF ▼ なし ▼ なし
README

gh-board

セッション開始時と毎ターン終了時に、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 回だけ取り直す)。ペイン右上の ↻ で手動更新。
  • PR 一覧: #番号 タイトル [draft] @author 💬コメント数。Conflict でマージできない PR (mergeable: CONFLICTING) は赤字。
  • Issue 一覧: #番号 タイトル @author 💬コメント数。ラベルごとにグループ化(複数ラベルは各グループに表示、ラベルなしは最後)。見出しクリックで折りたたみ / 展開。Pull Requests / Issues セクション自体も折りたためる。
  • 詳細表示: PR / Issue をクリックすると Description を Markdown でレンダリングし、その下に最新のコメント (会話コメントのみ。PR のレビューコメントは含まない) を表示。← 一覧へ で戻る。GitHub で開く リンク付き。
  • 複数リポジトリ: 複数指定するとリポジトリ名が最上位の折りたたみ見出しになり、その下に PR / Issues を並べる。取得はリポジトリごとに独立で、失敗したリポジトリだけ見出しの下に理由を出す。
  • エラー表示: リモート無し・未ログイン等の 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)。
  • 各 50 件まで(--limit 50)。

開発

claude plugin validate .
claude plugin test .

開発中のセッションに読み込むには claude --plugin-dir <このフォルダ>、またはデスクトップアプリなら ~/.claude/settings.json の env.CLAUDE_CODE_PLUGIN_DIRS にこのフォルダを追加する。

Source 3 files
hooks/register.tsx 319 lines
1import { 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}
319
hooks/logic.ts 173 lines
1import 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}
173
types/index.d.ts 60 lines
1export 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