devgen-cli 0.2.0__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/__init__.py +0 -0
- devgen/ai.py +28 -0
- devgen/cli/__init__.py +0 -0
- devgen/cli/changelog.py +38 -0
- devgen/cli/commit.py +96 -0
- devgen/cli/config.py +169 -0
- devgen/cli/gitignore.py +138 -0
- devgen/cli/license.py +101 -0
- devgen/cli/main.py +101 -0
- devgen/cli/release.py +30 -0
- devgen/cli/setup.py +85 -0
- devgen/modules/__init__.py +0 -0
- devgen/modules/changelog_generator.py +190 -0
- devgen/modules/commit_generator.py +257 -0
- devgen/modules/gitignore_generator.py +116 -0
- devgen/modules/license_generator.py +80 -0
- devgen/modules/release_note_generator.py +66 -0
- devgen/providers/__init__.py +21 -0
- devgen/providers/anthropic.py +23 -0
- devgen/providers/gemini.py +24 -0
- devgen/providers/huggingface.py +45 -0
- devgen/providers/openai.py +48 -0
- devgen/providers/openrouter.py +33 -0
- devgen/utils.py +198 -0
- devgen_cli-0.2.0.dist-info/METADATA +287 -0
- devgen_cli-0.2.0.dist-info/RECORD +30 -0
- devgen_cli-0.2.0.dist-info/WHEEL +5 -0
- devgen_cli-0.2.0.dist-info/entry_points.txt +2 -0
- devgen_cli-0.2.0.dist-info/licenses/LICENSE +675 -0
- devgen_cli-0.2.0.dist-info/top_level.txt +1 -0
devgen/cli/release.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
from typing_extensions import Annotated
|
|
3
|
+
|
|
4
|
+
from devgen.modules.release_note_generator import ReleaseNotesGenerator
|
|
5
|
+
|
|
6
|
+
app = typer.Typer(
|
|
7
|
+
name="release",
|
|
8
|
+
help="🚀 Generate release notes from git history.",
|
|
9
|
+
no_args_is_help=True,
|
|
10
|
+
rich_markup_mode="markdown",
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@app.command("notes")
|
|
15
|
+
def generate_release_notes(
|
|
16
|
+
version: Annotated[
|
|
17
|
+
str,
|
|
18
|
+
typer.Option("--version", "-v", help="Version for this release (ex: 1.4.0)"),
|
|
19
|
+
] = "Unreleased",
|
|
20
|
+
output: Annotated[
|
|
21
|
+
str,
|
|
22
|
+
typer.Option("--output", "-o", help="Output file path."),
|
|
23
|
+
] = "RELEASE-NOTES.md",
|
|
24
|
+
from_ref: Annotated[
|
|
25
|
+
str,
|
|
26
|
+
typer.Option("--from", "-f", help="Start reference. Defaults to last tag."),
|
|
27
|
+
] = "",
|
|
28
|
+
):
|
|
29
|
+
generator = ReleaseNotesGenerator()
|
|
30
|
+
generator.run(output_file=output, version=version, from_ref=from_ref)
|
devgen/cli/setup.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
import questionary
|
|
4
|
+
import typer
|
|
5
|
+
import yaml
|
|
6
|
+
|
|
7
|
+
app = typer.Typer(
|
|
8
|
+
name="setup",
|
|
9
|
+
help="⚙️ Setup and configuration management.",
|
|
10
|
+
no_args_is_help=True,
|
|
11
|
+
rich_markup_mode="markdown",
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@app.command("config")
|
|
16
|
+
def setup_config() -> None:
|
|
17
|
+
"""Interactively setup configuration."""
|
|
18
|
+
typer.secho("🛠️ Interactive Configuration Setup", fg=typer.colors.CYAN, bold=True)
|
|
19
|
+
|
|
20
|
+
config_path = Path.home() / ".devgen.yaml"
|
|
21
|
+
current_config = {}
|
|
22
|
+
if config_path.exists():
|
|
23
|
+
try:
|
|
24
|
+
with config_path.open("r", encoding="utf-8") as f:
|
|
25
|
+
current_config = yaml.safe_load(f) or {}
|
|
26
|
+
except Exception:
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
from devgen.utils import get_questionary_style
|
|
30
|
+
|
|
31
|
+
style = get_questionary_style()
|
|
32
|
+
|
|
33
|
+
# Questions
|
|
34
|
+
provider = questionary.select(
|
|
35
|
+
"Select AI Provider:",
|
|
36
|
+
choices=["gemini", "openai", "huggingface", "openrouter", "anthropic"],
|
|
37
|
+
default=current_config.get("provider", "gemini"),
|
|
38
|
+
style=style,
|
|
39
|
+
).ask()
|
|
40
|
+
if provider is None:
|
|
41
|
+
raise typer.Exit(code=130)
|
|
42
|
+
|
|
43
|
+
model_default = current_config.get("model", "gemini-2.5-flash")
|
|
44
|
+
|
|
45
|
+
model = questionary.text(
|
|
46
|
+
"Enter Model Name:", default=model_default, style=style
|
|
47
|
+
).ask()
|
|
48
|
+
if model is None:
|
|
49
|
+
raise typer.Exit(code=130)
|
|
50
|
+
|
|
51
|
+
api_key = questionary.password(
|
|
52
|
+
"Enter API Key (leave empty to keep existing or none):", style=style
|
|
53
|
+
).ask()
|
|
54
|
+
if api_key is None:
|
|
55
|
+
raise typer.Exit(code=130)
|
|
56
|
+
|
|
57
|
+
if not api_key:
|
|
58
|
+
api_key = current_config.get("api_key", "")
|
|
59
|
+
|
|
60
|
+
emoji_choice = questionary.select(
|
|
61
|
+
"Use Emojis in Commit Messages?",
|
|
62
|
+
choices=["Yes", "No"],
|
|
63
|
+
default="Yes" if current_config.get("emoji", True) else "No",
|
|
64
|
+
style=style,
|
|
65
|
+
).ask()
|
|
66
|
+
if emoji_choice is None:
|
|
67
|
+
raise typer.Exit(code=130)
|
|
68
|
+
emoji = emoji_choice == "Yes"
|
|
69
|
+
|
|
70
|
+
# Save Config
|
|
71
|
+
new_config = {
|
|
72
|
+
"provider": provider,
|
|
73
|
+
"model": model,
|
|
74
|
+
"api_key": api_key,
|
|
75
|
+
"emoji": emoji,
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
try:
|
|
79
|
+
with config_path.open("w", encoding="utf-8") as f:
|
|
80
|
+
yaml.dump(new_config, f, default_flow_style=False)
|
|
81
|
+
typer.secho(f"\nConfiguration saved to {config_path}", fg=typer.colors.GREEN)
|
|
82
|
+
typer.echo(yaml.dump(new_config, default_flow_style=False))
|
|
83
|
+
except Exception as e:
|
|
84
|
+
typer.secho(f"\nFailed to save configuration: {e}", fg=typer.colors.RED)
|
|
85
|
+
raise typer.Exit(code=1)
|
|
File without changes
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import re
|
|
2
|
+
import subprocess
|
|
3
|
+
from collections import defaultdict
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Dict, List, Optional
|
|
7
|
+
|
|
8
|
+
from devgen.utils import configure_logger
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ChangelogGenerator:
|
|
12
|
+
"""Generates a changelog from git history using Semantic Release style."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, logger=None):
|
|
15
|
+
self.logger = logger or configure_logger("devgen.changelog")
|
|
16
|
+
|
|
17
|
+
def _exec_git(self, args: List[str]) -> str:
|
|
18
|
+
"""Executes a git command."""
|
|
19
|
+
try:
|
|
20
|
+
res = subprocess.run(
|
|
21
|
+
args,
|
|
22
|
+
capture_output=True,
|
|
23
|
+
text=True,
|
|
24
|
+
encoding="utf-8",
|
|
25
|
+
errors="replace",
|
|
26
|
+
check=True,
|
|
27
|
+
)
|
|
28
|
+
return res.stdout.strip()
|
|
29
|
+
except subprocess.CalledProcessError as e:
|
|
30
|
+
self.logger.error(f"Git command failed: {e}")
|
|
31
|
+
raise RuntimeError(f"Git command failed: {e}")
|
|
32
|
+
|
|
33
|
+
def get_commits(self, from_ref: str = "", to_ref: str = "HEAD") -> List[str]:
|
|
34
|
+
"""Fetches commit messages in the specified range."""
|
|
35
|
+
range_spec = f"{from_ref}..{to_ref}" if from_ref else to_ref
|
|
36
|
+
# Format: hash|author|date|subject|body
|
|
37
|
+
fmt = "%H|%an|%ad|%s|%b"
|
|
38
|
+
cmd = ["git", "log", f"--format={fmt}", "--date=short", range_spec]
|
|
39
|
+
|
|
40
|
+
if not from_ref:
|
|
41
|
+
# If no start ref, try to find the last tag
|
|
42
|
+
try:
|
|
43
|
+
last_tag = self._exec_git(["git", "describe", "--tags", "--abbrev=0"])
|
|
44
|
+
cmd = [
|
|
45
|
+
"git",
|
|
46
|
+
"log",
|
|
47
|
+
f"--format={fmt}",
|
|
48
|
+
"--date=short",
|
|
49
|
+
f"{last_tag}..HEAD",
|
|
50
|
+
]
|
|
51
|
+
self.logger.info(f"Generating changelog from last tag: {last_tag}")
|
|
52
|
+
except RuntimeError:
|
|
53
|
+
self.logger.info("No tags found, generating for all commits.")
|
|
54
|
+
cmd = ["git", "log", f"--format={fmt}", "--date=short"]
|
|
55
|
+
|
|
56
|
+
return self._exec_git(cmd).split("\n")
|
|
57
|
+
|
|
58
|
+
def parse_commits(self, raw_commits: List[str]) -> Dict[str, List[Dict]]:
|
|
59
|
+
"""Parses raw commit strings into structured data."""
|
|
60
|
+
groups = defaultdict(list)
|
|
61
|
+
# Conventional Commit Regex: type(scope)!: subject
|
|
62
|
+
cc_pattern = re.compile(r"^(\w+)(?:\(([^)]+)\))?(!?):\s+(.*)")
|
|
63
|
+
|
|
64
|
+
for line in raw_commits:
|
|
65
|
+
if not line.strip():
|
|
66
|
+
continue
|
|
67
|
+
|
|
68
|
+
parts = line.split("|", 4)
|
|
69
|
+
if len(parts) < 4:
|
|
70
|
+
continue
|
|
71
|
+
|
|
72
|
+
commit_hash, author, date, subject, body = (
|
|
73
|
+
parts[0],
|
|
74
|
+
parts[1],
|
|
75
|
+
parts[2],
|
|
76
|
+
parts[3],
|
|
77
|
+
parts[4] if len(parts) > 4 else "",
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
match = cc_pattern.match(subject)
|
|
81
|
+
if match:
|
|
82
|
+
c_type, c_scope, breaking, c_subject = match.groups()
|
|
83
|
+
entry = {
|
|
84
|
+
"hash": commit_hash,
|
|
85
|
+
"author": author,
|
|
86
|
+
"date": date,
|
|
87
|
+
"scope": c_scope,
|
|
88
|
+
"subject": c_subject,
|
|
89
|
+
"body": body,
|
|
90
|
+
"breaking": bool(breaking),
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if breaking:
|
|
94
|
+
groups["BREAKING CHANGES"].append(entry)
|
|
95
|
+
|
|
96
|
+
if c_type in ["feat", "feature"]:
|
|
97
|
+
groups["Features"].append(entry)
|
|
98
|
+
elif c_type in ["fix", "bug"]:
|
|
99
|
+
groups["Bug Fixes"].append(entry)
|
|
100
|
+
elif c_type in ["docs"]:
|
|
101
|
+
groups["Documentation"].append(entry)
|
|
102
|
+
elif c_type in [
|
|
103
|
+
"style",
|
|
104
|
+
"refactor",
|
|
105
|
+
"perf",
|
|
106
|
+
"test",
|
|
107
|
+
"build",
|
|
108
|
+
"ci",
|
|
109
|
+
"chore",
|
|
110
|
+
]:
|
|
111
|
+
groups["Other Changes"].append(entry)
|
|
112
|
+
else:
|
|
113
|
+
groups["Other Changes"].append(entry)
|
|
114
|
+
else:
|
|
115
|
+
# Non-conventional commits
|
|
116
|
+
groups["Other Changes"].append(
|
|
117
|
+
{
|
|
118
|
+
"hash": commit_hash,
|
|
119
|
+
"author": author,
|
|
120
|
+
"date": date,
|
|
121
|
+
"scope": None,
|
|
122
|
+
"subject": subject,
|
|
123
|
+
"body": body,
|
|
124
|
+
"breaking": False,
|
|
125
|
+
}
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
return groups
|
|
129
|
+
|
|
130
|
+
def generate_markdown(
|
|
131
|
+
self, groups: Dict[str, List[Dict]], version: str = "Unreleased"
|
|
132
|
+
) -> str:
|
|
133
|
+
"""Generates markdown changelog from parsed commits."""
|
|
134
|
+
date_str = datetime.now().strftime("%Y-%m-%d")
|
|
135
|
+
md = [f"# {version} ({date_str})\n"]
|
|
136
|
+
|
|
137
|
+
# Order: Breaking, Features, Fixes, Docs, Others
|
|
138
|
+
order = [
|
|
139
|
+
"BREAKING CHANGES",
|
|
140
|
+
"Features",
|
|
141
|
+
"Bug Fixes",
|
|
142
|
+
"Documentation",
|
|
143
|
+
"Other Changes",
|
|
144
|
+
]
|
|
145
|
+
|
|
146
|
+
emoji_map = {
|
|
147
|
+
"BREAKING CHANGES": "💥 BREAKING CHANGES",
|
|
148
|
+
"Features": "✨ Features",
|
|
149
|
+
"Bug Fixes": "🐛 Bug Fixes",
|
|
150
|
+
"Documentation": "📚 Documentation",
|
|
151
|
+
"Other Changes": "🔨 Other Changes",
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
for section in order:
|
|
155
|
+
commits = groups.get(section)
|
|
156
|
+
if commits:
|
|
157
|
+
header = emoji_map.get(section, section)
|
|
158
|
+
md.append(f"## {header}\n")
|
|
159
|
+
for c in commits:
|
|
160
|
+
scope = f"**{c['scope']}**: " if c["scope"] else ""
|
|
161
|
+
md.append(f"- {scope}{c['subject']} ({c['hash'][:7]})")
|
|
162
|
+
md.append("")
|
|
163
|
+
|
|
164
|
+
return "\n".join(md)
|
|
165
|
+
|
|
166
|
+
def run(self, output_file: Optional[str] = "CHANGELOG.md", from_ref: str = ""):
|
|
167
|
+
"""Main execution method."""
|
|
168
|
+
raw_commits = self.get_commits(from_ref)
|
|
169
|
+
if not raw_commits or not raw_commits[0]:
|
|
170
|
+
self.logger.warning("No commits found.")
|
|
171
|
+
return
|
|
172
|
+
|
|
173
|
+
parsed = self.parse_commits(raw_commits)
|
|
174
|
+
md_content = self.generate_markdown(parsed)
|
|
175
|
+
|
|
176
|
+
if output_file:
|
|
177
|
+
path = Path(output_file)
|
|
178
|
+
# If file exists, prepend? For now, just overwrite or append logic could be complex.
|
|
179
|
+
# Let's implement prepend logic if file exists.
|
|
180
|
+
if path.exists():
|
|
181
|
+
old_content = path.read_text(encoding="utf-8")
|
|
182
|
+
new_content = old_content + "\n\n" + md_content
|
|
183
|
+
path.write_text(new_content, encoding="utf-8")
|
|
184
|
+
else:
|
|
185
|
+
path.write_text(md_content, encoding="utf-8")
|
|
186
|
+
|
|
187
|
+
self.logger.info(f"Changelog written to {output_file}")
|
|
188
|
+
print(f" Changelog updated: {output_file}")
|
|
189
|
+
else:
|
|
190
|
+
print(md_content)
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import subprocess
|
|
2
|
+
from collections import defaultdict
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any, Dict, List
|
|
6
|
+
|
|
7
|
+
from devgen.ai import generate_with_ai
|
|
8
|
+
from devgen.utils import (
|
|
9
|
+
configure_logger,
|
|
10
|
+
extract_commit_messages,
|
|
11
|
+
get_commit_dry_run_path,
|
|
12
|
+
is_file_recent,
|
|
13
|
+
load_template_env,
|
|
14
|
+
sanitize_ai_commit_message,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class CommitEngineError(Exception):
|
|
19
|
+
"""Exception raised for errors in the commit engine."""
|
|
20
|
+
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class CommitEngine:
|
|
25
|
+
"""
|
|
26
|
+
Engine for generating AI-powered commit messages.
|
|
27
|
+
Handles detection of changes, grouping, AI generation, and git operations.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(
|
|
31
|
+
self,
|
|
32
|
+
dry_run: bool = False,
|
|
33
|
+
push: bool = False,
|
|
34
|
+
debug: bool = False,
|
|
35
|
+
force_rebuild: bool = False,
|
|
36
|
+
provider: str = "gemini",
|
|
37
|
+
model: str = "gemini-2.5-flash",
|
|
38
|
+
logger: Any | None = None,
|
|
39
|
+
**kwargs,
|
|
40
|
+
):
|
|
41
|
+
self.dry_run = dry_run
|
|
42
|
+
self.push = push
|
|
43
|
+
self.debug = debug
|
|
44
|
+
self.force_rebuild = force_rebuild
|
|
45
|
+
self.provider = provider
|
|
46
|
+
self.model = model
|
|
47
|
+
self.logger = logger or configure_logger(
|
|
48
|
+
"devgen.commit", Path.home() / ".cache" / "devgen" / "commit.log"
|
|
49
|
+
)
|
|
50
|
+
self.kwargs = kwargs
|
|
51
|
+
self.dry_run_path = get_commit_dry_run_path()
|
|
52
|
+
self.template_env = load_template_env("commit")
|
|
53
|
+
|
|
54
|
+
# Load config from ~/.devgen.yaml
|
|
55
|
+
from devgen.utils import load_config
|
|
56
|
+
|
|
57
|
+
self.config = load_config()
|
|
58
|
+
|
|
59
|
+
def _exec_git(self, args: List[str], allow_error: bool = False) -> str:
|
|
60
|
+
"""Executes a git command."""
|
|
61
|
+
try:
|
|
62
|
+
res = subprocess.run(
|
|
63
|
+
args,
|
|
64
|
+
capture_output=True,
|
|
65
|
+
text=True,
|
|
66
|
+
encoding="utf-8",
|
|
67
|
+
errors="replace",
|
|
68
|
+
check=not allow_error,
|
|
69
|
+
)
|
|
70
|
+
return res.stdout.strip()
|
|
71
|
+
except subprocess.CalledProcessError as e:
|
|
72
|
+
msg = f"Git command failed: {' '.join(e.cmd)}\nError: {e.stderr.strip()}"
|
|
73
|
+
self.logger.error(msg)
|
|
74
|
+
raise CommitEngineError(msg) from e
|
|
75
|
+
|
|
76
|
+
def detect_changes(self) -> List[str]:
|
|
77
|
+
"""Detects changed, deleted, or untracked files."""
|
|
78
|
+
out = self._exec_git(
|
|
79
|
+
[
|
|
80
|
+
"git",
|
|
81
|
+
"ls-files",
|
|
82
|
+
"--deleted",
|
|
83
|
+
"--modified",
|
|
84
|
+
"--others",
|
|
85
|
+
"--exclude-standard",
|
|
86
|
+
]
|
|
87
|
+
)
|
|
88
|
+
return [f.strip() for f in out.split("\n") if f.strip()]
|
|
89
|
+
|
|
90
|
+
def group_files(self, files: List[str]) -> Dict[str, List[str]]:
|
|
91
|
+
"""Groups files by their parent directory."""
|
|
92
|
+
groups = defaultdict(list)
|
|
93
|
+
for f in files:
|
|
94
|
+
parent = str(Path(f).parent)
|
|
95
|
+
key = "root" if parent == "." else parent
|
|
96
|
+
groups[key].append(f)
|
|
97
|
+
return groups
|
|
98
|
+
|
|
99
|
+
def generate_diff(self, files: List[str]) -> str:
|
|
100
|
+
"""Generates diff for specific files."""
|
|
101
|
+
return self._exec_git(["git", "--no-pager", "diff", "--staged", "--", *files])
|
|
102
|
+
|
|
103
|
+
def _init_dry_run(self):
|
|
104
|
+
"""Initializes the dry-run file."""
|
|
105
|
+
self.dry_run_path.parent.mkdir(parents=True, exist_ok=True)
|
|
106
|
+
with self.dry_run_path.open("w", encoding="utf-8") as f:
|
|
107
|
+
ts = datetime.now().astimezone().strftime("%Y-%m-%d %H:%M:%S (%Z)")
|
|
108
|
+
f.write(f"# Dry Run: Commit Messages\n_Generated: {ts}_\n\n")
|
|
109
|
+
|
|
110
|
+
def _log_dry_run(self, group: str, msg: str):
|
|
111
|
+
"""Appends a dry-run entry."""
|
|
112
|
+
with self.dry_run_path.open("a", encoding="utf-8") as f:
|
|
113
|
+
f.write(f"## Group: `{group}`\n\n```md\n{msg}\n```\n\n---\n\n")
|
|
114
|
+
|
|
115
|
+
def stage_files(self, files: List[str]):
|
|
116
|
+
"""Stages files in git."""
|
|
117
|
+
if not files:
|
|
118
|
+
return
|
|
119
|
+
self.logger.info(f"Staging: {files}")
|
|
120
|
+
self._exec_git(["git", "add", *files])
|
|
121
|
+
|
|
122
|
+
def commit_staged(self, msg: str):
|
|
123
|
+
"""Commits staged changes."""
|
|
124
|
+
self.logger.info(f"Committing:\n{msg}")
|
|
125
|
+
self._exec_git(["git", "commit", "-m", msg])
|
|
126
|
+
|
|
127
|
+
def push_commits(self):
|
|
128
|
+
"""Pushes commits to remote."""
|
|
129
|
+
self.logger.info("Pushing to remote...")
|
|
130
|
+
self._exec_git(["git", "push"])
|
|
131
|
+
self.logger.info("Push successful.")
|
|
132
|
+
|
|
133
|
+
def generate_message(self, group: str, diff: str, cache: Dict[str, str]) -> str:
|
|
134
|
+
"""Generates a commit message using AI or cache."""
|
|
135
|
+
if not self.force_rebuild and group in cache:
|
|
136
|
+
self.logger.info(f"Using cached message for {group}")
|
|
137
|
+
return cache[group]
|
|
138
|
+
|
|
139
|
+
# Get settings from config or kwargs
|
|
140
|
+
provider = (
|
|
141
|
+
self.kwargs.get("provider") or self.config.get("provider") or self.provider
|
|
142
|
+
)
|
|
143
|
+
model = self.kwargs.get("model") or self.config.get("model") or self.model
|
|
144
|
+
api_key = self.kwargs.get("api_key") or self.config.get("api_key")
|
|
145
|
+
use_emoji = self.config.get("emoji", True)
|
|
146
|
+
|
|
147
|
+
template = self.template_env.get_template("commit_message.j2")
|
|
148
|
+
prompt = template.render(group_name=group, diff_text=diff, use_emoji=use_emoji)
|
|
149
|
+
|
|
150
|
+
raw = generate_with_ai(
|
|
151
|
+
prompt,
|
|
152
|
+
provider=provider,
|
|
153
|
+
model=model,
|
|
154
|
+
api_key=api_key,
|
|
155
|
+
debug=self.debug,
|
|
156
|
+
**self.kwargs,
|
|
157
|
+
)
|
|
158
|
+
return sanitize_ai_commit_message(raw)
|
|
159
|
+
|
|
160
|
+
def is_ahead_of_remote(self) -> bool:
|
|
161
|
+
"""Checks if local branch has unpushed commits."""
|
|
162
|
+
try:
|
|
163
|
+
self._exec_git(["git", "fetch", "origin"])
|
|
164
|
+
count = self._exec_git(
|
|
165
|
+
["git", "rev-list", "--count", "@{u}..HEAD"], allow_error=True
|
|
166
|
+
)
|
|
167
|
+
if count and int(count) > 0:
|
|
168
|
+
return True
|
|
169
|
+
except CommitEngineError:
|
|
170
|
+
# Maybe no upstream
|
|
171
|
+
return bool(self._exec_git(["git", "rev-parse", "HEAD"], allow_error=True))
|
|
172
|
+
return False
|
|
173
|
+
|
|
174
|
+
def load_cache(self) -> Dict[str, str]:
|
|
175
|
+
"""Loads dry-run cache."""
|
|
176
|
+
if self.dry_run:
|
|
177
|
+
self._init_dry_run()
|
|
178
|
+
return {}
|
|
179
|
+
if not self.force_rebuild and is_file_recent(self.dry_run_path):
|
|
180
|
+
self.logger.info(f"Loading cache from {self.dry_run_path}")
|
|
181
|
+
return extract_commit_messages(self.dry_run_path)
|
|
182
|
+
return {}
|
|
183
|
+
|
|
184
|
+
def process_group(
|
|
185
|
+
self, group: str, files: List[str], cache: Dict[str, str]
|
|
186
|
+
) -> bool:
|
|
187
|
+
"""Processes a single file group."""
|
|
188
|
+
self.stage_files(files)
|
|
189
|
+
diff = self.generate_diff(files)
|
|
190
|
+
|
|
191
|
+
if not diff.strip():
|
|
192
|
+
self.logger.info(f"Skipping empty diff for {group}")
|
|
193
|
+
self._exec_git(["git", "reset", "HEAD", "--", *files])
|
|
194
|
+
return True
|
|
195
|
+
|
|
196
|
+
msg = self.generate_message(group, diff, cache)
|
|
197
|
+
if not msg:
|
|
198
|
+
self.logger.error(f"Empty message for {group}")
|
|
199
|
+
return False
|
|
200
|
+
|
|
201
|
+
if self.dry_run:
|
|
202
|
+
self._log_dry_run(group, msg)
|
|
203
|
+
self._exec_git(["git", "reset", "HEAD", "--", *files])
|
|
204
|
+
else:
|
|
205
|
+
self.commit_staged(msg)
|
|
206
|
+
|
|
207
|
+
return True
|
|
208
|
+
|
|
209
|
+
def execute(self):
|
|
210
|
+
"""Main execution method."""
|
|
211
|
+
files = self.detect_changes()
|
|
212
|
+
ahead = self.is_ahead_of_remote()
|
|
213
|
+
|
|
214
|
+
if not files and not ahead:
|
|
215
|
+
self.logger.info("Nothing to commit or push.")
|
|
216
|
+
return
|
|
217
|
+
|
|
218
|
+
failed = []
|
|
219
|
+
if files:
|
|
220
|
+
groups = self.group_files(files)
|
|
221
|
+
cache = self.load_cache()
|
|
222
|
+
|
|
223
|
+
for group, group_files in groups.items():
|
|
224
|
+
try:
|
|
225
|
+
if not self.process_group(group, group_files, cache):
|
|
226
|
+
failed.append(group)
|
|
227
|
+
except KeyboardInterrupt:
|
|
228
|
+
self.logger.warning("\nOperation interrupted by user.")
|
|
229
|
+
raise
|
|
230
|
+
else:
|
|
231
|
+
self.logger.info("No changes to commit, checking push...")
|
|
232
|
+
|
|
233
|
+
if self.push and not self.dry_run:
|
|
234
|
+
if not failed:
|
|
235
|
+
self.push_commits()
|
|
236
|
+
else:
|
|
237
|
+
self.logger.error("Push aborted due to failed commits.")
|
|
238
|
+
|
|
239
|
+
if self.dry_run:
|
|
240
|
+
self.logger.info(f"Dry run done. See {self.dry_run_path}")
|
|
241
|
+
else:
|
|
242
|
+
self.logger.info("Done.")
|
|
243
|
+
if failed:
|
|
244
|
+
self.logger.warning(f"Failed groups: {failed}")
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def run_commit_engine(**kwargs):
|
|
248
|
+
"""Entry point for the commit engine."""
|
|
249
|
+
logger = configure_logger("devgen.commit")
|
|
250
|
+
try:
|
|
251
|
+
engine = CommitEngine(**kwargs)
|
|
252
|
+
engine.execute()
|
|
253
|
+
except Exception as e:
|
|
254
|
+
logger.error(f"Commit engine failed: {e}", exc_info=True)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
__all__ = ["run_commit_engine"]
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import List
|
|
3
|
+
|
|
4
|
+
import requests
|
|
5
|
+
|
|
6
|
+
from devgen.utils import configure_logger
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class GitignoreGenerator:
|
|
10
|
+
"""Fetches and manages .gitignore templates from GitHub."""
|
|
11
|
+
|
|
12
|
+
def __init__(self, logger=None):
|
|
13
|
+
self.logger = logger or configure_logger("devgen.gitignore")
|
|
14
|
+
self.cache_dir = Path.home() / ".cache" / "devgen" / "gitignore"
|
|
15
|
+
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
|
16
|
+
self.api_url = "https://api.github.com/repos/github/gitignore/contents/"
|
|
17
|
+
self.raw_url_base = "https://raw.githubusercontent.com/github/gitignore/main/"
|
|
18
|
+
|
|
19
|
+
def list_available_templates(self) -> List[str]:
|
|
20
|
+
"""Lists available templates from GitHub."""
|
|
21
|
+
try:
|
|
22
|
+
response = requests.get(self.api_url, timeout=10)
|
|
23
|
+
response.raise_for_status()
|
|
24
|
+
data = response.json()
|
|
25
|
+
return [
|
|
26
|
+
item["name"].replace(".gitignore", "")
|
|
27
|
+
for item in data
|
|
28
|
+
if item["name"].endswith(".gitignore")
|
|
29
|
+
]
|
|
30
|
+
except requests.RequestException as e:
|
|
31
|
+
self.logger.error(f"Failed to fetch templates: {e}")
|
|
32
|
+
raise RuntimeError(f"Failed to fetch templates: {e}")
|
|
33
|
+
|
|
34
|
+
def list_cached_templates(self) -> List[str]:
|
|
35
|
+
"""Lists cached templates."""
|
|
36
|
+
if not self.cache_dir.exists():
|
|
37
|
+
return []
|
|
38
|
+
return [f.stem for f in self.cache_dir.glob("*.gitignore")]
|
|
39
|
+
|
|
40
|
+
def get_template_content(
|
|
41
|
+
self, name: str, use_cache: bool = True, offline: bool = False
|
|
42
|
+
) -> str:
|
|
43
|
+
"""Fetches template content from cache or GitHub."""
|
|
44
|
+
cache_file = self.cache_dir / f"{name}.gitignore"
|
|
45
|
+
|
|
46
|
+
if offline:
|
|
47
|
+
if cache_file.exists():
|
|
48
|
+
self.logger.info(f"Using cached template for {name}")
|
|
49
|
+
return cache_file.read_text(encoding="utf-8")
|
|
50
|
+
else:
|
|
51
|
+
raise RuntimeError(
|
|
52
|
+
f"Template '{name}' not found in cache (offline mode)."
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
# Try to fetch from GitHub
|
|
56
|
+
try:
|
|
57
|
+
url = f"{self.raw_url_base}{name}.gitignore"
|
|
58
|
+
response = requests.get(url, timeout=10)
|
|
59
|
+
|
|
60
|
+
if response.status_code == 404:
|
|
61
|
+
# Try with capitalized first letter if lowercase fails, though the list should be accurate
|
|
62
|
+
# The GitHub repo is case-sensitive for raw URLs usually matching the filename.
|
|
63
|
+
# The list_available_templates returns exact filenames (minus extension).
|
|
64
|
+
# So if we use the name from the list, it should be correct.
|
|
65
|
+
# However, if user inputs manually, we might need to handle case.
|
|
66
|
+
raise RuntimeError(f"Template '{name}' not found on GitHub.")
|
|
67
|
+
|
|
68
|
+
response.raise_for_status()
|
|
69
|
+
content = response.text
|
|
70
|
+
|
|
71
|
+
if use_cache:
|
|
72
|
+
cache_file.write_text(content, encoding="utf-8")
|
|
73
|
+
self.logger.info(f"Cached template for {name}")
|
|
74
|
+
|
|
75
|
+
return content
|
|
76
|
+
|
|
77
|
+
except requests.RequestException as e:
|
|
78
|
+
if cache_file.exists():
|
|
79
|
+
self.logger.warning(f"Failed to fetch {name}, using cache: {e}")
|
|
80
|
+
return cache_file.read_text(encoding="utf-8")
|
|
81
|
+
raise RuntimeError(f"Failed to fetch {name} and no cache available: {e}")
|
|
82
|
+
|
|
83
|
+
def generate(
|
|
84
|
+
self,
|
|
85
|
+
templates: List[str],
|
|
86
|
+
output_file: str = ".gitignore",
|
|
87
|
+
append: bool = True,
|
|
88
|
+
offline: bool = False,
|
|
89
|
+
):
|
|
90
|
+
"""Generates the .gitignore file."""
|
|
91
|
+
content = ""
|
|
92
|
+
for name in templates:
|
|
93
|
+
try:
|
|
94
|
+
tpl_content = self.get_template_content(name, offline=offline)
|
|
95
|
+
content += f"\n### {name} ###\n{tpl_content}\n"
|
|
96
|
+
except Exception as e:
|
|
97
|
+
self.logger.error(str(e))
|
|
98
|
+
print(f" Error fetching {name}: {e}")
|
|
99
|
+
|
|
100
|
+
if not content:
|
|
101
|
+
return
|
|
102
|
+
|
|
103
|
+
mode = "a" if append else "w"
|
|
104
|
+
path = Path(output_file)
|
|
105
|
+
|
|
106
|
+
if path.exists() and append:
|
|
107
|
+
print(f"Appending to {output_file}...")
|
|
108
|
+
elif path.exists() and not append:
|
|
109
|
+
print(f"Overwriting {output_file}...")
|
|
110
|
+
else:
|
|
111
|
+
print(f"Creating {output_file}...")
|
|
112
|
+
|
|
113
|
+
with path.open(mode, encoding="utf-8") as f:
|
|
114
|
+
f.write(content)
|
|
115
|
+
|
|
116
|
+
print(f" Successfully wrote to {output_file}")
|