devcommit 0.1.5.5__tar.gz → 0.1.5.6__tar.gz
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.
- {devcommit-0.1.5.5 → devcommit-0.1.5.6}/PKG-INFO +12 -4
- {devcommit-0.1.5.5 → devcommit-0.1.5.6}/README.md +11 -3
- devcommit-0.1.5.6/devcommit/app/changelog.py +378 -0
- {devcommit-0.1.5.5 → devcommit-0.1.5.6}/devcommit/create_config.py +6 -0
- {devcommit-0.1.5.5 → devcommit-0.1.5.6}/pyproject.toml +2 -2
- devcommit-0.1.5.5/devcommit/app/changelog.py +0 -96
- {devcommit-0.1.5.5 → devcommit-0.1.5.6}/COPYING +0 -0
- {devcommit-0.1.5.5 → devcommit-0.1.5.6}/devcommit/__init__.py +0 -0
- {devcommit-0.1.5.5 → devcommit-0.1.5.6}/devcommit/__version__.py +0 -0
- {devcommit-0.1.5.5 → devcommit-0.1.5.6}/devcommit/app/__init__.py +0 -0
- {devcommit-0.1.5.5 → devcommit-0.1.5.6}/devcommit/app/ai_providers.py +0 -0
- {devcommit-0.1.5.5 → devcommit-0.1.5.6}/devcommit/app/gemini_ai.py +0 -0
- {devcommit-0.1.5.5 → devcommit-0.1.5.6}/devcommit/app/prompt.py +0 -0
- {devcommit-0.1.5.5 → devcommit-0.1.5.6}/devcommit/main.py +0 -0
- {devcommit-0.1.5.5 → devcommit-0.1.5.6}/devcommit/utils/__init__.py +0 -0
- {devcommit-0.1.5.5 → devcommit-0.1.5.6}/devcommit/utils/git.py +0 -0
- {devcommit-0.1.5.5 → devcommit-0.1.5.6}/devcommit/utils/logger.py +0 -0
- {devcommit-0.1.5.5 → devcommit-0.1.5.6}/devcommit/utils/parser.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: devcommit
|
|
3
|
-
Version: 0.1.5.
|
|
3
|
+
Version: 0.1.5.6
|
|
4
4
|
Summary: AI-powered git commit message generator
|
|
5
5
|
License: GNU GENERAL PUBLIC LICENSE
|
|
6
6
|
Version 3, 29 June 2007
|
|
@@ -782,6 +782,7 @@ A command-line AI tool for autocommits.
|
|
|
782
782
|
# GEMINI_MODEL / OPENAI_MODEL / GROQ_MODEL / OPENROUTER_MODEL / ANTHROPIC_MODEL / OLLAMA_MODEL / CUSTOM_MODEL
|
|
783
783
|
MODEL_NAME = gemini-2.5-flash
|
|
784
784
|
COMMIT_MODE = auto
|
|
785
|
+
CHANGELOG_MODE = timestamped
|
|
785
786
|
EOF
|
|
786
787
|
```
|
|
787
788
|
|
|
@@ -796,6 +797,7 @@ A command-line AI tool for autocommits.
|
|
|
796
797
|
COMMIT_TYPE = conventional
|
|
797
798
|
MODEL_NAME = gemini-2.0-flash-exp
|
|
798
799
|
COMMIT_MODE = auto
|
|
800
|
+
CHANGELOG_MODE = timestamped
|
|
799
801
|
EOF
|
|
800
802
|
```
|
|
801
803
|
|
|
@@ -1150,8 +1152,11 @@ devcommit --stageAll --changelog --files src/
|
|
|
1150
1152
|
|
|
1151
1153
|
- **With `--stageAll`**: Changelog is generated from unstaged changes **before** staging
|
|
1152
1154
|
- **Without `--stageAll`**: Changelog is generated from the last commit **after** committing
|
|
1153
|
-
-
|
|
1154
|
-
-
|
|
1155
|
+
- Storage mode is controlled by `CHANGELOG_MODE`:
|
|
1156
|
+
- `timestamped` (default): new file per run in `changelogs/<branch>/<year>/<month>/<day>/<time>.md`
|
|
1157
|
+
- `branch`: one file per branch, updated in place at `changelogs/<branch>.md`
|
|
1158
|
+
- Default base directory is `changelogs/` (configurable via `CHANGELOG_DIR` in `.dcommit`)
|
|
1159
|
+
- Empty sections are automatically removed (including empty `## [Unreleased]`)
|
|
1155
1160
|
- Uses Keep a Changelog format with AI-generated content
|
|
1156
1161
|
|
|
1157
1162
|
**Example workflow:**
|
|
@@ -1163,7 +1168,9 @@ devcommit --stageAll --changelog --files src/
|
|
|
1163
1168
|
# Stage all changes and generate changelog before committing
|
|
1164
1169
|
devcommit --stageAll --changelog
|
|
1165
1170
|
|
|
1166
|
-
# The changelog file is created
|
|
1171
|
+
# The changelog file is created based on CHANGELOG_MODE
|
|
1172
|
+
# timestamped: changelogs/<branch>/<year>/<month>/<day>/<time>.md
|
|
1173
|
+
# branch: changelogs/<branch>.md
|
|
1167
1174
|
# Then changes are staged and committed
|
|
1168
1175
|
```
|
|
1169
1176
|
|
|
@@ -1359,6 +1366,7 @@ All configuration can be set via **environment variables** or **`.dcommit` file*
|
|
|
1359
1366
|
| `EXCLUDE_FILES` | Files to exclude from diff | `package-lock.json, pnpm-lock.yaml, yarn.lock, *.lock` | Comma-separated file patterns |
|
|
1360
1367
|
| `MAX_TOKENS` | Maximum tokens for AI response | `8192` | Any positive integer |
|
|
1361
1368
|
| `CHANGELOG_DIR` | Directory for changelog files | `changelogs` | Any directory path |
|
|
1369
|
+
| `CHANGELOG_MODE` | Changelog file strategy | `timestamped` | `timestamped`, `branch` |
|
|
1362
1370
|
|
|
1363
1371
|
### Configuration Priority
|
|
1364
1372
|
|
|
@@ -77,6 +77,7 @@ A command-line AI tool for autocommits.
|
|
|
77
77
|
# GEMINI_MODEL / OPENAI_MODEL / GROQ_MODEL / OPENROUTER_MODEL / ANTHROPIC_MODEL / OLLAMA_MODEL / CUSTOM_MODEL
|
|
78
78
|
MODEL_NAME = gemini-2.5-flash
|
|
79
79
|
COMMIT_MODE = auto
|
|
80
|
+
CHANGELOG_MODE = timestamped
|
|
80
81
|
EOF
|
|
81
82
|
```
|
|
82
83
|
|
|
@@ -91,6 +92,7 @@ A command-line AI tool for autocommits.
|
|
|
91
92
|
COMMIT_TYPE = conventional
|
|
92
93
|
MODEL_NAME = gemini-2.0-flash-exp
|
|
93
94
|
COMMIT_MODE = auto
|
|
95
|
+
CHANGELOG_MODE = timestamped
|
|
94
96
|
EOF
|
|
95
97
|
```
|
|
96
98
|
|
|
@@ -445,8 +447,11 @@ devcommit --stageAll --changelog --files src/
|
|
|
445
447
|
|
|
446
448
|
- **With `--stageAll`**: Changelog is generated from unstaged changes **before** staging
|
|
447
449
|
- **Without `--stageAll`**: Changelog is generated from the last commit **after** committing
|
|
448
|
-
-
|
|
449
|
-
-
|
|
450
|
+
- Storage mode is controlled by `CHANGELOG_MODE`:
|
|
451
|
+
- `timestamped` (default): new file per run in `changelogs/<branch>/<year>/<month>/<day>/<time>.md`
|
|
452
|
+
- `branch`: one file per branch, updated in place at `changelogs/<branch>.md`
|
|
453
|
+
- Default base directory is `changelogs/` (configurable via `CHANGELOG_DIR` in `.dcommit`)
|
|
454
|
+
- Empty sections are automatically removed (including empty `## [Unreleased]`)
|
|
450
455
|
- Uses Keep a Changelog format with AI-generated content
|
|
451
456
|
|
|
452
457
|
**Example workflow:**
|
|
@@ -458,7 +463,9 @@ devcommit --stageAll --changelog --files src/
|
|
|
458
463
|
# Stage all changes and generate changelog before committing
|
|
459
464
|
devcommit --stageAll --changelog
|
|
460
465
|
|
|
461
|
-
# The changelog file is created
|
|
466
|
+
# The changelog file is created based on CHANGELOG_MODE
|
|
467
|
+
# timestamped: changelogs/<branch>/<year>/<month>/<day>/<time>.md
|
|
468
|
+
# branch: changelogs/<branch>.md
|
|
462
469
|
# Then changes are staged and committed
|
|
463
470
|
```
|
|
464
471
|
|
|
@@ -654,6 +661,7 @@ All configuration can be set via **environment variables** or **`.dcommit` file*
|
|
|
654
661
|
| `EXCLUDE_FILES` | Files to exclude from diff | `package-lock.json, pnpm-lock.yaml, yarn.lock, *.lock` | Comma-separated file patterns |
|
|
655
662
|
| `MAX_TOKENS` | Maximum tokens for AI response | `8192` | Any positive integer |
|
|
656
663
|
| `CHANGELOG_DIR` | Directory for changelog files | `changelogs` | Any directory path |
|
|
664
|
+
| `CHANGELOG_MODE` | Changelog file strategy | `timestamped` | `timestamped`, `branch` |
|
|
657
665
|
|
|
658
666
|
### Configuration Priority
|
|
659
667
|
|
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
"""Generate changelog files from git diffs using AI"""
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from typing import List, Tuple
|
|
8
|
+
|
|
9
|
+
from devcommit.app.ai_providers import get_ai_provider
|
|
10
|
+
from devcommit.utils.git import get_current_branch
|
|
11
|
+
from devcommit.utils.logger import config
|
|
12
|
+
|
|
13
|
+
PLACEHOLDER_LINES = {
|
|
14
|
+
"list new features",
|
|
15
|
+
"list changes to existing functionality",
|
|
16
|
+
"list bug fixes",
|
|
17
|
+
"list removed features",
|
|
18
|
+
"none",
|
|
19
|
+
"n/a",
|
|
20
|
+
"na",
|
|
21
|
+
"no changes",
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
DEFAULT_CHANGELOG_MODE = "timestamped"
|
|
25
|
+
BRANCH_CHANGELOG_MODES = {"branch", "branch_single", "single", "per_branch"}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def generate_changelog_prompt() -> str:
|
|
29
|
+
"""Generate the prompt for changelog creation"""
|
|
30
|
+
return """You are a changelog generator. Analyze the git diff and create a structured changelog in Keep a Changelog format.
|
|
31
|
+
|
|
32
|
+
Follow these guidelines:
|
|
33
|
+
1. Use markdown format with clear sections
|
|
34
|
+
2. Categorize changes into: Added, Changed, Fixed, Removed, Deprecated, Security
|
|
35
|
+
3. Write clear, user-friendly descriptions (not implementation details)
|
|
36
|
+
4. Group related changes together
|
|
37
|
+
5. Focus on what changed from a user/developer perspective
|
|
38
|
+
6. Be concise but informative
|
|
39
|
+
7. Include only sections that have real content
|
|
40
|
+
8. Use ## [Unreleased] only when it contains at least one non-empty subsection
|
|
41
|
+
|
|
42
|
+
Format:
|
|
43
|
+
# Changelog
|
|
44
|
+
|
|
45
|
+
## [Unreleased] (optional)
|
|
46
|
+
|
|
47
|
+
### Added (optional)
|
|
48
|
+
- Describe added features (only if any)
|
|
49
|
+
|
|
50
|
+
### Changed (optional)
|
|
51
|
+
- Describe changed behavior (only if any)
|
|
52
|
+
|
|
53
|
+
Do not include empty section headings or placeholders."""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _strip_fences(content: str) -> str:
|
|
57
|
+
lines = content.strip().splitlines()
|
|
58
|
+
if len(lines) >= 2 and lines[0].strip().startswith("```"):
|
|
59
|
+
if lines[-1].strip() == "```":
|
|
60
|
+
return "\n".join(lines[1:-1]).strip()
|
|
61
|
+
return content.strip()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _split_by_heading(
|
|
65
|
+
lines: List[str], level: int
|
|
66
|
+
) -> Tuple[List[str], List[Tuple[str, List[str]]]]:
|
|
67
|
+
"""Split content by markdown heading level."""
|
|
68
|
+
heading_prefix = "#" * level + " "
|
|
69
|
+
preamble: List[str] = []
|
|
70
|
+
sections: List[Tuple[str, List[str]]] = []
|
|
71
|
+
current_heading: str | None = None
|
|
72
|
+
current_body: List[str] = []
|
|
73
|
+
|
|
74
|
+
for line in lines:
|
|
75
|
+
if line.startswith(heading_prefix):
|
|
76
|
+
if current_heading is not None:
|
|
77
|
+
sections.append((current_heading.strip(), current_body))
|
|
78
|
+
current_heading = line
|
|
79
|
+
current_body = []
|
|
80
|
+
continue
|
|
81
|
+
|
|
82
|
+
if current_heading is None:
|
|
83
|
+
preamble.append(line)
|
|
84
|
+
else:
|
|
85
|
+
current_body.append(line)
|
|
86
|
+
|
|
87
|
+
if current_heading is not None:
|
|
88
|
+
sections.append((current_heading.strip(), current_body))
|
|
89
|
+
|
|
90
|
+
return preamble, sections
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _strip_blank_edges(lines: List[str]) -> List[str]:
|
|
94
|
+
start = 0
|
|
95
|
+
end = len(lines)
|
|
96
|
+
|
|
97
|
+
while start < end and not lines[start].strip():
|
|
98
|
+
start += 1
|
|
99
|
+
while end > start and not lines[end - 1].strip():
|
|
100
|
+
end -= 1
|
|
101
|
+
|
|
102
|
+
return lines[start:end]
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _is_placeholder_line(line: str) -> bool:
|
|
106
|
+
stripped = line.strip()
|
|
107
|
+
if not stripped:
|
|
108
|
+
return True
|
|
109
|
+
if stripped in {"-", "*", "+"}:
|
|
110
|
+
return True
|
|
111
|
+
|
|
112
|
+
candidate = stripped
|
|
113
|
+
for marker in ("- ", "* ", "+ "):
|
|
114
|
+
if candidate.startswith(marker):
|
|
115
|
+
candidate = candidate[len(marker) :].strip()
|
|
116
|
+
break
|
|
117
|
+
|
|
118
|
+
candidate_lower = candidate.lower().rstrip(".")
|
|
119
|
+
if candidate_lower in PLACEHOLDER_LINES:
|
|
120
|
+
return True
|
|
121
|
+
|
|
122
|
+
if re.fullmatch(r"(none|no changes|n/?a)\.?$", candidate_lower):
|
|
123
|
+
return True
|
|
124
|
+
|
|
125
|
+
return False
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _has_informative_content(lines: List[str]) -> bool:
|
|
129
|
+
for line in lines:
|
|
130
|
+
stripped = line.strip()
|
|
131
|
+
if not stripped:
|
|
132
|
+
continue
|
|
133
|
+
if stripped.startswith("#"):
|
|
134
|
+
continue
|
|
135
|
+
if _is_placeholder_line(stripped):
|
|
136
|
+
continue
|
|
137
|
+
return True
|
|
138
|
+
return False
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _clean_second_level_body(lines: List[str]) -> List[str]:
|
|
142
|
+
intro, subsections = _split_by_heading(lines, 3)
|
|
143
|
+
cleaned: List[str] = []
|
|
144
|
+
kept_subsections: List[Tuple[str, List[str]]] = []
|
|
145
|
+
|
|
146
|
+
for heading, body in subsections:
|
|
147
|
+
body = _strip_blank_edges(body)
|
|
148
|
+
if _has_informative_content(body):
|
|
149
|
+
kept_subsections.append((heading, body))
|
|
150
|
+
|
|
151
|
+
intro = _strip_blank_edges(intro)
|
|
152
|
+
keep_intro = _has_informative_content(intro)
|
|
153
|
+
if not keep_intro and not kept_subsections:
|
|
154
|
+
return []
|
|
155
|
+
|
|
156
|
+
if keep_intro:
|
|
157
|
+
cleaned.extend(intro)
|
|
158
|
+
|
|
159
|
+
for index, (heading, body) in enumerate(kept_subsections):
|
|
160
|
+
if cleaned:
|
|
161
|
+
cleaned.append("")
|
|
162
|
+
cleaned.append(heading)
|
|
163
|
+
cleaned.extend(body)
|
|
164
|
+
if index < len(kept_subsections) - 1:
|
|
165
|
+
cleaned.append("")
|
|
166
|
+
|
|
167
|
+
return cleaned
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def normalize_changelog_content(content: str) -> str:
|
|
171
|
+
"""Normalize AI output and remove empty sections/placeholders."""
|
|
172
|
+
content = _strip_fences(content)
|
|
173
|
+
raw_lines = _strip_blank_edges(content.splitlines())
|
|
174
|
+
if not raw_lines:
|
|
175
|
+
return "# Changelog"
|
|
176
|
+
|
|
177
|
+
title = "# Changelog"
|
|
178
|
+
body_lines = raw_lines
|
|
179
|
+
if raw_lines[0].startswith("# "):
|
|
180
|
+
title = raw_lines[0].strip()
|
|
181
|
+
body_lines = _strip_blank_edges(raw_lines[1:])
|
|
182
|
+
|
|
183
|
+
_, sections = _split_by_heading(body_lines, 2)
|
|
184
|
+
rebuilt_lines: List[str] = [title]
|
|
185
|
+
|
|
186
|
+
if sections:
|
|
187
|
+
kept_sections: List[Tuple[str, List[str]]] = []
|
|
188
|
+
for heading, body in sections:
|
|
189
|
+
cleaned_body = _clean_second_level_body(body)
|
|
190
|
+
if cleaned_body:
|
|
191
|
+
kept_sections.append((heading, cleaned_body))
|
|
192
|
+
|
|
193
|
+
if kept_sections:
|
|
194
|
+
rebuilt_lines.append("")
|
|
195
|
+
for index, (heading, body) in enumerate(kept_sections):
|
|
196
|
+
rebuilt_lines.append(heading)
|
|
197
|
+
rebuilt_lines.extend(body)
|
|
198
|
+
if index < len(kept_sections) - 1:
|
|
199
|
+
rebuilt_lines.append("")
|
|
200
|
+
else:
|
|
201
|
+
body_lines = _strip_blank_edges(body_lines)
|
|
202
|
+
if _has_informative_content(body_lines):
|
|
203
|
+
rebuilt_lines.append("")
|
|
204
|
+
rebuilt_lines.extend(body_lines)
|
|
205
|
+
|
|
206
|
+
return "\n".join(_strip_blank_edges(rebuilt_lines)) + "\n"
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _to_section_name(heading: str) -> str:
|
|
210
|
+
return heading.removeprefix("###").strip()
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _extract_category_blocks(content: str) -> List[Tuple[str, List[str]]]:
|
|
214
|
+
"""Extract non-empty category blocks from normalized changelog content."""
|
|
215
|
+
lines = _strip_blank_edges(content.splitlines())
|
|
216
|
+
if lines and lines[0].startswith("# "):
|
|
217
|
+
lines = _strip_blank_edges(lines[1:])
|
|
218
|
+
|
|
219
|
+
_, sections = _split_by_heading(lines, 2)
|
|
220
|
+
category_items: List[Tuple[str, List[str]]] = []
|
|
221
|
+
|
|
222
|
+
for _, section_body in sections:
|
|
223
|
+
section_intro, subsections = _split_by_heading(section_body, 3)
|
|
224
|
+
if subsections:
|
|
225
|
+
for heading, body in subsections:
|
|
226
|
+
body = _strip_blank_edges(body)
|
|
227
|
+
if _has_informative_content(body):
|
|
228
|
+
category_items.append((_to_section_name(heading), body))
|
|
229
|
+
continue
|
|
230
|
+
|
|
231
|
+
section_intro = _strip_blank_edges(section_intro)
|
|
232
|
+
if _has_informative_content(section_intro):
|
|
233
|
+
category_items.append(("Changed", section_intro))
|
|
234
|
+
|
|
235
|
+
if category_items:
|
|
236
|
+
preferred_order = [
|
|
237
|
+
"Added",
|
|
238
|
+
"Changed",
|
|
239
|
+
"Fixed",
|
|
240
|
+
"Removed",
|
|
241
|
+
"Deprecated",
|
|
242
|
+
"Security",
|
|
243
|
+
]
|
|
244
|
+
ordered: List[Tuple[str, List[str]]] = []
|
|
245
|
+
used_indices: set[int] = set()
|
|
246
|
+
|
|
247
|
+
for name in preferred_order:
|
|
248
|
+
for idx, item in enumerate(category_items):
|
|
249
|
+
if idx in used_indices:
|
|
250
|
+
continue
|
|
251
|
+
category_name, _ = item
|
|
252
|
+
if category_name.lower() == name.lower():
|
|
253
|
+
ordered.append(item)
|
|
254
|
+
used_indices.add(idx)
|
|
255
|
+
|
|
256
|
+
for idx, item in enumerate(category_items):
|
|
257
|
+
if idx not in used_indices:
|
|
258
|
+
ordered.append(item)
|
|
259
|
+
|
|
260
|
+
return ordered
|
|
261
|
+
|
|
262
|
+
return category_items
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _strip_top_heading(content: str) -> str:
|
|
266
|
+
lines = _strip_blank_edges(content.splitlines())
|
|
267
|
+
if lines and lines[0].startswith("# "):
|
|
268
|
+
return "\n".join(_strip_blank_edges(lines[1:]))
|
|
269
|
+
return "\n".join(lines)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _save_timestamped_changelog(
|
|
273
|
+
content: str, directory: str, branch_name: str, now: datetime
|
|
274
|
+
) -> str:
|
|
275
|
+
year_directory = now.strftime("%Y")
|
|
276
|
+
month_directory = now.strftime("%m")
|
|
277
|
+
day_directory = now.strftime("%d")
|
|
278
|
+
target_directory = os.path.join(
|
|
279
|
+
directory, branch_name, year_directory, month_directory, day_directory
|
|
280
|
+
)
|
|
281
|
+
os.makedirs(target_directory, exist_ok=True)
|
|
282
|
+
|
|
283
|
+
filename = now.strftime("%H-%M-%S.md")
|
|
284
|
+
filepath = os.path.join(target_directory, filename)
|
|
285
|
+
|
|
286
|
+
with open(filepath, "w", encoding="utf-8") as file:
|
|
287
|
+
file.write(content)
|
|
288
|
+
|
|
289
|
+
return filepath
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _save_branch_changelog(
|
|
293
|
+
content: str, directory: str, branch_name: str, now: datetime
|
|
294
|
+
) -> str:
|
|
295
|
+
os.makedirs(directory, exist_ok=True)
|
|
296
|
+
filepath = os.path.join(directory, f"{branch_name}.md")
|
|
297
|
+
|
|
298
|
+
timestamp = now.strftime("%Y-%m-%d %H:%M:%S")
|
|
299
|
+
categories = _extract_category_blocks(content)
|
|
300
|
+
entry_lines: List[str] = [f"## [{timestamp}]"]
|
|
301
|
+
|
|
302
|
+
if categories:
|
|
303
|
+
for section_name, section_lines in categories:
|
|
304
|
+
entry_lines.append("")
|
|
305
|
+
entry_lines.append(f"### {section_name}")
|
|
306
|
+
entry_lines.extend(section_lines)
|
|
307
|
+
else:
|
|
308
|
+
content_body = _strip_top_heading(content)
|
|
309
|
+
if content_body.strip():
|
|
310
|
+
entry_lines.append("")
|
|
311
|
+
entry_lines.extend(content_body.splitlines())
|
|
312
|
+
|
|
313
|
+
new_entry = "\n".join(_strip_blank_edges(entry_lines)).strip()
|
|
314
|
+
|
|
315
|
+
previous_body = ""
|
|
316
|
+
if os.path.exists(filepath):
|
|
317
|
+
with open(filepath, "r", encoding="utf-8") as file:
|
|
318
|
+
previous_body = _strip_top_heading(file.read()).strip()
|
|
319
|
+
|
|
320
|
+
output_lines = ["# Changelog", "", new_entry]
|
|
321
|
+
if previous_body:
|
|
322
|
+
output_lines.extend(["", previous_body])
|
|
323
|
+
|
|
324
|
+
with open(filepath, "w", encoding="utf-8") as file:
|
|
325
|
+
file.write("\n".join(_strip_blank_edges(output_lines)) + "\n")
|
|
326
|
+
|
|
327
|
+
return filepath
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def generate_changelog(diff: str) -> str:
|
|
331
|
+
"""Generate changelog content from git diff using AI.
|
|
332
|
+
|
|
333
|
+
Args:
|
|
334
|
+
diff: Git diff string
|
|
335
|
+
|
|
336
|
+
Returns:
|
|
337
|
+
Formatted markdown changelog content
|
|
338
|
+
"""
|
|
339
|
+
prompt = generate_changelog_prompt()
|
|
340
|
+
|
|
341
|
+
provider = get_ai_provider(config)
|
|
342
|
+
|
|
343
|
+
max_tokens = config("MAX_TOKENS", default=8192, cast=int)
|
|
344
|
+
changelog_content = provider.generate_commit_message(
|
|
345
|
+
diff, prompt, max_tokens
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
return normalize_changelog_content(changelog_content)
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def save_changelog(content: str, directory: str = None) -> str:
|
|
352
|
+
"""Save changelog content to a file.
|
|
353
|
+
|
|
354
|
+
Args:
|
|
355
|
+
content: Changelog markdown content
|
|
356
|
+
directory: Directory to save changelog (default from config)
|
|
357
|
+
|
|
358
|
+
Returns:
|
|
359
|
+
Path to the saved changelog file
|
|
360
|
+
"""
|
|
361
|
+
if directory is None:
|
|
362
|
+
directory = config("CHANGELOG_DIR", default="changelogs")
|
|
363
|
+
|
|
364
|
+
changelog_mode = (
|
|
365
|
+
config("CHANGELOG_MODE", default=DEFAULT_CHANGELOG_MODE).strip().lower()
|
|
366
|
+
)
|
|
367
|
+
normalized_content = normalize_changelog_content(content)
|
|
368
|
+
|
|
369
|
+
now = datetime.now()
|
|
370
|
+
branch_name = get_current_branch().replace("/", "-")
|
|
371
|
+
if changelog_mode in BRANCH_CHANGELOG_MODES:
|
|
372
|
+
return _save_branch_changelog(
|
|
373
|
+
normalized_content, directory, branch_name, now
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
return _save_timestamped_changelog(
|
|
377
|
+
normalized_content, directory, branch_name, now
|
|
378
|
+
)
|
|
@@ -85,6 +85,12 @@ EXCLUDE_FILES = *.lock, dist/*, build/*, node_modules/*
|
|
|
85
85
|
# Directory for changelog files (default: changelogs)
|
|
86
86
|
# Used when --changelog flag is passed
|
|
87
87
|
CHANGELOG_DIR = changelogs
|
|
88
|
+
|
|
89
|
+
# Changelog storage mode (default: timestamped)
|
|
90
|
+
# Options:
|
|
91
|
+
# - timestamped: Create a new changelog file per run (existing behavior)
|
|
92
|
+
# - branch: Keep one changelog file per branch and prepend new entries
|
|
93
|
+
CHANGELOG_MODE = timestamped
|
|
88
94
|
"""
|
|
89
95
|
|
|
90
96
|
if "VIRTUAL_ENV" in os.environ:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "devcommit"
|
|
3
|
-
version = "0.1.5.
|
|
3
|
+
version = "0.1.5.6"
|
|
4
4
|
description = "AI-powered git commit message generator"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = {file = "COPYING"}
|
|
@@ -44,7 +44,7 @@ create-dcommit = "devcommit.create_config:create_dcommit"
|
|
|
44
44
|
|
|
45
45
|
[tool.poetry]
|
|
46
46
|
name = "devcommit"
|
|
47
|
-
version = "0.1.5.
|
|
47
|
+
version = "0.1.5.6"
|
|
48
48
|
description = "AI-powered git commit message generator"
|
|
49
49
|
authors = ["Hordunlarmy <Hordunlarmy@gmail.com>"]
|
|
50
50
|
|
|
@@ -1,96 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python
|
|
2
|
-
"""Generate changelog files from git diffs using AI"""
|
|
3
|
-
|
|
4
|
-
import os
|
|
5
|
-
from datetime import datetime
|
|
6
|
-
|
|
7
|
-
from devcommit.app.ai_providers import get_ai_provider
|
|
8
|
-
from devcommit.utils.git import get_current_branch
|
|
9
|
-
from devcommit.utils.logger import config
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
def generate_changelog_prompt() -> str:
|
|
13
|
-
"""Generate the prompt for changelog creation"""
|
|
14
|
-
return """You are a changelog generator. Analyze the git diff and create a structured changelog in Keep a Changelog format.
|
|
15
|
-
|
|
16
|
-
Follow these guidelines:
|
|
17
|
-
1. Use markdown format with clear sections
|
|
18
|
-
2. Categorize changes into: Added, Changed, Fixed, Removed, Deprecated, Security
|
|
19
|
-
3. Write clear, user-friendly descriptions (not implementation details)
|
|
20
|
-
4. Group related changes together
|
|
21
|
-
5. Focus on what changed from a user/developer perspective
|
|
22
|
-
6. Be concise but informative
|
|
23
|
-
|
|
24
|
-
Format:
|
|
25
|
-
# Changelog
|
|
26
|
-
|
|
27
|
-
## [Unreleased]
|
|
28
|
-
|
|
29
|
-
### Added
|
|
30
|
-
- List new features
|
|
31
|
-
|
|
32
|
-
### Changed
|
|
33
|
-
- List changes to existing functionality
|
|
34
|
-
|
|
35
|
-
### Fixed
|
|
36
|
-
- List bug fixes
|
|
37
|
-
|
|
38
|
-
### Removed
|
|
39
|
-
- List removed features
|
|
40
|
-
|
|
41
|
-
Only include sections that have changes. Do not add empty sections."""
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
def generate_changelog(diff: str) -> str:
|
|
45
|
-
"""Generate changelog content from git diff using AI.
|
|
46
|
-
|
|
47
|
-
Args:
|
|
48
|
-
diff: Git diff string
|
|
49
|
-
|
|
50
|
-
Returns:
|
|
51
|
-
Formatted markdown changelog content
|
|
52
|
-
"""
|
|
53
|
-
prompt = generate_changelog_prompt()
|
|
54
|
-
|
|
55
|
-
provider = get_ai_provider(config)
|
|
56
|
-
|
|
57
|
-
max_tokens = config("MAX_TOKENS", default=8192, cast=int)
|
|
58
|
-
changelog_content = provider.generate_commit_message(
|
|
59
|
-
diff, prompt, max_tokens
|
|
60
|
-
)
|
|
61
|
-
|
|
62
|
-
return changelog_content
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
def save_changelog(content: str, directory: str = None) -> str:
|
|
66
|
-
"""Save changelog content to a file.
|
|
67
|
-
|
|
68
|
-
Args:
|
|
69
|
-
content: Changelog markdown content
|
|
70
|
-
directory: Directory to save changelog (default from config)
|
|
71
|
-
|
|
72
|
-
Returns:
|
|
73
|
-
Path to the saved changelog file
|
|
74
|
-
"""
|
|
75
|
-
if directory is None:
|
|
76
|
-
directory = config("CHANGELOG_DIR", default="changelogs")
|
|
77
|
-
|
|
78
|
-
now = datetime.now()
|
|
79
|
-
branch_name = get_current_branch().replace("/", "-")
|
|
80
|
-
year_directory = now.strftime("%Y")
|
|
81
|
-
month_directory = now.strftime("%m")
|
|
82
|
-
day_directory = now.strftime("%d")
|
|
83
|
-
target_directory = os.path.join(
|
|
84
|
-
directory, branch_name, year_directory, month_directory, day_directory
|
|
85
|
-
)
|
|
86
|
-
|
|
87
|
-
# Create the branch/year/month/day directory structure if it doesn't exist
|
|
88
|
-
os.makedirs(target_directory, exist_ok=True)
|
|
89
|
-
|
|
90
|
-
filename = now.strftime("%H-%M-%S.md")
|
|
91
|
-
filepath = os.path.join(target_directory, filename)
|
|
92
|
-
|
|
93
|
-
with open(filepath, "w", encoding="utf-8") as f:
|
|
94
|
-
f.write(content)
|
|
95
|
-
|
|
96
|
-
return filepath
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|