lechnerio-git-hooks 1.2.0 → 1.3.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.md CHANGED
@@ -32,7 +32,11 @@ On install, the package automatically:
32
32
  | ------------- | -------------------------------------------------------------------------------------------- |
33
33
  | `pre-commit` | Runs lint-staged (eslint --fix + prettier) |
34
34
  | `commit-msg` | Enforces conventional commits via commitlint |
35
- | `post-commit` | Interactive version bump (subtle/patch/minor/major), changelog generation, push/merge prompt |
35
+ | `post-commit` | Interactive version bump (subtle/patch/minor/major), changelog generation with optional skip flag, push/merge prompt |
36
+
37
+ ## AI release summaries
38
+
39
+ On Minor and Major version bumps, the `post-commit` hook sends the version's non-skipped commit messages to the Gemini API and stores the result as a `summary` field on the version entry in `data/changelog.json`. This requires `GITHOOKS_GEMINI_API_KEY` in the project's `.env` file or environment; without it the summary step is skipped silently.
36
40
 
37
41
  ## Peer dependencies (auto-installed)
38
42
 
@@ -55,6 +59,9 @@ For non-interactive usage (CI, scripts):
55
59
  | -------------- | --------------------------------------------------------------- |
56
60
  | `VERSION_BUMP` | `1` = subtle, `2` = patch, `3` = minor, `4` = major, `0` = skip |
57
61
  | `POST_ACTION` | `1` = push, `2` = merge to main, `0` = skip |
62
+ | `CHANGELOG_ADD` | `1` = keep commit in changelog, `0` = mark it skipped (asked when version bump is skipped) |
63
+ | `GITHOOKS_GEMINI_API_KEY` | Gemini API key for AI release summaries; unset = feature disabled |
64
+ | `GITHOOKS_GEMINI_MODEL` | Optional Gemini model for summaries, defaults to `gemini-flash-latest` |
58
65
 
59
66
  ## Update
60
67
 
package/hooks/post-commit CHANGED
@@ -76,13 +76,51 @@ bump_and_amend() {
76
76
  git add package.json
77
77
  git commit --amend --no-edit
78
78
  git tag -f -a "v${new_ver}" -m "v${new_ver}"
79
- node scripts/generate-changelog.mjs 2>/dev/null
79
+ local summarize_flag=""
80
+ if [ "$label" = "Minor" ] || [ "$label" = "Major" ]; then
81
+ summarize_flag="--summarize"
82
+ echo -e "${CYAN}🤖 Generating AI summary...${NC}"
83
+ fi
84
+ node scripts/generate-changelog.mjs $summarize_flag
80
85
  git add data/changelog.json
81
86
  git commit --amend --no-edit --no-verify
82
87
  git tag -f -a "v${new_ver}" -m "v${new_ver}"
83
88
  echo -e "${GREEN}✅ $label version bumped to ${new_ver}${NC}"
84
89
  }
85
90
 
91
+ prompt_changelog_skip() {
92
+ local choice
93
+ if [ -n "$CHANGELOG_ADD" ]; then
94
+ choice="$CHANGELOG_ADD"
95
+ elif exec < /dev/tty 2>/dev/null; then
96
+ echo -e "${CYAN}🔄 Changelog${NC}"
97
+ echo
98
+ echo -e "${CYAN}Should we add this message to the changelog?${NC}"
99
+ echo
100
+ echo -e "1) Yes ${YELLOW}(default)${NC}"
101
+ echo -e "${GRAY}0) No - Skip Changelog${NC}"
102
+ echo
103
+ echo -n "Select option (Enter for Default or 0 to skip): "
104
+ read -r choice
105
+ choice=${choice:-1}
106
+ else
107
+ choice=1
108
+ fi
109
+ echo
110
+
111
+ if [ "$choice" = "0" ]; then
112
+ if [ "$DRY_RUN" = "true" ]; then
113
+ echo -e "${YELLOW}[DRY RUN] Would mark commit as skipped in changelog${NC}"
114
+ else
115
+ node scripts/generate-changelog.mjs --skip-last 2>/dev/null
116
+ git add data/changelog.json
117
+ git commit --amend --no-edit --no-verify
118
+ echo -e "${CYAN}📋 Commit marked as skipped in changelog${NC}"
119
+ echo
120
+ fi
121
+ fi
122
+ }
123
+
86
124
  do_push() {
87
125
  echo -e "${YELLOW}✈️ Pushing to GitHub...${NC}"
88
126
  if [ "$DRY_RUN" = "true" ]; then
@@ -277,7 +315,7 @@ case $version_choice in
277
315
  3)
278
316
  echo -e "${YELLOW}📦 Bumping minor version...${NC}"
279
317
  if [ "$DRY_RUN" = "true" ]; then
280
- echo -e "${YELLOW}[DRY RUN] Would bump to ${minor_ver}${NC}"
318
+ echo -e "${YELLOW}[DRY RUN] Would bump to ${minor_ver} and generate AI summary${NC}"
281
319
  else
282
320
  bump_and_amend "$minor_ver" "Minor"
283
321
  fi
@@ -285,12 +323,13 @@ case $version_choice in
285
323
  4)
286
324
  echo -e "${YELLOW}📦 Bumping major version...${NC}"
287
325
  if [ "$DRY_RUN" = "true" ]; then
288
- echo -e "${YELLOW}[DRY RUN] Would bump to ${major_ver}${NC}"
326
+ echo -e "${YELLOW}[DRY RUN] Would bump to ${major_ver} and generate AI summary${NC}"
289
327
  else
290
328
  bump_and_amend "$major_ver" "Major"
291
329
  fi
292
330
  ;;
293
331
  0)
332
+ prompt_changelog_skip
294
333
  echo -e "${YELLOW}⏭️ Skipping version bump${NC}"
295
334
  ;;
296
335
  *)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lechnerio-git-hooks",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "Shared git hooks for lechnerio projects",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -1,8 +1,21 @@
1
1
  import { execSync } from "child_process"
2
- import { writeFileSync, mkdirSync } from "fs"
2
+ import { writeFileSync, mkdirSync, readFileSync, existsSync } from "fs"
3
3
 
4
4
  const run = (cmd) => execSync(cmd, { encoding: "utf-8", shell: "bash" }).trim()
5
5
 
6
+ const loadEnvKey = (name) => {
7
+ if (process.env[name]) return process.env[name]
8
+ try {
9
+ const line = readFileSync(".env", "utf-8")
10
+ .split("\n")
11
+ .map((l) => l.trim())
12
+ .find((l) => l.startsWith(`${name}=`))
13
+ return line ? line.slice(name.length + 1).trim().replace(/^["']|["']$/g, "") : null
14
+ } catch {
15
+ return null
16
+ }
17
+ }
18
+
6
19
  try {
7
20
  run("git --version")
8
21
  run("git rev-parse --git-dir")
@@ -17,12 +30,31 @@ if (tags.length === 0) {
17
30
  process.exit(0)
18
31
  }
19
32
 
33
+ const skipFlags = new Map()
34
+ const summaries = new Map()
35
+ if (existsSync("data/changelog.json")) {
36
+ try {
37
+ const existing = JSON.parse(readFileSync("data/changelog.json", "utf-8"))
38
+ for (const entry of existing) {
39
+ if (typeof entry.summary === "string") summaries.set(entry.version, entry.summary)
40
+ for (const commit of entry.commits ?? []) {
41
+ skipFlags.set(commit.message, commit.skip === true)
42
+ }
43
+ }
44
+ } catch {}
45
+ }
46
+
47
+ if (process.argv.includes("--skip-last")) {
48
+ const lastSubject = run('git log -1 --format="%s"')
49
+ skipFlags.set(lastSubject, true)
50
+ }
51
+
20
52
  const changelog = []
21
53
 
22
54
  const currentVersion = tags[0].replace(/^v/, "")
23
55
  const unreleasedRaw = run(`git log --format="%s" ${tags[0]}..HEAD`)
24
56
  if (unreleasedRaw) {
25
- const commits = [...new Set(unreleasedRaw.split("\n").filter(Boolean))].map((message) => ({ message }))
57
+ const commits = [...new Set(unreleasedRaw.split("\n").filter(Boolean))].map((message) => ({ message, skip: skipFlags.get(message) ?? false }))
26
58
  if (commits.length > 0) {
27
59
  const date = run(`git log --format=%ai HEAD -1`).split(" ")[0]
28
60
  changelog.push({ version: currentVersion, tag: null, date, commits })
@@ -38,7 +70,7 @@ for (let i = 0; i < tags.length; i++) {
38
70
  const raw = run(`git log --format="%s" ${range}`)
39
71
 
40
72
  const commits = raw
41
- ? [...new Set(raw.split("\n").filter(Boolean))].map((message) => ({ message }))
73
+ ? [...new Set(raw.split("\n").filter(Boolean))].map((message) => ({ message, skip: skipFlags.get(message) ?? false }))
42
74
  : []
43
75
 
44
76
  changelog.push({
@@ -72,6 +104,49 @@ for (const entry of grouped) {
72
104
  })
73
105
  }
74
106
 
107
+ for (const entry of grouped) {
108
+ const saved = summaries.get(entry.version)
109
+ if (saved && !entry.summary) entry.summary = saved
110
+ }
111
+
112
+ if (process.argv.includes("--summarize")) {
113
+ const apiKey = loadEnvKey("GITHOOKS_GEMINI_API_KEY")
114
+ if (!apiKey) {
115
+ console.warn("GITHOOKS_GEMINI_API_KEY not set, skipping AI summary")
116
+ } else {
117
+ const target = grouped.find((e) => e.tag === tags[0])
118
+ if (target && !target.summary) {
119
+ const messages = target.commits.filter((c) => !c.skip).map((c) => c.message)
120
+ if (messages.length === 0) {
121
+ console.warn("no commits to summarize")
122
+ } else {
123
+ try {
124
+ const model = loadEnvKey("GITHOOKS_GEMINI_MODEL") ?? "gemini-flash-latest"
125
+ const prompt = `Summarize these commit messages into a short user-facing release summary for version ${target.version}. Write 1-3 plain sentences, no markdown, no bullet points, no preamble.\n\n${messages.join("\n")}`
126
+ const res = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent`, {
127
+ method: "POST",
128
+ headers: { "Content-Type": "application/json", "x-goog-api-key": apiKey },
129
+ body: JSON.stringify({
130
+ contents: [{ parts: [{ text: prompt }] }],
131
+ generationConfig: { thinkingConfig: { thinkingBudget: 0 } },
132
+ }),
133
+ signal: AbortSignal.timeout(60000),
134
+ })
135
+ if (!res.ok) throw new Error(`gemini api returned ${res.status}`)
136
+ const data = await res.json()
137
+ const text = data.candidates?.[0]?.content?.parts?.[0]?.text?.trim()
138
+ if (text) {
139
+ target.summary = text
140
+ console.log(`ai summary generated for ${target.version}`)
141
+ }
142
+ } catch (err) {
143
+ console.warn(`ai summary failed: ${err.message}`)
144
+ }
145
+ }
146
+ }
147
+ }
148
+ }
149
+
75
150
  mkdirSync("data", { recursive: true })
76
151
  writeFileSync("data/changelog.json", JSON.stringify(grouped, null, 2))
77
152
  console.log(`changelog generated: ${grouped.length} versions`)