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/scripts/issue-map.ts
DELETED
|
@@ -1,446 +0,0 @@
|
|
|
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
|
-
* closed 是**指名**去要的(阻擋者、parent、parent 底下的子票),不掃整包——老 repo 幾千張
|
|
15
|
-
* closed 裡通常只有個位數會留下。代價:同一組裡已完成的兄弟票要靠 GitHub 原生 sub-issue 才
|
|
16
|
-
* 抽得到,用內文 `## Parent` 慣例的 repo 看不到它們,那一組的進度會比實際少。
|
|
17
|
-
*
|
|
18
|
-
* **要畫哪個 repo**:從 cwd 的 git 推斷,不必填——在那個 repo 裡跑 `bunx issue-map@latest`
|
|
19
|
-
* 就好。要指定別的 repo 設 `GH_REPO`。標籤字彙與 parent 的慣例都能用環境變數調,見底下的
|
|
20
|
-
* `CONFIG`,整份對照表在 README。
|
|
21
|
-
*
|
|
22
|
-
* 票名不進地圖。曾經試過在內文加一個 `## 短名` 段落給站點當標籤,但那要每張票靠人維護、
|
|
23
|
-
* 而且是票名的第二個事實來源,改標題不會改它。機械縮短標題也試過,這裡的標題沒有一致結構,
|
|
24
|
-
* 縮出來讀不通。所以站點只掛票號,名字交給清單。
|
|
25
|
-
*
|
|
26
|
-
* 用法:
|
|
27
|
-
* bun run scripts/issue-map.ts # 寫到 dist/issue-map.html
|
|
28
|
-
* bun run scripts/issue-map.ts path/to/out.html
|
|
29
|
-
*
|
|
30
|
-
* 要「重新整理就是最新」,改跑 `scripts/issue-map-serve.ts`:它每個請求都呼叫這裡的
|
|
31
|
-
* `takeSnapshot` 重抓一次。
|
|
32
|
-
*/
|
|
33
|
-
|
|
34
|
-
import { spawnSync } from 'bun'
|
|
35
|
-
|
|
36
|
-
import {
|
|
37
|
-
criticalPathOf,
|
|
38
|
-
groupsOf,
|
|
39
|
-
type MapIssue,
|
|
40
|
-
type NextStep,
|
|
41
|
-
type Snapshot,
|
|
42
|
-
type Status,
|
|
43
|
-
} from './issue-map-model.ts'
|
|
44
|
-
|
|
45
|
-
const TEMPLATE = new URL('./issue-map.html', import.meta.url).pathname
|
|
46
|
-
const CLIENT = new URL('./issue-map-page.ts', import.meta.url).pathname
|
|
47
|
-
const OUTPUT = process.argv[2] ?? 'dist/issue-map.html'
|
|
48
|
-
|
|
49
|
-
/** 一頁的張數。GraphQL 的 `first` 最多就是 100,票再多就靠 cursor 一頁一頁接。 */
|
|
50
|
-
const PAGE = 100
|
|
51
|
-
/** 一次用 alias 指名幾張票。GraphQL 對單一查詢的節點數有上限,這個量級離它還很遠。 */
|
|
52
|
-
const BATCH = 50
|
|
53
|
-
|
|
54
|
-
function labelList(raw: string | undefined, fallback: string): readonly string[] {
|
|
55
|
-
return (raw ?? fallback)
|
|
56
|
-
.split(',')
|
|
57
|
-
.map((name) => name.trim())
|
|
58
|
-
.filter(Boolean)
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/**
|
|
62
|
-
* 移植時要調的東西全在這裡,而且都有預設值——不設任何一個也跑得起來。
|
|
63
|
-
*
|
|
64
|
-
* repo 不在這裡:`gh` 的 `{owner}`/`{repo}` 佔位符會從 cwd 的 git 推斷,要指定別的 repo 就設
|
|
65
|
-
* `GH_REPO`(`gh` 自己的環境變數,fork 與多 remote 的判斷也一併交給它)。
|
|
66
|
-
*/
|
|
67
|
-
const CONFIG = {
|
|
68
|
-
/** 子票在內文裡指向 parent 的標題。GitHub 原生 sub-issue 有值時優先用原生的。 */
|
|
69
|
-
parentHeading: process.env.ISSUE_MAP_PARENT_HEADING ?? 'Parent',
|
|
70
|
-
/** 掛了就是還沒評估完,不能交給誰做。 */
|
|
71
|
-
unready: labelList(process.env.ISSUE_MAP_LABELS_UNREADY, 'needs-triage,needs-info'),
|
|
72
|
-
/** 掛了才算評估完、可以動工。 */
|
|
73
|
-
ready: labelList(process.env.ISSUE_MAP_LABELS_READY, 'ready-for-agent,ready-for-human'),
|
|
74
|
-
/** 掛了代表有人在做,不必有 assignee。 */
|
|
75
|
-
active: labelList(process.env.ISSUE_MAP_LABELS_ACTIVE, 'in-progress'),
|
|
76
|
-
/** 這些要人做,下一步不寫實作指令。 */
|
|
77
|
-
human: labelList(process.env.ISSUE_MAP_LABELS_HUMAN, 'ready-for-human'),
|
|
78
|
-
/** 可以動工時要跑的指令。搬去沒有這個 skill 的 repo 就換掉,不然圖上會叫人跑不存在的東西。 */
|
|
79
|
-
implementCommand: process.env.ISSUE_MAP_CMD_IMPLEMENT ?? '/implement',
|
|
80
|
-
/** 還要評估時要跑的指令。 */
|
|
81
|
-
triageCommand: process.env.ISSUE_MAP_CMD_TRIAGE ?? '/triage',
|
|
82
|
-
} as const
|
|
83
|
-
|
|
84
|
-
type IssueState = 'OPEN' | 'CLOSED'
|
|
85
|
-
|
|
86
|
-
type RawIssue = {
|
|
87
|
-
readonly number: number
|
|
88
|
-
readonly title: string
|
|
89
|
-
readonly state: IssueState
|
|
90
|
-
readonly url: string
|
|
91
|
-
readonly body: string
|
|
92
|
-
readonly closedAt: string | null
|
|
93
|
-
/** 開票的人。GitHub 帳號被刪掉的話是 null。 */
|
|
94
|
-
readonly author: { readonly login: string } | null
|
|
95
|
-
/** GitHub 原生的 sub-issue 關係。沒用這套的 repo 一律是 null,改讀內文的標題。 */
|
|
96
|
-
readonly parent: { readonly number: number } | null
|
|
97
|
-
readonly labels: { readonly nodes: readonly { readonly name: string }[] }
|
|
98
|
-
readonly assignees: { readonly nodes: readonly { readonly login: string }[] }
|
|
99
|
-
readonly blockedBy: { readonly nodes: readonly { readonly number: number }[] }
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
/** 一路帶著算好的 parent,免得同一段內文被 regex 掃好幾次。 */
|
|
103
|
-
type Issue = RawIssue & { readonly parentNumber: number | null }
|
|
104
|
-
|
|
105
|
-
interface Page<T> {
|
|
106
|
-
pageInfo: { hasNextPage: boolean; endCursor: string | null }
|
|
107
|
-
nodes: T[]
|
|
108
|
-
}
|
|
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
|
|
147
|
-
}
|
|
148
|
-
|
|
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
|
|
174
|
-
}
|
|
175
|
-
return { nameWithOwner, open }
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
/** alias 不能以數字開頭,所以票號前面補一個 `i`。 */
|
|
179
|
-
const alias = (number: number) => `i${number}`
|
|
180
|
-
|
|
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 = `
|
|
195
|
-
query($owner: String!, $repo: String!) {
|
|
196
|
-
repository(owner: $owner, name: $repo) {
|
|
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
|
-
}
|
|
219
|
-
}
|
|
220
|
-
}`
|
|
221
|
-
}
|
|
222
|
-
|
|
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
|
-
}
|
|
260
|
-
}
|
|
261
|
-
return children
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
/** 原生 sub-issue 優先;沒有就讀內文的 `## <標題>` 之後第一個 `#<n>`。 */
|
|
265
|
-
const PARENT_IN_BODY = new RegExp(`##\\s*${CONFIG.parentHeading}\\s*\\n[\\s\\S]*?#(\\d+)`)
|
|
266
|
-
function withParent(raw: RawIssue): Issue {
|
|
267
|
-
const inBody = PARENT_IN_BODY.exec(raw.body)
|
|
268
|
-
return { ...raw, parentNumber: raw.parent?.number ?? (inBody ? Number(inBody[1]) : null) }
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
export function takeSnapshot(): Snapshot {
|
|
272
|
-
const { nameWithOwner, open: rawOpen } = fetchOpen()
|
|
273
|
-
const open = rawOpen.map(withParent)
|
|
274
|
-
|
|
275
|
-
const openNumbers = new Set(open.map((issue) => issue.number))
|
|
276
|
-
const openParents = new Set(open.map((issue) => issue.parentNumber).filter(isNumber))
|
|
277
|
-
const blockers = new Set(
|
|
278
|
-
open.flatMap((issue) => issue.blockedBy.nodes.map((blocker) => blocker.number)),
|
|
279
|
-
)
|
|
280
|
-
|
|
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]
|
|
292
|
-
const parents = new Set(kept.map((issue) => issue.parentNumber).filter(isNumber))
|
|
293
|
-
const openChildren = new Map<number, number>()
|
|
294
|
-
for (const issue of kept) {
|
|
295
|
-
if (issue.parentNumber === null || issue.state !== 'OPEN') continue
|
|
296
|
-
openChildren.set(issue.parentNumber, (openChildren.get(issue.parentNumber) ?? 0) + 1)
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
// 這個 repo 到底有沒有在用 triage 標籤。都沒看到就別拿它當閘門,否則每張票都會變成待 triage。
|
|
300
|
-
const seen = new Set(kept.flatMap((issue) => issue.labels.nodes.map((label) => label.name)))
|
|
301
|
-
const triaged = [...CONFIG.unready, ...CONFIG.ready].some((name) => seen.has(name))
|
|
302
|
-
|
|
303
|
-
const issues = kept
|
|
304
|
-
.map((raw) => describeIssue(raw, { open: openNumbers, openChildren, parents, triaged }))
|
|
305
|
-
.toSorted((a, b) => a.number - b.number)
|
|
306
|
-
return {
|
|
307
|
-
generatedAt: new Date().toISOString(),
|
|
308
|
-
repo: nameWithOwner,
|
|
309
|
-
labels: { ready: CONFIG.ready, unready: CONFIG.unready },
|
|
310
|
-
groups: groupsOf(issues),
|
|
311
|
-
criticalPath: criticalPathOf(issues),
|
|
312
|
-
issues,
|
|
313
|
-
}
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
/** 同一張票可能同時是某人的阻擋者又是某人的兄弟,用票號收斂成一張。 */
|
|
317
|
-
function dedupe(issues: readonly RawIssue[]): RawIssue[] {
|
|
318
|
-
return [...new Map(issues.map((issue) => [issue.number, issue])).values()]
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
function isNumber(value: number | null): value is number {
|
|
322
|
-
return value !== null
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
interface Context {
|
|
326
|
-
readonly open: Set<number>
|
|
327
|
-
/** 每張主票底下還開著的子票。主票的狀態看的是這個,不是自己的阻擋者。 */
|
|
328
|
-
readonly openChildren: Map<number, number>
|
|
329
|
-
readonly parents: Set<number>
|
|
330
|
-
/** 這個 repo 有在用 triage 標籤,狀態才把它們當閘門。 */
|
|
331
|
-
readonly triaged: boolean
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
function describeIssue(raw: Issue, context: Context): MapIssue {
|
|
335
|
-
const labels = raw.labels.nodes.map((label) => label.name)
|
|
336
|
-
const assignees = raw.assignees.nodes.map((assignee) => assignee.login)
|
|
337
|
-
const blockedBy = raw.blockedBy.nodes.map((blocker) => blocker.number)
|
|
338
|
-
// 只有還開著的阻擋者算閘門;GitHub 的 blocked_by 摘要也是這樣算的。
|
|
339
|
-
const waitingFor = blockedBy.filter((number) => context.open.has(number))
|
|
340
|
-
const isParent = context.parents.has(raw.number)
|
|
341
|
-
const has = (names: readonly string[]) => names.some((name) => labels.includes(name))
|
|
342
|
-
|
|
343
|
-
function statusOf(): Status {
|
|
344
|
-
if (raw.state === 'CLOSED') return 'done'
|
|
345
|
-
// Parent 自己不做事,看的是子票:還有子票開著就是還在等。
|
|
346
|
-
if (isParent) {
|
|
347
|
-
return waitingFor.length || (context.openChildren.get(raw.number) ?? 0) ? 'blocked' : 'ready'
|
|
348
|
-
}
|
|
349
|
-
// 沒掛角色標籤的票還沒被評估過,不能因為沒人擋它就當成可接手。
|
|
350
|
-
if (context.triaged && (has(CONFIG.unready) || !has(CONFIG.ready))) return 'triage'
|
|
351
|
-
if (assignees.length || has(CONFIG.active)) return 'active'
|
|
352
|
-
return waitingFor.length ? 'blocked' : 'ready'
|
|
353
|
-
}
|
|
354
|
-
|
|
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
|
-
}
|
|
364
|
-
if (isParent) {
|
|
365
|
-
const left = context.openChildren.get(raw.number) ?? 0
|
|
366
|
-
return left ? { kind: 'waitChildren', count: left } : { kind: 'parentReady' }
|
|
367
|
-
}
|
|
368
|
-
if (status === 'blocked') return { kind: 'waitIssues', issues: waitingFor }
|
|
369
|
-
return has(CONFIG.human)
|
|
370
|
-
? { kind: 'manual' }
|
|
371
|
-
: { kind: 'command', command: CONFIG.implementCommand }
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
const status = statusOf()
|
|
375
|
-
return {
|
|
376
|
-
number: raw.number,
|
|
377
|
-
title: raw.title,
|
|
378
|
-
url: raw.url,
|
|
379
|
-
closedAt: raw.closedAt,
|
|
380
|
-
author: raw.author?.login ?? '',
|
|
381
|
-
labels,
|
|
382
|
-
assignees,
|
|
383
|
-
parent: raw.parentNumber,
|
|
384
|
-
blockedBy,
|
|
385
|
-
waitingFor,
|
|
386
|
-
status,
|
|
387
|
-
nextStep: nextStepOf(status),
|
|
388
|
-
isParent,
|
|
389
|
-
}
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
/** 把快照塞進樣板。回傳的是 artifact 用的片段(沒有 doctype/html/head/body)。 */
|
|
393
|
-
/**
|
|
394
|
-
* 把畫面那一支打包成一段可以直接放進 `<script>` 的程式碼。
|
|
395
|
-
*
|
|
396
|
-
* 產出必須是**一個檔案**(artifact 的頁面就是一份 HTML),但來源不必——來源是 TypeScript,
|
|
397
|
-
* 所以型別跟這裡共用同一份定義,而且純推導測得到。`format: 'iife'` 是因為它要塞進行內;
|
|
398
|
-
* 不 minify 是因為這是開發用的頁面,讀得懂比小重要。
|
|
399
|
-
*/
|
|
400
|
-
async function bundleClient(): Promise<string> {
|
|
401
|
-
const built = await Bun.build({
|
|
402
|
-
entrypoints: [CLIENT],
|
|
403
|
-
target: 'browser',
|
|
404
|
-
format: 'iife',
|
|
405
|
-
minify: false,
|
|
406
|
-
})
|
|
407
|
-
if (!built.success) throw new Error(`Failed to bundle ${CLIENT}: ${built.logs.join('\n')}`)
|
|
408
|
-
const [output] = built.outputs
|
|
409
|
-
if (!output) throw new Error(`Bundling ${CLIENT} produced no output`)
|
|
410
|
-
return output.text()
|
|
411
|
-
}
|
|
412
|
-
|
|
413
|
-
export async function renderFragment(snapshot: Snapshot): Promise<string> {
|
|
414
|
-
const template = await Bun.file(TEMPLATE).text()
|
|
415
|
-
const withData = replaceIn(
|
|
416
|
-
template,
|
|
417
|
-
/(<script id="issue-map-data" type="application\/json">)[\s\S]*?(<\/script>)/,
|
|
418
|
-
// JSON 裡把 `<` 一律逃脫成 `\u003c`:那在 JSON 字串裡等價,而且不可能提早關掉 <script>。
|
|
419
|
-
JSON.stringify(snapshot).replaceAll('<', '\\u003c'),
|
|
420
|
-
'issue-map-data',
|
|
421
|
-
)
|
|
422
|
-
return replaceIn(
|
|
423
|
-
withData,
|
|
424
|
-
/(<script id="issue-map-code">)[\s\S]*?(<\/script>)/,
|
|
425
|
-
// 程式碼不能這樣逃脫——`a < b` 會被改壞。只擋真正會提早收尾的那一個序列。
|
|
426
|
-
(await bundleClient()).replace(/<\/script/gi, '<\\/script'),
|
|
427
|
-
'issue-map-code',
|
|
428
|
-
)
|
|
429
|
-
}
|
|
430
|
-
|
|
431
|
-
function replaceIn(html: string, marker: RegExp, body: string, what: string): string {
|
|
432
|
-
const next = html.replace(marker, `$1${body}$2`)
|
|
433
|
-
if (next === html) throw new Error(`Template is missing the ${what} block: ${TEMPLATE}`)
|
|
434
|
-
return next
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
export function describe(snapshot: Snapshot): string {
|
|
438
|
-
const done = snapshot.issues.filter((issue) => issue.status === 'done').length
|
|
439
|
-
return `${snapshot.issues.length - done} unfinished, ${done} closed but still referenced (${snapshot.generatedAt})`
|
|
440
|
-
}
|
|
441
|
-
|
|
442
|
-
if (import.meta.main) {
|
|
443
|
-
const snapshot = takeSnapshot()
|
|
444
|
-
await Bun.write(OUTPUT, await renderFragment(snapshot))
|
|
445
|
-
console.log(`Wrote ${OUTPUT}: ${describe(snapshot)}`)
|
|
446
|
-
}
|