lechnerio-git-hooks 1.3.0 → 1.3.2
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 +5 -1
- package/package.json +1 -1
- package/scripts/generate-changelog.mjs +65 -40
package/README.md
CHANGED
|
@@ -34,9 +34,13 @@ On install, the package automatically:
|
|
|
34
34
|
| `commit-msg` | Enforces conventional commits via commitlint |
|
|
35
35
|
| `post-commit` | Interactive version bump (subtle/patch/minor/major), changelog generation with optional skip flag, push/merge prompt |
|
|
36
36
|
|
|
37
|
+
## Changelog versioning model
|
|
38
|
+
|
|
39
|
+
Commits belong to the version that was current while they were made. A Minor or Major bump closes the running version — all work since the previous bump stays under it, including the commit that triggered the bump — and opens the new version with a `Version Upgrade to <version>` marker entry. Future commits then accumulate under the new version.
|
|
40
|
+
|
|
37
41
|
## AI release summaries
|
|
38
42
|
|
|
39
|
-
On Minor and Major version bumps, the `post-commit` hook sends the
|
|
43
|
+
On Minor and Major version bumps, the `post-commit` hook sends the non-skipped commit messages of the version being closed to the Gemini API and stores the result as `summary_en` and `summary_ger` fields (English and German) on that 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. Run `node scripts/generate-changelog.mjs --summarize-version <version>` to generate the summary for any version by hand (accepts the version with or without the `v` prefix, subtle suffixes are normalized). Summaries are only generated when `summary_en` or `summary_ger` is missing on the entry — to regenerate one, delete both fields from `data/changelog.json` first.
|
|
40
44
|
|
|
41
45
|
## Peer dependencies (auto-installed)
|
|
42
46
|
|
package/package.json
CHANGED
|
@@ -36,7 +36,14 @@ if (existsSync("data/changelog.json")) {
|
|
|
36
36
|
try {
|
|
37
37
|
const existing = JSON.parse(readFileSync("data/changelog.json", "utf-8"))
|
|
38
38
|
for (const entry of existing) {
|
|
39
|
-
|
|
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
|
+
}
|
|
40
47
|
for (const commit of entry.commits ?? []) {
|
|
41
48
|
skipFlags.set(commit.message, commit.skip === true)
|
|
42
49
|
}
|
|
@@ -51,23 +58,13 @@ if (process.argv.includes("--skip-last")) {
|
|
|
51
58
|
|
|
52
59
|
const changelog = []
|
|
53
60
|
|
|
54
|
-
const currentVersion = tags[0].replace(/^v/, "")
|
|
55
|
-
const unreleasedRaw = run(`git log --format="%s" ${tags[0]}..HEAD`)
|
|
56
|
-
if (unreleasedRaw) {
|
|
57
|
-
const commits = [...new Set(unreleasedRaw.split("\n").filter(Boolean))].map((message) => ({ message, skip: skipFlags.get(message) ?? false }))
|
|
58
|
-
if (commits.length > 0) {
|
|
59
|
-
const date = run(`git log --format=%ai HEAD -1`).split(" ")[0]
|
|
60
|
-
changelog.push({ version: currentVersion, tag: null, date, commits })
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
|
|
64
61
|
for (let i = 0; i < tags.length; i++) {
|
|
65
62
|
const tag = tags[i]
|
|
63
|
+
const upper = i === 0 ? "HEAD" : tags[i - 1]
|
|
64
|
+
const eraRaw = run(`git log --format="%s" ${tag}..${upper}`)
|
|
65
|
+
const initialRaw = i === tags.length - 1 ? run(`git log --format="%s" ${tag}`) : ""
|
|
66
|
+
const raw = [eraRaw, initialRaw].filter(Boolean).join("\n")
|
|
66
67
|
const date = run(`git log --format=%ai ${tag} -1`).split(" ")[0]
|
|
67
|
-
const prevTag = tags[i + 1]
|
|
68
|
-
|
|
69
|
-
const range = prevTag ? `${prevTag}..${tag}` : tag
|
|
70
|
-
const raw = run(`git log --format="%s" ${range}`)
|
|
71
68
|
|
|
72
69
|
const commits = raw
|
|
73
70
|
? [...new Set(raw.split("\n").filter(Boolean))].map((message) => ({ message, skip: skipFlags.get(message) ?? false }))
|
|
@@ -86,10 +83,11 @@ const baseVersion = (v) => v.replace(/[a-z]+$/i, "")
|
|
|
86
83
|
const grouped = []
|
|
87
84
|
for (const entry of changelog) {
|
|
88
85
|
const base = baseVersion(entry.version)
|
|
89
|
-
const existing = grouped.find((g) => g.version === base
|
|
86
|
+
const existing = grouped.find((g) => g.version === base)
|
|
90
87
|
if (existing) {
|
|
91
88
|
existing.commits.push(...entry.commits)
|
|
92
89
|
if (entry.date < existing.date) existing.date = entry.date
|
|
90
|
+
if (entry.tag && (!existing.tag || entry.tag.replace(/^v/, "") === base)) existing.tag = entry.tag
|
|
93
91
|
} else {
|
|
94
92
|
grouped.push({ ...entry, version: base })
|
|
95
93
|
}
|
|
@@ -104,43 +102,70 @@ for (const entry of grouped) {
|
|
|
104
102
|
})
|
|
105
103
|
}
|
|
106
104
|
|
|
105
|
+
for (let i = 0; i < grouped.length - 1; i++) {
|
|
106
|
+
const entry = grouped[i]
|
|
107
|
+
const marker = `Version Upgrade to ${entry.version}`
|
|
108
|
+
if (!entry.commits.some((c) => c.message === marker)) {
|
|
109
|
+
entry.commits.push({ message: marker, skip: skipFlags.get(marker) ?? false })
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
107
113
|
for (const entry of grouped) {
|
|
108
114
|
const saved = summaries.get(entry.version)
|
|
109
|
-
if (saved
|
|
115
|
+
if (saved) {
|
|
116
|
+
if (saved.summary_en && !entry.summary_en) entry.summary_en = saved.summary_en
|
|
117
|
+
if (saved.summary_ger && !entry.summary_ger) entry.summary_ger = saved.summary_ger
|
|
118
|
+
}
|
|
110
119
|
}
|
|
111
120
|
|
|
112
|
-
|
|
121
|
+
const svIndex = process.argv.indexOf("--summarize-version")
|
|
122
|
+
const requestedVersion = svIndex === -1 ? null : process.argv[svIndex + 1] ?? null
|
|
123
|
+
|
|
124
|
+
if (process.argv.includes("--summarize") || svIndex !== -1) {
|
|
113
125
|
const apiKey = loadEnvKey("GITHOOKS_GEMINI_API_KEY")
|
|
114
|
-
if (!
|
|
126
|
+
if (svIndex !== -1 && !requestedVersion) {
|
|
127
|
+
console.warn("--summarize-version requires a version argument")
|
|
128
|
+
} else if (!apiKey) {
|
|
115
129
|
console.warn("GITHOOKS_GEMINI_API_KEY not set, skipping AI summary")
|
|
116
130
|
} else {
|
|
117
|
-
const
|
|
118
|
-
|
|
131
|
+
const closedTag = tags.length > 1 ? tags[1] : tags[0]
|
|
132
|
+
const targetVersion = requestedVersion ? baseVersion(requestedVersion.replace(/^v/, "")) : baseVersion(closedTag.replace(/^v/, ""))
|
|
133
|
+
const target = grouped.find((e) => e.version === targetVersion)
|
|
134
|
+
if (!target) {
|
|
135
|
+
console.warn(`version ${targetVersion} not found in changelog`)
|
|
136
|
+
} else if (!(target.summary_en && target.summary_ger)) {
|
|
119
137
|
const messages = target.commits.filter((c) => !c.skip).map((c) => c.message)
|
|
120
138
|
if (messages.length === 0) {
|
|
121
139
|
console.warn("no commits to summarize")
|
|
122
140
|
} else {
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
141
|
+
const model = loadEnvKey("GITHOOKS_GEMINI_MODEL") ?? "gemini-flash-latest"
|
|
142
|
+
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")}`
|
|
143
|
+
for (let attempt = 1; attempt <= 2; attempt++) {
|
|
144
|
+
try {
|
|
145
|
+
const res = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent`, {
|
|
146
|
+
method: "POST",
|
|
147
|
+
headers: { "Content-Type": "application/json", "x-goog-api-key": apiKey },
|
|
148
|
+
body: JSON.stringify({
|
|
149
|
+
contents: [{ parts: [{ text: prompt }] }],
|
|
150
|
+
generationConfig: { responseMimeType: "application/json", thinkingConfig: { thinkingBudget: 0 } },
|
|
151
|
+
}),
|
|
152
|
+
signal: AbortSignal.timeout(60000),
|
|
153
|
+
})
|
|
154
|
+
if (!res.ok) throw new Error(`gemini api returned ${res.status}`)
|
|
155
|
+
const data = await res.json()
|
|
156
|
+
const text = data.candidates?.[0]?.content?.parts?.[0]?.text?.trim()
|
|
157
|
+
if (!text) throw new Error("empty summary response")
|
|
158
|
+
const parsed = JSON.parse(text)
|
|
159
|
+
if (typeof parsed.summary_en !== "string" || typeof parsed.summary_ger !== "string") {
|
|
160
|
+
throw new Error("unexpected summary response shape")
|
|
161
|
+
}
|
|
162
|
+
target.summary_en = parsed.summary_en.trim()
|
|
163
|
+
target.summary_ger = parsed.summary_ger.trim()
|
|
140
164
|
console.log(`ai summary generated for ${target.version}`)
|
|
165
|
+
break
|
|
166
|
+
} catch (err) {
|
|
167
|
+
if (attempt === 2) console.warn(`ai summary failed: ${err.message}`)
|
|
141
168
|
}
|
|
142
|
-
} catch (err) {
|
|
143
|
-
console.warn(`ai summary failed: ${err.message}`)
|
|
144
169
|
}
|
|
145
170
|
}
|
|
146
171
|
}
|