devgen-cli 0.2.3__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 +53 -1
- devgen/cli/config.py +55 -2
- devgen/cli/main.py +12 -4
- devgen/cli/setup.py +20 -1
- devgen/modules/changelog_generator.py +115 -138
- devgen/modules/changelog_sections.py +79 -0
- devgen/modules/commit_generator.py +311 -227
- devgen/modules/diff_builder.py +220 -0
- devgen/modules/git_ops.py +144 -0
- devgen/modules/license_generator.py +1 -3
- 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 +118 -10
- devgen_cli-0.2.5.dist-info/METADATA +187 -0
- devgen_cli-0.2.5.dist-info/RECORD +49 -0
- {devgen_cli-0.2.3.dist-info → devgen_cli-0.2.5.dist-info}/WHEEL +1 -1
- devgen/templates/commit/commit_message.j2 +0 -15
- devgen_cli-0.2.3.dist-info/METADATA +0 -177
- devgen_cli-0.2.3.dist-info/RECORD +0 -44
- {devgen_cli-0.2.3.dist-info → devgen_cli-0.2.5.dist-info}/entry_points.txt +0 -0
- {devgen_cli-0.2.3.dist-info → devgen_cli-0.2.5.dist-info}/licenses/LICENSE +0 -0
- {devgen_cli-0.2.3.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
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
from typing import Annotated
|
|
2
2
|
|
|
3
|
+
import questionary
|
|
3
4
|
import typer
|
|
4
5
|
|
|
5
6
|
from devgen.modules.commit_generator import run_commit_engine
|
|
@@ -50,12 +51,35 @@ def run_commit(
|
|
|
50
51
|
help="Force regeneration of commit messages.",
|
|
51
52
|
),
|
|
52
53
|
] = False,
|
|
54
|
+
check: Annotated[
|
|
55
|
+
bool,
|
|
56
|
+
typer.Option(
|
|
57
|
+
"--check",
|
|
58
|
+
help="Review/edit commit message before committing.",
|
|
59
|
+
),
|
|
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,
|
|
53
76
|
) -> None:
|
|
54
77
|
log_file = get_main_log_path()
|
|
55
78
|
logger = configure_logger("devgen.cli.commit", log_file, console=debug)
|
|
56
79
|
logger.info(f"Log file: {log_file}")
|
|
57
80
|
logger.info(
|
|
58
|
-
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}"
|
|
59
83
|
)
|
|
60
84
|
|
|
61
85
|
run_commit_engine(
|
|
@@ -63,7 +87,10 @@ def run_commit(
|
|
|
63
87
|
push=push,
|
|
64
88
|
debug=debug,
|
|
65
89
|
force_rebuild=force_rebuild,
|
|
90
|
+
check=check,
|
|
66
91
|
logger=logger,
|
|
92
|
+
max_groups=max_groups,
|
|
93
|
+
max_diff_size=max_diff_size,
|
|
67
94
|
)
|
|
68
95
|
|
|
69
96
|
|
|
@@ -94,3 +121,28 @@ def validate() -> None:
|
|
|
94
121
|
typer.echo(f"- {f}")
|
|
95
122
|
else:
|
|
96
123
|
typer.secho("[i] No staged files.", fg=typer.colors.RED)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@app.command("undo")
|
|
127
|
+
def undo_commit() -> None:
|
|
128
|
+
"""Undoes the last commit but keeps changes staged."""
|
|
129
|
+
from devgen.utils import run_git_command
|
|
130
|
+
|
|
131
|
+
try:
|
|
132
|
+
# Check if there's at least one commit
|
|
133
|
+
run_git_command(["git", "rev-parse", "HEAD"])
|
|
134
|
+
except Exception:
|
|
135
|
+
typer.secho("No commits found to undo.", fg=typer.colors.RED)
|
|
136
|
+
return
|
|
137
|
+
|
|
138
|
+
if questionary.confirm(
|
|
139
|
+
"Are you sure you want to undo the last commit? (Changes will remain staged)",
|
|
140
|
+
default=False,
|
|
141
|
+
).ask():
|
|
142
|
+
try:
|
|
143
|
+
run_git_command(["git", "reset", "--soft", "HEAD~1"])
|
|
144
|
+
typer.secho(
|
|
145
|
+
"Last commit undone. Changes are still staged.", fg=typer.colors.GREEN
|
|
146
|
+
)
|
|
147
|
+
except Exception as e:
|
|
148
|
+
typer.secho(f"Failed to undo commit: {e}", fg=typer.colors.RED)
|
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?
|
|
@@ -167,3 +193,30 @@ def set_config() -> None:
|
|
|
167
193
|
_save_config(config)
|
|
168
194
|
typer.secho("\nConfiguration saved.", fg=typer.colors.GREEN)
|
|
169
195
|
typer.echo(yaml.dump(new_config, default_flow_style=False))
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
@app.command("info")
|
|
199
|
+
def config_info() -> None:
|
|
200
|
+
"""Show information about configuration options and templates."""
|
|
201
|
+
from rich.console import Console
|
|
202
|
+
from rich.table import Table
|
|
203
|
+
|
|
204
|
+
console = Console()
|
|
205
|
+
table = Table(title="Custom Template Variables", box=None)
|
|
206
|
+
table.add_column("Variable", style="cyan")
|
|
207
|
+
table.add_column("Description", style="white")
|
|
208
|
+
|
|
209
|
+
table.add_row("{{ group_name }}", "The folder name being committed (or 'root').")
|
|
210
|
+
table.add_row("{{ diff_text }}", "The git diff of the changes.")
|
|
211
|
+
table.add_row("{{ context }}", "Project context (manifest files content).")
|
|
212
|
+
|
|
213
|
+
console.print(table)
|
|
214
|
+
console.print(
|
|
215
|
+
"\n[bold]Tip:[/bold] If you hardcode emojis in your template, the AI will likely include them regardless of the 'emoji' setting.",
|
|
216
|
+
style="yellow",
|
|
217
|
+
)
|
|
218
|
+
console.print("\n[bold]Example Template:[/bold]")
|
|
219
|
+
console.print(
|
|
220
|
+
"custom_template: |\n [type]: [desc]\n \n Diff: {{ diff_text }}\n",
|
|
221
|
+
style="dim",
|
|
222
|
+
)
|
devgen/cli/main.py
CHANGED
|
@@ -15,7 +15,7 @@ from devgen.cli.setup import app as setup_app
|
|
|
15
15
|
app = typer.Typer(
|
|
16
16
|
name="devgen",
|
|
17
17
|
help="devgen-py: AI-Powered Git Commit & Release Automation.",
|
|
18
|
-
add_completion=
|
|
18
|
+
add_completion=True,
|
|
19
19
|
no_args_is_help=True,
|
|
20
20
|
rich_markup_mode="markdown",
|
|
21
21
|
)
|
|
@@ -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,174 +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 file exists, prepend? For now, just overwrite or append logic could be complex.
|
|
167
|
-
# Let's implement prepend logic if file exists.
|
|
168
|
-
if path.exists():
|
|
169
|
-
old_content = path.read_text(encoding="utf-8")
|
|
170
|
-
new_content = old_content + "\n\n" + md_content
|
|
171
|
-
path.write_text(new_content, encoding="utf-8")
|
|
172
|
-
else:
|
|
173
|
-
path.write_text(md_content, encoding="utf-8")
|
|
139
|
+
if not output_file:
|
|
140
|
+
print(md_content)
|
|
141
|
+
return
|
|
174
142
|
|
|
175
|
-
|
|
176
|
-
|
|
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}"
|
|
177
151
|
else:
|
|
178
|
-
|
|
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)
|