issue-map 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env bun
2
2
  /**
3
- * 開發地圖:把 GitHub Issues 的阻擋關係抓下來,塞進 `scripts/issue-map.html` 這份樣板,產出一頁
3
+ * 開發地圖:把 GitHub Issues 的阻擋關係抓下來,塞進 `src/issue-map.html` 這份樣板,產出一頁
4
4
  * 可以直接看的 HTML。每張票的狀態、在等誰、下一步都在這裡算完才送進頁面,樣板只負責畫。
5
5
  *
6
6
  * 帶進快照的 issue:所有 open issue,加上仍被 open issue 牽著的 closed issue(畫成「已完成」的
@@ -9,13 +9,13 @@
9
9
  * 完成的兄弟票要靠 GitHub 原生 sub-issue 才抽得到,用內文 `## Parent` 慣例的 repo 看不到它們,
10
10
  * 那一組的進度會比實際少。
11
11
  *
12
- * 用法:`bun run scripts/issue-map.ts [out.html]`,預設寫到 `dist/issue-map.html`。
12
+ * 用法:`bun run src/issue-map.ts [out.html]`,預設寫到 `dist/issue-map.html`。
13
13
  * 環境變數與設計決定見 README;移植要調的東西在底下的 `CONFIG`。
14
14
  */
15
15
 
16
- import { spawnSync } from 'bun'
16
+ import { spawn } from 'bun'
17
17
 
18
- import { LOCALE_NAME, LOCALES, t } from './issue-map-i18n.ts'
18
+ import { t } from './issue-map-i18n.ts'
19
19
  import {
20
20
  criticalPathOf,
21
21
  groupsOf,
@@ -24,7 +24,7 @@ import {
24
24
  type Snapshot,
25
25
  type Status,
26
26
  } from './issue-map-model.ts'
27
- import { esc, viewOf } from './issue-map-view.ts'
27
+ import { esc, langOptionsHTML, viewOf } from './issue-map-view.ts'
28
28
 
29
29
  const TEMPLATE = new URL('./issue-map.html', import.meta.url).pathname
30
30
  const CLIENT = new URL('./issue-map-page.ts', import.meta.url).pathname
@@ -48,8 +48,13 @@ function labelList(raw: string | undefined, fallback: string): readonly string[]
48
48
  * repo 不在這裡:那是 `gh` 自己的 `GH_REPO`,fork 與多 remote 的判斷也一併交給它。
49
49
  */
50
50
  const CONFIG = {
51
- /** 子票在內文裡指向 parent 的標題。GitHub 原生 sub-issue 有值時優先用原生的。 */
52
- parentHeading: process.env.ISSUE_MAP_PARENT_HEADING ?? 'Parent',
51
+ /**
52
+ * 子票在內文裡指向 parent 的標題,例如 `Parent`。**沒設就不讀內文**,也就不會去抓內文。
53
+ *
54
+ * 內文佔了回應的九成以上,而它只餵這一條 regex;用 GitHub 原生 sub-issue 的 repo 一個字都
55
+ * 用不到。所以這條慣例改成明講才生效——原生關係一律優先,設了也不會蓋過它。
56
+ */
57
+ parentHeading: process.env.ISSUE_MAP_PARENT_HEADING ?? '',
53
58
  /** 掛了就是還沒評估完,不能交給誰做。 */
54
59
  unready: labelList(process.env.ISSUE_MAP_LABELS_UNREADY, 'needs-triage,needs-info'),
55
60
  /** 掛了才算評估完、可以動工。 */
@@ -64,14 +69,16 @@ const CONFIG = {
64
69
  triageCommand: process.env.ISSUE_MAP_CMD_TRIAGE ?? '/triage',
65
70
  } as const
66
71
 
67
- type IssueState = 'OPEN' | 'CLOSED'
72
+ export type IssueState = 'OPEN' | 'CLOSED'
68
73
 
69
- type RawIssue = {
74
+ /** GraphQL 回來的一張票。純推導那幾支吃的就是這個形狀,所以測試組得出來。 */
75
+ export type RawIssue = {
70
76
  readonly number: number
71
77
  readonly title: string
72
78
  readonly state: IssueState
73
79
  readonly url: string
74
- readonly body: string
80
+ /** 只有設了 `ISSUE_MAP_PARENT_HEADING` 才會去抓,其餘時候不存在。 */
81
+ readonly body?: string
75
82
  readonly closedAt: string | null
76
83
  /** 開票的人。GitHub 帳號被刪掉的話是 null。 */
77
84
  readonly author: { readonly login: string } | null
@@ -83,18 +90,19 @@ type RawIssue = {
83
90
  }
84
91
 
85
92
  /** 一路帶著算好的 parent,免得同一段內文被 regex 掃好幾次。 */
86
- type Issue = RawIssue & { readonly parentNumber: number | null }
93
+ export type Issue = RawIssue & { readonly parentNumber: number | null }
87
94
 
88
- interface Page<T> {
95
+ /** GraphQL 的一頁。`collect` 吃的就是這個形狀。 */
96
+ export interface Page<T> {
89
97
  pageInfo: { hasNextPage: boolean; endCursor: string | null }
90
98
  nodes: T[]
91
99
  }
92
100
 
93
- /** 票號查不到(號碼其實是 PR,或那張票不存在)時 GraphQL null。 */
94
- type MaybeIssue = RawIssue | null
101
+ /** 內文只餵 `PARENT_IN_BODY` 一條 regex,沒設慣例就不要去抓——它佔了回應的九成以上。 */
102
+ const BODY_FIELD = CONFIG.parentHeading ? ' body' : ''
95
103
 
96
104
  const ISSUE_FIELDS = `
97
- number title state url body closedAt
105
+ number title state url closedAt${BODY_FIELD}
98
106
  author { login }
99
107
  parent { number }
100
108
  labels(first: 20) { nodes { name } }
@@ -134,8 +142,11 @@ export function dataOrThrow<T>(stdout: string, stderr: string): T {
134
142
  *
135
143
  * 票號是我們自己從前一次結果拿到的整數,直接組進查詢字串;只有 cursor 走變數——它是 API 給的
136
144
  * 不透明字串,沒有理由自己去逃脫它。
145
+ *
146
+ * **非同步**:互不相干的查詢要能一起送,同步等一個子行程就不可能並行;而 server 那邊同步等
147
+ * 還會把整條 event loop 卡住,第二個瀏覽器分頁得等第一個抓完才拿得到回應。
137
148
  */
138
- function run<T>(query: string, variables: Record<string, string> = {}): T {
149
+ async function run<T>(query: string, variables: Record<string, string> = {}): Promise<T> {
139
150
  // `{owner}`/`{repo}` 由 gh 從 cwd 的 git 推斷,`GH_REPO` 可以蓋過去。
140
151
  const args = [
141
152
  'gh',
@@ -149,8 +160,35 @@ function run<T>(query: string, variables: Record<string, string> = {}): T {
149
160
  'repo={repo}',
150
161
  ]
151
162
  for (const [name, value] of Object.entries(variables)) args.push('-f', `${name}=${value}`)
152
- const result = spawnSync(args, { stdout: 'pipe', stderr: 'pipe' })
153
- return dataOrThrow<T>(result.stdout.toString(), result.stderr.toString())
163
+ const proc = spawn(args, { stdout: 'pipe', stderr: 'pipe' })
164
+ // 兩條管子一起收:先讀完一條再讀另一條的話,另一條寫滿緩衝就會卡住整個行程。
165
+ const [stdout, stderr] = await Promise.all([
166
+ new Response(proc.stdout).text(),
167
+ new Response(proc.stderr).text(),
168
+ ])
169
+ await proc.exited
170
+ return dataOrThrow<T>(stdout, stderr)
171
+ }
172
+
173
+ /**
174
+ * 一路翻到底。`pageAt` 拿 cursor 去要一頁,回傳 `null` 代表那個東西不存在。
175
+ *
176
+ * 分頁的終止條件只有這一份——各寫一份的話兩邊會漂移,而漂移的那一邊要真的打 GitHub 才看得出來。
177
+ */
178
+ export async function collect<T>(
179
+ pageAt: (after: string | null) => Promise<Page<T> | null>,
180
+ from: string | null = null,
181
+ ): Promise<T[]> {
182
+ const all: T[] = []
183
+ let after = from
184
+ for (;;) {
185
+ const page = await pageAt(after)
186
+ if (!page) break
187
+ all.push(...page.nodes)
188
+ if (!page.pageInfo.hasNextPage || !page.pageInfo.endCursor) break
189
+ after = page.pageInfo.endCursor
190
+ }
191
+ return all
154
192
  }
155
193
 
156
194
  const OPEN_QUERY = `
@@ -165,27 +203,21 @@ const OPEN_QUERY = `
165
203
  }`
166
204
 
167
205
  /** open issue 全部都要,所以一路翻到底——票超過一頁不是錯誤,是常態。 */
168
- function fetchOpen(): { nameWithOwner: string; open: RawIssue[] } {
206
+ async function fetchOpen(): Promise<{ nameWithOwner: string; open: RawIssue[] }> {
169
207
  type Result = { repository: { nameWithOwner: string; issues: Page<RawIssue> } }
170
- const open: RawIssue[] = []
171
208
  let nameWithOwner = ''
172
- let after: string | null = null
173
- for (;;) {
174
- const variables: Record<string, string> = after ? { after } : {}
175
- const { repository } = run<Result>(OPEN_QUERY, variables)
209
+ const open = await collect<RawIssue>(async (after) => {
210
+ const { repository } = await run<Result>(OPEN_QUERY, after ? { after } : {})
176
211
  nameWithOwner = repository.nameWithOwner
177
- open.push(...repository.issues.nodes)
178
- const { hasNextPage, endCursor } = repository.issues.pageInfo
179
- if (!hasNextPage || !endCursor) break
180
- after = endCursor
181
- }
212
+ return repository.issues
213
+ })
182
214
  return { nameWithOwner, open }
183
215
  }
184
216
 
185
217
  /** alias 不能以數字開頭,所以票號前面補一個 `i`。 */
186
218
  const alias = (number: number) => `i${number}`
187
219
 
188
- function chunks<T>(items: readonly T[], size: number): T[][] {
220
+ export function chunks<T>(items: readonly T[], size: number): T[][] {
189
221
  const batches: T[][] = []
190
222
  for (let index = 0; index < items.length; index += size) {
191
223
  batches.push(items.slice(index, index + size))
@@ -193,26 +225,47 @@ function chunks<T>(items: readonly T[], size: number): T[][] {
193
225
  return batches
194
226
  }
195
227
 
196
- /** 指名要哪幾張票,一批 alias 問完。查不到的就當沒有。 */
197
- function fetchByNumber(numbers: readonly number[]): RawIssue[] {
198
- type Result = { repository: Record<string, MaybeIssue> }
199
- const found: RawIssue[] = []
200
- for (const batch of chunks(numbers, BATCH)) {
201
- const query = `
228
+ /**
229
+ * 一批 alias 指名要哪幾個票號底下的 `selection`。
230
+ *
231
+ * 組查詢、批次切分、依票號取回——這三件事只有這一份。批次之間互不相干,所以一起送;查不到的
232
+ * 票號不會出現在結果裡(GraphQL 那個 alias null,理由見 `dataOrThrow`)。
233
+ */
234
+ export function aliasedQuery(batch: readonly number[], selection: string): string {
235
+ return `
202
236
  query($owner: String!, $repo: String!) {
203
237
  repository(owner: $owner, name: $repo) {
204
- ${batch.map((number) => `${alias(number)}: issue(number: ${number}) { ${ISSUE_FIELDS} }`).join('\n ')}
238
+ ${batch.map((number) => `${alias(number)}: issue(number: ${number}) { ${selection} }`).join('\n ')}
205
239
  }
206
240
  }`
207
- const { repository } = run<Result>(query)
241
+ }
242
+
243
+ async function byAlias<T>(numbers: readonly number[], selection: string): Promise<Map<number, T>> {
244
+ type Result = { repository: Record<string, T | null> }
245
+ const batches = chunks(numbers, BATCH)
246
+ const answers = await Promise.all(
247
+ batches.map(async (batch) => (await run<Result>(aliasedQuery(batch, selection))).repository),
248
+ )
249
+ const found = new Map<number, T>()
250
+ batches.forEach((batch, index) => {
251
+ const repository = answers[index]
208
252
  for (const number of batch) {
209
- const issue = repository[alias(number)]
210
- if (issue) found.push(issue)
253
+ const node = repository?.[alias(number)]
254
+ if (node) found.set(number, node)
211
255
  }
212
- }
256
+ })
213
257
  return found
214
258
  }
215
259
 
260
+ /** 指名要哪幾張票的完整欄位。查不到的就當沒有。 */
261
+ async function fetchByNumber(numbers: readonly number[]): Promise<RawIssue[]> {
262
+ return [...(await byAlias<RawIssue>(numbers, ISSUE_FIELDS)).values()]
263
+ }
264
+
265
+ /** 子票這一趟只問票號與狀態,完整欄位留給指名去要的那一趟。 */
266
+ const CHILD_FIELDS = 'number state'
267
+ export type ChildState = { readonly number: number; readonly state: IssueState }
268
+
216
269
  function childrenQuery(parent: number): string {
217
270
  return `
218
271
  query($owner: String!, $repo: String!, $after: String) {
@@ -220,7 +273,7 @@ function childrenQuery(parent: number): string {
220
273
  issue(number: ${parent}) {
221
274
  subIssues(first: ${PAGE}, after: $after) {
222
275
  pageInfo { hasNextPage endCursor }
223
- nodes { ${ISSUE_FIELDS} }
276
+ nodes { ${CHILD_FIELDS} }
224
277
  }
225
278
  }
226
279
  }
@@ -228,70 +281,128 @@ function childrenQuery(parent: number): string {
228
281
  }
229
282
 
230
283
  /**
231
- * 拿這些 parent 底下的子票,為的是把同一組裡**已完成**的兄弟票撈出來當進度。
284
+ * 這些主票底下有哪些子票、各自是開是關。為的是把同一組裡**已完成**的兄弟票撈出來當進度。
285
+ *
286
+ * **只問票號與狀態。** `subIssues` 沒有 `states:` 可以篩,一定會連開著的一起回來,而開著的
287
+ * 兄弟本來就在 open 那包;關掉的要的是完整欄位,那一趟跟其他指名去要的票一起走。整包完整欄位
288
+ * 抓回來再丟掉的話,用原生 sub-issue 的 repo 每次都在重抓自己已經有的東西。
232
289
  *
233
290
  * 只有 GitHub 原生 sub-issue 有值;用內文 `## Parent` 慣例的 repo 這裡是空的(檔頭說的那個
234
- * 代價)。open 的兄弟不必靠這裡,它們本來就在 open 那包。
291
+ * 代價)。
235
292
  */
236
- function fetchChildren(parents: readonly number[]): RawIssue[] {
237
- type Batch = { repository: Record<string, { subIssues: Page<RawIssue> } | null> }
238
- type More = { repository: { issue: { subIssues: Page<RawIssue> } | null } }
239
- const children: RawIssue[] = []
240
- for (const batch of chunks(parents, BATCH)) {
241
- const query = `
242
- query($owner: String!, $repo: String!) {
243
- repository(owner: $owner, name: $repo) {
244
- ${batch
245
- .map(
246
- (number) => `${alias(number)}: issue(number: ${number}) {
247
- subIssues(first: ${PAGE}) { pageInfo { hasNextPage endCursor } nodes { ${ISSUE_FIELDS} } } }`,
248
- )
249
- .join('\n ')}
250
- }
251
- }`
252
- const { repository } = run<Batch>(query)
253
- for (const number of batch) {
254
- const page = repository[alias(number)]?.subIssues
255
- if (!page) continue
256
- children.push(...page.nodes)
257
- // 子票破百的 parent 很罕見,就讓它自己續抓,不為了它把整批都變成分頁查詢。
258
- let after = page.pageInfo.hasNextPage ? page.pageInfo.endCursor : null
259
- while (after) {
260
- const next = run<More>(childrenQuery(number), { after }).repository.issue?.subIssues
261
- if (!next) break
262
- children.push(...next.nodes)
263
- after = next.pageInfo.hasNextPage ? next.pageInfo.endCursor : null
264
- }
265
- }
293
+ async function fetchChildren(parents: readonly number[]): Promise<ChildState[]> {
294
+ type More = { repository: { issue: { subIssues: Page<ChildState> } | null } }
295
+ const first = await byAlias<{ subIssues: Page<ChildState> }>(
296
+ parents,
297
+ `subIssues(first: ${PAGE}) { pageInfo { hasNextPage endCursor } nodes { ${CHILD_FIELDS} } }`,
298
+ )
299
+
300
+ const children: ChildState[] = []
301
+ // 子票破百的 parent 很罕見,就讓它自己從上一頁的尾巴續抓,不為了它把整批都變成分頁查詢。
302
+ const rest: Promise<ChildState[]>[] = []
303
+ for (const [parent, node] of first) {
304
+ children.push(...node.subIssues.nodes)
305
+ const { hasNextPage, endCursor } = node.subIssues.pageInfo
306
+ if (!hasNextPage || !endCursor) continue
307
+ rest.push(
308
+ collect<ChildState>(
309
+ async (after) =>
310
+ (await run<More>(childrenQuery(parent), after ? { after } : {})).repository.issue
311
+ ?.subIssues ?? null,
312
+ endCursor,
313
+ ),
314
+ )
266
315
  }
316
+ for (const more of await Promise.all(rest)) children.push(...more)
267
317
  return children
268
318
  }
269
319
 
270
- /** 原生 sub-issue 優先;沒有就讀內文的 `## <標題>` 之後第一個 `#<n>`。 */
271
- const PARENT_IN_BODY = new RegExp(`##\\s*${CONFIG.parentHeading}\\s*\\n[\\s\\S]*?#(\\d+)`)
272
- function withParent(raw: RawIssue): Issue {
273
- const inBody = PARENT_IN_BODY.exec(raw.body)
274
- return { ...raw, parentNumber: raw.parent?.number ?? (inBody ? Number(inBody[1]) : null) }
320
+ /** 一個標題對應一條 regex,組過就留著——每張票各組一次沒有意義。 */
321
+ const PATTERNS = new Map<string, RegExp>()
322
+ function bodyPattern(heading: string): RegExp {
323
+ const cached = PATTERNS.get(heading)
324
+ if (cached) return cached
325
+ const made = new RegExp(`##\\s*${heading}\\s*\\n[\\s\\S]*?#(\\d+)`)
326
+ PATTERNS.set(heading, made)
327
+ return made
328
+ }
329
+
330
+ /**
331
+ * 內文裡指向 parent 的票號:`## <標題>` 之後第一個 `#<n>`。
332
+ *
333
+ * 標題是空的就不讀——那時內文根本沒被抓下來。標題走參數而不是直接讀環境變數,這條慣例才測得到。
334
+ */
335
+ export function parentInBody(body: string | undefined, heading: string): number | null {
336
+ if (!heading || !body) return null
337
+ const found = bodyPattern(heading).exec(body)
338
+ return found ? Number(found[1]) : null
275
339
  }
276
340
 
277
- export function takeSnapshot(): Snapshot {
278
- const { nameWithOwner, open: rawOpen } = fetchOpen()
279
- const open = rawOpen.map(withParent)
341
+ /** 原生 sub-issue 優先;沒有才看內文的慣例。 */
342
+ export function withParent(raw: RawIssue, heading = CONFIG.parentHeading): Issue {
343
+ return {
344
+ ...raw,
345
+ parentNumber: raw.parent?.number ?? parentInBody(raw.body, heading),
346
+ }
347
+ }
280
348
 
349
+ /**
350
+ * 還要指名去要哪幾張票。**純推導**——決定要打哪些請求的規則在這裡,打不打是呼叫端的事。
351
+ *
352
+ * `numbers` 是阻擋者與 parent 裡不在 open 那包的;`parents` 是要去撈子票的主票。指名去要而不
353
+ * 掃整包 closed,理由見檔頭。
354
+ */
355
+ export function wantedClosed(open: readonly Issue[]): {
356
+ numbers: number[]
357
+ parents: number[]
358
+ } {
281
359
  const openNumbers = new Set(open.map((issue) => issue.number))
282
- const openParents = new Set(open.map((issue) => issue.parentNumber).filter(isNumber))
360
+ const parents = new Set(open.map((issue) => issue.parentNumber).filter(isNumber))
283
361
  const blockers = new Set(
284
362
  open.flatMap((issue) => issue.blockedBy.nodes.map((blocker) => blocker.number)),
285
363
  )
364
+ return {
365
+ numbers: [...new Set([...blockers, ...parents])].filter((number) => !openNumbers.has(number)),
366
+ parents: [...parents],
367
+ }
368
+ }
286
369
 
287
- // 指名去要而不掃整包 closed,理由見檔頭。
288
- const referenced = [...new Set([...blockers, ...openParents])].filter(
289
- (number) => !openNumbers.has(number),
290
- )
291
- const closed = dedupe([...fetchByNumber(referenced), ...fetchChildren([...openParents])])
370
+ /**
371
+ * 從指名要回來的那堆票裡留下真正要帶進快照的。**純推導**。
372
+ *
373
+ * 只留已經關掉的:還開著的兄弟票本來就在 open 那包,留下來會變成同一張票兩份。同一張票可能
374
+ * 同時是某人的阻擋者又是某人的兄弟,所以先用票號收斂。
375
+ */
376
+ export function keptClosed(open: readonly Issue[], fetched: readonly RawIssue[]): Issue[] {
377
+ const openNumbers = new Set(open.map((issue) => issue.number))
378
+ return dedupe(fetched)
292
379
  .filter((issue) => issue.state === 'CLOSED' && !openNumbers.has(issue.number))
293
- .map(withParent)
380
+ .map((issue) => withParent(issue))
381
+ }
294
382
 
383
+ export async function takeSnapshot(): Promise<Snapshot> {
384
+ const { nameWithOwner, open: rawOpen } = await fetchOpen()
385
+ const open = rawOpen.map((issue) => withParent(issue))
386
+ const wanted = wantedClosed(open)
387
+
388
+ // 先用最便宜的一趟問出子票的狀態,再讓完整欄位只抓一次、只抓真的要留下的那些。
389
+ const children = await fetchChildren(wanted.parents)
390
+ const closedChildren = children.filter((child) => child.state === 'CLOSED')
391
+ const numbers = [...new Set([...wanted.numbers, ...closedChildren.map((c) => c.number)])]
392
+
393
+ return assemble(nameWithOwner, open, keptClosed(open, await fetchByNumber(numbers)))
394
+ }
395
+
396
+ /**
397
+ * 把抓回來的票推導成一份快照。**沒有 `gh`**——所有輸入都在參數裡,所以整段推導測得到,不必
398
+ * 真的打 GitHub。唯一的外部相依是取現在時間。
399
+ */
400
+ export function assemble(
401
+ nameWithOwner: string,
402
+ open: readonly Issue[],
403
+ closed: readonly Issue[],
404
+ ): Snapshot {
405
+ const openNumbers = new Set(open.map((issue) => issue.number))
295
406
  const kept = [...open, ...closed]
296
407
  const parents = new Set(kept.map((issue) => issue.parentNumber).filter(isNumber))
297
408
  const openChildren = new Map<number, number>()
@@ -305,12 +416,15 @@ export function takeSnapshot(): Snapshot {
305
416
  const triaged = [...CONFIG.unready, ...CONFIG.ready].some((name) => seen.has(name))
306
417
 
307
418
  const issues = kept
308
- .map((raw) => describeIssue(raw, { open: openNumbers, openChildren, parents, triaged }))
419
+ .map((raw) =>
420
+ describeIssue(raw, { open: openNumbers, openChildren, parents, triaged, rules: CONFIG }),
421
+ )
309
422
  .toSorted((a, b) => a.number - b.number)
310
423
  return {
311
424
  generatedAt: new Date().toISOString(),
312
425
  repo: nameWithOwner,
313
- labels: { ready: CONFIG.ready, unready: CONFIG.unready },
426
+ // 閘門開著沒有一起寫進去:頁尾要說的就是這一次的判定,不能自己從字彙長度重猜。
427
+ labels: { ready: CONFIG.ready, unready: CONFIG.unready, gated: triaged },
314
428
  groups: groupsOf(issues),
315
429
  criticalPath: criticalPathOf(issues),
316
430
  issues,
@@ -326,56 +440,88 @@ function isNumber(value: number | null): value is number {
326
440
  return value !== null
327
441
  }
328
442
 
329
- interface Context {
330
- readonly open: Set<number>
443
+ /** 狀態機吃的字彙與指令。`CONFIG` 就是這個形狀,抽出來是為了讓規則本身測得到。 */
444
+ export type Rules = Pick<
445
+ typeof CONFIG,
446
+ 'unready' | 'ready' | 'active' | 'human' | 'implementCommand' | 'triageCommand'
447
+ >
448
+
449
+ /** 判狀態要用到的、這張票自己的事實。刻意不含標題與網址那些只拿去顯示的欄位。 */
450
+ export interface IssueFacts {
451
+ readonly number: number
452
+ readonly state: IssueState
453
+ readonly labels: readonly string[]
454
+ readonly assignees: readonly string[]
455
+ /** 全部的阻擋者,含已關掉的。哪些還算閘門由這裡自己濾。 */
456
+ readonly blockedBy: readonly number[]
457
+ }
458
+
459
+ /** 判狀態要用到的、整個 repo 的事實。 */
460
+ export interface RepoFacts {
461
+ readonly open: ReadonlySet<number>
331
462
  /** 每張主票底下還開著的子票。主票的狀態看的是這個,不是自己的阻擋者。 */
332
- readonly openChildren: Map<number, number>
333
- readonly parents: Set<number>
463
+ readonly openChildren: ReadonlyMap<number, number>
464
+ readonly parents: ReadonlySet<number>
334
465
  /** 這個 repo 有在用 triage 標籤,狀態才把它們當閘門。 */
335
466
  readonly triaged: boolean
467
+ readonly rules: Rules
336
468
  }
337
469
 
338
- function describeIssue(raw: Issue, context: Context): MapIssue {
339
- const labels = raw.labels.nodes.map((label) => label.name)
340
- const assignees = raw.assignees.nodes.map((assignee) => assignee.login)
341
- const blockedBy = raw.blockedBy.nodes.map((blocker) => blocker.number)
470
+ /** 一張票的判定結果。`MapIssue` 其餘欄位都只是把原始資料抄過去。 */
471
+ export type Verdict = Pick<MapIssue, 'waitingFor' | 'status' | 'nextStep' | 'isParent'>
472
+
473
+ /**
474
+ * 這個工具的核心語意:一張票是什麼狀態、在等誰、下一步該做什麼。
475
+ *
476
+ * 純函式——所有輸入都在參數裡,沒有 `gh`、沒有時間、沒有環境變數(字彙走 `rules`)。
477
+ */
478
+ export function verdictOf(issue: IssueFacts, repo: RepoFacts): Verdict {
479
+ const { rules } = repo
342
480
  // 只有還開著的阻擋者算閘門;GitHub 的 blocked_by 摘要也是這樣算的。
343
- const waitingFor = blockedBy.filter((number) => context.open.has(number))
344
- const isParent = context.parents.has(raw.number)
345
- const has = (names: readonly string[]) => names.some((name) => labels.includes(name))
481
+ const waitingFor = issue.blockedBy.filter((number) => repo.open.has(number))
482
+ const isParent = repo.parents.has(issue.number)
483
+ const openChildren = repo.openChildren.get(issue.number) ?? 0
484
+ const has = (names: readonly string[]) => names.some((name) => issue.labels.includes(name))
346
485
 
347
486
  function statusOf(): Status {
348
- if (raw.state === 'CLOSED') return 'done'
487
+ if (issue.state === 'CLOSED') return 'done'
349
488
  // Parent 自己不做事,看的是子票:還有子票開著就是還在等。
350
- if (isParent) {
351
- return waitingFor.length || (context.openChildren.get(raw.number) ?? 0) ? 'blocked' : 'ready'
352
- }
489
+ if (isParent) return waitingFor.length || openChildren ? 'blocked' : 'ready'
353
490
  // 沒掛角色標籤的票還沒被評估過,不能因為沒人擋它就當成可接手。
354
- if (context.triaged && (has(CONFIG.unready) || !has(CONFIG.ready))) return 'triage'
355
- if (assignees.length || has(CONFIG.active)) return 'active'
491
+ if (repo.triaged && (has(rules.unready) || !has(rules.ready))) return 'triage'
492
+ if (issue.assignees.length || has(rules.active)) return 'active'
356
493
  return waitingFor.length ? 'blocked' : 'ready'
357
494
  }
358
495
 
359
496
  /** 只算出「是哪一種下一步」。句子是頁面的事,在 `issue-map-i18n.ts` 依語言組出來。 */
360
497
  function nextStepOf(status: Status): NextStep {
361
498
  if (status === 'done') return { kind: 'none' }
362
- if (status === 'triage') return { kind: 'command', command: CONFIG.triageCommand }
499
+ if (status === 'triage') return { kind: 'command', command: rules.triageCommand }
363
500
  if (status === 'active') {
364
501
  // 沒有 assignee 但掛了 active 標籤時,能講的就只有那個標籤名。
365
- const label = CONFIG.active[0]
366
- return { kind: 'active', who: assignees.length ? assignees : label ? [label] : [] }
367
- }
368
- if (isParent) {
369
- const left = context.openChildren.get(raw.number) ?? 0
370
- return left ? { kind: 'waitChildren', count: left } : { kind: 'parentReady' }
502
+ const label = rules.active[0]
503
+ return {
504
+ kind: 'active',
505
+ who: issue.assignees.length ? issue.assignees : label ? [label] : [],
506
+ }
371
507
  }
508
+ // 主票先講子票;子票全關卻還是 blocked,就是它自己被別的票擋著,那時要講那張票。
509
+ if (isParent && openChildren) return { kind: 'waitChildren', count: openChildren }
372
510
  if (status === 'blocked') return { kind: 'waitIssues', issues: waitingFor }
373
- return has(CONFIG.human)
511
+ if (isParent) return { kind: 'parentReady' }
512
+ return has(rules.human)
374
513
  ? { kind: 'manual' }
375
- : { kind: 'command', command: CONFIG.implementCommand }
514
+ : { kind: 'command', command: rules.implementCommand }
376
515
  }
377
516
 
378
517
  const status = statusOf()
518
+ return { waitingFor, status, nextStep: nextStepOf(status), isParent }
519
+ }
520
+
521
+ function describeIssue(raw: Issue, repo: RepoFacts): MapIssue {
522
+ const labels = raw.labels.nodes.map((label) => label.name)
523
+ const assignees = raw.assignees.nodes.map((assignee) => assignee.login)
524
+ const blockedBy = raw.blockedBy.nodes.map((blocker) => blocker.number)
379
525
  return {
380
526
  number: raw.number,
381
527
  title: raw.title,
@@ -386,10 +532,7 @@ function describeIssue(raw: Issue, context: Context): MapIssue {
386
532
  assignees,
387
533
  parent: raw.parentNumber,
388
534
  blockedBy,
389
- waitingFor,
390
- status,
391
- nextStep: nextStepOf(status),
392
- isParent,
535
+ ...verdictOf({ number: raw.number, state: raw.state, labels, assignees, blockedBy }, repo),
393
536
  }
394
537
  }
395
538
 
@@ -421,9 +564,7 @@ async function bundleClient(): Promise<string> {
421
564
  function fillById(html: string, id: string, body: string): string {
422
565
  // 標籤名要吃得到數字,`h1`、`h2` 都是容器。
423
566
  const marker = new RegExp(`(<[a-z][a-z0-9]*[^>]*\\sid="${id}"[^>]*>)(</[a-z][a-z0-9]*>)`)
424
- const next = html.replace(marker, `$1${body}$2`)
425
- if (next === html) throw new Error(`Template is missing an empty #${id}: ${TEMPLATE}`)
426
- return next
567
+ return replaceIn(html, marker, body, `empty #${id}`)
427
568
  }
428
569
 
429
570
  /**
@@ -455,7 +596,7 @@ function prerender(html: string, snapshot: Snapshot): string {
455
596
  ['foot-truth', foot.truth],
456
597
  ['foot-refresh', foot.refresh],
457
598
  ['foot-config', foot.config],
458
- ['lang', LOCALES.map((l) => `<option value="${l}">${esc(LOCALE_NAME[l])}</option>`).join('')],
599
+ ['lang', langOptionsHTML()],
459
600
  ]
460
601
  const withBody = filled.reduce((acc, [id, body]) => fillById(acc, id, body), html)
461
602
  return replaceIn(withBody, /(<title>)[\s\S]*?(<\/title>)/, esc(title), 'title')
@@ -493,10 +634,15 @@ export async function renderDocument(snapshot: Snapshot): Promise<string> {
493
634
  return `<!doctype html><html lang="en"><head>${head}</head><body>${await renderFragment(snapshot)}</body></html>`
494
635
  }
495
636
 
637
+ /**
638
+ * 把樣板裡 `marker` 圈起來的那一段換成 `body`。
639
+ *
640
+ * 判斷樣板在不在看的是 `marker` 有沒有比對到,**不是換完的字串有沒有變**——空的 repo 畫出來的
641
+ * 群組與清單本來就是空字串,拿「沒變」當「樣板壞了」的話,那種 repo 會產不出圖。
642
+ */
496
643
  function replaceIn(html: string, marker: RegExp, body: string, what: string): string {
497
- const next = html.replace(marker, `$1${body}$2`)
498
- if (next === html) throw new Error(`Template is missing the ${what} block: ${TEMPLATE}`)
499
- return next
644
+ if (!marker.test(html)) throw new Error(`Template is missing the ${what} block: ${TEMPLATE}`)
645
+ return html.replace(marker, `$1${body}$2`)
500
646
  }
501
647
 
502
648
  export function describe(snapshot: Snapshot): string {
@@ -505,7 +651,7 @@ export function describe(snapshot: Snapshot): string {
505
651
  }
506
652
 
507
653
  if (import.meta.main) {
508
- const snapshot = takeSnapshot()
654
+ const snapshot = await takeSnapshot()
509
655
  await Bun.write(OUTPUT, await renderDocument(snapshot))
510
656
  console.log(`Wrote ${OUTPUT}: ${describe(snapshot)}`)
511
657
  }
File without changes