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.
- package/README.ja.md +16 -13
- package/README.md +16 -13
- package/README.zh-CN.md +16 -13
- package/README.zh-TW.md +16 -13
- package/package.json +7 -13
- package/{scripts → src}/issue-map-model.ts +44 -22
- package/{scripts → src}/issue-map-page.ts +62 -38
- package/{scripts → src}/issue-map-serve.ts +17 -2
- package/{scripts → src}/issue-map-view.ts +108 -64
- package/{scripts → src}/issue-map.html +2 -1
- package/{scripts → src}/issue-map.ts +275 -129
- /package/{scripts → src}/issue-map-i18n.ts +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
/**
|
|
3
|
-
* 開發地圖:把 GitHub Issues 的阻擋關係抓下來,塞進 `
|
|
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
|
|
12
|
+
* 用法:`bun run src/issue-map.ts [out.html]`,預設寫到 `dist/issue-map.html`。
|
|
13
13
|
* 環境變數與設計決定見 README;移植要調的東西在底下的 `CONFIG`。
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
-
import {
|
|
16
|
+
import { spawn } from 'bun'
|
|
17
17
|
|
|
18
|
-
import {
|
|
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
|
-
/**
|
|
52
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
94
|
-
|
|
101
|
+
/** 內文只餵 `PARENT_IN_BODY` 一條 regex,沒設慣例就不要去抓——它佔了回應的九成以上。 */
|
|
102
|
+
const BODY_FIELD = CONFIG.parentHeading ? ' body' : ''
|
|
95
103
|
|
|
96
104
|
const ISSUE_FIELDS = `
|
|
97
|
-
number title state url
|
|
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
|
|
153
|
-
|
|
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
|
-
|
|
173
|
-
|
|
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
|
-
|
|
178
|
-
|
|
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
|
-
/**
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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}) { ${
|
|
238
|
+
${batch.map((number) => `${alias(number)}: issue(number: ${number}) { ${selection} }`).join('\n ')}
|
|
205
239
|
}
|
|
206
240
|
}`
|
|
207
|
-
|
|
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
|
|
210
|
-
if (
|
|
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 { ${
|
|
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
|
-
*
|
|
284
|
+
* 這些主票底下有哪些子票、各自是開是關。為的是把同一組裡**已完成**的兄弟票撈出來當進度。
|
|
285
|
+
*
|
|
286
|
+
* **只問票號與狀態。** `subIssues` 沒有 `states:` 可以篩,一定會連開著的一起回來,而開著的
|
|
287
|
+
* 兄弟本來就在 open 那包;關掉的要的是完整欄位,那一趟跟其他指名去要的票一起走。整包完整欄位
|
|
288
|
+
* 抓回來再丟掉的話,用原生 sub-issue 的 repo 每次都在重抓自己已經有的東西。
|
|
232
289
|
*
|
|
233
290
|
* 只有 GitHub 原生 sub-issue 有值;用內文 `## Parent` 慣例的 repo 這裡是空的(檔頭說的那個
|
|
234
|
-
* 代價)。
|
|
291
|
+
* 代價)。
|
|
235
292
|
*/
|
|
236
|
-
function fetchChildren(parents: readonly number[]):
|
|
237
|
-
type
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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
|
-
/**
|
|
271
|
-
const
|
|
272
|
-
function
|
|
273
|
-
const
|
|
274
|
-
|
|
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
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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
|
|
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
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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) =>
|
|
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
|
-
|
|
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
|
-
|
|
330
|
-
|
|
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:
|
|
333
|
-
readonly parents:
|
|
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
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
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) =>
|
|
344
|
-
const isParent =
|
|
345
|
-
const
|
|
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 (
|
|
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 (
|
|
355
|
-
if (assignees.length || has(
|
|
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:
|
|
499
|
+
if (status === 'triage') return { kind: 'command', command: rules.triageCommand }
|
|
363
500
|
if (status === 'active') {
|
|
364
501
|
// 沒有 assignee 但掛了 active 標籤時,能講的就只有那個標籤名。
|
|
365
|
-
const label =
|
|
366
|
-
return {
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
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
|
-
|
|
511
|
+
if (isParent) return { kind: 'parentReady' }
|
|
512
|
+
return has(rules.human)
|
|
374
513
|
? { kind: 'manual' }
|
|
375
|
-
: { kind: 'command', command:
|
|
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
|
-
|
|
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
|
-
|
|
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',
|
|
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
|
-
|
|
498
|
-
|
|
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
|