Compare commits

..
Author SHA1 Message Date
dependabot[bot] 188f1ed283 build(deps): bump github/codeql-action from 4 to 4.37.3
Bumps [github/codeql-action](https://github.com/github/codeql-action) from 4 to 4.37.3.
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/v4...v4.37.3)

---
updated-dependencies:
- dependency-name: github/codeql-action
  dependency-version: 4.37.3
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-31 01:14:23 +00:00
sankalp e69de1d99a Merge pull request #63 from S4NKALP/dependabot/github_actions/main/actions/checkout-7
build(deps): bump actions/checkout from 6 to 7
2026-07-03 19:07:59 +05:45
dependabot[bot] e5f22bca85 build(deps): bump actions/checkout from 6 to 7
Bumps [actions/checkout](https://github.com/actions/checkout) from 6 to 7.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-03 12:45:02 +00:00
sankalp 277f197a71 fix(.github/workflows): change fail-on-scopes to runtime
Update CI to enforce runtime dependency reviews, improving coverage of actual runtime packages.
2026-07-03 18:26:35 +05:45
sankalp 2edbf95ed5 feat(provider): refactor Gemini provider to use struct schema
Improves validation and aligns with latest API changes.
2026-07-03 18:24:48 +05:45
sankalp 109fcd584f feat(root): bump version to 0.3.0 and update dependencies
Update metadata and pin production dependencies for reproducible builds.
2026-07-03 18:23:37 +05:45
sankalp 61e51b5187 chore(deps): bump dependency versions
Upgrade to latest stable releases for security and compatibility.
2026-07-03 18:21:18 +05:45
sankalp d4b694e77d fix(devgen): handle missing git binary gracefully
Ensure the function returns an empty list when git is not found, avoiding crashes.
2026-07-03 18:08:00 +05:45
sankalp 261d04c56f refactor(devgen/providers): centralize retry and quota handling in BaseProvider
Move retry logic and quota detection from provider-specific code to BaseProvider to reduce duplication and simplify error handling.
2026-07-03 18:07:43 +05:45
sankalp f7001d4243 feat(devgen/modules): streamline provider guard and enhance ManifestInspector flexibility
Add token‑limit error handling and switch git diff to null‑delimited output for reliable file detection.
2026-07-03 18:07:26 +05:45
sankalp 11a9db7986 feat(devgen): extend header regex to include deps and wip scopes and ignore markdown fences
Prevent false positives on markdown fences and enable parsing of dependency-related commit types.
2026-07-03 17:02:18 +05:45
sankalp 34f5d090ce chore(devgen): extend commit message regex to include perf and revert
Update the regex in devgen/utils.py to recognize `perf` and `revert` as valid Conventional Commit types, ensuring alignment with the specification and preventing false negatives in automated analysis.
2026-07-03 16:46:05 +05:45
sankalp d20a31bf38 feat(devgen/templates/commit): update commit_message.tpl with expanded conventional commit format
Add detailed format description, examples, and diff handling to improve commit message clarity.
2026-07-03 16:45:47 +05:45
sankalp a43e09e924 feat(.github/workflows): enable auto-merge for dependabot
Allow automatic merging of Dependabot PRs to keep the repository up‑to‑date without manual intervention.
2026-06-16 15:43:46 +05:45
sankalp fca0e34422 chore(root): bump version to 0.2.9
Updated version and dependency constraints for release.
2026-06-16 15:12:54 +05:45
sankalp c4992cce57 formating 2026-06-16 15:05:07 +05:45
sankalp 957dda9d49 refactor: remove jinja2 dependency, use simple template engine
- Replace jinja2 with custom render_template() helper
- Support {{ var }} substitution and {% if %}...{% endif %} conditionals
- Remove jinja2 from pyproject.toml and requirements.txt
- Simplifies dependency tree and reduces install size
2026-06-16 14:58:25 +05:45
sankalp 869aa37f0c update 2026-06-16 14:45:55 +05:45
sankalp 51ede1a7da fix: address 7 security and reliability issues
- Prevent path traversal in gitignore template names (Critical)
- Fix TOCTOU races in file operations with try/except pattern (High)
- Add timeout to HuggingFace API requests (Medium)
- Add missing %x00 separator in changelog fallback format (Medium)
- Filter unsanitized kwargs before passing to OpenAI SDK (High)
- Use context managers for proper client cleanup in providers (High)
- Exit with non-zero code on commit engine failure (Low)
2026-06-16 14:43:52 +05:45
sankalp ede489c219 fix: resolve 8 bugs affecting real users
- Fix KeyError 'root' in FileGrouper when merging groups with no root-level files
- Fix changelog commit parsing breaking on multiline bodies (use %x00 separator)
- Add empty choices/content checks in OpenAI, Anthropic, OpenRouter providers
- Fix config edit crash when config is empty
- Fix validate command using wrong color for 'no staged files'
- Add type check for corrupted YAML config (non-dict returns empty dict)
2026-06-16 14:36:19 +05:45
sankalp a56fe0a6ee update 2026-06-13 15:17:42 +05:45
sankalp e13e763cf2 chore(.github/workflows): upgrade dependency-review-action to v4.6.0
Upgrade to the latest stable version to receive security fixes and new features.
2026-06-13 15:16:22 +05:45
sankalp 79294f1df0 Merge pull request #61 from S4NKALP/dependabot/pip/main/openai-2.41.1
build(deps): bump openai from 2.41.0 to 2.41.1
2026-06-13 15:15:43 +05:45
sankalp 187ad00e99 Merge branch 'main' into dependabot/pip/main/openai-2.41.1 2026-06-13 15:15:19 +05:45
sankalp 35e02ddf7d Merge pull request #60 from S4NKALP/dependabot/pip/main/pre-commit-4.6.0
build(deps): bump pre-commit from 4.5.0 to 4.6.0
2026-06-13 15:14:51 +05:45
sankalp 93f09c9d74 Merge pull request #62 from S4NKALP/dependabot/pip/main/anthropic-0.109.1
build(deps): bump anthropic from 0.105.2 to 0.109.1
2026-06-13 15:14:34 +05:45
sankalp ab9527df33 Merge pull request #59 from S4NKALP/dependabot/pip/main/ruff-0.15.17
build(deps): bump ruff from 0.15.16 to 0.15.17
2026-06-13 15:14:13 +05:45
sankalp 186d952fda Merge pull request #58 from S4NKALP/dependabot/github_actions/main/github/codeql-action-4
build(deps): bump github/codeql-action from 3 to 4
2026-06-13 15:13:59 +05:45
sankalp deba25b055 Merge pull request #57 from S4NKALP/dependabot/github_actions/main/actions/dependency-review-action-5
build(deps): bump actions/dependency-review-action from 4 to 5
2026-06-13 15:13:40 +05:45
dependabot[bot] 9e6c846790 build(deps): bump actions/dependency-review-action from 4 to 5
Bumps [actions/dependency-review-action](https://github.com/actions/dependency-review-action) from 4 to 5.
- [Release notes](https://github.com/actions/dependency-review-action/releases)
- [Commits](https://github.com/actions/dependency-review-action/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/dependency-review-action
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-13 09:28:12 +00:00
sankalp f007c7c579 chore(.github/workflows):switch fail-on-scopes from changes to direct 2026-06-13 15:11:43 +05:45
dependabot[bot] 363b8ec398 build(deps): bump pre-commit from 4.5.0 to 4.6.0
Bumps [pre-commit](https://github.com/pre-commit/pre-commit) from 4.5.0 to 4.6.0.
- [Release notes](https://github.com/pre-commit/pre-commit/releases)
- [Changelog](https://github.com/pre-commit/pre-commit/blob/main/CHANGELOG.md)
- [Commits](https://github.com/pre-commit/pre-commit/compare/v4.5.0...v4.6.0)

---
updated-dependencies:
- dependency-name: pre-commit
  dependency-version: 4.6.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-13 09:25:52 +00:00
dependabot[bot] 0c5ec5da0a build(deps): bump openai from 2.41.0 to 2.41.1
Bumps [openai](https://github.com/openai/openai-python) from 2.41.0 to 2.41.1.
- [Release notes](https://github.com/openai/openai-python/releases)
- [Changelog](https://github.com/openai/openai-python/blob/main/CHANGELOG.md)
- [Commits](https://github.com/openai/openai-python/compare/v2.41.0...v2.41.1)

---
updated-dependencies:
- dependency-name: openai
  dependency-version: 2.41.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-13 09:25:29 +00:00
dependabot[bot] 4f679bc90d build(deps): bump anthropic from 0.105.2 to 0.109.1
Bumps [anthropic](https://github.com/anthropics/anthropic-sdk-python) from 0.105.2 to 0.109.1.
- [Release notes](https://github.com/anthropics/anthropic-sdk-python/releases)
- [Changelog](https://github.com/anthropics/anthropic-sdk-python/blob/main/CHANGELOG.md)
- [Commits](https://github.com/anthropics/anthropic-sdk-python/compare/v0.105.2...v0.109.1)

---
updated-dependencies:
- dependency-name: anthropic
  dependency-version: 0.109.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-13 09:25:28 +00:00
dependabot[bot] f21b090b56 build(deps): bump ruff from 0.15.16 to 0.15.17
Bumps [ruff](https://github.com/astral-sh/ruff) from 0.15.16 to 0.15.17.
- [Release notes](https://github.com/astral-sh/ruff/releases)
- [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/ruff/compare/0.15.16...0.15.17)

---
updated-dependencies:
- dependency-name: ruff
  dependency-version: 0.15.17
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-13 09:24:55 +00:00
dependabot[bot] 8c63406126 build(deps): bump github/codeql-action from 3 to 4
Bumps [github/codeql-action](https://github.com/github/codeql-action) from 3 to 4.
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/v3...v4)

---
updated-dependencies:
- dependency-name: github/codeql-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-13 09:24:51 +00:00
sankalp 91500f634a chore(.github/workflows): remove vuln-check-non-vulnerable-refs flag
Removed the flag that disabled vulnerability checks for non‑vulnerable refs; allow default security behavior.
2026-06-13 15:07:07 +05:45
sankalp cb3a1662d9 refactor(devgen/cli): reorder config merge to preserve key order
Preserves insertion order and prevents accidental overwriting of existing keys
2026-06-06 21:36:37 +05:45
27 changed files with 955 additions and 797 deletions

No files matched your search

+2 -2
View File
@@ -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
+3 -3
View File
@@ -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.37.3
with:
languages: ${{ matrix.language }}
queries: security-and-quality
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3
uses: github/codeql-action/analyze@v4.37.3
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
+3 -4
View File
@@ -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
+1 -1
View File
@@ -6,7 +6,7 @@ jobs:
pre-commit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
fetch-depth: 0
+1 -1
View File
@@ -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:
+1 -1
View File
@@ -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")
+6
View File
@@ -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
View File
@@ -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:
+8 -4
View File
@@ -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
+30 -32
View File
@@ -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 -16
View File
@@ -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:
+6 -5
View File
@@ -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
+9
View File
@@ -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:
+2
View File
@@ -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
View File
@@ -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
View File
@@ -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 ""
+3 -1
View File
@@ -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:
+12 -8
View File
@@ -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(
+12 -7
View File
@@ -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 ""
+17 -12
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+1 -1
View File
@@ -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=[
Generated
+641 -575
View File
File diff suppressed because it is too large. Load diff