issue-map 0.2.0 → 0.3.1

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,4 +1,4 @@
1
- <title>開發地圖</title>
1
+ <title>Dev map</title>
2
2
  <link rel="preconnect" href="https://fonts.googleapis.com" />
3
3
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
4
4
  <link
@@ -35,8 +35,16 @@
35
35
  --t3: #8c5a24;
36
36
  --t4: #2f7a5a;
37
37
 
38
- --display: 'Bricolage Grotesque', 'Noto Sans TC', 'PingFang TC', sans-serif;
39
- --body: 'Public Sans', 'Noto Sans TC', 'PingFang TC', system-ui, sans-serif;
38
+ /*
39
+ * CJK 的部分只到 Noto Sans TC 為止是網路字型,簡體與日文交給系統字型(PingFang SC、
40
+ * Hiragino、YaHei、Meiryo)——四種語言各拉一套 Noto 會讓這頁多下載幾百 KB,而這是本機
41
+ * 開發用的頁面。
42
+ */
43
+ --cjk:
44
+ 'Noto Sans TC', 'PingFang TC', 'PingFang SC', 'Hiragino Sans', 'Microsoft JhengHei',
45
+ 'Microsoft YaHei', Meiryo;
46
+ --display: 'Bricolage Grotesque', var(--cjk), sans-serif;
47
+ --body: 'Public Sans', var(--cjk), system-ui, sans-serif;
40
48
  --mono: 'IBM Plex Mono', ui-monospace, 'SF Mono', Menlo, monospace;
41
49
  color-scheme: light;
42
50
  }
@@ -143,6 +151,14 @@
143
151
  flex-direction: column;
144
152
  gap: 14px;
145
153
  }
154
+ /* 抬頭第一行:左邊是 repo 與抓取時間,右邊是語言。 */
155
+ .masthead-top {
156
+ display: flex;
157
+ flex-wrap: wrap;
158
+ align-items: center;
159
+ justify-content: space-between;
160
+ gap: 8px 16px;
161
+ }
146
162
  .eyebrow {
147
163
  font-family: var(--mono);
148
164
  font-size: 11px;
@@ -151,6 +167,21 @@
151
167
  text-transform: uppercase;
152
168
  color: var(--faint);
153
169
  }
170
+ /* 語言用原生 select:鍵盤與行動裝置都不必自己實作,而且四個選項不值得一套下拉選單。 */
171
+ .lang {
172
+ font-family: var(--mono);
173
+ font-size: 11.5px;
174
+ color: var(--muted);
175
+ background: var(--card);
176
+ border: 1px solid var(--line-strong);
177
+ border-radius: 2px;
178
+ padding: 4px 8px;
179
+ cursor: pointer;
180
+ }
181
+ .lang:hover {
182
+ color: var(--ink);
183
+ border-color: var(--ink);
184
+ }
154
185
  h1 {
155
186
  font-family: var(--display);
156
187
  font-weight: 700;
@@ -844,8 +875,11 @@
844
875
 
845
876
  <div class="page">
846
877
  <header class="masthead">
847
- <div class="eyebrow" id="eyebrow"></div>
848
- <h1 id="page-title">開發地圖</h1>
878
+ <div class="masthead-top">
879
+ <div class="eyebrow" id="eyebrow"></div>
880
+ <select class="lang" id="lang"></select>
881
+ </div>
882
+ <h1 id="page-title"></h1>
849
883
  <p class="lede" id="lede"></p>
850
884
  <div class="stats" id="stats"></div>
851
885
  </header>
@@ -857,20 +891,17 @@
857
891
  <section class="group">
858
892
  <div class="list-head">
859
893
  <div class="group-head">
860
- <h2>清單</h2>
894
+ <h2 id="list-title"></h2>
861
895
  <span class="sub" id="list-sub"></span>
862
896
  </div>
863
- <div class="tabs" id="tabs" role="group" aria-label="狀態篩選"></div>
897
+ <div class="tabs" id="tabs" role="group"></div>
864
898
  </div>
865
899
  <div class="rows" id="rows"></div>
866
900
  </section>
867
901
 
868
902
  <footer>
869
- <div>狀態的權威是 GitHub Issues。這一頁是快照,頁面上不能改狀態。</div>
870
- <div>
871
- 重跑 <code>bun run issue-map</code> 產檔,或
872
- <code>bun run issue-map:serve</code> 讓重新整理就是最新。
873
- </div>
903
+ <div id="foot-truth"></div>
904
+ <div id="foot-refresh"></div>
874
905
  <div id="foot-config"></div>
875
906
  </footer>
876
907
  </div>
@@ -11,6 +11,10 @@
11
11
  * 帶進快照的 issue:所有 open issue,加上仍被 open issue 牽著的 closed issue。後者畫成「已完成」
12
12
  * 的節點讓進度看得見,沒人牽著之後自然消失。
13
13
  *
14
+ * closed 是**指名**去要的(阻擋者、parent、parent 底下的子票),不掃整包——老 repo 幾千張
15
+ * closed 裡通常只有個位數會留下。代價:同一組裡已完成的兄弟票要靠 GitHub 原生 sub-issue 才
16
+ * 抽得到,用內文 `## Parent` 慣例的 repo 看不到它們,那一組的進度會比實際少。
17
+ *
14
18
  * **要畫哪個 repo**:從 cwd 的 git 推斷,不必填——在那個 repo 裡跑 `bunx issue-map@latest`
15
19
  * 就好。要指定別的 repo 設 `GH_REPO`。標籤字彙與 parent 的慣例都能用環境變數調,見底下的
16
20
  * `CONFIG`,整份對照表在 README。
@@ -33,6 +37,7 @@ import {
33
37
  criticalPathOf,
34
38
  groupsOf,
35
39
  type MapIssue,
40
+ type NextStep,
36
41
  type Snapshot,
37
42
  type Status,
38
43
  } from './issue-map-model.ts'
@@ -41,8 +46,10 @@ const TEMPLATE = new URL('./issue-map.html', import.meta.url).pathname
41
46
  const CLIENT = new URL('./issue-map-page.ts', import.meta.url).pathname
42
47
  const OUTPUT = process.argv[2] ?? 'dist/issue-map.html'
43
48
 
44
- /** 一次查得回來的上限。GraphQL 的 `first` 最多就是 100,超過會少票,所以超過就喊。 */
49
+ /** 一頁的張數。GraphQL 的 `first` 最多就是 100,票再多就靠 cursor 一頁一頁接。 */
45
50
  const PAGE = 100
51
+ /** 一次用 alias 指名幾張票。GraphQL 對單一查詢的節點數有上限,這個量級離它還很遠。 */
52
+ const BATCH = 50
46
53
 
47
54
  function labelList(raw: string | undefined, fallback: string): readonly string[] {
48
55
  return (raw ?? fallback)
@@ -95,50 +102,163 @@ type RawIssue = {
95
102
  /** 一路帶著算好的 parent,免得同一段內文被 regex 掃好幾次。 */
96
103
  type Issue = RawIssue & { readonly parentNumber: number | null }
97
104
 
98
- interface Connection {
99
- pageInfo: { hasNextPage: boolean }
100
- nodes: RawIssue[]
105
+ interface Page<T> {
106
+ pageInfo: { hasNextPage: boolean; endCursor: string | null }
107
+ nodes: T[]
101
108
  }
102
- interface QueryResult {
103
- repository: { nameWithOwner: string } & Record<'open' | 'closed', Connection>
109
+
110
+ /** 票號查不到(號碼其實是 PR,或那張票不存在)時 GraphQL null。 */
111
+ type MaybeIssue = RawIssue | null
112
+
113
+ const ISSUE_FIELDS = `
114
+ number title state url body closedAt
115
+ author { login }
116
+ parent { number }
117
+ labels(first: 20) { nodes { name } }
118
+ assignees(first: 10) { nodes { login } }
119
+ blockedBy(first: 50) { nodes { number } }
120
+ `
121
+
122
+ /**
123
+ * 跑一次 `gh api graphql`。
124
+ *
125
+ * 票號是我們自己從前一次結果拿到的整數,直接組進查詢字串;只有 cursor 走變數——它是 API 給的
126
+ * 不透明字串,沒有理由自己去逃脫它。
127
+ */
128
+ function run<T>(query: string, variables: Record<string, string> = {}): T {
129
+ // `{owner}`/`{repo}` 由 gh 從 cwd 的 git 推斷,`GH_REPO` 可以蓋過去。
130
+ const args = [
131
+ 'gh',
132
+ 'api',
133
+ 'graphql',
134
+ '-f',
135
+ `query=${query}`,
136
+ '-F',
137
+ 'owner={owner}',
138
+ '-F',
139
+ 'repo={repo}',
140
+ ]
141
+ for (const [name, value] of Object.entries(variables)) args.push('-f', `${name}=${value}`)
142
+ const result = spawnSync(args, { stdout: 'pipe', stderr: 'pipe' })
143
+ if (result.exitCode !== 0) throw new Error(`gh api graphql failed: ${result.stderr.toString()}`)
144
+ const parsed = JSON.parse(result.stdout.toString()) as { data: T; errors?: unknown }
145
+ if (parsed.errors) throw new Error(`GraphQL errors: ${JSON.stringify(parsed.errors)}`)
146
+ return parsed.data
104
147
  }
105
148
 
106
- const FIELDS = `
107
- pageInfo { hasNextPage }
108
- nodes {
109
- number title state url body closedAt
110
- author { login }
111
- parent { number }
112
- labels(first: 20) { nodes { name } }
113
- assignees(first: 10) { nodes { login } }
114
- blockedBy(first: 50) { nodes { number } }
149
+ const OPEN_QUERY = `
150
+ query($owner: String!, $repo: String!, $after: String) {
151
+ repository(owner: $owner, name: $repo) {
152
+ nameWithOwner
153
+ issues(states: OPEN, first: ${PAGE}, after: $after, orderBy: { field: CREATED_AT, direction: DESC }) {
154
+ pageInfo { hasNextPage endCursor }
155
+ nodes { ${ISSUE_FIELDS} }
156
+ }
157
+ }
158
+ }`
159
+
160
+ /** open issue 全部都要,所以一路翻到底——票超過一頁不是錯誤,是常態。 */
161
+ function fetchOpen(): { nameWithOwner: string; open: RawIssue[] } {
162
+ type Result = { repository: { nameWithOwner: string; issues: Page<RawIssue> } }
163
+ const open: RawIssue[] = []
164
+ let nameWithOwner = ''
165
+ let after: string | null = null
166
+ for (;;) {
167
+ const variables: Record<string, string> = after ? { after } : {}
168
+ const { repository } = run<Result>(OPEN_QUERY, variables)
169
+ nameWithOwner = repository.nameWithOwner
170
+ open.push(...repository.issues.nodes)
171
+ const { hasNextPage, endCursor } = repository.issues.pageInfo
172
+ if (!hasNextPage || !endCursor) break
173
+ after = endCursor
115
174
  }
116
- `
175
+ return { nameWithOwner, open }
176
+ }
177
+
178
+ /** alias 不能以數字開頭,所以票號前面補一個 `i`。 */
179
+ const alias = (number: number) => `i${number}`
117
180
 
118
- /** 一次問完 repo 名字與兩種狀態的 issue,只開一個 `gh` 行程。 */
119
- const QUERY = `
181
+ function chunks<T>(items: readonly T[], size: number): T[][] {
182
+ const batches: T[][] = []
183
+ for (let index = 0; index < items.length; index += size) {
184
+ batches.push(items.slice(index, index + size))
185
+ }
186
+ return batches
187
+ }
188
+
189
+ /** 指名要哪幾張票,一批 alias 問完。查不到的就當沒有。 */
190
+ function fetchByNumber(numbers: readonly number[]): RawIssue[] {
191
+ type Result = { repository: Record<string, MaybeIssue> }
192
+ const found: RawIssue[] = []
193
+ for (const batch of chunks(numbers, BATCH)) {
194
+ const query = `
120
195
  query($owner: String!, $repo: String!) {
121
196
  repository(owner: $owner, name: $repo) {
122
- nameWithOwner
123
- open: issues(states: OPEN, first: ${PAGE}, orderBy: { field: CREATED_AT, direction: DESC }) { ${FIELDS} }
124
- closed: issues(states: CLOSED, first: ${PAGE}, orderBy: { field: CREATED_AT, direction: DESC }) { ${FIELDS} }
197
+ ${batch.map((number) => `${alias(number)}: issue(number: ${number}) { ${ISSUE_FIELDS} }`).join('\n ')}
198
+ }
199
+ }`
200
+ const { repository } = run<Result>(query)
201
+ for (const number of batch) {
202
+ const issue = repository[alias(number)]
203
+ if (issue) found.push(issue)
204
+ }
205
+ }
206
+ return found
207
+ }
208
+
209
+ function childrenQuery(parent: number): string {
210
+ return `
211
+ query($owner: String!, $repo: String!, $after: String) {
212
+ repository(owner: $owner, name: $repo) {
213
+ issue(number: ${parent}) {
214
+ subIssues(first: ${PAGE}, after: $after) {
215
+ pageInfo { hasNextPage endCursor }
216
+ nodes { ${ISSUE_FIELDS} }
217
+ }
218
+ }
125
219
  }
126
220
  }`
221
+ }
127
222
 
128
- function query(): QueryResult['repository'] {
129
- // `{owner}`/`{repo}` gh 從 cwd 的 git 推斷,`GH_REPO` 可以蓋過去。
130
- const result = spawnSync(
131
- ['gh', 'api', 'graphql', '-f', `query=${QUERY}`, '-F', 'owner={owner}', '-F', 'repo={repo}'],
132
- { stdout: 'pipe', stderr: 'pipe' },
133
- )
134
- if (result.exitCode !== 0) throw new Error(`gh api graphql 失敗:${result.stderr.toString()}`)
135
- const parsed = JSON.parse(result.stdout.toString()) as { data: QueryResult; errors?: unknown }
136
- if (parsed.errors) throw new Error(`GraphQL 錯誤:${JSON.stringify(parsed.errors)}`)
137
- const repository = parsed.data.repository
138
- if (repository.open.pageInfo.hasNextPage) {
139
- throw new Error(`open issue 超過 ${PAGE} 張,這支要改成分頁抓`)
223
+ /**
224
+ * 拿這些 parent 底下的子票,為的是把同一組裡**已完成**的兄弟票撈出來當進度。
225
+ *
226
+ * 只有 GitHub 原生 sub-issue 有值。用內文 `## Parent` 慣例的 repo 這裡是空的,那些已完成的
227
+ * 兄弟票就不會出現在圖上——要把它們找回來只能整包掃 closed,而那對老 repo 是幾十次請求換
228
+ * 幾張票。open 的兄弟不必靠這裡,它們本來就在 open 那包。
229
+ */
230
+ function fetchChildren(parents: readonly number[]): RawIssue[] {
231
+ type Batch = { repository: Record<string, { subIssues: Page<RawIssue> } | null> }
232
+ type More = { repository: { issue: { subIssues: Page<RawIssue> } | null } }
233
+ const children: RawIssue[] = []
234
+ for (const batch of chunks(parents, BATCH)) {
235
+ const query = `
236
+ query($owner: String!, $repo: String!) {
237
+ repository(owner: $owner, name: $repo) {
238
+ ${batch
239
+ .map(
240
+ (number) => `${alias(number)}: issue(number: ${number}) {
241
+ subIssues(first: ${PAGE}) { pageInfo { hasNextPage endCursor } nodes { ${ISSUE_FIELDS} } } }`,
242
+ )
243
+ .join('\n ')}
244
+ }
245
+ }`
246
+ const { repository } = run<Batch>(query)
247
+ for (const number of batch) {
248
+ const page = repository[alias(number)]?.subIssues
249
+ if (!page) continue
250
+ children.push(...page.nodes)
251
+ // 子票破百的 parent 很罕見,就讓它自己續抓,不為了它把整批都變成分頁查詢。
252
+ let after = page.pageInfo.hasNextPage ? page.pageInfo.endCursor : null
253
+ while (after) {
254
+ const next = run<More>(childrenQuery(number), { after }).repository.issue?.subIssues
255
+ if (!next) break
256
+ children.push(...next.nodes)
257
+ after = next.pageInfo.hasNextPage ? next.pageInfo.endCursor : null
258
+ }
259
+ }
140
260
  }
141
- return repository
261
+ return children
142
262
  }
143
263
 
144
264
  /** 原生 sub-issue 優先;沒有就讀內文的 `## <標題>` 之後第一個 `#<n>`。 */
@@ -149,9 +269,8 @@ function withParent(raw: RawIssue): Issue {
149
269
  }
150
270
 
151
271
  export function takeSnapshot(): Snapshot {
152
- const repository = query()
153
- const open = repository.open.nodes.map(withParent)
154
- const closed = repository.closed.nodes.map(withParent)
272
+ const { nameWithOwner, open: rawOpen } = fetchOpen()
273
+ const open = rawOpen.map(withParent)
155
274
 
156
275
  const openNumbers = new Set(open.map((issue) => issue.number))
157
276
  const openParents = new Set(open.map((issue) => issue.parentNumber).filter(isNumber))
@@ -159,17 +278,17 @@ export function takeSnapshot(): Snapshot {
159
278
  open.flatMap((issue) => issue.blockedBy.nodes.map((blocker) => blocker.number)),
160
279
  )
161
280
 
162
- // 只留 open issue 還牽著的 closed issue:當它們的阻擋者、當它們的 parent,或跟它們同一個
163
- // parent(同一組子票的已完成進度)。沒人牽著之後自然從地圖消失。
164
- const kept = [
165
- ...open,
166
- ...closed.filter(
167
- (issue) =>
168
- blockers.has(issue.number) ||
169
- openParents.has(issue.number) ||
170
- (issue.parentNumber !== null && openParents.has(issue.parentNumber)),
171
- ),
172
- ]
281
+ // 進圖的 closed issue 只有 open issue 還牽著的那些:它們的阻擋者、它們的 parent,以及同一個
282
+ // parent 底下已完成的兄弟(那一組的進度)。所以不掃整包 closed,而是指名去要——老 repo 的
283
+ // 幾千張 closed 裡通常只有個位數會留下,翻完它們是拿幾十次請求換幾張票。
284
+ const referenced = [...new Set([...blockers, ...openParents])].filter(
285
+ (number) => !openNumbers.has(number),
286
+ )
287
+ const closed = dedupe([...fetchByNumber(referenced), ...fetchChildren([...openParents])])
288
+ .filter((issue) => issue.state === 'CLOSED' && !openNumbers.has(issue.number))
289
+ .map(withParent)
290
+
291
+ const kept = [...open, ...closed]
173
292
  const parents = new Set(kept.map((issue) => issue.parentNumber).filter(isNumber))
174
293
  const openChildren = new Map<number, number>()
175
294
  for (const issue of kept) {
@@ -186,7 +305,7 @@ export function takeSnapshot(): Snapshot {
186
305
  .toSorted((a, b) => a.number - b.number)
187
306
  return {
188
307
  generatedAt: new Date().toISOString(),
189
- repo: repository.nameWithOwner,
308
+ repo: nameWithOwner,
190
309
  labels: { ready: CONFIG.ready, unready: CONFIG.unready },
191
310
  groups: groupsOf(issues),
192
311
  criticalPath: criticalPathOf(issues),
@@ -194,6 +313,11 @@ export function takeSnapshot(): Snapshot {
194
313
  }
195
314
  }
196
315
 
316
+ /** 同一張票可能同時是某人的阻擋者又是某人的兄弟,用票號收斂成一張。 */
317
+ function dedupe(issues: readonly RawIssue[]): RawIssue[] {
318
+ return [...new Map(issues.map((issue) => [issue.number, issue])).values()]
319
+ }
320
+
197
321
  function isNumber(value: number | null): value is number {
198
322
  return value !== null
199
323
  }
@@ -228,16 +352,23 @@ function describeIssue(raw: Issue, context: Context): MapIssue {
228
352
  return waitingFor.length ? 'blocked' : 'ready'
229
353
  }
230
354
 
231
- function nextStepOf(status: Status): string {
232
- if (status === 'done') return ''
233
- if (status === 'triage') return CONFIG.triageCommand
234
- if (status === 'active') return assignees.join(', ') || CONFIG.active[0] || '進行中'
355
+ /** 只算出「是哪一種下一步」。句子是頁面的事,在 `issue-map-i18n.ts` 依語言組出來。 */
356
+ function nextStepOf(status: Status): NextStep {
357
+ if (status === 'done') return { kind: 'none' }
358
+ if (status === 'triage') return { kind: 'command', command: CONFIG.triageCommand }
359
+ if (status === 'active') {
360
+ // 沒有 assignee 但掛了 active 標籤時,能講的就只有那個標籤名。
361
+ const label = CONFIG.active[0]
362
+ return { kind: 'active', who: assignees.length ? assignees : label ? [label] : [] }
363
+ }
235
364
  if (isParent) {
236
365
  const left = context.openChildren.get(raw.number) ?? 0
237
- return left ? `等 ${left} 張子票關完` : '子票全關,可以關掉了'
366
+ return left ? { kind: 'waitChildren', count: left } : { kind: 'parentReady' }
238
367
  }
239
- if (status === 'blocked') return `等 ${waitingFor.map((number) => `#${number}`).join(' ')}`
240
- return has(CONFIG.human) ? '人工實作' : CONFIG.implementCommand
368
+ if (status === 'blocked') return { kind: 'waitIssues', issues: waitingFor }
369
+ return has(CONFIG.human)
370
+ ? { kind: 'manual' }
371
+ : { kind: 'command', command: CONFIG.implementCommand }
241
372
  }
242
373
 
243
374
  const status = statusOf()
@@ -273,9 +404,9 @@ async function bundleClient(): Promise<string> {
273
404
  format: 'iife',
274
405
  minify: false,
275
406
  })
276
- if (!built.success) throw new Error(`打包 ${CLIENT} 失敗:${built.logs.join('\n')}`)
407
+ if (!built.success) throw new Error(`Failed to bundle ${CLIENT}: ${built.logs.join('\n')}`)
277
408
  const [output] = built.outputs
278
- if (!output) throw new Error(`打包 ${CLIENT} 沒有產出`)
409
+ if (!output) throw new Error(`Bundling ${CLIENT} produced no output`)
279
410
  return output.text()
280
411
  }
281
412
 
@@ -299,17 +430,17 @@ export async function renderFragment(snapshot: Snapshot): Promise<string> {
299
430
 
300
431
  function replaceIn(html: string, marker: RegExp, body: string, what: string): string {
301
432
  const next = html.replace(marker, `$1${body}$2`)
302
- if (next === html) throw new Error(`樣板缺少 ${what} 區塊:${TEMPLATE}`)
433
+ if (next === html) throw new Error(`Template is missing the ${what} block: ${TEMPLATE}`)
303
434
  return next
304
435
  }
305
436
 
306
437
  export function describe(snapshot: Snapshot): string {
307
438
  const done = snapshot.issues.filter((issue) => issue.status === 'done').length
308
- return `${snapshot.issues.length - done} 張未完成、${done} 張仍被引用的已完成(${snapshot.generatedAt})`
439
+ return `${snapshot.issues.length - done} unfinished, ${done} closed but still referenced (${snapshot.generatedAt})`
309
440
  }
310
441
 
311
442
  if (import.meta.main) {
312
443
  const snapshot = takeSnapshot()
313
444
  await Bun.write(OUTPUT, await renderFragment(snapshot))
314
- console.log(`已寫入 ${OUTPUT}:${describe(snapshot)}`)
445
+ console.log(`Wrote ${OUTPUT}: ${describe(snapshot)}`)
315
446
  }