Compare commits
129
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b03c13ae62 | ||
|
|
e69de1d99a | ||
|
|
e5f22bca85 | ||
|
|
277f197a71 | ||
|
|
2edbf95ed5 | ||
|
|
109fcd584f | ||
|
|
61e51b5187 | ||
|
|
d4b694e77d | ||
|
|
261d04c56f | ||
|
|
f7001d4243 | ||
|
|
11a9db7986 | ||
|
|
34f5d090ce | ||
|
|
d20a31bf38 | ||
|
|
a43e09e924 | ||
|
|
fca0e34422 | ||
|
|
c4992cce57 | ||
|
|
957dda9d49 | ||
|
|
869aa37f0c | ||
|
|
51ede1a7da | ||
|
|
ede489c219 | ||
|
|
a56fe0a6ee | ||
|
|
e13e763cf2 | ||
|
|
79294f1df0 | ||
|
|
187ad00e99 | ||
|
|
35e02ddf7d | ||
|
|
93f09c9d74 | ||
|
|
ab9527df33 | ||
|
|
186d952fda | ||
|
|
deba25b055 | ||
|
|
9e6c846790 | ||
|
|
f007c7c579 | ||
|
|
363b8ec398 | ||
|
|
0c5ec5da0a | ||
|
|
4f679bc90d | ||
|
|
f21b090b56 | ||
|
|
8c63406126 | ||
|
|
91500f634a | ||
|
|
cb3a1662d9 | ||
|
|
70e9068082 | ||
|
|
fb87bfa146 | ||
|
|
69bd79e0fa | ||
|
|
a86dcc93ee | ||
|
|
456040ed2b | ||
|
|
df1d1765d1 | ||
|
|
09ba5e9644 | ||
|
|
850e88972e | ||
|
|
96e776f7cc | ||
|
|
f70b5208ec | ||
|
|
e2ec269f16 | ||
|
|
90ca0c0f59 | ||
|
|
9781523e8e | ||
|
|
96b2499ecf | ||
|
|
4f8e358a60 | ||
|
|
72c57f0d33 | ||
|
|
0b023482d7 | ||
|
|
8069895bc7 | ||
|
|
d991af9c47 | ||
|
|
d8fcf0849a | ||
|
|
aad3703ea3 | ||
|
|
a8b48e9f07 | ||
|
|
7016cff3a7 | ||
|
|
55ebbaef15 | ||
|
|
80f68a6ccd | ||
|
|
fd3ce03484 | ||
|
|
51d2b7549f | ||
|
|
5660e40eb4 | ||
|
|
c6914e2139 | ||
|
|
4f0a055216 | ||
|
|
16c6f70057 | ||
|
|
3daf798dcd | ||
|
|
d6f2452d21 | ||
|
|
59aa40bdcf | ||
|
|
8fdc346913 | ||
|
|
cdca283cd2 | ||
|
|
e30d2d854d | ||
|
|
e77472fb0f | ||
|
|
e3dc6729dc | ||
|
|
c7c1da8459 | ||
|
|
b0ad273f87 | ||
|
|
dcecfec258 | ||
|
|
f21ba55d6d | ||
|
|
48ae3be0d4 | ||
|
|
5682a51d97 | ||
|
|
3007623f4c | ||
|
|
f2e5097e7f | ||
|
|
d95c88b240 | ||
|
|
dc2b00651f | ||
|
|
2c9c5ed22d | ||
|
|
8a42b547ec | ||
|
|
d7fd658b98 | ||
|
|
34a7635a6d | ||
|
|
fdd38d5a49 | ||
|
|
aa4fc8018e | ||
|
|
763537a324 | ||
|
|
867156b65a | ||
|
|
0e50e5f5b0 | ||
|
|
3fde0941eb | ||
|
|
5b7303c2a6 | ||
|
|
6a8e92b4fc | ||
|
|
89bfe85002 | ||
|
|
5515ca5633 | ||
|
|
e7385c55fb | ||
|
|
52cf0d4a7b | ||
|
|
ceebf728ca | ||
|
|
277cb324ba | ||
|
|
8c98b64495 | ||
|
|
b9899a774f | ||
|
|
96c8dd83b1 | ||
|
|
92e9f5ac19 | ||
|
|
75eb20fab3 | ||
|
|
5b7bd55ccd | ||
|
|
386e0ee72d | ||
|
|
b31b06eb11 | ||
|
|
cf637f34e8 | ||
|
|
cb99c2fa56 | ||
|
|
a67220cfb7 | ||
|
|
f9914fa482 | ||
|
|
fab6465c69 | ||
|
|
b94e8502c8 | ||
|
|
4fe5ef0d34 | ||
|
|
9e9c42ced7 | ||
|
|
81da7f1846 | ||
|
|
a01bdac09f | ||
|
|
b74fb8071a | ||
|
|
d135381ad5 | ||
|
|
daa6f203c3 | ||
|
|
1b36c3f2d7 | ||
|
|
9554f7af2a | ||
|
|
06f25af35f |
No files matched your search
@@ -0,0 +1,3 @@
|
||||
# Tell GitHub Linguist to skip *.tpl files entirely so they don't
|
||||
# inflate language stats (Linguist otherwise maps .tpl -> Go).
|
||||
*.tpl linguist-vendored
|
||||
@@ -0,0 +1,16 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
target-branch: "main"
|
||||
rebase-strategy: "auto"
|
||||
|
||||
- package-ecosystem: "pip"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
target-branch: "main"
|
||||
rebase-strategy: "auto"
|
||||
open-pull-requests-limit: 10
|
||||
@@ -0,0 +1,66 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
name: Lint & format
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
with:
|
||||
enable-cache: true
|
||||
cache-dependency-glob: "uv.lock"
|
||||
|
||||
- name: Set up Python
|
||||
run: uv python install 3.12
|
||||
|
||||
- name: Sync dependencies
|
||||
run: uv sync --all-extras --dev
|
||||
|
||||
- name: ruff check
|
||||
run: uv run ruff check .
|
||||
|
||||
- name: ruff format --check
|
||||
run: uv run ruff format --check .
|
||||
|
||||
smoke:
|
||||
name: Import & CLI smoke (${{ matrix.python-version }})
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
python-version: ["3.10", "3.11", "3.12", "3.13"]
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
with:
|
||||
enable-cache: true
|
||||
cache-dependency-glob: "uv.lock"
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
run: uv python install ${{ matrix.python-version }}
|
||||
|
||||
- name: Sync dependencies
|
||||
run: uv sync --all-extras --dev
|
||||
|
||||
- name: Import devgen
|
||||
run: uv run python -c "import devgen, devgen.ai, devgen.utils, devgen.cli.main; print('imports OK')"
|
||||
|
||||
- name: CLI --help
|
||||
run: uv run devgen --help
|
||||
|
||||
- name: CLI commit --help
|
||||
run: uv run devgen commit --help
|
||||
@@ -0,0 +1,49 @@
|
||||
name: CodeQL
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
schedule:
|
||||
- cron: "17 6 * * 1"
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
security-events: write
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze (${{ matrix.language }})
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
language: [python]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
with:
|
||||
enable-cache: true
|
||||
cache-dependency-glob: "uv.lock"
|
||||
|
||||
- name: Set up Python
|
||||
run: uv python install 3.12
|
||||
|
||||
- name: Sync dependencies
|
||||
run: uv sync --all-extras --dev
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v4
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
queries: security-and-quality
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v4
|
||||
with:
|
||||
category: "/language:${{ matrix.language }}"
|
||||
@@ -0,0 +1,23 @@
|
||||
name: Dependabot Auto-Merge
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, synchronize, reopened]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
dependabot:
|
||||
runs-on: ubuntu-latest
|
||||
# Check that it's actually dependabot
|
||||
if: github.event.pull_request.user.login == 'dependabot[bot]'
|
||||
steps:
|
||||
- name: Merge PR from Dependabot
|
||||
uses: fastify/github-action-merge-dependabot@v3
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
target: minor
|
||||
use-github-auto-merge: true
|
||||
merge-method: squash
|
||||
@@ -0,0 +1,26 @@
|
||||
name: Dependency Review
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
dependency-review:
|
||||
name: Audit dependencies
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Dependency Review
|
||||
uses: actions/dependency-review-action@v5
|
||||
with:
|
||||
fail-on-severity: high
|
||||
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
|
||||
@@ -6,12 +6,12 @@ jobs:
|
||||
pre-commit:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v4
|
||||
uses: astral-sh/setup-uv@v7
|
||||
with:
|
||||
enable-cache: true
|
||||
cache-dependency-glob: "uv.lock"
|
||||
|
||||
@@ -12,9 +12,9 @@ jobs:
|
||||
permissions:
|
||||
id-token: write # IMPORTANT: this permission is mandatory for trusted publishing
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: '3.10'
|
||||
cache: 'pip'
|
||||
|
||||
@@ -2,169 +2,148 @@
|
||||
|
||||
<div align="center">
|
||||
|
||||
> **Your AI Powerhouse for Git & Project Management.**
|
||||
> Stop wasting time on repetitive tasks. Automate your commits, changelogs, and project essentials with a single CLI.
|
||||
>
|
||||
**AI-Powered Git & Project Workflows in One CLI**
|
||||
|
||||
Stop wasting time on repetitive tasks. DevGen automates commits, changelogs, `.gitignore`, and license files using AI — from cloud providers or a local Ollama model.
|
||||
|
||||
> PyPI didn't allow the original name, so you'll find it as **devgen-cli** on PyPI
|
||||
|
||||
<a href="https://pypi.org/project/devgen-cli"><img src="https://img.shields.io/pypi/v/devgen-cli?color=blue&label=PyPI&logo=pypi&logoColor=white" alt="PyPI"></a>
|
||||
<img src="https://img.shields.io/badge/Python-3.10%2B-3776AB?logo=python&logoColor=white" alt="Python">
|
||||
<a href="https://github.com/S4NKALP/DevGen/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-GPL--3.0--or--later-blue.svg" alt="License"></a>
|
||||
> <a href="https://pypi.org/project/devgen-cli"><img src="https://img.shields.io/pypi/v/devgen-cli?color=blue&label=PyPI&logo=pypi&logoColor=white" alt="PyPI"></a>
|
||||
> <img src="https://img.shields.io/badge/Python-3.10%2B-3776AB?logo=python&logoColor=white" alt="Python">
|
||||
> <a href="https://github.com/S4NKALP/DevGen/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-GPL--3.0--or--later-blue.svg" alt="License"></a>
|
||||
> <a href="https://github.com/S4NKALP/DevGen/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/S4NKALP/DevGen/ci.yml?branch=main&label=CI" alt="CI"></a>
|
||||
> <a href="https://github.com/S4NKALP/DevGen/actions/workflows/pre-commit.yml"><img src="https://img.shields.io/github/actions/workflow/status/S4NKALP/DevGen/pre-commit.yml?branch=main&label=Pre-Commit" alt="Pre-Commit"></a>
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
## Why DevGen
|
||||
|
||||
**DevGen** isn't just another CLI tool it's your development workflow on steroids. By leveraging state of the art AI models, DevGen turns tedious manual tasks into one click magic. From semantic commit messages to comprehensive release notes, DevGen handles the boring stuff so you can focus on building.
|
||||
DevGen is a CLI for the parts of development that should be invisible. It writes Conventional Commits from your diff, drafts SemVer changelogs, fetches `.gitignore` and license templates, and routes everything through whichever AI provider you prefer — including a fully local Ollama model if you don't want to send code to the cloud.
|
||||
|
||||
## ⚡ Why DevGen?
|
||||
## Features
|
||||
|
||||
**🧠 AI Brain**
|
||||
Semantic, context-aware commit messages powered by Gemini, OpenAI, Claude, HuggingFace, and OpenRouter. It reads your diffs, identifies project manifests, and understands your code.
|
||||
- **AI-Powered Commits** — Conventional Commit messages generated from your staged diff, grouped by directory, with optional emoji prefixes.
|
||||
- **Multiple Providers** — Google Gemini, OpenAI, Anthropic, OpenRouter, HuggingFace, and local Ollama, all behind one CLI.
|
||||
- **Smart Caching** — `.gitignore` and license templates are cached for offline use; AI responses are de-duplicated.
|
||||
- **Conventional Changelogs** — `feat`, `fix`, `refactor`, `perf`, `docs`, `test`, `build`, `ci`, `chore`, `style`, and a `BREAKING CHANGES` section.
|
||||
- **Project Scaffolding** — Pull `.gitignore` from GitHub's collection and drop in SPDX license files (MIT, Apache-2.0, GPL-3.0, AGPL-3.0, BSD, MPL-2.0, …).
|
||||
- **Custom Templates** — Override the commit-message prompt via a `.tpl` file in your config.
|
||||
- **Interactive Setup** — `devgen setup` walks you through provider + API key + options.
|
||||
- **Undo Support** — `devgen commit undo` rolls back the last AI commit while keeping changes staged.
|
||||
- **Token-Limit Aware** — When a diff exceeds a model's context window, you get a single actionable error with `--max-groups` and `--max-diff-size` hints.
|
||||
|
||||
**🚀 Battle Tested**
|
||||
Generates **Conventional Commits** and **Semantic Versioning** compliant changelogs that actually make sense.
|
||||
## Supported AI Providers
|
||||
|
||||
**⚡ Lightning Fast**
|
||||
Smart caching and async operations mean you never wait longer than necessary.
|
||||
| Provider | Notes |
|
||||
| ----------------- | ---------------------------------------------------- |
|
||||
| **Google Gemini** | Default-friendly, generous free tier |
|
||||
| **OpenAI** | GPT-4o, GPT-4.1, o-series |
|
||||
| **Anthropic** | Claude 3.5 / 3.7 / 4 |
|
||||
| **OpenRouter** | Single key, many models |
|
||||
| **HuggingFace** | Inference API |
|
||||
| **Ollama** | Fully local, no API key, no data leaves your machine |
|
||||
|
||||
**Project Essentials**
|
||||
Quickly add `.gitignore` and license files to your existing projects. Access cached templates instantly, even without internet.
|
||||
|
||||
**🛠️ Zero Friction**
|
||||
Interactive setup gets you running in seconds.
|
||||
|
||||
## 📦 Installation
|
||||
|
||||
Get started in seconds.
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
# Recommended: Install via pipx for an isolated environment
|
||||
# Recommended: isolated environment
|
||||
pipx install devgen-cli
|
||||
|
||||
# Or use uv for blazing speed
|
||||
# Or use uv for speed
|
||||
uv tool install devgen-cli
|
||||
|
||||
# Standard pip install
|
||||
# Or plain pip
|
||||
pip install devgen-cli
|
||||
|
||||
# Enable Shell Completion (bash/zsh/fish)
|
||||
# Shell completion (bash/zsh/fish)
|
||||
devgen --install-completion
|
||||
```
|
||||
|
||||
## 🚀 Quick Start
|
||||
Requires **Python 3.10 or newer**.
|
||||
|
||||
**1. Initialize & Configure**
|
||||
Tell DevGen which AI provider to use.
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# 1. Configure a provider and API key
|
||||
devgen setup config
|
||||
```
|
||||
|
||||
**2. Stage & Commit**
|
||||
Stage your files and let AI write the message.
|
||||
|
||||
```bash
|
||||
# 2. Stage your work as usual
|
||||
git add .
|
||||
|
||||
# 3. Let DevGen write the commit message
|
||||
devgen commit run
|
||||
```
|
||||
|
||||
_Boom. Done._
|
||||
|
||||
## 💡 Feature Deep Dive
|
||||
|
||||
### 🤖 AI Powered Commits
|
||||
|
||||
Stop writing "fix bug" or "wip". DevGen analyzes your staged changes, groups them by component, and generates meaningful, semantic commit messages.
|
||||
|
||||
```bash
|
||||
# Preview what DevGen will generate
|
||||
# 4. Or preview first, then commit
|
||||
devgen commit run --dry-run
|
||||
|
||||
# Commit and push in one go
|
||||
devgen commit run --push
|
||||
|
||||
# Review and edit AI messages before committing
|
||||
devgen commit run --check
|
||||
|
||||
# Made a mistake? Undo the last AI commit and keep changes staged
|
||||
devgen commit undo
|
||||
```
|
||||
|
||||
### 🧠 Context Awareness
|
||||
DevGen isn't just looking at the code changes; it's looking at the big picture. It automatically detects and analyzes:
|
||||
- **Manifests**: `package.json`, `pyproject.toml`, `go.mod`, `Cargo.toml`, etc.
|
||||
- **Lock Files**: `uv.lock`, `package-lock.json`, `poetry.lock`, etc.
|
||||
## Commands
|
||||
|
||||
This allows the AI to understand *exactly* which library versions were updated, leading to hyper-accurate dependency commit messages.
|
||||
| Command | Description |
|
||||
| ------------------------------- | ----------------------------------------------------------------------------- |
|
||||
| `devgen setup config` | Interactive provider / API key / options wizard |
|
||||
| `devgen commit run` | Stage, generate message, commit (optionally `--push`, `--check`, `--dry-run`) |
|
||||
| `devgen commit undo` | Undo the last AI commit, keep changes staged |
|
||||
| `devgen changelog` | Generate a Conventional Commits changelog from recent history |
|
||||
| `devgen release-notes` | Generate release notes for a version range |
|
||||
| `devgen gitignore list` | List available GitHub `.gitignore` templates |
|
||||
| `devgen gitignore add <name> …` | Add `.gitignore` entries to the current project |
|
||||
| `devgen license list` | List available SPDX licenses |
|
||||
| `devgen license add <spdx>` | Add a LICENSE file to the current project |
|
||||
|
||||
### 📝 Changelogs & Release Notes
|
||||
Run `devgen <command> --help` for full options on any subcommand.
|
||||
|
||||
Turn your git history into beautiful, readable documentation.
|
||||
## Custom Templates
|
||||
|
||||
DevGen uses `.tpl` files for its commit prompt. To override the default, set `custom_template` in your config (see `devgen config info`) and point it at a `.tpl` file with `{{ diff_text }}`, `{{ context }}`, `{{ group_name }}`, and the conditional `{% if use_emoji %}` block.
|
||||
|
||||
Example minimal template:
|
||||
|
||||
```
|
||||
{{ diff_text }}
|
||||
Summarize the change above in one Conventional Commit line.
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
DevGen is built around a small set of composable components:
|
||||
|
||||
- **`BaseProvider`** — abstract base for every AI provider; handles API-key validation, token-limit detection, and error wrapping. Adding a new provider is one subclass with a single `_generate()` method.
|
||||
- **`GitOperator`** — all `subprocess` calls to `git` live here, behind a `GitError` exception.
|
||||
- **`DiffBuilder` / `FileGrouper` / `ManifestInspector`** — split a staged diff into per-directory groups with compact project context, so the model sees a focused slice instead of a wall of text.
|
||||
- **`Section` enum** — single source of truth for changelog ordering and emoji, shared by `ChangelogGenerator` and `ReleaseNotesGenerator`.
|
||||
|
||||
The CLI entry point is `devgen.cli.main:app` (Typer), exposed as the `devgen` script.
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
# Generate a changelog from the last tag
|
||||
devgen changelog generate
|
||||
|
||||
# Create release notes for v2.0.0
|
||||
devgen release notes --version 2.0.0
|
||||
git clone https://github.com/S4NKALP/DevGen.git
|
||||
cd DevGen
|
||||
uv sync --all-extras --dev # install runtime + dev deps
|
||||
uv run pre-commit run --all-files
|
||||
uv run devgen --help
|
||||
```
|
||||
|
||||
### 🛡️ Essential Files
|
||||
The project uses `uv` for dependency management, `ruff` for lint + format, and `pyright` for type checking. CI runs all three on Python 3.10 through 3.13 (see `.github/workflows/ci.yml`).
|
||||
|
||||
Don't waste time searching for templates. Generate the essential files for your project instantly.
|
||||
## Contributing
|
||||
|
||||
```bash
|
||||
# Interactive search for gitignore templates
|
||||
devgen gitignore generate
|
||||
Issues and PRs welcome. Please run `uv run pre-commit run --all-files` before opening a PR so CI stays green.
|
||||
|
||||
# Generate a license interactively
|
||||
devgen license generate
|
||||
```
|
||||
## License
|
||||
|
||||
## ⚙️ Configuration
|
||||
GPL-3.0-or-later. See [LICENSE](LICENSE).
|
||||
|
||||
Your settings live in `~/.devgen.yaml`. You can tweak your AI provider, model, and preferences there.
|
||||
## Acknowledgments
|
||||
|
||||
| Option | Description |
|
||||
| :--------- | :----------------------------------------------------------- |
|
||||
| `provider` | `gemini`, `openai`, `anthropic`, `huggingface`, `openrouter` |
|
||||
| `model` | Specific model name (e.g., `gemini-2.5-flash`, `gpt-4o`) |
|
||||
| `emoji` | Enable/disable gitmojis in commits (`true`/`false`) |
|
||||
| `custom_template` | Custom Jinja2 template for commit messages. |
|
||||
Built on the shoulders of:
|
||||
|
||||
### 🎨 Custom Templates
|
||||
You can define your own commit message structure in `~/.devgen.yaml`. Use `devgen config info` to see available variables like `{{ group_name }}`, `{{ diff_text }}`, and `{{ context }}`.
|
||||
|
||||
Example:
|
||||
```yaml
|
||||
custom_template: |
|
||||
{{ group_name }}: {{ diff_text }}
|
||||
---
|
||||
Manifests: {{ context }}
|
||||
```
|
||||
|
||||
> [!TIP]
|
||||
> Use `devgen config info` to see a full list of available variables and a template tip!
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
We love contributions! Found a bug? Want a new feature? Open an issue or submit a PR.
|
||||
|
||||
## � Acknowledgments
|
||||
|
||||
DevGen wouldn't be possible without these amazing open-source projects and AI providers:
|
||||
|
||||
- **[Typer](https://typer.tiangolo.com/)** & **[Rich](https://rich.readthedocs.io/)** for building the beautiful, intuitive, and responsive CLI interface.
|
||||
- **[Questionary](https://github.com/tmbo/questionary)** for creating interactive, user-friendly prompts and selection menus.
|
||||
- **[Jinja2](https://jinja.palletsprojects.com/)** for the powerful template engine used to generate files and messages.
|
||||
- **[Google Gemini](https://deepmind.google/technologies/gemini/)**, **[OpenAI](https://openai.com/)**, **[Anthropic](https://www.anthropic.com/)**, **[HuggingFace](https://huggingface.co/)**, and **[OpenRouter](https://openrouter.ai/)** for providing the advanced AI models that power the semantic generation features.
|
||||
|
||||
## �📝 License
|
||||
|
||||
Proudly open source under the [GPL-3.0-or-later](LICENSE) License.
|
||||
|
||||
---
|
||||
- **Typer** & **Rich** — CLI and terminal UI
|
||||
- **Questionary** — interactive prompts
|
||||
- **Google Gemini**, **OpenAI**, **Anthropic**, **OpenRouter**, **HuggingFace**, and **Ollama** — AI providers
|
||||
- **Ruff** — lint and format
|
||||
|
||||
<div align="center">
|
||||
Made with ❤️ by <a href="https://github.com/S4NKALP">Sankalp</a>
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
# 📋 Project Roadmap & TODOs
|
||||
|
||||
This document outlines future improvements, features, and technical debt for `devgen`.
|
||||
|
||||
## 🚀 Priority: Short-Term (Reliability & DX)
|
||||
- [ ] **Full Test Suite**: Implement `pytest` for all modules, especially `commit_generator` and `utils`.
|
||||
- [x] **Interactive Commit Review**: Add a `--check` flag to `devgen commit run` that allows users to approve/edit the AI message before committing.
|
||||
- [ ] **Project-Level Config**: Support `.devgen.yaml` in the repository root that overrides global `~/.devgen.yaml`.
|
||||
- [ ] **Improved Gitignore Detection**: Auto-detect project type (Python, Node, Go) and suggest relevant `.gitignore` templates during `devgen gitignore run`.
|
||||
|
||||
## 🤖 AI & Intelligence
|
||||
- [x] **Custom Prompts**: Allow users to define their own Jinja2 templates for commits in the config file(~/.devgen.yaml).
|
||||
- [x] **Context-Aware Commits**: Send the manifest file (`package.json`, `pyproject.toml`) to the AI to provide better context for dependency changes.
|
||||
- [ ] **Token Usage Dashboard**: Add a `devgen config tokens` command to estimate or track AI token consumption.
|
||||
- [ ] **More Providers**: Add support for **Mistral**, **Cohere**, and **Groq** for faster/cheaper generations.
|
||||
|
||||
## 🛠️ Developer Experience (DX)
|
||||
- [ ] **Pre-commit Hook Integration**: Provide a command to easily install `devgen` as a pre-commit hook.
|
||||
- [x] **Shell Completion**: Implement `typer` shell completion for `bash`, `zsh`, and `fish`.
|
||||
- [x] **Commit Undo**: `devgen commit undo` to easily revert the last AI-generated commit and unstage files.
|
||||
- [x] **Multi-Group Commits**: Better handling of repositories where changes are spread across many unrelated directories.
|
||||
|
||||
## 🏗️ Refactoring & Technical Debt
|
||||
- [ ] **Code Split**: Move CLI logic out of `devgen/cli` into separate service classes to improve testability.
|
||||
- [ ] **Consistent Logging**: Standardize log output across all modules.
|
||||
- [ ] **API Documentation**: Use Sphinx or MkDocs to generate documentation for the core modules.
|
||||
|
||||
## 🌍 Ecosystem
|
||||
- [ ] **GitHub Action**: Create an official `devgen` GitHub Action for automated changelog updates on PR merge.
|
||||
- [ ] **VS Code Extension**: A simple extension to trigger `devgen commit` from the sidebar.
|
||||
+11
-2
@@ -21,8 +21,17 @@ def generate_with_ai(
|
||||
provider_module = import_module(f"devgen.providers.{provider}")
|
||||
class_name = "".join([x.capitalize() for x in provider.split("_")]) + "Provider"
|
||||
provider_class = getattr(provider_module, class_name)
|
||||
except (ModuleNotFoundError, AttributeError) as e:
|
||||
raise ImportError(f"Provider `{provider}` not found or invalid: {e}") from e
|
||||
except ModuleNotFoundError as e:
|
||||
raise ImportError(
|
||||
f"Provider `{provider}` is not a built-in module: {e}. "
|
||||
f"Available providers: gemini, openai, anthropic, huggingface, "
|
||||
f"openrouter, ollama."
|
||||
) from e
|
||||
except AttributeError as e:
|
||||
raise ImportError(
|
||||
f"Provider `{provider}` exists but does not expose a "
|
||||
f"`{class_name}` class. This is a packaging bug."
|
||||
) from e
|
||||
|
||||
provider_instance = provider_class()
|
||||
return provider_instance.generate(prompt, api_key=api_key, model=model, **kwargs)
|
||||
+20
-2
@@ -58,12 +58,28 @@ def run_commit(
|
||||
help="Review/edit commit message before committing.",
|
||||
),
|
||||
] = False,
|
||||
max_groups: Annotated[
|
||||
int | None,
|
||||
typer.Option(
|
||||
"--max-groups",
|
||||
help="Maximum number of commit groups. Lower this if you hit token-limit errors.",
|
||||
),
|
||||
] = None,
|
||||
max_diff_size: Annotated[
|
||||
int | None,
|
||||
typer.Option(
|
||||
"--max-diff-size",
|
||||
help="Maximum diff size in chars per group (default 8000). "
|
||||
"Lower this if you hit token-limit errors.",
|
||||
),
|
||||
] = None,
|
||||
) -> None:
|
||||
log_file = get_main_log_path()
|
||||
logger = configure_logger("devgen.cli.commit", log_file, console=debug)
|
||||
logger.info(f"Log file: {log_file}")
|
||||
logger.info(
|
||||
f"Options: dry_run={dry_run}, push={push}, debug={debug}, force={force_rebuild}, check={check}"
|
||||
f"Options: dry_run={dry_run}, push={push}, debug={debug}, force={force_rebuild}, "
|
||||
f"check={check}, max_groups={max_groups}, max_diff_size={max_diff_size}"
|
||||
)
|
||||
|
||||
run_commit_engine(
|
||||
@@ -73,6 +89,8 @@ def run_commit(
|
||||
force_rebuild=force_rebuild,
|
||||
check=check,
|
||||
logger=logger,
|
||||
max_groups=max_groups,
|
||||
max_diff_size=max_diff_size,
|
||||
)
|
||||
|
||||
|
||||
@@ -102,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")
|
||||
|
||||
+34
-2
@@ -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()
|
||||
@@ -62,7 +68,14 @@ def edit_config(
|
||||
if key == "provider":
|
||||
value = questionary.select(
|
||||
"Select AI Provider:",
|
||||
choices=["gemini", "openai", "huggingface", "openrouter", "anthropic"],
|
||||
choices=[
|
||||
"gemini",
|
||||
"openai",
|
||||
"huggingface",
|
||||
"openrouter",
|
||||
"anthropic",
|
||||
"ollama",
|
||||
],
|
||||
default=str(current_val) if current_val else "gemini",
|
||||
style=style,
|
||||
).ask()
|
||||
@@ -117,7 +130,14 @@ def set_config() -> None:
|
||||
# Questions
|
||||
provider = questionary.select(
|
||||
"Select AI Provider:",
|
||||
choices=["gemini", "openai", "huggingface", "openrouter", "anthropic"],
|
||||
choices=[
|
||||
"gemini",
|
||||
"openai",
|
||||
"huggingface",
|
||||
"openrouter",
|
||||
"anthropic",
|
||||
"ollama",
|
||||
],
|
||||
default=config.get("provider", "gemini"),
|
||||
style=style,
|
||||
).ask()
|
||||
@@ -151,12 +171,24 @@ def set_config() -> None:
|
||||
raise typer.Exit(code=130)
|
||||
emoji = emoji_choice == "Yes"
|
||||
|
||||
ollama_host = config.get("ollama_host", "http://localhost:11434")
|
||||
if provider == "ollama":
|
||||
ollama_host_input = questionary.text(
|
||||
"Ollama server URL:",
|
||||
default=ollama_host,
|
||||
style=style,
|
||||
).ask()
|
||||
if ollama_host_input is None:
|
||||
raise typer.Exit(code=130)
|
||||
ollama_host = ollama_host_input.strip() or ollama_host
|
||||
|
||||
# Save Config
|
||||
new_config = {
|
||||
"provider": provider,
|
||||
"model": model,
|
||||
"api_key": api_key,
|
||||
"emoji": emoji,
|
||||
"ollama_host": ollama_host,
|
||||
}
|
||||
|
||||
# Merge with existing config to preserve other keys?
|
||||
|
||||
+11
-3
@@ -48,13 +48,21 @@ def _version_callback(value: bool) -> None:
|
||||
typer.echo(f"devgen version: {version}")
|
||||
else:
|
||||
typer.secho(
|
||||
"Error: Version not found in pyproject.toml",
|
||||
"Could not determine version: no `project.version` or "
|
||||
"`tool.poetry.version` found in pyproject.toml.",
|
||||
fg=typer.colors.RED,
|
||||
err=True,
|
||||
)
|
||||
except (FileNotFoundError, KeyError, Exception) as e:
|
||||
except FileNotFoundError:
|
||||
typer.secho(
|
||||
f"Error: Could not determine version: {e}",
|
||||
f"pyproject.toml not found at {pyproject_path}. "
|
||||
"The installation may be corrupt.",
|
||||
fg=typer.colors.RED,
|
||||
err=True,
|
||||
)
|
||||
except toml.TomlDecodeError as e:
|
||||
typer.secho(
|
||||
f"pyproject.toml is not valid TOML: {e}",
|
||||
fg=typer.colors.RED,
|
||||
err=True,
|
||||
)
|
||||
|
||||
+28
-6
@@ -33,7 +33,14 @@ def setup_config() -> None:
|
||||
# Questions
|
||||
provider = questionary.select(
|
||||
"Select AI Provider:",
|
||||
choices=["gemini", "openai", "huggingface", "openrouter", "anthropic"],
|
||||
choices=[
|
||||
"gemini",
|
||||
"openai",
|
||||
"huggingface",
|
||||
"openrouter",
|
||||
"anthropic",
|
||||
"ollama",
|
||||
],
|
||||
default=current_config.get("provider", "gemini"),
|
||||
style=style,
|
||||
).ask()
|
||||
@@ -67,12 +74,27 @@ def setup_config() -> None:
|
||||
raise typer.Exit(code=130)
|
||||
emoji = emoji_choice == "Yes"
|
||||
|
||||
# Save Config
|
||||
ollama_host = current_config.get("ollama_host", "http://localhost:11434")
|
||||
if provider == "ollama":
|
||||
ollama_host_input = questionary.text(
|
||||
"Ollama server URL:",
|
||||
default=ollama_host,
|
||||
style=style,
|
||||
).ask()
|
||||
if ollama_host_input is None:
|
||||
raise typer.Exit(code=130)
|
||||
ollama_host = ollama_host_input.strip() or ollama_host
|
||||
|
||||
# Save Config — merge with existing to preserve extra keys
|
||||
new_config = {
|
||||
"provider": provider,
|
||||
"model": model,
|
||||
"api_key": api_key,
|
||||
"emoji": emoji,
|
||||
**current_config,
|
||||
**{
|
||||
"provider": provider,
|
||||
"model": model,
|
||||
"api_key": api_key,
|
||||
"emoji": emoji,
|
||||
"ollama_host": ollama_host,
|
||||
},
|
||||
}
|
||||
|
||||
try:
|
||||
|
||||
@@ -5,179 +5,155 @@ from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from devgen.modules.changelog_sections import (
|
||||
DEFAULT_STYLES,
|
||||
Section,
|
||||
section_for_type,
|
||||
)
|
||||
from devgen.utils import configure_logger, run_git_command
|
||||
|
||||
|
||||
class ChangelogGenerator:
|
||||
"""Generates a changelog from git history using Semantic Release style."""
|
||||
|
||||
#: Conventional commit header: ``type(scope)!: subject``
|
||||
_CC_PATTERN = re.compile(r"^(\w+)(?:\(([^)]+)\))?(!?):\s+(.*)")
|
||||
|
||||
def __init__(self, logger=None):
|
||||
self.logger = logger or configure_logger("devgen.changelog")
|
||||
|
||||
# ------------------------------------------------------------------ commits
|
||||
|
||||
def get_commits(self, from_ref: str = "", to_ref: str = "HEAD") -> List[str]:
|
||||
"""Fetches commit messages in the specified range."""
|
||||
range_spec = f"{from_ref}..{to_ref}" if from_ref else to_ref
|
||||
# Format: hash|author|date|subject|body
|
||||
fmt = "%H|%an|%ad|%s|%b"
|
||||
cmd = ["git", "log", f"--format={fmt}", "--date=short", range_spec]
|
||||
|
||||
if not from_ref:
|
||||
# If no start ref, try to find the last tag
|
||||
try:
|
||||
last_tag = run_git_command(["git", "describe", "--tags", "--abbrev=0"])
|
||||
cmd = [
|
||||
"git",
|
||||
"log",
|
||||
f"--format={fmt}",
|
||||
"--date=short",
|
||||
f"{last_tag}..HEAD",
|
||||
]
|
||||
self.logger.info(f"Generating changelog from last tag: {last_tag}")
|
||||
except (RuntimeError, subprocess.CalledProcessError):
|
||||
self.logger.info("No tags found, generating for all commits.")
|
||||
cmd = ["git", "log", f"--format={fmt}", "--date=short"]
|
||||
|
||||
"""Fetch commit lines in the requested range."""
|
||||
if from_ref:
|
||||
range_spec = f"{from_ref}..{to_ref}"
|
||||
cmd = self._log_cmd(range_spec)
|
||||
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}")
|
||||
raise RuntimeError(f"Git command failed: {e}") from e
|
||||
|
||||
def parse_commits(self, raw_commits: List[str]) -> Dict[str, List[Dict]]:
|
||||
"""Parses raw commit strings into structured data."""
|
||||
groups = defaultdict(list)
|
||||
# Conventional Commit Regex: type(scope)!: subject
|
||||
cc_pattern = re.compile(r"^(\w+)(?:\(([^)]+)\))?(!?):\s+(.*)")
|
||||
|
||||
for line in raw_commits:
|
||||
if not line.strip():
|
||||
continue
|
||||
|
||||
parts = line.split("|", 4)
|
||||
if len(parts) < 4:
|
||||
continue
|
||||
|
||||
commit_hash, author, date, subject, body = (
|
||||
parts[0],
|
||||
parts[1],
|
||||
parts[2],
|
||||
parts[3],
|
||||
parts[4] if len(parts) > 4 else "",
|
||||
)
|
||||
|
||||
match = cc_pattern.match(subject)
|
||||
if match:
|
||||
c_type, c_scope, breaking, c_subject = match.groups()
|
||||
entry = {
|
||||
"hash": commit_hash,
|
||||
"author": author,
|
||||
"date": date,
|
||||
"scope": c_scope,
|
||||
"subject": c_subject,
|
||||
"body": body,
|
||||
"breaking": bool(breaking),
|
||||
}
|
||||
|
||||
if breaking:
|
||||
groups["BREAKING CHANGES"].append(entry)
|
||||
|
||||
if c_type in ["feat", "feature"]:
|
||||
groups["Features"].append(entry)
|
||||
elif c_type in ["fix", "bug"]:
|
||||
groups["Bug Fixes"].append(entry)
|
||||
elif c_type in ["docs"]:
|
||||
groups["Documentation"].append(entry)
|
||||
elif c_type in [
|
||||
"style",
|
||||
"refactor",
|
||||
"perf",
|
||||
"test",
|
||||
"build",
|
||||
"ci",
|
||||
"chore",
|
||||
]:
|
||||
groups["Other Changes"].append(entry)
|
||||
else:
|
||||
groups["Other Changes"].append(entry)
|
||||
else:
|
||||
# Non-conventional commits
|
||||
groups["Other Changes"].append(
|
||||
{
|
||||
"hash": commit_hash,
|
||||
"author": author,
|
||||
"date": date,
|
||||
"scope": None,
|
||||
"subject": subject,
|
||||
"body": body,
|
||||
"breaking": False,
|
||||
}
|
||||
)
|
||||
|
||||
return groups
|
||||
|
||||
def generate_markdown(
|
||||
self, groups: Dict[str, List[Dict]], version: str = "Unreleased"
|
||||
) -> str:
|
||||
"""Generates markdown changelog from parsed commits."""
|
||||
date_str = datetime.now().strftime("%Y-%m-%d")
|
||||
md = [f"## {version} ({date_str})\n"]
|
||||
|
||||
# Order: Breaking, Features, Fixes, Docs, Others
|
||||
order = [
|
||||
"BREAKING CHANGES",
|
||||
"Features",
|
||||
"Bug Fixes",
|
||||
"Documentation",
|
||||
"Other Changes",
|
||||
def _log_cmd(self, range_spec: str) -> List[str]:
|
||||
# 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%x00",
|
||||
"--date=short",
|
||||
range_spec,
|
||||
]
|
||||
|
||||
emoji_map = {
|
||||
"BREAKING CHANGES": "💥 BREAKING CHANGES",
|
||||
"Features": "✨ Features",
|
||||
"Bug Fixes": "🐛 Bug Fixes",
|
||||
"Documentation": "📚 Documentation",
|
||||
"Other Changes": "🔨 Other Changes",
|
||||
def _resolve_range(self, to_ref: str) -> List[str]:
|
||||
try:
|
||||
last_tag = run_git_command(["git", "describe", "--tags", "--abbrev=0"])
|
||||
self.logger.info(f"Generating changelog from last tag: {last_tag}")
|
||||
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%x00", "--date=short"]
|
||||
|
||||
# ----------------------------------------------------------------- parsing
|
||||
|
||||
def parse_commits(self, raw_commits: List[str]) -> Dict[Section, List[Dict]]:
|
||||
"""Group raw commit lines into :class:`Section` buckets."""
|
||||
groups: Dict[Section, List[Dict]] = defaultdict(list)
|
||||
for line in raw_commits:
|
||||
entry = self._parse_line(line)
|
||||
if entry is None:
|
||||
continue
|
||||
section = section_for_type(entry["type"])
|
||||
entry["section"] = section
|
||||
if entry["breaking"]:
|
||||
groups[Section.BREAKING].append(entry)
|
||||
groups[section].append(entry)
|
||||
return dict(groups)
|
||||
|
||||
def _parse_line(self, line: str) -> Dict | None:
|
||||
if not line.strip():
|
||||
return None
|
||||
parts = line.split("|", 4)
|
||||
if len(parts) < 4:
|
||||
return None
|
||||
commit_hash, author, date, subject = parts[:4]
|
||||
body = parts[4] if len(parts) > 4 else ""
|
||||
|
||||
match = self._CC_PATTERN.match(subject)
|
||||
if match:
|
||||
c_type, c_scope, breaking, c_subject = match.groups()
|
||||
return {
|
||||
"type": c_type,
|
||||
"hash": commit_hash,
|
||||
"author": author,
|
||||
"date": date,
|
||||
"scope": c_scope,
|
||||
"subject": c_subject,
|
||||
"body": body,
|
||||
"breaking": bool(breaking),
|
||||
}
|
||||
return {
|
||||
"type": "",
|
||||
"hash": commit_hash,
|
||||
"author": author,
|
||||
"date": date,
|
||||
"scope": None,
|
||||
"subject": subject,
|
||||
"body": body,
|
||||
"breaking": False,
|
||||
}
|
||||
|
||||
for section in order:
|
||||
commits = groups.get(section)
|
||||
if commits:
|
||||
header = emoji_map.get(section, section)
|
||||
md.append(f"## {header}\n")
|
||||
for c in commits:
|
||||
scope = f"**{c['scope']}**: " if c["scope"] else ""
|
||||
md.append(f"- {scope}{c['subject']} ({c['hash'][:7]})")
|
||||
md.append("")
|
||||
# ------------------------------------------------------------------ output
|
||||
|
||||
return "\n".join(md)
|
||||
def generate_markdown(
|
||||
self,
|
||||
groups: Dict[Section, List[Dict]],
|
||||
version: str = "Unreleased",
|
||||
) -> str:
|
||||
"""Render groups as a markdown changelog block."""
|
||||
date_str = datetime.now().strftime("%Y-%m-%d")
|
||||
out = [f"## {version} ({date_str})\n"]
|
||||
for section in Section.ordered():
|
||||
commits = groups.get(section)
|
||||
if not commits:
|
||||
continue
|
||||
style = DEFAULT_STYLES[section]
|
||||
out.append(f"## {style.emoji} {style.heading}\n")
|
||||
for c in commits:
|
||||
scope = f"**{c['scope']}**: " if c.get("scope") else ""
|
||||
out.append(f"- {scope}{c['subject']} ({c['hash'][:7]})")
|
||||
out.append("")
|
||||
return "\n".join(out)
|
||||
|
||||
def run(self, output_file: Optional[str] = "CHANGELOG.md", from_ref: str = ""):
|
||||
"""Main execution method."""
|
||||
"""Fetch → parse → write (or print) the changelog."""
|
||||
raw_commits = self.get_commits(from_ref)
|
||||
if not raw_commits or not raw_commits[0]:
|
||||
self.logger.warning("No commits found.")
|
||||
return
|
||||
groups = self.parse_commits(raw_commits)
|
||||
md_content = self.generate_markdown(groups)
|
||||
|
||||
parsed = self.parse_commits(raw_commits)
|
||||
md_content = self.generate_markdown(parsed)
|
||||
|
||||
if output_file:
|
||||
path = Path(output_file)
|
||||
if path.exists():
|
||||
old_content = path.read_text(encoding="utf-8")
|
||||
# Prepend the new content, assume # CHANGELOG is at the top or needs to be
|
||||
if old_content.strip().startswith("# CHANGELOG"):
|
||||
lines = old_content.split("\n", 1)
|
||||
header = lines[0]
|
||||
rest = lines[1] if len(lines) > 1 else ""
|
||||
new_content = f"{header}\n\n{md_content}\n{rest.lstrip()}"
|
||||
else:
|
||||
new_content = f"# CHANGELOG\n\n{md_content}\n\n{old_content}"
|
||||
path.write_text(new_content, encoding="utf-8")
|
||||
else:
|
||||
path.write_text(f"# CHANGELOG\n\n{md_content}", encoding="utf-8")
|
||||
|
||||
self.logger.info(f"Changelog written to {output_file}")
|
||||
print(f" Changelog updated: {output_file}")
|
||||
else:
|
||||
if not output_file:
|
||||
print(md_content)
|
||||
return
|
||||
|
||||
path = Path(output_file)
|
||||
if path.exists():
|
||||
old = path.read_text(encoding="utf-8")
|
||||
if old.strip().startswith("# CHANGELOG"):
|
||||
header, _, rest = old.partition("\n")
|
||||
new_content = f"{header}\n\n{md_content}\n{rest}"
|
||||
else:
|
||||
new_content = f"# CHANGELOG\n\n{md_content}\n\n{old}"
|
||||
else:
|
||||
new_content = f"# CHANGELOG\n\n{md_content}"
|
||||
path.write_text(new_content, encoding="utf-8")
|
||||
self.logger.info(f"Changelog written to {output_file}")
|
||||
print(f" Changelog updated: {output_file}")
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Shared section definitions for changelog and release-note generators.
|
||||
|
||||
Both generators render the same set of Conventional Commit groups; this
|
||||
module is the single source of truth for the section name, the emoji,
|
||||
and the display order.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import Mapping
|
||||
|
||||
|
||||
class Section(str, Enum):
|
||||
"""Sections in the order they should be rendered."""
|
||||
|
||||
BREAKING = "BREAKING CHANGES"
|
||||
FEATURES = "Features"
|
||||
BUG_FIXES = "Bug Fixes"
|
||||
PERFORMANCE = "Performance"
|
||||
DOCUMENTATION = "Documentation"
|
||||
REFACTOR = "Refactor"
|
||||
TESTS = "Tests"
|
||||
STYLE = "Style"
|
||||
CHORE = "Chore"
|
||||
OTHER = "Other Changes"
|
||||
|
||||
@classmethod
|
||||
def ordered(cls) -> tuple["Section", ...]:
|
||||
return tuple(cls)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SectionStyle:
|
||||
"""Visual style for a section (emoji + name for the heading)."""
|
||||
|
||||
emoji: str
|
||||
heading: str
|
||||
|
||||
|
||||
# Default style for each section. Generators can override per output
|
||||
# format (changelog vs. release notes).
|
||||
DEFAULT_STYLES: Mapping[Section, SectionStyle] = {
|
||||
Section.BREAKING: SectionStyle("💥", "BREAKING CHANGES"),
|
||||
Section.FEATURES: SectionStyle("✨", "Features"),
|
||||
Section.BUG_FIXES: SectionStyle("🐛", "Bug Fixes"),
|
||||
Section.PERFORMANCE: SectionStyle("⚡", "Performance"),
|
||||
Section.DOCUMENTATION: SectionStyle("📚", "Documentation"),
|
||||
Section.REFACTOR: SectionStyle("♻️", "Refactor"),
|
||||
Section.TESTS: SectionStyle("✅", "Tests"),
|
||||
Section.STYLE: SectionStyle("💄", "Style"),
|
||||
Section.CHORE: SectionStyle("🔧", "Chore"),
|
||||
Section.OTHER: SectionStyle("🧹", "Other Changes"),
|
||||
}
|
||||
|
||||
|
||||
# Conventional commit type → Section mapping. Long-form aliases ("feature",
|
||||
# "bug") are also accepted.
|
||||
TYPE_TO_SECTION: Mapping[str, Section] = {
|
||||
"feat": Section.FEATURES,
|
||||
"feature": Section.FEATURES,
|
||||
"fix": Section.BUG_FIXES,
|
||||
"bug": Section.BUG_FIXES,
|
||||
"perf": Section.PERFORMANCE,
|
||||
"docs": Section.DOCUMENTATION,
|
||||
"refactor": Section.REFACTOR,
|
||||
"test": Section.TESTS,
|
||||
"style": Section.STYLE,
|
||||
"build": Section.CHORE,
|
||||
"ci": Section.CHORE,
|
||||
"chore": Section.CHORE,
|
||||
}
|
||||
|
||||
|
||||
def section_for_type(commit_type: str) -> Section:
|
||||
"""Return the Section for a Conventional Commit ``type``.
|
||||
|
||||
Unknown types fall back to :attr:`Section.OTHER`.
|
||||
"""
|
||||
return TYPE_TO_SECTION.get(commit_type.lower(), Section.OTHER)
|
||||
+304
-422
@@ -1,51 +1,138 @@
|
||||
"""Orchestrates the AI-powered commit flow.
|
||||
|
||||
The :class:`CommitEngine` is a thin coordinator. The real work lives in:
|
||||
|
||||
* :class:`devgen.modules.git_ops.GitOperator` — all git side-effects
|
||||
* :class:`devgen.modules.diff_builder.FileGrouper` — files → commit groups
|
||||
* :class:`devgen.modules.diff_builder.DiffBuilder` — staged diff + truncation
|
||||
* :class:`devgen.modules.diff_builder.ManifestInspector` — project context
|
||||
* :class:`devgen.ai.generate_with_ai` — provider-agnostic AI call
|
||||
"""
|
||||
|
||||
import sys
|
||||
import questionary
|
||||
import subprocess
|
||||
from collections import defaultdict
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import questionary
|
||||
from rich.console import Console
|
||||
from rich.markdown import Markdown
|
||||
from rich.panel import Panel
|
||||
from rich.theme import Theme
|
||||
|
||||
from devgen.ai import generate_with_ai
|
||||
from devgen.modules.diff_builder import (
|
||||
DEFAULT_MAX_DIFF_SIZE,
|
||||
DiffBuilder,
|
||||
FileGrouper,
|
||||
ManifestInspector,
|
||||
)
|
||||
from devgen.modules.git_ops import GitOperator
|
||||
from devgen.utils import (
|
||||
configure_logger,
|
||||
extract_commit_messages,
|
||||
get_commit_dry_run_path,
|
||||
is_file_recent,
|
||||
load_template_env,
|
||||
run_git_command,
|
||||
is_token_limit_error,
|
||||
format_token_limit_error,
|
||||
load_template,
|
||||
render_template,
|
||||
sanitize_ai_commit_message,
|
||||
render_custom_template,
|
||||
)
|
||||
from rich.console import Console
|
||||
from rich.panel import Panel
|
||||
from rich.markdown import Markdown
|
||||
from rich.theme import Theme
|
||||
|
||||
|
||||
class CommitEngineError(Exception):
|
||||
"""Exception raised for errors in the commit engine."""
|
||||
|
||||
pass
|
||||
"""Raised when the commit flow cannot proceed."""
|
||||
|
||||
|
||||
# Token Optimization Constants
|
||||
MAX_DIFF_SIZE = 8000 # Maximum characters for a single group diff
|
||||
IGNORE_PATTERNS = [
|
||||
"uv.lock",
|
||||
"package-lock.json",
|
||||
"pnpm-lock.yaml",
|
||||
"yarn.lock",
|
||||
"composer.lock",
|
||||
"Gemfile.lock",
|
||||
"poetry.lock",
|
||||
]
|
||||
class CacheManager:
|
||||
"""Manages the dry-run cache file at ``~/.cache/devgen/commit_dry_run.md``."""
|
||||
|
||||
def __init__(self, path: Path, max_age_minutes: int = 120) -> None:
|
||||
self.path = path
|
||||
self.max_age_minutes = max_age_minutes
|
||||
|
||||
def init_dry_run(self) -> None:
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
ts = datetime.now().astimezone().strftime("%Y-%m-%d %H:%M:%S (%Z)")
|
||||
with self.path.open("w", encoding="utf-8") as f:
|
||||
f.write(f"# Dry Run: Commit Messages\n_Generated: {ts}_\n\n")
|
||||
|
||||
def load(self, *, dry_run: bool, force_rebuild: bool) -> Dict[str, str]:
|
||||
if dry_run:
|
||||
self.init_dry_run()
|
||||
return {}
|
||||
if not force_rebuild and is_file_recent(self.path, self.max_age_minutes):
|
||||
return extract_commit_messages(self.path)
|
||||
return {}
|
||||
|
||||
def append(self, group: str, message: str) -> None:
|
||||
with self.path.open("a", encoding="utf-8") as f:
|
||||
f.write(f"## Group: `{group}`\n\n```md\n{message}\n```\n\n---\n\n")
|
||||
|
||||
|
||||
class CommitMessageBuilder:
|
||||
"""Renders the prompt and calls the AI provider."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
template_str: str,
|
||||
config: Dict[str, Any],
|
||||
provider: str,
|
||||
model: str,
|
||||
debug: bool = False,
|
||||
) -> None:
|
||||
self.template_str = template_str
|
||||
self.config = config
|
||||
self.provider = provider
|
||||
self.model = model
|
||||
self.debug = debug
|
||||
|
||||
def build_message(
|
||||
self,
|
||||
group: str,
|
||||
diff: str,
|
||||
manifest_context: str,
|
||||
extra_kwargs: Dict[str, Any],
|
||||
) -> str:
|
||||
custom_template = self.config.get("custom_template")
|
||||
use_emoji = self.config.get("emoji", True)
|
||||
ollama_host = self.config.get("ollama_host")
|
||||
|
||||
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
|
||||
)
|
||||
model = extra_kwargs.get("model") or self.config.get("model") or self.model
|
||||
api_key = extra_kwargs.get("api_key") or self.config.get("api_key")
|
||||
|
||||
call_kwargs = dict(extra_kwargs)
|
||||
if ollama_host and provider == "ollama":
|
||||
call_kwargs.setdefault("ollama_host", ollama_host)
|
||||
return generate_with_ai(
|
||||
prompt,
|
||||
provider=provider,
|
||||
model=model,
|
||||
api_key=api_key,
|
||||
debug=self.debug,
|
||||
**call_kwargs,
|
||||
)
|
||||
|
||||
|
||||
class CommitEngine:
|
||||
"""
|
||||
Engine for generating AI-powered commit messages.
|
||||
Handles detection of changes, grouping, AI generation, and git operations.
|
||||
"""Coordinates the AI-powered commit flow.
|
||||
|
||||
The constructor signature is stable (used by ``cli/commit.py``) but
|
||||
most logic now lives in composed objects. The public ``execute()``
|
||||
method remains the single entry point.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
@@ -58,8 +145,10 @@ class CommitEngine:
|
||||
provider: str = "gemini",
|
||||
model: str = "gemini-2.5-flash",
|
||||
logger: Any | None = None,
|
||||
**kwargs,
|
||||
):
|
||||
max_groups: int | None = None,
|
||||
max_diff_size: int | None = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
self.dry_run = dry_run
|
||||
self.push = push
|
||||
self.debug = debug
|
||||
@@ -73,437 +162,230 @@ class CommitEngine:
|
||||
console=debug,
|
||||
)
|
||||
self.kwargs = kwargs
|
||||
self.dry_run_path = get_commit_dry_run_path()
|
||||
self.template_env = load_template_env("commit")
|
||||
|
||||
# CLI flags win over config; config wins over default.
|
||||
from devgen.utils import load_config
|
||||
|
||||
self.config = load_config()
|
||||
|
||||
self.console = Console(
|
||||
theme=Theme(
|
||||
{"info": "dim cyan", "warning": "magenta", "danger": "bold red"}
|
||||
)
|
||||
)
|
||||
|
||||
# Load config from ~/.devgen.yaml
|
||||
from devgen.utils import load_config
|
||||
|
||||
self.config = load_config()
|
||||
|
||||
def detect_changes(self) -> List[str]:
|
||||
"""Detects changed, deleted, or untracked files."""
|
||||
try:
|
||||
# Get modified, deleted and untracked files
|
||||
out = run_git_command(
|
||||
[
|
||||
"git",
|
||||
"ls-files",
|
||||
"--deleted",
|
||||
"--modified",
|
||||
"--others",
|
||||
"--exclude-standard",
|
||||
]
|
||||
)
|
||||
files = [f.strip() for f in out.split("\n") if f.strip()]
|
||||
|
||||
# Also get staged files (in case of a previous failed run)
|
||||
staged_out = run_git_command(["git", "diff", "--name-only", "--cached"])
|
||||
staged_files = [f.strip() for f in staged_out.split("\n") if f.strip()]
|
||||
|
||||
# Combine and deduplicate
|
||||
all_files = list(set(files + staged_files))
|
||||
return all_files
|
||||
except subprocess.CalledProcessError as e:
|
||||
msg = f"Git command failed: {' '.join(e.cmd)}\nError: {e.stderr.strip()}"
|
||||
self.logger.error(msg)
|
||||
raise CommitEngineError(msg) from e
|
||||
|
||||
def group_files(self, files: List[str]) -> Dict[str, List[str]]:
|
||||
"""Groups files by their parent directory with smart merging if limit is exceeded."""
|
||||
max_groups = self.config.get("max_groups", 5)
|
||||
|
||||
# 1. Initial grouping by immediate parent
|
||||
groups = defaultdict(list)
|
||||
for f in files:
|
||||
parent = str(Path(f).parent)
|
||||
key = "root" if parent == "." else parent
|
||||
groups[key].append(f)
|
||||
|
||||
if len(groups) <= max_groups:
|
||||
return dict(groups)
|
||||
|
||||
self.logger.info(
|
||||
f"Too many groups ({len(groups)}). Merging based on max_groups={max_groups}"
|
||||
)
|
||||
|
||||
# 2. Iteratively merge the deepest group into its parent until we hit the limit
|
||||
while len(groups) > max_groups:
|
||||
# Find the deepest path among the current group keys
|
||||
# Skip 'root' as it's the top level
|
||||
potential_merges = [k for k in groups.keys() if k != "root"]
|
||||
if not potential_merges:
|
||||
# This could happen if only 'root' is left or if max_groups is very small
|
||||
break
|
||||
|
||||
# Deepest path is the one with the most segments
|
||||
deepest = max(potential_merges, key=lambda p: len(Path(p).parts))
|
||||
|
||||
# Find the parent of this deepest path
|
||||
parent_path = str(Path(deepest).parent)
|
||||
new_key = "root" if parent_path == "." else parent_path
|
||||
|
||||
# Merge files into the new key
|
||||
self.logger.debug(f"Merging group '{deepest}' into '{new_key}'")
|
||||
groups[new_key].extend(groups.pop(deepest))
|
||||
|
||||
return dict(groups)
|
||||
|
||||
def generate_diff(self, files: List[str]) -> str:
|
||||
"""Generates diff for specific files, with truncation for token optimization."""
|
||||
try:
|
||||
# Filter out very large metadata files that don't need full diffs
|
||||
summary_info = []
|
||||
files_to_diff = []
|
||||
for f in files:
|
||||
if any(p in f for p in IGNORE_PATTERNS):
|
||||
summary_info.append(f"[METADATA UPDATED] {f}")
|
||||
else:
|
||||
files_to_diff.append(f)
|
||||
|
||||
diff = ""
|
||||
if files_to_diff:
|
||||
diff = run_git_command(
|
||||
["git", "--no-pager", "diff", "--staged", "--", *files_to_diff]
|
||||
)
|
||||
|
||||
full_content = "\n".join(summary_info + [diff]).strip()
|
||||
|
||||
# Truncate if too large
|
||||
if len(full_content) > MAX_DIFF_SIZE:
|
||||
self.logger.info(
|
||||
f"Truncating diff from {len(full_content)} to {MAX_DIFF_SIZE} chars"
|
||||
)
|
||||
half = MAX_DIFF_SIZE // 2
|
||||
return (
|
||||
full_content[:half]
|
||||
+ "\n\n... [DIFF TRUNCATED FOR TOKEN OPTIMIZATION] ...\n\n"
|
||||
+ full_content[-half:]
|
||||
)
|
||||
|
||||
return full_content
|
||||
except subprocess.CalledProcessError as e:
|
||||
msg = f"Git command failed: {' '.join(e.cmd)}\nError: {e.stderr.strip()}"
|
||||
self.logger.error(msg)
|
||||
raise CommitEngineError(msg) from e
|
||||
|
||||
def _init_dry_run(self):
|
||||
"""Initializes the dry-run file."""
|
||||
self.dry_run_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with self.dry_run_path.open("w", encoding="utf-8") as f:
|
||||
ts = datetime.now().astimezone().strftime("%Y-%m-%d %H:%M:%S (%Z)")
|
||||
f.write(f"# Dry Run: Commit Messages\n_Generated: {ts}_\n\n")
|
||||
|
||||
def _log_dry_run(self, group: str, msg: str):
|
||||
"""Appends a dry-run entry and prints to console."""
|
||||
self.console.print(
|
||||
Panel(
|
||||
Markdown(msg),
|
||||
title=f"Dry Run: {group}",
|
||||
border_style="yellow",
|
||||
expand=False,
|
||||
self.git = GitOperator(logger=self.logger)
|
||||
self.grouper = FileGrouper(
|
||||
max_groups=(
|
||||
max_groups
|
||||
if max_groups is not None
|
||||
else self.config.get("max_groups", 5)
|
||||
)
|
||||
)
|
||||
with self.dry_run_path.open("a", encoding="utf-8") as f:
|
||||
f.write(f"## Group: `{group}`\n\n```md\n{msg}\n```\n\n---\n\n")
|
||||
|
||||
def stage_files(self, files: List[str]):
|
||||
"""Stages files in git."""
|
||||
if not files:
|
||||
return
|
||||
self.logger.info(f"Staging: {files}")
|
||||
self.console.print(f"[info]Staging {len(files)} files...[/info]")
|
||||
try:
|
||||
run_git_command(["git", "add", *files])
|
||||
except subprocess.CalledProcessError as e:
|
||||
msg = f"Git command failed: {' '.join(e.cmd)}\nError: {e.stderr.strip()}"
|
||||
self.logger.error(msg)
|
||||
raise CommitEngineError(msg) from e
|
||||
|
||||
def commit_staged(self, msg: str):
|
||||
"""Commits staged changes."""
|
||||
self.logger.info(f"Committing:\n{msg}")
|
||||
self.console.print(
|
||||
Panel(Markdown(msg), title="Commit Message", border_style="green")
|
||||
self.diff_builder = DiffBuilder(
|
||||
max_size=(
|
||||
max_diff_size
|
||||
if max_diff_size is not None
|
||||
else self.config.get("max_diff_size", DEFAULT_MAX_DIFF_SIZE)
|
||||
),
|
||||
logger=self.logger,
|
||||
)
|
||||
try:
|
||||
run_git_command(["git", "commit", "-m", msg])
|
||||
except subprocess.CalledProcessError as e:
|
||||
msg = f"Git command failed: {' '.join(e.cmd)}\nError: {e.stderr.strip()}"
|
||||
self.logger.error(msg)
|
||||
raise CommitEngineError(msg) from e
|
||||
|
||||
def push_commits(self):
|
||||
"""Pushes commits to remote."""
|
||||
self.logger.info("Pushing to remote...")
|
||||
with self.console.status("[bold green]Pushing to remote...[/bold green]"):
|
||||
try:
|
||||
run_git_command(["git", "push"])
|
||||
except subprocess.CalledProcessError as e:
|
||||
msg = (
|
||||
f"Git command failed: {' '.join(e.cmd)}\nError: {e.stderr.strip()}"
|
||||
)
|
||||
self.logger.error(msg)
|
||||
# Check for "no upstream branch" specifically to give a hint?
|
||||
if "no upstream branch" in msg.lower():
|
||||
self.console.print(
|
||||
"[warning]No upstream branch. Skipping push.[/warning]"
|
||||
)
|
||||
return
|
||||
raise CommitEngineError(msg) from e
|
||||
self.console.print("[bold green]Push successful.[/bold green]")
|
||||
|
||||
def _get_manifest_context(self) -> str:
|
||||
"""Finds and reads manifest files to provide context."""
|
||||
manifests = [
|
||||
"pyproject.toml",
|
||||
"package.json",
|
||||
"go.mod",
|
||||
"Cargo.toml",
|
||||
"Gemfile",
|
||||
"requirements.txt",
|
||||
"uv.lock",
|
||||
"package-lock.json",
|
||||
"pnpm-lock.yaml",
|
||||
"yarn.lock",
|
||||
"composer.lock",
|
||||
"Gemfile.lock",
|
||||
"poetry.lock",
|
||||
]
|
||||
found = []
|
||||
for m in manifests:
|
||||
path = Path(m)
|
||||
if path.exists():
|
||||
try:
|
||||
content = path.read_text(encoding="utf-8")
|
||||
# Take only first 100 lines to avoid token bloat
|
||||
lines = content.splitlines()
|
||||
summary = "\n".join(lines[:100])
|
||||
if len(lines) > 100:
|
||||
summary += "\n... (truncated)"
|
||||
found.append(f"File: {m}\n---\n{summary}\n---")
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if not found:
|
||||
return ""
|
||||
|
||||
return "\n\n### Project Context (Manifests)\n" + "\n".join(found)
|
||||
|
||||
def generate_message(self, group: str, diff: str, cache: Dict[str, str]) -> str:
|
||||
"""Generates a commit message using AI or cache."""
|
||||
if not self.force_rebuild and group in cache:
|
||||
self.logger.info(f"Using cached message for {group}")
|
||||
return cache[group]
|
||||
|
||||
# Get settings from config or kwargs
|
||||
provider = (
|
||||
self.kwargs.get("provider") or self.config.get("provider") or self.provider
|
||||
self.template_str = load_template("commit", "commit_message.tpl")
|
||||
self.cache = CacheManager(get_commit_dry_run_path())
|
||||
self.message_builder = CommitMessageBuilder(
|
||||
self.template_str,
|
||||
self.config,
|
||||
provider=self.provider,
|
||||
model=self.model,
|
||||
debug=self.debug,
|
||||
)
|
||||
model = self.kwargs.get("model") or self.config.get("model") or self.model
|
||||
api_key = self.kwargs.get("api_key") or self.config.get("api_key")
|
||||
use_emoji = self.config.get("emoji", True)
|
||||
custom_template = self.config.get("custom_template")
|
||||
|
||||
manifest_context = self._get_manifest_context()
|
||||
if manifest_context:
|
||||
self.logger.info("Including manifest context in prompt")
|
||||
# ------------------------------------------------------------------ public
|
||||
|
||||
if custom_template:
|
||||
self.logger.info("Using custom template from config")
|
||||
prompt = render_custom_template(
|
||||
custom_template,
|
||||
group_name=group,
|
||||
diff_text=diff,
|
||||
use_emoji=use_emoji,
|
||||
context=manifest_context,
|
||||
)
|
||||
else:
|
||||
template = self.template_env.get_template("commit_message.j2")
|
||||
prompt = template.render(
|
||||
group_name=group,
|
||||
diff_text=diff,
|
||||
use_emoji=use_emoji,
|
||||
context=manifest_context,
|
||||
)
|
||||
|
||||
# Automatically append emoji instruction based on global setting
|
||||
emoji_instr = "Use emojis (🚀, 🐛)." if use_emoji else "No emojis."
|
||||
prompt = f"{prompt.strip()}\n\n- {emoji_instr}"
|
||||
|
||||
with self.console.status("[bold blue]Generating commit message...[/bold blue]"):
|
||||
raw = generate_with_ai(
|
||||
prompt,
|
||||
provider=provider,
|
||||
model=model,
|
||||
api_key=api_key,
|
||||
debug=self.debug,
|
||||
**self.kwargs,
|
||||
)
|
||||
return sanitize_ai_commit_message(raw)
|
||||
|
||||
def is_ahead_of_remote(self) -> bool:
|
||||
"""Checks if local branch has unpushed commits."""
|
||||
try:
|
||||
run_git_command(["git", "fetch", "origin"])
|
||||
count = run_git_command(
|
||||
["git", "rev-list", "--count", "@{u}..HEAD"], check=False
|
||||
)
|
||||
if count and int(count) > 0:
|
||||
return True
|
||||
except (subprocess.CalledProcessError, CommitEngineError):
|
||||
# Maybe no upstream
|
||||
try:
|
||||
return bool(run_git_command(["git", "rev-parse", "HEAD"], check=False))
|
||||
except subprocess.CalledProcessError:
|
||||
return False
|
||||
return False
|
||||
|
||||
def load_cache(self) -> Dict[str, str]:
|
||||
"""Loads dry-run cache."""
|
||||
if self.dry_run:
|
||||
self._init_dry_run()
|
||||
return {}
|
||||
if not self.force_rebuild and is_file_recent(self.dry_run_path):
|
||||
self.logger.info(f"Loading cache from {self.dry_run_path}")
|
||||
return extract_commit_messages(self.dry_run_path)
|
||||
return {}
|
||||
|
||||
def process_group(
|
||||
self, group: str, files: List[str], cache: Dict[str, str]
|
||||
) -> bool:
|
||||
"""Processes a single file group."""
|
||||
self.stage_files(files)
|
||||
diff = self.generate_diff(files)
|
||||
|
||||
if not diff.strip():
|
||||
self.logger.info(f"Skipping empty diff for {group}")
|
||||
try:
|
||||
run_git_command(["git", "reset", "HEAD", "--", *files])
|
||||
except subprocess.CalledProcessError:
|
||||
pass # Ignore reset errors
|
||||
return True
|
||||
|
||||
msg = self.generate_message(group, diff, cache)
|
||||
try:
|
||||
if not msg:
|
||||
self.logger.error(f"Empty message for {group}")
|
||||
self._reset_group(files)
|
||||
return False
|
||||
|
||||
if self.dry_run:
|
||||
self._log_dry_run(group, msg)
|
||||
self._reset_group(files)
|
||||
else:
|
||||
if self.check:
|
||||
self.console.print(
|
||||
Panel(
|
||||
Markdown(msg),
|
||||
title=f"Proposed Commit Message [group: {group}]",
|
||||
border_style="cyan",
|
||||
)
|
||||
)
|
||||
choice = questionary.select(
|
||||
"How would you like to proceed?",
|
||||
choices=[
|
||||
"Confirm",
|
||||
"Edit",
|
||||
"Abort",
|
||||
],
|
||||
default="Confirm",
|
||||
).ask()
|
||||
|
||||
if not choice or choice == "Abort":
|
||||
self.logger.info(f"Commit aborted by user at group {group}")
|
||||
self._reset_group(files)
|
||||
raise KeyboardInterrupt("User aborted")
|
||||
|
||||
if choice == "Edit":
|
||||
msg = questionary.text(
|
||||
"Edit commit message:",
|
||||
multiline=True,
|
||||
default=msg,
|
||||
).ask()
|
||||
if not msg:
|
||||
self.logger.info(
|
||||
f"Empty edit, commit cancelled for {group}"
|
||||
)
|
||||
self._reset_group(files)
|
||||
return True
|
||||
|
||||
self.commit_staged(msg)
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to process group {group}: {e}")
|
||||
self._reset_group(files)
|
||||
return False
|
||||
|
||||
def _reset_group(self, files: List[str]):
|
||||
"""Unstages files for a group."""
|
||||
try:
|
||||
run_git_command(["git", "reset", "HEAD", "--", *files])
|
||||
except subprocess.CalledProcessError:
|
||||
pass # Ignore reset errors
|
||||
|
||||
def execute(self):
|
||||
"""Main execution method."""
|
||||
files = self.detect_changes()
|
||||
ahead = self.is_ahead_of_remote()
|
||||
def execute(self) -> None:
|
||||
files = self.git.detect_changed()
|
||||
# Only consult the remote state if the user wants to push —
|
||||
# avoids a network round-trip on every run.
|
||||
ahead = self.git.is_ahead_of_remote(fetch=self.push) if self.push else False
|
||||
|
||||
if not files and not ahead:
|
||||
self.logger.info("Nothing to commit or push.")
|
||||
return
|
||||
|
||||
failed = []
|
||||
failed: list[str] = []
|
||||
if files:
|
||||
groups = self.group_files(files)
|
||||
cache = self.load_cache()
|
||||
|
||||
for group, group_files in groups.items():
|
||||
try:
|
||||
if not self.process_group(group, group_files, cache):
|
||||
failed.append(group)
|
||||
except KeyboardInterrupt:
|
||||
self.logger.warning("\nOperation interrupted by user.")
|
||||
raise
|
||||
else:
|
||||
self.logger.info("No changes to commit, checking push...")
|
||||
failed = self._process_groups(files)
|
||||
|
||||
if self.push and not self.dry_run:
|
||||
if not failed:
|
||||
self.push_commits()
|
||||
self.git.push()
|
||||
self.console.print("[bold green]Push successful.[/bold green]")
|
||||
else:
|
||||
self.logger.error("Push aborted due to failed commits.")
|
||||
|
||||
if self.dry_run:
|
||||
self.console.print(
|
||||
f"[bold green]Dry run done.[/bold green] See {self.dry_run_path}"
|
||||
f"[bold green]Dry run done.[/bold green] See {self.cache.path}"
|
||||
)
|
||||
else:
|
||||
self.console.print("[bold green]Done.[/bold green]")
|
||||
if failed:
|
||||
self.console.print(f"[bold red]Failed groups: {failed}[/bold red]")
|
||||
|
||||
# ------------------------------------------------------------------ groups
|
||||
|
||||
def run_commit_engine(**kwargs):
|
||||
"""Entry point for the commit engine."""
|
||||
def _process_groups(self, files: List[str]) -> list[str]:
|
||||
groups = self.grouper.group(files)
|
||||
cache_map = self.cache.load(
|
||||
dry_run=self.dry_run, force_rebuild=self.force_rebuild
|
||||
)
|
||||
failed: list[str] = []
|
||||
for i, (group, group_files) in enumerate(groups.items()):
|
||||
if not self._process_group(group, group_files, cache_map):
|
||||
failed.append(group)
|
||||
if len(groups) > 1 and i < len(groups) - 1:
|
||||
if self.provider != "ollama":
|
||||
import time
|
||||
|
||||
time.sleep(15) # Conservative 4 RPM guard for free-tier
|
||||
return failed
|
||||
|
||||
def _process_group(
|
||||
self,
|
||||
group: str,
|
||||
files: List[str],
|
||||
cache: Dict[str, str],
|
||||
) -> bool:
|
||||
self.git.stage(files)
|
||||
diff = self.diff_builder.build(files)
|
||||
|
||||
if not diff.strip():
|
||||
self.logger.info(f"Skipping empty diff for {group}")
|
||||
self.git.reset(files)
|
||||
return True
|
||||
|
||||
if not self.force_rebuild and group in cache:
|
||||
self.logger.info(f"Using cached message for {group}")
|
||||
return self._apply_message(group, cache[group], files)
|
||||
|
||||
try:
|
||||
manifest_context = ManifestInspector.summary(cwd=self.git.cwd)
|
||||
if manifest_context:
|
||||
self.logger.info("Including manifest context in prompt")
|
||||
with self.console.status(
|
||||
"[bold blue]Generating commit message...[/bold blue]"
|
||||
):
|
||||
raw = self.message_builder.build_message(
|
||||
group=group,
|
||||
diff=diff,
|
||||
manifest_context=manifest_context,
|
||||
extra_kwargs=self.kwargs,
|
||||
)
|
||||
message = sanitize_ai_commit_message(raw)
|
||||
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(
|
||||
f"Failed to generate message for group {group!r}: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
self.git.reset(files)
|
||||
return False
|
||||
|
||||
if not message:
|
||||
self.logger.error(f"Empty message for {group}")
|
||||
self.git.reset(files)
|
||||
return False
|
||||
|
||||
return self._apply_message(group, message, files)
|
||||
|
||||
def _apply_message(
|
||||
self,
|
||||
group: str,
|
||||
message: str,
|
||||
files: List[str],
|
||||
) -> bool:
|
||||
if self.dry_run:
|
||||
self.console.print(
|
||||
Panel(
|
||||
Markdown(message),
|
||||
title=f"Dry Run: {group}",
|
||||
border_style="yellow",
|
||||
expand=False,
|
||||
)
|
||||
)
|
||||
self.cache.append(group, message)
|
||||
self.git.reset(files)
|
||||
return True
|
||||
|
||||
if self.check:
|
||||
message = self._review_with_user(group, message, files)
|
||||
if message is None:
|
||||
return False
|
||||
|
||||
try:
|
||||
self.console.print(
|
||||
Panel(Markdown(message), title="Commit Message", border_style="green")
|
||||
)
|
||||
self.git.commit(message)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to process group {group}: {e}", exc_info=True)
|
||||
self.git.reset(files)
|
||||
return False
|
||||
return True
|
||||
|
||||
def _review_with_user(
|
||||
self,
|
||||
group: str,
|
||||
message: str,
|
||||
files: List[str],
|
||||
) -> Optional[str]:
|
||||
"""Present the message; return the final message, or None to abort."""
|
||||
self.console.print(
|
||||
Panel(
|
||||
Markdown(message),
|
||||
title=f"Proposed Commit Message [group: {group}]",
|
||||
border_style="cyan",
|
||||
)
|
||||
)
|
||||
choice = questionary.select(
|
||||
"How would you like to proceed?",
|
||||
choices=["Confirm", "Edit", "Abort"],
|
||||
default="Confirm",
|
||||
).ask()
|
||||
|
||||
if not choice or choice == "Abort":
|
||||
self.logger.info(f"Commit aborted by user at group {group}")
|
||||
self.git.reset(files)
|
||||
return None
|
||||
if choice == "Edit":
|
||||
edited = questionary.text(
|
||||
"Edit commit message:",
|
||||
multiline=True,
|
||||
default=message,
|
||||
).ask()
|
||||
if not edited:
|
||||
self.logger.info(f"Empty edit, commit cancelled for {group}")
|
||||
self.git.reset(files)
|
||||
return None
|
||||
return edited
|
||||
return message
|
||||
|
||||
|
||||
def run_commit_engine(**kwargs: Any) -> None:
|
||||
"""Entry point used by the CLI."""
|
||||
debug = kwargs.get("debug", False)
|
||||
logger = configure_logger("devgen.commit", console=debug)
|
||||
logger = configure_logger("devgen.cli.commit", console=debug)
|
||||
try:
|
||||
engine = CommitEngine(**kwargs)
|
||||
engine.execute()
|
||||
CommitEngine(**kwargs).execute()
|
||||
except KeyboardInterrupt:
|
||||
logger.warning("Interrupted by user. Exiting...")
|
||||
sys.exit(0)
|
||||
except Exception as e:
|
||||
logger.error(f"Commit engine failed: {e}", exc_info=True)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
__all__ = ["run_commit_engine"]
|
||||
__all__ = ["CommitEngine", "CommitEngineError", "run_commit_engine"]
|
||||
@@ -0,0 +1,222 @@
|
||||
"""Builds the prompt inputs for the commit engine.
|
||||
|
||||
Three small composable classes:
|
||||
|
||||
* :class:`FileGrouper` — groups a flat file list into per-commit folders
|
||||
* :class:`DiffBuilder` — produces a (possibly truncated) staged diff
|
||||
* :class:`ManifestInspector` — extracts a one-line-per-file project summary
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from devgen.utils import get_git_root, run_git_command
|
||||
|
||||
|
||||
# Lock files and other metadata blobs whose full diff would just bloat the
|
||||
# prompt. We mark them as updated and skip the actual diff.
|
||||
IGNORED_DIFF_PATTERNS = (
|
||||
"uv.lock",
|
||||
"package-lock.json",
|
||||
"pnpm-lock.yaml",
|
||||
"yarn.lock",
|
||||
"composer.lock",
|
||||
"Gemfile.lock",
|
||||
"poetry.lock",
|
||||
)
|
||||
|
||||
# Default cap for a single group diff. Real-world prompts above this size
|
||||
# are likely to hit the model's context window; the cap is overridable
|
||||
# via ``max_diff_size`` in the config.
|
||||
DEFAULT_MAX_DIFF_SIZE = 8000
|
||||
|
||||
# Manifests we read for project context, in priority order. Lock files
|
||||
# are excluded — they're noisy and rarely change in a way that informs
|
||||
# a commit message.
|
||||
_MANIFEST_FILES = (
|
||||
"pyproject.toml",
|
||||
"package.json",
|
||||
"requirements.txt",
|
||||
)
|
||||
|
||||
|
||||
class DiffBuilder:
|
||||
"""Produces a staged diff for a list of paths with truncation."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
max_size: int = DEFAULT_MAX_DIFF_SIZE,
|
||||
cwd: Optional[Path] = None,
|
||||
logger=None,
|
||||
) -> None:
|
||||
self.max_size = max_size
|
||||
self.cwd = cwd or get_git_root()
|
||||
self.logger = logger
|
||||
|
||||
def build(self, files: List[str]) -> str:
|
||||
summary, to_diff = self._partition(files)
|
||||
diff = self._run_staged_diff(to_diff)
|
||||
full = "\n".join(summary + [diff]).strip()
|
||||
if len(full) > self.max_size:
|
||||
return self._truncate(full)
|
||||
return full
|
||||
|
||||
# --- internals --------------------------------------------------------
|
||||
|
||||
def _partition(self, files: List[str]) -> tuple[list[str], list[str]]:
|
||||
summary, to_diff = [], []
|
||||
for f in files:
|
||||
if Path(f).name in IGNORED_DIFF_PATTERNS:
|
||||
summary.append(f"[METADATA UPDATED] {f}")
|
||||
else:
|
||||
to_diff.append(f)
|
||||
return summary, to_diff
|
||||
|
||||
def _run_staged_diff(self, files: List[str]) -> str:
|
||||
if not files:
|
||||
return ""
|
||||
try:
|
||||
return run_git_command(
|
||||
["git", "--no-pager", "diff", "--staged", "--", *files],
|
||||
cwd=self.cwd,
|
||||
)
|
||||
except subprocess.CalledProcessError as e:
|
||||
raise RuntimeError(
|
||||
f"Git command failed: {' '.join(e.cmd)}\nError: {e.stderr.strip()}"
|
||||
) from e
|
||||
|
||||
def _truncate(self, full: str) -> str:
|
||||
if self.logger is not None:
|
||||
self.logger.info(
|
||||
f"Truncating diff from {len(full)} to {self.max_size} chars "
|
||||
f"(config: max_diff_size). If you still hit token-limit errors, "
|
||||
f"lower this further or split the commit into more groups."
|
||||
)
|
||||
half = self.max_size // 2
|
||||
return (
|
||||
full[:half]
|
||||
+ "\n\n... [DIFF TRUNCATED FOR TOKEN OPTIMIZATION] ...\n\n"
|
||||
+ full[-half:]
|
||||
)
|
||||
|
||||
|
||||
class FileGrouper:
|
||||
"""Groups files by parent directory, merging down to ``max_groups``."""
|
||||
|
||||
def __init__(self, max_groups: int = 5) -> None:
|
||||
self.max_groups = max(1, max_groups)
|
||||
|
||||
def group(self, files: List[str]) -> Dict[str, List[str]]:
|
||||
groups: Dict[str, List[str]] = defaultdict(list)
|
||||
for f in files:
|
||||
parent = str(Path(f).parent)
|
||||
key = "root" if parent == "." else parent
|
||||
groups[key].append(f)
|
||||
if len(groups) <= self.max_groups:
|
||||
return dict(groups)
|
||||
return self._merge_deepest(dict(groups))
|
||||
|
||||
def _merge_deepest(self, groups: Dict[str, List[str]]) -> Dict[str, List[str]]:
|
||||
while len(groups) > self.max_groups:
|
||||
candidates = [k for k in groups if k != "root"]
|
||||
if not candidates:
|
||||
break
|
||||
deepest = max(candidates, key=lambda p: len(Path(p).parts))
|
||||
parent_path = str(Path(deepest).parent)
|
||||
new_key = "root" if parent_path == "." else parent_path
|
||||
files = groups.pop(deepest)
|
||||
groups.setdefault(new_key, []).extend(files)
|
||||
return groups
|
||||
|
||||
|
||||
class ManifestInspector:
|
||||
"""Extracts a compact one-line-per-file project summary.
|
||||
|
||||
Dumping the first 100 lines of ``pyproject.toml``/``requirements.txt``
|
||||
is extremely wasteful — most of it is build/tool config the model does
|
||||
not need. We extract just the project identity and the package list
|
||||
so the model can write context-aware commit messages.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def summary(cwd: Optional[Path] = None) -> str:
|
||||
cwd = cwd or get_git_root() or Path(".")
|
||||
lines = [
|
||||
s
|
||||
for s in (
|
||||
ManifestInspector._pyproject(cwd),
|
||||
ManifestInspector._package_json(cwd),
|
||||
ManifestInspector._requirements(cwd),
|
||||
)
|
||||
if s
|
||||
]
|
||||
if not lines:
|
||||
return ""
|
||||
return "\nProject: " + " | ".join(lines)
|
||||
|
||||
# --- extractors -------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _pkg_name(spec: str) -> str:
|
||||
return re.split(r"[><=!~\[]", spec, 1)[0].strip()
|
||||
|
||||
@staticmethod
|
||||
def _pyproject(cwd: Path) -> Optional[str]:
|
||||
path = cwd / "pyproject.toml"
|
||||
if not path.exists():
|
||||
return None
|
||||
try:
|
||||
import toml
|
||||
|
||||
data = toml.load(path)
|
||||
except Exception:
|
||||
return None
|
||||
proj = data.get("project", {}) if isinstance(data, dict) else {}
|
||||
parts = [
|
||||
f"{k}={proj[k]}" for k in ("name", "version", "description") if proj.get(k)
|
||||
]
|
||||
deps = proj.get("dependencies") or []
|
||||
if deps:
|
||||
names = [n for n in (ManifestInspector._pkg_name(d) for d in deps) if n]
|
||||
if names:
|
||||
parts.append(f"deps={','.join(names)}")
|
||||
return f"[pyproject.toml] {' '.join(parts)}" if parts else None
|
||||
|
||||
@staticmethod
|
||||
def _package_json(cwd: Path) -> Optional[str]:
|
||||
path = cwd / "package.json"
|
||||
if not path.exists():
|
||||
return None
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return None
|
||||
parts = [
|
||||
f"{k}={data[k]}" for k in ("name", "version", "description") if data.get(k)
|
||||
]
|
||||
deps = data.get("dependencies") or {}
|
||||
if isinstance(deps, dict) and deps:
|
||||
parts.append(f"deps={','.join(deps.keys())}")
|
||||
return f"[package.json] {' '.join(parts)}" if parts else None
|
||||
|
||||
@staticmethod
|
||||
def _requirements(cwd: Path) -> Optional[str]:
|
||||
path = cwd / "requirements.txt"
|
||||
if not path.exists():
|
||||
return None
|
||||
try:
|
||||
names = [
|
||||
n
|
||||
for n in (
|
||||
ManifestInspector._pkg_name(line)
|
||||
for line in path.read_text(encoding="utf-8").splitlines()
|
||||
)
|
||||
if n and not n.startswith("#")
|
||||
]
|
||||
except Exception:
|
||||
return None
|
||||
return f"[requirements.txt] deps={','.join(names)}" if names else None
|
||||
@@ -0,0 +1,153 @@
|
||||
"""Encapsulates every ``git`` invocation the commit engine makes.
|
||||
|
||||
Wraps :func:`devgen.utils.run_git_command` with the project's preferred
|
||||
error handling and turns raw CLI output into typed results. Higher-level
|
||||
classes (:class:`CommitEngine`) depend on this instead of calling git
|
||||
themselves, so the engine code stays focused on orchestration.
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from devgen.utils import configure_logger, get_git_root, run_git_command
|
||||
|
||||
|
||||
class GitError(RuntimeError):
|
||||
"""Raised when a git command exits non-zero."""
|
||||
|
||||
|
||||
class GitOperator:
|
||||
"""All git side-effects in one place."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
cwd: Optional[Path] = None,
|
||||
logger=None,
|
||||
quiet_commands: bool = True,
|
||||
) -> None:
|
||||
self.cwd = cwd or get_git_root()
|
||||
self.logger = logger or configure_logger("devgen.git")
|
||||
self.quiet_commands = quiet_commands
|
||||
|
||||
# ------------------------------------------------------------------ helpers
|
||||
|
||||
def _run(self, args, *, check: bool = True) -> str:
|
||||
try:
|
||||
return run_git_command(args, check=check, cwd=self.cwd)
|
||||
except subprocess.CalledProcessError as e:
|
||||
cmd_str = " ".join(e.cmd) if e.cmd else "git"
|
||||
stderr = (e.stderr or "").strip()
|
||||
raise GitError(f"Git command failed: {cmd_str}\nError: {stderr}") from e
|
||||
|
||||
def _format_error(self, e: subprocess.CalledProcessError) -> str:
|
||||
cmd_str = " ".join(e.cmd) if e.cmd else "git"
|
||||
return f"Git command failed: {cmd_str}\nError: {(e.stderr or '').strip()}"
|
||||
|
||||
# ------------------------------------------------------------------ queries
|
||||
|
||||
def has_upstream(self) -> bool:
|
||||
"""Return True if the current branch tracks an upstream."""
|
||||
return bool(self.upstream())
|
||||
|
||||
def upstream(self) -> str:
|
||||
"""Return the upstream branch name, or empty string if none."""
|
||||
return self._run(
|
||||
[
|
||||
"git",
|
||||
"rev-parse",
|
||||
"--abbrev-ref",
|
||||
"--symbolic-full-name",
|
||||
"@{u}",
|
||||
],
|
||||
check=False,
|
||||
)
|
||||
|
||||
def is_ahead_of_remote(self, fetch: bool = False) -> bool:
|
||||
"""Return True if local HEAD has unpushed commits.
|
||||
|
||||
When ``fetch`` is True, refresh the upstream ref first — but only
|
||||
do that when the caller is about to push. Skipping the fetch by
|
||||
default avoids a network round-trip on every run and shrinks the
|
||||
TOCTOU window between fetch and rev-list.
|
||||
"""
|
||||
upstream = self.upstream()
|
||||
if not upstream:
|
||||
return False
|
||||
if fetch:
|
||||
try:
|
||||
self._run(["git", "fetch", "origin", upstream], check=False)
|
||||
except GitError:
|
||||
pass
|
||||
try:
|
||||
count = self._run(["git", "rev-list", "--count", "@{u}..HEAD"], check=False)
|
||||
return bool(count) and int(count) > 0
|
||||
except (GitError, ValueError):
|
||||
return False
|
||||
|
||||
def detect_changed(self) -> list[str]:
|
||||
"""Return modified, deleted, and untracked files (deduplicated)."""
|
||||
out = self._run(
|
||||
[
|
||||
"git",
|
||||
"ls-files",
|
||||
"-z",
|
||||
"--deleted",
|
||||
"--modified",
|
||||
"--others",
|
||||
"--exclude-standard",
|
||||
]
|
||||
)
|
||||
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
|
||||
|
||||
def stage(self, files: list[str]) -> None:
|
||||
"""Stage the given paths — ``git add`` for existing files, ``git rm`` for deleted."""
|
||||
if not files:
|
||||
return
|
||||
existing = [f for f in files if Path(self.cwd or ".").joinpath(f).exists()]
|
||||
deleted = [f for f in files if f not in existing]
|
||||
try:
|
||||
if existing:
|
||||
self._run(["git", "add", *existing])
|
||||
if deleted:
|
||||
self._run(["git", "rm", *deleted])
|
||||
except GitError:
|
||||
# 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:
|
||||
"""``git commit -m`` with the given message."""
|
||||
self._run(["git", "commit", "-m", message])
|
||||
|
||||
def reset(self, files: list[str]) -> None:
|
||||
"""Unstage the given paths. Errors are swallowed — best-effort."""
|
||||
if not files:
|
||||
return
|
||||
try:
|
||||
self._run(["git", "reset", "HEAD", "--", *files], check=False)
|
||||
except GitError:
|
||||
pass
|
||||
|
||||
def push(self) -> None:
|
||||
"""Fetch upstream then push. Falls back to a no-upstream warning."""
|
||||
upstream = self.upstream()
|
||||
if upstream:
|
||||
try:
|
||||
self._run(["git", "fetch", "origin", upstream], check=False)
|
||||
except GitError:
|
||||
pass
|
||||
try:
|
||||
self._run(["git", "push"])
|
||||
except GitError as e:
|
||||
if "no upstream branch" in str(e).lower():
|
||||
if not self.quiet_commands:
|
||||
print("[warning]No upstream branch. Skipping push.[/warning]")
|
||||
return
|
||||
raise
|
||||
@@ -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,65 +2,55 @@ from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from devgen.modules.changelog_generator import ChangelogGenerator
|
||||
from devgen.modules.changelog_sections import Section
|
||||
from devgen.utils import configure_logger
|
||||
|
||||
|
||||
# Release-note-specific heading styles (use the same emoji, simpler heading).
|
||||
RELEASE_NOTE_STYLES = {
|
||||
Section.BREAKING: ("⚠️", "BREAKING CHANGES"),
|
||||
Section.FEATURES: ("✨", "Features"),
|
||||
Section.BUG_FIXES: ("🐛", "Bug Fixes"),
|
||||
Section.PERFORMANCE: ("⚡", "Performance"),
|
||||
Section.DOCUMENTATION: ("📚", "Documentation"),
|
||||
Section.REFACTOR: ("♻️", "Refactor"),
|
||||
Section.TESTS: ("✅", "Tests"),
|
||||
Section.STYLE: ("💄", "Style"),
|
||||
Section.CHORE: ("🔧", "Chore"),
|
||||
Section.OTHER: ("🧹", "Other Changes"),
|
||||
}
|
||||
|
||||
|
||||
class ReleaseNotesGenerator(ChangelogGenerator):
|
||||
"""Generate short, clean release notes from git history."""
|
||||
|
||||
SECTION_EMOJIS = {
|
||||
"BREAKING CHANGES": "⚠️",
|
||||
"Features": "✨",
|
||||
"Bug Fixes": "🐛",
|
||||
"Documentation": "📚",
|
||||
"Refactor": "♻️",
|
||||
"Tests": "✅",
|
||||
"Chore": "🔧",
|
||||
"Style": "✨",
|
||||
"Other Changes": "🧹",
|
||||
}
|
||||
|
||||
def __init__(self, logger=None):
|
||||
super().__init__(logger or configure_logger("devgen.releasenotes"))
|
||||
|
||||
def generate_release_markdown(self, groups, version="Unreleased"):
|
||||
"""Generate human-friendly release notes."""
|
||||
"""Render a human-friendly release-notes block."""
|
||||
date_str = datetime.now().strftime("%Y-%m-%d")
|
||||
md = [f"## 🚀 Release {version} — {date_str}\n"]
|
||||
|
||||
order = [
|
||||
"BREAKING CHANGES",
|
||||
"Features",
|
||||
"Bug Fixes",
|
||||
"Documentation",
|
||||
"Other Changes",
|
||||
]
|
||||
|
||||
for section in order:
|
||||
out = [f"## 🚀 Release {version} — {date_str}\n"]
|
||||
for section in Section.ordered():
|
||||
commits = groups.get(section)
|
||||
if not commits:
|
||||
continue
|
||||
|
||||
emoji = self.SECTION_EMOJIS.get(section, "")
|
||||
md.append(f"### {emoji} {section}")
|
||||
|
||||
emoji, heading = RELEASE_NOTE_STYLES[section]
|
||||
out.append(f"### {emoji} {heading}")
|
||||
for c in commits:
|
||||
scope = f"**{c['scope']}**: " if c["scope"] else ""
|
||||
md.append(f"- {scope}{c['subject']}")
|
||||
|
||||
md.append("")
|
||||
|
||||
return "\n".join(md)
|
||||
scope = f"**{c['scope']}**: " if c.get("scope") else ""
|
||||
out.append(f"- {scope}{c['subject']}")
|
||||
out.append("")
|
||||
return "\n".join(out)
|
||||
|
||||
def run(self, output_file="RELEASE-NOTES.md", version="Unreleased", from_ref=""):
|
||||
"""Main logic: get commits → parse → generate → write."""
|
||||
"""Fetch → parse → write release notes."""
|
||||
raw_commits = self.get_commits(from_ref)
|
||||
if not raw_commits or not raw_commits[0]:
|
||||
print("❗ No commits found for release notes.")
|
||||
return
|
||||
|
||||
parsed = self.parse_commits(raw_commits)
|
||||
md = self.generate_release_markdown(parsed, version=version)
|
||||
|
||||
groups = self.parse_commits(raw_commits)
|
||||
md = self.generate_release_markdown(groups, version=version)
|
||||
Path(output_file).write_text(md, encoding="utf-8")
|
||||
print(f" Release notes written to {output_file}")
|
||||
@@ -1,24 +0,0 @@
|
||||
def get_provider(name):
|
||||
name_lower = name.lower()
|
||||
if name_lower == "gemini":
|
||||
from devgen.providers.gemini import GeminiProvider
|
||||
|
||||
return GeminiProvider()
|
||||
elif name_lower == "openai":
|
||||
from devgen.providers.openai import OpenaiProvider
|
||||
|
||||
return OpenaiProvider()
|
||||
elif name_lower == "huggingface":
|
||||
from devgen.providers.huggingface import HuggingfaceProvider
|
||||
|
||||
return HuggingfaceProvider()
|
||||
elif name_lower == "openrouter":
|
||||
from devgen.providers.openrouter import OpenrouterProvider
|
||||
|
||||
return OpenrouterProvider()
|
||||
elif name_lower == "anthropic":
|
||||
from devgen.providers.anthropic import AnthropicProvider
|
||||
|
||||
return AnthropicProvider()
|
||||
|
||||
raise NotImplementedError(f"Provider '{name}' is not implemented yet.")
|
||||
@@ -1,23 +1,21 @@
|
||||
import anthropic
|
||||
|
||||
from devgen.providers.base import BaseProvider
|
||||
|
||||
class AnthropicProvider:
|
||||
|
||||
class AnthropicProvider(BaseProvider):
|
||||
"""Generates content using Anthropic's Claude models."""
|
||||
|
||||
def generate(
|
||||
self, prompt: str, api_key: str, model: str = "claude-3-opus-20240229", **kwargs
|
||||
) -> str:
|
||||
"""Generates a response using the Anthropic API."""
|
||||
if not api_key:
|
||||
raise ValueError("Anthropic API key is required.")
|
||||
DISPLAY_NAME = "Anthropic"
|
||||
DEFAULT_MODEL = "claude-3-opus-20240229"
|
||||
|
||||
try:
|
||||
client = anthropic.Anthropic(api_key=api_key)
|
||||
message = client.messages.create(
|
||||
model=model,
|
||||
max_tokens=1024,
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
)
|
||||
return message.content[0].text.strip()
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Anthropic generation failed: {e}")
|
||||
def _generate(self, prompt, api_key, model, **kwargs):
|
||||
client = anthropic.Anthropic(api_key=api_key)
|
||||
message = client.messages.create(
|
||||
model=model,
|
||||
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 ""
|
||||
@@ -0,0 +1,114 @@
|
||||
"""Base class for AI providers.
|
||||
|
||||
Every provider implements :meth:`_generate` and inherits the standard
|
||||
contract: API-key validation, token-limit detection, and friendly
|
||||
RuntimeError wrapping. Providers with provider-specific error shapes
|
||||
(eg. Hugging Face's HTTPError / RequestException split) override
|
||||
:meth:`_handle_error`.
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any
|
||||
|
||||
from devgen.utils import format_token_limit_error, is_token_limit_error
|
||||
|
||||
|
||||
class BaseProvider(ABC):
|
||||
"""Abstract base for all AI providers."""
|
||||
|
||||
#: Human-readable name used in error messages.
|
||||
DISPLAY_NAME: str = ""
|
||||
#: Whether the provider requires an API key.
|
||||
REQUIRES_API_KEY: bool = True
|
||||
#: Default model id when the user doesn't specify one.
|
||||
DEFAULT_MODEL: str = ""
|
||||
|
||||
def generate(
|
||||
self,
|
||||
prompt: str,
|
||||
api_key: str | None = None,
|
||||
model: str | None = None,
|
||||
**kwargs: Any,
|
||||
) -> str:
|
||||
"""Public entry point. Validates input, dispatches, and wraps errors."""
|
||||
import time
|
||||
|
||||
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
|
||||
|
||||
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.
|
||||
|
||||
Token-limit / context-window errors are reformatted with
|
||||
:func:`format_token_limit_error`; everything else is wrapped in a
|
||||
generic provider message. Subclasses override this to add
|
||||
provider-specific error shapes (eg. HTTPError vs RequestException).
|
||||
"""
|
||||
if is_token_limit_error(error):
|
||||
raise RuntimeError(
|
||||
format_token_limit_error(self.DISPLAY_NAME, error)
|
||||
) from error
|
||||
raise RuntimeError(
|
||||
f"{self.DISPLAY_NAME} request failed: {error}. "
|
||||
"Check the model name, API key permissions, and your account quota."
|
||||
) from error
|
||||
|
||||
@abstractmethod
|
||||
def _generate(
|
||||
self,
|
||||
prompt: str,
|
||||
api_key: str | None,
|
||||
model: str,
|
||||
**kwargs: Any,
|
||||
) -> str:
|
||||
"""Provider-specific generation. Return the raw generated text.
|
||||
|
||||
Implementations may raise any exception; :meth:`_handle_error` will
|
||||
translate it. Re-raise an exception to defer to default handling.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
+20
-19
@@ -1,24 +1,25 @@
|
||||
import google.generativeai as genai
|
||||
from google import genai
|
||||
from google.genai import types
|
||||
|
||||
from devgen.providers.base import BaseProvider
|
||||
|
||||
|
||||
class GeminiProvider:
|
||||
class GeminiProvider(BaseProvider):
|
||||
"""Generates content using Google's Gemini models."""
|
||||
|
||||
def generate(
|
||||
self, prompt: str, api_key: str, model: str = "gemini-pro", **kwargs
|
||||
) -> str:
|
||||
"""Generates a response using the Gemini API."""
|
||||
if not api_key:
|
||||
raise ValueError("Gemini API key is required.")
|
||||
DISPLAY_NAME = "Gemini"
|
||||
DEFAULT_MODEL = "gemini-2.5-flash"
|
||||
|
||||
genai.configure(api_key=api_key)
|
||||
|
||||
# Handle model name mapping if needed, or trust user input
|
||||
# gemini-pro is a common default
|
||||
|
||||
try:
|
||||
model_instance = genai.GenerativeModel(model)
|
||||
response = model_instance.generate_content(prompt)
|
||||
return response.text.strip()
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Gemini generation failed: {e}")
|
||||
def _generate(self, prompt, api_key, model, **kwargs):
|
||||
client = genai.Client(api_key=api_key)
|
||||
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 ""
|
||||
@@ -1,45 +1,56 @@
|
||||
import requests
|
||||
|
||||
from devgen.providers.base import BaseProvider
|
||||
from devgen.utils import format_token_limit_error, is_token_limit_error
|
||||
|
||||
class HuggingfaceProvider:
|
||||
|
||||
class HuggingfaceProvider(BaseProvider):
|
||||
"""Generates content using Hugging Face Inference API."""
|
||||
|
||||
DISPLAY_NAME = "Hugging Face"
|
||||
API_URL_TEMPLATE = "https://api-inference.huggingface.co/models/{model}"
|
||||
DEFAULT_MODEL = "mistralai/Mistral-7B-Instruct-v0.2"
|
||||
|
||||
def generate(
|
||||
self,
|
||||
prompt: str,
|
||||
api_key: str,
|
||||
model: str = "mistralai/Mistral-7B-Instruct-v0.2",
|
||||
**kwargs,
|
||||
) -> str:
|
||||
"""Generates a response using Hugging Face API."""
|
||||
if not api_key:
|
||||
raise ValueError("Hugging Face API token is required.")
|
||||
|
||||
def _generate(self, prompt, api_key, model, **kwargs):
|
||||
api_url = self.API_URL_TEMPLATE.format(model=model)
|
||||
headers = {"Authorization": f"Bearer {api_key}"}
|
||||
|
||||
# HF models often expect specific prompting formats, but we'll send raw prompt
|
||||
# Some models are text-generation, some are conversational.
|
||||
# Assuming text-generation for generic usage.
|
||||
|
||||
payload = {
|
||||
"inputs": prompt,
|
||||
"parameters": {"max_new_tokens": 500, "return_full_text": False},
|
||||
}
|
||||
|
||||
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:
|
||||
if is_token_limit_error(e):
|
||||
raise RuntimeError(
|
||||
format_token_limit_error(self.DISPLAY_NAME, e)
|
||||
) from e
|
||||
status = e.response.status_code if e.response is not None else "?"
|
||||
raise RuntimeError(
|
||||
f"Hugging Face request failed (HTTP {status}): {e}. "
|
||||
"Check the model id and your token permissions."
|
||||
) from e
|
||||
except requests.RequestException as e:
|
||||
raise RuntimeError(
|
||||
f"Hugging Face network error: {e}. Check your connection."
|
||||
) from e
|
||||
|
||||
if isinstance(result, list) and "generated_text" in result[0]:
|
||||
return result[0]["generated_text"].strip()
|
||||
elif isinstance(result, dict) and "error" in result:
|
||||
raise RuntimeError(f"Hugging Face API error: {result['error']}")
|
||||
else:
|
||||
return str(result)
|
||||
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Hugging Face generation failed: {e}")
|
||||
if isinstance(result, list) and result and "generated_text" in result[0]:
|
||||
return result[0]["generated_text"]
|
||||
if isinstance(result, dict) and "error" in result:
|
||||
err = result["error"]
|
||||
if "is currently loading" in str(err).lower():
|
||||
raise RuntimeError(
|
||||
f"Hugging Face model is loading: {err}. "
|
||||
"Wait a minute and retry, or pick a different model."
|
||||
)
|
||||
raise RuntimeError(f"Hugging Face API error: {err}")
|
||||
raise RuntimeError(
|
||||
"Hugging Face returned an unexpected response shape. "
|
||||
"The model may not support text-generation."
|
||||
)
|
||||
@@ -0,0 +1,55 @@
|
||||
from openai import OpenAI
|
||||
|
||||
from devgen.providers.base import BaseProvider
|
||||
from devgen.utils import format_token_limit_error, is_token_limit_error
|
||||
|
||||
|
||||
class OllamaProvider(BaseProvider):
|
||||
"""Generates content using a local Ollama server.
|
||||
|
||||
Ollama exposes an OpenAI-compatible endpoint at /v1/chat/completions,
|
||||
so we reuse the ``openai`` client. The host defaults to
|
||||
http://localhost:11434 but can be overridden via the ``ollama_host``
|
||||
kwarg (or ``ollama_host`` in ~/.devgen.yaml).
|
||||
"""
|
||||
|
||||
DISPLAY_NAME = "Ollama"
|
||||
REQUIRES_API_KEY = False
|
||||
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"
|
||||
|
||||
# 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(
|
||||
f"Ollama returned no choices for model {model!r}. "
|
||||
"The model may not be installed locally."
|
||||
)
|
||||
return response.choices[0].message.content or ""
|
||||
|
||||
def _handle_error(self, error):
|
||||
"""Override default wrapping — Ollama has no API key to check."""
|
||||
if is_token_limit_error(error):
|
||||
raise RuntimeError(
|
||||
format_token_limit_error(self.DISPLAY_NAME, error)
|
||||
) from error
|
||||
raise RuntimeError(
|
||||
f"{self.DISPLAY_NAME} request failed: {error}. "
|
||||
"Is the Ollama server running? Start it with `ollama serve` and "
|
||||
"pull a model with `ollama pull <model>`."
|
||||
) from error
|
||||
+17
-43
@@ -1,51 +1,25 @@
|
||||
from openai import OpenAI
|
||||
|
||||
from devgen.providers.base import BaseProvider
|
||||
|
||||
class OpenaiProvider:
|
||||
"""Generates a response string using the OpenAI ChatCompletion API based on the provided prompt and parameters. This method initializes an OpenAI client with the given API key, sends a chat completion request with specified model and additional parameters, and returns the content of the generated message.
|
||||
|
||||
Args:
|
||||
prompt (str): The input prompt to generate a response for.
|
||||
api_key (str): The API key used to authenticate with the OpenAI service.
|
||||
model (str, optional): The model to use for generation; defaults to "gpt-4o".
|
||||
**kwargs: Additional keyword arguments to customize the API request (e.g., temperature).
|
||||
|
||||
Returns:
|
||||
str: The content of the generated response message.
|
||||
"""
|
||||
class OpenaiProvider(BaseProvider):
|
||||
"""Generates content using OpenAI's Chat Completions API."""
|
||||
|
||||
DISPLAY_NAME = "OpenAI"
|
||||
DEFAULT_MODEL = "gpt-4o"
|
||||
|
||||
def generate(
|
||||
self, prompt: str, api_key: str, model: str | None = None, **kwargs
|
||||
) -> str:
|
||||
"""Generates a response from the OpenAI ChatCompletion API based on the provided prompt and parameters.
|
||||
# Keys consumed by devgen itself — never forwarded to the SDK.
|
||||
_DEVGEN_KEYS = frozenset({"debug", "ollama_host", "max_retries", "retry_delay"})
|
||||
|
||||
Creates a client instance with the specified API key, sends a chat completion request using the selected model and additional parameters, and returns the generated message content as a string.
|
||||
|
||||
Args:
|
||||
prompt (str): Prompt input.
|
||||
api_key (str): OpenAI API key.
|
||||
model (str, optional): Model to use (default: gpt-4o).
|
||||
**kwargs: Additional OpenAI ChatCompletion parameters (e.g., temperature).
|
||||
|
||||
Returns:
|
||||
str: Generated response content.
|
||||
"""
|
||||
# 1. Create a client instance with the API key.
|
||||
try:
|
||||
client = OpenAI(api_key=api_key)
|
||||
except Exception as e:
|
||||
# Add error handling if the client fails to initialize
|
||||
raise RuntimeError(f"Failed to initialize OpenAI client: {e}")
|
||||
|
||||
# Remove debug from kwargs if present
|
||||
kwargs.pop("debug", None)
|
||||
|
||||
# 2. Use the modern API syntax: client.chat.completions.create
|
||||
response = client.chat.completions.create(
|
||||
model=model or self.DEFAULT_MODEL,
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
**kwargs,
|
||||
)
|
||||
return response.choices[0].message.content.strip()
|
||||
def _generate(self, prompt, api_key, model, **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 ""
|
||||
@@ -1,36 +1,30 @@
|
||||
from openai import OpenAI
|
||||
|
||||
from devgen.providers.base import BaseProvider
|
||||
|
||||
class OpenrouterProvider:
|
||||
|
||||
class OpenrouterProvider(BaseProvider):
|
||||
"""Generates content using OpenRouter (OpenAI-compatible API)."""
|
||||
|
||||
DISPLAY_NAME = "OpenRouter"
|
||||
BASE_URL = "https://openrouter.ai/api/v1"
|
||||
DEFAULT_MODEL = "openai/gpt-3.5-turbo"
|
||||
|
||||
def generate(
|
||||
self, prompt: str, api_key: str, model: str = "openai/gpt-3.5-turbo", **kwargs
|
||||
) -> str:
|
||||
"""Generates a response using OpenRouter."""
|
||||
if not api_key:
|
||||
raise ValueError("OpenRouter API key is required.")
|
||||
|
||||
try:
|
||||
client = OpenAI(
|
||||
base_url=self.BASE_URL,
|
||||
api_key=api_key,
|
||||
)
|
||||
|
||||
# Remove debug from kwargs if present
|
||||
kwargs.pop("debug", None)
|
||||
# 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):
|
||||
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", # Optional
|
||||
"X-Title": "devgen CLI", # Optional
|
||||
"HTTP-Referer": "https://github.com/S4NKALP/devgen",
|
||||
"X-Title": "devgen CLI",
|
||||
},
|
||||
**kwargs,
|
||||
**safe_kwargs,
|
||||
)
|
||||
return response.choices[0].message.content.strip()
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"OpenRouter generation failed: {e}")
|
||||
if not response.choices:
|
||||
raise RuntimeError(f"OpenRouter returned no choices for model {model!r}.")
|
||||
return response.choices[0].message.content or ""
|
||||
@@ -1,11 +0,0 @@
|
||||
Analyze the following git diff for `{{ group_name }}` and generate a semantic commit message.
|
||||
|
||||
Guidelines:
|
||||
- Title line: `<type>(<scope>): <summary>` (scope is `{{ group_name }}` or `root`).
|
||||
- Optional body: A short summary of why.
|
||||
- Details: Bullet points for `New Features`, `Enhancements`, `Bug Fixes`.
|
||||
- Output ONLY the message itself, without any labels like "Title:" or "Format:".
|
||||
|
||||
Diff for `{{ group_name }}`:
|
||||
{{ diff_text }}
|
||||
{{ context }}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
You write Conventional Commits. Output ONLY the message — no preamble, no labels, no quotes, no markdown.
|
||||
|
||||
Format:
|
||||
<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: {% if group_name and group_name != 'root' %}({{ group_name }}){% else %}optional{% endif %}{% if use_emoji %} (prefix description with one matching emoji){% endif %}
|
||||
|
||||
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 }}
|
||||
+147
-23
@@ -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,35 +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
|
||||
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:
|
||||
"""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]:
|
||||
@@ -229,6 +284,7 @@ def load_config() -> Dict[str, Any]:
|
||||
"api_key": "",
|
||||
"emoji": True,
|
||||
"max_groups": 5,
|
||||
"ollama_host": "http://localhost:11434",
|
||||
}
|
||||
try:
|
||||
with config_path.open("w", encoding="utf-8") as f:
|
||||
@@ -240,12 +296,77 @@ 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 {}
|
||||
|
||||
|
||||
# Substrings used to identify token / context-length errors from AI providers.
|
||||
# Matched case-insensitively against the full stringified exception, so we
|
||||
# catch the wide variety of error formats each provider returns.
|
||||
_TOKEN_LIMIT_PATTERNS = (
|
||||
"context length",
|
||||
"context_length",
|
||||
"context_length_exceeded",
|
||||
"context window",
|
||||
"context window exceeded",
|
||||
"context_size",
|
||||
"maximum context",
|
||||
"max context",
|
||||
"max_tokens",
|
||||
"max tokens",
|
||||
"prompt is too long",
|
||||
"too many tokens",
|
||||
"token limit",
|
||||
"tokens in the prompt",
|
||||
"input is too long",
|
||||
"request too large",
|
||||
"request was too large",
|
||||
"string too long",
|
||||
"too long for the model",
|
||||
"reduce the length of the messages",
|
||||
"tokens_exceeded",
|
||||
)
|
||||
|
||||
|
||||
def is_token_limit_error(error: BaseException | str) -> bool:
|
||||
"""Return True if the error looks like a model context/token overflow.
|
||||
|
||||
Different SDKs phrase the same condition differently. We look for the
|
||||
common substrings so we can give a single, actionable message instead of
|
||||
the raw provider text.
|
||||
"""
|
||||
text = str(error).lower()
|
||||
return any(p in text for p in _TOKEN_LIMIT_PATTERNS)
|
||||
|
||||
|
||||
def format_token_limit_error(
|
||||
provider: str,
|
||||
error: BaseException | str,
|
||||
*,
|
||||
group: str | None = None,
|
||||
) -> str:
|
||||
"""Build a friendly message for a context-window overflow.
|
||||
|
||||
Kept short so it doesn't drown the log line. The original provider
|
||||
error is included so the user can still see the exact cause.
|
||||
"""
|
||||
scope = f" ({group})" if group else ""
|
||||
return (
|
||||
f"{provider}: diff exceeds context window{scope}. "
|
||||
f"Cut token usage: --max-groups N, --max-diff-size N, "
|
||||
f"or a larger-context model (gemini-2.5-flash, gpt-4o, claude-3-5-sonnet). "
|
||||
f"Details: {error}"
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ensure_log_directory",
|
||||
"get_main_log_path",
|
||||
@@ -258,9 +379,12 @@ __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",
|
||||
"format_token_limit_error",
|
||||
]
|
||||
|
||||
|
||||
|
||||
+9
-10
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "devgen-cli"
|
||||
version = "0.2.4"
|
||||
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-generativeai>=0.8.5",
|
||||
"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",
|
||||
]
|
||||
+11
-12
@@ -1,12 +1,11 @@
|
||||
anthropic>=0.75.0
|
||||
google-generativeai>=0.8.5
|
||||
jinja2>=3.1.6
|
||||
openai>=2.11.0
|
||||
pyyaml>=6.0.3
|
||||
questionary>=2.1.1
|
||||
requests>=2.32.5
|
||||
rich>=14.2.0
|
||||
toml>=0.10.2
|
||||
typer>=0.20.2
|
||||
pre-commit>=4.5.0
|
||||
ruff>=0.14.9
|
||||
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.20
|
||||
toml==0.10.2
|
||||
typer==0.26.8
|
||||
@@ -6,7 +6,7 @@ from setuptools import find_packages, setup
|
||||
|
||||
setup(
|
||||
name="devgen-cli",
|
||||
version="0.2.4",
|
||||
version="0.3.0",
|
||||
packages=find_packages(),
|
||||
include_package_data=True,
|
||||
install_requires=[
|
||||
|
||||
Reference in new issue
Block a user