issue-map 0.1.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.
@@ -0,0 +1,315 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * 開發地圖:把 GitHub Issues 的阻擋關係抓下來,塞進 `scripts/issue-map.html` 這份樣板,產出一頁
4
+ * 可以直接看的 HTML。
5
+ *
6
+ * 狀態的權威永遠是 GitHub Issues。這一頁只是**快照**:頁面上不能改狀態,要更新就重跑這支。
7
+ * 這樣不會長出第二個事實來源。
8
+ *
9
+ * 每張票的狀態、在等誰、下一步都在這裡算完才送進頁面,樣板只負責畫。
10
+ *
11
+ * 帶進快照的 issue:所有 open issue,加上仍被 open issue 牽著的 closed issue。後者畫成「已完成」
12
+ * 的節點讓進度看得見,沒人牽著之後自然消失。
13
+ *
14
+ * **要畫哪個 repo**:從 cwd 的 git 推斷,不必填——在那個 repo 裡跑 `bunx github:gunter1020/issue-map`
15
+ * 就好。要指定別的 repo 設 `GH_REPO`。標籤字彙與 parent 的慣例都能用環境變數調,見底下的
16
+ * `CONFIG`,整份對照表在 README。
17
+ *
18
+ * 票名不進地圖。曾經試過在內文加一個 `## 短名` 段落給站點當標籤,但那要每張票靠人維護、
19
+ * 而且是票名的第二個事實來源,改標題不會改它。機械縮短標題也試過,這裡的標題沒有一致結構,
20
+ * 縮出來讀不通。所以站點只掛票號,名字交給清單。
21
+ *
22
+ * 用法:
23
+ * bun run scripts/issue-map.ts # 寫到 dist/issue-map.html
24
+ * bun run scripts/issue-map.ts path/to/out.html
25
+ *
26
+ * 要「重新整理就是最新」,改跑 `scripts/issue-map-serve.ts`:它每個請求都呼叫這裡的
27
+ * `takeSnapshot` 重抓一次。
28
+ */
29
+
30
+ import { spawnSync } from 'bun'
31
+
32
+ import {
33
+ criticalPathOf,
34
+ groupsOf,
35
+ type MapIssue,
36
+ type Snapshot,
37
+ type Status,
38
+ } from './issue-map-model.ts'
39
+
40
+ const TEMPLATE = new URL('./issue-map.html', import.meta.url).pathname
41
+ const CLIENT = new URL('./issue-map-page.ts', import.meta.url).pathname
42
+ const OUTPUT = process.argv[2] ?? 'dist/issue-map.html'
43
+
44
+ /** 一次查得回來的上限。GraphQL 的 `first` 最多就是 100,超過會少票,所以超過就喊。 */
45
+ const PAGE = 100
46
+
47
+ function labelList(raw: string | undefined, fallback: string): readonly string[] {
48
+ return (raw ?? fallback)
49
+ .split(',')
50
+ .map((name) => name.trim())
51
+ .filter(Boolean)
52
+ }
53
+
54
+ /**
55
+ * 移植時要調的東西全在這裡,而且都有預設值——不設任何一個也跑得起來。
56
+ *
57
+ * repo 不在這裡:`gh` 的 `{owner}`/`{repo}` 佔位符會從 cwd 的 git 推斷,要指定別的 repo 就設
58
+ * `GH_REPO`(`gh` 自己的環境變數,fork 與多 remote 的判斷也一併交給它)。
59
+ */
60
+ const CONFIG = {
61
+ /** 子票在內文裡指向 parent 的標題。GitHub 原生 sub-issue 有值時優先用原生的。 */
62
+ parentHeading: process.env.ISSUE_MAP_PARENT_HEADING ?? 'Parent',
63
+ /** 掛了就是還沒評估完,不能交給誰做。 */
64
+ unready: labelList(process.env.ISSUE_MAP_LABELS_UNREADY, 'needs-triage,needs-info'),
65
+ /** 掛了才算評估完、可以動工。 */
66
+ ready: labelList(process.env.ISSUE_MAP_LABELS_READY, 'ready-for-agent,ready-for-human'),
67
+ /** 掛了代表有人在做,不必有 assignee。 */
68
+ active: labelList(process.env.ISSUE_MAP_LABELS_ACTIVE, 'in-progress'),
69
+ /** 這些要人做,下一步不寫實作指令。 */
70
+ human: labelList(process.env.ISSUE_MAP_LABELS_HUMAN, 'ready-for-human'),
71
+ /** 可以動工時要跑的指令。搬去沒有這個 skill 的 repo 就換掉,不然圖上會叫人跑不存在的東西。 */
72
+ implementCommand: process.env.ISSUE_MAP_CMD_IMPLEMENT ?? '/implement',
73
+ /** 還要評估時要跑的指令。 */
74
+ triageCommand: process.env.ISSUE_MAP_CMD_TRIAGE ?? '/triage',
75
+ } as const
76
+
77
+ type IssueState = 'OPEN' | 'CLOSED'
78
+
79
+ type RawIssue = {
80
+ readonly number: number
81
+ readonly title: string
82
+ readonly state: IssueState
83
+ readonly url: string
84
+ readonly body: string
85
+ readonly closedAt: string | null
86
+ /** 開票的人。GitHub 帳號被刪掉的話是 null。 */
87
+ readonly author: { readonly login: string } | null
88
+ /** GitHub 原生的 sub-issue 關係。沒用這套的 repo 一律是 null,改讀內文的標題。 */
89
+ readonly parent: { readonly number: number } | null
90
+ readonly labels: { readonly nodes: readonly { readonly name: string }[] }
91
+ readonly assignees: { readonly nodes: readonly { readonly login: string }[] }
92
+ readonly blockedBy: { readonly nodes: readonly { readonly number: number }[] }
93
+ }
94
+
95
+ /** 一路帶著算好的 parent,免得同一段內文被 regex 掃好幾次。 */
96
+ type Issue = RawIssue & { readonly parentNumber: number | null }
97
+
98
+ interface Connection {
99
+ pageInfo: { hasNextPage: boolean }
100
+ nodes: RawIssue[]
101
+ }
102
+ interface QueryResult {
103
+ repository: { nameWithOwner: string } & Record<'open' | 'closed', Connection>
104
+ }
105
+
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 } }
115
+ }
116
+ `
117
+
118
+ /** 一次問完 repo 名字與兩種狀態的 issue,只開一個 `gh` 行程。 */
119
+ const QUERY = `
120
+ query($owner: String!, $repo: String!) {
121
+ 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} }
125
+ }
126
+ }`
127
+
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} 張,這支要改成分頁抓`)
140
+ }
141
+ return repository
142
+ }
143
+
144
+ /** 原生 sub-issue 優先;沒有就讀內文的 `## <標題>` 之後第一個 `#<n>`。 */
145
+ const PARENT_IN_BODY = new RegExp(`##\\s*${CONFIG.parentHeading}\\s*\\n[\\s\\S]*?#(\\d+)`)
146
+ function withParent(raw: RawIssue): Issue {
147
+ const inBody = PARENT_IN_BODY.exec(raw.body)
148
+ return { ...raw, parentNumber: raw.parent?.number ?? (inBody ? Number(inBody[1]) : null) }
149
+ }
150
+
151
+ export function takeSnapshot(): Snapshot {
152
+ const repository = query()
153
+ const open = repository.open.nodes.map(withParent)
154
+ const closed = repository.closed.nodes.map(withParent)
155
+
156
+ const openNumbers = new Set(open.map((issue) => issue.number))
157
+ const openParents = new Set(open.map((issue) => issue.parentNumber).filter(isNumber))
158
+ const blockers = new Set(
159
+ open.flatMap((issue) => issue.blockedBy.nodes.map((blocker) => blocker.number)),
160
+ )
161
+
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
+ ]
173
+ const parents = new Set(kept.map((issue) => issue.parentNumber).filter(isNumber))
174
+ const openChildren = new Map<number, number>()
175
+ for (const issue of kept) {
176
+ if (issue.parentNumber === null || issue.state !== 'OPEN') continue
177
+ openChildren.set(issue.parentNumber, (openChildren.get(issue.parentNumber) ?? 0) + 1)
178
+ }
179
+
180
+ // 這個 repo 到底有沒有在用 triage 標籤。都沒看到就別拿它當閘門,否則每張票都會變成待 triage。
181
+ const seen = new Set(kept.flatMap((issue) => issue.labels.nodes.map((label) => label.name)))
182
+ const triaged = [...CONFIG.unready, ...CONFIG.ready].some((name) => seen.has(name))
183
+
184
+ const issues = kept
185
+ .map((raw) => describeIssue(raw, { open: openNumbers, openChildren, parents, triaged }))
186
+ .toSorted((a, b) => a.number - b.number)
187
+ return {
188
+ generatedAt: new Date().toISOString(),
189
+ repo: repository.nameWithOwner,
190
+ labels: { ready: CONFIG.ready, unready: CONFIG.unready },
191
+ groups: groupsOf(issues),
192
+ criticalPath: criticalPathOf(issues),
193
+ issues,
194
+ }
195
+ }
196
+
197
+ function isNumber(value: number | null): value is number {
198
+ return value !== null
199
+ }
200
+
201
+ interface Context {
202
+ readonly open: Set<number>
203
+ /** 每張主票底下還開著的子票。主票的狀態看的是這個,不是自己的阻擋者。 */
204
+ readonly openChildren: Map<number, number>
205
+ readonly parents: Set<number>
206
+ /** 這個 repo 有在用 triage 標籤,狀態才把它們當閘門。 */
207
+ readonly triaged: boolean
208
+ }
209
+
210
+ function describeIssue(raw: Issue, context: Context): MapIssue {
211
+ const labels = raw.labels.nodes.map((label) => label.name)
212
+ const assignees = raw.assignees.nodes.map((assignee) => assignee.login)
213
+ const blockedBy = raw.blockedBy.nodes.map((blocker) => blocker.number)
214
+ // 只有還開著的阻擋者算閘門;GitHub 的 blocked_by 摘要也是這樣算的。
215
+ const waitingFor = blockedBy.filter((number) => context.open.has(number))
216
+ const isParent = context.parents.has(raw.number)
217
+ const has = (names: readonly string[]) => names.some((name) => labels.includes(name))
218
+
219
+ function statusOf(): Status {
220
+ if (raw.state === 'CLOSED') return 'done'
221
+ // Parent 自己不做事,看的是子票:還有子票開著就是還在等。
222
+ if (isParent) {
223
+ return waitingFor.length || (context.openChildren.get(raw.number) ?? 0) ? 'blocked' : 'ready'
224
+ }
225
+ // 沒掛角色標籤的票還沒被評估過,不能因為沒人擋它就當成可接手。
226
+ if (context.triaged && (has(CONFIG.unready) || !has(CONFIG.ready))) return 'triage'
227
+ if (assignees.length || has(CONFIG.active)) return 'active'
228
+ return waitingFor.length ? 'blocked' : 'ready'
229
+ }
230
+
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] || '進行中'
235
+ if (isParent) {
236
+ const left = context.openChildren.get(raw.number) ?? 0
237
+ return left ? `等 ${left} 張子票關完` : '子票全關,可以關掉了'
238
+ }
239
+ if (status === 'blocked') return `等 ${waitingFor.map((number) => `#${number}`).join(' ')}`
240
+ return has(CONFIG.human) ? '人工實作' : CONFIG.implementCommand
241
+ }
242
+
243
+ const status = statusOf()
244
+ return {
245
+ number: raw.number,
246
+ title: raw.title,
247
+ url: raw.url,
248
+ closedAt: raw.closedAt,
249
+ author: raw.author?.login ?? '',
250
+ labels,
251
+ assignees,
252
+ parent: raw.parentNumber,
253
+ blockedBy,
254
+ waitingFor,
255
+ status,
256
+ nextStep: nextStepOf(status),
257
+ isParent,
258
+ }
259
+ }
260
+
261
+ /** 把快照塞進樣板。回傳的是 artifact 用的片段(沒有 doctype/html/head/body)。 */
262
+ /**
263
+ * 把畫面那一支打包成一段可以直接放進 `<script>` 的程式碼。
264
+ *
265
+ * 產出必須是**一個檔案**(artifact 的頁面就是一份 HTML),但來源不必——來源是 TypeScript,
266
+ * 所以型別跟這裡共用同一份定義,而且純推導測得到。`format: 'iife'` 是因為它要塞進行內;
267
+ * 不 minify 是因為這是開發用的頁面,讀得懂比小重要。
268
+ */
269
+ async function bundleClient(): Promise<string> {
270
+ const built = await Bun.build({
271
+ entrypoints: [CLIENT],
272
+ target: 'browser',
273
+ format: 'iife',
274
+ minify: false,
275
+ })
276
+ if (!built.success) throw new Error(`打包 ${CLIENT} 失敗:${built.logs.join('\n')}`)
277
+ const [output] = built.outputs
278
+ if (!output) throw new Error(`打包 ${CLIENT} 沒有產出`)
279
+ return output.text()
280
+ }
281
+
282
+ export async function renderFragment(snapshot: Snapshot): Promise<string> {
283
+ const template = await Bun.file(TEMPLATE).text()
284
+ const withData = replaceIn(
285
+ template,
286
+ /(<script id="issue-map-data" type="application\/json">)[\s\S]*?(<\/script>)/,
287
+ // JSON 裡把 `<` 一律逃脫成 `\u003c`:那在 JSON 字串裡等價,而且不可能提早關掉 <script>。
288
+ JSON.stringify(snapshot).replaceAll('<', '\\u003c'),
289
+ 'issue-map-data',
290
+ )
291
+ return replaceIn(
292
+ withData,
293
+ /(<script id="issue-map-code">)[\s\S]*?(<\/script>)/,
294
+ // 程式碼不能這樣逃脫——`a < b` 會被改壞。只擋真正會提早收尾的那一個序列。
295
+ (await bundleClient()).replace(/<\/script/gi, '<\\/script'),
296
+ 'issue-map-code',
297
+ )
298
+ }
299
+
300
+ function replaceIn(html: string, marker: RegExp, body: string, what: string): string {
301
+ const next = html.replace(marker, `$1${body}$2`)
302
+ if (next === html) throw new Error(`樣板缺少 ${what} 區塊:${TEMPLATE}`)
303
+ return next
304
+ }
305
+
306
+ export function describe(snapshot: Snapshot): string {
307
+ const done = snapshot.issues.filter((issue) => issue.status === 'done').length
308
+ return `${snapshot.issues.length - done} 張未完成、${done} 張仍被引用的已完成(${snapshot.generatedAt})`
309
+ }
310
+
311
+ if (import.meta.main) {
312
+ const snapshot = takeSnapshot()
313
+ await Bun.write(OUTPUT, await renderFragment(snapshot))
314
+ console.log(`已寫入 ${OUTPUT}:${describe(snapshot)}`)
315
+ }