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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Gunter Chou
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,96 @@
1
+ # issue-map
2
+
3
+ 把 GitHub Issues 的阻擋關係畫成一頁開發地圖:**哪幾張票現在可以動、哪幾張在等誰、關鍵路徑是哪一條。**
4
+
5
+ 狀態的權威永遠是 GitHub Issues。這一頁只是快照,頁面上不能改狀態——所以不會長出第二個事實來源。
6
+
7
+ ## 用法
8
+
9
+ 在**要看的那個 repo** 裡跑:
10
+
11
+ ```bash
12
+ bunx github:gunter1020/issue-map#main
13
+ ```
14
+
15
+ `#main` 不是裝飾:**不帶 ref 的話 bun 會一直用第一次解析到的那個 commit**。實測過——先跑不帶 ref 的版本,之後 repo 推了新 commit,再跑同一行還是拿到舊的(`~/.bun/install/cache/@GH@gunter1020-issue-map-<sha>@@@1` 看得到是哪一個),加上 `#main` 才拿到新的。
16
+
17
+ 起在 `http://localhost:4747` **並直接開瀏覽器**。每次重新整理都重抓 GitHub,看到的一定是現在的狀態。repo 是 `gh` 從 cwd 的 git 推斷的,不必填。
18
+
19
+ 不要自動開分頁就設 `ISSUE_MAP_OPEN=0`。
20
+
21
+ 只要一份靜態 HTML 的話(`-p` 是用來選另一個 bin 的,少了它會變成起 server):
22
+
23
+ ```bash
24
+ bunx -p github:gunter1020/issue-map#main issue-map-build # 寫到 dist/issue-map.html
25
+ bunx -p github:gunter1020/issue-map#main issue-map-build out.html
26
+ ```
27
+
28
+ 快照就是快照——狀態會過期,要看現在的狀態就用上面的 server。
29
+
30
+ ## 前置條件
31
+
32
+ - **Bun**。這幾支用了 `Bun.build`、`Bun.serve`、`Bun.file` 與 bun 的 `spawnSync`,Node 跑不起來。
33
+ - **`gh` CLI 已登入**,而且對目標 repo 有讀取權。
34
+ - 目標 repo 有 git remote 指向 GitHub。
35
+ - 沒有 runtime 依賴;devDependencies 只有型別與 lint/format 工具。
36
+
37
+ ## 設定
38
+
39
+ 全部有預設值,一個都不設也跑得起來。預設值長在 `scripts/issue-map.ts` 的 `CONFIG`。
40
+
41
+ | 環境變數 | 預設 | 意思 |
42
+ | -------------------------- | --------------------------------- | ------------------------------------------------------------------ |
43
+ | `GH_REPO` | 從 cwd 的 git 推斷 | 要畫別的 repo 時設它(`gh` 自己的變數,fork 與多 remote 也交給它) |
44
+ | `ISSUE_MAP_PARENT_HEADING` | `Parent` | 子票在內文指向母票的段落標題。GitHub 原生 sub-issue 有值時優先 |
45
+ | `ISSUE_MAP_LABELS_UNREADY` | `needs-triage,needs-info` | 掛了就是還沒評估完,不能交給誰做 |
46
+ | `ISSUE_MAP_LABELS_READY` | `ready-for-agent,ready-for-human` | 掛了才算評估完、可以動工 |
47
+ | `ISSUE_MAP_LABELS_ACTIVE` | `in-progress` | 掛了代表有人在做,不必有 assignee |
48
+ | `ISSUE_MAP_LABELS_HUMAN` | `ready-for-human` | 這些要人做,下一步不寫實作指令 |
49
+ | `ISSUE_MAP_CMD_IMPLEMENT` | `/implement` | 可以動工時圖上叫人跑的指令 |
50
+ | `ISSUE_MAP_CMD_TRIAGE` | `/triage` | 還要評估時圖上叫人跑的指令 |
51
+ | `ISSUE_MAP_PORT` | `4747` | server 的 port |
52
+ | `ISSUE_MAP_OPEN` | 開 | 設 `0` 就不自動開瀏覽器(`bun --watch` 的開發模式預設關掉) |
53
+
54
+ 兩個要特別想過的:
55
+
56
+ - **標籤字彙**:目標 repo 沒在用這套標籤就要換成它自己的名字。程式會偵測——快照裡完全沒出現 ready/unready 任何一個標籤時,就不拿 triage 當閘門,否則每張票都會變成「待評估」。
57
+ - **指令名**:`/implement`、`/triage` 是 Claude Code 的 skill。目標 repo 沒有的話一定要換掉,不然圖上會叫人跑不存在的東西。
58
+
59
+ ## 常見失敗
60
+
61
+ - `gh api graphql 失敗:…` — `gh` 沒登入,或 cwd 不在目標 repo 的 git 樹裡。
62
+ - `open issue 超過 100 張,這支要改成分頁抓` — GraphQL 的 `first` 上限就是 100。要支援更多票得在 `issue-map.ts` 的 `query()` 加分頁;這是要改程式,不是設定。
63
+
64
+ ## 檔案
65
+
66
+ | 檔案 | 責任 |
67
+ | ---------------------------- | ---------------------------------------------------------------------- |
68
+ | `scripts/issue-map.ts` | 抓快照、算每張票的狀態與下一步、產出 HTML。移植設定在裡面的 `CONFIG` |
69
+ | `scripts/issue-map-model.ts` | 純資料模型:分組、關鍵路徑。前後端共用 |
70
+ | `scripts/issue-map-page.ts` | 瀏覽器端程式碼,建置時被打包進 HTML |
71
+ | `scripts/issue-map.html` | 樣板。兩個佔位區塊(`issue-map-data`、`issue-map-code`)會被填入 |
72
+ | `scripts/issue-map-serve.ts` | 本機 server,每個請求重抓一次 |
73
+ | `scripts/mutate.ts` | 突變測試:改壞一行看測試會不會紅。守門測試的反向驗證用它,不要手改檔案 |
74
+
75
+ ## 在這個 repo 裡開發
76
+
77
+ ```bash
78
+ bun install
79
+ bun run issue-map:serve # --watch,改程式碼會自動重啟;刻意不自動開瀏覽器(每存一次檔就會多一個分頁)
80
+ bun run issue-map # 只產檔到 dist/issue-map.html
81
+ bun run check # lint + format:check + typecheck
82
+ bun test # 純模型那一層(分組、關鍵路徑、排版)
83
+ ```
84
+
85
+ 這個 repo 自己還沒有 issue,`GH_REPO=<owner>/<repo>` 指到有票的 repo 才畫得出東西。
86
+
87
+ `tests/` 只守會讓地圖說謊或不能看的事,外觀(顏色、形狀、間距)刻意不驗。新增守門測試要走反向驗證——把它宣稱要擋的缺陷放回產品碼,確認它會紅:
88
+
89
+ ```bash
90
+ bun run mutate scripts/issue-map-model.ts tests/issue-map-layout.test.ts
91
+ ```
92
+
93
+ ## 兩個設計上的決定,改之前先知道
94
+
95
+ - **這一頁不能改狀態。** 沒有按鈕會回寫 GitHub。刻意的:狀態只有一個事實來源,多一個入口就會不一致。
96
+ - **票名不進地圖。** 節點只掛票號,名字在下方清單。試過在內文加短名段落,那是票名的第二個事實來源,改標題不會改它;機械縮短標題讀不通。
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "issue-map",
3
+ "version": "0.1.0",
4
+ "description": "把 GitHub Issues 的阻擋關係畫成一頁開發地圖:哪幾張票現在可以動、哪幾張在等誰、關鍵路徑是哪一條。",
5
+ "keywords": [
6
+ "bun",
7
+ "cli",
8
+ "dependency-graph",
9
+ "github",
10
+ "issues"
11
+ ],
12
+ "license": "MIT",
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "git+https://github.com/gunter1020/issue-map.git"
16
+ },
17
+ "bin": {
18
+ "issue-map": "./scripts/issue-map-serve.ts",
19
+ "issue-map-build": "./scripts/issue-map.ts"
20
+ },
21
+ "files": [
22
+ "scripts/issue-map.ts",
23
+ "scripts/issue-map-model.ts",
24
+ "scripts/issue-map-page.ts",
25
+ "scripts/issue-map-serve.ts",
26
+ "scripts/issue-map.html"
27
+ ],
28
+ "type": "module",
29
+ "scripts": {
30
+ "issue-map": "bun run scripts/issue-map.ts",
31
+ "issue-map:serve": "ISSUE_MAP_OPEN=0 bun --watch scripts/issue-map-serve.ts",
32
+ "typecheck": "tsc --noEmit",
33
+ "mutate": "bun run scripts/mutate.ts",
34
+ "format": "oxfmt .",
35
+ "format:check": "oxfmt --check .",
36
+ "lint": "oxlint .",
37
+ "lint:fix": "oxlint --fix .",
38
+ "check": "bun run lint && bun run format:check && bun run typecheck"
39
+ },
40
+ "devDependencies": {
41
+ "@types/bun": "latest",
42
+ "oxfmt": "^0.62.0",
43
+ "oxlint": "^1.76.0",
44
+ "typescript": "^5"
45
+ },
46
+ "engines": {
47
+ "bun": ">=1.0.0"
48
+ }
49
+ }
@@ -0,0 +1,247 @@
1
+ /**
2
+ * 開發地圖的形狀與純推導。**沒有 Bun、沒有 DOM**——抓資料的那一側(`issue-map.ts`)與畫面
3
+ * 那一側(`issue-map-page.ts`)都 import 它,所以它不能碰任何一邊的專屬 API。
4
+ *
5
+ * 這裡住的是「同一份定義只有一份」的東西:狀態的五個值、一張票的形狀、分群規則、關鍵路徑,
6
+ * 以及線路圖的排版。畫面那一側曾經把狀態的五個值再抄一次,兩邊沒有東西保證同步;現在型別
7
+ * 是共用的,抄錯編不過。
8
+ */
9
+
10
+ /** 一張票現在的處境。同時是頁面的顏色與篩選分頁。 */
11
+ export type Status = 'ready' | 'active' | 'blocked' | 'triage' | 'done'
12
+
13
+ /** 五個狀態的顯示順序,也是清單的排序權重。 */
14
+ export const STATUS_ORDER: readonly Status[] = ['ready', 'active', 'blocked', 'triage', 'done']
15
+
16
+ export const STATUS_LABEL: Readonly<Record<Status, string>> = {
17
+ ready: '可接手',
18
+ active: '進行中',
19
+ blocked: '阻擋中',
20
+ triage: '待 triage',
21
+ done: '已完成',
22
+ }
23
+
24
+ /** 頁面吃的形狀。這裡是它唯一的定義。 */
25
+ export type MapIssue = {
26
+ readonly number: number
27
+ readonly title: string
28
+ readonly url: string
29
+ readonly closedAt: string | null
30
+ /** 開票的人。帳號已刪除時是空字串。 */
31
+ readonly author: string
32
+ readonly labels: readonly string[]
33
+ readonly assignees: readonly string[]
34
+ readonly parent: number | null
35
+ /** 全部的阻擋者,含已關掉的——頁面要畫出「已解鎖的前置」。 */
36
+ readonly blockedBy: readonly number[]
37
+ /** 還開著的阻擋者,就是實際的閘門。 */
38
+ readonly waitingFor: readonly number[]
39
+ readonly status: Status
40
+ /** 白話的下一步,已完成的票是空字串。 */
41
+ readonly nextStep: string
42
+ /** 有子票的票。它自己不做事,等子票全關。 */
43
+ readonly isParent: boolean
44
+ }
45
+
46
+ /** 畫在同一張圖上的一群票。 */
47
+ export type Group = {
48
+ /** 有 parent 的群就是那張主票;沒有的是「其他依賴鏈」與「獨立票」這兩種。 */
49
+ readonly parent: number | null
50
+ readonly title: string
51
+ readonly members: readonly number[]
52
+ }
53
+
54
+ export type Snapshot = {
55
+ readonly generatedAt: string
56
+ readonly repo: string
57
+ /** 這一次實際生效的標籤字彙。圖例照它寫,不然改了設定圖例就會說謊。 */
58
+ readonly labels: { readonly ready: readonly string[]; readonly unready: readonly string[] }
59
+ /** 一張圖一群。 */
60
+ readonly groups: readonly Group[]
61
+ /** 最長的一條依序未完成鏈,也就是最少要幾輪。 */
62
+ readonly criticalPath: number
63
+ readonly issues: readonly MapIssue[]
64
+ }
65
+
66
+ /**
67
+ * 分群:同一張主票底下的子票一群;沒有主票但跟別人有前置關係的合成一群;完全孤立的合成一群。
68
+ * 一張圖畫一群。
69
+ */
70
+ export function groupsOf(issues: readonly MapIssue[]): Group[] {
71
+ const known = new Set(issues.map((issue) => issue.number))
72
+ const blocking = new Set(issues.flatMap((issue) => issue.blockedBy))
73
+ const byParent = new Map<number, number[]>()
74
+ for (const issue of issues) {
75
+ if (issue.parent === null) continue
76
+ const siblings = byParent.get(issue.parent) ?? []
77
+ siblings.push(issue.number)
78
+ byParent.set(issue.parent, siblings)
79
+ }
80
+
81
+ const groups: Group[] = []
82
+ for (const [parent, members] of byParent) {
83
+ const spec = issues.find((issue) => issue.number === parent)
84
+ groups.push({ parent, title: spec?.title ?? `#${parent} 的子票`, members })
85
+ }
86
+
87
+ const grouped = new Set([...byParent.values()].flat())
88
+ const linked: number[] = []
89
+ const alone: number[] = []
90
+ for (const issue of issues) {
91
+ if (grouped.has(issue.number) || issue.isParent) continue
92
+ const hasEdge = blocking.has(issue.number) || issue.blockedBy.some((n) => known.has(n))
93
+ ;(hasEdge ? linked : alone).push(issue.number)
94
+ }
95
+ if (linked.length) groups.push({ parent: null, title: '其他依賴鏈', members: linked })
96
+ if (alone.length) groups.push({ parent: null, title: '獨立票', members: alone })
97
+ return groups
98
+ }
99
+
100
+ /** DAG 裡從來源算起的最長路徑。環在資料裡不該有,真的有就當它走到底。 */
101
+ function longestPath(
102
+ start: number,
103
+ predecessorsOf: (n: number) => readonly number[],
104
+ memo: Map<number, number>,
105
+ ): number {
106
+ function walk(n: number, seen: Set<number>): number {
107
+ const cached = memo.get(n)
108
+ if (cached !== undefined) return cached
109
+ if (seen.has(n)) return 0
110
+ seen.add(n)
111
+ let best = 0
112
+ for (const previous of predecessorsOf(n)) best = Math.max(best, walk(previous, seen))
113
+ memo.set(n, best + 1)
114
+ return best + 1
115
+ }
116
+ return walk(start, new Set())
117
+ }
118
+
119
+ /** 最長的一條依序未完成鏈。 */
120
+ export function criticalPathOf(issues: readonly MapIssue[]): number {
121
+ const waiting = new Map(issues.map((issue) => [issue.number, issue.waitingFor]))
122
+ const memo = new Map<number, number>()
123
+ let longest = 0
124
+ for (const issue of issues) {
125
+ if (issue.status === 'done') continue
126
+ longest = Math.max(
127
+ longest,
128
+ longestPath(issue.number, (n) => waiting.get(n) ?? [], memo),
129
+ )
130
+ }
131
+ return longest
132
+ }
133
+
134
+ /** 線路圖的尺寸。改這裡就會同時改到排版與 SVG 的畫布大小。 */
135
+ export const MAP = {
136
+ /** 左邊留給線名,也讓第一站的名字不會壓到它。 */
137
+ gutter: 96,
138
+ /** 站與站的水平距離。 */
139
+ step: 108,
140
+ /** 線與線的垂直距離。 */
141
+ row: 72,
142
+ /** 第一條線離上緣。 */
143
+ top: 26,
144
+ /** 站點的半徑。 */
145
+ dot: 7,
146
+ /** 跨線轉折的圓角。 */
147
+ bend: 12,
148
+ /** 最後一站的名字會往右伸出去,畫布要留得下。 */
149
+ rightPad: 46,
150
+ } as const
151
+
152
+ export type Point = { readonly x: number; readonly y: number }
153
+ export type Edge = { readonly from: number; readonly to: number }
154
+
155
+ export type Layout = {
156
+ /** 每一條線由前到後的站。只有一站的線不標線名。 */
157
+ readonly tracks: readonly (readonly number[])[]
158
+ /** 孤立的票排成幾列月台。 */
159
+ readonly islandRows: number
160
+ /** 月台從第幾列開始。 */
161
+ readonly islandFrom: number
162
+ readonly xy: ReadonlyMap<number, Point>
163
+ readonly edges: readonly Edge[]
164
+ readonly width: number
165
+ readonly height: number
166
+ }
167
+
168
+ /**
169
+ * 把一組票排成線路圖。
170
+ *
171
+ * x 由整張圖的 level 決定(前置在左),y 由票屬於哪一條線決定。所有邊因此一律向右,跨線的邊
172
+ * 只需要一個直角轉折。
173
+ *
174
+ * 真的有前置關係的票才排成線:每次挑最長的一條鏈當一條線,挑完移除再挑下一條。組裡完全沒有
175
+ * 前置關係的票(既不擋人也不被擋)不算線,打包成一片月台橫排——不然一組十二張互不相干的票
176
+ * 會變成十二條單站線。
177
+ */
178
+ export function layoutOf(members: readonly MapIssue[]): Layout {
179
+ const inGroup = new Set(members.map((m) => m.number))
180
+ const preds = new Map(
181
+ members.map((m) => [m.number, m.blockedBy.filter((n) => inGroup.has(n))] as const),
182
+ )
183
+ const predsOf = (n: number): readonly number[] => preds.get(n) ?? []
184
+
185
+ const hasEdge = new Set<number>()
186
+ for (const m of members) {
187
+ for (const from of predsOf(m.number)) {
188
+ hasEdge.add(m.number)
189
+ hasEdge.add(from)
190
+ }
191
+ }
192
+ const wired = members.filter((m) => hasEdge.has(m.number))
193
+ const island = members.filter((m) => !hasEdge.has(m.number))
194
+
195
+ const level = new Map<number, number>()
196
+ for (const m of wired) longestPath(m.number, predsOf, level)
197
+ const levelOf = (n: number) => level.get(n) ?? 1
198
+ const deeperFirst = (a: number, b: number) => levelOf(b) - levelOf(a)
199
+
200
+ const left = new Set(wired.map((m) => m.number))
201
+ const tracks: number[][] = []
202
+ while (left.size) {
203
+ const tail = [...left].sort((a, b) => deeperFirst(a, b) || a - b)[0] as number
204
+ const chain = [tail]
205
+ for (let at = tail; ;) {
206
+ const back = predsOf(at)
207
+ .filter((n) => left.has(n))
208
+ .sort(deeperFirst)[0]
209
+ if (back === undefined) break
210
+ chain.unshift(back)
211
+ at = back
212
+ }
213
+ for (const n of chain) left.delete(n)
214
+ tracks.push(chain)
215
+ }
216
+
217
+ const depth = wired.length ? Math.max(...wired.map((m) => levelOf(m.number))) : 1
218
+ const perRow = Math.max(depth, 4)
219
+ const xy = new Map<number, Point>()
220
+ tracks.forEach((chain, index) => {
221
+ for (const n of chain) {
222
+ xy.set(n, { x: MAP.gutter + (levelOf(n) - 1) * MAP.step, y: MAP.top + index * MAP.row })
223
+ }
224
+ })
225
+ island.forEach((m, index) => {
226
+ xy.set(m.number, {
227
+ x: MAP.gutter + (index % perRow) * MAP.step,
228
+ y: MAP.top + (tracks.length + Math.floor(index / perRow)) * MAP.row,
229
+ })
230
+ })
231
+ const islandRows = Math.ceil(island.length / perRow)
232
+
233
+ const edges: Edge[] = []
234
+ for (const m of members) {
235
+ for (const from of predsOf(m.number)) edges.push({ from, to: m.number })
236
+ }
237
+
238
+ return {
239
+ tracks,
240
+ islandRows,
241
+ islandFrom: tracks.length,
242
+ xy,
243
+ edges,
244
+ width: MAP.gutter + perRow * MAP.step + MAP.rightPad,
245
+ height: MAP.top + (tracks.length + islandRows) * MAP.row,
246
+ }
247
+ }