issue-map 0.3.1 → 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.
- package/README.ja.md +87 -111
- package/README.md +94 -115
- package/README.zh-CN.md +80 -81
- package/README.zh-TW.md +80 -81
- package/package.json +10 -15
- package/{scripts → src}/issue-map-i18n.ts +11 -5
- package/{scripts → src}/issue-map-model.ts +55 -26
- package/src/issue-map-page.ts +411 -0
- package/{scripts → src}/issue-map-serve.ts +33 -19
- package/src/issue-map-view.ts +694 -0
- package/{scripts → src}/issue-map.html +33 -6
- package/src/issue-map.ts +657 -0
- package/scripts/issue-map-page.ts +0 -991
- package/scripts/issue-map.ts +0 -446
package/src/issue-map.ts
ADDED
|
@@ -0,0 +1,657 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* 開發地圖:把 GitHub Issues 的阻擋關係抓下來,塞進 `src/issue-map.html` 這份樣板,產出一頁
|
|
4
|
+
* 可以直接看的 HTML。每張票的狀態、在等誰、下一步都在這裡算完才送進頁面,樣板只負責畫。
|
|
5
|
+
*
|
|
6
|
+
* 帶進快照的 issue:所有 open issue,加上仍被 open issue 牽著的 closed issue(畫成「已完成」的
|
|
7
|
+
* 節點讓進度看得見,沒人牽著之後自然消失)。closed 是**指名**去要的——阻擋者、parent、parent
|
|
8
|
+
* 底下的子票,不掃整包,因為老 repo 幾千張 closed 裡通常只有個位數會留下。代價:同一組裡已
|
|
9
|
+
* 完成的兄弟票要靠 GitHub 原生 sub-issue 才抽得到,用內文 `## Parent` 慣例的 repo 看不到它們,
|
|
10
|
+
* 那一組的進度會比實際少。
|
|
11
|
+
*
|
|
12
|
+
* 用法:`bun run src/issue-map.ts [out.html]`,預設寫到 `dist/issue-map.html`。
|
|
13
|
+
* 環境變數與設計決定見 README;移植要調的東西在底下的 `CONFIG`。
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { spawn } from 'bun'
|
|
17
|
+
|
|
18
|
+
import { t } from './issue-map-i18n.ts'
|
|
19
|
+
import {
|
|
20
|
+
criticalPathOf,
|
|
21
|
+
groupsOf,
|
|
22
|
+
type MapIssue,
|
|
23
|
+
type NextStep,
|
|
24
|
+
type Snapshot,
|
|
25
|
+
type Status,
|
|
26
|
+
} from './issue-map-model.ts'
|
|
27
|
+
import { esc, langOptionsHTML, viewOf } from './issue-map-view.ts'
|
|
28
|
+
|
|
29
|
+
const TEMPLATE = new URL('./issue-map.html', import.meta.url).pathname
|
|
30
|
+
const CLIENT = new URL('./issue-map-page.ts', import.meta.url).pathname
|
|
31
|
+
const OUTPUT = process.argv[2] ?? 'dist/issue-map.html'
|
|
32
|
+
|
|
33
|
+
/** 一頁的張數。GraphQL 的 `first` 最多就是 100,票再多就靠 cursor 一頁一頁接。 */
|
|
34
|
+
const PAGE = 100
|
|
35
|
+
/** 一次用 alias 指名幾張票。GraphQL 對單一查詢的節點數有上限,這個量級離它還很遠。 */
|
|
36
|
+
const BATCH = 50
|
|
37
|
+
|
|
38
|
+
function labelList(raw: string | undefined, fallback: string): readonly string[] {
|
|
39
|
+
return (raw ?? fallback)
|
|
40
|
+
.split(',')
|
|
41
|
+
.map((name) => name.trim())
|
|
42
|
+
.filter(Boolean)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* 移植時要調的東西全在這裡,而且都有預設值——不設任何一個也跑得起來。
|
|
47
|
+
*
|
|
48
|
+
* repo 不在這裡:那是 `gh` 自己的 `GH_REPO`,fork 與多 remote 的判斷也一併交給它。
|
|
49
|
+
*/
|
|
50
|
+
const CONFIG = {
|
|
51
|
+
/**
|
|
52
|
+
* 子票在內文裡指向 parent 的標題,例如 `Parent`。**沒設就不讀內文**,也就不會去抓內文。
|
|
53
|
+
*
|
|
54
|
+
* 內文佔了回應的九成以上,而它只餵這一條 regex;用 GitHub 原生 sub-issue 的 repo 一個字都
|
|
55
|
+
* 用不到。所以這條慣例改成明講才生效——原生關係一律優先,設了也不會蓋過它。
|
|
56
|
+
*/
|
|
57
|
+
parentHeading: process.env.ISSUE_MAP_PARENT_HEADING ?? '',
|
|
58
|
+
/** 掛了就是還沒評估完,不能交給誰做。 */
|
|
59
|
+
unready: labelList(process.env.ISSUE_MAP_LABELS_UNREADY, 'needs-triage,needs-info'),
|
|
60
|
+
/** 掛了才算評估完、可以動工。 */
|
|
61
|
+
ready: labelList(process.env.ISSUE_MAP_LABELS_READY, 'ready-for-agent,ready-for-human'),
|
|
62
|
+
/** 掛了代表有人在做,不必有 assignee。 */
|
|
63
|
+
active: labelList(process.env.ISSUE_MAP_LABELS_ACTIVE, 'in-progress'),
|
|
64
|
+
/** 這些要人做,下一步不寫實作指令。 */
|
|
65
|
+
human: labelList(process.env.ISSUE_MAP_LABELS_HUMAN, 'ready-for-human'),
|
|
66
|
+
/** 可以動工時要跑的指令。搬去沒有這個 skill 的 repo 就換掉,不然圖上會叫人跑不存在的東西。 */
|
|
67
|
+
implementCommand: process.env.ISSUE_MAP_CMD_IMPLEMENT ?? '/implement',
|
|
68
|
+
/** 還要評估時要跑的指令。 */
|
|
69
|
+
triageCommand: process.env.ISSUE_MAP_CMD_TRIAGE ?? '/triage',
|
|
70
|
+
} as const
|
|
71
|
+
|
|
72
|
+
export type IssueState = 'OPEN' | 'CLOSED'
|
|
73
|
+
|
|
74
|
+
/** GraphQL 回來的一張票。純推導那幾支吃的就是這個形狀,所以測試組得出來。 */
|
|
75
|
+
export type RawIssue = {
|
|
76
|
+
readonly number: number
|
|
77
|
+
readonly title: string
|
|
78
|
+
readonly state: IssueState
|
|
79
|
+
readonly url: string
|
|
80
|
+
/** 只有設了 `ISSUE_MAP_PARENT_HEADING` 才會去抓,其餘時候不存在。 */
|
|
81
|
+
readonly body?: string
|
|
82
|
+
readonly closedAt: string | null
|
|
83
|
+
/** 開票的人。GitHub 帳號被刪掉的話是 null。 */
|
|
84
|
+
readonly author: { readonly login: string } | null
|
|
85
|
+
/** GitHub 原生的 sub-issue 關係。沒用這套的 repo 一律是 null,改讀內文的標題。 */
|
|
86
|
+
readonly parent: { readonly number: number } | null
|
|
87
|
+
readonly labels: { readonly nodes: readonly { readonly name: string }[] }
|
|
88
|
+
readonly assignees: { readonly nodes: readonly { readonly login: string }[] }
|
|
89
|
+
readonly blockedBy: { readonly nodes: readonly { readonly number: number }[] }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** 一路帶著算好的 parent,免得同一段內文被 regex 掃好幾次。 */
|
|
93
|
+
export type Issue = RawIssue & { readonly parentNumber: number | null }
|
|
94
|
+
|
|
95
|
+
/** GraphQL 的一頁。`collect` 吃的就是這個形狀。 */
|
|
96
|
+
export interface Page<T> {
|
|
97
|
+
pageInfo: { hasNextPage: boolean; endCursor: string | null }
|
|
98
|
+
nodes: T[]
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** 內文只餵 `PARENT_IN_BODY` 一條 regex,沒設慣例就不要去抓——它佔了回應的九成以上。 */
|
|
102
|
+
const BODY_FIELD = CONFIG.parentHeading ? ' body' : ''
|
|
103
|
+
|
|
104
|
+
const ISSUE_FIELDS = `
|
|
105
|
+
number title state url closedAt${BODY_FIELD}
|
|
106
|
+
author { login }
|
|
107
|
+
parent { number }
|
|
108
|
+
labels(first: 20) { nodes { name } }
|
|
109
|
+
assignees(first: 10) { nodes { login } }
|
|
110
|
+
blockedBy(first: 50) { nodes { number } }
|
|
111
|
+
`
|
|
112
|
+
|
|
113
|
+
type GraphQLError = { readonly type?: string; readonly message?: string }
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* 從 `gh api graphql` 的輸出取出 data。
|
|
117
|
+
*
|
|
118
|
+
* **查不到的票不是錯誤。** 指名去要某個票號時,GitHub 會同時回 `data`(那個 alias 是 `null`)
|
|
119
|
+
* 與一筆 `NOT_FOUND`,而 `gh` 為了那筆錯誤以非零離開。號碼其實是 PR、票被轉移或刪掉、內文
|
|
120
|
+
* 慣例掃出來的誤判——在老 repo 上都是常態,一個掃不到就讓整張圖產不出來並不合理。所以只有
|
|
121
|
+
* `NOT_FOUND` 以外的錯誤才拋,資料照用。
|
|
122
|
+
*
|
|
123
|
+
* 連 data 都沒有(沒登入、網路不通)就拿 stderr 當原因拋出去。
|
|
124
|
+
*/
|
|
125
|
+
export function dataOrThrow<T>(stdout: string, stderr: string): T {
|
|
126
|
+
let parsed: { data?: T; errors?: readonly GraphQLError[] } | undefined
|
|
127
|
+
try {
|
|
128
|
+
parsed = stdout ? (JSON.parse(stdout) as typeof parsed) : undefined
|
|
129
|
+
} catch {
|
|
130
|
+
parsed = undefined
|
|
131
|
+
}
|
|
132
|
+
const fatal = (parsed?.errors ?? []).filter((error) => error.type !== 'NOT_FOUND')
|
|
133
|
+
if (!parsed?.data || fatal.length) {
|
|
134
|
+
const why = fatal.length ? JSON.stringify(fatal) : stderr.trim() || stdout.trim()
|
|
135
|
+
throw new Error(`gh api graphql failed: ${why}`)
|
|
136
|
+
}
|
|
137
|
+
return parsed.data
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* 跑一次 `gh api graphql`。
|
|
142
|
+
*
|
|
143
|
+
* 票號是我們自己從前一次結果拿到的整數,直接組進查詢字串;只有 cursor 走變數——它是 API 給的
|
|
144
|
+
* 不透明字串,沒有理由自己去逃脫它。
|
|
145
|
+
*
|
|
146
|
+
* **非同步**:互不相干的查詢要能一起送,同步等一個子行程就不可能並行;而 server 那邊同步等
|
|
147
|
+
* 還會把整條 event loop 卡住,第二個瀏覽器分頁得等第一個抓完才拿得到回應。
|
|
148
|
+
*/
|
|
149
|
+
async function run<T>(query: string, variables: Record<string, string> = {}): Promise<T> {
|
|
150
|
+
// `{owner}`/`{repo}` 由 gh 從 cwd 的 git 推斷,`GH_REPO` 可以蓋過去。
|
|
151
|
+
const args = [
|
|
152
|
+
'gh',
|
|
153
|
+
'api',
|
|
154
|
+
'graphql',
|
|
155
|
+
'-f',
|
|
156
|
+
`query=${query}`,
|
|
157
|
+
'-F',
|
|
158
|
+
'owner={owner}',
|
|
159
|
+
'-F',
|
|
160
|
+
'repo={repo}',
|
|
161
|
+
]
|
|
162
|
+
for (const [name, value] of Object.entries(variables)) args.push('-f', `${name}=${value}`)
|
|
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
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const OPEN_QUERY = `
|
|
195
|
+
query($owner: String!, $repo: String!, $after: String) {
|
|
196
|
+
repository(owner: $owner, name: $repo) {
|
|
197
|
+
nameWithOwner
|
|
198
|
+
issues(states: OPEN, first: ${PAGE}, after: $after, orderBy: { field: CREATED_AT, direction: DESC }) {
|
|
199
|
+
pageInfo { hasNextPage endCursor }
|
|
200
|
+
nodes { ${ISSUE_FIELDS} }
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}`
|
|
204
|
+
|
|
205
|
+
/** open issue 全部都要,所以一路翻到底——票超過一頁不是錯誤,是常態。 */
|
|
206
|
+
async function fetchOpen(): Promise<{ nameWithOwner: string; open: RawIssue[] }> {
|
|
207
|
+
type Result = { repository: { nameWithOwner: string; issues: Page<RawIssue> } }
|
|
208
|
+
let nameWithOwner = ''
|
|
209
|
+
const open = await collect<RawIssue>(async (after) => {
|
|
210
|
+
const { repository } = await run<Result>(OPEN_QUERY, after ? { after } : {})
|
|
211
|
+
nameWithOwner = repository.nameWithOwner
|
|
212
|
+
return repository.issues
|
|
213
|
+
})
|
|
214
|
+
return { nameWithOwner, open }
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** alias 不能以數字開頭,所以票號前面補一個 `i`。 */
|
|
218
|
+
const alias = (number: number) => `i${number}`
|
|
219
|
+
|
|
220
|
+
export function chunks<T>(items: readonly T[], size: number): T[][] {
|
|
221
|
+
const batches: T[][] = []
|
|
222
|
+
for (let index = 0; index < items.length; index += size) {
|
|
223
|
+
batches.push(items.slice(index, index + size))
|
|
224
|
+
}
|
|
225
|
+
return batches
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* 一批 alias 指名要哪幾個票號底下的 `selection`。
|
|
230
|
+
*
|
|
231
|
+
* 組查詢、批次切分、依票號取回——這三件事只有這一份。批次之間互不相干,所以一起送;查不到的
|
|
232
|
+
* 票號不會出現在結果裡(GraphQL 那個 alias 回 null,理由見 `dataOrThrow`)。
|
|
233
|
+
*/
|
|
234
|
+
export function aliasedQuery(batch: readonly number[], selection: string): string {
|
|
235
|
+
return `
|
|
236
|
+
query($owner: String!, $repo: String!) {
|
|
237
|
+
repository(owner: $owner, name: $repo) {
|
|
238
|
+
${batch.map((number) => `${alias(number)}: issue(number: ${number}) { ${selection} }`).join('\n ')}
|
|
239
|
+
}
|
|
240
|
+
}`
|
|
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]
|
|
252
|
+
for (const number of batch) {
|
|
253
|
+
const node = repository?.[alias(number)]
|
|
254
|
+
if (node) found.set(number, node)
|
|
255
|
+
}
|
|
256
|
+
})
|
|
257
|
+
return found
|
|
258
|
+
}
|
|
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
|
+
|
|
269
|
+
function childrenQuery(parent: number): string {
|
|
270
|
+
return `
|
|
271
|
+
query($owner: String!, $repo: String!, $after: String) {
|
|
272
|
+
repository(owner: $owner, name: $repo) {
|
|
273
|
+
issue(number: ${parent}) {
|
|
274
|
+
subIssues(first: ${PAGE}, after: $after) {
|
|
275
|
+
pageInfo { hasNextPage endCursor }
|
|
276
|
+
nodes { ${CHILD_FIELDS} }
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}`
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* 這些主票底下有哪些子票、各自是開是關。為的是把同一組裡**已完成**的兄弟票撈出來當進度。
|
|
285
|
+
*
|
|
286
|
+
* **只問票號與狀態。** `subIssues` 沒有 `states:` 可以篩,一定會連開著的一起回來,而開著的
|
|
287
|
+
* 兄弟本來就在 open 那包;關掉的要的是完整欄位,那一趟跟其他指名去要的票一起走。整包完整欄位
|
|
288
|
+
* 抓回來再丟掉的話,用原生 sub-issue 的 repo 每次都在重抓自己已經有的東西。
|
|
289
|
+
*
|
|
290
|
+
* 只有 GitHub 原生 sub-issue 有值;用內文 `## Parent` 慣例的 repo 這裡是空的(檔頭說的那個
|
|
291
|
+
* 代價)。
|
|
292
|
+
*/
|
|
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
|
+
)
|
|
315
|
+
}
|
|
316
|
+
for (const more of await Promise.all(rest)) children.push(...more)
|
|
317
|
+
return children
|
|
318
|
+
}
|
|
319
|
+
|
|
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
|
|
339
|
+
}
|
|
340
|
+
|
|
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
|
+
}
|
|
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
|
+
} {
|
|
359
|
+
const openNumbers = new Set(open.map((issue) => issue.number))
|
|
360
|
+
const parents = new Set(open.map((issue) => issue.parentNumber).filter(isNumber))
|
|
361
|
+
const blockers = new Set(
|
|
362
|
+
open.flatMap((issue) => issue.blockedBy.nodes.map((blocker) => blocker.number)),
|
|
363
|
+
)
|
|
364
|
+
return {
|
|
365
|
+
numbers: [...new Set([...blockers, ...parents])].filter((number) => !openNumbers.has(number)),
|
|
366
|
+
parents: [...parents],
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
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)
|
|
379
|
+
.filter((issue) => issue.state === 'CLOSED' && !openNumbers.has(issue.number))
|
|
380
|
+
.map((issue) => withParent(issue))
|
|
381
|
+
}
|
|
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))
|
|
406
|
+
const kept = [...open, ...closed]
|
|
407
|
+
const parents = new Set(kept.map((issue) => issue.parentNumber).filter(isNumber))
|
|
408
|
+
const openChildren = new Map<number, number>()
|
|
409
|
+
for (const issue of kept) {
|
|
410
|
+
if (issue.parentNumber === null || issue.state !== 'OPEN') continue
|
|
411
|
+
openChildren.set(issue.parentNumber, (openChildren.get(issue.parentNumber) ?? 0) + 1)
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// 這個 repo 到底有沒有在用 triage 標籤。都沒看到就別拿它當閘門,否則每張票都會變成待 triage。
|
|
415
|
+
const seen = new Set(kept.flatMap((issue) => issue.labels.nodes.map((label) => label.name)))
|
|
416
|
+
const triaged = [...CONFIG.unready, ...CONFIG.ready].some((name) => seen.has(name))
|
|
417
|
+
|
|
418
|
+
const issues = kept
|
|
419
|
+
.map((raw) =>
|
|
420
|
+
describeIssue(raw, { open: openNumbers, openChildren, parents, triaged, rules: CONFIG }),
|
|
421
|
+
)
|
|
422
|
+
.toSorted((a, b) => a.number - b.number)
|
|
423
|
+
return {
|
|
424
|
+
generatedAt: new Date().toISOString(),
|
|
425
|
+
repo: nameWithOwner,
|
|
426
|
+
// 閘門開著沒有一起寫進去:頁尾要說的就是這一次的判定,不能自己從字彙長度重猜。
|
|
427
|
+
labels: { ready: CONFIG.ready, unready: CONFIG.unready, gated: triaged },
|
|
428
|
+
groups: groupsOf(issues),
|
|
429
|
+
criticalPath: criticalPathOf(issues),
|
|
430
|
+
issues,
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/** 同一張票可能同時是某人的阻擋者又是某人的兄弟,用票號收斂成一張。 */
|
|
435
|
+
function dedupe(issues: readonly RawIssue[]): RawIssue[] {
|
|
436
|
+
return [...new Map(issues.map((issue) => [issue.number, issue])).values()]
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function isNumber(value: number | null): value is number {
|
|
440
|
+
return value !== null
|
|
441
|
+
}
|
|
442
|
+
|
|
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>
|
|
462
|
+
/** 每張主票底下還開著的子票。主票的狀態看的是這個,不是自己的阻擋者。 */
|
|
463
|
+
readonly openChildren: ReadonlyMap<number, number>
|
|
464
|
+
readonly parents: ReadonlySet<number>
|
|
465
|
+
/** 這個 repo 有在用 triage 標籤,狀態才把它們當閘門。 */
|
|
466
|
+
readonly triaged: boolean
|
|
467
|
+
readonly rules: Rules
|
|
468
|
+
}
|
|
469
|
+
|
|
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
|
|
480
|
+
// 只有還開著的阻擋者算閘門;GitHub 的 blocked_by 摘要也是這樣算的。
|
|
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))
|
|
485
|
+
|
|
486
|
+
function statusOf(): Status {
|
|
487
|
+
if (issue.state === 'CLOSED') return 'done'
|
|
488
|
+
// Parent 自己不做事,看的是子票:還有子票開著就是還在等。
|
|
489
|
+
if (isParent) return waitingFor.length || openChildren ? 'blocked' : 'ready'
|
|
490
|
+
// 沒掛角色標籤的票還沒被評估過,不能因為沒人擋它就當成可接手。
|
|
491
|
+
if (repo.triaged && (has(rules.unready) || !has(rules.ready))) return 'triage'
|
|
492
|
+
if (issue.assignees.length || has(rules.active)) return 'active'
|
|
493
|
+
return waitingFor.length ? 'blocked' : 'ready'
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/** 只算出「是哪一種下一步」。句子是頁面的事,在 `issue-map-i18n.ts` 依語言組出來。 */
|
|
497
|
+
function nextStepOf(status: Status): NextStep {
|
|
498
|
+
if (status === 'done') return { kind: 'none' }
|
|
499
|
+
if (status === 'triage') return { kind: 'command', command: rules.triageCommand }
|
|
500
|
+
if (status === 'active') {
|
|
501
|
+
// 沒有 assignee 但掛了 active 標籤時,能講的就只有那個標籤名。
|
|
502
|
+
const label = rules.active[0]
|
|
503
|
+
return {
|
|
504
|
+
kind: 'active',
|
|
505
|
+
who: issue.assignees.length ? issue.assignees : label ? [label] : [],
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
// 主票先講子票;子票全關卻還是 blocked,就是它自己被別的票擋著,那時要講那張票。
|
|
509
|
+
if (isParent && openChildren) return { kind: 'waitChildren', count: openChildren }
|
|
510
|
+
if (status === 'blocked') return { kind: 'waitIssues', issues: waitingFor }
|
|
511
|
+
if (isParent) return { kind: 'parentReady' }
|
|
512
|
+
return has(rules.human)
|
|
513
|
+
? { kind: 'manual' }
|
|
514
|
+
: { kind: 'command', command: rules.implementCommand }
|
|
515
|
+
}
|
|
516
|
+
|
|
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)
|
|
525
|
+
return {
|
|
526
|
+
number: raw.number,
|
|
527
|
+
title: raw.title,
|
|
528
|
+
url: raw.url,
|
|
529
|
+
closedAt: raw.closedAt,
|
|
530
|
+
author: raw.author?.login ?? '',
|
|
531
|
+
labels,
|
|
532
|
+
assignees,
|
|
533
|
+
parent: raw.parentNumber,
|
|
534
|
+
blockedBy,
|
|
535
|
+
...verdictOf({ number: raw.number, state: raw.state, labels, assignees, blockedBy }, repo),
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/**
|
|
540
|
+
* 把畫面那一支打包成一段可以直接放進 `<script>` 的程式碼。
|
|
541
|
+
*
|
|
542
|
+
* 產出必須是一個檔案,但來源不必——來源是 TypeScript,所以型別跟這裡共用同一份定義,而且純
|
|
543
|
+
* 推導測得到。`format: 'iife'` 是因為它要塞進行內;不 minify 是因為這是開發用的頁面。
|
|
544
|
+
*/
|
|
545
|
+
async function bundleClient(): Promise<string> {
|
|
546
|
+
const built = await Bun.build({
|
|
547
|
+
entrypoints: [CLIENT],
|
|
548
|
+
target: 'browser',
|
|
549
|
+
format: 'iife',
|
|
550
|
+
minify: false,
|
|
551
|
+
})
|
|
552
|
+
if (!built.success) throw new Error(`Failed to bundle ${CLIENT}: ${built.logs.join('\n')}`)
|
|
553
|
+
const [output] = built.outputs
|
|
554
|
+
if (!output) throw new Error(`Bundling ${CLIENT} produced no output`)
|
|
555
|
+
return output.text()
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* 把樣板裡某個容器的內容填掉。
|
|
560
|
+
*
|
|
561
|
+
* 樣板裡這些容器都是空的(`<div id="x"></div>`),所以只要在開頭標籤與結尾標籤之間插入即可,
|
|
562
|
+
* 不必真的剖析 HTML。填不到就是樣板被改壞了,直接喊。
|
|
563
|
+
*/
|
|
564
|
+
function fillById(html: string, id: string, body: string): string {
|
|
565
|
+
// 標籤名要吃得到數字,`h1`、`h2` 都是容器。
|
|
566
|
+
const marker = new RegExp(`(<[a-z][a-z0-9]*[^>]*\\sid="${id}"[^>]*>)(</[a-z][a-z0-9]*>)`)
|
|
567
|
+
return replaceIn(html, marker, body, `empty #${id}`)
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/**
|
|
571
|
+
* 建置時就把整頁畫好。
|
|
572
|
+
*
|
|
573
|
+
* 標記由 `issue-map-view.ts` 產生,畫面那一支重畫時用的是同一批函式——所以沒有 JS 的環境看到的
|
|
574
|
+
* 是同一份頁面,只是不能互動。少了這一步,擋掉 inline script 的地方(嚴格 CSP、某些預覽窗)
|
|
575
|
+
* 拿到的會是一份空骨架。
|
|
576
|
+
*
|
|
577
|
+
* 預設語言是英文;換語言要有 JS,那本來就不是靜態檔能做的事。
|
|
578
|
+
*/
|
|
579
|
+
function prerender(html: string, snapshot: Snapshot): string {
|
|
580
|
+
const view = viewOf(snapshot)
|
|
581
|
+
const foot = view.footerHTML()
|
|
582
|
+
const detail = view.detailPanelHTML(view.defaultPick())
|
|
583
|
+
const rows = view.rowsHTML('all')
|
|
584
|
+
const title = view.title()
|
|
585
|
+
const filled: [string, string][] = [
|
|
586
|
+
['eyebrow', esc(view.eyebrow())],
|
|
587
|
+
['page-title', esc(title)],
|
|
588
|
+
['lede', view.lede()],
|
|
589
|
+
['stats', view.statsHTML()],
|
|
590
|
+
['detail', detail.html],
|
|
591
|
+
['groups', view.groupsHTML()],
|
|
592
|
+
['list-title', esc(t('list.title'))],
|
|
593
|
+
['list-sub', esc(t('list.count', { n: rows.shown }))],
|
|
594
|
+
['tabs', view.tabsHTML('all')],
|
|
595
|
+
['rows', rows.html],
|
|
596
|
+
['foot-truth', foot.truth],
|
|
597
|
+
['foot-refresh', foot.refresh],
|
|
598
|
+
['foot-config', foot.config],
|
|
599
|
+
['lang', langOptionsHTML()],
|
|
600
|
+
]
|
|
601
|
+
const withBody = filled.reduce((acc, [id, body]) => fillById(acc, id, body), html)
|
|
602
|
+
return replaceIn(withBody, /(<title>)[\s\S]*?(<\/title>)/, esc(title), 'title')
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/** 把快照塞進樣板。回傳的是 artifact 用的片段(沒有 doctype/html/head/body)。 */
|
|
606
|
+
export async function renderFragment(snapshot: Snapshot): Promise<string> {
|
|
607
|
+
const template = await Bun.file(TEMPLATE).text()
|
|
608
|
+
const withData = replaceIn(
|
|
609
|
+
template,
|
|
610
|
+
/(<script id="issue-map-data" type="application\/json">)[\s\S]*?(<\/script>)/,
|
|
611
|
+
// JSON 裡把 `<` 一律逃脫成 `\u003c`:那在 JSON 字串裡等價,而且不可能提早關掉 <script>。
|
|
612
|
+
JSON.stringify(snapshot).replaceAll('<', '\\u003c'),
|
|
613
|
+
'issue-map-data',
|
|
614
|
+
)
|
|
615
|
+
const withPage = prerender(withData, snapshot)
|
|
616
|
+
return replaceIn(
|
|
617
|
+
withPage,
|
|
618
|
+
/(<script id="issue-map-code">)[\s\S]*?(<\/script>)/,
|
|
619
|
+
// 程式碼不能這樣逃脫——`a < b` 會被改壞。只擋真正會提早收尾的那一個序列。
|
|
620
|
+
(await bundleClient()).replace(/<\/script/gi, '<\\/script'),
|
|
621
|
+
'issue-map-code',
|
|
622
|
+
)
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/**
|
|
626
|
+
* 把片段包成一份完整文件。**直接開檔案看的一律走這裡**:少了 doctype 瀏覽器會進 quirks mode。
|
|
627
|
+
* charset 是防禦性的——從 `file://` 開沒有 header 可依靠,而這一頁帶著四種語言的文案。
|
|
628
|
+
*
|
|
629
|
+
* `lang` 是預設語言;頁面上換語言時畫面那一支會改掉 `documentElement.lang`。
|
|
630
|
+
*/
|
|
631
|
+
export async function renderDocument(snapshot: Snapshot): Promise<string> {
|
|
632
|
+
const head =
|
|
633
|
+
'<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">'
|
|
634
|
+
return `<!doctype html><html lang="en"><head>${head}</head><body>${await renderFragment(snapshot)}</body></html>`
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/**
|
|
638
|
+
* 把樣板裡 `marker` 圈起來的那一段換成 `body`。
|
|
639
|
+
*
|
|
640
|
+
* 判斷樣板在不在看的是 `marker` 有沒有比對到,**不是換完的字串有沒有變**——空的 repo 畫出來的
|
|
641
|
+
* 群組與清單本來就是空字串,拿「沒變」當「樣板壞了」的話,那種 repo 會產不出圖。
|
|
642
|
+
*/
|
|
643
|
+
function replaceIn(html: string, marker: RegExp, body: string, what: string): string {
|
|
644
|
+
if (!marker.test(html)) throw new Error(`Template is missing the ${what} block: ${TEMPLATE}`)
|
|
645
|
+
return html.replace(marker, `$1${body}$2`)
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
export function describe(snapshot: Snapshot): string {
|
|
649
|
+
const done = snapshot.issues.filter((issue) => issue.status === 'done').length
|
|
650
|
+
return `${snapshot.issues.length - done} unfinished, ${done} closed but still referenced (${snapshot.generatedAt})`
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
if (import.meta.main) {
|
|
654
|
+
const snapshot = await takeSnapshot()
|
|
655
|
+
await Bun.write(OUTPUT, await renderDocument(snapshot))
|
|
656
|
+
console.log(`Wrote ${OUTPUT}: ${describe(snapshot)}`)
|
|
657
|
+
}
|