tracker-boot-git-hooks 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 +21 -0
- package/README.md +433 -0
- package/bin/cli.js +88 -0
- package/package.json +41 -0
- package/src/apiClient.js +78 -0
- package/src/commentBuilder.js +29 -0
- package/src/configManager.js +22 -0
- package/src/gitClient.js +30 -0
- package/src/hook.js +120 -0
- package/src/hookTemplate.js +24 -0
- package/src/i18n.js +44 -0
- package/src/installer.js +63 -0
- package/src/pushInputParser.js +17 -0
- package/src/storyIdExtractor.js +11 -0
- package/src/updateChecker.js +34 -0
- package/src/urls.js +6 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 BeKind Labs, Inc.
|
|
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,433 @@
|
|
|
1
|
+
# tracker-boot-git-hooks
|
|
2
|
+
|
|
3
|
+
コミットにストーリーIDが含まれているとき、プッシュ時にTracker Bootのストーリーへ自動でコメントを投稿します。
|
|
4
|
+
커밋에 스토리 ID가 포함된 경우, 푸시 시 Tracker Boot 스토리에 자동으로 댓글을 게시합니다。
|
|
5
|
+
Automatically posts a comment on a Tracker Boot story when you push a commit that references it.
|
|
6
|
+
|
|
7
|
+
[日本語](#ja) · [한국어](#ko) · [English](#en)
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
<a id="ja"></a>
|
|
12
|
+
## 日本語
|
|
13
|
+
|
|
14
|
+
**必要環境:** Node.js ≥ 18、Git
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
### インストール
|
|
19
|
+
|
|
20
|
+
```sh
|
|
21
|
+
npm install -g tracker-boot-git-hooks
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
トラッキングしたいリポジトリ内で一度実行してください:
|
|
25
|
+
|
|
26
|
+
```sh
|
|
27
|
+
tracker-boot-git-hooks install
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
初回プッシュ時にTracker Boot APIキーの入力を求められます。グローバルgit設定(`~/.gitconfig`)に保存され、以降は聞かれません。
|
|
31
|
+
|
|
32
|
+
新しいリポジトリからの初回プッシュ時には、そのリポジトリのプロジェクトIDの入力も求められます。そのリポジトリの`.git/config`にローカル保存されます。
|
|
33
|
+
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
### アップデート
|
|
37
|
+
|
|
38
|
+
プッシュのたびに、フックはnpmレジストリを確認し、新しいバージョンが利用可能な場合はstderrに通知を表示します:
|
|
39
|
+
|
|
40
|
+
```
|
|
41
|
+
tracker-boot: アップデートがあります 1.2.0 (現在: 1.1.0)。実行: npm install -g tracker-boot-git-hooks
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
アップデートするには、2つのコマンドを実行します:
|
|
45
|
+
|
|
46
|
+
```sh
|
|
47
|
+
npm install -g tracker-boot-git-hooks
|
|
48
|
+
tracker-boot-git-hooks install
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
`npm install -g`でCLIを更新し、`tracker-boot-git-hooks install`で各リポジトリのフックファイルを新しいバージョンに書き換えます。
|
|
52
|
+
|
|
53
|
+
---
|
|
54
|
+
|
|
55
|
+
### コミットでストーリーを参照する
|
|
56
|
+
|
|
57
|
+
コミットメッセージの件名の任意の位置に、ストーリーID(9桁)を角括弧で囲んで記述します:
|
|
58
|
+
|
|
59
|
+
```
|
|
60
|
+
[#200022323] F-198B: ダンスのアニメーションを追加
|
|
61
|
+
[finishes #200022323] fix redirect after login
|
|
62
|
+
[fixes #200022323] typo
|
|
63
|
+
[#200022323 delivered] ship feature
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
対応キーワード: `fixes`、`fixed`、`finish`、`finishes`、`finished`、`completes`、`completed`、`delivers`、`delivered` — またはキーワードなしでも使用可能です。キーワードはIDの前後どちらに置いても構いません。
|
|
67
|
+
|
|
68
|
+
> **注意:** キーワードはコメントに含まれますが、現時点ではストーリーの状態を変更しません。
|
|
69
|
+
|
|
70
|
+
1つのコミットで複数のストーリーを参照できます。それぞれのストーリーに個別のコメントが投稿されます。
|
|
71
|
+
|
|
72
|
+
---
|
|
73
|
+
|
|
74
|
+
### すでにpre-pushフックがある場合
|
|
75
|
+
|
|
76
|
+
他のツールが`.git/hooks/pre-push`(または`.husky/pre-push`)を使用している場合、インストールは中断されます。対処法は2つあります:
|
|
77
|
+
|
|
78
|
+
**方法A** — 置き換える。既存のフックを削除してから`install`を再実行します。
|
|
79
|
+
|
|
80
|
+
**方法B** — 追記する。既存のフックの末尾に以下の行を追加します:
|
|
81
|
+
|
|
82
|
+
```sh
|
|
83
|
+
tracker-boot-git-hooks hook "$@"
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
---
|
|
87
|
+
|
|
88
|
+
### デバッグ
|
|
89
|
+
|
|
90
|
+
```sh
|
|
91
|
+
TRACKER_DEBUG=1 git push
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
各ステップをstderrに出力します: プッシュ入力の生データ、検出されたコミット、抽出されたストーリーID、APIコールの内容。
|
|
95
|
+
|
|
96
|
+
すでにプッシュ済みのコミットに対してフックを再実行するには:
|
|
97
|
+
|
|
98
|
+
```sh
|
|
99
|
+
echo "refs/heads/main $(git rev-parse HEAD) refs/heads/main $(git rev-parse HEAD~)" \
|
|
100
|
+
| TRACKER_DEBUG=1 sh .git/hooks/pre-push
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
---
|
|
104
|
+
|
|
105
|
+
### 非標準のTracker Bootインスタンス
|
|
106
|
+
|
|
107
|
+
ローカルまたはステージングインスタンスを使用している場合は、プッシュ前に`TRACKER_BASE_URL`を設定してください:
|
|
108
|
+
|
|
109
|
+
```sh
|
|
110
|
+
TRACKER_BASE_URL=https://trackerboot.staging.example.com git push
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
ベースURLに`/graphql`を自動的に付加します。
|
|
114
|
+
|
|
115
|
+
フックを非標準インスタンスに恒久的に向けるには:
|
|
116
|
+
|
|
117
|
+
```sh
|
|
118
|
+
tracker-boot-git-hooks install --base-url https://trackerboot.staging.example.com
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
---
|
|
122
|
+
|
|
123
|
+
### 既知の制限事項
|
|
124
|
+
|
|
125
|
+
**コミットリンクはGitHubのみ対応。** リモートがGitHub URL(HTTPSまたはSSH)の場合のみ、コメントにハイパーリンク付きのコミットSHAが含まれます。その他のホスト(GitLab、Bitbucketなど)ではプレーンテキストで表示されます。
|
|
126
|
+
|
|
127
|
+
**フックマネージャーはHuskyのみ検出。** lefthook、simple-git-hooks、その他のマネージャーを使用している場合、`install`では検出されません — 上記のセクションで紹介した`tracker-boot-git-hooks hook`アプローチを使用してください。
|
|
128
|
+
|
|
129
|
+
**`tracker-boot-git-hooks hook`使用時はstdinが利用可能である必要があります。** 既存のフックでフック行の前にstdinを読み取るコマンドがある場合、gitのrefデータがすでに消費されてしまい、コミットが検出されません。ほとんどのフック(リンター、フォーマッターなど)はstdinを読み取らないため、実際にはほとんど問題になりません。
|
|
130
|
+
|
|
131
|
+
**1コミットで複数のストーリーIDを参照した場合。** コミットが複数のストーリーを参照している場合、各ストーリーに同一のコメントが投稿されます — 他のストーリーIDを含む完全なコミット件名がそのまま引用されます。
|
|
132
|
+
|
|
133
|
+
**キーワードはストーリーの状態を変更しません。** 上記のコミット書式セクションの注意を参照してください。
|
|
134
|
+
|
|
135
|
+
---
|
|
136
|
+
|
|
137
|
+
### 仕組み
|
|
138
|
+
|
|
139
|
+
1. Gitはpre-pushフックを呼び出し、プッシュされるrefの範囲をstdin経由で渡します
|
|
140
|
+
2. フックは新しいコミットに対して`git log`を実行し、各コミットメッセージからストーリーIDを抽出します
|
|
141
|
+
3. 各ストーリーIDに対して、GraphQL APIを通じてTracker Bootにコミットへのリンクを含むコメントを投稿します
|
|
142
|
+
|
|
143
|
+
フックはプッシュをブロックしません — APIに到達できない場合やエラーが返された場合でも、プッシュは続行され、エラーはstderrに出力されます。
|
|
144
|
+
|
|
145
|
+
### TODO
|
|
146
|
+
|
|
147
|
+
- [ ] 世界へのリリース 🌍
|
|
148
|
+
- [ ] キーワードに基づくストーリー状態変更のサポート
|
|
149
|
+
- [ ] より多くのGitホスティングプロバイダーのサポート(GitLab、Bitbucketなど)
|
|
150
|
+
|
|
151
|
+
---
|
|
152
|
+
|
|
153
|
+
<a id="ko"></a>
|
|
154
|
+
## 한국어
|
|
155
|
+
|
|
156
|
+
**필요 환경:** Node.js ≥ 18、Git
|
|
157
|
+
|
|
158
|
+
---
|
|
159
|
+
|
|
160
|
+
### 설치
|
|
161
|
+
|
|
162
|
+
```sh
|
|
163
|
+
npm install -g tracker-boot-git-hooks
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
추적하려는 저장소 안에서 한 번 실행하세요:
|
|
167
|
+
|
|
168
|
+
```sh
|
|
169
|
+
tracker-boot-git-hooks install
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
저장소에서 처음 푸시할 때 Tracker Boot API 키를 입력하라는 메시지가 표시됩니다。전역 git 설정(`~/.gitconfig`)에 저장되며 이후에는 묻지 않습니다。
|
|
173
|
+
|
|
174
|
+
새로운 저장소에서 처음 푸시할 때는 해당 저장소의 프로젝트 ID도 입력하라는 메시지가 표시됩니다。해당 저장소의 `.git/config`에 로컬로 저장됩니다。
|
|
175
|
+
|
|
176
|
+
---
|
|
177
|
+
|
|
178
|
+
### 업데이트
|
|
179
|
+
|
|
180
|
+
푸시할 때마다 훅이 npm 레지스트리를 확인하고, 새 버전이 있으면 stderr에 알림을 표시합니다:
|
|
181
|
+
|
|
182
|
+
```
|
|
183
|
+
tracker-boot: 업데이트 가능 1.2.0 (현재: 1.1.0). 실행: npm install -g tracker-boot-git-hooks
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
업데이트하려면 두 명령을 실행하세요:
|
|
187
|
+
|
|
188
|
+
```sh
|
|
189
|
+
npm install -g tracker-boot-git-hooks
|
|
190
|
+
tracker-boot-git-hooks install
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
`npm install -g`로 CLI를 업데이트하고, `tracker-boot-git-hooks install`로 각 저장소의 훅 파일을 새 버전으로 다시 작성합니다。
|
|
194
|
+
|
|
195
|
+
---
|
|
196
|
+
|
|
197
|
+
### 커밋에서 스토리 참조하기
|
|
198
|
+
|
|
199
|
+
커밋 제목의 어느 위치에나 스토리 ID(9자리)를 대괄호로 감싸서 작성합니다:
|
|
200
|
+
|
|
201
|
+
```
|
|
202
|
+
[#200022323] F-198B: ダンスのアニメーションを追加
|
|
203
|
+
[finishes #200022323] fix redirect after login
|
|
204
|
+
[fixes #200022323] typo
|
|
205
|
+
[#200022323 delivered] ship feature
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
지원 키워드: `fixes`、`fixed`、`finish`、`finishes`、`finished`、`completes`、`completed`、`delivers`、`delivered` — 또는 키워드 없이도 사용 가능합니다。키워드는 ID 앞뒤 어디에나 올 수 있습니다。
|
|
209
|
+
|
|
210
|
+
> **참고:** 키워드는 댓글에 포함되지만 현재는 스토리 상태를 변경하지 않습니다。
|
|
211
|
+
|
|
212
|
+
하나의 커밋에서 여러 스토리를 참조할 수 있습니다。각 스토리에 개별 댓글이 게시됩니다。
|
|
213
|
+
|
|
214
|
+
---
|
|
215
|
+
|
|
216
|
+
### 이미 pre-push 훅이 있는 경우
|
|
217
|
+
|
|
218
|
+
다른 도구가 `.git/hooks/pre-push`(또는 `.husky/pre-push`)를 사용 중이면 설치가 중단됩니다。두 가지 방법이 있습니다:
|
|
219
|
+
|
|
220
|
+
**방법 A** — 교체하기。기존 훅을 삭제하고 `install`을 다시 실행합니다。
|
|
221
|
+
|
|
222
|
+
**방법 B** — 추가하기。기존 훅의 맨 아래에 다음 줄을 추가합니다:
|
|
223
|
+
|
|
224
|
+
```sh
|
|
225
|
+
tracker-boot-git-hooks hook "$@"
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
---
|
|
229
|
+
|
|
230
|
+
### 디버깅
|
|
231
|
+
|
|
232
|
+
```sh
|
|
233
|
+
TRACKER_DEBUG=1 git push
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
각 단계를 stderr에 출력합니다: 원시 푸시 입력、감지된 커밋、추출된 스토리 ID、수행된 API 호출。
|
|
237
|
+
|
|
238
|
+
이미 푸시된 커밋에 대해 훅을 재실행하려면:
|
|
239
|
+
|
|
240
|
+
```sh
|
|
241
|
+
echo "refs/heads/main $(git rev-parse HEAD) refs/heads/main $(git rev-parse HEAD~)" \
|
|
242
|
+
| TRACKER_DEBUG=1 sh .git/hooks/pre-push
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
---
|
|
246
|
+
|
|
247
|
+
### 비표준 Tracker Boot 인스턴스
|
|
248
|
+
|
|
249
|
+
로컬 또는 스테이징 인스턴스를 사용하는 경우 푸시 전에 `TRACKER_BASE_URL`을 설정하세요:
|
|
250
|
+
|
|
251
|
+
```sh
|
|
252
|
+
TRACKER_BASE_URL=https://trackerboot.staging.example.com git push
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
베이스 URL에 `/graphql`을 자동으로 추가합니다。
|
|
256
|
+
|
|
257
|
+
비표준 인스턴스를 영구적으로 가리키는 훅을 설치하려면:
|
|
258
|
+
|
|
259
|
+
```sh
|
|
260
|
+
tracker-boot-git-hooks install --base-url https://trackerboot.staging.example.com
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
---
|
|
264
|
+
|
|
265
|
+
### 알려진 제한 사항
|
|
266
|
+
|
|
267
|
+
**커밋 링크는 GitHub 전용입니다。** 리모트가 GitHub URL(HTTPS 또는 SSH)인 경우에만 댓글에 하이퍼링크된 커밋 SHA가 포함됩니다。다른 호스트(GitLab、Bitbucket 등)에서는 일반 텍스트로 표시됩니다。
|
|
268
|
+
|
|
269
|
+
**훅 매니저는 Husky만 감지합니다。** lefthook、simple-git-hooks 또는 다른 매니저를 사용하는 경우 `install`이 감지하지 못합니다 — 위 섹션의 `tracker-boot-git-hooks hook` 방식을 사용하세요。
|
|
270
|
+
|
|
271
|
+
**`tracker-boot-git-hooks hook` 사용 시 stdin이 사용 가능해야 합니다。** 기존 훅에서 훅 줄 이전에 stdin을 읽는 명령이 있으면 git의 ref 데이터가 이미 소비되어 커밋을 찾을 수 없습니다。대부분의 훅(린터、포매터 등)은 stdin을 읽지 않으므로 실제로는 거의 문제가 없습니다。
|
|
272
|
+
|
|
273
|
+
**하나의 커밋에 여러 스토리 ID가 있는 경우。** 커밋이 여러 스토리를 참조하면 각 스토리에 동일한 댓글이 게시됩니다 — 다른 스토리 ID를 포함한 전체 커밋 제목이 그대로 인용됩니다。
|
|
274
|
+
|
|
275
|
+
**키워드는 스토리 상태를 변경하지 않습니다。** 위의 커밋 형식 섹션의 참고 사항을 확인하세요。
|
|
276
|
+
|
|
277
|
+
---
|
|
278
|
+
|
|
279
|
+
### 작동 방식
|
|
280
|
+
|
|
281
|
+
1. Git이 pre-push 훅을 호출하고 stdin을 통해 푸시되는 ref 범위를 전달합니다
|
|
282
|
+
2. 훅이 새 커밋에 대해 `git log`를 실행하고 각 커밋 메시지에서 스토리 ID를 추출합니다
|
|
283
|
+
3. 각 스토리 ID에 대해 GraphQL API를 통해 Tracker Boot에 커밋 링크가 포함된 댓글을 게시합니다
|
|
284
|
+
|
|
285
|
+
훅은 절대 푸시를 차단하지 않습니다 — API에 연결할 수 없거나 오류가 반환되더라도 푸시는 진행되고 오류는 stderr에 출력됩니다。
|
|
286
|
+
|
|
287
|
+
### TODO
|
|
288
|
+
|
|
289
|
+
- [ ] 세상에 출시하기 🌍
|
|
290
|
+
- [ ] 키워드를 기반으로 한 스토리 상태 변경 지원
|
|
291
|
+
- [ ] 더 많은 Git 호스팅 제공업체 지원(GitLab、Bitbucket 등)
|
|
292
|
+
|
|
293
|
+
---
|
|
294
|
+
|
|
295
|
+
<a id="en"></a>
|
|
296
|
+
## English
|
|
297
|
+
|
|
298
|
+
**Requires:** Node.js ≥ 18, Git
|
|
299
|
+
|
|
300
|
+
---
|
|
301
|
+
|
|
302
|
+
### Installation
|
|
303
|
+
|
|
304
|
+
```sh
|
|
305
|
+
npm install -g tracker-boot-git-hooks
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
Then run this once inside any repo you want to track:
|
|
309
|
+
|
|
310
|
+
```sh
|
|
311
|
+
tracker-boot-git-hooks install
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
The first time you push from any repo, you'll be prompted for your Tracker Boot API key. It's saved to your global git config (`~/.gitconfig`) and never asked again.
|
|
315
|
+
|
|
316
|
+
The first time you push from a *new* repo, you'll also be prompted for that repo's project ID. It's saved locally to that repo's `.git/config`.
|
|
317
|
+
|
|
318
|
+
---
|
|
319
|
+
|
|
320
|
+
### Updating
|
|
321
|
+
|
|
322
|
+
Every push, the hook checks npm for a newer version. If one is available, it prints a nudge to stderr after posting comments:
|
|
323
|
+
|
|
324
|
+
```
|
|
325
|
+
tracker-boot: update available 1.2.0 (current: 1.1.0). Run: npm install -g tracker-boot-git-hooks
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
To update, run both commands:
|
|
329
|
+
|
|
330
|
+
```sh
|
|
331
|
+
npm install -g tracker-boot-git-hooks
|
|
332
|
+
tracker-boot-git-hooks install
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
`npm install -g` updates the CLI, and `tracker-boot-git-hooks install` rewrites the hook file in each repo to pick up any changes to the hook template.
|
|
336
|
+
|
|
337
|
+
---
|
|
338
|
+
|
|
339
|
+
### Referencing a story in a commit
|
|
340
|
+
|
|
341
|
+
Include the story ID (9 digits) in square brackets anywhere in your commit subject:
|
|
342
|
+
|
|
343
|
+
```
|
|
344
|
+
[#200022323] F-198B: ダンスのアニメーションを追加
|
|
345
|
+
[finishes #200022323] fix redirect after login
|
|
346
|
+
[fixes #200022323] typo
|
|
347
|
+
[#200022323 delivered] ship feature
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
Supported keywords: `fixes`, `fixed`, `finish`, `finishes`, `finished`, `completes`, `completed`, `delivers`, `delivered` — or no keyword at all. The keyword may appear before or after the ID.
|
|
351
|
+
|
|
352
|
+
> **Note:** Keywords are recognized but don't change story state yet. For now they're just included in the comment for context.
|
|
353
|
+
|
|
354
|
+
A commit can reference multiple stories. Each gets its own comment.
|
|
355
|
+
|
|
356
|
+
---
|
|
357
|
+
|
|
358
|
+
### If you already have a pre-push hook
|
|
359
|
+
|
|
360
|
+
If another tool already owns `.git/hooks/pre-push` (or `.husky/pre-push`), installation will stop and tell you. You have two options:
|
|
361
|
+
|
|
362
|
+
**Option A** — Replace it. Remove the existing hook and re-run `install`.
|
|
363
|
+
|
|
364
|
+
**Option B** — Append to it. Add this line to the bottom of your existing hook:
|
|
365
|
+
|
|
366
|
+
```sh
|
|
367
|
+
tracker-boot-git-hooks hook "$@"
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
---
|
|
371
|
+
|
|
372
|
+
### Debugging
|
|
373
|
+
|
|
374
|
+
```sh
|
|
375
|
+
TRACKER_DEBUG=1 git push
|
|
376
|
+
```
|
|
377
|
+
|
|
378
|
+
Prints each step to stderr: the raw push input, commits found, story IDs extracted, and which API calls are made.
|
|
379
|
+
|
|
380
|
+
To replay the hook against a commit you've already pushed:
|
|
381
|
+
|
|
382
|
+
```sh
|
|
383
|
+
echo "refs/heads/main $(git rev-parse HEAD) refs/heads/main $(git rev-parse HEAD~)" \
|
|
384
|
+
| TRACKER_DEBUG=1 sh .git/hooks/pre-push
|
|
385
|
+
```
|
|
386
|
+
|
|
387
|
+
---
|
|
388
|
+
|
|
389
|
+
### Non-standard Tracker Boot instance
|
|
390
|
+
|
|
391
|
+
If you're running a local or staging instance, set `TRACKER_BASE_URL` before pushing:
|
|
392
|
+
|
|
393
|
+
```sh
|
|
394
|
+
TRACKER_BASE_URL=https://trackerboot.staging.example.com git push
|
|
395
|
+
```
|
|
396
|
+
|
|
397
|
+
The hook appends `/graphql` to the base URL automatically.
|
|
398
|
+
|
|
399
|
+
To install a hook permanently pointed at a non-standard instance:
|
|
400
|
+
|
|
401
|
+
```sh
|
|
402
|
+
tracker-boot-git-hooks install --base-url https://trackerboot.staging.example.com
|
|
403
|
+
```
|
|
404
|
+
|
|
405
|
+
---
|
|
406
|
+
|
|
407
|
+
### Known limitations
|
|
408
|
+
|
|
409
|
+
**Commit links are GitHub-only.** The comment includes a hyperlinked commit SHA when the remote is a GitHub URL (HTTPS or SSH). For other hosts (GitLab, Bitbucket, etc.) the SHA appears as plain text.
|
|
410
|
+
|
|
411
|
+
**Hook manager detection is Husky-only.** If you use lefthook, simple-git-hooks, or another manager, `install` won't detect it — use the `tracker-boot-git-hooks hook` approach from the section above instead.
|
|
412
|
+
|
|
413
|
+
**Stdin must be available when using `tracker-boot-git-hooks hook`.** If another command in your existing hook reads stdin before the hook line, git's ref data will already be consumed and no commits will be found. Most hooks (linters, formatters) don't read stdin, so this is rarely an issue in practice.
|
|
414
|
+
|
|
415
|
+
**Multiple story IDs in one commit.** When a commit references more than one story, each story gets an identical comment — the full commit subject, including the other story IDs, quoted verbatim.
|
|
416
|
+
|
|
417
|
+
**Keywords don't change story state.** See the note in the commit format section above.
|
|
418
|
+
|
|
419
|
+
---
|
|
420
|
+
|
|
421
|
+
### How it works
|
|
422
|
+
|
|
423
|
+
1. Git calls the pre-push hook and passes the ref ranges being pushed via stdin
|
|
424
|
+
2. The hook runs `git log` over the new commits and extracts story IDs from each commit message
|
|
425
|
+
3. For each story ID, it posts a comment to Tracker Boot via the GraphQL API with a link to the commit
|
|
426
|
+
|
|
427
|
+
The hook never blocks a push — if the API is unreachable or returns an error, the push goes through and the error is printed to stderr.
|
|
428
|
+
|
|
429
|
+
### TODO
|
|
430
|
+
|
|
431
|
+
- [ ] Release to the world 🌍
|
|
432
|
+
- [ ] Support story state changes based on keywords
|
|
433
|
+
- [ ] Support more Git hosting providers (GitLab, Bitbucket, etc.)
|
package/bin/cli.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFileSync } from 'fs'
|
|
3
|
+
import { spawnSync } from 'child_process'
|
|
4
|
+
import { install } from '../src/installer.js'
|
|
5
|
+
import { buildHookScript } from '../src/hookTemplate.js'
|
|
6
|
+
import { detectLang, t } from '../src/i18n.js'
|
|
7
|
+
import { runHook } from '../src/hook.js'
|
|
8
|
+
import { resolveUrls, DEFAULT_BASE_URL } from '../src/urls.js'
|
|
9
|
+
import boxen from 'boxen'
|
|
10
|
+
|
|
11
|
+
const args = process.argv.slice(2)
|
|
12
|
+
const subcommand = args[0]
|
|
13
|
+
|
|
14
|
+
if (subcommand === 'hook') {
|
|
15
|
+
if (process.stdin.isTTY) {
|
|
16
|
+
process.stderr.write('tracker-boot-git-hooks: "hook" must be called by git (stdin must be a pipe).\n')
|
|
17
|
+
process.exit(1)
|
|
18
|
+
}
|
|
19
|
+
const stdin = readFileSync('/dev/stdin', 'utf8')
|
|
20
|
+
const baseUrl = process.env.TRACKER_BASE_URL ?? DEFAULT_BASE_URL
|
|
21
|
+
// Git passes the remote name as $1 and remote URL as $2 to the pre-push hook
|
|
22
|
+
const remoteName = args[1] ?? null
|
|
23
|
+
const remoteUrl = args[2] ?? null
|
|
24
|
+
|
|
25
|
+
runHook({
|
|
26
|
+
stdin,
|
|
27
|
+
...resolveUrls(baseUrl),
|
|
28
|
+
remoteName,
|
|
29
|
+
remoteUrl,
|
|
30
|
+
}).catch((err) => {
|
|
31
|
+
process.stderr.write(`tracker-boot-git-hooks: unexpected error: ${err.message}\n`)
|
|
32
|
+
})
|
|
33
|
+
} else if (subcommand === 'install') {
|
|
34
|
+
const baseFlagIndex = args.indexOf('--base-url')
|
|
35
|
+
const baseUrl = baseFlagIndex !== -1 ? args[baseFlagIndex + 1] : DEFAULT_BASE_URL
|
|
36
|
+
|
|
37
|
+
if (!baseUrl) {
|
|
38
|
+
process.stderr.write('Error: --base-url requires a value.\n')
|
|
39
|
+
process.exit(1)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function getRepoRoot() {
|
|
43
|
+
const result = spawnSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8' })
|
|
44
|
+
if (result.status !== 0) return null
|
|
45
|
+
return result.stdout.trim()
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const lang = detectLang(process.env.LANG)
|
|
49
|
+
const repoRoot = getRepoRoot()
|
|
50
|
+
if (!repoRoot) {
|
|
51
|
+
process.stderr.write('Error: not inside a git repository.\n')
|
|
52
|
+
process.exit(1)
|
|
53
|
+
}
|
|
54
|
+
const hookScript = buildHookScript({ baseUrl })
|
|
55
|
+
|
|
56
|
+
let result
|
|
57
|
+
try {
|
|
58
|
+
result = install({ repoRoot, hookScript })
|
|
59
|
+
} catch (err) {
|
|
60
|
+
if (err.code === 'HOOK_EXISTS') {
|
|
61
|
+
const [title, ...bodyLines] = t('hookExistsError', lang, { path: err.path }).split('\n')
|
|
62
|
+
process.stderr.write('\n' + boxen(bodyLines.join('\n').trim(), {
|
|
63
|
+
title,
|
|
64
|
+
titleAlignment: 'center',
|
|
65
|
+
padding: 1,
|
|
66
|
+
borderStyle: 'double',
|
|
67
|
+
borderColor: 'red',
|
|
68
|
+
}) + '\n\n')
|
|
69
|
+
process.exit(1)
|
|
70
|
+
}
|
|
71
|
+
process.stderr.write(`Error: ${err.message}\n`)
|
|
72
|
+
process.exit(1)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (result.warning) {
|
|
76
|
+
process.stdout.write(`\nWarning: ${result.warning}\n`)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (result.method === 'direct') {
|
|
80
|
+
process.stdout.write(`\nhook installed\n`)
|
|
81
|
+
} else if (result.method === 'husky') {
|
|
82
|
+
process.stdout.write(`\nhook installed at .husky/pre-push\n`)
|
|
83
|
+
process.stdout.write(`Next: ${result.reminder}\n`)
|
|
84
|
+
}
|
|
85
|
+
} else {
|
|
86
|
+
process.stderr.write(`Usage: tracker-boot-git-hooks <install [--base-url <url>] | hook>\n`)
|
|
87
|
+
process.exit(1)
|
|
88
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "tracker-boot-git-hooks",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Git hooks for Tracker Boot — automatically comments on stories when commits are pushed",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"tracker-boot-git-hooks": "./bin/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin/",
|
|
11
|
+
"src/"
|
|
12
|
+
],
|
|
13
|
+
"scripts": {
|
|
14
|
+
"test": "vitest run",
|
|
15
|
+
"test:watch": "vitest"
|
|
16
|
+
},
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "https://github.com/Bekind-Labs/tracker-boot-git-hooks.git"
|
|
20
|
+
},
|
|
21
|
+
"homepage": "https://github.com/Bekind-Labs/tracker-boot-git-hooks#readme",
|
|
22
|
+
"keywords": [
|
|
23
|
+
"git",
|
|
24
|
+
"hooks",
|
|
25
|
+
"pre-push",
|
|
26
|
+
"tracker",
|
|
27
|
+
"tracker-boot"
|
|
28
|
+
],
|
|
29
|
+
"author": "BeKind Labs",
|
|
30
|
+
"license": "MIT",
|
|
31
|
+
"engines": {
|
|
32
|
+
"node": ">=18.0.0"
|
|
33
|
+
},
|
|
34
|
+
"os": ["darwin", "linux"],
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"boxen": "^8.0.1"
|
|
37
|
+
},
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"vitest": "^2.1.0"
|
|
40
|
+
}
|
|
41
|
+
}
|
package/src/apiClient.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { randomUUID } from 'crypto'
|
|
2
|
+
|
|
3
|
+
const CREATE_COMMENT_MUTATION = `
|
|
4
|
+
mutation ExecuteCommentCreate(
|
|
5
|
+
$projectId: ID!
|
|
6
|
+
$commandId: ID!
|
|
7
|
+
$storyId: ID!
|
|
8
|
+
$content: String!
|
|
9
|
+
) {
|
|
10
|
+
executeCommand(
|
|
11
|
+
input: {
|
|
12
|
+
projectId: $projectId
|
|
13
|
+
version: 1
|
|
14
|
+
commandId: $commandId
|
|
15
|
+
type: COMMENT_CREATE
|
|
16
|
+
parameters: { storyId: $storyId, content: $content, attachments: [] }
|
|
17
|
+
}
|
|
18
|
+
) {
|
|
19
|
+
version
|
|
20
|
+
type
|
|
21
|
+
data {
|
|
22
|
+
__typename
|
|
23
|
+
... on Comment {
|
|
24
|
+
id
|
|
25
|
+
storyId
|
|
26
|
+
content
|
|
27
|
+
user { id name avatarUrl }
|
|
28
|
+
createdAt
|
|
29
|
+
updatedAt
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
`.trim()
|
|
35
|
+
|
|
36
|
+
function assertNoErrors(json) {
|
|
37
|
+
if (json.errors?.length) throw new Error(json.errors[0].message)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function graphqlPost(url, headers, body, timeoutMs = 5000) {
|
|
41
|
+
const controller = new AbortController()
|
|
42
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
|
43
|
+
let res
|
|
44
|
+
try {
|
|
45
|
+
res = await fetch(url, {
|
|
46
|
+
method: 'POST',
|
|
47
|
+
headers: { ...headers, 'Content-Type': 'application/json' },
|
|
48
|
+
body,
|
|
49
|
+
signal: controller.signal,
|
|
50
|
+
})
|
|
51
|
+
} catch (err) {
|
|
52
|
+
throw new Error(err.name === 'AbortError' ? 'request timed out after 5s' : err.message)
|
|
53
|
+
} finally {
|
|
54
|
+
clearTimeout(timer)
|
|
55
|
+
}
|
|
56
|
+
if (!res.ok) {
|
|
57
|
+
const preview = await res.text().catch(() => '')
|
|
58
|
+
throw new Error(`HTTP ${res.status}${preview ? ': ' + preview.slice(0, 120) : ''}`)
|
|
59
|
+
}
|
|
60
|
+
const text = await res.text()
|
|
61
|
+
try {
|
|
62
|
+
return JSON.parse(text)
|
|
63
|
+
} catch {
|
|
64
|
+
throw new Error(`HTTP ${res.status}: non-JSON response`)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function createComment({ mutationUrl, apiKey, projectId, storyId, content, timeoutMs }) {
|
|
69
|
+
const variables = { projectId, commandId: randomUUID(), storyId, content }
|
|
70
|
+
const json = await graphqlPost(
|
|
71
|
+
mutationUrl,
|
|
72
|
+
{ 'Authorization': `Bearer ${apiKey}` },
|
|
73
|
+
JSON.stringify({ query: CREATE_COMMENT_MUTATION, variables }),
|
|
74
|
+
timeoutMs
|
|
75
|
+
)
|
|
76
|
+
assertNoErrors(json)
|
|
77
|
+
return json.data
|
|
78
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
const GITHUB_HTTPS = /^https:\/\/github\.com\/([^/]+)\/([^/]+?)(?:\.git)?$/
|
|
2
|
+
const GITHUB_SSH = /^git@github\.com:([^/]+)\/([^/]+?)(?:\.git)?$/
|
|
3
|
+
|
|
4
|
+
function parseGitHubCommitUrl(remoteUrl, sha) {
|
|
5
|
+
if (!remoteUrl) return null
|
|
6
|
+
const m = remoteUrl.match(GITHUB_HTTPS) ?? remoteUrl.match(GITHUB_SSH)
|
|
7
|
+
if (!m) return null
|
|
8
|
+
return `https://github.com/${m[1]}/${m[2]}/commit/${sha}`
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function buildCommentContent(commit) {
|
|
12
|
+
const shortSha = commit.sha.slice(0, 7)
|
|
13
|
+
const commitUrl = parseGitHubCommitUrl(commit.remoteUrl, commit.sha)
|
|
14
|
+
|
|
15
|
+
const firstLine = commitUrl
|
|
16
|
+
? `[${shortSha}](${commitUrl})`
|
|
17
|
+
: shortSha
|
|
18
|
+
|
|
19
|
+
const parts = [`${firstLine}\n\`${commit.subject}\``]
|
|
20
|
+
|
|
21
|
+
if (commit.body) {
|
|
22
|
+
const trimmedBody = commit.body.trimEnd()
|
|
23
|
+
if (trimmedBody) parts.push(trimmedBody)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
parts.push('\n*posted via [Tracker Boot Git Hook](https://github.com/Bekind-Labs/tracker-boot-git-hooks)*')
|
|
27
|
+
|
|
28
|
+
return parts.join('\n')
|
|
29
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { spawnSync } from 'child_process'
|
|
2
|
+
|
|
3
|
+
const SPAWN_OPTS = { encoding: 'utf8' }
|
|
4
|
+
|
|
5
|
+
export function getConfig(key, { global: isGlobal = false, local: isLocal = false } = {}) {
|
|
6
|
+
let args
|
|
7
|
+
if (isGlobal) args = ['config', '--global', key]
|
|
8
|
+
else if (isLocal) args = ['config', '--local', key]
|
|
9
|
+
else args = ['config', key]
|
|
10
|
+
const result = spawnSync('git', args, SPAWN_OPTS)
|
|
11
|
+
return result.status === 0 ? result.stdout.trimEnd() : null
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function setGlobalConfig(key, value) {
|
|
15
|
+
const r = spawnSync('git', ['config', '--global', key, value], SPAWN_OPTS)
|
|
16
|
+
if (r.status !== 0) throw new Error(`git config --global ${key}: ${(r.stderr ?? '').trim()}`)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function setLocalConfig(key, value) {
|
|
20
|
+
const r = spawnSync('git', ['config', key, value], SPAWN_OPTS)
|
|
21
|
+
if (r.status !== 0) throw new Error(`git config ${key}: ${(r.stderr ?? '').trim()}`)
|
|
22
|
+
}
|
package/src/gitClient.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { spawnSync } from 'child_process'
|
|
2
|
+
|
|
3
|
+
export function getCommitsInRange(remoteSha, localSha, { remote } = {}) {
|
|
4
|
+
// When remoteSha is null the branch has no upstream yet — only walk commits
|
|
5
|
+
// not already reachable from the remote to avoid re-commenting on history.
|
|
6
|
+
const rangeArgs = remoteSha
|
|
7
|
+
? [`${remoteSha}..${localSha}`]
|
|
8
|
+
: [localSha, '--not', remote ? `--remotes=${remote}` : '--remotes']
|
|
9
|
+
const result = spawnSync(
|
|
10
|
+
'git',
|
|
11
|
+
// --format= (tformat:) appends \n after each entry; the parser's .trim() calls rely on this
|
|
12
|
+
['log', ...rangeArgs, '--format=%H%x00%s%x00%b%x00'],
|
|
13
|
+
{ encoding: 'utf8' }
|
|
14
|
+
)
|
|
15
|
+
if (result.status !== 0) {
|
|
16
|
+
throw new Error(`git log failed: ${(result.stderr ?? '').trim()}`)
|
|
17
|
+
}
|
|
18
|
+
const raw = result.stdout ?? ''
|
|
19
|
+
if (!raw.trim()) return []
|
|
20
|
+
|
|
21
|
+
const tokens = raw.split('\x00')
|
|
22
|
+
const commits = []
|
|
23
|
+
for (let i = 0; i + 2 < tokens.length; i += 3) {
|
|
24
|
+
const sha = tokens[i].trim()
|
|
25
|
+
const subject = tokens[i + 1].trim()
|
|
26
|
+
const body = tokens[i + 2].trim()
|
|
27
|
+
if (sha) commits.push({ sha, subject, body })
|
|
28
|
+
}
|
|
29
|
+
return commits
|
|
30
|
+
}
|
package/src/hook.js
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import readline from 'readline'
|
|
2
|
+
import { createReadStream } from 'fs'
|
|
3
|
+
import { getConfig, setGlobalConfig, setLocalConfig } from './configManager.js'
|
|
4
|
+
import { createComment } from './apiClient.js'
|
|
5
|
+
import { parsePushInput } from './pushInputParser.js'
|
|
6
|
+
import { getCommitsInRange } from './gitClient.js'
|
|
7
|
+
import { buildCommentContent } from './commentBuilder.js'
|
|
8
|
+
import { extractStoryIds } from './storyIdExtractor.js'
|
|
9
|
+
import { detectLang, t } from './i18n.js'
|
|
10
|
+
import { checkForUpdate } from './updateChecker.js'
|
|
11
|
+
|
|
12
|
+
function debug(msg) {
|
|
13
|
+
if (process.env.TRACKER_DEBUG) {
|
|
14
|
+
process.stderr.write(`[tracker-boot debug] ${msg}\n`)
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function openTtyPrompt(question) {
|
|
19
|
+
return new Promise((resolve, reject) => {
|
|
20
|
+
const tty = createReadStream('/dev/tty')
|
|
21
|
+
tty.on('error', (err) => {
|
|
22
|
+
reject(new Error(`Interactive setup required — run git push from a terminal first (${err.message})`))
|
|
23
|
+
})
|
|
24
|
+
const rl = readline.createInterface({ input: tty, output: process.stderr })
|
|
25
|
+
rl.question(question, (answer) => {
|
|
26
|
+
rl.close()
|
|
27
|
+
tty.destroy()
|
|
28
|
+
resolve(answer.trim())
|
|
29
|
+
})
|
|
30
|
+
})
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function ensureCredentials({ lang, deps }) {
|
|
34
|
+
let apiKey = deps.getConfig('tracker.apiKey', { global: true })
|
|
35
|
+
let projectId = deps.getConfig('tracker.projectId', { local: true })
|
|
36
|
+
|
|
37
|
+
if (!apiKey) {
|
|
38
|
+
apiKey = await deps.prompt(t('promptApiKey', lang))
|
|
39
|
+
deps.setGlobalConfig('tracker.apiKey', apiKey)
|
|
40
|
+
process.stderr.write(t('apiKeyStored', lang) + '\n')
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (!projectId) {
|
|
44
|
+
projectId = await deps.prompt(t('promptProjectId', lang))
|
|
45
|
+
deps.setLocalConfig('tracker.projectId', projectId)
|
|
46
|
+
process.stderr.write(t('projectIdStored', lang) + '\n')
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return { apiKey, projectId }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function runHook({ stdin, mutationUrl, remoteName, remoteUrl }, inject = {}) {
|
|
53
|
+
const lang = detectLang(process.env.LANG)
|
|
54
|
+
const deps = {
|
|
55
|
+
getConfig,
|
|
56
|
+
setGlobalConfig,
|
|
57
|
+
setLocalConfig,
|
|
58
|
+
createComment,
|
|
59
|
+
getCommitsInRange,
|
|
60
|
+
prompt: openTtyPrompt,
|
|
61
|
+
checkForUpdate,
|
|
62
|
+
...inject,
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const updateCheck = deps.checkForUpdate()
|
|
66
|
+
|
|
67
|
+
debug(`stdin: ${JSON.stringify(stdin)}`)
|
|
68
|
+
debug(`mutationUrl: ${mutationUrl}`)
|
|
69
|
+
|
|
70
|
+
const ranges = parsePushInput(stdin)
|
|
71
|
+
debug(`parsed ${ranges.length} ref range(s): ${JSON.stringify(ranges)}`)
|
|
72
|
+
|
|
73
|
+
const work = []
|
|
74
|
+
for (const { localSha, remoteSha } of ranges) {
|
|
75
|
+
let commits
|
|
76
|
+
try {
|
|
77
|
+
commits = deps.getCommitsInRange(remoteSha, localSha, { remote: remoteName })
|
|
78
|
+
} catch (err) {
|
|
79
|
+
process.stderr.write(`tracker-boot-git-hooks: ${err.message}\n`)
|
|
80
|
+
continue
|
|
81
|
+
}
|
|
82
|
+
debug(`found ${commits.length} commit(s) in ${remoteSha}..${localSha}`)
|
|
83
|
+
for (const commit of commits) {
|
|
84
|
+
const text = commit.body ? `${commit.subject}\n${commit.body}` : commit.subject
|
|
85
|
+
const storyIds = extractStoryIds(text)
|
|
86
|
+
debug(`commit ${commit.sha}: subject="${commit.subject}" storyIds=${JSON.stringify(storyIds)}`)
|
|
87
|
+
for (const storyId of storyIds) {
|
|
88
|
+
work.push({ commit, storyId })
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (work.length === 0) return
|
|
94
|
+
|
|
95
|
+
let credentials
|
|
96
|
+
try {
|
|
97
|
+
credentials = await ensureCredentials({ lang, deps })
|
|
98
|
+
} catch (err) {
|
|
99
|
+
process.stderr.write(`tracker-boot-git-hooks: setup failed: ${err.message}\n`)
|
|
100
|
+
return
|
|
101
|
+
}
|
|
102
|
+
const { apiKey, projectId } = credentials
|
|
103
|
+
debug(`projectId: ${projectId}`)
|
|
104
|
+
|
|
105
|
+
for (const { commit, storyId } of work) {
|
|
106
|
+
const content = buildCommentContent({ ...commit, remoteUrl })
|
|
107
|
+
debug(`posting comment for story ${storyId}`)
|
|
108
|
+
try {
|
|
109
|
+
await deps.createComment({ mutationUrl, apiKey, projectId, storyId, content })
|
|
110
|
+
process.stderr.write(t('commentPosted', lang, { storyId }) + '\n')
|
|
111
|
+
} catch (err) {
|
|
112
|
+
process.stderr.write(t('apiError', lang, { storyId, message: err.message }) + '\n')
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const update = await updateCheck
|
|
117
|
+
if (update) {
|
|
118
|
+
process.stderr.write(t('updateAvailable', lang, { latest: update.latest, current: update.current }) + '\n')
|
|
119
|
+
}
|
|
120
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { createRequire } from 'module'
|
|
2
|
+
import { OUR_MARKER } from './installer.js'
|
|
3
|
+
|
|
4
|
+
const require = createRequire(import.meta.url)
|
|
5
|
+
|
|
6
|
+
function shellQuote(s) {
|
|
7
|
+
return `'${s.replace(/'/g, "'\\''")}'`
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function getVersion() {
|
|
11
|
+
return require('../package.json').version
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function buildHookScript({ baseUrl }) {
|
|
15
|
+
const version = getVersion()
|
|
16
|
+
return [
|
|
17
|
+
'#!/bin/sh',
|
|
18
|
+
`${OUR_MARKER} pre-push hook (version: ${version})`,
|
|
19
|
+
`# To update: npm install -g tracker-boot-git-hooks && tracker-boot-git-hooks install`,
|
|
20
|
+
`# Override with TRACKER_BASE_URL env var.`,
|
|
21
|
+
`TRACKER_BASE_URL=${shellQuote(baseUrl)} \\`,
|
|
22
|
+
` tracker-boot-git-hooks hook "$@"`,
|
|
23
|
+
].join('\n') + '\n'
|
|
24
|
+
}
|
package/src/i18n.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
const MESSAGES = {
|
|
2
|
+
en: {
|
|
3
|
+
promptApiKey: 'Enter your tracker-boot API key: ',
|
|
4
|
+
promptProjectId: 'Enter your tracker-boot project ID for this repo: ',
|
|
5
|
+
apiKeyStored: 'API key saved to global git config (tracker.apiKey).',
|
|
6
|
+
projectIdStored: 'Project ID saved to local git config (tracker.projectId).',
|
|
7
|
+
apiError: 'tracker-boot: failed to post comment for story {storyId}: {message}',
|
|
8
|
+
commentPosted: 'tracker-boot: comment posted on story {storyId}.',
|
|
9
|
+
hookExistsError: '🚫 Hook conflict — installation aborted 🚫\nA pre-push hook already exists at:\n {path}\n\nRemove it first, then re-run:\n tracker-boot-git-hooks install\n\nOr append to your existing hook:\n tracker-boot-git-hooks hook "$@"',
|
|
10
|
+
updateAvailable: 'tracker-boot: update available {latest} (current: {current}). Run: npm install -g tracker-boot-git-hooks',
|
|
11
|
+
},
|
|
12
|
+
ko: {
|
|
13
|
+
promptApiKey: 'tracker-boot API 키를 입력하세요: ',
|
|
14
|
+
promptProjectId: '이 저장소의 tracker-boot 프로젝트 ID를 입력하세요: ',
|
|
15
|
+
apiKeyStored: 'API 키가 전역 git 설정에 저장되었습니다 (tracker.apiKey).',
|
|
16
|
+
projectIdStored: '프로젝트 ID가 로컬 git 설정에 저장되었습니다 (tracker.projectId).',
|
|
17
|
+
apiError: 'tracker-boot: 스토리 {storyId}에 댓글을 게시하지 못했습니다: {message}',
|
|
18
|
+
commentPosted: 'tracker-boot: 스토리 {storyId}에 댓글이 게시되었습니다.',
|
|
19
|
+
hookExistsError: '🚫 훅 충돌 — 설치가 중단되었습니다 🚫\n이미 pre-push 훅이 존재합니다:\n {path}\n\n먼저 삭제한 후 다시 실행하세요:\n tracker-boot-git-hooks install\n\n또는 기존 훅에 다음 줄을 추가하세요:\n tracker-boot-git-hooks hook "$@"',
|
|
20
|
+
updateAvailable: 'tracker-boot: 업데이트 가능 {latest} (현재: {current}). 실행: npm install -g tracker-boot-git-hooks',
|
|
21
|
+
},
|
|
22
|
+
ja: {
|
|
23
|
+
promptApiKey: 'tracker-boot APIキーを入力してください: ',
|
|
24
|
+
promptProjectId: 'このリポジトリのtracker-bootプロジェクトIDを入力してください: ',
|
|
25
|
+
apiKeyStored: 'APIキーがグローバルgit設定に保存されました (tracker.apiKey)。',
|
|
26
|
+
projectIdStored: 'プロジェクトIDがローカルgit設定に保存されました (tracker.projectId)。',
|
|
27
|
+
apiError: 'tracker-boot: ストーリー{storyId}へのコメント投稿に失敗しました: {message}',
|
|
28
|
+
commentPosted: 'tracker-boot: ストーリー{storyId}にコメントが投稿されました。',
|
|
29
|
+
hookExistsError: '🚫 フック競合 — インストールを中断しました 🚫\n以下のパスにpre-pushフックが既に存在します:\n {path}\n\n先に削除してから再実行してください:\n tracker-boot-git-hooks install\n\nまたは既存のフックに以下を追記してください:\n tracker-boot-git-hooks hook "$@"',
|
|
30
|
+
updateAvailable: 'tracker-boot: アップデートがあります {latest} (現在: {current})。実行: npm install -g tracker-boot-git-hooks',
|
|
31
|
+
},
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function detectLang(langEnv) {
|
|
35
|
+
if (!langEnv) return 'en'
|
|
36
|
+
const code = langEnv.split('_')[0].toLowerCase()
|
|
37
|
+
return MESSAGES[code] ? code : 'en'
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function t(key, lang, vars = {}) {
|
|
41
|
+
const messages = MESSAGES[lang] ?? MESSAGES.en
|
|
42
|
+
const template = messages[key] ?? MESSAGES.en[key] ?? key
|
|
43
|
+
return template.replace(/\{(\w+)\}/g, (_, k) => vars[k] ?? `{${k}}`)
|
|
44
|
+
}
|
package/src/installer.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import fs from 'fs'
|
|
2
|
+
import path from 'path'
|
|
3
|
+
import { spawnSync } from 'child_process'
|
|
4
|
+
|
|
5
|
+
export function detectHusky(repoRoot) {
|
|
6
|
+
return fs.existsSync(path.join(repoRoot, '.husky'))
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export const OUR_MARKER = '# tracker-boot-git-hooks'
|
|
10
|
+
|
|
11
|
+
function resolveGitPath(repoRoot, p) {
|
|
12
|
+
return path.isAbsolute(p) ? p : path.resolve(repoRoot, p)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function getGitCommonDir(repoRoot) {
|
|
16
|
+
const result = spawnSync('git', ['-C', repoRoot, 'rev-parse', '--git-common-dir'], { encoding: 'utf8' })
|
|
17
|
+
if (result.status !== 0) return path.join(repoRoot, '.git')
|
|
18
|
+
return resolveGitPath(repoRoot, result.stdout.trim())
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function getHooksDir(repoRoot) {
|
|
22
|
+
const result = spawnSync('git', ['-C', repoRoot, 'config', 'core.hooksPath'], { encoding: 'utf8' })
|
|
23
|
+
if (result.status === 0 && result.stdout.trim()) {
|
|
24
|
+
return resolveGitPath(repoRoot, result.stdout.trim())
|
|
25
|
+
}
|
|
26
|
+
return path.join(getGitCommonDir(repoRoot), 'hooks')
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function installHookFile(filePath, content) {
|
|
30
|
+
if (fs.existsSync(filePath)) {
|
|
31
|
+
const existing = fs.readFileSync(filePath, 'utf8')
|
|
32
|
+
if (!existing.includes(OUR_MARKER)) {
|
|
33
|
+
const err = new Error(`pre-push hook already exists at ${filePath}`)
|
|
34
|
+
err.code = 'HOOK_EXISTS'
|
|
35
|
+
err.path = filePath
|
|
36
|
+
throw err
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
fs.writeFileSync(filePath, content)
|
|
40
|
+
fs.chmodSync(filePath, '755')
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function install({ repoRoot, hookScript }) {
|
|
44
|
+
if (detectHusky(repoRoot)) {
|
|
45
|
+
const hookPath = path.join(repoRoot, '.husky', 'pre-push')
|
|
46
|
+
installHookFile(hookPath, hookScript)
|
|
47
|
+
return { method: 'husky', reminder: 'Run: git add .husky/pre-push' }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const hooksDir = getHooksDir(repoRoot)
|
|
51
|
+
installHookFile(path.join(hooksDir, 'pre-push'), hookScript)
|
|
52
|
+
|
|
53
|
+
if (fs.existsSync(path.join(repoRoot, '.pre-commit-config.yaml'))) {
|
|
54
|
+
return {
|
|
55
|
+
method: 'direct',
|
|
56
|
+
warning:
|
|
57
|
+
'pre-commit runs hooks in isolated Python virtualenvs and cannot run Node scripts directly. ' +
|
|
58
|
+
'The hook has been installed directly to .git/hooks/pre-push instead.',
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return { method: 'direct' }
|
|
63
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
const ZERO_SHA = '0000000000000000000000000000000000000000'
|
|
2
|
+
|
|
3
|
+
export function parsePushInput(stdin) {
|
|
4
|
+
return stdin
|
|
5
|
+
.split('\n')
|
|
6
|
+
.map((line) => line.trim())
|
|
7
|
+
.filter(Boolean)
|
|
8
|
+
.flatMap((line) => {
|
|
9
|
+
const parts = line.split(' ')
|
|
10
|
+
if (parts.length < 4) return []
|
|
11
|
+
// git pre-push stdin: <local-ref> <local-sha> <remote-ref> <remote-sha>
|
|
12
|
+
const [, localSha, , remoteSha] = parts
|
|
13
|
+
if (localSha === ZERO_SHA) return [] // branch deletion
|
|
14
|
+
// null remoteSha = new branch; getCommitsInRange handles it with --not --remotes
|
|
15
|
+
return [{ localSha, remoteSha: remoteSha === ZERO_SHA ? null : remoteSha }]
|
|
16
|
+
})
|
|
17
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
const KEYWORDS = ['fixed', 'fixes', 'finish', 'finishes', 'finished', 'completes', 'completed', 'delivers', 'delivered']
|
|
2
|
+
const KW = `(?:${KEYWORDS.join('|')})`
|
|
3
|
+
const STORY_PATTERN = new RegExp(`\\[(?:${KW}\\s+)?#([0-9]{9})(?:\\s+${KW})?\\]`, 'g')
|
|
4
|
+
|
|
5
|
+
export function extractStoryIds(commitMessage) {
|
|
6
|
+
const ids = new Set()
|
|
7
|
+
for (const match of commitMessage.matchAll(STORY_PATTERN)) {
|
|
8
|
+
ids.add(match[1])
|
|
9
|
+
}
|
|
10
|
+
return Array.from(ids)
|
|
11
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { createRequire } from 'module'
|
|
2
|
+
|
|
3
|
+
const require = createRequire(import.meta.url)
|
|
4
|
+
const NPM_REGISTRY_URL = 'https://registry.npmjs.org/tracker-boot-git-hooks/latest'
|
|
5
|
+
|
|
6
|
+
function isNewer(latest, current) {
|
|
7
|
+
const l = latest.split('.').map(Number)
|
|
8
|
+
const c = current.split('.').map(Number)
|
|
9
|
+
for (let i = 0; i < 3; i++) {
|
|
10
|
+
if (l[i] !== c[i]) return l[i] > c[i]
|
|
11
|
+
}
|
|
12
|
+
return false
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function checkForUpdate({ timeoutMs = 2000, fetch: _fetch = fetch } = {}) {
|
|
16
|
+
const current = require('../package.json').version
|
|
17
|
+
try {
|
|
18
|
+
const controller = new AbortController()
|
|
19
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
|
20
|
+
let res
|
|
21
|
+
try {
|
|
22
|
+
res = await _fetch(NPM_REGISTRY_URL, { signal: controller.signal })
|
|
23
|
+
} finally {
|
|
24
|
+
clearTimeout(timer)
|
|
25
|
+
}
|
|
26
|
+
if (!res.ok) return null
|
|
27
|
+
const data = await res.json()
|
|
28
|
+
const latest = data?.version
|
|
29
|
+
if (typeof latest !== 'string' || !isNewer(latest, current)) return null
|
|
30
|
+
return { latest, current }
|
|
31
|
+
} catch {
|
|
32
|
+
return null
|
|
33
|
+
}
|
|
34
|
+
}
|