SLOPSHOPPER

gitlab-sync

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

newbandcommandtoastprocessnetwork
v0.1.0MITupdated 2026-10-09StalicJi/my-mods/gitlab-sync
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · gitlab-sync
› fix the failing auth test and add an audit log call ⏺ 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 › /gitlab ⎿ gitlab-sync: 分支 (比對 origin/):✓ 已同步,沒有未提交的檔案 ⎿ gitlab-sync: GitLab:這台電腦還沒設定 GitLab 網址,不檢查 GitLab(用 /plugin configure gitlab-sync 設定 gitlabUrl)。 本機 · ⎇ · ✓ 已同步 ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
本機 · ⎇ · ✓ 已同步 ⟨Claude Code's own drawing⟩
README

my-mods

個人使用的 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-sync)

項目設定方式沒設定時
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 所屬平台的新動態與通知暫停)
GitHubgh 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
  • 清單本身就是私人資訊,只放在每台電腦,不進 repo。清單檔不存在、或裡面有寫錯的正規表示式時,一律擋下推送。
  • 確定是誤判時用 git push --no-verify。

授權

clean-view 與 gitlab-sync 採用根目錄的 MIT License。where-am-i(MIT)與 next-steps(Apache 2.0)是 fork,依各自資料夾裡的 LICENSE。

Source 5 files
hooks/register.tsx 638 lines
1// 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}
638
hooks/gitlab.ts 325 lines
1// 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}
325
hooks/github.ts 317 lines
1// 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}
317
hooks/shared.ts 246 lines
1// 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}
246
types/index.d.ts 41 lines
1// 目前所在 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