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
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Dict, List, Optional
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class LicenseGenerator:
|
|
8
|
+
"""Generates license files from templates."""
|
|
9
|
+
|
|
10
|
+
def __init__(self):
|
|
11
|
+
self.templates_dir = (
|
|
12
|
+
Path(__file__).parent.parent / "prompt_templates" / "licenses"
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
def list_licenses(self) -> List[Dict[str, str]]:
|
|
16
|
+
"""Lists available license templates."""
|
|
17
|
+
licenses = []
|
|
18
|
+
if not self.templates_dir.exists():
|
|
19
|
+
return []
|
|
20
|
+
|
|
21
|
+
for file_path in self.templates_dir.glob("*.json"):
|
|
22
|
+
try:
|
|
23
|
+
with file_path.open("r", encoding="utf-8") as f:
|
|
24
|
+
data = json.load(f)
|
|
25
|
+
licenses.append(
|
|
26
|
+
{
|
|
27
|
+
"key": data.get("key", file_path.stem),
|
|
28
|
+
"name": data.get("name", "Unknown License"),
|
|
29
|
+
"description": data.get("description", ""),
|
|
30
|
+
}
|
|
31
|
+
)
|
|
32
|
+
except Exception:
|
|
33
|
+
continue
|
|
34
|
+
|
|
35
|
+
# Sort by name
|
|
36
|
+
return sorted(licenses, key=lambda x: x["name"])
|
|
37
|
+
|
|
38
|
+
def get_license_template(self, key: str) -> Optional[Dict]:
|
|
39
|
+
"""Loads a specific license template by key."""
|
|
40
|
+
# We assume key matches filename for simplicity, or we search.
|
|
41
|
+
# Based on file listing, filenames match keys (e.g. mit.json -> mit)
|
|
42
|
+
file_path = self.templates_dir / f"{key}.json"
|
|
43
|
+
if not file_path.exists():
|
|
44
|
+
return None
|
|
45
|
+
|
|
46
|
+
try:
|
|
47
|
+
with file_path.open("r", encoding="utf-8") as f:
|
|
48
|
+
return json.load(f)
|
|
49
|
+
except Exception:
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
def render_license(self, key: str, author: str, year: str = "") -> str:
|
|
53
|
+
"""Renders the license content with placeholders replaced."""
|
|
54
|
+
template_data = self.get_license_template(key)
|
|
55
|
+
if not template_data:
|
|
56
|
+
raise ValueError(f"License template '{key}' not found.")
|
|
57
|
+
|
|
58
|
+
content = template_data.get("template", "")
|
|
59
|
+
|
|
60
|
+
if not year:
|
|
61
|
+
year = str(datetime.now().year)
|
|
62
|
+
|
|
63
|
+
# Replace placeholders
|
|
64
|
+
# Support various formats found in templates
|
|
65
|
+
year_placeholders = ["[year]", "[yyyy]", "{{year}}", "<year>"]
|
|
66
|
+
author_placeholders = [
|
|
67
|
+
"[fullname]",
|
|
68
|
+
"[name of copyright owner]",
|
|
69
|
+
"{{author}}",
|
|
70
|
+
"<name of author>",
|
|
71
|
+
"[author]",
|
|
72
|
+
]
|
|
73
|
+
|
|
74
|
+
for p in year_placeholders:
|
|
75
|
+
content = content.replace(p, year)
|
|
76
|
+
|
|
77
|
+
for p in author_placeholders:
|
|
78
|
+
content = content.replace(p, author)
|
|
79
|
+
|
|
80
|
+
return content
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from devgen.modules.changelog_generator import ChangelogGenerator
|
|
5
|
+
from devgen.utils import configure_logger
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ReleaseNotesGenerator(ChangelogGenerator):
|
|
9
|
+
"""Generate short, clean release notes from git history."""
|
|
10
|
+
|
|
11
|
+
SECTION_EMOJIS = {
|
|
12
|
+
"BREAKING CHANGES": "⚠️",
|
|
13
|
+
"Features": "✨",
|
|
14
|
+
"Bug Fixes": "🐛",
|
|
15
|
+
"Documentation": "📚",
|
|
16
|
+
"Refactor": "♻️",
|
|
17
|
+
"Tests": "✅",
|
|
18
|
+
"Chore": "🔧",
|
|
19
|
+
"Style": "✨",
|
|
20
|
+
"Other Changes": "🧹",
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
def __init__(self, logger=None):
|
|
24
|
+
super().__init__(logger or configure_logger("devgen.releasenotes"))
|
|
25
|
+
|
|
26
|
+
def generate_release_markdown(self, groups, version="Unreleased"):
|
|
27
|
+
"""Generate human-friendly release notes."""
|
|
28
|
+
date_str = datetime.now().strftime("%Y-%m-%d")
|
|
29
|
+
md = [f"## 🚀 Release {version} — {date_str}\n"]
|
|
30
|
+
|
|
31
|
+
order = [
|
|
32
|
+
"BREAKING CHANGES",
|
|
33
|
+
"Features",
|
|
34
|
+
"Bug Fixes",
|
|
35
|
+
"Documentation",
|
|
36
|
+
"Other Changes",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
for section in order:
|
|
40
|
+
commits = groups.get(section)
|
|
41
|
+
if not commits:
|
|
42
|
+
continue
|
|
43
|
+
|
|
44
|
+
emoji = self.SECTION_EMOJIS.get(section, "")
|
|
45
|
+
md.append(f"### {emoji} {section}")
|
|
46
|
+
|
|
47
|
+
for c in commits:
|
|
48
|
+
scope = f"**{c['scope']}**: " if c["scope"] else ""
|
|
49
|
+
md.append(f"- {scope}{c['subject']}")
|
|
50
|
+
|
|
51
|
+
md.append("")
|
|
52
|
+
|
|
53
|
+
return "\n".join(md)
|
|
54
|
+
|
|
55
|
+
def run(self, output_file="RELEASE-NOTES.md", version="Unreleased", from_ref=""):
|
|
56
|
+
"""Main logic: get commits → parse → generate → write."""
|
|
57
|
+
raw_commits = self.get_commits(from_ref)
|
|
58
|
+
if not raw_commits or not raw_commits[0]:
|
|
59
|
+
print("❗ No commits found for release notes.")
|
|
60
|
+
return
|
|
61
|
+
|
|
62
|
+
parsed = self.parse_commits(raw_commits)
|
|
63
|
+
md = self.generate_release_markdown(parsed, version=version)
|
|
64
|
+
|
|
65
|
+
Path(output_file).write_text(md, encoding="utf-8")
|
|
66
|
+
print(f" Release notes written to {output_file}")
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from devgen.providers.anthropic import AnthropicProvider
|
|
2
|
+
from devgen.providers.gemini import GeminiProvider
|
|
3
|
+
from devgen.providers.huggingface import HuggingfaceProvider
|
|
4
|
+
from devgen.providers.openai import OpenaiProvider
|
|
5
|
+
from devgen.providers.openrouter import OpenrouterProvider
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def get_provider(name):
|
|
9
|
+
name_lower = name.lower()
|
|
10
|
+
if name_lower == "gemini":
|
|
11
|
+
return GeminiProvider()
|
|
12
|
+
elif name_lower == "openai":
|
|
13
|
+
return OpenaiProvider()
|
|
14
|
+
elif name_lower == "huggingface":
|
|
15
|
+
return HuggingfaceProvider()
|
|
16
|
+
elif name_lower == "openrouter":
|
|
17
|
+
return OpenrouterProvider()
|
|
18
|
+
elif name_lower == "anthropic":
|
|
19
|
+
return AnthropicProvider()
|
|
20
|
+
|
|
21
|
+
raise NotImplementedError(f"Provider '{name}' is not implemented yet.")
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import anthropic
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class AnthropicProvider:
|
|
5
|
+
"""Generates content using Anthropic's Claude models."""
|
|
6
|
+
|
|
7
|
+
def generate(
|
|
8
|
+
self, prompt: str, api_key: str, model: str = "claude-3-opus-20240229", **kwargs
|
|
9
|
+
) -> str:
|
|
10
|
+
"""Generates a response using the Anthropic API."""
|
|
11
|
+
if not api_key:
|
|
12
|
+
raise ValueError("Anthropic API key is required.")
|
|
13
|
+
|
|
14
|
+
try:
|
|
15
|
+
client = anthropic.Anthropic(api_key=api_key)
|
|
16
|
+
message = client.messages.create(
|
|
17
|
+
model=model,
|
|
18
|
+
max_tokens=1024,
|
|
19
|
+
messages=[{"role": "user", "content": prompt}],
|
|
20
|
+
)
|
|
21
|
+
return message.content[0].text.strip()
|
|
22
|
+
except Exception as e:
|
|
23
|
+
raise RuntimeError(f"Anthropic generation failed: {e}")
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import google.generativeai as genai
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class GeminiProvider:
|
|
5
|
+
"""Generates content using Google's Gemini models."""
|
|
6
|
+
|
|
7
|
+
def generate(
|
|
8
|
+
self, prompt: str, api_key: str, model: str = "gemini-pro", **kwargs
|
|
9
|
+
) -> str:
|
|
10
|
+
"""Generates a response using the Gemini API."""
|
|
11
|
+
if not api_key:
|
|
12
|
+
raise ValueError("Gemini API key is required.")
|
|
13
|
+
|
|
14
|
+
genai.configure(api_key=api_key)
|
|
15
|
+
|
|
16
|
+
# Handle model name mapping if needed, or trust user input
|
|
17
|
+
# gemini-pro is a common default
|
|
18
|
+
|
|
19
|
+
try:
|
|
20
|
+
model_instance = genai.GenerativeModel(model)
|
|
21
|
+
response = model_instance.generate_content(prompt)
|
|
22
|
+
return response.text.strip()
|
|
23
|
+
except Exception as e:
|
|
24
|
+
raise RuntimeError(f"Gemini generation failed: {e}")
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import requests
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class HuggingfaceProvider:
|
|
5
|
+
"""Generates content using Hugging Face Inference API."""
|
|
6
|
+
|
|
7
|
+
API_URL_TEMPLATE = "https://api-inference.huggingface.co/models/{model}"
|
|
8
|
+
|
|
9
|
+
def generate(
|
|
10
|
+
self,
|
|
11
|
+
prompt: str,
|
|
12
|
+
api_key: str,
|
|
13
|
+
model: str = "mistralai/Mistral-7B-Instruct-v0.2",
|
|
14
|
+
**kwargs,
|
|
15
|
+
) -> str:
|
|
16
|
+
"""Generates a response using Hugging Face API."""
|
|
17
|
+
if not api_key:
|
|
18
|
+
raise ValueError("Hugging Face API token is required.")
|
|
19
|
+
|
|
20
|
+
api_url = self.API_URL_TEMPLATE.format(model=model)
|
|
21
|
+
headers = {"Authorization": f"Bearer {api_key}"}
|
|
22
|
+
|
|
23
|
+
# HF models often expect specific prompting formats, but we'll send raw prompt
|
|
24
|
+
# Some models are text-generation, some are conversational.
|
|
25
|
+
# Assuming text-generation for generic usage.
|
|
26
|
+
|
|
27
|
+
payload = {
|
|
28
|
+
"inputs": prompt,
|
|
29
|
+
"parameters": {"max_new_tokens": 500, "return_full_text": False},
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
try:
|
|
33
|
+
response = requests.post(api_url, headers=headers, json=payload)
|
|
34
|
+
response.raise_for_status()
|
|
35
|
+
result = response.json()
|
|
36
|
+
|
|
37
|
+
if isinstance(result, list) and "generated_text" in result[0]:
|
|
38
|
+
return result[0]["generated_text"].strip()
|
|
39
|
+
elif isinstance(result, dict) and "error" in result:
|
|
40
|
+
raise RuntimeError(f"Hugging Face API error: {result['error']}")
|
|
41
|
+
else:
|
|
42
|
+
return str(result)
|
|
43
|
+
|
|
44
|
+
except Exception as e:
|
|
45
|
+
raise RuntimeError(f"Hugging Face generation failed: {e}")
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
from openai import OpenAI
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class OpenaiProvider:
|
|
5
|
+
"""Generates a response string using the OpenAI ChatCompletion API based on the provided prompt and parameters. This method initializes an OpenAI client with the given API key, sends a chat completion request with specified model and additional parameters, and returns the content of the generated message.
|
|
6
|
+
|
|
7
|
+
Args:
|
|
8
|
+
prompt (str): The input prompt to generate a response for.
|
|
9
|
+
api_key (str): The API key used to authenticate with the OpenAI service.
|
|
10
|
+
model (str, optional): The model to use for generation; defaults to "gpt-4o".
|
|
11
|
+
**kwargs: Additional keyword arguments to customize the API request (e.g., temperature).
|
|
12
|
+
|
|
13
|
+
Returns:
|
|
14
|
+
str: The content of the generated response message.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
DEFAULT_MODEL = "gpt-4o"
|
|
18
|
+
|
|
19
|
+
def generate(
|
|
20
|
+
self, prompt: str, api_key: str, model: str | None = None, **kwargs
|
|
21
|
+
) -> str:
|
|
22
|
+
"""Generates a response from the OpenAI ChatCompletion API based on the provided prompt and parameters.
|
|
23
|
+
|
|
24
|
+
Creates a client instance with the specified API key, sends a chat completion request using the selected model and additional parameters, and returns the generated message content as a string.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
prompt (str): Prompt input.
|
|
28
|
+
api_key (str): OpenAI API key.
|
|
29
|
+
model (str, optional): Model to use (default: gpt-4o).
|
|
30
|
+
**kwargs: Additional OpenAI ChatCompletion parameters (e.g., temperature).
|
|
31
|
+
|
|
32
|
+
Returns:
|
|
33
|
+
str: Generated response content.
|
|
34
|
+
"""
|
|
35
|
+
# 1. Create a client instance with the API key.
|
|
36
|
+
try:
|
|
37
|
+
client = OpenAI(api_key=api_key)
|
|
38
|
+
except Exception as e:
|
|
39
|
+
# Add error handling if the client fails to initialize
|
|
40
|
+
raise RuntimeError(f"Failed to initialize OpenAI client: {e}")
|
|
41
|
+
|
|
42
|
+
# 2. Use the modern API syntax: client.chat.completions.create
|
|
43
|
+
response = client.chat.completions.create(
|
|
44
|
+
model=model or self.DEFAULT_MODEL,
|
|
45
|
+
messages=[{"role": "user", "content": prompt}],
|
|
46
|
+
**kwargs,
|
|
47
|
+
)
|
|
48
|
+
return response.choices[0].message.content.strip()
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from openai import OpenAI
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class OpenrouterProvider:
|
|
5
|
+
"""Generates content using OpenRouter (OpenAI-compatible API)."""
|
|
6
|
+
|
|
7
|
+
BASE_URL = "https://openrouter.ai/api/v1"
|
|
8
|
+
|
|
9
|
+
def generate(
|
|
10
|
+
self, prompt: str, api_key: str, model: str = "openai/gpt-3.5-turbo", **kwargs
|
|
11
|
+
) -> str:
|
|
12
|
+
"""Generates a response using OpenRouter."""
|
|
13
|
+
if not api_key:
|
|
14
|
+
raise ValueError("OpenRouter API key is required.")
|
|
15
|
+
|
|
16
|
+
try:
|
|
17
|
+
client = OpenAI(
|
|
18
|
+
base_url=self.BASE_URL,
|
|
19
|
+
api_key=api_key,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
response = client.chat.completions.create(
|
|
23
|
+
model=model,
|
|
24
|
+
messages=[{"role": "user", "content": prompt}],
|
|
25
|
+
extra_headers={
|
|
26
|
+
"HTTP-Referer": "https://github.com/S4NKALP/devgen", # Optional
|
|
27
|
+
"X-Title": "devgen CLI", # Optional
|
|
28
|
+
},
|
|
29
|
+
**kwargs,
|
|
30
|
+
)
|
|
31
|
+
return response.choices[0].message.content.strip()
|
|
32
|
+
except Exception as e:
|
|
33
|
+
raise RuntimeError(f"OpenRouter generation failed: {e}")
|
devgen/utils.py
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import re
|
|
3
|
+
import subprocess
|
|
4
|
+
import time
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, Dict, Optional
|
|
7
|
+
|
|
8
|
+
import yaml
|
|
9
|
+
from jinja2 import Environment, FileSystemLoader
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def ensure_log_directory() -> Path:
|
|
13
|
+
log_dir = Path.home() / ".cache" / "devgen"
|
|
14
|
+
log_dir.mkdir(parents=True, exist_ok=True)
|
|
15
|
+
return log_dir
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def get_main_log_path() -> Path:
|
|
19
|
+
return ensure_log_directory() / "devgen.log"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def get_commit_dry_run_path() -> Path:
|
|
23
|
+
return ensure_log_directory() / "commit_dry_run.md"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def is_file_recent(file_path: Path | str, max_age_minutes: int = 120) -> bool:
|
|
27
|
+
path = Path(file_path)
|
|
28
|
+
if not path.exists():
|
|
29
|
+
return False
|
|
30
|
+
return (time.time() - path.stat().st_mtime) <= max_age_minutes * 60
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def sanitize_ai_commit_message(raw_text: str) -> str:
|
|
34
|
+
lines = raw_text.strip().split("\n")
|
|
35
|
+
cleaned_lines = []
|
|
36
|
+
in_block = False
|
|
37
|
+
# Regex for conventional commit header
|
|
38
|
+
header_pattern = re.compile(
|
|
39
|
+
r"^(feat|fix|chore|refactor|docs|style|test|build|ci)(\(.*\))?!?: .*"
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
for line in lines:
|
|
43
|
+
stripped = line.strip()
|
|
44
|
+
if in_block:
|
|
45
|
+
if header_pattern.match(stripped) or "**Sponsor**" in line:
|
|
46
|
+
break
|
|
47
|
+
cleaned_lines.append(line)
|
|
48
|
+
elif header_pattern.match(stripped):
|
|
49
|
+
in_block = True
|
|
50
|
+
cleaned_lines.append(line)
|
|
51
|
+
|
|
52
|
+
return "\n".join(cleaned_lines).strip() if cleaned_lines else ""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def parse_markdown_sections(
|
|
56
|
+
filepath: Path | str, marker_pattern: str
|
|
57
|
+
) -> dict[str, str]:
|
|
58
|
+
path = Path(filepath)
|
|
59
|
+
if not path.exists():
|
|
60
|
+
return {}
|
|
61
|
+
|
|
62
|
+
with path.open(encoding="utf-8") as f:
|
|
63
|
+
content = f.read()
|
|
64
|
+
|
|
65
|
+
results = {}
|
|
66
|
+
matches = re.findall(marker_pattern, content, re.DOTALL)
|
|
67
|
+
for key, value in matches:
|
|
68
|
+
results[key] = value.strip()
|
|
69
|
+
return results
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def extract_commit_messages(filepath: Path | str) -> dict[str, str]:
|
|
73
|
+
pattern = r"## Group: `(.*?)`\s*```md\n(.*?)\n```"
|
|
74
|
+
return parse_markdown_sections(filepath, pattern)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def configure_logger(
|
|
78
|
+
name: str = "devgen", log_file: Optional[Path | str] = None
|
|
79
|
+
) -> logging.Logger:
|
|
80
|
+
logger = logging.getLogger(name)
|
|
81
|
+
logger.setLevel(logging.INFO)
|
|
82
|
+
|
|
83
|
+
if logger.hasHandlers():
|
|
84
|
+
logger.handlers.clear()
|
|
85
|
+
|
|
86
|
+
formatter = logging.Formatter(
|
|
87
|
+
"%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
# Console handler
|
|
91
|
+
ch = logging.StreamHandler()
|
|
92
|
+
ch.setFormatter(formatter)
|
|
93
|
+
logger.addHandler(ch)
|
|
94
|
+
|
|
95
|
+
# File handler
|
|
96
|
+
if log_file:
|
|
97
|
+
path = Path(log_file)
|
|
98
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
99
|
+
fh = logging.FileHandler(path, mode="w", encoding="utf-8")
|
|
100
|
+
fh.setFormatter(formatter)
|
|
101
|
+
logger.addHandler(fh)
|
|
102
|
+
|
|
103
|
+
return logger
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def get_git_staged_files() -> list[str]:
|
|
107
|
+
try:
|
|
108
|
+
res = subprocess.run(
|
|
109
|
+
["git", "diff", "--name-only", "--cached"],
|
|
110
|
+
capture_output=True,
|
|
111
|
+
text=True,
|
|
112
|
+
check=True,
|
|
113
|
+
)
|
|
114
|
+
return [f for f in res.stdout.splitlines() if f.strip()]
|
|
115
|
+
except subprocess.CalledProcessError:
|
|
116
|
+
return []
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def read_file_content(filepath: Path | str) -> Optional[str]:
|
|
120
|
+
path = Path(filepath)
|
|
121
|
+
if path.exists():
|
|
122
|
+
return path.read_text(encoding="utf-8")
|
|
123
|
+
return None
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def delete_file(filepath: Path | str) -> bool:
|
|
127
|
+
path = Path(filepath)
|
|
128
|
+
if path.exists():
|
|
129
|
+
path.unlink()
|
|
130
|
+
return True
|
|
131
|
+
return False
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def load_template_env(sub_dir: str) -> Environment:
|
|
135
|
+
template_dir = Path(__file__).parent / "prompt_templates" / sub_dir
|
|
136
|
+
return Environment(loader=FileSystemLoader(template_dir))
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def load_config() -> Dict[str, Any]:
|
|
140
|
+
config_path = Path.home() / ".devgen.yaml"
|
|
141
|
+
|
|
142
|
+
if not config_path.exists():
|
|
143
|
+
default_config = {
|
|
144
|
+
"provider": "gemini",
|
|
145
|
+
"model": "gemini-2.5-flash",
|
|
146
|
+
"api_key": "",
|
|
147
|
+
"emoji": True,
|
|
148
|
+
}
|
|
149
|
+
try:
|
|
150
|
+
with config_path.open("w", encoding="utf-8") as f:
|
|
151
|
+
yaml.dump(default_config, f, default_flow_style=False)
|
|
152
|
+
# We don't print here to avoid noise during normal execution
|
|
153
|
+
except Exception as e:
|
|
154
|
+
print(f"Warning: Failed to create default config at {config_path}: {e}")
|
|
155
|
+
return {}
|
|
156
|
+
|
|
157
|
+
try:
|
|
158
|
+
with config_path.open("r", encoding="utf-8") as f:
|
|
159
|
+
return yaml.safe_load(f) or {}
|
|
160
|
+
except Exception as e:
|
|
161
|
+
print(f"Warning: Failed to load config from {config_path}: {e}")
|
|
162
|
+
return {}
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
__all__ = [
|
|
166
|
+
"ensure_log_directory",
|
|
167
|
+
"get_main_log_path",
|
|
168
|
+
"get_commit_dry_run_path",
|
|
169
|
+
"is_file_recent",
|
|
170
|
+
"sanitize_ai_commit_message",
|
|
171
|
+
"extract_commit_messages",
|
|
172
|
+
"configure_logger",
|
|
173
|
+
"get_git_staged_files",
|
|
174
|
+
"read_file_content",
|
|
175
|
+
"delete_file",
|
|
176
|
+
"load_template_env",
|
|
177
|
+
"load_config",
|
|
178
|
+
"get_questionary_style",
|
|
179
|
+
]
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def get_questionary_style():
|
|
183
|
+
from questionary import Style
|
|
184
|
+
|
|
185
|
+
return Style(
|
|
186
|
+
[
|
|
187
|
+
("qmark", "fg:#673ab7 bold"), # Token.QuestionMark
|
|
188
|
+
("question", "bold"), # Token.Question
|
|
189
|
+
("answer", "fg:#f44336 bold"), # Token.Answer
|
|
190
|
+
("pointer", "fg:#673ab7 bold"), # Token.Pointer
|
|
191
|
+
("highlighted", "fg:#673ab7 bold"), # Token.Selected
|
|
192
|
+
("selected", "fg:#cc5454"), # Token.SelectedItem
|
|
193
|
+
("separator", "fg:#cc5454"), # Token.Separator
|
|
194
|
+
("instruction", ""), # Token.Instruction
|
|
195
|
+
("text", ""), # Token.Text
|
|
196
|
+
("disabled", "fg:#858585 italic"), # Token.Disabled
|
|
197
|
+
]
|
|
198
|
+
)
|