Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e0bb0b8f04 | ||
|
|
e69de1d99a | ||
|
|
e5f22bca85 | ||
|
|
277f197a71 | ||
|
|
2edbf95ed5 | ||
|
|
109fcd584f | ||
|
|
61e51b5187 | ||
|
|
d4b694e77d | ||
|
|
261d04c56f | ||
|
|
f7001d4243 | ||
|
|
11a9db7986 | ||
|
|
34f5d090ce | ||
|
|
d20a31bf38 | ||
|
|
a43e09e924 |
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
|
||||
|
||||
@@ -19,4 +19,5 @@ jobs:
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
target: minor
|
||||
use-github-auto-merge: true
|
||||
merge-method: squash
|
||||
@@ -13,13 +13,13 @@ 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@v5
|
||||
with:
|
||||
fail-on-severity: high
|
||||
fail-on-scopes: direct
|
||||
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
|
||||
|
||||
@@ -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,7 +12,7 @@ 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
|
||||
with:
|
||||
|
||||
@@ -33,6 +33,8 @@ from devgen.utils import (
|
||||
extract_commit_messages,
|
||||
get_commit_dry_run_path,
|
||||
is_file_recent,
|
||||
is_token_limit_error,
|
||||
format_token_limit_error,
|
||||
load_template,
|
||||
render_template,
|
||||
sanitize_ai_commit_message,
|
||||
@@ -241,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(
|
||||
@@ -265,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(
|
||||
@@ -279,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
|
||||
|
||||
|
||||
@@ -143,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
|
||||
]
|
||||
@@ -164,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:
|
||||
@@ -186,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:
|
||||
@@ -203,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:
|
||||
|
||||
@@ -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
|
||||
|
||||
+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 ""
|
||||
+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 }}
|
||||
+5
-2
@@ -46,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,
|
||||
)
|
||||
|
||||
@@ -75,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:
|
||||
@@ -196,7 +199,7 @@ 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 []
|
||||
|
||||
|
||||
|
||||
+9
-9
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "devgen-cli"
|
||||
version = "0.2.9"
|
||||
version = "0.3.0"
|
||||
description = "A collection of developer tools"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
@@ -25,15 +25,15 @@ classifiers = [
|
||||
"Programming Language :: Python :: 3.10",
|
||||
]
|
||||
dependencies = [
|
||||
"anthropic>=0.75.0",
|
||||
"google-genai>=0.1.0",
|
||||
"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"
|
||||
@@ -49,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",
|
||||
]
|
||||
+8
-8
@@ -1,11 +1,11 @@
|
||||
anthropic==0.76.0
|
||||
google-genai==1.60.0
|
||||
openai==2.15.0
|
||||
pre-commit==4.5.1
|
||||
anthropic==1.9.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.32.5
|
||||
rich==14.2.0
|
||||
ruff==0.14.13
|
||||
requests==2.34.2
|
||||
rich==15.0.0
|
||||
ruff==0.15.20
|
||||
toml==0.10.2
|
||||
typer==0.21.1
|
||||
typer==0.26.8
|
||||
@@ -6,7 +6,7 @@ from setuptools import find_packages, setup
|
||||
|
||||
setup(
|
||||
name="devgen-cli",
|
||||
version="0.2.9",
|
||||
version="0.3.0",
|
||||
packages=find_packages(),
|
||||
include_package_data=True,
|
||||
install_requires=[
|
||||
|
||||
Reference in new issue
Block a user