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 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 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.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lechnerio-git-hooks",
3
- "version": "1.3.0",
3
+ "version": "1.3.2",
4
4
  "description": "Shared git hooks for lechnerio projects",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -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
- if (typeof entry.summary === "string") summaries.set(entry.version, entry.summary)
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 && g.tag !== null)
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 && !entry.summary) entry.summary = 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
- if (process.argv.includes("--summarize")) {
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 (!apiKey) {
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 target = grouped.find((e) => e.tag === tags[0])
118
- if (target && !target.summary) {
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
- 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
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
  }