輸入框上方一行顯示目前分支跟遠端差幾個 commit,以及還開著的張數(GitLab 的 Task、Issue 分開計數,GitHub 只有 Issue);GitLab 與 GitHub 上自己開的、指派給自己的 issue/MR(PR),以及要自己審查的 MR(PR)有新動態時通知,/gitlab、/github…

個人使用的 Claude Code mod marketplace。這裡只放通用的程式碼,網址、token 這類私人設定由每台電腦各自設定,不進版控。
| mod | 用途 |
|---|---|
clean-view | 工具呼叫收成一行淡色摘要(Edit、Write、Bash 與錯誤訊息照常顯示),計畫清單顯示在輸入框上方;/clean 切換 |
where-am-i | 輸入框上方的進度摘要(繁體中文版,fork 自 hamzafer/claude-code-mods,MIT) |
next-steps | 回合結束後建議下一步(繁體中文版,fork 自 anthropics/claude-plugins-community,Apache 2.0) |
gitlab-sync | 分支跟遠端的同步狀態與還開著的張數(GitLab 的 Task、Issue 分開計數);GitLab 與 GitHub 上自己的 issue/MR(PR)有新動態時通知,/gitlab、/github 查看 |
agent-panel | 派出子代理時跳出面板(右側或輸入框上方),顯示每個子代理的模型、用量、估算費用與時間;/agents 開關 |
clone 到固定的資料夾,再把那個資料夾加成 marketplace。這樣 Claude Code 會直接讀資料夾裡的程式,git pull 之後執行 /reload-plugins 就是新版:
git clone <這個 repo 的網址> ~/.claude/mods-marketplaces/my-mods
claude plugin marketplace add ~/.claude/mods-marketplaces/my-mods
claude plugin install clean-view@my-mods
claude plugin install where-am-i@my-mods
claude plugin install next-steps@my-mods
claude plugin install gitlab-sync@my-mods
claude plugin install agent-panel@my-mods
| 項目 | 設定方式 | 沒設定時 | |
|---|---|---|---|
| GitLab 網址 | 在 Claude Code 執行 /plugin configure gitlab-sync,填入 gitlabUrl(例如 https://gitlab.example.com,不含 /api/v4);也可以執行 `echo '{"gitlabUrl":"https://gitlab.example.com"}' \ | claude plugin configure gitlab-sync@my-mods --values-stdin` | 不檢查 GitLab |
| GitLab token | 環境變數 GITLAB_TOKEN;或存進 macOS 鑰匙圈:security add-generic-password -U -a "$USER" -s gitlab-token -w。環境變數優先,是空的或被拒絕(401)時改讀鑰匙圈 | 輸入框上方顯示「GitLab 通知暫停」(在 GitHub repo 裡不顯示:band 只顯示目前 repo 所屬平台的新動態與通知暫停) | |
| GitHub | gh auth login,token 跟 gh CLI 共用 | 不檢查 GitHub |
設定值存在這台電腦的 Claude Code 設定裡,不在這個 repo。
改完任一個 mod:
claude plugin test <mod 資料夾>
claude plugin validate <mod 資料夾>
bash scripts/check-contracts.sh # 改過任何 types/index.d.ts 後執行
可能有好幾個 Claude Code session 同時在改這裡的檔案,動手前先看 git status。
scripts/git-hooks/pre-push 會在 git push 前掃描要送出去的 commit:新增的行、檔名、作者與提交者、commit 訊息。找到私有網段 IP,或私人關鍵字清單裡的字,就擋下推送。每個 clone 要做兩件事:
git config core.hooksPath scripts/git-hooks
# 這台電腦的私人關鍵字清單:一行一個延伸正規表示式,不分大小寫,# 開頭是註解
$EDITOR ~/.config/git/private-patterns
git push --no-verify。clean-view 與 gitlab-sync 採用根目錄的 MIT License。where-am-i(MIT)與 next-steps(Apache 2.0)是 fork,依各自資料夾裡的 LICENSE。
hooks/register.tsx 638 lines1// GitLab Sync:輸入框上方一行顯示目前分支跟遠端差幾個 commit,以及自己開的或指派給自己、還開著的張數(GitLab 分 Task、Issue);
2// GitLab 與 GitHub 上自己開的、指派給自己的 issue/MR(PR),以及要自己審查的 MR(PR)有新動態時跳 toast;
3// /gitlab、/github 立即檢查並列出該平台的未讀動態,以及那些還開著的項目(在該平台專案的 repo 裡兩者都只算這個專案的)
4import { atom, read, update } from 'claude-code'
5import type { EngineInterface, HttpResponse, Register, Timer } from 'claude-code'
6
7import type { InboxStatus, KindCount, RepoSync } from '../types'
8import { gitlabProjectOf, pollGitlab } from './gitlab'
9import { githubRepoOf, pollGithub } from './github'
10import {
11 clipColumns,
12 columnsWidth,
13 describeEvent,
14 formatAgo,
15 groupByKind,
16 inProject,
17 openIssueLines,
18 projectName,
19 remoteHostOf,
20 repoScopeLabel,
21 toEvents,
22 toStrings,
23 type ForgeEvent,
24 type EnvName,
25 type ForgeUser,
26 type Io,
27 type OpenIssuesResult,
28 type PollResult,
29 type Segment,
30 type Span,
31} from './shared'
32
33const TICK_MS = 60_000
34const FETCH_INTERVAL_MS = 5 * 60_000
35const POLL_INTERVAL_MS = 2 * 60_000
36// 同一個 repo 開了好幾個 session 時,FETCH_HEAD 夠新就代表別的 session 剛 fetch 過,這次不必再抓
37const FETCH_FRESH_MS = FETCH_INTERVAL_MS - 30_000
38// 輪詢範圍往前多抓一段,避免伺服器時間誤差漏掉動態;重複抓到的事件靠 id 去掉
39const POLL_OVERLAP_MS = 2 * 60_000
40const GIT_TIMEOUT_MS = 15_000
41const FETCH_TIMEOUT_MS = 30_000
42const HTTP_TIMEOUT_MS = 15_000
43const MAX_UNREAD = 50
44const MAX_SEEN_IDS = 500
45const TOAST_MS = 10_000
46const TOAST_COLUMNS = 100
47// band 是一行字,不加框也不上底色(框、底色、膠囊兩端都試過,使用者都不要)。
48// 文字前留兩格:上方 where-am-i 的摘要框是「框線一格+內距一格」才寫字,兩邊的文字起點才會對齊
49const BAND_PADDING_X = 2
50// 背景跑的 git 不能停在密碼提示,連不上就直接失敗
51const GIT_ENV = { GIT_TERMINAL_PROMPT: '0', GIT_SSH_COMMAND: 'ssh -o BatchMode=yes' }
52const EMPTY_INBOX: InboxStatus = { unreadCount: 0, problem: null, openCounts: null }
53
54const repoSync = atom({ plugin: 'gitlab-sync', key: 'repo' } as const, null)
55// GitLab 沿用最早的鍵名 inbox
56const gitlabInbox = atom({ plugin: 'gitlab-sync', key: 'inbox' } as const, EMPTY_INBOX)
57const githubInbox = atom({ plugin: 'gitlab-sync', key: 'githubInbox' } as const, EMPTY_INBOX)
58
59// 平台的名稱:label 寫在 band、toast 與報告裡,command 是斜線指令
60export type ForgeNames = { label: string; command: string }
61
62type Forge = ForgeNames & {
63 id: 'gitlab' | 'github'
64 // $.store 的鍵;$.store 跨 session 共用:每個 session 都會輪詢,但同一個事件只記一次,未讀清單大家看同一份
65 storeKeys: { since: string; seenIds: string; unread: string }
66 // 這個 repo 在這個平台上的專案路徑;null 代表 remote 不在這個平台:
67 // 未讀動態與還開著的項目不分專案全部列,band 不顯示張數;remote 在另一個平台時,band 也不顯示這個平台的新動態與通知暫停
68 projectOf: (repo: RepoSync | null) => string | null
69 // 還開著的項目分哪幾種類型計數;這些類型 0 張也顯示,其他類型有才顯示
70 issueKinds: readonly string[]
71 // 這台電腦沒設定這個平台(pollPlatform 回傳 null)時,斜線指令的說明
72 disabledHint: string
73}
74
75// 模組變數在熱重載時會歸零,所以只放可以重建的東西;要留住的放 $.state 或 $.store
76type ForgeRuntime = {
77 me: ForgeUser | null
78 // 最近一次輪詢查到的還開著的 issue(全部專案,還沒依 repo 過濾);null 代表還沒查、通知暫停中或平台關閉
79 openIssues: OpenIssuesResult | null
80 isDisabled: boolean
81 lastPollAt: number
82 lastToastedProblem: string | null
83 hasSeededToasts: boolean
84 toastedEventIds: Set<string>
85}
86
87// 每台電腦在 userConfig 各自設定;空字串代表沒設定,GitLab 部分整個關閉
88let gitlabBaseUrl = ''
89
90const GITLAB: Forge = {
91 id: 'gitlab',
92 label: 'GitLab',
93 command: 'gitlab',
94 // 沿用最早的鍵名:升級前存下的起點與未讀清單照常使用
95 storeKeys: { since: 'since', seenIds: 'seenEventIds', unread: 'unread' },
96 projectOf: repo => repo?.gitlabProject ?? null,
97 // GitLab 的 work item 有 Task、Issue 等類型,別人指派過來的多半是 Task
98 issueKinds: ['Task', 'Issue'],
99 disabledHint: 'GitLab:這台電腦還沒設定 GitLab 網址,不檢查 GitLab(用 /plugin configure gitlab-sync 設定 gitlabUrl)。',
100}
101
102const GITHUB: Forge = {
103 id: 'github',
104 label: 'GitHub',
105 command: 'github',
106 storeKeys: { since: 'github.since', seenIds: 'github.seenEventIds', unread: 'github.unread' },
107 projectOf: repo => repo?.githubRepo ?? null,
108 issueKinds: ['Issue'],
109 disabledHint: 'GitHub:這台電腦沒有登入 gh,不檢查 GitHub(在終端機執行 gh auth login 後,下一輪就會開始)。',
110}
111
112const FORGES = [GITLAB, GITHUB] as const
113
114let tickTimer: Timer | undefined
115let runningTick: Promise<void> | null = null
116let lastFetchAt = 0
117let lastBehind: { key: string; behind: number } | null = null
118const fetchFailedByRoot = new Map<string, boolean>()
119const runtimes = new Map<Forge, ForgeRuntime>()
120
121function runtimeOf(forge: Forge): ForgeRuntime {
122 let runtime = runtimes.get(forge)
123 if (runtime === undefined) {
124 runtime = { me: null, openIssues: null, isDisabled: false, lastPollAt: 0, lastToastedProblem: null, hasSeededToasts: false, toastedEventIds: new Set() }
125 runtimes.set(forge, runtime)
126 }
127 return runtime
128}
129
130// ── 排程 ──────────────────────────────────────────────
131
132function startTicking($: EngineInterface) {
133 tickTimer?.cancel()
134 tickTimer = $.clock.every(TICK_MS, () => void runTick($, false).catch(() => {}))
135}
136
137// 同一時間只跑一輪;斜線指令要求立即檢查時,等目前這輪跑完再強制跑一輪
138function runTick($: EngineInterface, isForced: boolean): Promise<void> {
139 if (runningTick) return isForced ? runningTick.catch(() => {}).then(() => runTick($, true)) : runningTick
140 runningTick = tick($, isForced).finally(() => {
141 runningTick = null
142 })
143 return runningTick
144}
145
146async function tick($: EngineInterface, isForced: boolean) {
147 const now = await $.clock.now()
148 const shouldFetch = isForced || now - lastFetchAt >= FETCH_INTERVAL_MS
149 if (shouldFetch) lastFetchAt = now
150 await refreshRepo($, shouldFetch, now)
151 // 兩個平台同時查:一邊連不上等逾時,不拖住另一邊
152 await Promise.all(
153 FORGES.map(async forge => {
154 const runtime = runtimeOf(forge)
155 if (isForced || now - runtime.lastPollAt >= POLL_INTERVAL_MS) {
156 runtime.lastPollAt = now
157 await pollForge($, forge, now)
158 }
159 await syncInbox($, forge)
160 }),
161 )
162}
163
164// ── git ───────────────────────────────────────────────
165
166async function git($: EngineInterface, cwd: string, args: readonly string[], timeoutMs = GIT_TIMEOUT_MS): Promise<string | null> {
167 try {
168 // --no-optional-locks:背景的 git status 不搶 index.lock,避免跟 Claude 正在跑的 git commit 撞在一起
169 const result = await $.process.run(['git', '--no-optional-locks', '-C', cwd, ...args], { timeoutMs, env: GIT_ENV })
170 return result.exitCode === 0 ? result.stdout.trim() : null
171 } catch {
172 return null
173 }
174}
175
176async function refreshRepo($: EngineInterface, shouldFetch: boolean, now: number) {
177 const root = await git($, await $.session.cwd(), ['rev-parse', '--show-toplevel'])
178 if (root === null) return setRepo($, null)
179 const branchName = await git($, root, ['symbolic-ref', '--quiet', '--short', 'HEAD'])
180 const isDetached = branchName === null
181 const branch = branchName ?? (await git($, root, ['rev-parse', '--short', 'HEAD']))
182 // 還沒有任何 commit 的 repo 沒有東西可以比
183 if (branch === null) return setRepo($, null)
184
185 const upstream = isDetached ? null : await resolveUpstream($, root, branch)
186 if (upstream !== null && shouldFetch) {
187 const fetched = await fetchUpstream($, root, upstream, now)
188 if (fetched !== 'skipped') fetchFailedByRoot.set(root, fetched === 'failed')
189 }
190 const [ahead, behind] = upstream === null ? [0, 0] : parseCounts(await git($, root, ['rev-list', '--left-right', '--count', `HEAD...${upstream}`]))
191 const status = await git($, root, ['status', '--porcelain'])
192 const dirtyCount = status ? status.split('\n').filter(line => line !== '').length : 0
193 const remote = await resolveRemote($, root, upstream)
194
195 notifyNewRemoteCommits($, `${root}\n${branch}\n${upstream}`, upstream, behind)
196 await setRepo($, { branch, isDetached, upstream, ahead, behind, dirtyCount, isFetchFailed: fetchFailedByRoot.get(root) ?? false, ...remote })
197}
198
199type RemoteInfo = Pick<RepoSync, 'remoteHost' | 'gitlabProject' | 'githubRepo'>
200
201// 看遠端分支所在的 remote;沒有遠端分支(detached、還沒 push)時看 origin
202async function resolveRemote($: EngineInterface, root: string, upstream: string | null): Promise<RemoteInfo> {
203 const remote = upstream === null ? 'origin' : upstream.slice(0, upstream.indexOf('/'))
204 const remoteUrl = await git($, root, ['remote', 'get-url', remote])
205 if (remoteUrl === null) return { remoteHost: null, gitlabProject: null, githubRepo: null }
206 return { remoteHost: remoteHostOf(remoteUrl), gitlabProject: gitlabProjectOf(remoteUrl, gitlabBaseUrl), githubRepo: githubRepoOf(remoteUrl) }
207}
208
209// 優先用分支設定的 upstream;沒設時退回 origin/<同名分支>,跟 check-gitlab-version skill 的規則一樣
210async function resolveUpstream($: EngineInterface, root: string, branch: string): Promise<string | null> {
211 const configured = await git($, root, ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}'])
212 if (configured) return configured
213 const fallback = `origin/${branch}`
214 return (await git($, root, ['rev-parse', '--verify', '--quiet', `refs/remotes/${fallback}`])) === null ? null : fallback
215}
216
217async function fetchUpstream($: EngineInterface, root: string, upstream: string, now: number): Promise<'fetched' | 'failed' | 'skipped'> {
218 const fetchHead = await git($, root, ['rev-parse', '--path-format=absolute', '--git-path', 'FETCH_HEAD'])
219 if (fetchHead !== null) {
220 const stat = await $.fs.stat(fetchHead).catch(() => null)
221 if (stat && now - stat.mtimeMs < FETCH_FRESH_MS) return 'skipped'
222 }
223 // upstream 是「遠端名稱/遠端分支」,分支名稱本身可能含斜線(feat/x),只切第一個
224 const slash = upstream.indexOf('/')
225 const fetched = await git($, root, ['fetch', '--quiet', upstream.slice(0, slash), upstream.slice(slash + 1)], FETCH_TIMEOUT_MS)
226 return fetched === null ? 'failed' : 'fetched'
227}
228
229function parseCounts(output: string | null): [number, number] {
230 const [ahead = 0, behind = 0] = (output ?? '').split(/\s+/).map(Number).filter(Number.isFinite)
231 return [ahead, behind]
232}
233
234// 同一個 repo、分支與遠端分支,落後數比上次多才提醒;剛開 session 或換分支時只記下來
235function notifyNewRemoteCommits($: EngineInterface, key: string, upstream: string | null, behind: number) {
236 if (upstream !== null && lastBehind?.key === key && behind > lastBehind.behind) {
237 toast($, `${upstream} 多了 ${behind - lastBehind.behind} 個新 commit,本機落後 ${behind} 個`)
238 }
239 lastBehind = { key, behind }
240}
241
242async function setRepo($: EngineInterface, next: RepoSync | null) {
243 // 沒變就不寫:寫入會讓 band 重畫
244 if (JSON.stringify(await read($, repoSync)) === JSON.stringify(next)) return
245 await update($, repoSync, () => next)
246 // 換了目錄或 remote,band 的數字要跟著換專案算,不等下一次輪詢
247 await Promise.all(FORGES.map(forge => syncCounts($, forge)))
248}
249
250// ── 平台輪詢 ───────────────────────────────────────────
251
252async function pollForge($: EngineInterface, forge: Forge, now: number) {
253 const runtime = runtimeOf(forge)
254 const storedSince = await $.store.get(forge.storeKeys.since)
255 // 第一次輪詢只記下起點,不補之前的舊動態
256 const sinceMs = typeof storedSince === 'string' ? Date.parse(storedSince) - POLL_OVERLAP_MS : null
257 let result: PollResult | null
258 try {
259 result = await pollPlatform($, forge, sinceMs)
260 } catch (error) {
261 runtime.me = null
262 runtime.isDisabled = false
263 return stopPolling($, forge, error instanceof Error ? error.message : String(error))
264 }
265 runtime.isDisabled = result === null
266 runtime.me = result?.me ?? null
267 if (result === null) {
268 await setProblem($, forge, null)
269 return setOpenIssues($, forge, null)
270 }
271 if (sinceMs !== null) await recordEvents($, forge, result.events)
272 await $.store.set(forge.storeKeys.since, new Date(now).toISOString())
273 await setProblem($, forge, null)
274 await setOpenIssues($, forge, result.openIssues)
275}
276
277// 回傳 null 代表這台電腦沒設定這個平台,整個不顯示、不算問題;丟出錯誤代表通知暫停,訊息就是原因。
278// 載入器只准把 $ 傳給檔案最上層宣告的函式,所以輪詢函式不放在 Forge 物件裡,由這裡依平台分派
279async function pollPlatform($: EngineInterface, forge: Forge, sinceMs: number | null): Promise<PollResult | null> {
280 switch (forge.id) {
281 case 'gitlab':
282 return gitlabBaseUrl === '' ? null : pollGitlab(ioOf($), gitlabBaseUrl, sinceMs)
283 case 'github':
284 return pollGithub(ioOf($), sinceMs)
285 }
286}
287
288// 平台模組的 I/O:token 只放在 header,不出現在網址或指令參數
289function ioOf($: EngineInterface): Io {
290 return {
291 fetch: (url, headers) => fetchWithTimeout($, url, headers),
292 run: (argv, timeoutMs) => runCommand($, argv, timeoutMs),
293 env: name => readEnv($, name),
294 }
295}
296
297async function readEnv($: EngineInterface, name: EnvName): Promise<string | undefined> {
298 switch (name) {
299 case 'GITLAB_TOKEN':
300 return $.env.get('GITLAB_TOKEN')
301 case 'USER':
302 return $.env.get('USER')
303 }
304}
305
306async function fetchWithTimeout($: EngineInterface, url: string, headers: Record<string, string>): Promise<HttpResponse | null> {
307 return withTimeout($, $.http.fetch(url, { headers }), HTTP_TIMEOUT_MS).catch(() => null)
308}
309
310async function runCommand($: EngineInterface, argv: readonly string[], timeoutMs: number): Promise<{ exitCode: number; stdout: string } | null> {
311 try {
312 const { exitCode, stdout } = await $.process.run(argv, { timeoutMs })
313 return { exitCode, stdout }
314 } catch {
315 return null
316 }
317}
318
319// 用計時器而不是 $.clock.sleep:sleep 會算進 hook 的執行預算
320function withTimeout<T>($: EngineInterface, work: Promise<T>, ms: number): Promise<T> {
321 return new Promise((resolve, reject) => {
322 const timer = $.clock.after(ms, () => reject(new Error('timeout')))
323 work.then(
324 value => {
325 timer.cancel()
326 resolve(value)
327 },
328 error => {
329 timer.cancel()
330 reject(error)
331 },
332 )
333 })
334}
335
336// 通知暫停時張數也跟著清掉,band 只顯示暫停,不留一個不知道多舊的數字
337async function stopPolling($: EngineInterface, forge: Forge, problem: string) {
338 await setProblem($, forge, problem)
339 await setOpenIssues($, forge, null)
340}
341
342async function setOpenIssues($: EngineInterface, forge: Forge, next: OpenIssuesResult | null) {
343 runtimeOf(forge).openIssues = next
344 await syncCounts($, forge)
345}
346
347// band 的數字(新動態則數、各類型的張數)跟斜線指令的清單用同一個 repo 過濾;
348// 未讀清單、還開著的項目或目前的 repo 任一個變了都要重算
349async function syncCounts($: EngineInterface, forge: Forge) {
350 const project = forge.projectOf(await read($, repoSync))
351 const unreadCount = inProject(toEvents(await $.store.get(forge.storeKeys.unread)), project).length
352 const openIssues = runtimeOf(forge).openIssues
353 const openCounts: KindCount[] | null =
354 openIssues !== null && 'issues' in openIssues
355 ? groupByKind(inProject(openIssues.issues, project), forge.issueKinds).map(group => ({ kind: group.kind, count: group.issues.length }))
356 : null
357 const status = await readInbox($, forge)
358 if (status.unreadCount !== unreadCount || JSON.stringify(status.openCounts) !== JSON.stringify(openCounts)) await updateInbox($, forge, { unreadCount, openCounts })
359}
360
361// 載入器要求 read/update 直接寫出這個檔案的 atom,所以依平台分派
362async function readInbox($: EngineInterface, forge: Forge): Promise<InboxStatus> {
363 switch (forge.id) {
364 case 'gitlab':
365 return read($, gitlabInbox)
366 case 'github':
367 return read($, githubInbox)
368 }
369}
370
371async function updateInbox($: EngineInterface, forge: Forge, changes: Partial<InboxStatus>) {
372 switch (forge.id) {
373 case 'gitlab':
374 return update($, gitlabInbox, current => ({ ...current, ...changes }))
375 case 'github':
376 return update($, githubInbox, current => ({ ...current, ...changes }))
377 }
378}
379
380async function recordEvents($: EngineInterface, forge: Forge, events: readonly ForgeEvent[]) {
381 const seen = toStrings(await $.store.get(forge.storeKeys.seenIds))
382 const fresh = [...events].filter(event => !seen.includes(event.id)).sort((a, b) => Date.parse(a.at) - Date.parse(b.at))
383 if (fresh.length === 0) return
384 // 寫入前才讀未讀清單,縮小跟其他 session 同時寫入的空窗
385 const unread = toEvents(await $.store.get(forge.storeKeys.unread))
386 await $.store.set(forge.storeKeys.unread, [...unread, ...fresh].slice(-MAX_UNREAD))
387 await $.store.set(forge.storeKeys.seenIds, [...seen, ...fresh.map(event => event.id)].slice(-MAX_SEEN_IDS))
388}
389
390// 每個 session 各自跳 toast;剛啟動(或熱重載)時已經在清單裡的只彙總成一則。
391// 別的專案的動態照樣跳,只是在這裡打斜線指令看不到,toast 會寫明到哪個 repo 看
392async function syncInbox($: EngineInterface, forge: Forge) {
393 const runtime = runtimeOf(forge)
394 const unread = toEvents(await $.store.get(forge.storeKeys.unread))
395 const fresh = unread.filter(event => !runtime.toastedEventIds.has(event.id))
396 for (const event of fresh) runtime.toastedEventIds.add(event.id)
397 const project = forge.projectOf(await read($, repoSync))
398 if (!runtime.hasSeededToasts) {
399 runtime.hasSeededToasts = true
400 const summary = startupToastText(unread, project, forge)
401 if (summary !== null) toast($, summary)
402 } else if (fresh.length > 0) {
403 toast($, toastText(fresh, project, forge))
404 }
405 await syncCounts($, forge)
406}
407
408async function setProblem($: EngineInterface, forge: Forge, problem: string | null) {
409 const runtime = runtimeOf(forge)
410 if (problem !== null && problem !== runtime.lastToastedProblem) toast($, `${forge.label} 通知暫停:${problem}`)
411 runtime.lastToastedProblem = problem
412 const status = await readInbox($, forge)
413 if (status.problem !== problem) await updateInbox($, forge, { problem })
414}
415
416function toast($: EngineInterface, text: string) {
417 void Promise.resolve($.ui.toast(clipColumns(text, TOAST_COLUMNS), { timeoutMs: TOAST_MS })).catch(() => {})
418}
419
420// ── /gitlab、/github ──────────────────────────────────
421
422async function checkNow($: EngineInterface, forge: Forge): Promise<string> {
423 await runTick($, true)
424 const runtime = runtimeOf(forge)
425 const [repo, status, unread, now] = await Promise.all([read($, repoSync), readInbox($, forge), $.store.get(forge.storeKeys.unread).then(toEvents), $.clock.now()])
426 const project = forge.projectOf(repo)
427 const lines = [repoReport(repo)]
428 if (runtime.isDisabled) return [...lines, forge.disabledHint].join('\n')
429 if (status.problem !== null) lines.push(`${forge.label} 通知暫停:${status.problem}`)
430 // 只列、只標已讀目前 repo 的;別的專案的留著,到那個 repo 再看
431 const shown = inProject(unread, project)
432 // 「裡」直接接英文的平台名稱時空一格
433 const scope = project === null ? '' : `${repoScopeLabel(project)} `
434 if (shown.length === 0) {
435 if (status.problem === null) lines.push(`${scope}${forge.label} 新動態 0 則。`)
436 } else {
437 lines.push(`${scope}${forge.label} 新動態 ${shown.length} 則(已標為已讀):`)
438 for (const event of [...shown].reverse()) lines.push(`• ${formatAgo(now, event.at)} ${describeEvent(event)}`, ` ${event.url}`)
439 await markRead($, forge, shown)
440 }
441 lines.push(...openIssuesReport(forge, project, now))
442 return lines.join('\n')
443}
444
445// 用上面強制輪詢那一輪查到的結果,不另外再查;通知暫停時(沒 token、401、連不上)是 null,上面已經說明原因
446function openIssuesReport(forge: Forge, project: string | null, now: number): string[] {
447 const { openIssues, me } = runtimeOf(forge)
448 if (openIssues === null || me === null) return []
449 if ('error' in openIssues) return [`讀不到還開著的 ${forge.issueKinds.join('/')}:${openIssues.error}`]
450 return openIssueLines(openIssues.issues, me, now, project, forge.issueKinds)
451}
452
453// 只移除這次列出的事件:列出到寫回之間,別的 session 可能剛記下新的
454async function markRead($: EngineInterface, forge: Forge, shown: readonly ForgeEvent[]) {
455 const runtime = runtimeOf(forge)
456 const shownIds = new Set(shown.map(event => event.id))
457 for (const id of shownIds) runtime.toastedEventIds.add(id)
458 const remaining = toEvents(await $.store.get(forge.storeKeys.unread)).filter(event => !shownIds.has(event.id))
459 await $.store.set(forge.storeKeys.unread, remaining)
460 await syncCounts($, forge)
461}
462
463export function repoReport(repo: RepoSync | null): string {
464 if (repo === null) return '目前目錄不是 git repo(或還沒有任何 commit)。'
465 const sync = syncSegment(repo)
466 const dirty = repo.dirtyCount > 0 ? `${repo.dirtyCount} 個檔案未提交` : '沒有未提交的檔案'
467 const upstream = repo.upstream === null ? '' : `(比對 ${repo.upstream})`
468 return `分支 ${repo.branch}${upstream}:${sync ? `${sync.text},` : ''}${dirty}`
469}
470
471// ── 畫面 ──────────────────────────────────────────────
472
473export function bandSegments(repo: RepoSync | null, gitlabStatus: InboxStatus, githubStatus: InboxStatus = EMPTY_INBOX): Segment[] {
474 const segments: Segment[] = []
475 if (repo !== null) {
476 segments.push(remoteSegment(repo))
477 segments.push({ text: `⎇ ${repo.branch}${repo.isDetached ? '(detached)' : ''}`, isDim: true })
478 const sync = syncSegment(repo)
479 if (sync) segments.push(sync)
480 if (repo.dirtyCount > 0) segments.push({ text: `${repo.dirtyCount} 個檔案未提交`, isDim: true })
481 }
482 const statuses = [
483 { forge: GITLAB, status: gitlabStatus },
484 { forge: GITHUB, status: githubStatus },
485 ]
486 // 在某個平台的 repo 裡只顯示那個平台的新動態與通知暫停(例如在 GitHub repo 不顯示「GitLab 通知暫停」),
487 // 另一個平台的新動態照樣跳 toast;不在 repo、本機或其他主機時沒有所屬平台,兩個平台都顯示
488 const homeForge = FORGES.find(forge => forge.projectOf(repo) !== null)
489 for (const { forge, status } of statuses) {
490 if (homeForge !== undefined && forge !== homeForge) continue
491 if (status.unreadCount > 0) segments.push({ text: `${forge.label} ${status.unreadCount} 則新動態(/${forge.command})`, color: 'suggestion', isBold: true })
492 else if (status.problem !== null) segments.push({ text: `${forge.label} 通知暫停(/${forge.command} 看原因)`, color: 'warning', isDim: true })
493 }
494 // 放最後:這一行太長時從尾端截斷,先犧牲這些一直都在的數字,新動態留著。0 張也顯示,還沒查到時才不顯示。
495 // 只在該平台的專案裡顯示:在別的平台、本機或不在 repo 的目錄,這些數字是全部專案加總,容易誤會成這個 repo 的
496 for (const { forge, status } of statuses) {
497 // 熱重載後 $.state 可能還是舊版存的形狀(沒有 openCounts),下一次重算前先當成還沒查到
498 const counts = status.openCounts ?? null
499 if (forge.projectOf(repo) !== null && counts !== null) segments.push(...openCountSegments(counts, forge.command))
500 }
501 return segments
502}
503
504// 這個 repo 的遠端在哪裡:設定的 GitLab 用 Claude 橘色;GitHub 用主題的文字色(深色主題是白色,淺色主題自動變黑,不會白字白底);
505// 沒有遠端(本機)與其他主機(直接寫主機名稱)用暗色
506export function remoteSegment(repo: RepoSync): Segment {
507 if (repo.gitlabProject !== null) return { text: 'GitLab', color: 'claude' }
508 if (repo.remoteHost === null) return { text: '本機', isDim: true }
509 if (repo.remoteHost === 'github.com') return { text: 'GitHub', color: 'text' }
510 return { text: repo.remoteHost, isDim: true }
511}
512
513// 每種類型一段,例如「Task 2 張 · Issue 0 張(/gitlab)」,指令提示只接在最後一段。
514// 只有數字上色:0 張綠色,有張數紅色,其餘字暗色
515function openCountSegments(counts: readonly KindCount[], command: string): Segment[] {
516 return counts.map(({ kind, count }, index) => {
517 const hint = index === counts.length - 1 ? `(/${command})` : ''
518 const spans: Span[] = [
519 { text: `${kind} `, isDim: true },
520 { text: String(count), color: count === 0 ? 'success' : 'error', isBold: true },
521 { text: ` 張${hint}`, isDim: true },
522 ]
523 return { text: spans.map(span => span.text).join(''), spans }
524 })
525}
526
527function syncSegment(repo: RepoSync): Segment | null {
528 if (repo.isDetached) return null
529 if (repo.upstream === null) return { text: '沒有遠端分支(還沒 push?)', isDim: true }
530 const cached = repo.isFetchFailed ? '(fetch 失敗,依快取)' : ''
531 if (repo.ahead === 0 && repo.behind === 0) return { text: `✓ 已同步${cached}`, color: 'success' }
532 if (repo.behind === 0) return { text: `↑${repo.ahead} 待 push${cached}`, color: 'warning' }
533 if (repo.ahead === 0) return { text: `↓${repo.behind} 待 pull${cached}`, color: 'warning' }
534 return { text: `↑${repo.ahead} ↓${repo.behind} 分岔${cached}`, color: 'error' }
535}
536
537// 提示放在最後、不跟著截斷:太長時截的是動態內容
538export function toastText(events: readonly ForgeEvent[], project: string | null, names: ForgeNames): string {
539 const latest = events[events.length - 1]
540 if (latest === undefined) return ''
541 const elsewhere = elsewhereName(latest, project)
542 const body = events.length === 1 ? `${names.label}:${describeEvent(latest)}` : `${names.label} ${events.length} 則新動態,最新:${describeEvent(latest)}`
543 const hint = elsewhere !== null ? `(到 ${elsewhere} 用 /${names.command} 查看)` : events.length === 1 ? '' : `(/${names.command} 查看)`
544 return `${clipColumns(body, TOAST_COLUMNS - columnsWidth(hint))}${hint}`
545}
546
547// 剛啟動(或熱重載)時的彙總:目前 repo 的與其他專案的分開算,其他專案寫出是哪幾個
548export function startupToastText(unread: readonly ForgeEvent[], project: string | null, names: ForgeNames): string | null {
549 if (unread.length === 0) return null
550 const elsewhereNames = unread.map(event => elsewhereName(event, project)).filter((name): name is string => name !== null)
551 const here = unread.length - elsewhereNames.length
552 const others = `(${[...new Set(elsewhereNames)].join('、')})`
553 const hint = `(/${names.command} 查看)`
554 if (elsewhereNames.length === 0) return `${names.label} 有 ${here} 則未讀動態${hint}`
555 if (here === 0) return `${names.label} 其他專案有 ${elsewhereNames.length} 則未讀動態${others},到那個 repo 用 /${names.command} 查看`
556 return `${names.label} 有 ${here} 則未讀動態${hint},其他專案還有 ${elsewhereNames.length} 則${others}`
557}
558
559// 動態不在目前 repo、這裡打斜線指令看不到時,回傳要去的專案名稱;看得到時是 null
560function elsewhereName(event: ForgeEvent, project: string | null): string | null {
561 if (event.projectPath === null || inProject([event], project).length > 0) return null
562 return projectName(event.projectPath)
563}
564
565// ── 註冊 ──────────────────────────────────────────────
566
567export const register: Register = (on, options) => {
568 gitlabBaseUrl = typeof options.gitlabUrl === 'string' ? options.gitlabUrl.trim().replace(/\/+$/, '') : ''
569
570 on('session.start', async ($, e, next) => {
571 const started = await next(e)
572 // 名稱已被佔用時會被拒絕,不影響其他功能。
573 // 說明是選單上的一行字,太長會被截斷,細節(標為已讀、只列這個 repo 的)由指令輸出自己講
574 await $.command
575 .register({
576 name: 'gitlab',
577 description: '檢查分支與 GitLab,列出未讀動態和還開著的 Task、Issue',
578 })
579 .catch(() => {})
580 await $.command
581 .register({
582 name: 'github',
583 description: '檢查分支與 GitHub,列出未讀動態和還開著的 Issue',
584 })
585 .catch(() => {})
586 startTicking($)
587 // 不等它跑完:git fetch 與平台輪詢可能要幾秒,別擋住第一個 prompt
588 void runTick($, false).catch(() => {})
589 return started
590 })
591
592 on('command.run', { command: 'gitlab' }, async $ => ({ text: await checkNow($, GITLAB) }))
593 on('command.run', { command: 'github' }, async $ => ({ text: await checkNow($, GITHUB) }))
594
595 // Claude 可能剛 commit、push 或切了分支,回合結束就更新一次(只讀本機,不 fetch)
596 on('turn.complete', async ($, e, next) => {
597 const completed = await next(e)
598 void refreshAfterTurn($)
599 return completed
600 })
601
602 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
603 const rest = await next(e) // 其他 mod 與 Claude Code 畫在這裡的內容照樣保留
604 if (e.props.hasSurvey) return rest
605 const segments = bandSegments(await read($, repoSync), await read($, gitlabInbox), await read($, githubInbox))
606 if (segments.length === 0) return rest
607 const { Box, Text } = $.ui.resolve(e)
608 const line = (
609 <Text wrap="truncate-end">
610 {segments.flatMap((segment, index) =>
611 (segment.spans ?? [segment]).map((span, spanIndex) => (
612 // 分隔符號跟著該段第一個片段的樣式
613 <Text key={`segment-${index}-${spanIndex}`} color={span.color} dimColor={span.isDim} bold={span.isBold}>
614 {`${index > 0 && spanIndex === 0 ? ' · ' : ''}${span.text}`}
615 </Text>
616 )),
617 )}
618 </Text>
619 )
620 return (
621 <Box flexDirection="column">
622 <Box key="gitlab-band" paddingX={BAND_PADDING_X}>
623 {line}
624 </Box>
625 {rest}
626 </Box>
627 )
628 })
629}
630
631async function refreshAfterTurn($: EngineInterface) {
632 try {
633 await refreshRepo($, false, await $.clock.now())
634 } catch {
635 // 下一次計時器還會再更新
636 }
637}
638hooks/gitlab.ts 325 lines1// GitLab API:一輪輪詢查出自己開的、指派給自己的 issue/MR 與要自己審查的 MR 的新動態,以及還開著的 issue
2import {
3 asArray,
4 asRecord,
5 asString,
6 excerptOf,
7 remoteProjectPath,
8 TokenRejectedError,
9 type EventKind,
10 type ForgeEvent,
11 type ForgeUser,
12 type Io,
13 type OpenIssue,
14 type OpenIssuesResult,
15 type PollResult,
16} from './shared'
17
18const KEYCHAIN_TIMEOUT_MS = 5_000
19// macOS 鑰匙圈裡存 GitLab token 的項目名稱(security add-generic-password -s gitlab-token)
20const KEYCHAIN_SERVICE = 'gitlab-token'
21
22type ItemType = 'issue' | 'mr'
23type StateChange = { at: string; by: ForgeUser }
24
25export type GitlabItem = {
26 type: ItemType
27 id: number
28 iid: number
29 projectId: number
30 title: string
31 ref: string
32 // null 代表 GitLab 沒給完整路徑
33 projectPath: string | null
34 url: string
35 state: string
36 closed: StateChange | null
37 merged: StateChange | null
38}
39
40export type GitlabNote = { id: number; body: string; isSystem: boolean; createdAt: string; author: ForgeUser }
41type GitlabClient = { baseUrl: string; token: string }
42
43const ITEM_LISTS = [
44 { type: 'issue', path: '/issues', scope: 'created_by_me' },
45 { type: 'issue', path: '/issues', scope: 'assigned_to_me' },
46 { type: 'mr', path: '/merge_requests', scope: 'created_by_me' },
47 { type: 'mr', path: '/merge_requests', scope: 'assigned_to_me' },
48 { type: 'mr', path: '/merge_requests', scope: 'reviews_for_me' },
49] as const
50
51const OPEN_ISSUE_SCOPES = ['created_by_me', 'assigned_to_me'] as const
52
53// 模組變數在熱重載時會歸零,只放可以重建的東西
54let currentUser: ForgeUser | null = null
55// 從鑰匙圈換來的 token;有值時就不再用環境變數的,直到它也被拒絕
56let keychainToken: string | null = null
57
58// sinceMs 是 null 代表第一次輪詢:只查使用者與還開著的 issue,不補之前的舊動態。
59// 沒有 token、token 被拒絕或連不上時丟出錯誤,訊息就是通知暫停的原因
60export async function pollGitlab(io: Io, baseUrl: string, sinceMs: number | null): Promise<PollResult> {
61 const token = await resolveToken(io)
62 if (token === null) throw new Error(`GITLAB_TOKEN 是空的,鑰匙圈也讀不到 ${KEYCHAIN_SERVICE}(Keychain 鎖住,或還沒存)`)
63 try {
64 const { client, value } = await withKeychainFallback(io, { baseUrl, token }, current => pollEvents(io, current, sinceMs))
65 return { ...value, openIssues: await queryOpenIssues(io, client) }
66 } catch (error) {
67 currentUser = null
68 throw error
69 }
70}
71
72async function pollEvents(io: Io, client: GitlabClient, sinceMs: number | null): Promise<{ me: ForgeUser; events: ForgeEvent[] }> {
73 currentUser ??= toUser(await gitlabGet(io, client, '/user'))
74 if (currentUser === null) throw new Error('讀不到 GitLab 使用者資料')
75 const me = currentUser
76 if (sinceMs === null) return { me, events: [] }
77 const events: ForgeEvent[] = []
78 for (const item of await listUpdatedItems(io, client, new Date(sinceMs).toISOString())) {
79 events.push(...(await itemEvents(io, client, item, sinceMs, me)))
80 }
81 return { me, events }
82}
83
84// 只認主機跟設定的 GitLab 相同的 remote,回傳去掉 .git 的專案路徑
85export function gitlabProjectOf(remoteUrl: string, gitlabUrl: string): string | null {
86 const gitlabHost = /^[a-z][a-z0-9+.-]*:\/\/(?:[^@/]+@)?([^/:]+)/i.exec(gitlabUrl)?.[1]
87 return gitlabHost === undefined ? null : remoteProjectPath(remoteUrl, gitlabHost)
88}
89
90// ── token ─────────────────────────────────────────────
91// 環境變數 GITLAB_TOKEN 優先;它可能是 shell 啟動時從鑰匙圈讀出來的,之後在鑰匙圈換了 token,環境變數還是舊的,
92// 所以環境變數是空的、或 GitLab 回 401 時,改讀鑰匙圈的最新值
93
94async function resolveToken(io: Io): Promise<string | null> {
95 if (keychainToken !== null) return keychainToken
96 const envToken = await io.env('GITLAB_TOKEN')
97 if (envToken) return envToken
98 keychainToken = await readKeychainToken(io)
99 return keychainToken
100}
101
102// token 被拒絕時換用鑰匙圈的最新 token 重試一次,回傳最後成功用的 client;鑰匙圈也沒有可用的就放棄
103async function withKeychainFallback<T>(
104 io: Io,
105 client: GitlabClient,
106 work: (client: GitlabClient) => Promise<T>,
107): Promise<{ client: GitlabClient; value: T }> {
108 const first = await attempt(client, work)
109 if (first !== REJECTED) return { client, value: first }
110 keychainToken = null
111 const latest = await readKeychainToken(io)
112 // 鑰匙圈裡是同一個被拒絕的 token 時,不必再試一次
113 if (latest !== null && latest !== client.token) {
114 const retried = { ...client, token: latest }
115 const second = await attempt(retried, work)
116 if (second !== REJECTED) {
117 keychainToken = latest
118 return { client: retried, value: second }
119 }
120 }
121 throw new Error('GitLab token 無效或已過期(HTTP 401),鑰匙圈裡也沒有可用的新 token')
122}
123
124const REJECTED = Symbol('rejected')
125
126// 只把 401 當成「不接受」,連不上或其他錯誤照常丟出
127async function attempt<T>(client: GitlabClient, work: (client: GitlabClient) => Promise<T>): Promise<T | typeof REJECTED> {
128 try {
129 return await work(client)
130 } catch (error) {
131 if (error instanceof TokenRejectedError) return REJECTED
132 throw error
133 }
134}
135
136// token 從 stdout 讀回來,不出現在指令參數;不是 macOS、沒存或鑰匙圈鎖住時都是 null
137async function readKeychainToken(io: Io): Promise<string | null> {
138 const account = await io.env('USER')
139 const argv = ['security', 'find-generic-password', ...(account ? ['-a', account] : []), '-s', KEYCHAIN_SERVICE, '-w']
140 const result = await io.run(argv, KEYCHAIN_TIMEOUT_MS)
141 const token = result?.stdout.trim() ?? ''
142 return result?.exitCode === 0 && token !== '' ? token : null
143}
144
145// ── 查詢 ──────────────────────────────────────────────
146
147async function gitlabGet(io: Io, client: GitlabClient, path: string): Promise<unknown> {
148 // token 只放在 header,不出現在網址或指令參數
149 const response = await io.fetch(`${client.baseUrl}/api/v4${path}`, { 'PRIVATE-TOKEN': client.token })
150 if (response === null) throw new Error(`連不上 GitLab(${client.baseUrl}),可能不在能連到它的網路`)
151 if (response.status === 401) throw new TokenRejectedError('GitLab token 無效或已過期(HTTP 401)')
152 if (!response.ok) throw new Error(`GitLab 回應 HTTP ${response.status}(${path.split('?')[0]})`)
153 return JSON.parse(response.text)
154}
155
156async function listUpdatedItems(io: Io, client: GitlabClient, sinceIso: string): Promise<GitlabItem[]> {
157 // 同一張 MR 可能同時是自己開的、指派給自己的、要自己審查的,用 type+id 合併
158 const byKey = new Map<string, GitlabItem>()
159 for (const list of ITEM_LISTS) {
160 const query = `scope=${list.scope}&state=all&updated_after=${encodeURIComponent(sinceIso)}&per_page=100`
161 for (const raw of asArray(await gitlabGet(io, client, `${list.path}?${query}`))) {
162 const item = toItem(raw, list.type)
163 if (item) byKey.set(`${item.type}:${item.id}`, item)
164 }
165 }
166 return [...byKey.values()]
167}
168
169async function itemEvents(io: Io, client: GitlabClient, item: GitlabItem, since: number, me: ForgeUser): Promise<ForgeEvent[]> {
170 const collection = item.type === 'mr' ? 'merge_requests' : 'issues'
171 const raw = await gitlabGet(io, client, `/projects/${item.projectId}/${collection}/${item.iid}/notes?sort=desc&order_by=created_at&per_page=50`)
172 const notes = asArray(raw)
173 .map(toNote)
174 .filter((note): note is GitlabNote => note !== null)
175 return [...noteEvents(item, notes, since, me), ...stateEvents(item, since, me)]
176}
177
178// 別人在 since 之後留的言,以及 GitLab 自動產生、跟自己有關的系統留言(指派給我、請我審查、核准)
179export function noteEvents(item: GitlabItem, notes: readonly GitlabNote[], since: number, me: ForgeUser): ForgeEvent[] {
180 const events: ForgeEvent[] = []
181 for (const note of notes) {
182 if (note.author.id === me.id || Date.parse(note.createdAt) < since) continue
183 const kind = noteKind(note, me)
184 if (kind === null) continue
185 events.push({
186 id: `note-${note.id}`,
187 kind,
188 actor: note.author.username,
189 ref: item.ref,
190 projectPath: item.projectPath,
191 title: item.title,
192 excerpt: kind === 'comment' ? excerptOf(note.body) : '',
193 url: kind === 'comment' ? `${item.url}#note_${note.id}` : item.url,
194 at: note.createdAt,
195 })
196 }
197 return events
198}
199
200// 系統留言是固定的英文句子(GitLab 19.4 實測:「assigned to @alice」「approved this merge request」);
201// 「requested review from @alice」照 GitLab 的固定寫法,還沒實測過。
202// 「mentioned in commit」這類跟自己無關的略過
203function noteKind(note: GitlabNote, me: ForgeUser): EventKind | null {
204 if (!note.isSystem) return 'comment'
205 const mentionsMe = note.body.split(/[\s,]+/).includes(`@${me.username}`)
206 if (note.body.startsWith('assigned to') && mentionsMe) return 'assigned'
207 if (note.body.startsWith('requested review from') && mentionsMe) return 'review_requested'
208 if (note.body.startsWith('approved this merge request')) return 'approved'
209 return null
210}
211
212// 關閉與合併沒有系統留言,改看 closed_at/merged_at 與執行的人
213export function stateEvents(item: GitlabItem, since: number, me: ForgeUser): ForgeEvent[] {
214 const changes = [
215 { kind: 'closed', change: item.state === 'closed' ? item.closed : null },
216 { kind: 'merged', change: item.state === 'merged' ? item.merged : null },
217 ] as const
218 return changes.flatMap(({ kind, change }) =>
219 change === null || change.by.id === me.id || Date.parse(change.at) < since
220 ? []
221 : [
222 {
223 id: `${kind}-${item.type}-${item.id}-${change.at}`,
224 kind,
225 actor: change.by.username,
226 ref: item.ref,
227 projectPath: item.projectPath,
228 title: item.title,
229 excerpt: '',
230 url: item.url,
231 at: change.at,
232 },
233 ],
234 )
235}
236
237// 跟動態分開接錯:這一段查不到只影響張數與指令的清單,不讓整個通知暫停
238async function queryOpenIssues(io: Io, client: GitlabClient): Promise<OpenIssuesResult> {
239 try {
240 return { issues: await listOpenIssues(io, client) }
241 } catch (error) {
242 return { error: error instanceof Error ? error.message : String(error) }
243 }
244}
245
246// 自己開的又指派給自己的會出現兩次,用 id 合併;最近更新的排前面
247async function listOpenIssues(io: Io, client: GitlabClient): Promise<OpenIssue[]> {
248 const byId = new Map<number, OpenIssue>()
249 for (const scope of OPEN_ISSUE_SCOPES) {
250 for (const raw of asArray(await gitlabGet(io, client, `/issues?scope=${scope}&state=opened&per_page=100`))) {
251 const issue = toOpenIssue(raw)
252 if (issue) byId.set(issue.id, issue)
253 }
254 }
255 return [...byId.values()].sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt))
256}
257
258// ── 回應解析 ──────────────────────────────────────────
259
260function toUser(value: unknown): ForgeUser | null {
261 const raw = asRecord(value)
262 return raw && typeof raw.id === 'number' && typeof raw.username === 'string' ? { id: raw.id, username: raw.username } : null
263}
264
265function toStateChange(at: unknown, by: unknown): StateChange | null {
266 const time = asString(at)
267 const user = toUser(by)
268 return time !== null && user !== null ? { at: time, by: user } : null
269}
270
271export function toItem(value: unknown, type: ItemType): GitlabItem | null {
272 const raw = asRecord(value)
273 if (!raw || typeof raw.id !== 'number' || typeof raw.iid !== 'number' || typeof raw.project_id !== 'number') return null
274 const fullRef = asString(asRecord(raw.references)?.full) ?? `${raw.project_id}${type === 'mr' ? '!' : '#'}${raw.iid}`
275 return {
276 type,
277 id: raw.id,
278 iid: raw.iid,
279 projectId: raw.project_id,
280 title: asString(raw.title) ?? '',
281 // acme/web/web-app#5 → web-app#5
282 ref: fullRef.slice(fullRef.lastIndexOf('/') + 1),
283 projectPath: projectPathOf(asString(asRecord(raw.references)?.full)),
284 url: asString(raw.web_url) ?? '',
285 state: asString(raw.state) ?? '',
286 closed: toStateChange(raw.closed_at, raw.closed_by),
287 merged: toStateChange(raw.merged_at, raw.merged_by),
288 }
289}
290
291export function toOpenIssue(value: unknown): OpenIssue | null {
292 const item = toItem(value, 'issue')
293 const raw = asRecord(value)
294 const author = toUser(raw?.author)
295 const updatedAt = asString(raw?.updated_at)
296 if (item === null || raw === null || author === null || updatedAt === null) return null
297 const assignees = asArray(raw.assignees)
298 .map(toUser)
299 .filter((user): user is ForgeUser => user !== null)
300 const kind = workItemKind(asString(raw.issue_type))
301 return { id: item.id, kind, ref: item.ref, projectPath: item.projectPath, title: item.title, url: item.url, author, assignees, updatedAt }
302}
303
304const WORK_ITEM_KINDS: Record<string, string> = { issue: 'Issue', task: 'Task', incident: 'Incident', test_case: 'Test case' }
305
306// /issues 回的是各種類型的 work item(網址都是 /-/work_items/N,看不出類型),類型要看 issue_type。
307// 沒有這個欄位的舊版 GitLab 只有 Issue;不認得的類型照原字顯示
308export function workItemKind(issueType: string | null): string {
309 if (issueType === null) return 'Issue'
310 return WORK_ITEM_KINDS[issueType] ?? issueType
311}
312
313// acme/web/web-app#5、acme/monitor!3 → 前面的專案路徑
314function projectPathOf(fullRef: string | null): string | null {
315 return /^(.+)[#!]\d+$/.exec(fullRef ?? '')?.[1] ?? null
316}
317
318export function toNote(value: unknown): GitlabNote | null {
319 const raw = asRecord(value)
320 const author = toUser(raw?.author)
321 const createdAt = asString(raw?.created_at)
322 if (!raw || typeof raw.id !== 'number' || author === null || createdAt === null) return null
323 return { id: raw.id, body: asString(raw.body) ?? '', isSystem: raw.system === true, createdAt, author }
324}
325hooks/github.ts 317 lines1// GitHub API:一輪輪詢查出自己開的、指派給自己的 issue/PR 與要自己審查的 PR 的新動態,以及還開著的 issue。
2// token 跟 gh CLI 共用(gh auth token),這台電腦沒登入 gh 時整個 GitHub 部分關閉,不算問題
3import {
4 asArray,
5 asRecord,
6 asString,
7 excerptOf,
8 remoteProjectPath,
9 TokenRejectedError,
10 type EventKind,
11 type ForgeEvent,
12 type ForgeUser,
13 type Io,
14 type OpenIssue,
15 type OpenIssuesResult,
16 type PollResult,
17} from './shared'
18
19const API_URL = 'https://api.github.com'
20const GH_TIMEOUT_MS = 5_000
21const PER_PAGE = 100
22
23export type GithubItem = {
24 id: number
25 number: number
26 // owner/repo
27 repo: string
28 title: string
29 url: string
30 // 已合併的 PR:timeline 會同時有 merged 與 closed,只通知合併
31 isMerged: boolean
32}
33
34// timeline 的一筆,已整理成判斷要不要通知需要的欄位
35export type TimelineEntry = {
36 id: string
37 type: 'comment' | 'review' | 'assigned' | 'review_requested' | 'closed' | 'merged'
38 actor: ForgeUser
39 at: string
40 body: string
41 // 審查結果:approved、changes_requested、commented、dismissed
42 reviewState: string
43 // 被指派或被請求審查的人
44 target: ForgeUser | null
45 url: string | null
46}
47
48type Response = { body: unknown; headers: Record<string, string> }
49
50// 模組變數在熱重載時會歸零,只放可以重建的東西
51let cachedToken: string | null = null
52let currentUser: ForgeUser | null = null
53
54// 回傳 null 代表這台電腦沒登入 gh:不輪詢、不顯示。sinceMs 是 null 代表第一次輪詢,只查使用者與還開著的 issue。
55// token 被拒絕或連不上時丟出錯誤,訊息就是通知暫停的原因
56export async function pollGithub(io: Io, sinceMs: number | null): Promise<PollResult | null> {
57 cachedToken ??= await readGhToken(io)
58 const token = cachedToken
59 if (token === null) {
60 currentUser = null
61 return null
62 }
63 try {
64 currentUser ??= toUser((await githubGet(io, token, '/user')).body)
65 if (currentUser === null) throw new Error('讀不到 GitHub 使用者資料')
66 const me = currentUser
67 const events: ForgeEvent[] = []
68 if (sinceMs !== null) {
69 for (const item of await listUpdatedItems(io, token, sinceMs)) {
70 events.push(...timelineEvents(item, await readTimeline(io, token, item), sinceMs, me))
71 }
72 }
73 return { me, events, openIssues: await queryOpenIssues(io, token) }
74 } catch (error) {
75 currentUser = null
76 // 下一輪重新向 gh 要 token:使用者可能剛重新登入
77 if (error instanceof TokenRejectedError) cachedToken = null
78 throw error
79 }
80}
81
82// remote 在 github.com 時回傳 owner/repo
83export function githubRepoOf(remoteUrl: string): string | null {
84 return remoteProjectPath(remoteUrl, 'github.com')
85}
86
87// token 從 stdout 讀回來,不出現在指令參數;gh 沒裝、沒登入時是 null
88async function readGhToken(io: Io): Promise<string | null> {
89 const result = await io.run(['gh', 'auth', 'token'], GH_TIMEOUT_MS)
90 const token = result?.stdout.trim() ?? ''
91 return result?.exitCode === 0 && token !== '' ? token : null
92}
93
94// ── 查詢 ──────────────────────────────────────────────
95
96// pathOrUrl 是 /開頭的路徑,或分頁 Link header 給的完整網址(只接受 api.github.com 的,token 不外流)
97async function githubGet(io: Io, token: string, pathOrUrl: string): Promise<Response> {
98 const url = pathOrUrl.startsWith('/') ? `${API_URL}${pathOrUrl}` : pathOrUrl
99 if (!url.startsWith(`${API_URL}/`)) throw new Error(`不是 GitHub API 的網址:${url}`)
100 // token 只放在 header,不出現在網址或指令參數
101 const response = await io.fetch(url, {
102 Authorization: `Bearer ${token}`,
103 Accept: 'application/vnd.github+json',
104 'X-GitHub-Api-Version': '2022-11-28',
105 'User-Agent': 'gitlab-sync-mod',
106 })
107 const path = new URL(url).pathname
108 if (response === null) throw new Error('連不上 GitHub(api.github.com)')
109 if (response.status === 401) throw new TokenRejectedError('GitHub token 無效或已過期(HTTP 401),在終端機執行 gh auth login 重新登入')
110 if (!response.ok) throw new Error(`GitHub 回應 HTTP ${response.status}(${path})`)
111 return { body: JSON.parse(response.text), headers: response.headers }
112}
113
114// 自己開的、指派給自己的 issue 與 PR(GitHub 的 /issues 兩種都包含),加上請自己審查、自己審查過的 PR。
115// 同一張可能同時符合好幾個條件,用 id 合併
116async function listUpdatedItems(io: Io, token: string, sinceMs: number): Promise<GithubItem[]> {
117 const sinceIso = new Date(sinceMs).toISOString()
118 // 搜尋語法的時間不接受毫秒
119 const searchSince = sinceIso.replace(/\.\d{3}Z$/, 'Z')
120 const lists = [
121 `/issues?filter=created&state=all&since=${encodeURIComponent(sinceIso)}&per_page=${PER_PAGE}`,
122 `/issues?filter=assigned&state=all&since=${encodeURIComponent(sinceIso)}&per_page=${PER_PAGE}`,
123 `/search/issues?q=${encodeURIComponent(`is:pr review-requested:@me updated:>=${searchSince}`)}&per_page=${PER_PAGE}`,
124 `/search/issues?q=${encodeURIComponent(`is:pr reviewed-by:@me updated:>=${searchSince}`)}&per_page=${PER_PAGE}`,
125 ]
126 const byId = new Map<number, GithubItem>()
127 for (const path of lists) {
128 const { body } = await githubGet(io, token, path)
129 // 搜尋的結果包在 items 裡
130 for (const raw of Array.isArray(body) ? body : asArray(asRecord(body)?.items)) {
131 const item = toItem(raw)
132 if (item) byId.set(item.id, item)
133 }
134 }
135 return [...byId.values()]
136}
137
138// timeline 由舊到新排,新動態在最後一頁;超過一頁時讀最後兩頁,足夠涵蓋兩分鐘內的新動態
139async function readTimeline(io: Io, token: string, item: GithubItem): Promise<TimelineEntry[]> {
140 const first = await githubGet(io, token, `/repos/${item.repo}/issues/${item.number}/timeline?per_page=${PER_PAGE}`)
141 const lastUrl = linkUrl(first.headers.link, 'last')
142 const pages = [first.body]
143 if (lastUrl !== null) {
144 const lastPage = Number(new URL(lastUrl).searchParams.get('page'))
145 if (lastPage > 2) {
146 const previousUrl = new URL(lastUrl)
147 previousUrl.searchParams.set('page', String(lastPage - 1))
148 pages.push((await githubGet(io, token, previousUrl.toString())).body)
149 }
150 pages.push((await githubGet(io, token, lastUrl)).body)
151 }
152 return pages.flatMap(asArray).flatMap(toTimelineEntries)
153}
154
155// Link: <https://api.github.com/...&page=2>; rel="next", <https://api.github.com/...&page=5>; rel="last"
156export function linkUrl(header: string | undefined, rel: string): string | null {
157 for (const part of (header ?? '').split(',')) {
158 const match = /<([^>]+)>\s*;\s*rel="([^"]+)"/.exec(part)
159 if (match?.[2] === rel) return match[1] ?? null
160 }
161 return null
162}
163
164// 別人在 since 之後做的、跟自己有關的事:留言、審查結果、指派給我、請我審查、關閉、合併
165export function timelineEvents(item: GithubItem, entries: readonly TimelineEntry[], since: number, me: ForgeUser): ForgeEvent[] {
166 const events: ForgeEvent[] = []
167 for (const entry of entries) {
168 if (entry.actor.id === me.id || Date.parse(entry.at) < since) continue
169 const kind = entryKind(entry, item, me)
170 if (kind === null) continue
171 events.push({
172 id: entry.id,
173 kind,
174 actor: entry.actor.username,
175 ref: `${item.repo.slice(item.repo.indexOf('/') + 1)}#${item.number}`,
176 projectPath: item.repo,
177 title: item.title,
178 excerpt: kind === 'comment' ? excerptOf(entry.body) : '',
179 url: entry.url ?? item.url,
180 at: entry.at,
181 })
182 }
183 return events
184}
185
186function entryKind(entry: TimelineEntry, item: GithubItem, me: ForgeUser): EventKind | null {
187 switch (entry.type) {
188 case 'comment':
189 return 'comment'
190 case 'review':
191 // 只有逐行意見、沒寫總評的審查,逐行意見本身會以留言出現
192 if (entry.reviewState === 'approved') return 'approved'
193 if (entry.reviewState === 'changes_requested') return 'changes_requested'
194 return entry.reviewState === 'commented' && entry.body.trim() !== '' ? 'comment' : null
195 case 'assigned':
196 return entry.target?.id === me.id ? 'assigned' : null
197 case 'review_requested':
198 return entry.target?.id === me.id ? 'review_requested' : null
199 case 'closed':
200 return item.isMerged ? null : 'closed'
201 case 'merged':
202 return 'merged'
203 }
204}
205
206// 跟動態分開接錯:這一段查不到只影響張數與指令的清單,不讓整個通知暫停
207async function queryOpenIssues(io: Io, token: string): Promise<OpenIssuesResult> {
208 try {
209 return { issues: await listOpenIssues(io, token) }
210 } catch (error) {
211 return { error: error instanceof Error ? error.message : String(error) }
212 }
213}
214
215// 只算 issue、不算 PR;自己開的又指派給自己的會出現兩次,用 id 合併;最近更新的排前面
216async function listOpenIssues(io: Io, token: string): Promise<OpenIssue[]> {
217 const byId = new Map<number, OpenIssue>()
218 for (const filter of ['created', 'assigned']) {
219 for (const raw of asArray((await githubGet(io, token, `/issues?filter=${filter}&state=open&per_page=${PER_PAGE}`)).body)) {
220 const issue = toOpenIssue(raw)
221 if (issue) byId.set(issue.id, issue)
222 }
223 }
224 return [...byId.values()].sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt))
225}
226
227// ── 回應解析 ──────────────────────────────────────────
228
229function toUser(value: unknown): ForgeUser | null {
230 const raw = asRecord(value)
231 return raw && typeof raw.id === 'number' && typeof raw.login === 'string' ? { id: raw.id, username: raw.login } : null
232}
233
234// https://api.github.com/repos/alice/notes-app → alice/notes-app
235function repoOf(raw: Record<string, unknown>): string | null {
236 return /\/repos\/([^/]+\/[^/]+)$/.exec(asString(raw.repository_url) ?? '')?.[1] ?? null
237}
238
239export function toItem(value: unknown): GithubItem | null {
240 const raw = asRecord(value)
241 const repo = raw === null ? null : repoOf(raw)
242 if (raw === null || repo === null || typeof raw.id !== 'number' || typeof raw.number !== 'number') return null
243 return {
244 id: raw.id,
245 number: raw.number,
246 repo,
247 title: asString(raw.title) ?? '',
248 url: asString(raw.html_url) ?? '',
249 isMerged: asString(asRecord(raw.pull_request)?.merged_at) !== null,
250 }
251}
252
253export function toOpenIssue(value: unknown): OpenIssue | null {
254 const item = toItem(value)
255 const raw = asRecord(value)
256 const author = toUser(raw?.user)
257 const updatedAt = asString(raw?.updated_at)
258 if (item === null || raw === null || author === null || updatedAt === null || raw.pull_request !== undefined) return null
259 const assignees = asArray(raw.assignees)
260 .map(toUser)
261 .filter((user): user is ForgeUser => user !== null)
262 return {
263 id: item.id,
264 kind: 'Issue',
265 ref: `${item.repo.slice(item.repo.indexOf('/') + 1)}#${item.number}`,
266 projectPath: item.repo,
267 title: item.title,
268 url: item.url,
269 author,
270 assignees,
271 updatedAt,
272 }
273}
274
275// timeline 各種事件的欄位不一樣:留言的人在 user(也有 actor)、審查的人只有 user、時間是 submitted_at;
276// 逐行意見(line-commented)一筆包了好幾則留言,拆開來各算一則
277export function toTimelineEntries(value: unknown): TimelineEntry[] {
278 const raw = asRecord(value)
279 if (raw === null) return []
280 const base = { body: asString(raw.body) ?? '', reviewState: '', target: null, url: asString(raw.html_url) }
281 switch (raw.event) {
282 case 'commented':
283 return entry(raw, 'comment', `comment-${raw.id}`, toUser(raw.user) ?? toUser(raw.actor), raw.created_at, base)
284 case 'line-commented':
285 return asArray(raw.comments).flatMap(item => {
286 const comment = asRecord(item)
287 if (comment === null) return []
288 const commentBase = { ...base, body: asString(comment.body) ?? '', url: asString(comment.html_url) }
289 return entry(comment, 'comment', `review-comment-${comment.id}`, toUser(comment.user), comment.created_at, commentBase)
290 })
291 case 'reviewed':
292 return entry(raw, 'review', `review-${raw.id}`, toUser(raw.user), raw.submitted_at, { ...base, reviewState: (asString(raw.state) ?? '').toLowerCase() })
293 case 'assigned':
294 return entry(raw, 'assigned', `assigned-${raw.id}`, toUser(raw.actor), raw.created_at, { ...base, target: toUser(raw.assignee) })
295 case 'review_requested':
296 return entry(raw, 'review_requested', `review-requested-${raw.id}`, toUser(raw.actor), raw.created_at, { ...base, target: toUser(raw.requested_reviewer) })
297 case 'closed':
298 return entry(raw, 'closed', `closed-${raw.id}`, toUser(raw.actor), raw.created_at, base)
299 case 'merged':
300 return entry(raw, 'merged', `merged-${raw.id}`, toUser(raw.actor), raw.created_at, base)
301 default:
302 return []
303 }
304}
305
306function entry(
307 raw: Record<string, unknown>,
308 type: TimelineEntry['type'],
309 id: string,
310 actor: ForgeUser | null,
311 at: unknown,
312 rest: Pick<TimelineEntry, 'body' | 'reviewState' | 'target' | 'url'>,
313): TimelineEntry[] {
314 const time = asString(at)
315 return typeof raw.id !== 'number' || actor === null || time === null ? [] : [{ id, type, actor, at: time, ...rest }]
316}
317hooks/shared.ts 246 lines1// GitLab 與 GitHub 共用的型別、文字處理與回應解析
2import type { HttpResponse, ThemeKey } from 'claude-code'
3
4export type EventKind = 'comment' | 'assigned' | 'review_requested' | 'approved' | 'changes_requested' | 'closed' | 'merged'
5
6export type ForgeEvent = {
7 // 去重用的 id,例如 note-551、closed-issue-47-<時間>
8 id: string
9 kind: EventKind
10 actor: string
11 // 例如 handbook#29、monitor!1、notes-app#3
12 ref: string
13 // 例如 acme/team/handbook、alice/notes-app,用來依目前 repo 過濾;null 代表不知道(舊版存下的動態)
14 projectPath: string | null
15 title: string
16 // 留言開頭;其他種類是空字串
17 excerpt: string
18 url: string
19 at: string
20}
21
22// GitHub 的 login 也放在 username
23export type ForgeUser = { id: number; username: string }
24
25// 「還開著的 issue」:跟動態通知分開查,不受 since 起點影響;band 顯示張數,指令列出清單。
26// 查詢不分專案,顯示時才依目前 repo 過濾,換目錄不必重查
27export type OpenIssue = {
28 id: number
29 // 類型的顯示名稱:GitLab 的 work item 有 Task、Issue 等類型,GitHub 一律是 Issue
30 kind: string
31 ref: string
32 projectPath: string | null
33 title: string
34 url: string
35 author: ForgeUser
36 assignees: ForgeUser[]
37 updatedAt: string
38}
39export type OpenIssuesResult = { issues: OpenIssue[] } | { error: string }
40
41// 一輪輪詢的結果:events 是起點之後、跟自己有關的別人做的事
42export type PollResult = { me: ForgeUser; events: ForgeEvent[]; openIssues: OpenIssuesResult }
43
44// 平台回 401:token 被拒絕,跟連不上、其他 HTTP 錯誤分開,才知道要不要換 token 再試
45export class TokenRejectedError extends Error {}
46
47export type Span = { text: string; color?: ThemeKey; isDim?: boolean; isBold?: boolean }
48// spans 有值時分段上色,text 是 spans 接起來的全文(判斷內容與測試看這個)
49export type Segment = Span & { spans?: readonly Span[] }
50
51// ── remote 網址 ───────────────────────────────────────
52
53// remote 有三種寫法:http(s)://[帳號@]主機[:埠]/路徑、ssh://git@主機[:埠]/路徑、git@主機:路徑
54const REMOTE_URL = /^(?:[a-z][a-z0-9+.-]*:\/\/)?(?:[^@/]+@)?([^/:]+)(?::\d+)?[/:](.+)$/i
55
56// remote 的主機名稱(小寫);本機路徑的 remote(/path/repo.git、../repo)不算主機,回傳 null
57export function remoteHostOf(remoteUrl: string): string | null {
58 const host = REMOTE_URL.exec(remoteUrl.trim())?.[1]
59 return host !== undefined && /^[a-z0-9]/i.test(host) ? host.toLowerCase() : null
60}
61
62// remote 在指定主機上時回傳去掉 .git 的專案路徑;埠不比,SSH 跟網頁本來就走不同的埠
63export function remoteProjectPath(remoteUrl: string, host: string): string | null {
64 const [, remoteHost, rawPath] = REMOTE_URL.exec(remoteUrl.trim()) ?? []
65 if (remoteHost === undefined || rawPath === undefined || remoteHost.toLowerCase() !== host.toLowerCase()) return null
66 const path = rawPath.replace(/^\/+|\/+$/g, '').replace(/\.git$/, '')
67 return path === '' ? null : path
68}
69
70// ── 依目前 repo 過濾 ──────────────────────────────────
71
72// 在平台專案的 repo 裡只留這個專案的;project 是 null(不在 repo 裡、remote 不在這個平台)時全部留著。
73// 不知道是哪個專案的(舊版存下的動態)每個 repo 都留著,免得永遠看不到。專案路徑不分大小寫
74export function inProject<T extends { projectPath: string | null }>(items: readonly T[], project: string | null): T[] {
75 if (project === null) return [...items]
76 const wanted = project.toLowerCase()
77 return items.filter(item => item.projectPath === null || item.projectPath.toLowerCase() === wanted)
78}
79
80// 「這個 repo(api-server)裡」;不分專案時是空字串
81export function repoScopeLabel(project: string | null): string {
82 return project === null ? '' : `這個 repo(${projectName(project)})裡`
83}
84
85// acme/backend/api-server → api-server
86export function projectName(projectPath: string): string {
87 return projectPath.slice(projectPath.lastIndexOf('/') + 1)
88}
89
90// ── 文字 ──────────────────────────────────────────────
91
92// 依類型分組:kinds 列的類型一定有、照 kinds 的順序(0 張也列),其他類型有才列、接在後面;組內維持原本的順序
93export function groupByKind(issues: readonly OpenIssue[], kinds: readonly string[]): { kind: string; issues: OpenIssue[] }[] {
94 const groups = new Map<string, OpenIssue[]>(kinds.map(kind => [kind, []]))
95 for (const issue of issues) {
96 const group = groups.get(issue.kind)
97 if (group) group.push(issue)
98 else groups.set(issue.kind, [issue])
99 }
100 return [...groups].map(([kind, grouped]) => ({ kind, issues: grouped }))
101}
102
103// 每種類型一段,0 張也照寫張數,不改說「沒有」
104export function openIssueLines(issues: readonly OpenIssue[], me: ForgeUser, now: number, project: string | null, kinds: readonly string[]): string[] {
105 const lines: string[] = []
106 for (const group of groupByKind(inProject(issues, project), kinds)) {
107 const heading = `${repoScopeLabel(project)}你開的或指派給你、還開著的 ${group.kind} ${group.issues.length} 張`
108 if (group.issues.length === 0) {
109 lines.push(`${heading}。`)
110 continue
111 }
112 lines.push(`${heading}:`)
113 for (const issue of group.issues) {
114 lines.push(`• ${issue.ref} ${issue.title}`, ` ${issueRole(issue, me)},${formatAgo(now, issue.updatedAt)}更新`, ` ${issue.url}`)
115 }
116 }
117 return lines
118}
119
120// 例如「你開的,指派給 bob」「bob 開的,指派給你、carol」「你開的,還沒指派」
121function issueRole(issue: OpenIssue, me: ForgeUser): string {
122 const opener = issue.author.id === me.id ? '你開的' : `${issue.author.username} 開的`
123 const assignees = issue.assignees.map(user => (user.id === me.id ? '你' : user.username))
124 if (assignees.length === 0) return `${opener},還沒指派`
125 // 中文直接接英文帳號時空一格:「指派給 bob」,接「你」時不空
126 const separator = assignees[0] === '你' ? '' : ' '
127 return `${opener},指派給${separator}${assignees.join('、')}`
128}
129
130export function describeEvent(event: ForgeEvent): string {
131 switch (event.kind) {
132 case 'comment':
133 return `${event.actor} 在 ${event.ref} 留言:${event.excerpt}`
134 case 'assigned':
135 return `${event.actor} 把 ${event.ref} 指派給你:${event.title}`
136 case 'review_requested':
137 return `${event.actor} 請你審查 ${event.ref}:${event.title}`
138 case 'approved':
139 return `${event.actor} 核准了 ${event.ref}:${event.title}`
140 case 'changes_requested':
141 return `${event.actor} 要求修改 ${event.ref}:${event.title}`
142 case 'closed':
143 return `${event.actor} 關閉了 ${event.ref}:${event.title}`
144 case 'merged':
145 return `${event.actor} 合併了 ${event.ref}:${event.title}`
146 }
147}
148
149export function formatAgo(now: number, iso: string): string {
150 const minutes = Math.max(0, Math.floor((now - Date.parse(iso)) / 60_000))
151 if (minutes < 1) return '剛剛'
152 if (minutes < 60) return `${minutes} 分鐘前`
153 const hours = Math.floor(minutes / 60)
154 return hours < 24 ? `${hours} 小時前` : `${Math.floor(hours / 24)} 天前`
155}
156
157const EXCERPT_COLUMNS = 60
158
159// 留言內文是 Markdown:圖片換成「[圖片]」、連結只留文字、去掉強調符號,再壓成一行
160export function excerptOf(body: string): string {
161 const plain = body
162 .replace(/!\[[^\]]*\]\([^)]*\)/g, '[圖片]')
163 .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
164 .replace(/[*_`#>]+/g, '')
165 .replace(/\s+/g, ' ')
166 .trim()
167 return clipColumns(plain, EXCERPT_COLUMNS)
168}
169
170// 依終端機顯示寬度截斷(中文字佔兩格),超過時結尾補「…」
171export function clipColumns(text: string, maxColumns: number): string {
172 if (columnsWidth(text) <= maxColumns) return text
173 let used = 0
174 let clipped = ''
175 for (const char of text) {
176 if (used + columnsOf(char) > maxColumns - 1) break
177 used += columnsOf(char)
178 clipped += char
179 }
180 return `${clipped}…`
181}
182
183export function columnsWidth(text: string): number {
184 return [...text].reduce((sum, char) => sum + columnsOf(char), 0)
185}
186
187function columnsOf(char: string): number {
188 const cp = char.codePointAt(0) ?? 0
189 const isWide =
190 (cp >= 0x1100 && cp <= 0x115f) ||
191 (cp >= 0x2e80 && cp <= 0xa4cf) ||
192 (cp >= 0xac00 && cp <= 0xd7a3) ||
193 (cp >= 0xf900 && cp <= 0xfaff) ||
194 (cp >= 0xfe30 && cp <= 0xfe4f) ||
195 (cp >= 0xff00 && cp <= 0xff60) ||
196 (cp >= 0xffe0 && cp <= 0xffe6) ||
197 (cp >= 0x1f300 && cp <= 0x1faff) ||
198 (cp >= 0x20000 && cp <= 0x3fffd)
199 return isWide ? 2 : 1
200}
201
202// ── 對外的 I/O ─────────────────────────────────────────
203
204// 平台模組讀得到的環境變數;載入器要求 $.env.get 的名稱寫死,所以只開放這幾個
205export type EnvName = 'GITLAB_TOKEN' | 'USER'
206
207// 平台模組用到的 I/O,由 register.tsx 用 $ 實作後傳進來(載入器不准把 $ 傳過 import)
208export type Io = {
209 // 逾時或連不上時是 null
210 fetch(url: string, headers: Record<string, string>): Promise<HttpResponse | null>
211 // 指令不存在或逾時時是 null
212 run(argv: readonly string[], timeoutMs: number): Promise<{ exitCode: number; stdout: string } | null>
213 env(name: EnvName): Promise<string | undefined>
214}
215
216// ── 回應與 $.store 內容的解析 ───────────────────────────
217
218export type Raw = Record<string, unknown>
219
220export function asRecord(value: unknown): Raw | null {
221 return typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Raw) : null
222}
223
224export function asArray(value: unknown): unknown[] {
225 return Array.isArray(value) ? value : []
226}
227
228export function asString(value: unknown): string | null {
229 return typeof value === 'string' ? value : null
230}
231
232export function toStrings(value: unknown): string[] {
233 return asArray(value).filter((item): item is string => typeof item === 'string')
234}
235
236const EVENT_FIELDS = ['id', 'kind', 'actor', 'ref', 'title', 'excerpt', 'url', 'at'] as const
237
238// 舊版存下的動態沒有 projectPath,當成不知道是哪個專案(null)
239export function toEvents(value: unknown): ForgeEvent[] {
240 return asArray(value).flatMap(item => {
241 const raw = asRecord(item)
242 if (raw === null || !EVENT_FIELDS.every(field => typeof raw[field] === 'string')) return []
243 return [{ ...(raw as unknown as ForgeEvent), projectPath: asString(raw.projectPath) }]
244 })
245}
246types/index.d.ts 41 lines1// 目前所在 repo 跟遠端追蹤分支的差距
2export type RepoSync = {
3 // detached HEAD 時是短 hash
4 branch: string
5 isDetached: boolean
6 // 比對的遠端分支(例如 origin/develop);null 代表找不到可比對的遠端分支
7 upstream: string | null
8 ahead: number
9 behind: number
10 dirtyCount: number
11 // 最近一次 fetch 失敗,ahead/behind 是用本機快取比對的
12 isFetchFailed: boolean
13 // remote 的主機名稱(例如 gitlab.example.com、github.com),band 用來標示 GitLab/GitHub/本機;null 代表沒有 remote
14 remoteHost: string | null
15 // 這個 repo 在設定的 GitLab 上的專案路徑(例如 acme/backend/api-server);
16 // null 代表 remote 不在這台 GitLab:/gitlab 的未讀動態與還開著的 Task、Issue 不分專案全部列,band 不顯示張數
17 gitlabProject: string | null
18 // remote 在 github.com 時的 owner/repo(例如 alice/notes-app);
19 // null 代表不在 GitHub:/github 的未讀動態與還開著的 issue 不分專案全部列,band 不顯示 GitHub 的 Issue 張數
20 githubRepo: string | null
21}
22
23// 一種類型(例如 Task、Issue)還開著的張數
24export type KindCount = { kind: string; count: number }
25
26// 一個平台(GitLab 或 GitHub)通知的狀況:problem 不是 null 代表輪詢停擺,內容是原因;unreadCount 是未讀動態則數;
27// openCounts 是自己開的或指派給自己、還開著的張數,依類型分開(GitLab 的 Task、Issue 等,GitHub 只有 Issue),
28// null 代表還沒查到、查不到或平台沒設定。兩種數字在該平台專案的 repo 裡都只算這個專案的
29export type InboxStatus = { unreadCount: number; problem: string | null; openCounts: KindCount[] | null }
30
31declare module 'claude-code' {
32 interface PluginState {
33 'gitlab-sync': {
34 repo: RepoSync | null
35 // GitLab 的通知狀況(沿用最早的鍵名)
36 inbox: InboxStatus
37 githubInbox: InboxStatus
38 }
39 }
40}
41