專案進度面板:從 GitHub milestone/issue/PR 與 git log 自動彙整目前專案的開發進度與里程碑。

A Claude Code mod that opens a project progress pane beside your conversation, built automatically from your repo's GitHub data and git history. Nothing to type in by hand.
Works in the terminal and in the Claude Desktop app's Code tab. UI text is in Traditional Chinese (繁體中文); see 中文說明 below.
gh installed and logged in (gh auth login), with access to the repo you are working ingitclaude plugin marketplace add RexWei1016/claude-project-progress
claude plugin install project-progress@claude-project-progress
In an already-running session run /reload-plugins, then:
/project-progress
In the Desktop app the command does not appear in the slash-command typeahead; type the full name and press Enter.
To try the source without installing:
git clone https://github.com/RexWei1016/claude-project-progress
claude --plugin-dir ./claude-project-progress
| Key / control | Action |
|---|---|
r or 重新整理 | Refresh now |
a or 全部 / 精簡 | Show all rows / show the first 6 of each list |
1 – 5 or click a section title | Collapse / expand that section |
| Esc | Close the pane (/project-progress reopens it) |
Keys reach the pane while it has focus (click it, or ctrl+x tab in the terminal). While the pane is open it refreshes every 10 minutes.
gh repo view, gh api .../milestones, gh issue list, gh pr list, git rev-parse, git status --porcelain and git log, all run through the mod API's $.process.run in the session's working directory.gh talking to GitHub as you.claude plugin validate .
Edit hooks/register.tsx; a session started with --plugin-dir hot-reloads on save. Type-check against the declarations the engine lays in .claude-plugin/types/ after the first load with tsc -p ..
這是一個 Claude Code mod,在對話旁開一個「專案進度」面板,內容從目前 repo 的 GitHub 資料與 git 歷史自動產生:
需要先安裝並登入 gh,工作目錄必須是有 GitHub remote 的 git repo。安裝指令同上;安裝後在 session 裡輸入 /project-progress。按 r 重新整理、a 切換全部/精簡、1 到 5 收合或展開各區塊、Esc 關閉面板。
MIT
hooks/register.tsx 596 lines1import type { EngineInterface, Register } from 'claude-code'
2
3import type { Commit, Issue, Label, Milestone, Pr, SectionId, Snapshot } from '../types'
4
5const PANE = 'project-progress'
6const COMMAND = 'project-progress'
7const TITLE = '專案進度'
8const AUTO_REFRESH_MS = 10 * 60 * 1000
9const COLLAPSED_ROWS = 6
10
11const BAR_DONE = '#3fb950'
12const BAR_LEFT = '#6e7681'
13
14const EMPTY: Snapshot = {
15 commits: [],
16 milestones: [],
17 issues: [],
18 prs: [],
19 merged: [],
20 isLoading: false,
21}
22
23const SECTIONS: Array<{ id: SectionId; title: string; hotkey: string }> = [
24 { id: 'milestones', title: '里程碑', hotkey: '1' },
25 { id: 'issues', title: '未排入里程碑的 Issue', hotkey: '2' },
26 { id: 'prs', title: '進行中的 PR', hotkey: '3' },
27 { id: 'merged', title: '最近合併', hotkey: '4' },
28 { id: 'commits', title: '最近提交', hotkey: '5' },
29]
30
31// 模組變數:熱載入時會重設,但 session.start 會再從 $.store 讀回上次的快照。
32let snapshot: Snapshot = EMPTY
33let showAll = false
34let collapsed: Partial<Record<SectionId, boolean>> = { merged: true, commits: true }
35let storeKey = ''
36let isRefreshing = false
37
38export const register: Register = on => {
39 on('session.start', async ($, e, next) => {
40 storeKey = 'snapshot:' + encodeURIComponent(e.cwd)
41 await $.command.register({
42 name: COMMAND,
43 description: '開啟專案進度面板:GitHub 里程碑、Issue、PR 與最近提交',
44 immediate: true,
45 })
46
47 try {
48 const cached = await $.store.get(storeKey)
49 if (isSnapshot(cached)) snapshot = { ...cached, isLoading: false }
50 } catch {
51 // 讀不到快取就從空白開始
52 }
53
54 $.clock.every(AUTO_REFRESH_MS, () => {
55 void (async () => {
56 const panes = await $.ui.panes()
57 if (panes.some(p => p.id === PANE && p.isPlaced)) await refresh($)
58 })()
59 })
60
61 return next(e)
62 })
63
64 on('command.run', { command: COMMAND }, async $ => {
65 const opened = await $.ui.open({ id: PANE, title: TITLE })
66 void refresh($)
67 return {
68 text: opened.isPlaced
69 ? `${TITLE}面板已開啟,正在更新資料。`
70 : `${TITLE}面板已排入,但目前視窗太窄無法顯示,加寬後會自動出現。`,
71 }
72 })
73
74 on('ui.render', { component: 'Pane', requestId: PANE }, ($, e) => {
75 const { Box, Text, Button, Link } = $.ui.resolve(e)
76 const s = snapshot
77 const cols = Math.max(28, e.props.bodyColumns ?? e.viewport?.columns ?? 60)
78 const inner = cols - 4 // 卡片框線與左右內距
79 const limit = showAll ? Number.MAX_SAFE_INTEGER : COLLAPSED_ROWS
80 const redraw = () => $.ui.invalidate('ui.render')
81
82 const openMilestones = s.milestones.filter(m => m.state === 'open')
83 const closedMilestones = s.milestones.filter(m => m.state === 'closed')
84 const unassigned = s.issues.filter(i => !i.milestone)
85 const counts: Record<SectionId, number> = {
86 milestones: openMilestones.length,
87 issues: unassigned.length,
88 prs: s.prs.length,
89 merged: s.merged.length,
90 commits: s.commits.length,
91 }
92
93 const dim = (text: string) => <Text dimColor>{text}</Text>
94 const chip = (text: string, color: string) => (
95 <Text color={color} bold>
96 {text}
97 </Text>
98 )
99 const empty = (text: string) => (
100 <Box paddingLeft={2}>
101 <Text dimColor italic>
102 {text}
103 </Text>
104 </Box>
105 )
106 const moreRow = (hidden: number) =>
107 hidden > 0 ? (
108 <Box paddingLeft={2}>
109 <Button key={`more-${hidden}`} plain dimColor label={`… 還有 ${hidden} 筆,按 a 顯示全部`} onPress={() => { showAll = true; redraw() }} />
110 </Box>
111 ) : null
112
113 const sectionHeader = (sec: (typeof SECTIONS)[number]) => {
114 const isCollapsed = collapsed[sec.id] === true
115 return (
116 <Box marginTop={1} flexDirection="row" gap={1}>
117 <Button
118 key={`sec-${sec.id}`}
119 plain
120 hotkey={sec.hotkey}
121 onPress={() => {
122 collapsed = { ...collapsed, [sec.id]: !isCollapsed }
123 redraw()
124 }}
125 >
126 <Text bold color="claude">
127 {isCollapsed ? '▸ ' : '▾ '}
128 {sec.title}
129 </Text>
130 <Text dimColor> {counts[sec.id]}</Text>
131 </Button>
132 </Box>
133 )
134 }
135
136 const numberLink = (n: number, url: string | undefined, color: string) =>
137 url ? (
138 <Link href={url}>
139 <Text color={color} bold>{`#${n}`}</Text>
140 </Link>
141 ) : (
142 <Text color={color} bold>{`#${n}`}</Text>
143 )
144
145 const issueRow = (i: Issue, indent: number) => {
146 const chips = i.labels.slice(0, 3)
147 const chipCells = chips.reduce((n, l) => n + cells(l.name) + 3, 0)
148 const room = Math.max(8, inner - indent - 7 - chipCells)
149 return (
150 <Box key={`issue-${i.number}`} paddingLeft={indent} flexDirection="row">
151 {numberLink(i.number, i.url, 'suggestion')}
152 <Text hover={{ bold: true, color: 'claude' }}>{` ${fit(i.title, room)}`}</Text>
153 {chips.map(l => (
154 <Text color={labelColor(l)}>{` [${l.name}]`}</Text>
155 ))}
156 </Box>
157 )
158 }
159
160 const prRow = (p: Pr) => {
161 const tag = reviewTag(p.review)
162 const extra = (p.isDraft ? 5 : 0) + (tag ? cells(tag.text) + 1 : 0)
163 const room = Math.max(8, inner - 2 - 7 - extra)
164 return (
165 <Box key={`pr-${p.number}`} paddingLeft={2} flexDirection="row">
166 {numberLink(p.number, p.url, 'permission')}
167 <Text dimColor={p.isDraft} hover={{ bold: true, color: 'claude' }}>{` ${fit(p.title, room)}`}</Text>
168 {p.isDraft && dim(' 草稿')}
169 {tag && <Text color={tag.color}>{` ${tag.text}`}</Text>}
170 </Box>
171 )
172 }
173
174 const mergedRow = (p: Pr) => (
175 <Box key={`merged-${p.number}`} paddingLeft={2} flexDirection="row">
176 {dim(p.mergedAt ? day(p.mergedAt) : ' ')}
177 <Text> </Text>
178 {numberLink(p.number, p.url, 'merged')}
179 <Text dimColor>{` ${fit(p.title, Math.max(8, inner - 2 - 6 - 7))}`}</Text>
180 </Box>
181 )
182
183 const commitRow = (c: Commit) => {
184 const kind = commitKind(c.subject)
185 return (
186 <Box key={`commit-${c.sha}`} paddingLeft={2} flexDirection="row">
187 <Text color="inactive">{c.sha}</Text>
188 {kind && <Text color={kind.color}>{` ${kind.text}`}</Text>}
189 <Text dimColor>{` ${fit(kind ? c.subject.slice(kind.raw.length) : c.subject, Math.max(8, inner - 2 - 9 - (kind ? cells(kind.text) + 1 : 0)))}`}</Text>
190 </Box>
191 )
192 }
193
194 const milestoneCard = (m: Milestone) => {
195 const total = m.open + m.closed
196 const pct = total === 0 ? 0 : Math.round((m.closed / total) * 100)
197 const mine = s.issues.filter(i => i.milestone === m.title)
198 const shown = mine.slice(0, limit)
199 const due = m.dueOn ? dueState(m.dueOn) : undefined
200 const barWidth = Math.max(10, Math.min(30, inner - 22))
201 const filled = Math.round((pct / 100) * barWidth)
202 return (
203 <Box
204 key={`ms-${m.number}`}
205 flexDirection="column"
206 marginTop={1}
207 paddingX={1}
208 borderStyle="round"
209 borderColor={pct >= 100 ? 'success' : 'claude'}
210 borderDimColor={pct < 100}
211 >
212 <Box flexDirection="row" justifyContent="space-between">
213 <Box flexDirection="row">
214 {m.url ? (
215 <Link href={m.url}>
216 <Text bold>{fit(m.title, Math.max(8, inner - 16))}</Text>
217 </Link>
218 ) : (
219 <Text bold>{fit(m.title, Math.max(8, inner - 16))}</Text>
220 )}
221 </Box>
222 {due && <Text color={due.color}>{due.text}</Text>}
223 </Box>
224 <Box flexDirection="row" marginTop={1}>
225 <Text backgroundColor={BAR_DONE}>{' '.repeat(filled)}</Text>
226 <Text backgroundColor={BAR_LEFT}>{' '.repeat(barWidth - filled)}</Text>
227 <Text bold color={pctColor(pct)}>{` ${pct}%`}</Text>
228 <Text dimColor>{` ${m.closed}/${total} 完成`}</Text>
229 </Box>
230 {m.description && (
231 <Box marginTop={1}>
232 <Text dimColor italic wrap="truncate-end">
233 {m.description}
234 </Text>
235 </Box>
236 )}
237 {mine.length > 0 && <Box marginTop={1} />}
238 {shown.map(i => issueRow(i, 0))}
239 {moreRow(mine.length - shown.length)}
240 </Box>
241 )
242 }
243
244 return (
245 <Box flexDirection="column">
246 {/* 工具列 */}
247 <Box flexDirection="row" gap={1}>
248 <Button
249 key="refresh"
250 variant="primary"
251 label={s.isLoading ? '⟳ 更新中…' : '重新整理'}
252 hotkey="r"
253 onPress={() => void refresh($)}
254 />
255 <Button key="toggle" label={showAll ? '精簡' : '全部'} hotkey="a" onPress={() => { showAll = !showAll; redraw() }} />
256 <Text dimColor>{s.fetchedAt ? `更新於 ${clock(s.fetchedAt)}` : '尚未更新'}</Text>
257 </Box>
258
259 {/* 專案卡 */}
260 <Box flexDirection="column" marginTop={1} paddingX={1} borderStyle="round" borderColor="claude">
261 <Box flexDirection="row" justifyContent="space-between">
262 <Text bold>{s.repo ? fit(s.repo.name, Math.max(8, inner - 10)) : '(尚未取得 GitHub 資料)'}</Text>
263 {s.repo && (
264 <Link href={s.repo.url}>
265 <Text color="suggestion">GitHub ↗</Text>
266 </Link>
267 )}
268 </Box>
269 <Box flexDirection="row" gap={2}>
270 {s.branch && (
271 <Text>
272 <Text dimColor>分支 </Text>
273 <Text color="permission">{s.branch}</Text>
274 </Text>
275 )}
276 {s.dirty !== undefined &&
277 (s.dirty > 0 ? chip(`● ${s.dirty} 個檔案未提交`, 'warning') : chip('● 工作區乾淨', 'success'))}
278 </Box>
279 <Box flexDirection="row" gap={2} marginTop={1}>
280 <Text>
281 <Text dimColor>里程碑 </Text>
282 <Text bold>{String(counts.milestones)}</Text>
283 </Text>
284 <Text>
285 <Text dimColor>Issue </Text>
286 <Text bold>{String(s.issues.length)}</Text>
287 </Text>
288 <Text>
289 <Text dimColor>PR </Text>
290 <Text bold>{String(counts.prs)}</Text>
291 </Text>
292 </Box>
293 </Box>
294
295 {s.error && (
296 <Box marginTop={1} paddingX={1} borderStyle="round" borderColor="error">
297 <Text color="error" wrap="wrap">
298 {s.error}
299 </Text>
300 </Box>
301 )}
302
303 {/* 里程碑 */}
304 {sectionHeader(SECTIONS[0]!)}
305 {!collapsed.milestones && openMilestones.length === 0 && empty('沒有進行中的里程碑')}
306 {!collapsed.milestones && openMilestones.map(milestoneCard)}
307 {!collapsed.milestones && closedMilestones.length > 0 && (
308 <Box paddingLeft={2} marginTop={1}>
309 <Text dimColor wrap="truncate-end">{`已結束:${closedMilestones.map(m => m.title).join('、')}`}</Text>
310 </Box>
311 )}
312
313 {/* 未排入里程碑的 Issue */}
314 {sectionHeader(SECTIONS[1]!)}
315 {!collapsed.issues && unassigned.length === 0 && empty('所有開啟中的 Issue 都已排入里程碑')}
316 {!collapsed.issues && unassigned.slice(0, limit).map(i => issueRow(i, 2))}
317 {!collapsed.issues && moreRow(unassigned.length - Math.min(unassigned.length, limit))}
318
319 {/* PR */}
320 {sectionHeader(SECTIONS[2]!)}
321 {!collapsed.prs && s.prs.length === 0 && empty('沒有開啟中的 PR')}
322 {!collapsed.prs && s.prs.map(prRow)}
323
324 {/* 最近合併 */}
325 {sectionHeader(SECTIONS[3]!)}
326 {!collapsed.merged && s.merged.length === 0 && empty('沒有合併紀錄')}
327 {!collapsed.merged && s.merged.slice(0, limit).map(mergedRow)}
328 {!collapsed.merged && moreRow(s.merged.length - Math.min(s.merged.length, limit))}
329
330 {/* 最近提交 */}
331 {sectionHeader(SECTIONS[4]!)}
332 {!collapsed.commits && s.commits.length === 0 && empty('沒有提交紀錄')}
333 {!collapsed.commits && s.commits.slice(0, limit).map(commitRow)}
334 {!collapsed.commits && moreRow(s.commits.length - Math.min(s.commits.length, limit))}
335
336 <Box marginTop={1}>
337 <Text dimColor>r 重新整理 · a 全部/精簡 · 1-5 展開/收合 · 面板開著時每 10 分鐘自動更新</Text>
338 </Box>
339 </Box>
340 )
341 })
342}
343
344async function refresh($: EngineInterface): Promise<void> {
345 if (isRefreshing) return
346 isRefreshing = true
347 try {
348 snapshot = { ...snapshot, isLoading: true, error: undefined }
349 $.ui.invalidate('ui.render')
350
351 const next: Snapshot = { ...EMPTY }
352 const errors: string[] = []
353
354 try {
355 next.branch = (await run($, ['git', 'rev-parse', '--abbrev-ref', 'HEAD'])).trim()
356 const status = await run($, ['git', 'status', '--porcelain'])
357 next.dirty = status.split('\n').filter(l => l.trim()).length
358 const log = await run($, ['git', 'log', '--oneline', '-10', '--no-decorate'])
359 next.commits = parseLog(log)
360 } catch (err) {
361 errors.push(`git:${message(err)}`)
362 }
363
364 try {
365 const repo = JSON.parse(await run($, ['gh', 'repo', 'view', '--json', 'nameWithOwner,url'])) as {
366 nameWithOwner: string
367 url: string
368 }
369 next.repo = { name: repo.nameWithOwner, url: repo.url }
370 const [ms, issues, prs, merged] = await Promise.all([
371 run($, ['gh', 'api', `repos/${repo.nameWithOwner}/milestones?state=all&per_page=100`]),
372 run($, [
373 'gh', 'issue', 'list', '--state', 'open', '--limit', '200',
374 '--json', 'number,title,milestone,labels,updatedAt,url',
375 ]),
376 run($, [
377 'gh', 'pr', 'list', '--state', 'open', '--limit', '50',
378 '--json', 'number,title,headRefName,isDraft,reviewDecision,url',
379 ]),
380 run($, ['gh', 'pr', 'list', '--state', 'merged', '--limit', '10', '--json', 'number,title,mergedAt,url']),
381 ])
382 next.milestones = parseMilestones(ms)
383 next.issues = parseIssues(issues)
384 next.prs = parsePrs(prs)
385 next.merged = parsePrs(merged)
386 } catch (err) {
387 errors.push(`GitHub:${message(err)}`)
388 }
389
390 next.fetchedAt = await $.clock.now()
391 next.error = errors.length > 0 ? errors.join(';') : undefined
392 next.isLoading = false
393 snapshot = next
394 $.ui.invalidate('ui.render')
395
396 if (storeKey) {
397 try {
398 await $.store.set(storeKey, next)
399 } catch {
400 // 快取寫不進去不影響顯示
401 }
402 }
403 } catch (err) {
404 snapshot = { ...snapshot, isLoading: false, error: `更新失敗:${message(err)}` }
405 $.ui.invalidate('ui.render')
406 } finally {
407 isRefreshing = false
408 }
409}
410
411async function run($: EngineInterface, argv: readonly string[]): Promise<string> {
412 const r = await $.process.run(argv, { timeoutMs: 60_000 })
413 if (r.exitCode !== 0) {
414 const first = (r.stderr || r.stdout).trim().split('\n')[0] ?? ''
415 throw new Error(first || `${argv.join(' ')} 結束碼 ${r.exitCode}`)
416 }
417 return r.stdout
418}
419
420function parseLog(text: string): Commit[] {
421 return text
422 .split('\n')
423 .filter(l => l.trim())
424 .map(l => {
425 const i = l.indexOf(' ')
426 return i < 0 ? { sha: l, subject: '' } : { sha: l.slice(0, i), subject: l.slice(i + 1) }
427 })
428}
429
430function parseMilestones(text: string): Milestone[] {
431 const raw = JSON.parse(text) as Array<Record<string, unknown>>
432 return raw
433 .map(m => ({
434 number: Number(m.number),
435 title: String(m.title ?? ''),
436 state: m.state === 'closed' ? ('closed' as const) : ('open' as const),
437 open: Number(m.open_issues ?? 0),
438 closed: Number(m.closed_issues ?? 0),
439 dueOn: typeof m.due_on === 'string' ? m.due_on : undefined,
440 description:
441 typeof m.description === 'string' && m.description.trim() ? m.description.trim() : undefined,
442 url: typeof m.html_url === 'string' ? m.html_url : undefined,
443 }))
444 .sort((a, b) => (a.dueOn ?? '9').localeCompare(b.dueOn ?? '9') || a.number - b.number)
445}
446
447function parseIssues(text: string): Issue[] {
448 const raw = JSON.parse(text) as Array<Record<string, unknown>>
449 return raw.map(i => {
450 const ms = i.milestone as { title?: string } | null | undefined
451 const labels: Label[] = Array.isArray(i.labels)
452 ? (i.labels as Array<{ name?: string; color?: string }>).map(l => ({
453 name: String(l.name ?? ''),
454 color: typeof l.color === 'string' && /^[0-9a-fA-F]{6}$/.test(l.color) ? l.color : undefined,
455 }))
456 : []
457 return {
458 number: Number(i.number),
459 title: String(i.title ?? ''),
460 milestone: ms && typeof ms.title === 'string' ? ms.title : undefined,
461 labels,
462 updatedAt: String(i.updatedAt ?? ''),
463 url: typeof i.url === 'string' ? i.url : undefined,
464 }
465 })
466}
467
468function parsePrs(text: string): Pr[] {
469 const raw = JSON.parse(text) as Array<Record<string, unknown>>
470 return raw.map(p => ({
471 number: Number(p.number),
472 title: String(p.title ?? ''),
473 branch: typeof p.headRefName === 'string' ? p.headRefName : undefined,
474 isDraft: p.isDraft === true,
475 review: typeof p.reviewDecision === 'string' ? p.reviewDecision : undefined,
476 mergedAt: typeof p.mergedAt === 'string' ? p.mergedAt : undefined,
477 url: typeof p.url === 'string' ? p.url : undefined,
478 }))
479}
480
481function reviewTag(review: string | undefined): { text: string; color: string } | undefined {
482 switch (review) {
483 case 'APPROVED':
484 return { text: '✓ 已核准', color: 'success' }
485 case 'CHANGES_REQUESTED':
486 return { text: '✗ 要求修改', color: 'error' }
487 case 'REVIEW_REQUIRED':
488 return { text: '◌ 待審', color: 'warning' }
489 default:
490 return undefined
491 }
492}
493
494function commitKind(subject: string): { raw: string; text: string; color: string } | undefined {
495 const m = /^(\w+)(\([^)]*\))?(!)?:/.exec(subject)
496 if (!m) return undefined
497 const type = m[1]!.toLowerCase()
498 const colors: Record<string, string> = {
499 feat: 'success',
500 fix: 'error',
501 docs: 'suggestion',
502 refactor: 'warning',
503 perf: 'warning',
504 test: 'permission',
505 chore: 'inactive',
506 build: 'inactive',
507 ci: 'inactive',
508 style: 'inactive',
509 infra: 'inactive',
510 }
511 return { raw: m[0], text: m[0], color: colors[type] ?? 'text' }
512}
513
514function labelColor(l: Label): string {
515 return l.color ? `#${l.color}` : 'inactive'
516}
517
518function pctColor(pct: number): string {
519 if (pct >= 100) return 'success'
520 if (pct >= 50) return 'claude'
521 if (pct > 0) return 'warning'
522 return 'inactive'
523}
524
525function dueState(iso: string): { text: string; color: string } {
526 const due = new Date(iso)
527 if (Number.isNaN(due.getTime())) return { text: iso.slice(0, 10), color: 'inactive' }
528 const days = Math.ceil((due.getTime() - Date.now()) / 86_400_000)
529 if (days < 0) return { text: `逾期 ${-days} 天`, color: 'error' }
530 if (days <= 7) return { text: `${days} 天後到期`, color: 'warning' }
531 return { text: `到期 ${day(iso)}`, color: 'inactive' }
532}
533
534/** 終端機裡 CJK 佔兩格;用來估算一行能放多少字。 */
535function cells(text: string): number {
536 let n = 0
537 for (const ch of text) {
538 const c = ch.codePointAt(0) ?? 0
539 n +=
540 (c >= 0x1100 && c <= 0x115f) ||
541 (c >= 0x2e80 && c <= 0xa4cf) ||
542 (c >= 0xac00 && c <= 0xd7a3) ||
543 (c >= 0xf900 && c <= 0xfaff) ||
544 (c >= 0xfe30 && c <= 0xfe4f) ||
545 (c >= 0xff00 && c <= 0xff60) ||
546 (c >= 0xffe0 && c <= 0xffe6) ||
547 (c >= 0x20000 && c <= 0x3fffd)
548 ? 2
549 : 1
550 }
551 return n
552}
553
554function fit(text: string, maxCells: number): string {
555 if (cells(text) <= maxCells) return text
556 let out = ''
557 let used = 0
558 for (const ch of text) {
559 const w = cells(ch)
560 if (used + w > maxCells - 1) break
561 out += ch
562 used += w
563 }
564 return out + '…'
565}
566
567
568function pad(n: number): string {
569 return n < 10 ? `0${n}` : String(n)
570}
571
572function clock(ms: number): string {
573 const d = new Date(ms)
574 return `${pad(d.getHours())}:${pad(d.getMinutes())}`
575}
576
577function day(iso: string): string {
578 const d = new Date(iso)
579 if (Number.isNaN(d.getTime())) return iso.slice(0, 10)
580 return `${pad(d.getMonth() + 1)}/${pad(d.getDate())}`
581}
582
583function message(err: unknown): string {
584 return err instanceof Error ? err.message : String(err)
585}
586
587function isSnapshot(value: unknown): value is Snapshot {
588 return (
589 typeof value === 'object' &&
590 value !== null &&
591 Array.isArray((value as Snapshot).milestones) &&
592 Array.isArray((value as Snapshot).issues)
593 )
594}
595
596types/index.d.ts 50 lines1export type Milestone = {
2 number: number
3 title: string
4 state: 'open' | 'closed'
5 open: number
6 closed: number
7 dueOn?: string
8 description?: string
9 url?: string
10}
11
12export type Label = { name: string; color?: string }
13
14export type Issue = {
15 number: number
16 title: string
17 milestone?: string
18 labels: Label[]
19 updatedAt: string
20 url?: string
21}
22
23export type Pr = {
24 number: number
25 title: string
26 branch?: string
27 isDraft?: boolean
28 review?: string
29 mergedAt?: string
30 url?: string
31}
32
33export type Commit = { sha: string; subject: string }
34
35export type Snapshot = {
36 repo?: { name: string; url: string }
37 branch?: string
38 dirty?: number
39 commits: Commit[]
40 milestones: Milestone[]
41 issues: Issue[]
42 prs: Pr[]
43 merged: Pr[]
44 fetchedAt?: number
45 error?: string
46 isLoading: boolean
47}
48
49export type SectionId = 'milestones' | 'issues' | 'prs' | 'merged' | 'commits'
50