Compare commits
163
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
188f1ed283 | ||
|
|
e69de1d99a | ||
|
|
e5f22bca85 | ||
|
|
277f197a71 | ||
|
|
2edbf95ed5 | ||
|
|
109fcd584f | ||
|
|
61e51b5187 | ||
|
|
d4b694e77d | ||
|
|
261d04c56f | ||
|
|
f7001d4243 | ||
|
|
11a9db7986 | ||
|
|
34f5d090ce | ||
|
|
d20a31bf38 | ||
|
|
a43e09e924 | ||
|
|
fca0e34422 | ||
|
|
c4992cce57 | ||
|
|
957dda9d49 | ||
|
|
869aa37f0c | ||
|
|
51ede1a7da | ||
|
|
ede489c219 | ||
|
|
a56fe0a6ee | ||
|
|
e13e763cf2 | ||
|
|
79294f1df0 | ||
|
|
187ad00e99 | ||
|
|
35e02ddf7d | ||
|
|
93f09c9d74 | ||
|
|
ab9527df33 | ||
|
|
186d952fda | ||
|
|
deba25b055 | ||
|
|
9e6c846790 | ||
|
|
f007c7c579 | ||
|
|
363b8ec398 | ||
|
|
0c5ec5da0a | ||
|
|
4f679bc90d | ||
|
|
f21b090b56 | ||
|
|
8c63406126 | ||
|
|
91500f634a | ||
|
|
cb3a1662d9 | ||
|
|
70e9068082 | ||
|
|
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 | ||
|
|
cc94b53d48 | ||
|
|
b09de402cc | ||
|
|
c121a7fa59 | ||
|
|
525aebadf4 | ||
|
|
1219c1ad81 | ||
|
|
cfa891dcde | ||
|
|
286c46d2b5 | ||
|
|
f2ff3327c3 | ||
|
|
37e5d94373 | ||
|
|
40c9b3ca57 | ||
|
|
4d309c313d | ||
|
|
ba747c727c | ||
|
|
3635887a85 | ||
|
|
a7507255c7 | ||
|
|
d373f95388 | ||
|
|
fe7da77ed6 | ||
|
|
407458149b | ||
|
|
e9a45cf140 | ||
|
|
70e150611b | ||
|
|
f85da0461f | ||
|
|
7c15e2183b | ||
|
|
a2f3657269 | ||
|
|
c50f8a4b89 | ||
|
|
a488d5e4ac | ||
|
|
48836797b9 | ||
|
|
09151decad | ||
|
|
24dc7f57b4 | ||
|
|
449a955d15 | ||
|
|
87ed8bf878 | ||
|
|
6e4b177fe3 | ||
|
|
8db30f14bc | ||
|
|
d502849daf | ||
|
|
f3c68aaf29 | ||
|
|
7f6b247b40 |
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.37.3
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
queries: security-and-quality
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v4.37.3
|
||||
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,11 +12,11 @@ jobs:
|
||||
permissions:
|
||||
id-token: write # IMPORTANT: this permission is mandatory for trusted publishing
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v4
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.12'
|
||||
python-version: '3.10'
|
||||
cache: 'pip'
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
|
||||
@@ -47,3 +47,4 @@ logs/
|
||||
devgen.egg-info/
|
||||
build/
|
||||
devgen_utils.egg-info/
|
||||
devgen_cli.egg-info/
|
||||
@@ -2,261 +2,149 @@
|
||||
|
||||
<div align="center">
|
||||
|
||||
<img src="https://img.shields.io/pypi/v/devgen?color=blue&label=PyPI&logo=pypi&logoColor=white" alt="PyPI">
|
||||
<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>
|
||||
**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://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>
|
||||
|
||||
**DevGen** is an AI-powered developer toolkit that automates common Git and project management tasks. Generate semantic commit messages, changelogs, release notes, `.gitignore` files, and licenses—all from a single unified CLI.
|
||||
## Why DevGen
|
||||
|
||||
## ✨ Features
|
||||
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.
|
||||
|
||||
- 🚀 **AI-Powered Commit Messages** - Automatically generate semantic commit messages from your staged changes using AI
|
||||
- 📝 **Changelog Generation** - Create changelogs from git history using conventional commits
|
||||
- 🚀 **Release Notes** - Generate clean, emoji-enhanced release notes for your releases
|
||||
- 🙈 **Gitignore Templates** - Generate `.gitignore` files from GitHub's official templates
|
||||
- 📄 **License Generation** - Quickly generate open source licenses with interactive setup
|
||||
- ⚙️ **Multi-Provider AI Support** - Works with Gemini, OpenAI, Anthropic, HuggingFace, and OpenRouter
|
||||
## Features
|
||||
|
||||
## 📦 Installation
|
||||
- **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.
|
||||
|
||||
## Supported AI Providers
|
||||
|
||||
| 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 |
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
# Stable release from PyPI
|
||||
pip install devgen-cli
|
||||
|
||||
# Using pipx (recommended for isolated CLI)
|
||||
# Recommended: isolated environment
|
||||
pipx install devgen-cli
|
||||
|
||||
# Using uv (fast Python package manager)
|
||||
# Or use uv for speed
|
||||
uv tool install devgen-cli
|
||||
|
||||
# Or plain pip
|
||||
pip install devgen-cli
|
||||
|
||||
# Shell completion (bash/zsh/fish)
|
||||
devgen --install-completion
|
||||
```
|
||||
|
||||
## 🚀 Quick Start
|
||||
Requires **Python 3.10 or newer**.
|
||||
|
||||
### 1. Initial Setup
|
||||
|
||||
Configure your AI provider and API key:
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# 1. Configure a provider and API key
|
||||
devgen setup config
|
||||
```
|
||||
|
||||
This will create a configuration file at `~/.devgen.yaml` with your preferred AI provider, model, and API key.
|
||||
# 2. Stage your work as usual
|
||||
git add .
|
||||
|
||||
### 2. Generate Commit Messages
|
||||
|
||||
Stage your changes and let DevGen generate semantic commit messages:
|
||||
|
||||
```bash
|
||||
# Dry run to preview commit messages
|
||||
devgen commit run --dry-run
|
||||
|
||||
# Actually commit with AI-generated messages
|
||||
# 3. Let DevGen write the commit message
|
||||
devgen commit run
|
||||
|
||||
# Commit and automatically push
|
||||
# 4. Or preview first, then commit
|
||||
devgen commit run --dry-run
|
||||
devgen commit run --push
|
||||
|
||||
# Force regeneration of commit messages
|
||||
devgen commit run --force-rebuild
|
||||
```
|
||||
|
||||
**How it works:**
|
||||
## Commands
|
||||
|
||||
- DevGen detects all staged and unstaged changes
|
||||
- Groups files by directory
|
||||
- Generates semantic commit messages for each group using AI
|
||||
- Follows [Conventional Commits](https://www.conventionalcommits.org/) format
|
||||
| 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 |
|
||||
|
||||
**Additional commit commands:**
|
||||
Run `devgen <command> --help` for full options on any subcommand.
|
||||
|
||||
## 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
|
||||
# View cached commit messages
|
||||
devgen commit list-cached
|
||||
|
||||
# Clear the commit cache
|
||||
devgen commit clear-cache
|
||||
|
||||
# Check what files are staged
|
||||
devgen commit validate
|
||||
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
|
||||
```
|
||||
|
||||
### 3. Generate Changelogs
|
||||
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`).
|
||||
|
||||
Create changelogs from your git history:
|
||||
## Contributing
|
||||
|
||||
```bash
|
||||
# Generate changelog from last tag to HEAD
|
||||
devgen changelog generate
|
||||
Issues and PRs welcome. Please run `uv run pre-commit run --all-files` before opening a PR so CI stays green.
|
||||
|
||||
# Generate from a specific reference
|
||||
devgen changelog generate --from v1.0.0
|
||||
## License
|
||||
|
||||
# Output to a custom file
|
||||
devgen changelog generate --output CHANGES.md
|
||||
```
|
||||
GPL-3.0-or-later. See [LICENSE](LICENSE).
|
||||
|
||||
The changelog follows Semantic Release conventions, categorizing commits into:
|
||||
## Acknowledgments
|
||||
|
||||
- **BREAKING CHANGES**
|
||||
- **Features**
|
||||
- **Bug Fixes**
|
||||
- **Documentation**
|
||||
- **Other Changes**
|
||||
Built on the shoulders of:
|
||||
|
||||
### 4. Generate Release Notes
|
||||
- **Typer** & **Rich** — CLI and terminal UI
|
||||
- **Questionary** — interactive prompts
|
||||
- **Google Gemini**, **OpenAI**, **Anthropic**, **OpenRouter**, **HuggingFace**, and **Ollama** — AI providers
|
||||
- **Ruff** — lint and format
|
||||
|
||||
Create release notes for your releases:
|
||||
|
||||
```bash
|
||||
# Generate release notes
|
||||
devgen release notes --version 1.4.0
|
||||
|
||||
# Custom output file
|
||||
devgen release notes --version 1.4.0 --output RELEASE.md
|
||||
|
||||
# From a specific reference
|
||||
devgen release notes --version 1.4.0 --from v1.3.0
|
||||
```
|
||||
|
||||
### 5. Generate .gitignore Files
|
||||
|
||||
Create `.gitignore` files from GitHub templates:
|
||||
|
||||
```bash
|
||||
# Interactive mode (search and select templates)
|
||||
devgen gitignore generate
|
||||
|
||||
# Specify templates directly
|
||||
devgen gitignore generate Python Node Docker
|
||||
|
||||
# List available templates
|
||||
devgen gitignore list
|
||||
|
||||
# Use cached templates (offline mode)
|
||||
devgen gitignore generate --offline Python
|
||||
```
|
||||
|
||||
**Options:**
|
||||
|
||||
- `--append` / `--overwrite` - Append to or overwrite existing `.gitignore`
|
||||
- `--output` - Specify output file path (default: `.gitignore`)
|
||||
- `--offline` - Use only cached templates
|
||||
|
||||
### 6. Generate Licenses
|
||||
|
||||
Generate open source licenses interactively:
|
||||
|
||||
```bash
|
||||
devgen license generate
|
||||
```
|
||||
|
||||
This will:
|
||||
|
||||
1. Show available licenses (MIT, Apache-2.0, GPL-3.0, etc.)
|
||||
2. Prompt for author name
|
||||
3. Prompt for year (defaults to current year)
|
||||
4. Generate the license file
|
||||
|
||||
**Available licenses:**
|
||||
|
||||
- MIT
|
||||
- Apache-2.0
|
||||
- GPL-2.0, GPL-3.0
|
||||
- LGPL-2.1
|
||||
- AGPL-3.0
|
||||
- BSD-2-Clause, BSD-3-Clause
|
||||
- MPL-2.0
|
||||
- EPL-2.0
|
||||
- BSL-1.0
|
||||
- CC0-1.0
|
||||
- Unlicense
|
||||
|
||||
## ⚙️ Configuration
|
||||
|
||||
Configuration is stored in `~/.devgen.yaml`. You can edit it manually or use the interactive setup:
|
||||
|
||||
```bash
|
||||
devgen setup config
|
||||
```
|
||||
|
||||
**Configuration options:**
|
||||
|
||||
- `provider` - AI provider: `gemini`, `openai`, `anthropic`, `huggingface`, or `openrouter`
|
||||
- `model` - Model name (e.g., `gemini-2.5-flash`, `gpt-4`, `claude-3-opus`)
|
||||
- `api_key` - Your API key
|
||||
- `emoji` - Enable/disable emojis in commit messages (default: `true`)
|
||||
|
||||
## 📋 Command Reference
|
||||
|
||||
### Commit Commands
|
||||
|
||||
```bash
|
||||
devgen commit run [--dry-run] [--push] [--debug] [--force-rebuild]
|
||||
devgen commit validate
|
||||
devgen commit list-cached
|
||||
devgen commit clear-cache
|
||||
```
|
||||
|
||||
### Changelog Commands
|
||||
|
||||
```bash
|
||||
devgen changelog generate [--output FILE] [--from REF]
|
||||
```
|
||||
|
||||
### Release Commands
|
||||
|
||||
```bash
|
||||
devgen release notes [--version VERSION] [--output FILE] [--from REF]
|
||||
```
|
||||
|
||||
### Gitignore Commands
|
||||
|
||||
```bash
|
||||
devgen gitignore generate [TEMPLATES...] [--output FILE] [--append/--overwrite] [--offline]
|
||||
devgen gitignore list [--cached]
|
||||
```
|
||||
|
||||
### License Commands
|
||||
|
||||
```bash
|
||||
devgen license generate [--output FILE]
|
||||
```
|
||||
|
||||
### Setup Commands
|
||||
|
||||
```bash
|
||||
devgen setup config
|
||||
```
|
||||
|
||||
## 🎯 Use Cases
|
||||
|
||||
- **Automated Commit Workflow**: Let AI analyze your changes and generate meaningful commit messages
|
||||
- **Release Preparation**: Automatically generate changelogs and release notes from git history
|
||||
- **Project Setup**: Quickly add `.gitignore` and license files to new projects
|
||||
- **CI/CD Integration**: Use in scripts to automate documentation generation
|
||||
|
||||
## 🔧 Requirements
|
||||
|
||||
- Python 3.10 or higher
|
||||
- Git repository (for commit/changelog/release features)
|
||||
- API key for your chosen AI provider
|
||||
|
||||
## 📝 License
|
||||
|
||||
This project is licensed under the GPL-3.0-or-later License - see the [LICENSE](LICENSE) file for details.
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
Contributions are welcome! Please feel free to submit a Pull Request.
|
||||
|
||||
## 📚 Links
|
||||
|
||||
- **Homepage**: [https://github.com/S4NKALP/devgen](https://github.com/S4NKALP/devgen)
|
||||
- **PyPI**: [https://pypi.org/project/devgen](https://pypi.org/project/devgen)
|
||||
|
||||
## 🙏 Acknowledgments
|
||||
|
||||
- Uses GitHub's official [gitignore templates](https://github.com/github/gitignore)
|
||||
- Follows [Conventional Commits](https://www.conventionalcommits.org/) specification
|
||||
<div align="center">
|
||||
Made with ❤️ by <a href="https://github.com/S4NKALP">Sankalp</a>
|
||||
</div>
|
||||
+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)
|
||||
+55
-3
@@ -1,5 +1,6 @@
|
||||
from typing import Annotated
|
||||
|
||||
import questionary
|
||||
import typer
|
||||
|
||||
from devgen.modules.commit_generator import run_commit_engine
|
||||
@@ -50,12 +51,35 @@ def run_commit(
|
||||
help="Force regeneration of commit messages.",
|
||||
),
|
||||
] = False,
|
||||
check: Annotated[
|
||||
bool,
|
||||
typer.Option(
|
||||
"--check",
|
||||
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)
|
||||
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}"
|
||||
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(
|
||||
@@ -63,7 +87,10 @@ def run_commit(
|
||||
push=push,
|
||||
debug=debug,
|
||||
force_rebuild=force_rebuild,
|
||||
check=check,
|
||||
logger=logger,
|
||||
max_groups=max_groups,
|
||||
max_diff_size=max_diff_size,
|
||||
)
|
||||
|
||||
|
||||
@@ -93,4 +120,29 @@ 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")
|
||||
def undo_commit() -> None:
|
||||
"""Undoes the last commit but keeps changes staged."""
|
||||
from devgen.utils import run_git_command
|
||||
|
||||
try:
|
||||
# Check if there's at least one commit
|
||||
run_git_command(["git", "rev-parse", "HEAD"])
|
||||
except Exception:
|
||||
typer.secho("No commits found to undo.", fg=typer.colors.RED)
|
||||
return
|
||||
|
||||
if questionary.confirm(
|
||||
"Are you sure you want to undo the last commit? (Changes will remain staged)",
|
||||
default=False,
|
||||
).ask():
|
||||
try:
|
||||
run_git_command(["git", "reset", "--soft", "HEAD~1"])
|
||||
typer.secho(
|
||||
"Last commit undone. Changes are still staged.", fg=typer.colors.GREEN
|
||||
)
|
||||
except Exception as e:
|
||||
typer.secho(f"Failed to undo commit: {e}", fg=typer.colors.RED)
|
||||
+61
-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?
|
||||
@@ -167,3 +199,30 @@ def set_config() -> None:
|
||||
_save_config(config)
|
||||
typer.secho("\nConfiguration saved.", fg=typer.colors.GREEN)
|
||||
typer.echo(yaml.dump(new_config, default_flow_style=False))
|
||||
|
||||
|
||||
@app.command("info")
|
||||
def config_info() -> None:
|
||||
"""Show information about configuration options and templates."""
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
|
||||
console = Console()
|
||||
table = Table(title="Custom Template Variables", box=None)
|
||||
table.add_column("Variable", style="cyan")
|
||||
table.add_column("Description", style="white")
|
||||
|
||||
table.add_row("{{ group_name }}", "The folder name being committed (or 'root').")
|
||||
table.add_row("{{ diff_text }}", "The git diff of the changes.")
|
||||
table.add_row("{{ context }}", "Project context (manifest files content).")
|
||||
|
||||
console.print(table)
|
||||
console.print(
|
||||
"\n[bold]Tip:[/bold] If you hardcode emojis in your template, the AI will likely include them regardless of the 'emoji' setting.",
|
||||
style="yellow",
|
||||
)
|
||||
console.print("\n[bold]Example Template:[/bold]")
|
||||
console.print(
|
||||
"custom_template: |\n [type]: [desc]\n \n Diff: {{ diff_text }}\n",
|
||||
style="dim",
|
||||
)
|
||||
+12
-4
@@ -15,7 +15,7 @@ from devgen.cli.setup import app as setup_app
|
||||
app = typer.Typer(
|
||||
name="devgen",
|
||||
help="devgen-py: AI-Powered Git Commit & Release Automation.",
|
||||
add_completion=False,
|
||||
add_completion=True,
|
||||
no_args_is_help=True,
|
||||
rich_markup_mode="markdown",
|
||||
)
|
||||
@@ -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,186 +5,155 @@ from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from devgen.utils import configure_logger
|
||||
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")
|
||||
|
||||
def _exec_git(self, args: List[str]) -> str:
|
||||
"""Executes a git command."""
|
||||
try:
|
||||
res = subprocess.run(
|
||||
args,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
check=True,
|
||||
)
|
||||
return res.stdout.strip()
|
||||
except subprocess.CalledProcessError as e:
|
||||
self.logger.error(f"Git command failed: {e}")
|
||||
raise RuntimeError(f"Git command failed: {e}")
|
||||
# ------------------------------------------------------------------ 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]
|
||||
"""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:
|
||||
output = run_git_command(cmd)
|
||||
# Use %x00 as separator to avoid splitting on newlines in commit bodies
|
||||
# Then filter out empty strings
|
||||
return [c for c in output.split("\0") if c.strip()]
|
||||
except subprocess.CalledProcessError as e:
|
||||
self.logger.error(f"Git command failed: {e}")
|
||||
raise RuntimeError(f"Git command failed: {e}") from e
|
||||
|
||||
if not from_ref:
|
||||
# If no start ref, try to find the last tag
|
||||
try:
|
||||
last_tag = self._exec_git(["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:
|
||||
self.logger.info("No tags found, generating for all commits.")
|
||||
cmd = ["git", "log", f"--format={fmt}", "--date=short"]
|
||||
|
||||
return self._exec_git(cmd).split("\n")
|
||||
|
||||
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 file exists, prepend? For now, just overwrite or append logic could be complex.
|
||||
# Let's implement prepend logic if file exists.
|
||||
if path.exists():
|
||||
old_content = path.read_text(encoding="utf-8")
|
||||
new_content = old_content + "\n\n" + md_content
|
||||
path.write_text(new_content, encoding="utf-8")
|
||||
else:
|
||||
path.write_text(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)
|
||||
+319
-185
@@ -1,30 +1,138 @@
|
||||
import subprocess
|
||||
from collections import defaultdict
|
||||
"""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
|
||||
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,
|
||||
is_token_limit_error,
|
||||
format_token_limit_error,
|
||||
load_template,
|
||||
render_template,
|
||||
sanitize_ai_commit_message,
|
||||
)
|
||||
|
||||
|
||||
class CommitEngineError(Exception):
|
||||
"""Exception raised for errors in the commit engine."""
|
||||
"""Raised when the commit flow cannot proceed."""
|
||||
|
||||
pass
|
||||
|
||||
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__(
|
||||
@@ -33,225 +141,251 @@ class CommitEngine:
|
||||
push: bool = False,
|
||||
debug: bool = False,
|
||||
force_rebuild: bool = False,
|
||||
check: bool = False,
|
||||
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
|
||||
self.force_rebuild = force_rebuild
|
||||
self.check = check
|
||||
self.provider = provider
|
||||
self.model = model
|
||||
self.logger = logger or configure_logger(
|
||||
"devgen.commit", Path.home() / ".cache" / "devgen" / "commit.log"
|
||||
"devgen.commit",
|
||||
Path.home() / ".cache" / "devgen" / "commit.log",
|
||||
console=debug,
|
||||
)
|
||||
self.kwargs = kwargs
|
||||
self.dry_run_path = get_commit_dry_run_path()
|
||||
self.template_env = load_template_env("commit")
|
||||
|
||||
# Load config from ~/.devgen.yaml
|
||||
# CLI flags win over config; config wins over default.
|
||||
from devgen.utils import load_config
|
||||
|
||||
self.config = load_config()
|
||||
|
||||
def _exec_git(self, args: List[str], allow_error: bool = False) -> str:
|
||||
"""Executes a git command."""
|
||||
try:
|
||||
res = subprocess.run(
|
||||
args,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
check=not allow_error,
|
||||
self.console = Console(
|
||||
theme=Theme(
|
||||
{"info": "dim cyan", "warning": "magenta", "danger": "bold red"}
|
||||
)
|
||||
return res.stdout.strip()
|
||||
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 detect_changes(self) -> List[str]:
|
||||
"""Detects changed, deleted, or untracked files."""
|
||||
out = self._exec_git(
|
||||
[
|
||||
"git",
|
||||
"ls-files",
|
||||
"--deleted",
|
||||
"--modified",
|
||||
"--others",
|
||||
"--exclude-standard",
|
||||
]
|
||||
)
|
||||
return [f.strip() for f in out.split("\n") if f.strip()]
|
||||
|
||||
def group_files(self, files: List[str]) -> Dict[str, List[str]]:
|
||||
"""Groups files by their parent directory."""
|
||||
groups = defaultdict(list)
|
||||
for f in files:
|
||||
parent = str(Path(f).parent)
|
||||
key = "root" if parent == "." else parent
|
||||
groups[key].append(f)
|
||||
return groups
|
||||
|
||||
def generate_diff(self, files: List[str]) -> str:
|
||||
"""Generates diff for specific files."""
|
||||
return self._exec_git(["git", "--no-pager", "diff", "--staged", "--", *files])
|
||||
|
||||
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."""
|
||||
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._exec_git(["git", "add", *files])
|
||||
|
||||
def commit_staged(self, msg: str):
|
||||
"""Commits staged changes."""
|
||||
self.logger.info(f"Committing:\n{msg}")
|
||||
self._exec_git(["git", "commit", "-m", msg])
|
||||
|
||||
def push_commits(self):
|
||||
"""Pushes commits to remote."""
|
||||
self.logger.info("Pushing to remote...")
|
||||
self._exec_git(["git", "push"])
|
||||
self.logger.info("Push successful.")
|
||||
|
||||
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.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)
|
||||
)
|
||||
)
|
||||
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)
|
||||
|
||||
template = self.template_env.get_template("commit_message.j2")
|
||||
prompt = template.render(group_name=group, diff_text=diff, use_emoji=use_emoji)
|
||||
|
||||
raw = generate_with_ai(
|
||||
prompt,
|
||||
provider=provider,
|
||||
model=model,
|
||||
api_key=api_key,
|
||||
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,
|
||||
)
|
||||
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,
|
||||
**self.kwargs,
|
||||
)
|
||||
return sanitize_ai_commit_message(raw)
|
||||
|
||||
def is_ahead_of_remote(self) -> bool:
|
||||
"""Checks if local branch has unpushed commits."""
|
||||
try:
|
||||
self._exec_git(["git", "fetch", "origin"])
|
||||
count = self._exec_git(
|
||||
["git", "rev-list", "--count", "@{u}..HEAD"], allow_error=True
|
||||
)
|
||||
if count and int(count) > 0:
|
||||
return True
|
||||
except CommitEngineError:
|
||||
# Maybe no upstream
|
||||
return bool(self._exec_git(["git", "rev-parse", "HEAD"], allow_error=True))
|
||||
return False
|
||||
# ------------------------------------------------------------------ public
|
||||
|
||||
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}")
|
||||
self._exec_git(["git", "reset", "HEAD", "--", *files])
|
||||
return True
|
||||
|
||||
msg = self.generate_message(group, diff, cache)
|
||||
if not msg:
|
||||
self.logger.error(f"Empty message for {group}")
|
||||
return False
|
||||
|
||||
if self.dry_run:
|
||||
self._log_dry_run(group, msg)
|
||||
self._exec_git(["git", "reset", "HEAD", "--", *files])
|
||||
else:
|
||||
self.commit_staged(msg)
|
||||
|
||||
return True
|
||||
|
||||
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.logger.info(f"Dry run done. See {self.dry_run_path}")
|
||||
self.console.print(
|
||||
f"[bold green]Dry run done.[/bold green] See {self.cache.path}"
|
||||
)
|
||||
else:
|
||||
self.logger.info("Done.")
|
||||
self.console.print("[bold green]Done.[/bold green]")
|
||||
if failed:
|
||||
self.logger.warning(f"Failed groups: {failed}")
|
||||
self.console.print(f"[bold red]Failed groups: {failed}[/bold red]")
|
||||
|
||||
# ------------------------------------------------------------------ groups
|
||||
|
||||
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):
|
||||
"""Entry point for the commit engine."""
|
||||
logger = configure_logger("devgen.commit")
|
||||
def run_commit_engine(**kwargs: Any) -> None:
|
||||
"""Entry point used by the CLI."""
|
||||
debug = kwargs.get("debug", False)
|
||||
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:
|
||||
|
||||
@@ -8,9 +8,7 @@ class LicenseGenerator:
|
||||
"""Generates license files from templates."""
|
||||
|
||||
def __init__(self):
|
||||
self.templates_dir = (
|
||||
Path(__file__).parent.parent / "prompt_templates" / "licenses"
|
||||
)
|
||||
self.templates_dir = Path(__file__).parent.parent / "templates" / "licenses"
|
||||
|
||||
def list_licenses(self) -> List[Dict[str, str]]:
|
||||
"""Lists available license templates."""
|
||||
|
||||
@@ -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,21 +0,0 @@
|
||||
from devgen.providers.anthropic import AnthropicProvider
|
||||
from devgen.providers.gemini import GeminiProvider
|
||||
from devgen.providers.huggingface import HuggingfaceProvider
|
||||
from devgen.providers.openai import OpenaiProvider
|
||||
from devgen.providers.openrouter import OpenrouterProvider
|
||||
|
||||
|
||||
def get_provider(name):
|
||||
name_lower = name.lower()
|
||||
if name_lower == "gemini":
|
||||
return GeminiProvider()
|
||||
elif name_lower == "openai":
|
||||
return OpenaiProvider()
|
||||
elif name_lower == "huggingface":
|
||||
return HuggingfaceProvider()
|
||||
elif name_lower == "openrouter":
|
||||
return OpenrouterProvider()
|
||||
elif name_lower == "anthropic":
|
||||
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
-40
@@ -1,48 +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}")
|
||||
|
||||
# 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,33 +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,
|
||||
)
|
||||
# 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,15 +0,0 @@
|
||||
|
||||
Analyze the following git diff for the directory `{{ group_name }}` and generate a detailed semantic release commit message4. Format the output as:
|
||||
1. A summary title in semantic release format (type(scope): summary) — scope can be the directory or 'root'.
|
||||
2. A short summary paragraph if needed.
|
||||
3. Bullet points listing the main changes, grouped as 'New Features', 'Enhancements & Refinements', and 'Bug Fixes' if relevant. Use bold for feature/section names.
|
||||
{% if use_emoji %}
|
||||
4. Use appropriate emojis for the commit type and sections (e.g., 🚀 for features, 🐛 for bugs).
|
||||
{% else %}
|
||||
4. Do NOT use any emojis in the commit message.
|
||||
{% endif %}
|
||||
Output only the commit message, don't include explanations or extra text.
|
||||
|
||||
Here is the git diff for `{{ group_name }}`:
|
||||
|
||||
{{ diff_text }}
|
||||
+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 }}
|
||||
+244
-35
@@ -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,32 +23,73 @@ 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:
|
||||
"""
|
||||
Cleans up the AI-generated commit message.
|
||||
It looks for a conventional commit header and extracts everything from there.
|
||||
It handles bolded headers and common AI prefixes.
|
||||
"""
|
||||
if not raw_text:
|
||||
return ""
|
||||
|
||||
lines = raw_text.strip().split("\n")
|
||||
cleaned_lines = []
|
||||
in_block = False
|
||||
# Regex for conventional commit header
|
||||
|
||||
# Regex for conventional commit header (including optional bolding)
|
||||
# Examples:
|
||||
# - feat(root): summary
|
||||
# - **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,
|
||||
)
|
||||
|
||||
cleaned_lines = []
|
||||
found_header = False
|
||||
|
||||
for line in lines:
|
||||
stripped = line.strip()
|
||||
if in_block:
|
||||
if header_pattern.match(stripped) or "**Sponsor**" in line:
|
||||
if not found_header:
|
||||
# First, strip bolding if it wraps the whole line or parts of it
|
||||
# This is simpler than handling it in the regex
|
||||
candidate = stripped.replace("**", "").strip()
|
||||
|
||||
# Clean the line by removing labels like "1. Title:", "### Message:", etc.
|
||||
clean_line = re.sub(
|
||||
r"^(#+\s+)?(\d+\.?|\*|-)?\s*(Title|Commit Message|Message):\s*",
|
||||
"",
|
||||
candidate,
|
||||
flags=re.IGNORECASE,
|
||||
).strip()
|
||||
|
||||
if header_pattern.match(clean_line):
|
||||
found_header = True
|
||||
cleaned_lines.append(clean_line)
|
||||
else:
|
||||
# If we hit another header or a known separator, we stop
|
||||
if "**Sponsor**" in line:
|
||||
break
|
||||
cleaned_lines.append(line)
|
||||
elif header_pattern.match(stripped):
|
||||
in_block = True
|
||||
# Ignore trailing markdown code block markers
|
||||
if line.strip() in ("```", "```md", "```markdown"):
|
||||
continue
|
||||
cleaned_lines.append(line)
|
||||
|
||||
return "\n".join(cleaned_lines).strip() if cleaned_lines else ""
|
||||
if cleaned_lines:
|
||||
return "\n".join(cleaned_lines).strip()
|
||||
|
||||
# Fallback: if no conventional commit header found, just take the first non-empty line
|
||||
# to avoid failing completely, but log a warning if possible.
|
||||
for line in lines:
|
||||
if line.strip():
|
||||
return line.strip()
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
def parse_markdown_sections(
|
||||
@@ -75,7 +115,7 @@ def extract_commit_messages(filepath: Path | str) -> dict[str, str]:
|
||||
|
||||
|
||||
def configure_logger(
|
||||
name: str = "devgen", log_file: Optional[Path | str] = None
|
||||
name: str = "devgen", log_file: Optional[Path | str] = None, console: bool = True
|
||||
) -> logging.Logger:
|
||||
logger = logging.getLogger(name)
|
||||
logger.setLevel(logging.INFO)
|
||||
@@ -88,9 +128,10 @@ def configure_logger(
|
||||
)
|
||||
|
||||
# Console handler
|
||||
ch = logging.StreamHandler()
|
||||
ch.setFormatter(formatter)
|
||||
logger.addHandler(ch)
|
||||
if console:
|
||||
ch = logging.StreamHandler()
|
||||
ch.setFormatter(formatter)
|
||||
logger.addHandler(ch)
|
||||
|
||||
# File handler
|
||||
if log_file:
|
||||
@@ -103,37 +144,134 @@ def configure_logger(
|
||||
return logger
|
||||
|
||||
|
||||
def get_git_staged_files() -> list[str]:
|
||||
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,
|
||||
cwd: Optional[Path] = None,
|
||||
encoding: str = "utf-8",
|
||||
errors: str = "replace",
|
||||
) -> str:
|
||||
"""
|
||||
Executes a git command and returns the output.
|
||||
|
||||
Args:
|
||||
args: List of command arguments (e.g., ["git", "status"]).
|
||||
check: Whether to raise an exception on non-zero exit code.
|
||||
cwd: Current working directory for the command.
|
||||
encoding: Output encoding.
|
||||
errors: Error handling strategy for encoding.
|
||||
|
||||
Returns:
|
||||
The standard output of the command, stripped of leading/trailing whitespace.
|
||||
|
||||
Raises:
|
||||
subprocess.CalledProcessError: If the command fails and check is True.
|
||||
"""
|
||||
try:
|
||||
res = subprocess.run(
|
||||
["git", "diff", "--name-only", "--cached"],
|
||||
args,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
encoding=encoding,
|
||||
errors=errors,
|
||||
check=check,
|
||||
cwd=cwd,
|
||||
)
|
||||
return [f for f in res.stdout.splitlines() if f.strip()]
|
||||
except subprocess.CalledProcessError:
|
||||
return res.stdout.strip()
|
||||
except subprocess.CalledProcessError as e:
|
||||
# Log the error if a logger is configured, but re-raise
|
||||
# We don't have access to a specific logger here easily without passing it in,
|
||||
# so we rely on the caller to handle logging if needed, or we could log to a default one.
|
||||
# For now, just re-raise as the caller expects.
|
||||
raise e
|
||||
|
||||
|
||||
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, 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_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]:
|
||||
@@ -145,6 +283,8 @@ def load_config() -> Dict[str, Any]:
|
||||
"model": "gemini-2.5-flash",
|
||||
"api_key": "",
|
||||
"emoji": True,
|
||||
"max_groups": 5,
|
||||
"ollama_host": "http://localhost:11434",
|
||||
}
|
||||
try:
|
||||
with config_path.open("w", encoding="utf-8") as f:
|
||||
@@ -156,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",
|
||||
@@ -170,12 +375,16 @@ __all__ = [
|
||||
"sanitize_ai_commit_message",
|
||||
"extract_commit_messages",
|
||||
"configure_logger",
|
||||
"run_git_command",
|
||||
"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.1"
|
||||
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.1",
|
||||
version="0.3.0",
|
||||
packages=find_packages(),
|
||||
include_package_data=True,
|
||||
install_requires=[
|
||||
|
||||
Reference in new issue
Block a user