issue-map 0.3.0 → 0.3.1

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 CHANGED
@@ -74,8 +74,9 @@ bunx -p issue-map@latest issue-map-build out.html
74
74
  i18n の層です。スナップショットに文を保存していたら、言語を切り替えるたびに GitHub を取り直す
75
75
  ことになります。
76
76
 
77
- CLI 側(生成メッセージ、エラー)は意図的に中国語のままです。あれはこのツールを開発する人向けで
78
- あって、ページの一部ではありません。
77
+ CLI 側(生成メッセージ、エラー)は英語のみで、ページの言語設定には従いません。あれは
78
+ `bunx issue-map` の出力であり、コマンドを打った人が読むもので、ページの一部ではありません。
79
+ 例外は `scripts/mutate.ts` で、この repo 内部のツールなので中国語のままです。
79
80
 
80
81
  ## 前提条件
81
82
 
@@ -90,36 +91,39 @@ CLI 側(生成メッセージ、エラー)は意図的に中国語のまま
90
91
  すべて既定値があり、何も設定しなくても動きます。既定値は `scripts/issue-map.ts` の `CONFIG` に
91
92
  あります。
92
93
 
93
- | 環境変数 | 既定 | 意味 |
94
- | -------------------------- | --------------------------------- | -------------------------------------------------------------------------------------- |
95
- | `GH_REPO` | cwd の git から推測 | 別の repo を描くときに設定(`gh` 自身の変数。fork や複数 remote の判断も任せます) |
96
- | `ISSUE_MAP_PARENT_HEADING` | `Parent` | サブチケットが本文で親を指す見出し。GitHub ネイティブの sub-issue があればそちらを優先 |
97
- | `ISSUE_MAP_LABELS_UNREADY` | `needs-triage,needs-info` | 付いていれば未評価で、まだ誰にも渡せない |
98
- | `ISSUE_MAP_LABELS_READY` | `ready-for-agent,ready-for-human` | 付いて初めて評価済み・着手可とみなす |
99
- | `ISSUE_MAP_LABELS_ACTIVE` | `in-progress` | 付いていれば誰かが対応中(assignee がなくてもよい) |
100
- | `ISSUE_MAP_LABELS_HUMAN` | `ready-for-human` | 人がやるもの。次の一手に実装コマンドを出さない |
101
- | `ISSUE_MAP_CMD_IMPLEMENT` | `/implement` | 着手できるときにマップが促すコマンド |
102
- | `ISSUE_MAP_CMD_TRIAGE` | `/triage` | まだ評価が要るときにマップが促すコマンド |
103
- | `ISSUE_MAP_PORT` | `4747` | server のポート |
104
- | `ISSUE_MAP_OPEN` | 有効 | `0` にするとブラウザを開かない(`bun --watch` の開発モードは既定で無効) |
105
-
106
- とくに考えておくべきものが 2 つ:
94
+ | 環境変数 | 既定 | 意味 |
95
+ | -------------------------- | --------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
96
+ | `GH_REPO` | cwd の git から推測 | 別の repo を描くときに設定(`gh` 自身の変数。fork や複数 remote の判断も任せます) |
97
+ | `ISSUE_MAP_PARENT_HEADING` | `Parent` | サブチケットが本文で親を指す見出し。GitHub ネイティブの sub-issue があればそちらを優先。クローズ済みの兄弟が見えるのはネイティブの場合だけ(下記) |
98
+ | `ISSUE_MAP_LABELS_UNREADY` | `needs-triage,needs-info` | 付いていれば未評価で、まだ誰にも渡せない |
99
+ | `ISSUE_MAP_LABELS_READY` | `ready-for-agent,ready-for-human` | 付いて初めて評価済み・着手可とみなす |
100
+ | `ISSUE_MAP_LABELS_ACTIVE` | `in-progress` | 付いていれば誰かが対応中(assignee がなくてもよい) |
101
+ | `ISSUE_MAP_LABELS_HUMAN` | `ready-for-human` | 人がやるもの。次の一手に実装コマンドを出さない |
102
+ | `ISSUE_MAP_CMD_IMPLEMENT` | `/implement` | 着手できるときにマップが促すコマンド |
103
+ | `ISSUE_MAP_CMD_TRIAGE` | `/triage` | まだ評価が要るときにマップが促すコマンド |
104
+ | `ISSUE_MAP_PORT` | `4747` | server のポート |
105
+ | `ISSUE_MAP_OPEN` | 有効 | `0` にするとブラウザを開かない(`bun --watch` の開発モードは既定で無効) |
106
+
107
+ とくに考えておくべきものが 3 つ:
107
108
 
108
109
  - **ラベルの語彙**:対象 repo がこのセットを使っていないなら、その repo の名前に置き換えてください。
109
110
  コード側で検知します——スナップショットに ready/unready のどのラベルも現れないときは triage を
110
111
  ゲートにしません。そうしないと全チケットが「未評価」になってしまいます。
111
112
  - **コマンド名**:`/implement` と `/triage` は Claude Code の skill です。対象 repo にそれがない
112
113
  場合は必ず差し替えてください。さもないと、存在しないものを走らせろとマップが指示します。
114
+ - **クローズ済みの兄弟にはネイティブ sub-issue が要る**:マップが GitHub に問い合わせるクローズ済み
115
+ チケットは、open なチケットがまだ指しているものだけです——そのブロッカー、その親、そして親の
116
+ 子チケット。子チケットは GitHub ネイティブの sub-issue 関係から取るので、`## Parent` の本文慣例を
117
+ 使う repo ではグループ内の**クローズ済み**の子が出てこず、進捗が実際より少なく見えます。open な
118
+ チケットは影響を受けません。代わりに repo のクローズ済みチケットを全部走査する手もありますが、
119
+ 古い repo ではわずか数枚のために数十回のリクエストを払うことになります。ネイティブ sub-issue より
120
+ 前に作られたグループなら、子チケットを一度リンクし直す(チケットの Sub-issues)だけでクローズ済みの
121
+ 子が戻ります。本文の慣例は残しておいて構いません——ネイティブのほうが優先されます。
113
122
 
114
123
  ## よくある失敗
115
124
 
116
- - `gh api graphql 失敗:…` — `gh` にログインしていないか、cwd が対象 repo の git ツリーの中に
125
+ - `gh api graphql failed: …` — `gh` にログインしていないか、cwd が対象 repo の git ツリーの中に
117
126
  ありません。
118
- - `open issue 超過 100 張,這支要改成分頁抓` — GraphQL の `first` の上限が 100 です。それ以上の
119
- チケットに対応するには `issue-map.ts` の `query()` にページングを足す必要があります。これは設定
120
- ではなくコードの変更です。
121
-
122
- (CLI のメッセージは中国語なので、実際に出力される通りに引用しています。)
123
127
 
124
128
  ## ファイル
125
129
 
package/README.md CHANGED
@@ -71,8 +71,9 @@ The model and fetch side **no longer builds sentences**: `nextStep` is a structu
71
71
  `{ kind: 'waitChildren', count: 2 }`, and so are group names. The words are assembled in the i18n
72
72
  layer. If the snapshot stored sentences, switching language would mean re-fetching from GitHub.
73
73
 
74
- The CLI side (build messages, errors) is deliberately left in Chinese: that output is for whoever
75
- develops this tool, not part of the page.
74
+ The CLI side (build messages, errors) is English only and does not follow the page's language
75
+ setting: it is the output of `bunx issue-map`, read by whoever ran the command, not part of the
76
+ page. `scripts/mutate.ts` is the exception — it is an in-repo tool, so it stays Chinese.
76
77
 
77
78
  ## Requirements
78
79
 
@@ -87,20 +88,20 @@ develops this tool, not part of the page.
87
88
  Everything has a default — it runs with nothing set. The defaults live in `CONFIG` in
88
89
  `scripts/issue-map.ts`.
89
90
 
90
- | Environment variable | Default | Meaning |
91
- | -------------------------- | --------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
92
- | `GH_REPO` | inferred from the git in cwd | Set it to map another repo (`gh`'s own variable; forks and multiple remotes are its job too) |
93
- | `ISSUE_MAP_PARENT_HEADING` | `Parent` | The body heading under which a sub-issue points at its parent. A native GitHub sub-issue relation wins when present |
94
- | `ISSUE_MAP_LABELS_UNREADY` | `needs-triage,needs-info` | Carrying one means it is not assessed yet and cannot be handed to anyone |
95
- | `ISSUE_MAP_LABELS_READY` | `ready-for-agent,ready-for-human` | Only with one of these does an issue count as assessed and ready to work on |
96
- | `ISSUE_MAP_LABELS_ACTIVE` | `in-progress` | Carrying one means somebody is on it, with or without an assignee |
97
- | `ISSUE_MAP_LABELS_HUMAN` | `ready-for-human` | These need a person, so the next step is not an implementation command |
98
- | `ISSUE_MAP_CMD_IMPLEMENT` | `/implement` | The command the map tells you to run when an issue is ready |
99
- | `ISSUE_MAP_CMD_TRIAGE` | `/triage` | The command the map tells you to run when it still needs assessing |
100
- | `ISSUE_MAP_PORT` | `4747` | Port for the server |
101
- | `ISSUE_MAP_OPEN` | on | Set `0` to stop opening the browser (the `bun --watch` dev mode has it off) |
102
-
103
- Two worth thinking through:
91
+ | Environment variable | Default | Meaning |
92
+ | -------------------------- | --------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
93
+ | `GH_REPO` | inferred from the git in cwd | Set it to map another repo (`gh`'s own variable; forks and multiple remotes are its job too) |
94
+ | `ISSUE_MAP_PARENT_HEADING` | `Parent` | The body heading under which a sub-issue points at its parent. A native GitHub sub-issue relation wins when present, and only native ones show _closed_ siblings (see below) |
95
+ | `ISSUE_MAP_LABELS_UNREADY` | `needs-triage,needs-info` | Carrying one means it is not assessed yet and cannot be handed to anyone |
96
+ | `ISSUE_MAP_LABELS_READY` | `ready-for-agent,ready-for-human` | Only with one of these does an issue count as assessed and ready to work on |
97
+ | `ISSUE_MAP_LABELS_ACTIVE` | `in-progress` | Carrying one means somebody is on it, with or without an assignee |
98
+ | `ISSUE_MAP_LABELS_HUMAN` | `ready-for-human` | These need a person, so the next step is not an implementation command |
99
+ | `ISSUE_MAP_CMD_IMPLEMENT` | `/implement` | The command the map tells you to run when an issue is ready |
100
+ | `ISSUE_MAP_CMD_TRIAGE` | `/triage` | The command the map tells you to run when it still needs assessing |
101
+ | `ISSUE_MAP_PORT` | `4747` | Port for the server |
102
+ | `ISSUE_MAP_OPEN` | on | Set `0` to stop opening the browser (the `bun --watch` dev mode has it off) |
103
+
104
+ Three worth thinking through:
104
105
 
105
106
  - **Label vocabulary**: if the target repo does not use this set, replace them with its own names.
106
107
  The code detects it — when the snapshot contains none of the ready/unready labels at all, triage
@@ -108,16 +109,19 @@ Two worth thinking through:
108
109
  - **Command names**: `/implement` and `/triage` are Claude Code skills. If the target repo has no
109
110
  such skills you must change them, or the map will tell people to run something that does not
110
111
  exist.
112
+ - **Closed siblings need native sub-issues**: the map asks GitHub only for the closed issues an
113
+ open one still points at — its blockers, its parent, and that parent's children. Children come
114
+ from GitHub's native sub-issue relation, so with the `## Parent` body convention a group's
115
+ _closed_ children never appear and its progress looks smaller than it is. Open issues are not
116
+ affected. The alternative is scanning every closed issue in the repo, which on an old repo means
117
+ dozens of requests to find a handful of issues. If a group predates native sub-issues, linking
118
+ its children once (the issue's Sub-issues panel) brings the closed ones back; the body convention
119
+ can stay, the native relation wins anyway.
111
120
 
112
121
  ## When it fails
113
122
 
114
- - `gh api graphql 失敗:…` — `gh` is not logged in, or your cwd is not inside the target repo's git
115
- tree.
116
- - `open issue 超過 100 張,這支要改成分頁抓` — 100 is the hard cap on GraphQL's `first`. Supporting
117
- more issues means adding pagination to `query()` in `issue-map.ts`; that is a code change, not
118
- configuration.
119
-
120
- (CLI messages are in Chinese, so they are quoted here as they actually appear.)
123
+ - `gh api graphql failed: …` — `gh` is not logged in, or your cwd is not inside the target repo's
124
+ git tree.
121
125
 
122
126
  ## Files
123
127
 
package/README.zh-CN.md CHANGED
@@ -61,7 +61,7 @@ bunx -p issue-map@latest issue-map-build out.html
61
61
  的值,分组名字也一样,话在 i18n 那一层才组出来。快照里存中文句子的话,换一次语言就得重抓一次
62
62
  GitHub。
63
63
 
64
- CLI 那一侧(产文件消息、错误)刻意留中文:那是给开发者看的,不是页面的一部分。
64
+ CLI 那一侧(产文件消息、错误)只有英文,也不跟着页面的语言走:那是 `bunx issue-map` 的输出,给下指令的人看的,不是页面的一部分。例外是 `scripts/mutate.ts`——它是这个 repo 内部的工具,留中文。
65
65
 
66
66
  ## 前置条件
67
67
 
@@ -74,30 +74,28 @@ CLI 那一侧(产文件消息、错误)刻意留中文:那是给开发者
74
74
 
75
75
  全部有默认值,一个都不设也跑得起来。默认值长在 `scripts/issue-map.ts` 的 `CONFIG`。
76
76
 
77
- | 环境变量 | 默认 | 意思 |
78
- | -------------------------- | --------------------------------- | ------------------------------------------------------------------ |
79
- | `GH_REPO` | 从 cwd 的 git 推断 | 要画别的 repo 时设它(`gh` 自己的变量,fork 与多 remote 也交给它) |
80
- | `ISSUE_MAP_PARENT_HEADING` | `Parent` | 子票在正文指向母票的段落标题。GitHub 原生 sub-issue 有值时优先 |
81
- | `ISSUE_MAP_LABELS_UNREADY` | `needs-triage,needs-info` | 挂了就是还没评估完,不能交给谁做 |
82
- | `ISSUE_MAP_LABELS_READY` | `ready-for-agent,ready-for-human` | 挂了才算评估完、可以动工 |
83
- | `ISSUE_MAP_LABELS_ACTIVE` | `in-progress` | 挂了代表有人在做,不必有 assignee |
84
- | `ISSUE_MAP_LABELS_HUMAN` | `ready-for-human` | 这些要人做,下一步不写实作指令 |
85
- | `ISSUE_MAP_CMD_IMPLEMENT` | `/implement` | 可以动工时图上叫人跑的指令 |
86
- | `ISSUE_MAP_CMD_TRIAGE` | `/triage` | 还要评估时图上叫人跑的指令 |
87
- | `ISSUE_MAP_PORT` | `4747` | server 的端口 |
88
- | `ISSUE_MAP_OPEN` | 开 | 设 `0` 就不自动开浏览器(`bun --watch` 的开发模式默认关掉) |
89
-
90
- 两个要特别想过的:
77
+ | 环境变量 | 默认 | 意思 |
78
+ | -------------------------- | --------------------------------- | ---------------------------------------------------------------------------------------------------------- |
79
+ | `GH_REPO` | 从 cwd 的 git 推断 | 要画别的 repo 时设它(`gh` 自己的变量,fork 与多 remote 也交给它) |
80
+ | `ISSUE_MAP_PARENT_HEADING` | `Parent` | 子票在正文指向母票的段落标题。GitHub 原生 sub-issue 有值时优先,而且只有原生的看得到已完成的兄弟票(见下) |
81
+ | `ISSUE_MAP_LABELS_UNREADY` | `needs-triage,needs-info` | 挂了就是还没评估完,不能交给谁做 |
82
+ | `ISSUE_MAP_LABELS_READY` | `ready-for-agent,ready-for-human` | 挂了才算评估完、可以动工 |
83
+ | `ISSUE_MAP_LABELS_ACTIVE` | `in-progress` | 挂了代表有人在做,不必有 assignee |
84
+ | `ISSUE_MAP_LABELS_HUMAN` | `ready-for-human` | 这些要人做,下一步不写实作指令 |
85
+ | `ISSUE_MAP_CMD_IMPLEMENT` | `/implement` | 可以动工时图上叫人跑的指令 |
86
+ | `ISSUE_MAP_CMD_TRIAGE` | `/triage` | 还要评估时图上叫人跑的指令 |
87
+ | `ISSUE_MAP_PORT` | `4747` | server 的端口 |
88
+ | `ISSUE_MAP_OPEN` | 开 | 设 `0` 就不自动开浏览器(`bun --watch` 的开发模式默认关掉) |
89
+
90
+ 三个要特别想过的:
91
91
 
92
92
  - **标签词汇**:目标 repo 没在用这套标签就要换成它自己的名字。程序会侦测——快照里完全没出现 ready/unready 任何一个标签时,就不拿 triage 当闸门,否则每张票都会变成「待评估」。
93
93
  - **指令名**:`/implement`、`/triage` 是 Claude Code 的 skill。目标 repo 没有的话一定要换掉,不然图上会叫人跑不存在的东西。
94
+ - **已完成的兄弟票要靠原生 sub-issue**:地图只跟 GitHub 要 open 票还牵着的 closed 票——它的阻挡者、它的 parent,以及那个 parent 底下的子票。子票是从 GitHub 原生的 sub-issue 关系拿的,所以用 `## Parent` 正文惯例的 repo 看不到一组里**已完成**的子票,那一组的进度会比实际少。open 票不受影响。另一条路是整包扫过 repo 里所有 closed 票,而那在老 repo 上是几十次请求换个位数张票。一组票如果是原生 sub-issue 之前开的,把子票关联上去一次(票页的 Sub-issues)就会回来;正文惯例可以留着,原生的本来就优先。
94
95
 
95
96
  ## 常见失败
96
97
 
97
- - `gh api graphql 失敗:…` — `gh` 没登录,或 cwd 不在目标 repo 的 git 树里。
98
- - `open issue 超過 100 張,這支要改成分頁抓` — GraphQL 的 `first` 上限就是 100。要支持更多票得在 `issue-map.ts` 的 `query()` 加分页;这是要改程序,不是配置。
99
-
100
- (CLI 消息是繁体中文,所以这里照它实际印出来的样子引用。)
98
+ - `gh api graphql failed: …` — `gh` 没登录,或 cwd 不在目标 repo 的 git 树里。
101
99
 
102
100
  ## 文件
103
101
 
package/README.zh-TW.md CHANGED
@@ -61,7 +61,7 @@ bunx -p issue-map@latest issue-map-build out.html
61
61
  的值,分組名字也一樣,話在 i18n 那一層才組出來。快照裡存中文句子的話,換一次語言就得重抓一次
62
62
  GitHub。
63
63
 
64
- CLI 那一側(產檔訊息、錯誤)刻意留中文:那是給開發者看的,不是頁面的一部分。
64
+ CLI 那一側(產檔訊息、錯誤)只有英文,也不跟著頁面的語言走:那是 `bunx issue-map` 的輸出,給下指令的人看的,不是頁面的一部分。例外是 `scripts/mutate.ts`——它是這個 repo 內部的工具,留中文。
65
65
 
66
66
  ## 前置條件
67
67
 
@@ -74,28 +74,28 @@ CLI 那一側(產檔訊息、錯誤)刻意留中文:那是給開發者看
74
74
 
75
75
  全部有預設值,一個都不設也跑得起來。預設值長在 `scripts/issue-map.ts` 的 `CONFIG`。
76
76
 
77
- | 環境變數 | 預設 | 意思 |
78
- | -------------------------- | --------------------------------- | ------------------------------------------------------------------ |
79
- | `GH_REPO` | 從 cwd 的 git 推斷 | 要畫別的 repo 時設它(`gh` 自己的變數,fork 與多 remote 也交給它) |
80
- | `ISSUE_MAP_PARENT_HEADING` | `Parent` | 子票在內文指向母票的段落標題。GitHub 原生 sub-issue 有值時優先 |
81
- | `ISSUE_MAP_LABELS_UNREADY` | `needs-triage,needs-info` | 掛了就是還沒評估完,不能交給誰做 |
82
- | `ISSUE_MAP_LABELS_READY` | `ready-for-agent,ready-for-human` | 掛了才算評估完、可以動工 |
83
- | `ISSUE_MAP_LABELS_ACTIVE` | `in-progress` | 掛了代表有人在做,不必有 assignee |
84
- | `ISSUE_MAP_LABELS_HUMAN` | `ready-for-human` | 這些要人做,下一步不寫實作指令 |
85
- | `ISSUE_MAP_CMD_IMPLEMENT` | `/implement` | 可以動工時圖上叫人跑的指令 |
86
- | `ISSUE_MAP_CMD_TRIAGE` | `/triage` | 還要評估時圖上叫人跑的指令 |
87
- | `ISSUE_MAP_PORT` | `4747` | server 的 port |
88
- | `ISSUE_MAP_OPEN` | 開 | 設 `0` 就不自動開瀏覽器(`bun --watch` 的開發模式預設關掉) |
89
-
90
- 兩個要特別想過的:
77
+ | 環境變數 | 預設 | 意思 |
78
+ | -------------------------- | --------------------------------- | ---------------------------------------------------------------------------------------------------------- |
79
+ | `GH_REPO` | 從 cwd 的 git 推斷 | 要畫別的 repo 時設它(`gh` 自己的變數,fork 與多 remote 也交給它) |
80
+ | `ISSUE_MAP_PARENT_HEADING` | `Parent` | 子票在內文指向母票的段落標題。GitHub 原生 sub-issue 有值時優先,而且只有原生的看得到已完成的兄弟票(見下) |
81
+ | `ISSUE_MAP_LABELS_UNREADY` | `needs-triage,needs-info` | 掛了就是還沒評估完,不能交給誰做 |
82
+ | `ISSUE_MAP_LABELS_READY` | `ready-for-agent,ready-for-human` | 掛了才算評估完、可以動工 |
83
+ | `ISSUE_MAP_LABELS_ACTIVE` | `in-progress` | 掛了代表有人在做,不必有 assignee |
84
+ | `ISSUE_MAP_LABELS_HUMAN` | `ready-for-human` | 這些要人做,下一步不寫實作指令 |
85
+ | `ISSUE_MAP_CMD_IMPLEMENT` | `/implement` | 可以動工時圖上叫人跑的指令 |
86
+ | `ISSUE_MAP_CMD_TRIAGE` | `/triage` | 還要評估時圖上叫人跑的指令 |
87
+ | `ISSUE_MAP_PORT` | `4747` | server 的 port |
88
+ | `ISSUE_MAP_OPEN` | 開 | 設 `0` 就不自動開瀏覽器(`bun --watch` 的開發模式預設關掉) |
89
+
90
+ 三個要特別想過的:
91
91
 
92
92
  - **標籤字彙**:目標 repo 沒在用這套標籤就要換成它自己的名字。程式會偵測——快照裡完全沒出現 ready/unready 任何一個標籤時,就不拿 triage 當閘門,否則每張票都會變成「待評估」。
93
93
  - **指令名**:`/implement`、`/triage` 是 Claude Code 的 skill。目標 repo 沒有的話一定要換掉,不然圖上會叫人跑不存在的東西。
94
+ - **已完成的兄弟票要靠原生 sub-issue**:地圖只跟 GitHub 要 open 票還牽著的 closed 票——它的阻擋者、它的 parent,以及那個 parent 底下的子票。子票是從 GitHub 原生的 sub-issue 關係拿的,所以用 `## Parent` 內文慣例的 repo 看不到一組裡**已完成**的子票,那一組的進度會比實際少。open 票不受影響。另一條路是整包掃過 repo 裡所有 closed 票,而那在老 repo 上是幾十次請求換個位數張票。一組票如果是原生 sub-issue 之前開的,把子票關聯上去一次(票頁的 Sub-issues)就會回來;內文慣例可以留著,原生的本來就優先。
94
95
 
95
96
  ## 常見失敗
96
97
 
97
- - `gh api graphql 失敗:…` — `gh` 沒登入,或 cwd 不在目標 repo 的 git 樹裡。
98
- - `open issue 超過 100 張,這支要改成分頁抓` — GraphQL 的 `first` 上限就是 100。要支援更多票得在 `issue-map.ts` 的 `query()` 加分頁;這是要改程式,不是設定。
98
+ - `gh api graphql failed: …` — `gh` 沒登入,或 cwd 不在目標 repo 的 git 樹裡。
99
99
 
100
100
  ## 檔案
101
101
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "issue-map",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "description": "Draws the blocking relationships between your GitHub Issues as a one-page dev map: which issues can be picked up now, which are waiting on what, and where the critical path runs.",
5
5
  "keywords": [
6
6
  "bun",
@@ -36,7 +36,7 @@ function openInBrowser(url: string): void {
36
36
  const result = spawnSync(command, { stdout: 'ignore', stderr: 'pipe' })
37
37
  if (!result.success) {
38
38
  console.error(
39
- `打不開瀏覽器(${command[0]}:${result.stderr.toString().trim()})——自己開上面那個網址`,
39
+ `Could not open the browser (${command[0]}: ${result.stderr.toString().trim()}) — open the URL above yourself`,
40
40
  )
41
41
  }
42
42
  }
@@ -72,5 +72,5 @@ const server = Bun.serve({
72
72
  })
73
73
 
74
74
  const url = `http://localhost:${server.port}`
75
- console.log(`開發地圖:${url}(重新整理就重抓 GitHub)`)
75
+ console.log(`Dev map: ${url} (every refresh re-fetches from GitHub)`)
76
76
  if (OPEN) openInBrowser(url)
@@ -11,6 +11,10 @@
11
11
  * 帶進快照的 issue:所有 open issue,加上仍被 open issue 牽著的 closed issue。後者畫成「已完成」
12
12
  * 的節點讓進度看得見,沒人牽著之後自然消失。
13
13
  *
14
+ * closed 是**指名**去要的(阻擋者、parent、parent 底下的子票),不掃整包——老 repo 幾千張
15
+ * closed 裡通常只有個位數會留下。代價:同一組裡已完成的兄弟票要靠 GitHub 原生 sub-issue 才
16
+ * 抽得到,用內文 `## Parent` 慣例的 repo 看不到它們,那一組的進度會比實際少。
17
+ *
14
18
  * **要畫哪個 repo**:從 cwd 的 git 推斷,不必填——在那個 repo 裡跑 `bunx issue-map@latest`
15
19
  * 就好。要指定別的 repo 設 `GH_REPO`。標籤字彙與 parent 的慣例都能用環境變數調,見底下的
16
20
  * `CONFIG`,整份對照表在 README。
@@ -42,8 +46,10 @@ const TEMPLATE = new URL('./issue-map.html', import.meta.url).pathname
42
46
  const CLIENT = new URL('./issue-map-page.ts', import.meta.url).pathname
43
47
  const OUTPUT = process.argv[2] ?? 'dist/issue-map.html'
44
48
 
45
- /** 一次查得回來的上限。GraphQL 的 `first` 最多就是 100,超過會少票,所以超過就喊。 */
49
+ /** 一頁的張數。GraphQL 的 `first` 最多就是 100,票再多就靠 cursor 一頁一頁接。 */
46
50
  const PAGE = 100
51
+ /** 一次用 alias 指名幾張票。GraphQL 對單一查詢的節點數有上限,這個量級離它還很遠。 */
52
+ const BATCH = 50
47
53
 
48
54
  function labelList(raw: string | undefined, fallback: string): readonly string[] {
49
55
  return (raw ?? fallback)
@@ -96,50 +102,163 @@ type RawIssue = {
96
102
  /** 一路帶著算好的 parent,免得同一段內文被 regex 掃好幾次。 */
97
103
  type Issue = RawIssue & { readonly parentNumber: number | null }
98
104
 
99
- interface Connection {
100
- pageInfo: { hasNextPage: boolean }
101
- nodes: RawIssue[]
105
+ interface Page<T> {
106
+ pageInfo: { hasNextPage: boolean; endCursor: string | null }
107
+ nodes: T[]
102
108
  }
103
- interface QueryResult {
104
- repository: { nameWithOwner: string } & Record<'open' | 'closed', Connection>
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
105
147
  }
106
148
 
107
- const FIELDS = `
108
- pageInfo { hasNextPage }
109
- nodes {
110
- number title state url body closedAt
111
- author { login }
112
- parent { number }
113
- labels(first: 20) { nodes { name } }
114
- assignees(first: 10) { nodes { login } }
115
- blockedBy(first: 50) { nodes { number } }
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
116
174
  }
117
- `
175
+ return { nameWithOwner, open }
176
+ }
177
+
178
+ /** alias 不能以數字開頭,所以票號前面補一個 `i`。 */
179
+ const alias = (number: number) => `i${number}`
118
180
 
119
- /** 一次問完 repo 名字與兩種狀態的 issue,只開一個 `gh` 行程。 */
120
- const QUERY = `
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 = `
121
195
  query($owner: String!, $repo: String!) {
122
196
  repository(owner: $owner, name: $repo) {
123
- nameWithOwner
124
- open: issues(states: OPEN, first: ${PAGE}, orderBy: { field: CREATED_AT, direction: DESC }) { ${FIELDS} }
125
- closed: issues(states: CLOSED, first: ${PAGE}, orderBy: { field: CREATED_AT, direction: DESC }) { ${FIELDS} }
197
+ ${batch.map((number) => `${alias(number)}: issue(number: ${number}) { ${ISSUE_FIELDS} }`).join('\n ')}
126
198
  }
127
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
+ }
128
208
 
129
- function query(): QueryResult['repository'] {
130
- // `{owner}`/`{repo}` 由 gh 從 cwd 的 git 推斷,`GH_REPO` 可以蓋過去。
131
- const result = spawnSync(
132
- ['gh', 'api', 'graphql', '-f', `query=${QUERY}`, '-F', 'owner={owner}', '-F', 'repo={repo}'],
133
- { stdout: 'pipe', stderr: 'pipe' },
134
- )
135
- if (result.exitCode !== 0) throw new Error(`gh api graphql 失敗:${result.stderr.toString()}`)
136
- const parsed = JSON.parse(result.stdout.toString()) as { data: QueryResult; errors?: unknown }
137
- if (parsed.errors) throw new Error(`GraphQL 錯誤:${JSON.stringify(parsed.errors)}`)
138
- const repository = parsed.data.repository
139
- if (repository.open.pageInfo.hasNextPage) {
140
- throw new Error(`open issue 超過 ${PAGE} 張,這支要改成分頁抓`)
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
+ }
141
260
  }
142
- return repository
261
+ return children
143
262
  }
144
263
 
145
264
  /** 原生 sub-issue 優先;沒有就讀內文的 `## <標題>` 之後第一個 `#<n>`。 */
@@ -150,9 +269,8 @@ function withParent(raw: RawIssue): Issue {
150
269
  }
151
270
 
152
271
  export function takeSnapshot(): Snapshot {
153
- const repository = query()
154
- const open = repository.open.nodes.map(withParent)
155
- const closed = repository.closed.nodes.map(withParent)
272
+ const { nameWithOwner, open: rawOpen } = fetchOpen()
273
+ const open = rawOpen.map(withParent)
156
274
 
157
275
  const openNumbers = new Set(open.map((issue) => issue.number))
158
276
  const openParents = new Set(open.map((issue) => issue.parentNumber).filter(isNumber))
@@ -160,17 +278,17 @@ export function takeSnapshot(): Snapshot {
160
278
  open.flatMap((issue) => issue.blockedBy.nodes.map((blocker) => blocker.number)),
161
279
  )
162
280
 
163
- // 只留 open issue 還牽著的 closed issue:當它們的阻擋者、當它們的 parent,或跟它們同一個
164
- // parent(同一組子票的已完成進度)。沒人牽著之後自然從地圖消失。
165
- const kept = [
166
- ...open,
167
- ...closed.filter(
168
- (issue) =>
169
- blockers.has(issue.number) ||
170
- openParents.has(issue.number) ||
171
- (issue.parentNumber !== null && openParents.has(issue.parentNumber)),
172
- ),
173
- ]
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]
174
292
  const parents = new Set(kept.map((issue) => issue.parentNumber).filter(isNumber))
175
293
  const openChildren = new Map<number, number>()
176
294
  for (const issue of kept) {
@@ -187,7 +305,7 @@ export function takeSnapshot(): Snapshot {
187
305
  .toSorted((a, b) => a.number - b.number)
188
306
  return {
189
307
  generatedAt: new Date().toISOString(),
190
- repo: repository.nameWithOwner,
308
+ repo: nameWithOwner,
191
309
  labels: { ready: CONFIG.ready, unready: CONFIG.unready },
192
310
  groups: groupsOf(issues),
193
311
  criticalPath: criticalPathOf(issues),
@@ -195,6 +313,11 @@ export function takeSnapshot(): Snapshot {
195
313
  }
196
314
  }
197
315
 
316
+ /** 同一張票可能同時是某人的阻擋者又是某人的兄弟,用票號收斂成一張。 */
317
+ function dedupe(issues: readonly RawIssue[]): RawIssue[] {
318
+ return [...new Map(issues.map((issue) => [issue.number, issue])).values()]
319
+ }
320
+
198
321
  function isNumber(value: number | null): value is number {
199
322
  return value !== null
200
323
  }
@@ -281,9 +404,9 @@ async function bundleClient(): Promise<string> {
281
404
  format: 'iife',
282
405
  minify: false,
283
406
  })
284
- if (!built.success) throw new Error(`打包 ${CLIENT} 失敗:${built.logs.join('\n')}`)
407
+ if (!built.success) throw new Error(`Failed to bundle ${CLIENT}: ${built.logs.join('\n')}`)
285
408
  const [output] = built.outputs
286
- if (!output) throw new Error(`打包 ${CLIENT} 沒有產出`)
409
+ if (!output) throw new Error(`Bundling ${CLIENT} produced no output`)
287
410
  return output.text()
288
411
  }
289
412
 
@@ -307,17 +430,17 @@ export async function renderFragment(snapshot: Snapshot): Promise<string> {
307
430
 
308
431
  function replaceIn(html: string, marker: RegExp, body: string, what: string): string {
309
432
  const next = html.replace(marker, `$1${body}$2`)
310
- if (next === html) throw new Error(`樣板缺少 ${what} 區塊:${TEMPLATE}`)
433
+ if (next === html) throw new Error(`Template is missing the ${what} block: ${TEMPLATE}`)
311
434
  return next
312
435
  }
313
436
 
314
437
  export function describe(snapshot: Snapshot): string {
315
438
  const done = snapshot.issues.filter((issue) => issue.status === 'done').length
316
- return `${snapshot.issues.length - done} 張未完成、${done} 張仍被引用的已完成(${snapshot.generatedAt})`
439
+ return `${snapshot.issues.length - done} unfinished, ${done} closed but still referenced (${snapshot.generatedAt})`
317
440
  }
318
441
 
319
442
  if (import.meta.main) {
320
443
  const snapshot = takeSnapshot()
321
444
  await Bun.write(OUTPUT, await renderFragment(snapshot))
322
- console.log(`已寫入 ${OUTPUT}:${describe(snapshot)}`)
445
+ console.log(`Wrote ${OUTPUT}: ${describe(snapshot)}`)
323
446
  }