devgen-cli 0.2.4__py3-none-any.whl → 0.2.5__py3-none-any.whl
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.
- devgen/ai.py +11 -2
- devgen/cli/commit.py +19 -1
- devgen/cli/config.py +28 -2
- devgen/cli/main.py +11 -3
- devgen/cli/setup.py +20 -1
- devgen/modules/changelog_generator.py +115 -143
- devgen/modules/changelog_sections.py +79 -0
- devgen/modules/commit_generator.py +298 -415
- devgen/modules/diff_builder.py +220 -0
- devgen/modules/git_ops.py +144 -0
- devgen/modules/release_note_generator.py +28 -38
- devgen/providers/__init__.py +0 -24
- devgen/providers/anthropic.py +13 -17
- devgen/providers/base.py +90 -0
- devgen/providers/gemini.py +56 -17
- devgen/providers/huggingface.py +36 -27
- devgen/providers/ollama.py +51 -0
- devgen/providers/openai.py +8 -39
- devgen/providers/openrouter.py +18 -29
- devgen/templates/commit/commit_message.tpl +14 -0
- devgen/utils.py +62 -0
- devgen_cli-0.2.5.dist-info/METADATA +187 -0
- devgen_cli-0.2.5.dist-info/RECORD +49 -0
- {devgen_cli-0.2.4.dist-info → devgen_cli-0.2.5.dist-info}/WHEEL +1 -1
- devgen/templates/commit/commit_message.j2 +0 -11
- devgen_cli-0.2.4.dist-info/METADATA +0 -208
- devgen_cli-0.2.4.dist-info/RECORD +0 -44
- {devgen_cli-0.2.4.dist-info → devgen_cli-0.2.5.dist-info}/entry_points.txt +0 -0
- {devgen_cli-0.2.4.dist-info → devgen_cli-0.2.5.dist-info}/licenses/LICENSE +0 -0
- {devgen_cli-0.2.4.dist-info → devgen_cli-0.2.5.dist-info}/top_level.txt +0 -0
devgen/ai.py
CHANGED
|
@@ -21,8 +21,17 @@ def generate_with_ai(
|
|
|
21
21
|
provider_module = import_module(f"devgen.providers.{provider}")
|
|
22
22
|
class_name = "".join([x.capitalize() for x in provider.split("_")]) + "Provider"
|
|
23
23
|
provider_class = getattr(provider_module, class_name)
|
|
24
|
-
except
|
|
25
|
-
raise ImportError(
|
|
24
|
+
except ModuleNotFoundError as e:
|
|
25
|
+
raise ImportError(
|
|
26
|
+
f"Provider `{provider}` is not a built-in module: {e}. "
|
|
27
|
+
f"Available providers: gemini, openai, anthropic, huggingface, "
|
|
28
|
+
f"openrouter, ollama."
|
|
29
|
+
) from e
|
|
30
|
+
except AttributeError as e:
|
|
31
|
+
raise ImportError(
|
|
32
|
+
f"Provider `{provider}` exists but does not expose a "
|
|
33
|
+
f"`{class_name}` class. This is a packaging bug."
|
|
34
|
+
) from e
|
|
26
35
|
|
|
27
36
|
provider_instance = provider_class()
|
|
28
37
|
return provider_instance.generate(prompt, api_key=api_key, model=model, **kwargs)
|
devgen/cli/commit.py
CHANGED
|
@@ -58,12 +58,28 @@ def run_commit(
|
|
|
58
58
|
help="Review/edit commit message before committing.",
|
|
59
59
|
),
|
|
60
60
|
] = False,
|
|
61
|
+
max_groups: Annotated[
|
|
62
|
+
int | None,
|
|
63
|
+
typer.Option(
|
|
64
|
+
"--max-groups",
|
|
65
|
+
help="Maximum number of commit groups. Lower this if you hit token-limit errors.",
|
|
66
|
+
),
|
|
67
|
+
] = None,
|
|
68
|
+
max_diff_size: Annotated[
|
|
69
|
+
int | None,
|
|
70
|
+
typer.Option(
|
|
71
|
+
"--max-diff-size",
|
|
72
|
+
help="Maximum diff size in chars per group (default 8000). "
|
|
73
|
+
"Lower this if you hit token-limit errors.",
|
|
74
|
+
),
|
|
75
|
+
] = None,
|
|
61
76
|
) -> None:
|
|
62
77
|
log_file = get_main_log_path()
|
|
63
78
|
logger = configure_logger("devgen.cli.commit", log_file, console=debug)
|
|
64
79
|
logger.info(f"Log file: {log_file}")
|
|
65
80
|
logger.info(
|
|
66
|
-
f"Options: dry_run={dry_run}, push={push}, debug={debug}, force={force_rebuild},
|
|
81
|
+
f"Options: dry_run={dry_run}, push={push}, debug={debug}, force={force_rebuild}, "
|
|
82
|
+
f"check={check}, max_groups={max_groups}, max_diff_size={max_diff_size}"
|
|
67
83
|
)
|
|
68
84
|
|
|
69
85
|
run_commit_engine(
|
|
@@ -73,6 +89,8 @@ def run_commit(
|
|
|
73
89
|
force_rebuild=force_rebuild,
|
|
74
90
|
check=check,
|
|
75
91
|
logger=logger,
|
|
92
|
+
max_groups=max_groups,
|
|
93
|
+
max_diff_size=max_diff_size,
|
|
76
94
|
)
|
|
77
95
|
|
|
78
96
|
|
devgen/cli/config.py
CHANGED
|
@@ -62,7 +62,14 @@ def edit_config(
|
|
|
62
62
|
if key == "provider":
|
|
63
63
|
value = questionary.select(
|
|
64
64
|
"Select AI Provider:",
|
|
65
|
-
choices=[
|
|
65
|
+
choices=[
|
|
66
|
+
"gemini",
|
|
67
|
+
"openai",
|
|
68
|
+
"huggingface",
|
|
69
|
+
"openrouter",
|
|
70
|
+
"anthropic",
|
|
71
|
+
"ollama",
|
|
72
|
+
],
|
|
66
73
|
default=str(current_val) if current_val else "gemini",
|
|
67
74
|
style=style,
|
|
68
75
|
).ask()
|
|
@@ -117,7 +124,14 @@ def set_config() -> None:
|
|
|
117
124
|
# Questions
|
|
118
125
|
provider = questionary.select(
|
|
119
126
|
"Select AI Provider:",
|
|
120
|
-
choices=[
|
|
127
|
+
choices=[
|
|
128
|
+
"gemini",
|
|
129
|
+
"openai",
|
|
130
|
+
"huggingface",
|
|
131
|
+
"openrouter",
|
|
132
|
+
"anthropic",
|
|
133
|
+
"ollama",
|
|
134
|
+
],
|
|
121
135
|
default=config.get("provider", "gemini"),
|
|
122
136
|
style=style,
|
|
123
137
|
).ask()
|
|
@@ -151,12 +165,24 @@ def set_config() -> None:
|
|
|
151
165
|
raise typer.Exit(code=130)
|
|
152
166
|
emoji = emoji_choice == "Yes"
|
|
153
167
|
|
|
168
|
+
ollama_host = config.get("ollama_host", "http://localhost:11434")
|
|
169
|
+
if provider == "ollama":
|
|
170
|
+
ollama_host_input = questionary.text(
|
|
171
|
+
"Ollama server URL:",
|
|
172
|
+
default=ollama_host,
|
|
173
|
+
style=style,
|
|
174
|
+
).ask()
|
|
175
|
+
if ollama_host_input is None:
|
|
176
|
+
raise typer.Exit(code=130)
|
|
177
|
+
ollama_host = ollama_host_input.strip() or ollama_host
|
|
178
|
+
|
|
154
179
|
# Save Config
|
|
155
180
|
new_config = {
|
|
156
181
|
"provider": provider,
|
|
157
182
|
"model": model,
|
|
158
183
|
"api_key": api_key,
|
|
159
184
|
"emoji": emoji,
|
|
185
|
+
"ollama_host": ollama_host,
|
|
160
186
|
}
|
|
161
187
|
|
|
162
188
|
# Merge with existing config to preserve other keys?
|
devgen/cli/main.py
CHANGED
|
@@ -48,13 +48,21 @@ def _version_callback(value: bool) -> None:
|
|
|
48
48
|
typer.echo(f"devgen version: {version}")
|
|
49
49
|
else:
|
|
50
50
|
typer.secho(
|
|
51
|
-
"
|
|
51
|
+
"Could not determine version: no `project.version` or "
|
|
52
|
+
"`tool.poetry.version` found in pyproject.toml.",
|
|
52
53
|
fg=typer.colors.RED,
|
|
53
54
|
err=True,
|
|
54
55
|
)
|
|
55
|
-
except
|
|
56
|
+
except FileNotFoundError:
|
|
56
57
|
typer.secho(
|
|
57
|
-
f"
|
|
58
|
+
f"pyproject.toml not found at {pyproject_path}. "
|
|
59
|
+
"The installation may be corrupt.",
|
|
60
|
+
fg=typer.colors.RED,
|
|
61
|
+
err=True,
|
|
62
|
+
)
|
|
63
|
+
except toml.TomlDecodeError as e:
|
|
64
|
+
typer.secho(
|
|
65
|
+
f"pyproject.toml is not valid TOML: {e}",
|
|
58
66
|
fg=typer.colors.RED,
|
|
59
67
|
err=True,
|
|
60
68
|
)
|
devgen/cli/setup.py
CHANGED
|
@@ -33,7 +33,14 @@ def setup_config() -> None:
|
|
|
33
33
|
# Questions
|
|
34
34
|
provider = questionary.select(
|
|
35
35
|
"Select AI Provider:",
|
|
36
|
-
choices=[
|
|
36
|
+
choices=[
|
|
37
|
+
"gemini",
|
|
38
|
+
"openai",
|
|
39
|
+
"huggingface",
|
|
40
|
+
"openrouter",
|
|
41
|
+
"anthropic",
|
|
42
|
+
"ollama",
|
|
43
|
+
],
|
|
37
44
|
default=current_config.get("provider", "gemini"),
|
|
38
45
|
style=style,
|
|
39
46
|
).ask()
|
|
@@ -67,12 +74,24 @@ def setup_config() -> None:
|
|
|
67
74
|
raise typer.Exit(code=130)
|
|
68
75
|
emoji = emoji_choice == "Yes"
|
|
69
76
|
|
|
77
|
+
ollama_host = current_config.get("ollama_host", "http://localhost:11434")
|
|
78
|
+
if provider == "ollama":
|
|
79
|
+
ollama_host_input = questionary.text(
|
|
80
|
+
"Ollama server URL:",
|
|
81
|
+
default=ollama_host,
|
|
82
|
+
style=style,
|
|
83
|
+
).ask()
|
|
84
|
+
if ollama_host_input is None:
|
|
85
|
+
raise typer.Exit(code=130)
|
|
86
|
+
ollama_host = ollama_host_input.strip() or ollama_host
|
|
87
|
+
|
|
70
88
|
# Save Config
|
|
71
89
|
new_config = {
|
|
72
90
|
"provider": provider,
|
|
73
91
|
"model": model,
|
|
74
92
|
"api_key": api_key,
|
|
75
93
|
"emoji": emoji,
|
|
94
|
+
"ollama_host": ollama_host,
|
|
76
95
|
}
|
|
77
96
|
|
|
78
97
|
try:
|
|
@@ -5,179 +5,151 @@ from datetime import datetime
|
|
|
5
5
|
from pathlib import Path
|
|
6
6
|
from typing import Dict, List, Optional
|
|
7
7
|
|
|
8
|
+
from devgen.modules.changelog_sections import (
|
|
9
|
+
DEFAULT_STYLES,
|
|
10
|
+
Section,
|
|
11
|
+
section_for_type,
|
|
12
|
+
)
|
|
8
13
|
from devgen.utils import configure_logger, run_git_command
|
|
9
14
|
|
|
10
15
|
|
|
11
16
|
class ChangelogGenerator:
|
|
12
17
|
"""Generates a changelog from git history using Semantic Release style."""
|
|
13
18
|
|
|
19
|
+
#: Conventional commit header: ``type(scope)!: subject``
|
|
20
|
+
_CC_PATTERN = re.compile(r"^(\w+)(?:\(([^)]+)\))?(!?):\s+(.*)")
|
|
21
|
+
|
|
14
22
|
def __init__(self, logger=None):
|
|
15
23
|
self.logger = logger or configure_logger("devgen.changelog")
|
|
16
24
|
|
|
17
|
-
|
|
18
|
-
"""Fetches commit messages in the specified range."""
|
|
19
|
-
range_spec = f"{from_ref}..{to_ref}" if from_ref else to_ref
|
|
20
|
-
# Format: hash|author|date|subject|body
|
|
21
|
-
fmt = "%H|%an|%ad|%s|%b"
|
|
22
|
-
cmd = ["git", "log", f"--format={fmt}", "--date=short", range_spec]
|
|
23
|
-
|
|
24
|
-
if not from_ref:
|
|
25
|
-
# If no start ref, try to find the last tag
|
|
26
|
-
try:
|
|
27
|
-
last_tag = run_git_command(["git", "describe", "--tags", "--abbrev=0"])
|
|
28
|
-
cmd = [
|
|
29
|
-
"git",
|
|
30
|
-
"log",
|
|
31
|
-
f"--format={fmt}",
|
|
32
|
-
"--date=short",
|
|
33
|
-
f"{last_tag}..HEAD",
|
|
34
|
-
]
|
|
35
|
-
self.logger.info(f"Generating changelog from last tag: {last_tag}")
|
|
36
|
-
except (RuntimeError, subprocess.CalledProcessError):
|
|
37
|
-
self.logger.info("No tags found, generating for all commits.")
|
|
38
|
-
cmd = ["git", "log", f"--format={fmt}", "--date=short"]
|
|
25
|
+
# ------------------------------------------------------------------ commits
|
|
39
26
|
|
|
27
|
+
def get_commits(self, from_ref: str = "", to_ref: str = "HEAD") -> List[str]:
|
|
28
|
+
"""Fetch commit lines in the requested range."""
|
|
29
|
+
if from_ref:
|
|
30
|
+
range_spec = f"{from_ref}..{to_ref}"
|
|
31
|
+
cmd = self._log_cmd(range_spec)
|
|
32
|
+
else:
|
|
33
|
+
cmd = self._resolve_range(to_ref)
|
|
40
34
|
try:
|
|
41
35
|
return run_git_command(cmd).split("\n")
|
|
42
36
|
except subprocess.CalledProcessError as e:
|
|
43
37
|
self.logger.error(f"Git command failed: {e}")
|
|
44
|
-
raise RuntimeError(f"Git command failed: {e}")
|
|
38
|
+
raise RuntimeError(f"Git command failed: {e}") from e
|
|
45
39
|
|
|
46
|
-
def
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
40
|
+
def _log_cmd(self, range_spec: str) -> List[str]:
|
|
41
|
+
# Format: hash|author|date|subject|body
|
|
42
|
+
return [
|
|
43
|
+
"git",
|
|
44
|
+
"log",
|
|
45
|
+
"--format=%H|%an|%ad|%s|%b",
|
|
46
|
+
"--date=short",
|
|
47
|
+
range_spec,
|
|
48
|
+
]
|
|
51
49
|
|
|
50
|
+
def _resolve_range(self, to_ref: str) -> List[str]:
|
|
51
|
+
try:
|
|
52
|
+
last_tag = run_git_command(["git", "describe", "--tags", "--abbrev=0"])
|
|
53
|
+
self.logger.info(f"Generating changelog from last tag: {last_tag}")
|
|
54
|
+
return self._log_cmd(f"{last_tag}..HEAD")
|
|
55
|
+
except (RuntimeError, subprocess.CalledProcessError):
|
|
56
|
+
self.logger.info("No tags found, generating for all commits.")
|
|
57
|
+
return ["git", "log", "--format=%H|%an|%ad|%s|%b", "--date=short"]
|
|
58
|
+
|
|
59
|
+
# ----------------------------------------------------------------- parsing
|
|
60
|
+
|
|
61
|
+
def parse_commits(self, raw_commits: List[str]) -> Dict[Section, List[Dict]]:
|
|
62
|
+
"""Group raw commit lines into :class:`Section` buckets."""
|
|
63
|
+
groups: Dict[Section, List[Dict]] = defaultdict(list)
|
|
52
64
|
for line in raw_commits:
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
parts = line.split("|", 4)
|
|
57
|
-
if len(parts) < 4:
|
|
65
|
+
entry = self._parse_line(line)
|
|
66
|
+
if entry is None:
|
|
58
67
|
continue
|
|
68
|
+
section = section_for_type(entry["type"])
|
|
69
|
+
entry["section"] = section
|
|
70
|
+
if entry["breaking"]:
|
|
71
|
+
groups[Section.BREAKING].append(entry)
|
|
72
|
+
groups[section].append(entry)
|
|
73
|
+
return dict(groups)
|
|
74
|
+
|
|
75
|
+
def _parse_line(self, line: str) -> Dict | None:
|
|
76
|
+
if not line.strip():
|
|
77
|
+
return None
|
|
78
|
+
parts = line.split("|", 4)
|
|
79
|
+
if len(parts) < 4:
|
|
80
|
+
return None
|
|
81
|
+
commit_hash, author, date, subject = parts[:4]
|
|
82
|
+
body = parts[4] if len(parts) > 4 else ""
|
|
83
|
+
|
|
84
|
+
match = self._CC_PATTERN.match(subject)
|
|
85
|
+
if match:
|
|
86
|
+
c_type, c_scope, breaking, c_subject = match.groups()
|
|
87
|
+
return {
|
|
88
|
+
"type": c_type,
|
|
89
|
+
"hash": commit_hash,
|
|
90
|
+
"author": author,
|
|
91
|
+
"date": date,
|
|
92
|
+
"scope": c_scope,
|
|
93
|
+
"subject": c_subject,
|
|
94
|
+
"body": body,
|
|
95
|
+
"breaking": bool(breaking),
|
|
96
|
+
}
|
|
97
|
+
return {
|
|
98
|
+
"type": "",
|
|
99
|
+
"hash": commit_hash,
|
|
100
|
+
"author": author,
|
|
101
|
+
"date": date,
|
|
102
|
+
"scope": None,
|
|
103
|
+
"subject": subject,
|
|
104
|
+
"body": body,
|
|
105
|
+
"breaking": False,
|
|
106
|
+
}
|
|
59
107
|
|
|
60
|
-
|
|
61
|
-
parts[0],
|
|
62
|
-
parts[1],
|
|
63
|
-
parts[2],
|
|
64
|
-
parts[3],
|
|
65
|
-
parts[4] if len(parts) > 4 else "",
|
|
66
|
-
)
|
|
67
|
-
|
|
68
|
-
match = cc_pattern.match(subject)
|
|
69
|
-
if match:
|
|
70
|
-
c_type, c_scope, breaking, c_subject = match.groups()
|
|
71
|
-
entry = {
|
|
72
|
-
"hash": commit_hash,
|
|
73
|
-
"author": author,
|
|
74
|
-
"date": date,
|
|
75
|
-
"scope": c_scope,
|
|
76
|
-
"subject": c_subject,
|
|
77
|
-
"body": body,
|
|
78
|
-
"breaking": bool(breaking),
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
if breaking:
|
|
82
|
-
groups["BREAKING CHANGES"].append(entry)
|
|
83
|
-
|
|
84
|
-
if c_type in ["feat", "feature"]:
|
|
85
|
-
groups["Features"].append(entry)
|
|
86
|
-
elif c_type in ["fix", "bug"]:
|
|
87
|
-
groups["Bug Fixes"].append(entry)
|
|
88
|
-
elif c_type in ["docs"]:
|
|
89
|
-
groups["Documentation"].append(entry)
|
|
90
|
-
elif c_type in [
|
|
91
|
-
"style",
|
|
92
|
-
"refactor",
|
|
93
|
-
"perf",
|
|
94
|
-
"test",
|
|
95
|
-
"build",
|
|
96
|
-
"ci",
|
|
97
|
-
"chore",
|
|
98
|
-
]:
|
|
99
|
-
groups["Other Changes"].append(entry)
|
|
100
|
-
else:
|
|
101
|
-
groups["Other Changes"].append(entry)
|
|
102
|
-
else:
|
|
103
|
-
# Non-conventional commits
|
|
104
|
-
groups["Other Changes"].append(
|
|
105
|
-
{
|
|
106
|
-
"hash": commit_hash,
|
|
107
|
-
"author": author,
|
|
108
|
-
"date": date,
|
|
109
|
-
"scope": None,
|
|
110
|
-
"subject": subject,
|
|
111
|
-
"body": body,
|
|
112
|
-
"breaking": False,
|
|
113
|
-
}
|
|
114
|
-
)
|
|
115
|
-
|
|
116
|
-
return groups
|
|
108
|
+
# ------------------------------------------------------------------ output
|
|
117
109
|
|
|
118
110
|
def generate_markdown(
|
|
119
|
-
self,
|
|
111
|
+
self,
|
|
112
|
+
groups: Dict[Section, List[Dict]],
|
|
113
|
+
version: str = "Unreleased",
|
|
120
114
|
) -> str:
|
|
121
|
-
"""
|
|
115
|
+
"""Render groups as a markdown changelog block."""
|
|
122
116
|
date_str = datetime.now().strftime("%Y-%m-%d")
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
# Order: Breaking, Features, Fixes, Docs, Others
|
|
126
|
-
order = [
|
|
127
|
-
"BREAKING CHANGES",
|
|
128
|
-
"Features",
|
|
129
|
-
"Bug Fixes",
|
|
130
|
-
"Documentation",
|
|
131
|
-
"Other Changes",
|
|
132
|
-
]
|
|
133
|
-
|
|
134
|
-
emoji_map = {
|
|
135
|
-
"BREAKING CHANGES": "💥 BREAKING CHANGES",
|
|
136
|
-
"Features": "✨ Features",
|
|
137
|
-
"Bug Fixes": "🐛 Bug Fixes",
|
|
138
|
-
"Documentation": "📚 Documentation",
|
|
139
|
-
"Other Changes": "🔨 Other Changes",
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
for section in order:
|
|
117
|
+
out = [f"## {version} ({date_str})\n"]
|
|
118
|
+
for section in Section.ordered():
|
|
143
119
|
commits = groups.get(section)
|
|
144
|
-
if commits:
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
return "\n".join(
|
|
120
|
+
if not commits:
|
|
121
|
+
continue
|
|
122
|
+
style = DEFAULT_STYLES[section]
|
|
123
|
+
out.append(f"## {style.emoji} {style.heading}\n")
|
|
124
|
+
for c in commits:
|
|
125
|
+
scope = f"**{c['scope']}**: " if c.get("scope") else ""
|
|
126
|
+
out.append(f"- {scope}{c['subject']} ({c['hash'][:7]})")
|
|
127
|
+
out.append("")
|
|
128
|
+
return "\n".join(out)
|
|
153
129
|
|
|
154
130
|
def run(self, output_file: Optional[str] = "CHANGELOG.md", from_ref: str = ""):
|
|
155
|
-
"""
|
|
131
|
+
"""Fetch → parse → write (or print) the changelog."""
|
|
156
132
|
raw_commits = self.get_commits(from_ref)
|
|
157
133
|
if not raw_commits or not raw_commits[0]:
|
|
158
134
|
self.logger.warning("No commits found.")
|
|
159
135
|
return
|
|
136
|
+
groups = self.parse_commits(raw_commits)
|
|
137
|
+
md_content = self.generate_markdown(groups)
|
|
160
138
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
if output_file:
|
|
165
|
-
path = Path(output_file)
|
|
166
|
-
if path.exists():
|
|
167
|
-
old_content = path.read_text(encoding="utf-8")
|
|
168
|
-
# Prepend the new content, assume # CHANGELOG is at the top or needs to be
|
|
169
|
-
if old_content.strip().startswith("# CHANGELOG"):
|
|
170
|
-
lines = old_content.split("\n", 1)
|
|
171
|
-
header = lines[0]
|
|
172
|
-
rest = lines[1] if len(lines) > 1 else ""
|
|
173
|
-
new_content = f"{header}\n\n{md_content}\n{rest.lstrip()}"
|
|
174
|
-
else:
|
|
175
|
-
new_content = f"# CHANGELOG\n\n{md_content}\n\n{old_content}"
|
|
176
|
-
path.write_text(new_content, encoding="utf-8")
|
|
177
|
-
else:
|
|
178
|
-
path.write_text(f"# CHANGELOG\n\n{md_content}", encoding="utf-8")
|
|
139
|
+
if not output_file:
|
|
140
|
+
print(md_content)
|
|
141
|
+
return
|
|
179
142
|
|
|
180
|
-
|
|
181
|
-
|
|
143
|
+
path = Path(output_file)
|
|
144
|
+
if path.exists():
|
|
145
|
+
old = path.read_text(encoding="utf-8")
|
|
146
|
+
if old.strip().startswith("# CHANGELOG"):
|
|
147
|
+
header, _, rest = old.partition("\n")
|
|
148
|
+
new_content = f"{header}\n\n{md_content}\n{rest.lstrip()}"
|
|
149
|
+
else:
|
|
150
|
+
new_content = f"# CHANGELOG\n\n{md_content}\n\n{old}"
|
|
182
151
|
else:
|
|
183
|
-
|
|
152
|
+
new_content = f"# CHANGELOG\n\n{md_content}"
|
|
153
|
+
path.write_text(new_content, encoding="utf-8")
|
|
154
|
+
self.logger.info(f"Changelog written to {output_file}")
|
|
155
|
+
print(f" Changelog updated: {output_file}")
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Shared section definitions for changelog and release-note generators.
|
|
2
|
+
|
|
3
|
+
Both generators render the same set of Conventional Commit groups; this
|
|
4
|
+
module is the single source of truth for the section name, the emoji,
|
|
5
|
+
and the display order.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from enum import Enum
|
|
10
|
+
from typing import Mapping
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Section(str, Enum):
|
|
14
|
+
"""Sections in the order they should be rendered."""
|
|
15
|
+
|
|
16
|
+
BREAKING = "BREAKING CHANGES"
|
|
17
|
+
FEATURES = "Features"
|
|
18
|
+
BUG_FIXES = "Bug Fixes"
|
|
19
|
+
PERFORMANCE = "Performance"
|
|
20
|
+
DOCUMENTATION = "Documentation"
|
|
21
|
+
REFACTOR = "Refactor"
|
|
22
|
+
TESTS = "Tests"
|
|
23
|
+
STYLE = "Style"
|
|
24
|
+
CHORE = "Chore"
|
|
25
|
+
OTHER = "Other Changes"
|
|
26
|
+
|
|
27
|
+
@classmethod
|
|
28
|
+
def ordered(cls) -> tuple["Section", ...]:
|
|
29
|
+
return tuple(cls)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class SectionStyle:
|
|
34
|
+
"""Visual style for a section (emoji + name for the heading)."""
|
|
35
|
+
|
|
36
|
+
emoji: str
|
|
37
|
+
heading: str
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# Default style for each section. Generators can override per output
|
|
41
|
+
# format (changelog vs. release notes).
|
|
42
|
+
DEFAULT_STYLES: Mapping[Section, SectionStyle] = {
|
|
43
|
+
Section.BREAKING: SectionStyle("💥", "BREAKING CHANGES"),
|
|
44
|
+
Section.FEATURES: SectionStyle("✨", "Features"),
|
|
45
|
+
Section.BUG_FIXES: SectionStyle("🐛", "Bug Fixes"),
|
|
46
|
+
Section.PERFORMANCE: SectionStyle("⚡", "Performance"),
|
|
47
|
+
Section.DOCUMENTATION: SectionStyle("📚", "Documentation"),
|
|
48
|
+
Section.REFACTOR: SectionStyle("♻️", "Refactor"),
|
|
49
|
+
Section.TESTS: SectionStyle("✅", "Tests"),
|
|
50
|
+
Section.STYLE: SectionStyle("💄", "Style"),
|
|
51
|
+
Section.CHORE: SectionStyle("🔧", "Chore"),
|
|
52
|
+
Section.OTHER: SectionStyle("🧹", "Other Changes"),
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# Conventional commit type → Section mapping. Long-form aliases ("feature",
|
|
57
|
+
# "bug") are also accepted.
|
|
58
|
+
TYPE_TO_SECTION: Mapping[str, Section] = {
|
|
59
|
+
"feat": Section.FEATURES,
|
|
60
|
+
"feature": Section.FEATURES,
|
|
61
|
+
"fix": Section.BUG_FIXES,
|
|
62
|
+
"bug": Section.BUG_FIXES,
|
|
63
|
+
"perf": Section.PERFORMANCE,
|
|
64
|
+
"docs": Section.DOCUMENTATION,
|
|
65
|
+
"refactor": Section.REFACTOR,
|
|
66
|
+
"test": Section.TESTS,
|
|
67
|
+
"style": Section.STYLE,
|
|
68
|
+
"build": Section.CHORE,
|
|
69
|
+
"ci": Section.CHORE,
|
|
70
|
+
"chore": Section.CHORE,
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def section_for_type(commit_type: str) -> Section:
|
|
75
|
+
"""Return the Section for a Conventional Commit ``type``.
|
|
76
|
+
|
|
77
|
+
Unknown types fall back to :attr:`Section.OTHER`.
|
|
78
|
+
"""
|
|
79
|
+
return TYPE_TO_SECTION.get(commit_type.lower(), Section.OTHER)
|