lechnerio-git-hooks 1.2.0 → 1.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.md +8 -1
- package/hooks/post-commit +42 -3
- package/package.json +1 -1
- package/scripts/generate-changelog.mjs +97 -4
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 `summary_en` and `summary_ger` fields (English and German) 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
|
-
|
|
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,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,38 @@ 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
|
+
const summaryEn = entry.summary_en ?? entry.summary
|
|
40
|
+
const summaryGer = entry.summary_ger
|
|
41
|
+
if (typeof summaryEn === "string" || typeof summaryGer === "string") {
|
|
42
|
+
const saved = {}
|
|
43
|
+
if (typeof summaryEn === "string") saved.summary_en = summaryEn
|
|
44
|
+
if (typeof summaryGer === "string") saved.summary_ger = summaryGer
|
|
45
|
+
summaries.set(entry.version, saved)
|
|
46
|
+
}
|
|
47
|
+
for (const commit of entry.commits ?? []) {
|
|
48
|
+
skipFlags.set(commit.message, commit.skip === true)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
} catch {}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (process.argv.includes("--skip-last")) {
|
|
55
|
+
const lastSubject = run('git log -1 --format="%s"')
|
|
56
|
+
skipFlags.set(lastSubject, true)
|
|
57
|
+
}
|
|
58
|
+
|
|
20
59
|
const changelog = []
|
|
21
60
|
|
|
22
61
|
const currentVersion = tags[0].replace(/^v/, "")
|
|
23
62
|
const unreleasedRaw = run(`git log --format="%s" ${tags[0]}..HEAD`)
|
|
24
63
|
if (unreleasedRaw) {
|
|
25
|
-
const commits = [...new Set(unreleasedRaw.split("\n").filter(Boolean))].map((message) => ({ message }))
|
|
64
|
+
const commits = [...new Set(unreleasedRaw.split("\n").filter(Boolean))].map((message) => ({ message, skip: skipFlags.get(message) ?? false }))
|
|
26
65
|
if (commits.length > 0) {
|
|
27
66
|
const date = run(`git log --format=%ai HEAD -1`).split(" ")[0]
|
|
28
67
|
changelog.push({ version: currentVersion, tag: null, date, commits })
|
|
@@ -38,7 +77,7 @@ for (let i = 0; i < tags.length; i++) {
|
|
|
38
77
|
const raw = run(`git log --format="%s" ${range}`)
|
|
39
78
|
|
|
40
79
|
const commits = raw
|
|
41
|
-
? [...new Set(raw.split("\n").filter(Boolean))].map((message) => ({ message }))
|
|
80
|
+
? [...new Set(raw.split("\n").filter(Boolean))].map((message) => ({ message, skip: skipFlags.get(message) ?? false }))
|
|
42
81
|
: []
|
|
43
82
|
|
|
44
83
|
changelog.push({
|
|
@@ -54,10 +93,11 @@ const baseVersion = (v) => v.replace(/[a-z]+$/i, "")
|
|
|
54
93
|
const grouped = []
|
|
55
94
|
for (const entry of changelog) {
|
|
56
95
|
const base = baseVersion(entry.version)
|
|
57
|
-
const existing = grouped.find((g) => g.version === base
|
|
96
|
+
const existing = grouped.find((g) => g.version === base)
|
|
58
97
|
if (existing) {
|
|
59
98
|
existing.commits.push(...entry.commits)
|
|
60
99
|
if (entry.date < existing.date) existing.date = entry.date
|
|
100
|
+
if (entry.tag && (!existing.tag || entry.tag.replace(/^v/, "") === base)) existing.tag = entry.tag
|
|
61
101
|
} else {
|
|
62
102
|
grouped.push({ ...entry, version: base })
|
|
63
103
|
}
|
|
@@ -72,6 +112,59 @@ for (const entry of grouped) {
|
|
|
72
112
|
})
|
|
73
113
|
}
|
|
74
114
|
|
|
115
|
+
for (const entry of grouped) {
|
|
116
|
+
const saved = summaries.get(entry.version)
|
|
117
|
+
if (saved) {
|
|
118
|
+
if (saved.summary_en && !entry.summary_en) entry.summary_en = saved.summary_en
|
|
119
|
+
if (saved.summary_ger && !entry.summary_ger) entry.summary_ger = saved.summary_ger
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (process.argv.includes("--summarize")) {
|
|
124
|
+
const apiKey = loadEnvKey("GITHOOKS_GEMINI_API_KEY")
|
|
125
|
+
if (!apiKey) {
|
|
126
|
+
console.warn("GITHOOKS_GEMINI_API_KEY not set, skipping AI summary")
|
|
127
|
+
} else {
|
|
128
|
+
const target = grouped.find((e) => e.version === baseVersion(tags[0].replace(/^v/, "")))
|
|
129
|
+
if (target && !(target.summary_en && target.summary_ger)) {
|
|
130
|
+
const messages = target.commits.filter((c) => !c.skip).map((c) => c.message)
|
|
131
|
+
if (messages.length === 0) {
|
|
132
|
+
console.warn("no commits to summarize")
|
|
133
|
+
} else {
|
|
134
|
+
const model = loadEnvKey("GITHOOKS_GEMINI_MODEL") ?? "gemini-flash-latest"
|
|
135
|
+
const prompt = `Summarize these commit messages into a short user-facing release summary for version ${target.version}. Write 1-3 plain sentences per language, no markdown, no bullet points, no preamble. Return a JSON object with exactly two string keys: "summary_en" (English) and "summary_ger" (German).\n\n${messages.join("\n")}`
|
|
136
|
+
for (let attempt = 1; attempt <= 2; attempt++) {
|
|
137
|
+
try {
|
|
138
|
+
const res = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent`, {
|
|
139
|
+
method: "POST",
|
|
140
|
+
headers: { "Content-Type": "application/json", "x-goog-api-key": apiKey },
|
|
141
|
+
body: JSON.stringify({
|
|
142
|
+
contents: [{ parts: [{ text: prompt }] }],
|
|
143
|
+
generationConfig: { responseMimeType: "application/json", thinkingConfig: { thinkingBudget: 0 } },
|
|
144
|
+
}),
|
|
145
|
+
signal: AbortSignal.timeout(60000),
|
|
146
|
+
})
|
|
147
|
+
if (!res.ok) throw new Error(`gemini api returned ${res.status}`)
|
|
148
|
+
const data = await res.json()
|
|
149
|
+
const text = data.candidates?.[0]?.content?.parts?.[0]?.text?.trim()
|
|
150
|
+
if (!text) throw new Error("empty summary response")
|
|
151
|
+
const parsed = JSON.parse(text)
|
|
152
|
+
if (typeof parsed.summary_en !== "string" || typeof parsed.summary_ger !== "string") {
|
|
153
|
+
throw new Error("unexpected summary response shape")
|
|
154
|
+
}
|
|
155
|
+
target.summary_en = parsed.summary_en.trim()
|
|
156
|
+
target.summary_ger = parsed.summary_ger.trim()
|
|
157
|
+
console.log(`ai summary generated for ${target.version}`)
|
|
158
|
+
break
|
|
159
|
+
} catch (err) {
|
|
160
|
+
if (attempt === 2) console.warn(`ai summary failed: ${err.message}`)
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
75
168
|
mkdirSync("data", { recursive: true })
|
|
76
169
|
writeFileSync("data/changelog.json", JSON.stringify(grouped, null, 2))
|
|
77
170
|
console.log(`changelog generated: ${grouped.length} versions`)
|