issue-map 0.3.1 → 0.4.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 +82 -109
- package/README.md +88 -112
- package/README.zh-CN.md +74 -78
- package/README.zh-TW.md +74 -78
- package/package.json +5 -4
- package/scripts/issue-map-i18n.ts +11 -5
- package/scripts/issue-map-model.ts +11 -4
- package/scripts/issue-map-page.ts +109 -713
- package/scripts/issue-map-serve.ts +16 -17
- package/scripts/issue-map-view.ts +650 -0
- package/scripts/issue-map.html +31 -5
- package/scripts/issue-map.ts +109 -44
package/scripts/issue-map.html
CHANGED
|
@@ -424,6 +424,7 @@
|
|
|
424
424
|
}
|
|
425
425
|
|
|
426
426
|
.map-wrap {
|
|
427
|
+
position: relative;
|
|
427
428
|
background: var(--card);
|
|
428
429
|
border: 1px solid var(--line);
|
|
429
430
|
border-top: 2px solid var(--track, var(--line-strong));
|
|
@@ -537,17 +538,42 @@
|
|
|
537
538
|
}
|
|
538
539
|
|
|
539
540
|
/* 線名 */
|
|
540
|
-
|
|
541
|
+
/* 線名。畫在 SVG 外面的一欄,這樣長的名字用 text-overflow 就截得掉——SVG 的 <text> 不會
|
|
542
|
+
換行也不會自己截斷,長一點就會蓋到第一個站點上。寬度比 MAP.gutter 少一點,留白給站點。 */
|
|
543
|
+
.tlabel {
|
|
544
|
+
position: absolute;
|
|
545
|
+
left: 12px;
|
|
546
|
+
width: 84px;
|
|
547
|
+
transform: translateY(-12px);
|
|
541
548
|
font-family: var(--mono);
|
|
549
|
+
pointer-events: none;
|
|
550
|
+
}
|
|
551
|
+
.tlabel b,
|
|
552
|
+
.tlabel span {
|
|
553
|
+
display: block;
|
|
554
|
+
overflow: hidden;
|
|
555
|
+
white-space: nowrap;
|
|
556
|
+
text-overflow: ellipsis;
|
|
557
|
+
}
|
|
558
|
+
.tlabel b {
|
|
542
559
|
font-size: 12px;
|
|
543
560
|
font-weight: 600;
|
|
544
|
-
|
|
561
|
+
color: var(--muted);
|
|
545
562
|
font-variant-numeric: tabular-nums;
|
|
546
563
|
}
|
|
547
|
-
.
|
|
548
|
-
font-family: var(--mono);
|
|
564
|
+
.tlabel span {
|
|
549
565
|
font-size: 10px;
|
|
550
|
-
|
|
566
|
+
color: var(--faint);
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/* 沒有畫圖的那幾組。 */
|
|
570
|
+
.undrawn {
|
|
571
|
+
margin: 0;
|
|
572
|
+
padding: 14px 12px;
|
|
573
|
+
color: var(--faint);
|
|
574
|
+
font-size: 13px;
|
|
575
|
+
border: 1px dashed var(--line);
|
|
576
|
+
border-radius: 2px;
|
|
551
577
|
}
|
|
552
578
|
|
|
553
579
|
.map-key {
|
package/scripts/issue-map.ts
CHANGED
|
@@ -1,38 +1,21 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
/**
|
|
3
3
|
* 開發地圖:把 GitHub Issues 的阻擋關係抓下來,塞進 `scripts/issue-map.html` 這份樣板,產出一頁
|
|
4
|
-
* 可以直接看的 HTML
|
|
4
|
+
* 可以直接看的 HTML。每張票的狀態、在等誰、下一步都在這裡算完才送進頁面,樣板只負責畫。
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
6
|
+
* 帶進快照的 issue:所有 open issue,加上仍被 open issue 牽著的 closed issue(畫成「已完成」的
|
|
7
|
+
* 節點讓進度看得見,沒人牽著之後自然消失)。closed 是**指名**去要的——阻擋者、parent、parent
|
|
8
|
+
* 底下的子票,不掃整包,因為老 repo 幾千張 closed 裡通常只有個位數會留下。代價:同一組裡已
|
|
9
|
+
* 完成的兄弟票要靠 GitHub 原生 sub-issue 才抽得到,用內文 `## Parent` 慣例的 repo 看不到它們,
|
|
10
|
+
* 那一組的進度會比實際少。
|
|
8
11
|
*
|
|
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` 重抓一次。
|
|
12
|
+
* 用法:`bun run scripts/issue-map.ts [out.html]`,預設寫到 `dist/issue-map.html`。
|
|
13
|
+
* 環境變數與設計決定見 README;移植要調的東西在底下的 `CONFIG`。
|
|
32
14
|
*/
|
|
33
15
|
|
|
34
16
|
import { spawnSync } from 'bun'
|
|
35
17
|
|
|
18
|
+
import { LOCALE_NAME, LOCALES, t } from './issue-map-i18n.ts'
|
|
36
19
|
import {
|
|
37
20
|
criticalPathOf,
|
|
38
21
|
groupsOf,
|
|
@@ -41,6 +24,7 @@ import {
|
|
|
41
24
|
type Snapshot,
|
|
42
25
|
type Status,
|
|
43
26
|
} from './issue-map-model.ts'
|
|
27
|
+
import { esc, viewOf } from './issue-map-view.ts'
|
|
44
28
|
|
|
45
29
|
const TEMPLATE = new URL('./issue-map.html', import.meta.url).pathname
|
|
46
30
|
const CLIENT = new URL('./issue-map-page.ts', import.meta.url).pathname
|
|
@@ -61,8 +45,7 @@ function labelList(raw: string | undefined, fallback: string): readonly string[]
|
|
|
61
45
|
/**
|
|
62
46
|
* 移植時要調的東西全在這裡,而且都有預設值——不設任何一個也跑得起來。
|
|
63
47
|
*
|
|
64
|
-
* repo
|
|
65
|
-
* `GH_REPO`(`gh` 自己的環境變數,fork 與多 remote 的判斷也一併交給它)。
|
|
48
|
+
* repo 不在這裡:那是 `gh` 自己的 `GH_REPO`,fork 與多 remote 的判斷也一併交給它。
|
|
66
49
|
*/
|
|
67
50
|
const CONFIG = {
|
|
68
51
|
/** 子票在內文裡指向 parent 的標題。GitHub 原生 sub-issue 有值時優先用原生的。 */
|
|
@@ -119,6 +102,33 @@ const ISSUE_FIELDS = `
|
|
|
119
102
|
blockedBy(first: 50) { nodes { number } }
|
|
120
103
|
`
|
|
121
104
|
|
|
105
|
+
type GraphQLError = { readonly type?: string; readonly message?: string }
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* 從 `gh api graphql` 的輸出取出 data。
|
|
109
|
+
*
|
|
110
|
+
* **查不到的票不是錯誤。** 指名去要某個票號時,GitHub 會同時回 `data`(那個 alias 是 `null`)
|
|
111
|
+
* 與一筆 `NOT_FOUND`,而 `gh` 為了那筆錯誤以非零離開。號碼其實是 PR、票被轉移或刪掉、內文
|
|
112
|
+
* 慣例掃出來的誤判——在老 repo 上都是常態,一個掃不到就讓整張圖產不出來並不合理。所以只有
|
|
113
|
+
* `NOT_FOUND` 以外的錯誤才拋,資料照用。
|
|
114
|
+
*
|
|
115
|
+
* 連 data 都沒有(沒登入、網路不通)就拿 stderr 當原因拋出去。
|
|
116
|
+
*/
|
|
117
|
+
export function dataOrThrow<T>(stdout: string, stderr: string): T {
|
|
118
|
+
let parsed: { data?: T; errors?: readonly GraphQLError[] } | undefined
|
|
119
|
+
try {
|
|
120
|
+
parsed = stdout ? (JSON.parse(stdout) as typeof parsed) : undefined
|
|
121
|
+
} catch {
|
|
122
|
+
parsed = undefined
|
|
123
|
+
}
|
|
124
|
+
const fatal = (parsed?.errors ?? []).filter((error) => error.type !== 'NOT_FOUND')
|
|
125
|
+
if (!parsed?.data || fatal.length) {
|
|
126
|
+
const why = fatal.length ? JSON.stringify(fatal) : stderr.trim() || stdout.trim()
|
|
127
|
+
throw new Error(`gh api graphql failed: ${why}`)
|
|
128
|
+
}
|
|
129
|
+
return parsed.data
|
|
130
|
+
}
|
|
131
|
+
|
|
122
132
|
/**
|
|
123
133
|
* 跑一次 `gh api graphql`。
|
|
124
134
|
*
|
|
@@ -140,10 +150,7 @@ function run<T>(query: string, variables: Record<string, string> = {}): T {
|
|
|
140
150
|
]
|
|
141
151
|
for (const [name, value] of Object.entries(variables)) args.push('-f', `${name}=${value}`)
|
|
142
152
|
const result = spawnSync(args, { stdout: 'pipe', stderr: 'pipe' })
|
|
143
|
-
|
|
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
|
|
153
|
+
return dataOrThrow<T>(result.stdout.toString(), result.stderr.toString())
|
|
147
154
|
}
|
|
148
155
|
|
|
149
156
|
const OPEN_QUERY = `
|
|
@@ -223,9 +230,8 @@ function childrenQuery(parent: number): string {
|
|
|
223
230
|
/**
|
|
224
231
|
* 拿這些 parent 底下的子票,為的是把同一組裡**已完成**的兄弟票撈出來當進度。
|
|
225
232
|
*
|
|
226
|
-
* 只有 GitHub 原生 sub-issue
|
|
227
|
-
*
|
|
228
|
-
* 幾張票。open 的兄弟不必靠這裡,它們本來就在 open 那包。
|
|
233
|
+
* 只有 GitHub 原生 sub-issue 有值;用內文 `## Parent` 慣例的 repo 這裡是空的(檔頭說的那個
|
|
234
|
+
* 代價)。open 的兄弟不必靠這裡,它們本來就在 open 那包。
|
|
229
235
|
*/
|
|
230
236
|
function fetchChildren(parents: readonly number[]): RawIssue[] {
|
|
231
237
|
type Batch = { repository: Record<string, { subIssues: Page<RawIssue> } | null> }
|
|
@@ -278,9 +284,7 @@ export function takeSnapshot(): Snapshot {
|
|
|
278
284
|
open.flatMap((issue) => issue.blockedBy.nodes.map((blocker) => blocker.number)),
|
|
279
285
|
)
|
|
280
286
|
|
|
281
|
-
//
|
|
282
|
-
// parent 底下已完成的兄弟(那一組的進度)。所以不掃整包 closed,而是指名去要——老 repo 的
|
|
283
|
-
// 幾千張 closed 裡通常只有個位數會留下,翻完它們是拿幾十次請求換幾張票。
|
|
287
|
+
// 指名去要而不掃整包 closed,理由見檔頭。
|
|
284
288
|
const referenced = [...new Set([...blockers, ...openParents])].filter(
|
|
285
289
|
(number) => !openNumbers.has(number),
|
|
286
290
|
)
|
|
@@ -389,13 +393,11 @@ function describeIssue(raw: Issue, context: Context): MapIssue {
|
|
|
389
393
|
}
|
|
390
394
|
}
|
|
391
395
|
|
|
392
|
-
/** 把快照塞進樣板。回傳的是 artifact 用的片段(沒有 doctype/html/head/body)。 */
|
|
393
396
|
/**
|
|
394
397
|
* 把畫面那一支打包成一段可以直接放進 `<script>` 的程式碼。
|
|
395
398
|
*
|
|
396
|
-
*
|
|
397
|
-
*
|
|
398
|
-
* 不 minify 是因為這是開發用的頁面,讀得懂比小重要。
|
|
399
|
+
* 產出必須是一個檔案,但來源不必——來源是 TypeScript,所以型別跟這裡共用同一份定義,而且純
|
|
400
|
+
* 推導測得到。`format: 'iife'` 是因為它要塞進行內;不 minify 是因為這是開發用的頁面。
|
|
399
401
|
*/
|
|
400
402
|
async function bundleClient(): Promise<string> {
|
|
401
403
|
const built = await Bun.build({
|
|
@@ -410,6 +412,56 @@ async function bundleClient(): Promise<string> {
|
|
|
410
412
|
return output.text()
|
|
411
413
|
}
|
|
412
414
|
|
|
415
|
+
/**
|
|
416
|
+
* 把樣板裡某個容器的內容填掉。
|
|
417
|
+
*
|
|
418
|
+
* 樣板裡這些容器都是空的(`<div id="x"></div>`),所以只要在開頭標籤與結尾標籤之間插入即可,
|
|
419
|
+
* 不必真的剖析 HTML。填不到就是樣板被改壞了,直接喊。
|
|
420
|
+
*/
|
|
421
|
+
function fillById(html: string, id: string, body: string): string {
|
|
422
|
+
// 標籤名要吃得到數字,`h1`、`h2` 都是容器。
|
|
423
|
+
const marker = new RegExp(`(<[a-z][a-z0-9]*[^>]*\\sid="${id}"[^>]*>)(</[a-z][a-z0-9]*>)`)
|
|
424
|
+
const next = html.replace(marker, `$1${body}$2`)
|
|
425
|
+
if (next === html) throw new Error(`Template is missing an empty #${id}: ${TEMPLATE}`)
|
|
426
|
+
return next
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* 建置時就把整頁畫好。
|
|
431
|
+
*
|
|
432
|
+
* 標記由 `issue-map-view.ts` 產生,畫面那一支重畫時用的是同一批函式——所以沒有 JS 的環境看到的
|
|
433
|
+
* 是同一份頁面,只是不能互動。少了這一步,擋掉 inline script 的地方(嚴格 CSP、某些預覽窗)
|
|
434
|
+
* 拿到的會是一份空骨架。
|
|
435
|
+
*
|
|
436
|
+
* 預設語言是英文;換語言要有 JS,那本來就不是靜態檔能做的事。
|
|
437
|
+
*/
|
|
438
|
+
function prerender(html: string, snapshot: Snapshot): string {
|
|
439
|
+
const view = viewOf(snapshot)
|
|
440
|
+
const foot = view.footerHTML()
|
|
441
|
+
const detail = view.detailPanelHTML(view.defaultPick())
|
|
442
|
+
const rows = view.rowsHTML('all')
|
|
443
|
+
const title = view.title()
|
|
444
|
+
const filled: [string, string][] = [
|
|
445
|
+
['eyebrow', esc(view.eyebrow())],
|
|
446
|
+
['page-title', esc(title)],
|
|
447
|
+
['lede', view.lede()],
|
|
448
|
+
['stats', view.statsHTML()],
|
|
449
|
+
['detail', detail.html],
|
|
450
|
+
['groups', view.groupsHTML()],
|
|
451
|
+
['list-title', esc(t('list.title'))],
|
|
452
|
+
['list-sub', esc(t('list.count', { n: rows.shown }))],
|
|
453
|
+
['tabs', view.tabsHTML('all')],
|
|
454
|
+
['rows', rows.html],
|
|
455
|
+
['foot-truth', foot.truth],
|
|
456
|
+
['foot-refresh', foot.refresh],
|
|
457
|
+
['foot-config', foot.config],
|
|
458
|
+
['lang', LOCALES.map((l) => `<option value="${l}">${esc(LOCALE_NAME[l])}</option>`).join('')],
|
|
459
|
+
]
|
|
460
|
+
const withBody = filled.reduce((acc, [id, body]) => fillById(acc, id, body), html)
|
|
461
|
+
return replaceIn(withBody, /(<title>)[\s\S]*?(<\/title>)/, esc(title), 'title')
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/** 把快照塞進樣板。回傳的是 artifact 用的片段(沒有 doctype/html/head/body)。 */
|
|
413
465
|
export async function renderFragment(snapshot: Snapshot): Promise<string> {
|
|
414
466
|
const template = await Bun.file(TEMPLATE).text()
|
|
415
467
|
const withData = replaceIn(
|
|
@@ -419,8 +471,9 @@ export async function renderFragment(snapshot: Snapshot): Promise<string> {
|
|
|
419
471
|
JSON.stringify(snapshot).replaceAll('<', '\\u003c'),
|
|
420
472
|
'issue-map-data',
|
|
421
473
|
)
|
|
474
|
+
const withPage = prerender(withData, snapshot)
|
|
422
475
|
return replaceIn(
|
|
423
|
-
|
|
476
|
+
withPage,
|
|
424
477
|
/(<script id="issue-map-code">)[\s\S]*?(<\/script>)/,
|
|
425
478
|
// 程式碼不能這樣逃脫——`a < b` 會被改壞。只擋真正會提早收尾的那一個序列。
|
|
426
479
|
(await bundleClient()).replace(/<\/script/gi, '<\\/script'),
|
|
@@ -428,6 +481,18 @@ export async function renderFragment(snapshot: Snapshot): Promise<string> {
|
|
|
428
481
|
)
|
|
429
482
|
}
|
|
430
483
|
|
|
484
|
+
/**
|
|
485
|
+
* 把片段包成一份完整文件。**直接開檔案看的一律走這裡**:少了 doctype 瀏覽器會進 quirks mode。
|
|
486
|
+
* charset 是防禦性的——從 `file://` 開沒有 header 可依靠,而這一頁帶著四種語言的文案。
|
|
487
|
+
*
|
|
488
|
+
* `lang` 是預設語言;頁面上換語言時畫面那一支會改掉 `documentElement.lang`。
|
|
489
|
+
*/
|
|
490
|
+
export async function renderDocument(snapshot: Snapshot): Promise<string> {
|
|
491
|
+
const head =
|
|
492
|
+
'<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">'
|
|
493
|
+
return `<!doctype html><html lang="en"><head>${head}</head><body>${await renderFragment(snapshot)}</body></html>`
|
|
494
|
+
}
|
|
495
|
+
|
|
431
496
|
function replaceIn(html: string, marker: RegExp, body: string, what: string): string {
|
|
432
497
|
const next = html.replace(marker, `$1${body}$2`)
|
|
433
498
|
if (next === html) throw new Error(`Template is missing the ${what} block: ${TEMPLATE}`)
|
|
@@ -441,6 +506,6 @@ export function describe(snapshot: Snapshot): string {
|
|
|
441
506
|
|
|
442
507
|
if (import.meta.main) {
|
|
443
508
|
const snapshot = takeSnapshot()
|
|
444
|
-
await Bun.write(OUTPUT, await
|
|
509
|
+
await Bun.write(OUTPUT, await renderDocument(snapshot))
|
|
445
510
|
console.log(`Wrote ${OUTPUT}: ${describe(snapshot)}`)
|
|
446
511
|
}
|