Compare commits
38
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b03c13ae62 | ||
|
|
e69de1d99a | ||
|
|
e5f22bca85 | ||
|
|
277f197a71 | ||
|
|
2edbf95ed5 | ||
|
|
109fcd584f | ||
|
|
61e51b5187 | ||
|
|
d4b694e77d | ||
|
|
261d04c56f | ||
|
|
f7001d4243 | ||
|
|
11a9db7986 | ||
|
|
34f5d090ce | ||
|
|
d20a31bf38 | ||
|
|
a43e09e924 | ||
|
|
fca0e34422 | ||
|
|
c4992cce57 | ||
|
|
957dda9d49 | ||
|
|
869aa37f0c | ||
|
|
51ede1a7da | ||
|
|
ede489c219 | ||
|
|
a56fe0a6ee | ||
|
|
e13e763cf2 | ||
|
|
79294f1df0 | ||
|
|
187ad00e99 | ||
|
|
35e02ddf7d | ||
|
|
93f09c9d74 | ||
|
|
ab9527df33 | ||
|
|
186d952fda | ||
|
|
deba25b055 | ||
|
|
9e6c846790 | ||
|
|
f007c7c579 | ||
|
|
363b8ec398 | ||
|
|
0c5ec5da0a | ||
|
|
4f679bc90d | ||
|
|
f21b090b56 | ||
|
|
8c63406126 | ||
|
|
91500f634a | ||
|
|
cb3a1662d9 |
No files matched your search
@@ -14,7 +14,7 @@ jobs:
|
||||
name: Lint & format
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
@@ -42,7 +42,7 @@ jobs:
|
||||
matrix:
|
||||
python-version: ["3.10", "3.11", "3.12", "3.13"]
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
|
||||
@@ -23,7 +23,7 @@ jobs:
|
||||
language: [python]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
@@ -38,12 +38,12 @@ jobs:
|
||||
run: uv sync --all-extras --dev
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v3
|
||||
uses: github/codeql-action/init@v4
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
queries: security-and-quality
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v3
|
||||
uses: github/codeql-action/analyze@v4
|
||||
with:
|
||||
category: "/language:${{ matrix.language }}"
|
||||
@@ -19,4 +19,5 @@ jobs:
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
target: minor
|
||||
use-github-auto-merge: true
|
||||
merge-method: squash
|
||||
@@ -13,15 +13,14 @@ jobs:
|
||||
name: Audit dependencies
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Dependency Review
|
||||
uses: actions/dependency-review-action@v4
|
||||
uses: actions/dependency-review-action@v5
|
||||
with:
|
||||
fail-on-severity: high
|
||||
fail-on-scopes: changes
|
||||
fail-on-scopes: runtime
|
||||
license-check: true
|
||||
# Project is GPL-3.0-or-later; flag licenses that would taint it.
|
||||
deny-licenses: AGPL-1.0-only, AGPL-1.0-or-later, AGPL-3.0-only, AGPL-3.0-or-later, SSPL-1.0, BUSL-1.1
|
||||
comment-summary-in-pr: always
|
||||
vuln-check-non-vulnerable-refs: true
|
||||
@@ -6,7 +6,7 @@ jobs:
|
||||
pre-commit:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
|
||||
@@ -12,9 +12,9 @@ jobs:
|
||||
permissions:
|
||||
id-token: write # IMPORTANT: this permission is mandatory for trusted publishing
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: '3.10'
|
||||
cache: 'pip'
|
||||
|
||||
@@ -120,7 +120,7 @@ def validate() -> None:
|
||||
for f in staged:
|
||||
typer.echo(f"- {f}")
|
||||
else:
|
||||
typer.secho("[i] No staged files.", fg=typer.colors.RED)
|
||||
typer.secho("[i] No staged files.", fg=typer.colors.YELLOW)
|
||||
|
||||
|
||||
@app.command("undo")
|
||||
|
||||
@@ -48,6 +48,12 @@ def edit_config(
|
||||
|
||||
if not key:
|
||||
choices = list(config.keys())
|
||||
if not choices:
|
||||
typer.secho(
|
||||
"No configuration keys found. Run `devgen setup config` first.",
|
||||
fg=typer.colors.YELLOW,
|
||||
)
|
||||
raise typer.Exit()
|
||||
key = questionary.select(
|
||||
"Select key to update:", choices=choices, style=style
|
||||
).ask()
|
||||
|
||||
+10
-7
@@ -86,13 +86,16 @@ def setup_config() -> None:
|
||||
ollama_host = ollama_host_input.strip() or ollama_host
|
||||
|
||||
# Save Config — merge with existing to preserve extra keys
|
||||
new_config = {**current_config, **{
|
||||
"provider": provider,
|
||||
"model": model,
|
||||
"api_key": api_key,
|
||||
"emoji": emoji,
|
||||
"ollama_host": ollama_host,
|
||||
}}
|
||||
new_config = {
|
||||
**current_config,
|
||||
**{
|
||||
"provider": provider,
|
||||
"model": model,
|
||||
"api_key": api_key,
|
||||
"emoji": emoji,
|
||||
"ollama_host": ollama_host,
|
||||
},
|
||||
}
|
||||
|
||||
try:
|
||||
with config_path.open("w", encoding="utf-8") as f:
|
||||
|
||||
@@ -32,17 +32,21 @@ class ChangelogGenerator:
|
||||
else:
|
||||
cmd = self._resolve_range(to_ref)
|
||||
try:
|
||||
return run_git_command(cmd).split("\n")
|
||||
output = run_git_command(cmd)
|
||||
# Use %x00 as separator to avoid splitting on newlines in commit bodies
|
||||
# Then filter out empty strings
|
||||
return [c for c in output.split("\0") if c.strip()]
|
||||
except subprocess.CalledProcessError as e:
|
||||
self.logger.error(f"Git command failed: {e}")
|
||||
raise RuntimeError(f"Git command failed: {e}") from e
|
||||
|
||||
def _log_cmd(self, range_spec: str) -> List[str]:
|
||||
# Format: hash|author|date|subject|body
|
||||
# Format: hash|author|date|subject|body (separated by %x00 to avoid
|
||||
# splitting on newlines within commit bodies)
|
||||
return [
|
||||
"git",
|
||||
"log",
|
||||
"--format=%H|%an|%ad|%s|%b",
|
||||
"--format=%H|%an|%ad|%s|%b%x00",
|
||||
"--date=short",
|
||||
range_spec,
|
||||
]
|
||||
@@ -54,7 +58,7 @@ class ChangelogGenerator:
|
||||
return self._log_cmd(f"{last_tag}..HEAD")
|
||||
except (RuntimeError, subprocess.CalledProcessError):
|
||||
self.logger.info("No tags found, generating for all commits.")
|
||||
return ["git", "log", "--format=%H|%an|%ad|%s|%b", "--date=short"]
|
||||
return ["git", "log", "--format=%H|%an|%ad|%s|%b%x00", "--date=short"]
|
||||
|
||||
# ----------------------------------------------------------------- parsing
|
||||
|
||||
|
||||
@@ -33,8 +33,10 @@ from devgen.utils import (
|
||||
extract_commit_messages,
|
||||
get_commit_dry_run_path,
|
||||
is_file_recent,
|
||||
load_template_env,
|
||||
render_custom_template,
|
||||
is_token_limit_error,
|
||||
format_token_limit_error,
|
||||
load_template,
|
||||
render_template,
|
||||
sanitize_ai_commit_message,
|
||||
)
|
||||
|
||||
@@ -74,13 +76,13 @@ class CommitMessageBuilder:
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
template_env,
|
||||
template_str: str,
|
||||
config: Dict[str, Any],
|
||||
provider: str,
|
||||
model: str,
|
||||
debug: bool = False,
|
||||
) -> None:
|
||||
self.template_env = template_env
|
||||
self.template_str = template_str
|
||||
self.config = config
|
||||
self.provider = provider
|
||||
self.model = model
|
||||
@@ -97,25 +99,14 @@ class CommitMessageBuilder:
|
||||
use_emoji = self.config.get("emoji", True)
|
||||
ollama_host = self.config.get("ollama_host")
|
||||
|
||||
if custom_template:
|
||||
prompt = render_custom_template(
|
||||
custom_template,
|
||||
group_name=group,
|
||||
diff_text=diff,
|
||||
use_emoji=use_emoji,
|
||||
context=manifest_context,
|
||||
)
|
||||
else:
|
||||
prompt = (
|
||||
self.template_env.get_template("commit_message.tpl")
|
||||
.render(
|
||||
group_name=group,
|
||||
diff_text=diff,
|
||||
use_emoji=use_emoji,
|
||||
context=manifest_context,
|
||||
)
|
||||
.strip()
|
||||
)
|
||||
template = custom_template or self.template_str
|
||||
prompt = render_template(
|
||||
template,
|
||||
group_name=group,
|
||||
diff_text=diff,
|
||||
use_emoji=use_emoji,
|
||||
context=manifest_context,
|
||||
).strip()
|
||||
|
||||
provider = (
|
||||
extra_kwargs.get("provider") or self.config.get("provider") or self.provider
|
||||
@@ -198,10 +189,10 @@ class CommitEngine:
|
||||
),
|
||||
logger=self.logger,
|
||||
)
|
||||
self.template_env = load_template_env("commit")
|
||||
self.template_str = load_template("commit", "commit_message.tpl")
|
||||
self.cache = CacheManager(get_commit_dry_run_path())
|
||||
self.message_builder = CommitMessageBuilder(
|
||||
self.template_env,
|
||||
self.template_str,
|
||||
self.config,
|
||||
provider=self.provider,
|
||||
model=self.model,
|
||||
@@ -252,9 +243,10 @@ class CommitEngine:
|
||||
if not self._process_group(group, group_files, cache_map):
|
||||
failed.append(group)
|
||||
if len(groups) > 1 and i < len(groups) - 1:
|
||||
import time
|
||||
if self.provider != "ollama":
|
||||
import time
|
||||
|
||||
time.sleep(15) # Conservative 4 RPM guard for free-tier
|
||||
time.sleep(15) # Conservative 4 RPM guard for free-tier
|
||||
return failed
|
||||
|
||||
def _process_group(
|
||||
@@ -276,7 +268,7 @@ class CommitEngine:
|
||||
return self._apply_message(group, cache[group], files)
|
||||
|
||||
try:
|
||||
manifest_context = ManifestInspector.summary()
|
||||
manifest_context = ManifestInspector.summary(cwd=self.git.cwd)
|
||||
if manifest_context:
|
||||
self.logger.info("Including manifest context in prompt")
|
||||
with self.console.status(
|
||||
@@ -290,10 +282,15 @@ class CommitEngine:
|
||||
)
|
||||
message = sanitize_ai_commit_message(raw)
|
||||
except Exception as e:
|
||||
self.logger.error(
|
||||
f"Failed to generate message for group {group!r}: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
if is_token_limit_error(e):
|
||||
msg = format_token_limit_error(self.provider, e, group=group)
|
||||
self.console.print(f"[bold red]{msg}[/bold red]")
|
||||
self.logger.error(msg)
|
||||
else:
|
||||
self.logger.error(
|
||||
f"Failed to generate message for group {group!r}: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
self.git.reset(files)
|
||||
return False
|
||||
|
||||
@@ -388,6 +385,7 @@ def run_commit_engine(**kwargs: Any) -> None:
|
||||
sys.exit(0)
|
||||
except Exception as e:
|
||||
logger.error(f"Commit engine failed: {e}", exc_info=True)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
__all__ = ["CommitEngine", "CommitEngineError", "run_commit_engine"]
|
||||
@@ -14,7 +14,7 @@ from collections import defaultdict
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from devgen.utils import run_git_command
|
||||
from devgen.utils import get_git_root, run_git_command
|
||||
|
||||
|
||||
# Lock files and other metadata blobs whose full diff would just bloat the
|
||||
@@ -54,7 +54,7 @@ class DiffBuilder:
|
||||
logger=None,
|
||||
) -> None:
|
||||
self.max_size = max_size
|
||||
self.cwd = cwd
|
||||
self.cwd = cwd or get_git_root()
|
||||
self.logger = logger
|
||||
|
||||
def build(self, files: List[str]) -> str:
|
||||
@@ -129,10 +129,7 @@ class FileGrouper:
|
||||
parent_path = str(Path(deepest).parent)
|
||||
new_key = "root" if parent_path == "." else parent_path
|
||||
files = groups.pop(deepest)
|
||||
if new_key in groups:
|
||||
groups[new_key].extend(files)
|
||||
else:
|
||||
groups["root"].extend(files)
|
||||
groups.setdefault(new_key, []).extend(files)
|
||||
return groups
|
||||
|
||||
|
||||
@@ -146,13 +143,14 @@ class ManifestInspector:
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def summary() -> str:
|
||||
def summary(cwd: Optional[Path] = None) -> str:
|
||||
cwd = cwd or get_git_root() or Path(".")
|
||||
lines = [
|
||||
s
|
||||
for s in (
|
||||
ManifestInspector._pyproject(),
|
||||
ManifestInspector._package_json(),
|
||||
ManifestInspector._requirements(),
|
||||
ManifestInspector._pyproject(cwd),
|
||||
ManifestInspector._package_json(cwd),
|
||||
ManifestInspector._requirements(cwd),
|
||||
)
|
||||
if s
|
||||
]
|
||||
@@ -167,8 +165,8 @@ class ManifestInspector:
|
||||
return re.split(r"[><=!~\[]", spec, 1)[0].strip()
|
||||
|
||||
@staticmethod
|
||||
def _pyproject() -> Optional[str]:
|
||||
path = Path("pyproject.toml")
|
||||
def _pyproject(cwd: Path) -> Optional[str]:
|
||||
path = cwd / "pyproject.toml"
|
||||
if not path.exists():
|
||||
return None
|
||||
try:
|
||||
@@ -189,8 +187,8 @@ class ManifestInspector:
|
||||
return f"[pyproject.toml] {' '.join(parts)}" if parts else None
|
||||
|
||||
@staticmethod
|
||||
def _package_json() -> Optional[str]:
|
||||
path = Path("package.json")
|
||||
def _package_json(cwd: Path) -> Optional[str]:
|
||||
path = cwd / "package.json"
|
||||
if not path.exists():
|
||||
return None
|
||||
try:
|
||||
@@ -206,8 +204,8 @@ class ManifestInspector:
|
||||
return f"[package.json] {' '.join(parts)}" if parts else None
|
||||
|
||||
@staticmethod
|
||||
def _requirements() -> Optional[str]:
|
||||
path = Path("requirements.txt")
|
||||
def _requirements(cwd: Path) -> Optional[str]:
|
||||
path = cwd / "requirements.txt"
|
||||
if not path.exists():
|
||||
return None
|
||||
try:
|
||||
|
||||
@@ -10,7 +10,7 @@ import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from devgen.utils import configure_logger, run_git_command
|
||||
from devgen.utils import configure_logger, get_git_root, run_git_command
|
||||
|
||||
|
||||
class GitError(RuntimeError):
|
||||
@@ -26,7 +26,7 @@ class GitOperator:
|
||||
logger=None,
|
||||
quiet_commands: bool = True,
|
||||
) -> None:
|
||||
self.cwd = cwd
|
||||
self.cwd = cwd or get_git_root()
|
||||
self.logger = logger or configure_logger("devgen.git")
|
||||
self.quiet_commands = quiet_commands
|
||||
|
||||
@@ -91,15 +91,16 @@ class GitOperator:
|
||||
[
|
||||
"git",
|
||||
"ls-files",
|
||||
"-z",
|
||||
"--deleted",
|
||||
"--modified",
|
||||
"--others",
|
||||
"--exclude-standard",
|
||||
]
|
||||
)
|
||||
files = {f.strip() for f in out.split("\n") if f.strip()}
|
||||
staged_out = self._run(["git", "diff", "--name-only", "--cached"])
|
||||
files.update(f.strip() for f in staged_out.split("\n") if f.strip())
|
||||
files = {f for f in out.split("\0") if f}
|
||||
staged_out = self._run(["git", "diff", "-z", "--name-only", "--cached"])
|
||||
files.update(f for f in staged_out.split("\0") if f)
|
||||
return sorted(files)
|
||||
|
||||
# ------------------------------------------------------------------ actions
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
@@ -5,6 +6,9 @@ import requests
|
||||
|
||||
from devgen.utils import configure_logger
|
||||
|
||||
# Only allow safe characters in template names to prevent path traversal
|
||||
_TEMPLATE_NAME_RE = re.compile(r"^[a-zA-Z0-9._-]+$")
|
||||
|
||||
|
||||
class GitignoreGenerator:
|
||||
"""Fetches and manages .gitignore templates from GitHub."""
|
||||
@@ -41,6 +45,11 @@ class GitignoreGenerator:
|
||||
self, name: str, use_cache: bool = True, offline: bool = False
|
||||
) -> str:
|
||||
"""Fetches template content from cache or GitHub."""
|
||||
if not _TEMPLATE_NAME_RE.match(name):
|
||||
raise ValueError(
|
||||
f"Invalid template name '{name}'. "
|
||||
"Names must contain only alphanumeric characters, hyphens, dots, or underscores."
|
||||
)
|
||||
cache_file = self.cache_dir / f"{name}.gitignore"
|
||||
|
||||
if offline:
|
||||
|
||||
@@ -16,4 +16,6 @@ class AnthropicProvider(BaseProvider):
|
||||
max_tokens=1024,
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
)
|
||||
if not message.content:
|
||||
raise RuntimeError(f"Anthropic returned no content for model {model!r}.")
|
||||
return message.content[0].text or ""
|
||||
+43
-20
@@ -30,33 +30,56 @@ class BaseProvider(ABC):
|
||||
model: str | None = None,
|
||||
**kwargs: Any,
|
||||
) -> str:
|
||||
"""Public entry point. Validates input, dispatches, and wraps errors.
|
||||
"""Public entry point. Validates input, dispatches, and wraps errors."""
|
||||
import time
|
||||
|
||||
Args:
|
||||
prompt: The prompt to send to the model.
|
||||
api_key: Provider API key. May be ``None`` for local providers
|
||||
(see :attr:`REQUIRES_API_KEY`).
|
||||
model: Model id. Falls back to :attr:`DEFAULT_MODEL` when ``None``.
|
||||
**kwargs: Forwarded to :meth:`_generate`.
|
||||
|
||||
Returns:
|
||||
The generated text, stripped.
|
||||
|
||||
Raises:
|
||||
ValueError: If the provider requires an API key and none was given.
|
||||
RuntimeError: For any underlying failure, with a friendly message.
|
||||
"""
|
||||
if self.REQUIRES_API_KEY and not api_key:
|
||||
raise ValueError(
|
||||
f"{self.DISPLAY_NAME} API key is missing. "
|
||||
"Set it via `devgen setup config` or pass --api-key."
|
||||
)
|
||||
chosen_model = model or self.DEFAULT_MODEL
|
||||
try:
|
||||
text = self._generate(prompt, api_key, chosen_model, **kwargs)
|
||||
return (text or "").strip()
|
||||
except Exception as e:
|
||||
self._handle_error(e)
|
||||
|
||||
max_retries = kwargs.get("max_retries", 5)
|
||||
base_delay = kwargs.get("retry_delay", 10)
|
||||
|
||||
for attempt in range(max_retries + 1):
|
||||
try:
|
||||
text = self._generate(prompt, api_key, chosen_model, **kwargs)
|
||||
return (text or "").strip()
|
||||
except Exception as e:
|
||||
if is_token_limit_error(e):
|
||||
self._handle_error(e)
|
||||
if self._is_quota_error(e) and attempt < max_retries:
|
||||
delay = base_delay * (2**attempt)
|
||||
print(
|
||||
f"\n[{self.DISPLAY_NAME}] Quota/Rate limit hit (429). "
|
||||
f"Waiting {delay}s and retrying... "
|
||||
f"(Attempt {attempt + 1}/{max_retries})"
|
||||
)
|
||||
time.sleep(delay)
|
||||
continue
|
||||
if self._is_quota_error(e) and attempt >= max_retries:
|
||||
raise RuntimeError(
|
||||
f"{self.DISPLAY_NAME} API quota exhausted (429) after multiple retries."
|
||||
) from e
|
||||
self._handle_error(e)
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def _is_quota_error(error: Exception) -> bool:
|
||||
text = str(error).upper()
|
||||
return any(
|
||||
marker in text
|
||||
for marker in (
|
||||
"429",
|
||||
"RESOURCE_EXHAUSTED",
|
||||
"QUOTA_EXCEEDED",
|
||||
"THROTTLED",
|
||||
"RATE_LIMIT_EXCEEDED",
|
||||
"TOO_MANY_REQUESTS",
|
||||
)
|
||||
)
|
||||
|
||||
def _handle_error(self, error: Exception) -> None:
|
||||
"""Translate an internal exception into a user-facing RuntimeError.
|
||||
|
||||
+10
-48
@@ -1,10 +1,7 @@
|
||||
import time
|
||||
|
||||
from google import genai
|
||||
from google.genai import types
|
||||
|
||||
from devgen.providers.base import BaseProvider
|
||||
from devgen.utils import is_token_limit_error
|
||||
|
||||
|
||||
class GeminiProvider(BaseProvider):
|
||||
@@ -14,50 +11,15 @@ class GeminiProvider(BaseProvider):
|
||||
DEFAULT_MODEL = "gemini-2.5-flash"
|
||||
|
||||
def _generate(self, prompt, api_key, model, **kwargs):
|
||||
max_retries = kwargs.get("max_retries", 5)
|
||||
base_delay = kwargs.get("retry_delay", 10) # 10s base for 429 backoff
|
||||
client = genai.Client(api_key=api_key)
|
||||
|
||||
for attempt in range(max_retries + 1):
|
||||
try:
|
||||
response = client.models.generate_content(
|
||||
model=model,
|
||||
contents=prompt,
|
||||
config=types.GenerateContentConfig(
|
||||
temperature=kwargs.get("temperature", 0.7),
|
||||
top_p=kwargs.get("top_p", 0.95),
|
||||
top_k=kwargs.get("top_k", 40),
|
||||
max_output_tokens=kwargs.get("max_output_tokens", 2048),
|
||||
),
|
||||
)
|
||||
return response.text or ""
|
||||
except Exception as e:
|
||||
if is_token_limit_error(e):
|
||||
raise # base class handles with friendly message
|
||||
if self._is_quota_error(e) and attempt < max_retries:
|
||||
delay = base_delay * (2**attempt)
|
||||
print(
|
||||
f"\n[Gemini] Quota limit hit (429). This is often a temporary "
|
||||
f"rate limit (RPM). Waiting {delay}s and retrying... "
|
||||
f"(Attempt {attempt + 1}/{max_retries})"
|
||||
)
|
||||
time.sleep(delay)
|
||||
continue
|
||||
if self._is_quota_error(e):
|
||||
raise RuntimeError(
|
||||
"Gemini API quota exhausted (429) after multiple retries.\n\n"
|
||||
"POSSIBLE CAUSES:\n"
|
||||
"1. Rate Limit (RPM/RPD): You are sending requests too fast for the Free Tier.\n"
|
||||
"2. Daily Limit: You have reached the maximum free requests allowed for today (typically 1,500).\n"
|
||||
"3. Credit/Billing: If on a paid plan, your credit may be exhausted.\n\n"
|
||||
"Please check your usage metrics at: https://aistudio.google.com/app/usage"
|
||||
) from e
|
||||
raise
|
||||
|
||||
@staticmethod
|
||||
def _is_quota_error(error: Exception) -> bool:
|
||||
text = str(error).upper()
|
||||
return any(
|
||||
marker in text
|
||||
for marker in ("429", "RESOURCE_EXHAUSTED", "QUOTA_EXCEEDED", "THROTTLED")
|
||||
response = client.models.generate_content(
|
||||
model=model,
|
||||
contents=prompt,
|
||||
config=types.GenerateContentConfig(
|
||||
temperature=kwargs.get("temperature", 0.7),
|
||||
top_p=kwargs.get("top_p", 0.95),
|
||||
top_k=kwargs.get("top_k", 40),
|
||||
max_output_tokens=kwargs.get("max_output_tokens", 2048),
|
||||
),
|
||||
)
|
||||
return response.text or ""
|
||||
@@ -20,7 +20,9 @@ class HuggingfaceProvider(BaseProvider):
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.post(api_url, headers=headers, json=payload)
|
||||
response = requests.post(
|
||||
api_url, headers=headers, json=payload, timeout=120
|
||||
)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
except requests.HTTPError as e:
|
||||
|
||||
@@ -18,18 +18,22 @@ class OllamaProvider(BaseProvider):
|
||||
DEFAULT_HOST = "http://localhost:11434"
|
||||
DEFAULT_MODEL = "llama3.2"
|
||||
|
||||
# Keys consumed by devgen itself — never forwarded to the SDK.
|
||||
_DEVGEN_KEYS = frozenset({"debug", "ollama_host", "max_retries", "retry_delay"})
|
||||
|
||||
def _generate(self, prompt, api_key, model, **kwargs):
|
||||
host = kwargs.get("ollama_host") or self.DEFAULT_HOST
|
||||
base_url = f"{host.rstrip('/')}/v1"
|
||||
kwargs.pop("debug", None)
|
||||
kwargs.pop("ollama_host", None)
|
||||
|
||||
client = OpenAI(base_url=base_url, api_key=api_key or "ollama")
|
||||
response = client.chat.completions.create(
|
||||
model=model,
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
**kwargs,
|
||||
)
|
||||
# Only forward known OpenAI-compatible parameters
|
||||
safe_kwargs = {k: v for k, v in kwargs.items() if k not in self._DEVGEN_KEYS}
|
||||
|
||||
with OpenAI(base_url=base_url, api_key=api_key or "ollama") as client:
|
||||
response = client.chat.completions.create(
|
||||
model=model,
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
**safe_kwargs,
|
||||
)
|
||||
|
||||
if not response.choices:
|
||||
raise RuntimeError(
|
||||
|
||||
@@ -9,12 +9,17 @@ class OpenaiProvider(BaseProvider):
|
||||
DISPLAY_NAME = "OpenAI"
|
||||
DEFAULT_MODEL = "gpt-4o"
|
||||
|
||||
# Keys consumed by devgen itself — never forwarded to the SDK.
|
||||
_DEVGEN_KEYS = frozenset({"debug", "ollama_host", "max_retries", "retry_delay"})
|
||||
|
||||
def _generate(self, prompt, api_key, model, **kwargs):
|
||||
kwargs.pop("debug", None)
|
||||
client = OpenAI(api_key=api_key)
|
||||
response = client.chat.completions.create(
|
||||
model=model,
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
**kwargs,
|
||||
)
|
||||
safe_kwargs = {k: v for k, v in kwargs.items() if k not in self._DEVGEN_KEYS}
|
||||
with OpenAI(api_key=api_key) as client:
|
||||
response = client.chat.completions.create(
|
||||
model=model,
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
**safe_kwargs,
|
||||
)
|
||||
if not response.choices:
|
||||
raise RuntimeError(f"OpenAI returned no choices for model {model!r}.")
|
||||
return response.choices[0].message.content or ""
|
||||
@@ -10,16 +10,21 @@ class OpenrouterProvider(BaseProvider):
|
||||
BASE_URL = "https://openrouter.ai/api/v1"
|
||||
DEFAULT_MODEL = "openai/gpt-3.5-turbo"
|
||||
|
||||
# Keys consumed by devgen itself — never forwarded to the SDK.
|
||||
_DEVGEN_KEYS = frozenset({"debug", "ollama_host", "max_retries", "retry_delay"})
|
||||
|
||||
def _generate(self, prompt, api_key, model, **kwargs):
|
||||
kwargs.pop("debug", None)
|
||||
client = OpenAI(base_url=self.BASE_URL, api_key=api_key)
|
||||
response = client.chat.completions.create(
|
||||
model=model,
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
extra_headers={
|
||||
"HTTP-Referer": "https://github.com/S4NKALP/devgen",
|
||||
"X-Title": "devgen CLI",
|
||||
},
|
||||
**kwargs,
|
||||
)
|
||||
return response.choices[0].message.content
|
||||
safe_kwargs = {k: v for k, v in kwargs.items() if k not in self._DEVGEN_KEYS}
|
||||
with OpenAI(base_url=self.BASE_URL, api_key=api_key) as client:
|
||||
response = client.chat.completions.create(
|
||||
model=model,
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
extra_headers={
|
||||
"HTTP-Referer": "https://github.com/S4NKALP/devgen",
|
||||
"X-Title": "devgen CLI",
|
||||
},
|
||||
**safe_kwargs,
|
||||
)
|
||||
if not response.choices:
|
||||
raise RuntimeError(f"OpenRouter returned no choices for model {model!r}.")
|
||||
return response.choices[0].message.content or ""
|
||||
+21
-6
@@ -1,14 +1,29 @@
|
||||
You write Conventional Commits. Output ONLY the message — no preamble, no labels, no quotes, no markdown.
|
||||
|
||||
Format:
|
||||
<type>(<scope>): <summary>
|
||||
[optional 1-2 line body on WHY]
|
||||
<type>[optional scope][optional !]: <description>
|
||||
[optional blank line]
|
||||
[optional body]
|
||||
[optional blank line]
|
||||
[optional footer(s)]
|
||||
|
||||
types: feat fix refactor perf docs test build ci chore style
|
||||
scope: {{ group_name }}{% if use_emoji %} (prefix summary with one matching emoji){% endif %}
|
||||
scope: {% if group_name and group_name != 'root' %}({{ group_name }}){% else %}optional{% endif %}{% if use_emoji %} (prefix description with one matching emoji){% endif %}
|
||||
|
||||
Example:
|
||||
feat(auth): add login rate limit
|
||||
Prevents brute-force attacks.
|
||||
Examples:
|
||||
feat: add email notifications on new direct messages
|
||||
feat(shopping cart): add the amazing button
|
||||
feat!: remove ticket list endpoint
|
||||
refers to JIRA-1337
|
||||
BREAKING CHANGE: ticket endpoints no longer supports list all entities.
|
||||
fix(shopping-cart): prevent order an empty shopping cart
|
||||
fix(api): fix wrong calculation of request body checksum
|
||||
fix: add missing parameter to service call
|
||||
The error occurred due to <reasons>.
|
||||
perf: decrease memory footprint for determine unique visitors by using HyperLogLog
|
||||
build: update dependencies
|
||||
build(release): bump version to 1.0.0
|
||||
refactor: implement fibonacci number calculation as recursion
|
||||
style: remove empty line
|
||||
|
||||
{{ diff_text }}{{ context }}
|
||||
+83
-26
@@ -6,7 +6,6 @@ from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import yaml
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
|
||||
|
||||
def ensure_log_directory() -> Path:
|
||||
@@ -24,10 +23,10 @@ def get_commit_dry_run_path() -> Path:
|
||||
|
||||
|
||||
def is_file_recent(file_path: Path | str, max_age_minutes: int = 120) -> bool:
|
||||
path = Path(file_path)
|
||||
if not path.exists():
|
||||
try:
|
||||
return (time.time() - Path(file_path).stat().st_mtime) <= max_age_minutes * 60
|
||||
except FileNotFoundError:
|
||||
return False
|
||||
return (time.time() - path.stat().st_mtime) <= max_age_minutes * 60
|
||||
|
||||
|
||||
def sanitize_ai_commit_message(raw_text: str) -> str:
|
||||
@@ -47,7 +46,7 @@ def sanitize_ai_commit_message(raw_text: str) -> str:
|
||||
# - **fix: bug fix**
|
||||
# - chore(deps)!: breaking change
|
||||
header_pattern = re.compile(
|
||||
r"^(\*\*)?(feat|fix|chore|refactor|docs|style|test|build|ci)(\(.*\))?!?: .*",
|
||||
r"^(\*\*)?(feat|fix|chore|refactor|docs|style|test|build|ci|perf|revert|deps|wip)(\(.*\))?!?: .*",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
@@ -76,6 +75,9 @@ def sanitize_ai_commit_message(raw_text: str) -> str:
|
||||
# If we hit another header or a known separator, we stop
|
||||
if "**Sponsor**" in line:
|
||||
break
|
||||
# Ignore trailing markdown code block markers
|
||||
if line.strip() in ("```", "```md", "```markdown"):
|
||||
continue
|
||||
cleaned_lines.append(line)
|
||||
|
||||
if cleaned_lines:
|
||||
@@ -142,6 +144,15 @@ def configure_logger(
|
||||
return logger
|
||||
|
||||
|
||||
def get_git_root(cwd: Optional[Path] = None) -> Optional[Path]:
|
||||
"""Return the root directory of the current git repository, or None."""
|
||||
try:
|
||||
output = run_git_command(["git", "rev-parse", "--show-toplevel"], cwd=cwd)
|
||||
return Path(output) if output else None
|
||||
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||
return None
|
||||
|
||||
|
||||
def run_git_command(
|
||||
args: list[str],
|
||||
check: bool = True,
|
||||
@@ -188,40 +199,79 @@ def get_git_staged_files() -> list[str]:
|
||||
try:
|
||||
output = run_git_command(["git", "diff", "--name-only", "--cached"])
|
||||
return [f for f in output.splitlines() if f.strip()]
|
||||
except subprocess.CalledProcessError:
|
||||
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||
return []
|
||||
|
||||
|
||||
def read_file_content(filepath: Path | str) -> Optional[str]:
|
||||
path = Path(filepath)
|
||||
if path.exists():
|
||||
return path.read_text(encoding="utf-8")
|
||||
return None
|
||||
try:
|
||||
return Path(filepath).read_text(encoding="utf-8")
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
|
||||
|
||||
def delete_file(filepath: Path | str) -> bool:
|
||||
path = Path(filepath)
|
||||
if path.exists():
|
||||
path.unlink()
|
||||
try:
|
||||
Path(filepath).unlink()
|
||||
return True
|
||||
return False
|
||||
except FileNotFoundError:
|
||||
return False
|
||||
|
||||
|
||||
def load_template_env(sub_dir: str) -> Environment:
|
||||
def load_template(sub_dir: str, name: str) -> str:
|
||||
"""Load a template file from the templates directory."""
|
||||
template_dir = Path(__file__).parent / "templates" / sub_dir
|
||||
if not template_dir.is_dir():
|
||||
template_path = template_dir / name
|
||||
if not template_path.is_file():
|
||||
raise RuntimeError(
|
||||
f"Template directory not found: {template_dir}. "
|
||||
"The installation may be corrupted."
|
||||
f"Template not found: {template_path}. The installation may be corrupted."
|
||||
)
|
||||
return Environment(loader=FileSystemLoader(template_dir))
|
||||
return template_path.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def render_custom_template(template_str: str, **context) -> str:
|
||||
"""Renders a Jinja2 template from a string."""
|
||||
env = Environment()
|
||||
template = env.from_string(template_str)
|
||||
return template.render(**context)
|
||||
def render_template(template_str: str, **context) -> str:
|
||||
"""Render a simple template with variable substitution and conditionals.
|
||||
|
||||
Supports:
|
||||
- {{ variable }} - variable substitution
|
||||
- {% if condition %}...{% endif %} - conditional blocks
|
||||
- {% if condition %}...{% else %}...{% endif %} - conditional with else
|
||||
"""
|
||||
result = template_str
|
||||
|
||||
# Process conditional blocks: {% if var %}...{% endif %}
|
||||
# and {% if var %}...{% else %}...{% endif %}
|
||||
if_pattern = re.compile(
|
||||
r"\{%\s*if\s+(\w+)\s*%\}(.*?)\{%\s*endif\s*%\}",
|
||||
re.DOTALL,
|
||||
)
|
||||
|
||||
def replace_if(match):
|
||||
var_name = match.group(1)
|
||||
content = match.group(2)
|
||||
value = context.get(var_name)
|
||||
if value:
|
||||
# Check for else block
|
||||
else_pattern = re.compile(r"\{%\s*else\s*%\}", re.DOTALL)
|
||||
parts = else_pattern.split(content, 1)
|
||||
return parts[0]
|
||||
else:
|
||||
else_pattern = re.compile(r"\{%\s*else\s*%\}", re.DOTALL)
|
||||
parts = else_pattern.split(content, 1)
|
||||
return parts[1] if len(parts) > 1 else ""
|
||||
|
||||
result = if_pattern.sub(replace_if, result)
|
||||
|
||||
# Process variable substitutions: {{ variable }}
|
||||
var_pattern = re.compile(r"\{\{\s*(\w+)\s*\}\}")
|
||||
|
||||
def replace_var(match):
|
||||
var_name = match.group(1)
|
||||
return str(context.get(var_name, ""))
|
||||
|
||||
result = var_pattern.sub(replace_var, result)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def load_config() -> Dict[str, Any]:
|
||||
@@ -246,7 +296,13 @@ def load_config() -> Dict[str, Any]:
|
||||
|
||||
try:
|
||||
with config_path.open("r", encoding="utf-8") as f:
|
||||
return yaml.safe_load(f) or {}
|
||||
data = yaml.safe_load(f)
|
||||
if not isinstance(data, dict):
|
||||
print(
|
||||
f"Warning: Config at {config_path} is not a dict, using defaults."
|
||||
)
|
||||
return {}
|
||||
return data
|
||||
except Exception as e:
|
||||
print(f"Warning: Failed to load config from {config_path}: {e}")
|
||||
return {}
|
||||
@@ -323,7 +379,8 @@ __all__ = [
|
||||
"get_git_staged_files",
|
||||
"read_file_content",
|
||||
"delete_file",
|
||||
"load_template_env",
|
||||
"load_template",
|
||||
"render_template",
|
||||
"load_config",
|
||||
"get_questionary_style",
|
||||
"is_token_limit_error",
|
||||
|
||||
+9
-10
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "devgen-cli"
|
||||
version = "0.2.8"
|
||||
version = "0.3.0"
|
||||
description = "A collection of developer tools"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
@@ -25,16 +25,15 @@ classifiers = [
|
||||
"Programming Language :: Python :: 3.10",
|
||||
]
|
||||
dependencies = [
|
||||
"anthropic>=0.75.0",
|
||||
"google-genai>=0.1.0",
|
||||
"jinja2>=3.1.6",
|
||||
"openai>=2.11.0",
|
||||
"anthropic>=0.116.0",
|
||||
"google-genai>=2.10.0",
|
||||
"openai>=2.44.0",
|
||||
"pyyaml>=6.0.3",
|
||||
"questionary>=2.1.1",
|
||||
"requests>=2.32.5",
|
||||
"rich>=14.2.0",
|
||||
"requests>=2.34.2",
|
||||
"rich>=15.0.0",
|
||||
"toml>=0.10.2",
|
||||
"typer>=0.20.0",
|
||||
"typer>=0.26.8",
|
||||
]
|
||||
[project.urls]
|
||||
Homepage = "https://github.com/S4NKALP/devgen"
|
||||
@@ -50,6 +49,6 @@ include = ["devgen*"]
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"pre-commit>=4.5.0",
|
||||
"ruff>=0.14.9",
|
||||
"pre-commit>=4.6.0",
|
||||
"ruff>=0.15.20",
|
||||
]
|
||||
+6
-7
@@ -1,12 +1,11 @@
|
||||
anthropic==0.105.2
|
||||
google-genai==2.8.0
|
||||
jinja2==3.1.6
|
||||
openai==2.41.0
|
||||
pre-commit==4.5.0
|
||||
anthropic==0.116.0
|
||||
google-genai==2.10.0
|
||||
openai==2.44.0
|
||||
pre-commit==4.6.0
|
||||
pyyaml==6.0.3
|
||||
questionary==2.1.1
|
||||
requests==2.34.2
|
||||
rich==15.0.0
|
||||
ruff==0.15.16
|
||||
ruff==0.15.20
|
||||
toml==0.10.2
|
||||
typer==0.26.7
|
||||
typer==0.26.8
|
||||
@@ -6,7 +6,7 @@ from setuptools import find_packages, setup
|
||||
|
||||
setup(
|
||||
name="devgen-cli",
|
||||
version="0.2.8",
|
||||
version="0.3.0",
|
||||
packages=find_packages(),
|
||||
include_package_data=True,
|
||||
install_requires=[
|
||||
|
||||
Reference in new issue
Block a user