Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
188f1ed283 | ||
|
|
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 | ||
|
|
70e9068082 |
No files matched your search
@@ -14,7 +14,7 @@ jobs:
|
|||||||
name: Lint & format
|
name: Lint & format
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@v7
|
uses: astral-sh/setup-uv@v7
|
||||||
@@ -42,7 +42,7 @@ jobs:
|
|||||||
matrix:
|
matrix:
|
||||||
python-version: ["3.10", "3.11", "3.12", "3.13"]
|
python-version: ["3.10", "3.11", "3.12", "3.13"]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@v7
|
uses: astral-sh/setup-uv@v7
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ jobs:
|
|||||||
language: [python]
|
language: [python]
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@v7
|
uses: astral-sh/setup-uv@v7
|
||||||
@@ -38,12 +38,12 @@ jobs:
|
|||||||
run: uv sync --all-extras --dev
|
run: uv sync --all-extras --dev
|
||||||
|
|
||||||
- name: Initialize CodeQL
|
- name: Initialize CodeQL
|
||||||
uses: github/codeql-action/init@v3
|
uses: github/codeql-action/init@v4.37.3
|
||||||
with:
|
with:
|
||||||
languages: ${{ matrix.language }}
|
languages: ${{ matrix.language }}
|
||||||
queries: security-and-quality
|
queries: security-and-quality
|
||||||
|
|
||||||
- name: Perform CodeQL Analysis
|
- name: Perform CodeQL Analysis
|
||||||
uses: github/codeql-action/analyze@v3
|
uses: github/codeql-action/analyze@v4.37.3
|
||||||
with:
|
with:
|
||||||
category: "/language:${{ matrix.language }}"
|
category: "/language:${{ matrix.language }}"
|
||||||
@@ -19,4 +19,5 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
target: minor
|
target: minor
|
||||||
|
use-github-auto-merge: true
|
||||||
merge-method: squash
|
merge-method: squash
|
||||||
@@ -13,15 +13,14 @@ jobs:
|
|||||||
name: Audit dependencies
|
name: Audit dependencies
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Dependency Review
|
- name: Dependency Review
|
||||||
uses: actions/dependency-review-action@v4
|
uses: actions/dependency-review-action@v5
|
||||||
with:
|
with:
|
||||||
fail-on-severity: high
|
fail-on-severity: high
|
||||||
fail-on-scopes: changes
|
fail-on-scopes: runtime
|
||||||
license-check: true
|
license-check: true
|
||||||
# Project is GPL-3.0-or-later; flag licenses that would taint it.
|
# 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
|
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
|
comment-summary-in-pr: always
|
||||||
vuln-check-non-vulnerable-refs: true
|
|
||||||
@@ -6,7 +6,7 @@ jobs:
|
|||||||
pre-commit:
|
pre-commit:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ jobs:
|
|||||||
permissions:
|
permissions:
|
||||||
id-token: write # IMPORTANT: this permission is mandatory for trusted publishing
|
id-token: write # IMPORTANT: this permission is mandatory for trusted publishing
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v7
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: actions/setup-python@v6
|
uses: actions/setup-python@v6
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ def validate() -> None:
|
|||||||
for f in staged:
|
for f in staged:
|
||||||
typer.echo(f"- {f}")
|
typer.echo(f"- {f}")
|
||||||
else:
|
else:
|
||||||
typer.secho("[i] No staged files.", fg=typer.colors.RED)
|
typer.secho("[i] No staged files.", fg=typer.colors.YELLOW)
|
||||||
|
|
||||||
|
|
||||||
@app.command("undo")
|
@app.command("undo")
|
||||||
|
|||||||
@@ -48,6 +48,12 @@ def edit_config(
|
|||||||
|
|
||||||
if not key:
|
if not key:
|
||||||
choices = list(config.keys())
|
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(
|
key = questionary.select(
|
||||||
"Select key to update:", choices=choices, style=style
|
"Select key to update:", choices=choices, style=style
|
||||||
).ask()
|
).ask()
|
||||||
|
|||||||
+4
-1
@@ -85,13 +85,16 @@ def setup_config() -> None:
|
|||||||
raise typer.Exit(code=130)
|
raise typer.Exit(code=130)
|
||||||
ollama_host = ollama_host_input.strip() or ollama_host
|
ollama_host = ollama_host_input.strip() or ollama_host
|
||||||
|
|
||||||
# Save Config
|
# Save Config — merge with existing to preserve extra keys
|
||||||
new_config = {
|
new_config = {
|
||||||
|
**current_config,
|
||||||
|
**{
|
||||||
"provider": provider,
|
"provider": provider,
|
||||||
"model": model,
|
"model": model,
|
||||||
"api_key": api_key,
|
"api_key": api_key,
|
||||||
"emoji": emoji,
|
"emoji": emoji,
|
||||||
"ollama_host": ollama_host,
|
"ollama_host": ollama_host,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -32,17 +32,21 @@ class ChangelogGenerator:
|
|||||||
else:
|
else:
|
||||||
cmd = self._resolve_range(to_ref)
|
cmd = self._resolve_range(to_ref)
|
||||||
try:
|
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:
|
except subprocess.CalledProcessError as e:
|
||||||
self.logger.error(f"Git command failed: {e}")
|
self.logger.error(f"Git command failed: {e}")
|
||||||
raise RuntimeError(f"Git command failed: {e}") from e
|
raise RuntimeError(f"Git command failed: {e}") from e
|
||||||
|
|
||||||
def _log_cmd(self, range_spec: str) -> List[str]:
|
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 [
|
return [
|
||||||
"git",
|
"git",
|
||||||
"log",
|
"log",
|
||||||
"--format=%H|%an|%ad|%s|%b",
|
"--format=%H|%an|%ad|%s|%b%x00",
|
||||||
"--date=short",
|
"--date=short",
|
||||||
range_spec,
|
range_spec,
|
||||||
]
|
]
|
||||||
@@ -54,7 +58,7 @@ class ChangelogGenerator:
|
|||||||
return self._log_cmd(f"{last_tag}..HEAD")
|
return self._log_cmd(f"{last_tag}..HEAD")
|
||||||
except (RuntimeError, subprocess.CalledProcessError):
|
except (RuntimeError, subprocess.CalledProcessError):
|
||||||
self.logger.info("No tags found, generating for all commits.")
|
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
|
# ----------------------------------------------------------------- parsing
|
||||||
|
|
||||||
@@ -145,7 +149,7 @@ class ChangelogGenerator:
|
|||||||
old = path.read_text(encoding="utf-8")
|
old = path.read_text(encoding="utf-8")
|
||||||
if old.strip().startswith("# CHANGELOG"):
|
if old.strip().startswith("# CHANGELOG"):
|
||||||
header, _, rest = old.partition("\n")
|
header, _, rest = old.partition("\n")
|
||||||
new_content = f"{header}\n\n{md_content}\n{rest.lstrip()}"
|
new_content = f"{header}\n\n{md_content}\n{rest}"
|
||||||
else:
|
else:
|
||||||
new_content = f"# CHANGELOG\n\n{md_content}\n\n{old}"
|
new_content = f"# CHANGELOG\n\n{md_content}\n\n{old}"
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -33,8 +33,10 @@ from devgen.utils import (
|
|||||||
extract_commit_messages,
|
extract_commit_messages,
|
||||||
get_commit_dry_run_path,
|
get_commit_dry_run_path,
|
||||||
is_file_recent,
|
is_file_recent,
|
||||||
load_template_env,
|
is_token_limit_error,
|
||||||
render_custom_template,
|
format_token_limit_error,
|
||||||
|
load_template,
|
||||||
|
render_template,
|
||||||
sanitize_ai_commit_message,
|
sanitize_ai_commit_message,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -48,6 +50,7 @@ class CacheManager:
|
|||||||
|
|
||||||
def __init__(self, path: Path, max_age_minutes: int = 120) -> None:
|
def __init__(self, path: Path, max_age_minutes: int = 120) -> None:
|
||||||
self.path = path
|
self.path = path
|
||||||
|
self.max_age_minutes = max_age_minutes
|
||||||
|
|
||||||
def init_dry_run(self) -> None:
|
def init_dry_run(self) -> None:
|
||||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
@@ -59,7 +62,7 @@ class CacheManager:
|
|||||||
if dry_run:
|
if dry_run:
|
||||||
self.init_dry_run()
|
self.init_dry_run()
|
||||||
return {}
|
return {}
|
||||||
if not force_rebuild and is_file_recent(self.path):
|
if not force_rebuild and is_file_recent(self.path, self.max_age_minutes):
|
||||||
return extract_commit_messages(self.path)
|
return extract_commit_messages(self.path)
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
@@ -73,13 +76,13 @@ class CommitMessageBuilder:
|
|||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
template_env,
|
template_str: str,
|
||||||
config: Dict[str, Any],
|
config: Dict[str, Any],
|
||||||
provider: str,
|
provider: str,
|
||||||
model: str,
|
model: str,
|
||||||
debug: bool = False,
|
debug: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.template_env = template_env
|
self.template_str = template_str
|
||||||
self.config = config
|
self.config = config
|
||||||
self.provider = provider
|
self.provider = provider
|
||||||
self.model = model
|
self.model = model
|
||||||
@@ -96,25 +99,14 @@ class CommitMessageBuilder:
|
|||||||
use_emoji = self.config.get("emoji", True)
|
use_emoji = self.config.get("emoji", True)
|
||||||
ollama_host = self.config.get("ollama_host")
|
ollama_host = self.config.get("ollama_host")
|
||||||
|
|
||||||
if custom_template:
|
template = custom_template or self.template_str
|
||||||
prompt = render_custom_template(
|
prompt = render_template(
|
||||||
custom_template,
|
template,
|
||||||
group_name=group,
|
group_name=group,
|
||||||
diff_text=diff,
|
diff_text=diff,
|
||||||
use_emoji=use_emoji,
|
use_emoji=use_emoji,
|
||||||
context=manifest_context,
|
context=manifest_context,
|
||||||
)
|
).strip()
|
||||||
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()
|
|
||||||
)
|
|
||||||
|
|
||||||
provider = (
|
provider = (
|
||||||
extra_kwargs.get("provider") or self.config.get("provider") or self.provider
|
extra_kwargs.get("provider") or self.config.get("provider") or self.provider
|
||||||
@@ -197,10 +189,10 @@ class CommitEngine:
|
|||||||
),
|
),
|
||||||
logger=self.logger,
|
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.cache = CacheManager(get_commit_dry_run_path())
|
||||||
self.message_builder = CommitMessageBuilder(
|
self.message_builder = CommitMessageBuilder(
|
||||||
self.template_env,
|
self.template_str,
|
||||||
self.config,
|
self.config,
|
||||||
provider=self.provider,
|
provider=self.provider,
|
||||||
model=self.model,
|
model=self.model,
|
||||||
@@ -251,6 +243,7 @@ class CommitEngine:
|
|||||||
if not self._process_group(group, group_files, cache_map):
|
if not self._process_group(group, group_files, cache_map):
|
||||||
failed.append(group)
|
failed.append(group)
|
||||||
if len(groups) > 1 and i < len(groups) - 1:
|
if len(groups) > 1 and i < len(groups) - 1:
|
||||||
|
if self.provider != "ollama":
|
||||||
import time
|
import time
|
||||||
|
|
||||||
time.sleep(15) # Conservative 4 RPM guard for free-tier
|
time.sleep(15) # Conservative 4 RPM guard for free-tier
|
||||||
@@ -275,7 +268,7 @@ class CommitEngine:
|
|||||||
return self._apply_message(group, cache[group], files)
|
return self._apply_message(group, cache[group], files)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
manifest_context = ManifestInspector.summary()
|
manifest_context = ManifestInspector.summary(cwd=self.git.cwd)
|
||||||
if manifest_context:
|
if manifest_context:
|
||||||
self.logger.info("Including manifest context in prompt")
|
self.logger.info("Including manifest context in prompt")
|
||||||
with self.console.status(
|
with self.console.status(
|
||||||
@@ -289,6 +282,11 @@ class CommitEngine:
|
|||||||
)
|
)
|
||||||
message = sanitize_ai_commit_message(raw)
|
message = sanitize_ai_commit_message(raw)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
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(
|
self.logger.error(
|
||||||
f"Failed to generate message for group {group!r}: {e}",
|
f"Failed to generate message for group {group!r}: {e}",
|
||||||
exc_info=True,
|
exc_info=True,
|
||||||
@@ -387,6 +385,7 @@ def run_commit_engine(**kwargs: Any) -> None:
|
|||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Commit engine failed: {e}", exc_info=True)
|
logger.error(f"Commit engine failed: {e}", exc_info=True)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
__all__ = ["CommitEngine", "CommitEngineError", "run_commit_engine"]
|
__all__ = ["CommitEngine", "CommitEngineError", "run_commit_engine"]
|
||||||
@@ -14,7 +14,7 @@ from collections import defaultdict
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Dict, List, Optional
|
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
|
# Lock files and other metadata blobs whose full diff would just bloat the
|
||||||
@@ -54,7 +54,7 @@ class DiffBuilder:
|
|||||||
logger=None,
|
logger=None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.max_size = max_size
|
self.max_size = max_size
|
||||||
self.cwd = cwd
|
self.cwd = cwd or get_git_root()
|
||||||
self.logger = logger
|
self.logger = logger
|
||||||
|
|
||||||
def build(self, files: List[str]) -> str:
|
def build(self, files: List[str]) -> str:
|
||||||
@@ -70,7 +70,7 @@ class DiffBuilder:
|
|||||||
def _partition(self, files: List[str]) -> tuple[list[str], list[str]]:
|
def _partition(self, files: List[str]) -> tuple[list[str], list[str]]:
|
||||||
summary, to_diff = [], []
|
summary, to_diff = [], []
|
||||||
for f in files:
|
for f in files:
|
||||||
if any(p in f for p in IGNORED_DIFF_PATTERNS):
|
if Path(f).name in IGNORED_DIFF_PATTERNS:
|
||||||
summary.append(f"[METADATA UPDATED] {f}")
|
summary.append(f"[METADATA UPDATED] {f}")
|
||||||
else:
|
else:
|
||||||
to_diff.append(f)
|
to_diff.append(f)
|
||||||
@@ -128,7 +128,8 @@ class FileGrouper:
|
|||||||
deepest = max(candidates, key=lambda p: len(Path(p).parts))
|
deepest = max(candidates, key=lambda p: len(Path(p).parts))
|
||||||
parent_path = str(Path(deepest).parent)
|
parent_path = str(Path(deepest).parent)
|
||||||
new_key = "root" if parent_path == "." else parent_path
|
new_key = "root" if parent_path == "." else parent_path
|
||||||
groups.setdefault(new_key, []).extend(groups.pop(deepest))
|
files = groups.pop(deepest)
|
||||||
|
groups.setdefault(new_key, []).extend(files)
|
||||||
return groups
|
return groups
|
||||||
|
|
||||||
|
|
||||||
@@ -142,13 +143,14 @@ class ManifestInspector:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def summary() -> str:
|
def summary(cwd: Optional[Path] = None) -> str:
|
||||||
|
cwd = cwd or get_git_root() or Path(".")
|
||||||
lines = [
|
lines = [
|
||||||
s
|
s
|
||||||
for s in (
|
for s in (
|
||||||
ManifestInspector._pyproject(),
|
ManifestInspector._pyproject(cwd),
|
||||||
ManifestInspector._package_json(),
|
ManifestInspector._package_json(cwd),
|
||||||
ManifestInspector._requirements(),
|
ManifestInspector._requirements(cwd),
|
||||||
)
|
)
|
||||||
if s
|
if s
|
||||||
]
|
]
|
||||||
@@ -163,8 +165,8 @@ class ManifestInspector:
|
|||||||
return re.split(r"[><=!~\[]", spec, 1)[0].strip()
|
return re.split(r"[><=!~\[]", spec, 1)[0].strip()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _pyproject() -> Optional[str]:
|
def _pyproject(cwd: Path) -> Optional[str]:
|
||||||
path = Path("pyproject.toml")
|
path = cwd / "pyproject.toml"
|
||||||
if not path.exists():
|
if not path.exists():
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
@@ -185,8 +187,8 @@ class ManifestInspector:
|
|||||||
return f"[pyproject.toml] {' '.join(parts)}" if parts else None
|
return f"[pyproject.toml] {' '.join(parts)}" if parts else None
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _package_json() -> Optional[str]:
|
def _package_json(cwd: Path) -> Optional[str]:
|
||||||
path = Path("package.json")
|
path = cwd / "package.json"
|
||||||
if not path.exists():
|
if not path.exists():
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
@@ -202,8 +204,8 @@ class ManifestInspector:
|
|||||||
return f"[package.json] {' '.join(parts)}" if parts else None
|
return f"[package.json] {' '.join(parts)}" if parts else None
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _requirements() -> Optional[str]:
|
def _requirements(cwd: Path) -> Optional[str]:
|
||||||
path = Path("requirements.txt")
|
path = cwd / "requirements.txt"
|
||||||
if not path.exists():
|
if not path.exists():
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import subprocess
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
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):
|
class GitError(RuntimeError):
|
||||||
@@ -26,7 +26,7 @@ class GitOperator:
|
|||||||
logger=None,
|
logger=None,
|
||||||
quiet_commands: bool = True,
|
quiet_commands: bool = True,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.cwd = cwd
|
self.cwd = cwd or get_git_root()
|
||||||
self.logger = logger or configure_logger("devgen.git")
|
self.logger = logger or configure_logger("devgen.git")
|
||||||
self.quiet_commands = quiet_commands
|
self.quiet_commands = quiet_commands
|
||||||
|
|
||||||
@@ -91,15 +91,16 @@ class GitOperator:
|
|||||||
[
|
[
|
||||||
"git",
|
"git",
|
||||||
"ls-files",
|
"ls-files",
|
||||||
|
"-z",
|
||||||
"--deleted",
|
"--deleted",
|
||||||
"--modified",
|
"--modified",
|
||||||
"--others",
|
"--others",
|
||||||
"--exclude-standard",
|
"--exclude-standard",
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
files = {f.strip() for f in out.split("\n") if f.strip()}
|
files = {f for f in out.split("\0") if f}
|
||||||
staged_out = self._run(["git", "diff", "--name-only", "--cached"])
|
staged_out = self._run(["git", "diff", "-z", "--name-only", "--cached"])
|
||||||
files.update(f.strip() for f in staged_out.split("\n") if f.strip())
|
files.update(f for f in staged_out.split("\0") if f)
|
||||||
return sorted(files)
|
return sorted(files)
|
||||||
|
|
||||||
# ------------------------------------------------------------------ actions
|
# ------------------------------------------------------------------ actions
|
||||||
@@ -115,8 +116,11 @@ class GitOperator:
|
|||||||
self._run(["git", "add", *existing])
|
self._run(["git", "add", *existing])
|
||||||
if deleted:
|
if deleted:
|
||||||
self._run(["git", "rm", *deleted])
|
self._run(["git", "rm", *deleted])
|
||||||
except GitError as e:
|
except GitError:
|
||||||
raise GitError(f"Could not stage files: {e}") from e
|
# Fallback: file state may have changed between check and command.
|
||||||
|
# Try a single git add --all for the whole set; git will handle
|
||||||
|
# both additions and removals correctly.
|
||||||
|
self._run(["git", "add", "--all", "--", *files])
|
||||||
|
|
||||||
def commit(self, message: str) -> None:
|
def commit(self, message: str) -> None:
|
||||||
"""``git commit -m`` with the given message."""
|
"""``git commit -m`` with the given message."""
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import re
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import List
|
from typing import List
|
||||||
|
|
||||||
@@ -5,6 +6,9 @@ import requests
|
|||||||
|
|
||||||
from devgen.utils import configure_logger
|
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:
|
class GitignoreGenerator:
|
||||||
"""Fetches and manages .gitignore templates from GitHub."""
|
"""Fetches and manages .gitignore templates from GitHub."""
|
||||||
@@ -41,6 +45,11 @@ class GitignoreGenerator:
|
|||||||
self, name: str, use_cache: bool = True, offline: bool = False
|
self, name: str, use_cache: bool = True, offline: bool = False
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Fetches template content from cache or GitHub."""
|
"""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"
|
cache_file = self.cache_dir / f"{name}.gitignore"
|
||||||
|
|
||||||
if offline:
|
if offline:
|
||||||
|
|||||||
@@ -16,4 +16,6 @@ class AnthropicProvider(BaseProvider):
|
|||||||
max_tokens=1024,
|
max_tokens=1024,
|
||||||
messages=[{"role": "user", "content": prompt}],
|
messages=[{"role": "user", "content": prompt}],
|
||||||
)
|
)
|
||||||
return message.content[0].text
|
if not message.content:
|
||||||
|
raise RuntimeError(f"Anthropic returned no content for model {model!r}.")
|
||||||
|
return message.content[0].text or ""
|
||||||
+40
-16
@@ -30,32 +30,56 @@ class BaseProvider(ABC):
|
|||||||
model: str | None = None,
|
model: str | None = None,
|
||||||
**kwargs: Any,
|
**kwargs: Any,
|
||||||
) -> str:
|
) -> 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:
|
if self.REQUIRES_API_KEY and not api_key:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"{self.DISPLAY_NAME} API key is missing. "
|
f"{self.DISPLAY_NAME} API key is missing. "
|
||||||
"Set it via `devgen setup config` or pass --api-key."
|
"Set it via `devgen setup config` or pass --api-key."
|
||||||
)
|
)
|
||||||
chosen_model = model or self.DEFAULT_MODEL
|
chosen_model = model or self.DEFAULT_MODEL
|
||||||
|
|
||||||
|
max_retries = kwargs.get("max_retries", 5)
|
||||||
|
base_delay = kwargs.get("retry_delay", 10)
|
||||||
|
|
||||||
|
for attempt in range(max_retries + 1):
|
||||||
try:
|
try:
|
||||||
return self._generate(prompt, api_key, chosen_model, **kwargs).strip()
|
text = self._generate(prompt, api_key, chosen_model, **kwargs)
|
||||||
|
return (text or "").strip()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
if is_token_limit_error(e):
|
||||||
self._handle_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:
|
def _handle_error(self, error: Exception) -> None:
|
||||||
"""Translate an internal exception into a user-facing RuntimeError.
|
"""Translate an internal exception into a user-facing RuntimeError.
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
import time
|
|
||||||
|
|
||||||
from google import genai
|
from google import genai
|
||||||
from google.genai import types
|
from google.genai import types
|
||||||
|
|
||||||
from devgen.providers.base import BaseProvider
|
from devgen.providers.base import BaseProvider
|
||||||
from devgen.utils import is_token_limit_error
|
|
||||||
|
|
||||||
|
|
||||||
class GeminiProvider(BaseProvider):
|
class GeminiProvider(BaseProvider):
|
||||||
@@ -14,12 +11,7 @@ class GeminiProvider(BaseProvider):
|
|||||||
DEFAULT_MODEL = "gemini-2.5-flash"
|
DEFAULT_MODEL = "gemini-2.5-flash"
|
||||||
|
|
||||||
def _generate(self, prompt, api_key, model, **kwargs):
|
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)
|
client = genai.Client(api_key=api_key)
|
||||||
|
|
||||||
for attempt in range(max_retries + 1):
|
|
||||||
try:
|
|
||||||
response = client.models.generate_content(
|
response = client.models.generate_content(
|
||||||
model=model,
|
model=model,
|
||||||
contents=prompt,
|
contents=prompt,
|
||||||
@@ -30,34 +22,4 @@ class GeminiProvider(BaseProvider):
|
|||||||
max_output_tokens=kwargs.get("max_output_tokens", 2048),
|
max_output_tokens=kwargs.get("max_output_tokens", 2048),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
return response.text
|
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")
|
|
||||||
)
|
|
||||||
@@ -20,7 +20,9 @@ class HuggingfaceProvider(BaseProvider):
|
|||||||
}
|
}
|
||||||
|
|
||||||
try:
|
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()
|
response.raise_for_status()
|
||||||
result = response.json()
|
result = response.json()
|
||||||
except requests.HTTPError as e:
|
except requests.HTTPError as e:
|
||||||
|
|||||||
@@ -18,17 +18,21 @@ class OllamaProvider(BaseProvider):
|
|||||||
DEFAULT_HOST = "http://localhost:11434"
|
DEFAULT_HOST = "http://localhost:11434"
|
||||||
DEFAULT_MODEL = "llama3.2"
|
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):
|
def _generate(self, prompt, api_key, model, **kwargs):
|
||||||
host = kwargs.get("ollama_host") or self.DEFAULT_HOST
|
host = kwargs.get("ollama_host") or self.DEFAULT_HOST
|
||||||
base_url = f"{host.rstrip('/')}/v1"
|
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")
|
# 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(
|
response = client.chat.completions.create(
|
||||||
model=model,
|
model=model,
|
||||||
messages=[{"role": "user", "content": prompt}],
|
messages=[{"role": "user", "content": prompt}],
|
||||||
**kwargs,
|
**safe_kwargs,
|
||||||
)
|
)
|
||||||
|
|
||||||
if not response.choices:
|
if not response.choices:
|
||||||
@@ -36,7 +40,7 @@ class OllamaProvider(BaseProvider):
|
|||||||
f"Ollama returned no choices for model {model!r}. "
|
f"Ollama returned no choices for model {model!r}. "
|
||||||
"The model may not be installed locally."
|
"The model may not be installed locally."
|
||||||
)
|
)
|
||||||
return response.choices[0].message.content
|
return response.choices[0].message.content or ""
|
||||||
|
|
||||||
def _handle_error(self, error):
|
def _handle_error(self, error):
|
||||||
"""Override default wrapping — Ollama has no API key to check."""
|
"""Override default wrapping — Ollama has no API key to check."""
|
||||||
|
|||||||
@@ -9,12 +9,17 @@ class OpenaiProvider(BaseProvider):
|
|||||||
DISPLAY_NAME = "OpenAI"
|
DISPLAY_NAME = "OpenAI"
|
||||||
DEFAULT_MODEL = "gpt-4o"
|
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):
|
def _generate(self, prompt, api_key, model, **kwargs):
|
||||||
kwargs.pop("debug", None)
|
safe_kwargs = {k: v for k, v in kwargs.items() if k not in self._DEVGEN_KEYS}
|
||||||
client = OpenAI(api_key=api_key)
|
with OpenAI(api_key=api_key) as client:
|
||||||
response = client.chat.completions.create(
|
response = client.chat.completions.create(
|
||||||
model=model,
|
model=model,
|
||||||
messages=[{"role": "user", "content": prompt}],
|
messages=[{"role": "user", "content": prompt}],
|
||||||
**kwargs,
|
**safe_kwargs,
|
||||||
)
|
)
|
||||||
return response.choices[0].message.content
|
if not response.choices:
|
||||||
|
raise RuntimeError(f"OpenAI returned no choices for model {model!r}.")
|
||||||
|
return response.choices[0].message.content or ""
|
||||||
@@ -10,9 +10,12 @@ class OpenrouterProvider(BaseProvider):
|
|||||||
BASE_URL = "https://openrouter.ai/api/v1"
|
BASE_URL = "https://openrouter.ai/api/v1"
|
||||||
DEFAULT_MODEL = "openai/gpt-3.5-turbo"
|
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):
|
def _generate(self, prompt, api_key, model, **kwargs):
|
||||||
kwargs.pop("debug", None)
|
safe_kwargs = {k: v for k, v in kwargs.items() if k not in self._DEVGEN_KEYS}
|
||||||
client = OpenAI(base_url=self.BASE_URL, api_key=api_key)
|
with OpenAI(base_url=self.BASE_URL, api_key=api_key) as client:
|
||||||
response = client.chat.completions.create(
|
response = client.chat.completions.create(
|
||||||
model=model,
|
model=model,
|
||||||
messages=[{"role": "user", "content": prompt}],
|
messages=[{"role": "user", "content": prompt}],
|
||||||
@@ -20,6 +23,8 @@ class OpenrouterProvider(BaseProvider):
|
|||||||
"HTTP-Referer": "https://github.com/S4NKALP/devgen",
|
"HTTP-Referer": "https://github.com/S4NKALP/devgen",
|
||||||
"X-Title": "devgen CLI",
|
"X-Title": "devgen CLI",
|
||||||
},
|
},
|
||||||
**kwargs,
|
**safe_kwargs,
|
||||||
)
|
)
|
||||||
return response.choices[0].message.content
|
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.
|
You write Conventional Commits. Output ONLY the message — no preamble, no labels, no quotes, no markdown.
|
||||||
|
|
||||||
Format:
|
Format:
|
||||||
<type>(<scope>): <summary>
|
<type>[optional scope][optional !]: <description>
|
||||||
[optional 1-2 line body on WHY]
|
[optional blank line]
|
||||||
|
[optional body]
|
||||||
|
[optional blank line]
|
||||||
|
[optional footer(s)]
|
||||||
|
|
||||||
types: feat fix refactor perf docs test build ci chore style
|
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:
|
Examples:
|
||||||
feat(auth): add login rate limit
|
feat: add email notifications on new direct messages
|
||||||
Prevents brute-force attacks.
|
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 }}
|
{{ diff_text }}{{ context }}
|
||||||
+83
-21
@@ -6,7 +6,6 @@ from pathlib import Path
|
|||||||
from typing import Any, Dict, Optional
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
from jinja2 import Environment, FileSystemLoader
|
|
||||||
|
|
||||||
|
|
||||||
def ensure_log_directory() -> Path:
|
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:
|
def is_file_recent(file_path: Path | str, max_age_minutes: int = 120) -> bool:
|
||||||
path = Path(file_path)
|
try:
|
||||||
if not path.exists():
|
return (time.time() - Path(file_path).stat().st_mtime) <= max_age_minutes * 60
|
||||||
|
except FileNotFoundError:
|
||||||
return False
|
return False
|
||||||
return (time.time() - path.stat().st_mtime) <= max_age_minutes * 60
|
|
||||||
|
|
||||||
|
|
||||||
def sanitize_ai_commit_message(raw_text: str) -> str:
|
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**
|
# - **fix: bug fix**
|
||||||
# - chore(deps)!: breaking change
|
# - chore(deps)!: breaking change
|
||||||
header_pattern = re.compile(
|
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,
|
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 we hit another header or a known separator, we stop
|
||||||
if "**Sponsor**" in line:
|
if "**Sponsor**" in line:
|
||||||
break
|
break
|
||||||
|
# Ignore trailing markdown code block markers
|
||||||
|
if line.strip() in ("```", "```md", "```markdown"):
|
||||||
|
continue
|
||||||
cleaned_lines.append(line)
|
cleaned_lines.append(line)
|
||||||
|
|
||||||
if cleaned_lines:
|
if cleaned_lines:
|
||||||
@@ -142,6 +144,15 @@ def configure_logger(
|
|||||||
return 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(
|
def run_git_command(
|
||||||
args: list[str],
|
args: list[str],
|
||||||
check: bool = True,
|
check: bool = True,
|
||||||
@@ -188,35 +199,79 @@ def get_git_staged_files() -> list[str]:
|
|||||||
try:
|
try:
|
||||||
output = run_git_command(["git", "diff", "--name-only", "--cached"])
|
output = run_git_command(["git", "diff", "--name-only", "--cached"])
|
||||||
return [f for f in output.splitlines() if f.strip()]
|
return [f for f in output.splitlines() if f.strip()]
|
||||||
except subprocess.CalledProcessError:
|
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
def read_file_content(filepath: Path | str) -> Optional[str]:
|
def read_file_content(filepath: Path | str) -> Optional[str]:
|
||||||
path = Path(filepath)
|
try:
|
||||||
if path.exists():
|
return Path(filepath).read_text(encoding="utf-8")
|
||||||
return path.read_text(encoding="utf-8")
|
except FileNotFoundError:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def delete_file(filepath: Path | str) -> bool:
|
def delete_file(filepath: Path | str) -> bool:
|
||||||
path = Path(filepath)
|
try:
|
||||||
if path.exists():
|
Path(filepath).unlink()
|
||||||
path.unlink()
|
|
||||||
return True
|
return True
|
||||||
|
except FileNotFoundError:
|
||||||
return False
|
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
|
template_dir = Path(__file__).parent / "templates" / sub_dir
|
||||||
return Environment(loader=FileSystemLoader(template_dir))
|
template_path = template_dir / name
|
||||||
|
if not template_path.is_file():
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Template not found: {template_path}. The installation may be corrupted."
|
||||||
|
)
|
||||||
|
return template_path.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
def render_custom_template(template_str: str, **context) -> str:
|
def render_template(template_str: str, **context) -> str:
|
||||||
"""Renders a Jinja2 template from a string."""
|
"""Render a simple template with variable substitution and conditionals.
|
||||||
env = Environment()
|
|
||||||
template = env.from_string(template_str)
|
Supports:
|
||||||
return template.render(**context)
|
- {{ 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]:
|
def load_config() -> Dict[str, Any]:
|
||||||
@@ -241,7 +296,13 @@ def load_config() -> Dict[str, Any]:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
with config_path.open("r", encoding="utf-8") as f:
|
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:
|
except Exception as e:
|
||||||
print(f"Warning: Failed to load config from {config_path}: {e}")
|
print(f"Warning: Failed to load config from {config_path}: {e}")
|
||||||
return {}
|
return {}
|
||||||
@@ -318,7 +379,8 @@ __all__ = [
|
|||||||
"get_git_staged_files",
|
"get_git_staged_files",
|
||||||
"read_file_content",
|
"read_file_content",
|
||||||
"delete_file",
|
"delete_file",
|
||||||
"load_template_env",
|
"load_template",
|
||||||
|
"render_template",
|
||||||
"load_config",
|
"load_config",
|
||||||
"get_questionary_style",
|
"get_questionary_style",
|
||||||
"is_token_limit_error",
|
"is_token_limit_error",
|
||||||
|
|||||||
+9
-10
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "devgen-cli"
|
name = "devgen-cli"
|
||||||
version = "0.2.7"
|
version = "0.3.0"
|
||||||
description = "A collection of developer tools"
|
description = "A collection of developer tools"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
@@ -25,16 +25,15 @@ classifiers = [
|
|||||||
"Programming Language :: Python :: 3.10",
|
"Programming Language :: Python :: 3.10",
|
||||||
]
|
]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anthropic>=0.75.0",
|
"anthropic>=0.116.0",
|
||||||
"google-genai>=0.1.0",
|
"google-genai>=2.10.0",
|
||||||
"jinja2>=3.1.6",
|
"openai>=2.44.0",
|
||||||
"openai>=2.11.0",
|
|
||||||
"pyyaml>=6.0.3",
|
"pyyaml>=6.0.3",
|
||||||
"questionary>=2.1.1",
|
"questionary>=2.1.1",
|
||||||
"requests>=2.32.5",
|
"requests>=2.34.2",
|
||||||
"rich>=14.2.0",
|
"rich>=15.0.0",
|
||||||
"toml>=0.10.2",
|
"toml>=0.10.2",
|
||||||
"typer>=0.20.0",
|
"typer>=0.26.8",
|
||||||
]
|
]
|
||||||
[project.urls]
|
[project.urls]
|
||||||
Homepage = "https://github.com/S4NKALP/devgen"
|
Homepage = "https://github.com/S4NKALP/devgen"
|
||||||
@@ -50,6 +49,6 @@ include = ["devgen*"]
|
|||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
dev = [
|
dev = [
|
||||||
"pre-commit>=4.5.0",
|
"pre-commit>=4.6.0",
|
||||||
"ruff>=0.14.9",
|
"ruff>=0.15.20",
|
||||||
]
|
]
|
||||||
+6
-7
@@ -1,12 +1,11 @@
|
|||||||
anthropic==0.105.2
|
anthropic==0.116.0
|
||||||
google-genai==2.8.0
|
google-genai==2.10.0
|
||||||
jinja2==3.1.6
|
openai==2.44.0
|
||||||
openai==2.41.0
|
pre-commit==4.6.0
|
||||||
pre-commit==4.5.0
|
|
||||||
pyyaml==6.0.3
|
pyyaml==6.0.3
|
||||||
questionary==2.1.1
|
questionary==2.1.1
|
||||||
requests==2.34.2
|
requests==2.34.2
|
||||||
rich==15.0.0
|
rich==15.0.0
|
||||||
ruff==0.15.16
|
ruff==0.15.20
|
||||||
toml==0.10.2
|
toml==0.10.2
|
||||||
typer==0.26.7
|
typer==0.26.8
|
||||||
@@ -6,7 +6,7 @@ from setuptools import find_packages, setup
|
|||||||
|
|
||||||
setup(
|
setup(
|
||||||
name="devgen-cli",
|
name="devgen-cli",
|
||||||
version="0.2.7",
|
version="0.3.0",
|
||||||
packages=find_packages(),
|
packages=find_packages(),
|
||||||
include_package_data=True,
|
include_package_data=True,
|
||||||
install_requires=[
|
install_requires=[
|
||||||
|
|||||||
Reference in new issue
Block a user