Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fe7da77ed6 | ||
|
|
407458149b | ||
|
|
e9a45cf140 | ||
|
|
70e150611b | ||
|
|
f85da0461f | ||
|
|
7c15e2183b | ||
|
|
a2f3657269 | ||
|
|
c50f8a4b89 | ||
|
|
a488d5e4ac | ||
|
|
48836797b9 | ||
|
|
09151decad | ||
|
|
24dc7f57b4 | ||
|
|
449a955d15 | ||
|
|
87ed8bf878 | ||
|
|
6e4b177fe3 | ||
|
|
8db30f14bc | ||
|
|
d502849daf | ||
|
|
f3c68aaf29 | ||
|
|
7f6b247b40 | ||
|
|
6e7525241d | ||
|
|
b7faa9e17c | ||
|
|
97a0b34f9c | ||
|
|
b975ffc5e3 |
No files matched your search
@@ -12,11 +12,11 @@ jobs:
|
|||||||
permissions:
|
permissions:
|
||||||
id-token: write # IMPORTANT: this permission is mandatory for trusted publishing
|
id-token: write # IMPORTANT: this permission is mandatory for trusted publishing
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v6
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: actions/setup-python@v4
|
uses: actions/setup-python@v6
|
||||||
with:
|
with:
|
||||||
python-version: '3.12'
|
python-version: '3.10'
|
||||||
cache: 'pip'
|
cache: 'pip'
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -47,3 +47,4 @@ logs/
|
|||||||
devgen.egg-info/
|
devgen.egg-info/
|
||||||
build/
|
build/
|
||||||
devgen_utils.egg-info/
|
devgen_utils.egg-info/
|
||||||
|
devgen_cli.egg-info/
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
graft devgen/templates
|
||||||
|
include README.md
|
||||||
|
include LICENSE
|
||||||
@@ -2,248 +2,139 @@
|
|||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
<img src="https://img.shields.io/pypi/v/devgen?color=blue&label=PyPI&logo=pypi&logoColor=white" alt="PyPI">
|
> **Your AI Powerhouse for Git & Project Management.**
|
||||||
|
> Stop wasting time on repetitive tasks. Automate your commits, changelogs, and project essentials with a single CLI.
|
||||||
|
>
|
||||||
|
> 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">
|
<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/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-GPL--3.0--or--later-blue.svg" alt="License"></a>
|
||||||
|
|
||||||
</div>
|
</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.
|
---
|
||||||
|
|
||||||
## ✨ Features
|
**DevGen** isn't just another CLI tool it's your development workflow on steroids. By leveraging state of the art AI models, DevGen turns tedious manual tasks into one click magic. From semantic commit messages to comprehensive release notes, DevGen handles the boring stuff so you can focus on building.
|
||||||
|
|
||||||
- 🚀 **AI-Powered Commit Messages** - Automatically generate semantic commit messages from your staged changes using AI
|
## ⚡ Why DevGen?
|
||||||
- 📝 **Changelog Generation** - Create changelogs from git history using conventional commits
|
|
||||||
- 🚀 **Release Notes** - Generate clean, emoji-enhanced release notes for your releases
|
**🧠 AI Brain**
|
||||||
- 🙈 **Gitignore Templates** - Generate `.gitignore` files from GitHub's official templates
|
Semantic commit messages powered by Gemini, OpenAI, Claude, HuggingFace, and OpenRouter. It reads your diffs and understands your code.
|
||||||
- 📄 **License Generation** - Quickly generate open source licenses with interactive setup
|
|
||||||
- ⚙️ **Multi-Provider AI Support** - Works with Gemini, OpenAI, Anthropic, HuggingFace, and OpenRouter
|
**🚀 Battle Tested**
|
||||||
|
Generates **Conventional Commits** and **Semantic Versioning** compliant changelogs that actually make sense.
|
||||||
|
|
||||||
|
**⚡ Lightning Fast**
|
||||||
|
Smart caching and async operations mean you never wait longer than necessary.
|
||||||
|
|
||||||
|
**Project Essentials**
|
||||||
|
Quickly add `.gitignore` and license files to your existing projects. Access cached templates instantly, even without internet.
|
||||||
|
|
||||||
|
**🛠️ Zero Friction**
|
||||||
|
Interactive setup gets you running in seconds.
|
||||||
|
|
||||||
## 📦 Installation
|
## 📦 Installation
|
||||||
|
|
||||||
|
Get started in seconds.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Stable release from PyPI
|
# Recommended: Install via pipx for an isolated environment
|
||||||
pip install devgen
|
pipx install devgen-cli
|
||||||
|
|
||||||
# Using pipx (recommended for isolated CLI)
|
# Or use uv for blazing speed
|
||||||
pipx install devgen
|
uv tool install devgen-cli
|
||||||
|
|
||||||
# Using uv (fast Python package manager)
|
# Standard pip install
|
||||||
uv tool install devgen
|
pip install devgen-cli
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🚀 Quick Start
|
## 🚀 Quick Start
|
||||||
|
|
||||||
### 1. Initial Setup
|
**1. Initialize & Configure**
|
||||||
|
Tell DevGen which AI provider to use.
|
||||||
Configure your AI provider and API key:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
devgen setup config
|
devgen setup config
|
||||||
```
|
```
|
||||||
|
|
||||||
This will create a configuration file at `~/.devgen.yaml` with your preferred AI provider, model, and API key.
|
**2. Stage & Commit**
|
||||||
|
Stage your files and let AI write the message.
|
||||||
### 2. Generate Commit Messages
|
|
||||||
|
|
||||||
Stage your changes and let DevGen generate semantic commit messages:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Dry run to preview commit messages
|
git add .
|
||||||
|
devgen commit run
|
||||||
|
```
|
||||||
|
|
||||||
|
_Boom. Done._
|
||||||
|
|
||||||
|
## 💡 Feature Deep Dive
|
||||||
|
|
||||||
|
### 🤖 AI Powered Commits
|
||||||
|
|
||||||
|
Stop writing "fix bug" or "wip". DevGen analyzes your staged changes, groups them by component, and generates meaningful, semantic commit messages.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Preview what DevGen will generate
|
||||||
devgen commit run --dry-run
|
devgen commit run --dry-run
|
||||||
|
|
||||||
# Actually commit with AI-generated messages
|
# Commit and push in one go
|
||||||
devgen commit run
|
|
||||||
|
|
||||||
# Commit and automatically push
|
|
||||||
devgen commit run --push
|
devgen commit run --push
|
||||||
|
|
||||||
# Force regeneration of commit messages
|
|
||||||
devgen commit run --force-rebuild
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**How it works:**
|
### 📝 Changelogs & Release Notes
|
||||||
- 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
|
|
||||||
|
|
||||||
**Additional commit commands:**
|
Turn your git history into beautiful, readable documentation.
|
||||||
```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
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Generate Changelogs
|
|
||||||
|
|
||||||
Create changelogs from your git history:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Generate changelog from last tag to HEAD
|
# Generate a changelog from the last tag
|
||||||
devgen changelog generate
|
devgen changelog generate
|
||||||
|
|
||||||
# Generate from a specific reference
|
# Create release notes for v2.0.0
|
||||||
devgen changelog generate --from v1.0.0
|
devgen release notes --version 2.0.0
|
||||||
|
|
||||||
# Output to a custom file
|
|
||||||
devgen changelog generate --output CHANGES.md
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The changelog follows Semantic Release conventions, categorizing commits into:
|
### 🛡️ Essential Files
|
||||||
- **BREAKING CHANGES**
|
|
||||||
- **Features**
|
|
||||||
- **Bug Fixes**
|
|
||||||
- **Documentation**
|
|
||||||
- **Other Changes**
|
|
||||||
|
|
||||||
### 4. Generate Release Notes
|
Don't waste time searching for templates. Generate the essential files for your project instantly.
|
||||||
|
|
||||||
Create release notes for your releases:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Generate release notes
|
# Interactive search for gitignore templates
|
||||||
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
|
devgen gitignore generate
|
||||||
|
|
||||||
# Specify templates directly
|
# Generate a license interactively
|
||||||
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
|
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
|
||||||
|
|
||||||
Configuration is stored in `~/.devgen.yaml`. You can edit it manually or use the interactive setup:
|
Your settings live in `~/.devgen.yaml`. You can tweak your AI provider, model, and preferences there.
|
||||||
|
|
||||||
```bash
|
| Option | Description |
|
||||||
devgen setup config
|
| :--------- | :----------------------------------------------------------- |
|
||||||
```
|
| `provider` | `gemini`, `openai`, `anthropic`, `huggingface`, `openrouter` |
|
||||||
|
| `model` | Specific model name (e.g., `gemini-2.5-flash`, `gpt-4o`) |
|
||||||
**Configuration options:**
|
| `emoji` | Enable/disable gitmojis in commits (`true`/`false`) |
|
||||||
- `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
|
## 🤝 Contributing
|
||||||
|
|
||||||
Contributions are welcome! Please feel free to submit a Pull Request.
|
We love contributions! Found a bug? Want a new feature? Open an issue or submit a PR.
|
||||||
|
|
||||||
## 📚 Links
|
## � Acknowledgments
|
||||||
|
|
||||||
- **Homepage**: [https://github.com/S4NKALP/devgen](https://github.com/S4NKALP/devgen)
|
DevGen wouldn't be possible without these amazing open-source projects and AI providers:
|
||||||
- **PyPI**: [https://pypi.org/project/devgen](https://pypi.org/project/devgen)
|
|
||||||
|
|
||||||
## 🙏 Acknowledgments
|
- **[Typer](https://typer.tiangolo.com/)** & **[Rich](https://rich.readthedocs.io/)** for building the beautiful, intuitive, and responsive CLI interface.
|
||||||
|
- **[Questionary](https://github.com/tmbo/questionary)** for creating interactive, user-friendly prompts and selection menus.
|
||||||
|
- **[Jinja2](https://jinja.palletsprojects.com/)** for the powerful template engine used to generate files and messages.
|
||||||
|
- **[Google Gemini](https://deepmind.google/technologies/gemini/)**, **[OpenAI](https://openai.com/)**, **[Anthropic](https://www.anthropic.com/)**, **[HuggingFace](https://huggingface.co/)**, and **[OpenRouter](https://openrouter.ai/)** for providing the advanced AI models that power the semantic generation features.
|
||||||
|
|
||||||
- Uses GitHub's official [gitignore templates](https://github.com/github/gitignore)
|
## �📝 License
|
||||||
- Follows [Conventional Commits](https://www.conventionalcommits.org/) specification
|
|
||||||
|
Proudly open source under the [GPL-3.0-or-later](LICENSE) License.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
Made with ❤️ by <a href="https://github.com/S4NKALP">Sankalp</a>
|
||||||
|
</div>
|
||||||
@@ -52,7 +52,7 @@ def run_commit(
|
|||||||
] = False,
|
] = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
log_file = get_main_log_path()
|
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"Log file: {log_file}")
|
||||||
logger.info(
|
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}"
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from datetime import datetime
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Dict, List, Optional
|
from typing import Dict, List, Optional
|
||||||
|
|
||||||
from devgen.utils import configure_logger
|
from devgen.utils import configure_logger, run_git_command
|
||||||
|
|
||||||
|
|
||||||
class ChangelogGenerator:
|
class ChangelogGenerator:
|
||||||
@@ -14,22 +14,6 @@ class ChangelogGenerator:
|
|||||||
def __init__(self, logger=None):
|
def __init__(self, logger=None):
|
||||||
self.logger = logger or configure_logger("devgen.changelog")
|
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}")
|
|
||||||
|
|
||||||
def get_commits(self, from_ref: str = "", to_ref: str = "HEAD") -> List[str]:
|
def get_commits(self, from_ref: str = "", to_ref: str = "HEAD") -> List[str]:
|
||||||
"""Fetches commit messages in the specified range."""
|
"""Fetches commit messages in the specified range."""
|
||||||
range_spec = f"{from_ref}..{to_ref}" if from_ref else to_ref
|
range_spec = f"{from_ref}..{to_ref}" if from_ref else to_ref
|
||||||
@@ -40,7 +24,7 @@ class ChangelogGenerator:
|
|||||||
if not from_ref:
|
if not from_ref:
|
||||||
# If no start ref, try to find the last tag
|
# If no start ref, try to find the last tag
|
||||||
try:
|
try:
|
||||||
last_tag = self._exec_git(["git", "describe", "--tags", "--abbrev=0"])
|
last_tag = run_git_command(["git", "describe", "--tags", "--abbrev=0"])
|
||||||
cmd = [
|
cmd = [
|
||||||
"git",
|
"git",
|
||||||
"log",
|
"log",
|
||||||
@@ -49,11 +33,15 @@ class ChangelogGenerator:
|
|||||||
f"{last_tag}..HEAD",
|
f"{last_tag}..HEAD",
|
||||||
]
|
]
|
||||||
self.logger.info(f"Generating changelog from last tag: {last_tag}")
|
self.logger.info(f"Generating changelog from last tag: {last_tag}")
|
||||||
except RuntimeError:
|
except (RuntimeError, subprocess.CalledProcessError):
|
||||||
self.logger.info("No tags found, generating for all commits.")
|
self.logger.info("No tags found, generating for all commits.")
|
||||||
cmd = ["git", "log", f"--format={fmt}", "--date=short"]
|
cmd = ["git", "log", f"--format={fmt}", "--date=short"]
|
||||||
|
|
||||||
return self._exec_git(cmd).split("\n")
|
try:
|
||||||
|
return run_git_command(cmd).split("\n")
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
self.logger.error(f"Git command failed: {e}")
|
||||||
|
raise RuntimeError(f"Git command failed: {e}")
|
||||||
|
|
||||||
def parse_commits(self, raw_commits: List[str]) -> Dict[str, List[Dict]]:
|
def parse_commits(self, raw_commits: List[str]) -> Dict[str, List[Dict]]:
|
||||||
"""Parses raw commit strings into structured data."""
|
"""Parses raw commit strings into structured data."""
|
||||||
|
|||||||
@@ -11,8 +11,13 @@ from devgen.utils import (
|
|||||||
get_commit_dry_run_path,
|
get_commit_dry_run_path,
|
||||||
is_file_recent,
|
is_file_recent,
|
||||||
load_template_env,
|
load_template_env,
|
||||||
|
run_git_command,
|
||||||
sanitize_ai_commit_message,
|
sanitize_ai_commit_message,
|
||||||
)
|
)
|
||||||
|
from rich.console import Console
|
||||||
|
from rich.panel import Panel
|
||||||
|
from rich.markdown import Markdown
|
||||||
|
from rich.theme import Theme
|
||||||
|
|
||||||
|
|
||||||
class CommitEngineError(Exception):
|
class CommitEngineError(Exception):
|
||||||
@@ -45,48 +50,44 @@ class CommitEngine:
|
|||||||
self.provider = provider
|
self.provider = provider
|
||||||
self.model = model
|
self.model = model
|
||||||
self.logger = logger or configure_logger(
|
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.kwargs = kwargs
|
||||||
self.dry_run_path = get_commit_dry_run_path()
|
self.dry_run_path = get_commit_dry_run_path()
|
||||||
self.template_env = load_template_env("commit")
|
self.template_env = load_template_env("commit")
|
||||||
|
|
||||||
|
self.console = Console(
|
||||||
|
theme=Theme(
|
||||||
|
{"info": "dim cyan", "warning": "magenta", "danger": "bold red"}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
# Load config from ~/.devgen.yaml
|
# Load config from ~/.devgen.yaml
|
||||||
from devgen.utils import load_config
|
from devgen.utils import load_config
|
||||||
|
|
||||||
self.config = load_config()
|
self.config = load_config()
|
||||||
|
|
||||||
def _exec_git(self, args: List[str], allow_error: bool = False) -> str:
|
def detect_changes(self) -> List[str]:
|
||||||
"""Executes a git command."""
|
"""Detects changed, deleted, or untracked files."""
|
||||||
try:
|
try:
|
||||||
res = subprocess.run(
|
out = run_git_command(
|
||||||
args,
|
[
|
||||||
capture_output=True,
|
"git",
|
||||||
text=True,
|
"ls-files",
|
||||||
encoding="utf-8",
|
"--deleted",
|
||||||
errors="replace",
|
"--modified",
|
||||||
check=not allow_error,
|
"--others",
|
||||||
|
"--exclude-standard",
|
||||||
|
]
|
||||||
)
|
)
|
||||||
return res.stdout.strip()
|
return [f.strip() for f in out.split("\n") if f.strip()]
|
||||||
except subprocess.CalledProcessError as e:
|
except subprocess.CalledProcessError as e:
|
||||||
msg = f"Git command failed: {' '.join(e.cmd)}\nError: {e.stderr.strip()}"
|
msg = f"Git command failed: {' '.join(e.cmd)}\nError: {e.stderr.strip()}"
|
||||||
self.logger.error(msg)
|
self.logger.error(msg)
|
||||||
raise CommitEngineError(msg) from e
|
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]]:
|
def group_files(self, files: List[str]) -> Dict[str, List[str]]:
|
||||||
"""Groups files by their parent directory."""
|
"""Groups files by their parent directory."""
|
||||||
groups = defaultdict(list)
|
groups = defaultdict(list)
|
||||||
@@ -98,7 +99,14 @@ class CommitEngine:
|
|||||||
|
|
||||||
def generate_diff(self, files: List[str]) -> str:
|
def generate_diff(self, files: List[str]) -> str:
|
||||||
"""Generates diff for specific files."""
|
"""Generates diff for specific files."""
|
||||||
return self._exec_git(["git", "--no-pager", "diff", "--staged", "--", *files])
|
try:
|
||||||
|
return run_git_command(
|
||||||
|
["git", "--no-pager", "diff", "--staged", "--", *files]
|
||||||
|
)
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
msg = f"Git command failed: {' '.join(e.cmd)}\nError: {e.stderr.strip()}"
|
||||||
|
self.logger.error(msg)
|
||||||
|
raise CommitEngineError(msg) from e
|
||||||
|
|
||||||
def _init_dry_run(self):
|
def _init_dry_run(self):
|
||||||
"""Initializes the dry-run file."""
|
"""Initializes the dry-run file."""
|
||||||
@@ -108,7 +116,15 @@ class CommitEngine:
|
|||||||
f.write(f"# Dry Run: Commit Messages\n_Generated: {ts}_\n\n")
|
f.write(f"# Dry Run: Commit Messages\n_Generated: {ts}_\n\n")
|
||||||
|
|
||||||
def _log_dry_run(self, group: str, msg: str):
|
def _log_dry_run(self, group: str, msg: str):
|
||||||
"""Appends a dry-run entry."""
|
"""Appends a dry-run entry and prints to console."""
|
||||||
|
self.console.print(
|
||||||
|
Panel(
|
||||||
|
Markdown(msg),
|
||||||
|
title=f"Dry Run: {group}",
|
||||||
|
border_style="yellow",
|
||||||
|
expand=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
with self.dry_run_path.open("a", encoding="utf-8") as f:
|
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")
|
f.write(f"## Group: `{group}`\n\n```md\n{msg}\n```\n\n---\n\n")
|
||||||
|
|
||||||
@@ -117,18 +133,40 @@ class CommitEngine:
|
|||||||
if not files:
|
if not files:
|
||||||
return
|
return
|
||||||
self.logger.info(f"Staging: {files}")
|
self.logger.info(f"Staging: {files}")
|
||||||
self._exec_git(["git", "add", *files])
|
self.console.print(f"[info]Staging {len(files)} files...[/info]")
|
||||||
|
try:
|
||||||
|
run_git_command(["git", "add", *files])
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
msg = f"Git command failed: {' '.join(e.cmd)}\nError: {e.stderr.strip()}"
|
||||||
|
self.logger.error(msg)
|
||||||
|
raise CommitEngineError(msg) from e
|
||||||
|
|
||||||
def commit_staged(self, msg: str):
|
def commit_staged(self, msg: str):
|
||||||
"""Commits staged changes."""
|
"""Commits staged changes."""
|
||||||
self.logger.info(f"Committing:\n{msg}")
|
self.logger.info(f"Committing:\n{msg}")
|
||||||
self._exec_git(["git", "commit", "-m", msg])
|
self.console.print(
|
||||||
|
Panel(Markdown(msg), title="Commit Message", border_style="green")
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
run_git_command(["git", "commit", "-m", msg])
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
msg = f"Git command failed: {' '.join(e.cmd)}\nError: {e.stderr.strip()}"
|
||||||
|
self.logger.error(msg)
|
||||||
|
raise CommitEngineError(msg) from e
|
||||||
|
|
||||||
def push_commits(self):
|
def push_commits(self):
|
||||||
"""Pushes commits to remote."""
|
"""Pushes commits to remote."""
|
||||||
self.logger.info("Pushing to remote...")
|
self.logger.info("Pushing to remote...")
|
||||||
self._exec_git(["git", "push"])
|
with self.console.status("[bold green]Pushing to remote...[/bold green]"):
|
||||||
self.logger.info("Push successful.")
|
try:
|
||||||
|
run_git_command(["git", "push"])
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
msg = (
|
||||||
|
f"Git command failed: {' '.join(e.cmd)}\nError: {e.stderr.strip()}"
|
||||||
|
)
|
||||||
|
self.logger.error(msg)
|
||||||
|
raise CommitEngineError(msg) from e
|
||||||
|
self.console.print("[bold green]Push successful.[/bold green]")
|
||||||
|
|
||||||
def generate_message(self, group: str, diff: str, cache: Dict[str, str]) -> str:
|
def generate_message(self, group: str, diff: str, cache: Dict[str, str]) -> str:
|
||||||
"""Generates a commit message using AI or cache."""
|
"""Generates a commit message using AI or cache."""
|
||||||
@@ -147,28 +185,32 @@ class CommitEngine:
|
|||||||
template = self.template_env.get_template("commit_message.j2")
|
template = self.template_env.get_template("commit_message.j2")
|
||||||
prompt = template.render(group_name=group, diff_text=diff, use_emoji=use_emoji)
|
prompt = template.render(group_name=group, diff_text=diff, use_emoji=use_emoji)
|
||||||
|
|
||||||
raw = generate_with_ai(
|
with self.console.status("[bold blue]Generating commit message...[/bold blue]"):
|
||||||
prompt,
|
raw = generate_with_ai(
|
||||||
provider=provider,
|
prompt,
|
||||||
model=model,
|
provider=provider,
|
||||||
api_key=api_key,
|
model=model,
|
||||||
debug=self.debug,
|
api_key=api_key,
|
||||||
**self.kwargs,
|
debug=self.debug,
|
||||||
)
|
**self.kwargs,
|
||||||
|
)
|
||||||
return sanitize_ai_commit_message(raw)
|
return sanitize_ai_commit_message(raw)
|
||||||
|
|
||||||
def is_ahead_of_remote(self) -> bool:
|
def is_ahead_of_remote(self) -> bool:
|
||||||
"""Checks if local branch has unpushed commits."""
|
"""Checks if local branch has unpushed commits."""
|
||||||
try:
|
try:
|
||||||
self._exec_git(["git", "fetch", "origin"])
|
run_git_command(["git", "fetch", "origin"])
|
||||||
count = self._exec_git(
|
count = run_git_command(
|
||||||
["git", "rev-list", "--count", "@{u}..HEAD"], allow_error=True
|
["git", "rev-list", "--count", "@{u}..HEAD"], check=False
|
||||||
)
|
)
|
||||||
if count and int(count) > 0:
|
if count and int(count) > 0:
|
||||||
return True
|
return True
|
||||||
except CommitEngineError:
|
except (subprocess.CalledProcessError, CommitEngineError):
|
||||||
# Maybe no upstream
|
# Maybe no upstream
|
||||||
return bool(self._exec_git(["git", "rev-parse", "HEAD"], allow_error=True))
|
try:
|
||||||
|
return bool(run_git_command(["git", "rev-parse", "HEAD"], check=False))
|
||||||
|
except subprocess.CalledProcessError:
|
||||||
|
return False
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def load_cache(self) -> Dict[str, str]:
|
def load_cache(self) -> Dict[str, str]:
|
||||||
@@ -190,7 +232,10 @@ class CommitEngine:
|
|||||||
|
|
||||||
if not diff.strip():
|
if not diff.strip():
|
||||||
self.logger.info(f"Skipping empty diff for {group}")
|
self.logger.info(f"Skipping empty diff for {group}")
|
||||||
self._exec_git(["git", "reset", "HEAD", "--", *files])
|
try:
|
||||||
|
run_git_command(["git", "reset", "HEAD", "--", *files])
|
||||||
|
except subprocess.CalledProcessError:
|
||||||
|
pass # Ignore reset errors
|
||||||
return True
|
return True
|
||||||
|
|
||||||
msg = self.generate_message(group, diff, cache)
|
msg = self.generate_message(group, diff, cache)
|
||||||
@@ -200,7 +245,10 @@ class CommitEngine:
|
|||||||
|
|
||||||
if self.dry_run:
|
if self.dry_run:
|
||||||
self._log_dry_run(group, msg)
|
self._log_dry_run(group, msg)
|
||||||
self._exec_git(["git", "reset", "HEAD", "--", *files])
|
try:
|
||||||
|
run_git_command(["git", "reset", "HEAD", "--", *files])
|
||||||
|
except subprocess.CalledProcessError:
|
||||||
|
pass
|
||||||
else:
|
else:
|
||||||
self.commit_staged(msg)
|
self.commit_staged(msg)
|
||||||
|
|
||||||
@@ -237,16 +285,19 @@ class CommitEngine:
|
|||||||
self.logger.error("Push aborted due to failed commits.")
|
self.logger.error("Push aborted due to failed commits.")
|
||||||
|
|
||||||
if self.dry_run:
|
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.dry_run_path}"
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
self.logger.info("Done.")
|
self.console.print("[bold green]Done.[/bold green]")
|
||||||
if failed:
|
if failed:
|
||||||
self.logger.warning(f"Failed groups: {failed}")
|
self.console.print(f"[bold red]Failed groups: {failed}[/bold red]")
|
||||||
|
|
||||||
|
|
||||||
def run_commit_engine(**kwargs):
|
def run_commit_engine(**kwargs):
|
||||||
"""Entry point for the commit engine."""
|
"""Entry point for the commit engine."""
|
||||||
logger = configure_logger("devgen.commit")
|
debug = kwargs.get("debug", False)
|
||||||
|
logger = configure_logger("devgen.commit", console=debug)
|
||||||
try:
|
try:
|
||||||
engine = CommitEngine(**kwargs)
|
engine = CommitEngine(**kwargs)
|
||||||
engine.execute()
|
engine.execute()
|
||||||
|
|||||||
@@ -1,21 +1,24 @@
|
|||||||
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):
|
def get_provider(name):
|
||||||
name_lower = name.lower()
|
name_lower = name.lower()
|
||||||
if name_lower == "gemini":
|
if name_lower == "gemini":
|
||||||
|
from devgen.providers.gemini import GeminiProvider
|
||||||
|
|
||||||
return GeminiProvider()
|
return GeminiProvider()
|
||||||
elif name_lower == "openai":
|
elif name_lower == "openai":
|
||||||
|
from devgen.providers.openai import OpenaiProvider
|
||||||
|
|
||||||
return OpenaiProvider()
|
return OpenaiProvider()
|
||||||
elif name_lower == "huggingface":
|
elif name_lower == "huggingface":
|
||||||
|
from devgen.providers.huggingface import HuggingfaceProvider
|
||||||
|
|
||||||
return HuggingfaceProvider()
|
return HuggingfaceProvider()
|
||||||
elif name_lower == "openrouter":
|
elif name_lower == "openrouter":
|
||||||
|
from devgen.providers.openrouter import OpenrouterProvider
|
||||||
|
|
||||||
return OpenrouterProvider()
|
return OpenrouterProvider()
|
||||||
elif name_lower == "anthropic":
|
elif name_lower == "anthropic":
|
||||||
|
from devgen.providers.anthropic import AnthropicProvider
|
||||||
|
|
||||||
return AnthropicProvider()
|
return AnthropicProvider()
|
||||||
|
|
||||||
raise NotImplementedError(f"Provider '{name}' is not implemented yet.")
|
raise NotImplementedError(f"Provider '{name}' is not implemented yet.")
|
||||||
@@ -39,6 +39,9 @@ class OpenaiProvider:
|
|||||||
# Add error handling if the client fails to initialize
|
# Add error handling if the client fails to initialize
|
||||||
raise RuntimeError(f"Failed to initialize OpenAI client: {e}")
|
raise RuntimeError(f"Failed to initialize OpenAI client: {e}")
|
||||||
|
|
||||||
|
# Remove debug from kwargs if present
|
||||||
|
kwargs.pop("debug", None)
|
||||||
|
|
||||||
# 2. Use the modern API syntax: client.chat.completions.create
|
# 2. Use the modern API syntax: client.chat.completions.create
|
||||||
response = client.chat.completions.create(
|
response = client.chat.completions.create(
|
||||||
model=model or self.DEFAULT_MODEL,
|
model=model or self.DEFAULT_MODEL,
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ class OpenrouterProvider:
|
|||||||
api_key=api_key,
|
api_key=api_key,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Remove debug from kwargs if present
|
||||||
|
kwargs.pop("debug", None)
|
||||||
|
|
||||||
response = client.chat.completions.create(
|
response = client.chat.completions.create(
|
||||||
model=model,
|
model=model,
|
||||||
messages=[{"role": "user", "content": prompt}],
|
messages=[{"role": "user", "content": prompt}],
|
||||||
|
|||||||
+48
-9
@@ -75,7 +75,7 @@ def extract_commit_messages(filepath: Path | str) -> dict[str, str]:
|
|||||||
|
|
||||||
|
|
||||||
def configure_logger(
|
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:
|
) -> logging.Logger:
|
||||||
logger = logging.getLogger(name)
|
logger = logging.getLogger(name)
|
||||||
logger.setLevel(logging.INFO)
|
logger.setLevel(logging.INFO)
|
||||||
@@ -88,9 +88,10 @@ def configure_logger(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Console handler
|
# Console handler
|
||||||
ch = logging.StreamHandler()
|
if console:
|
||||||
ch.setFormatter(formatter)
|
ch = logging.StreamHandler()
|
||||||
logger.addHandler(ch)
|
ch.setFormatter(formatter)
|
||||||
|
logger.addHandler(ch)
|
||||||
|
|
||||||
# File handler
|
# File handler
|
||||||
if log_file:
|
if log_file:
|
||||||
@@ -103,15 +104,52 @@ def configure_logger(
|
|||||||
return logger
|
return logger
|
||||||
|
|
||||||
|
|
||||||
def get_git_staged_files() -> list[str]:
|
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:
|
try:
|
||||||
res = subprocess.run(
|
res = subprocess.run(
|
||||||
["git", "diff", "--name-only", "--cached"],
|
args,
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
check=True,
|
encoding=encoding,
|
||||||
|
errors=errors,
|
||||||
|
check=check,
|
||||||
|
cwd=cwd,
|
||||||
)
|
)
|
||||||
return [f for f in res.stdout.splitlines() if f.strip()]
|
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:
|
except subprocess.CalledProcessError:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
@@ -132,7 +170,7 @@ def delete_file(filepath: Path | str) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
def load_template_env(sub_dir: str) -> Environment:
|
def load_template_env(sub_dir: str) -> Environment:
|
||||||
template_dir = Path(__file__).parent / "prompt_templates" / sub_dir
|
template_dir = Path(__file__).parent / "templates" / sub_dir
|
||||||
return Environment(loader=FileSystemLoader(template_dir))
|
return Environment(loader=FileSystemLoader(template_dir))
|
||||||
|
|
||||||
|
|
||||||
@@ -170,6 +208,7 @@ __all__ = [
|
|||||||
"sanitize_ai_commit_message",
|
"sanitize_ai_commit_message",
|
||||||
"extract_commit_messages",
|
"extract_commit_messages",
|
||||||
"configure_logger",
|
"configure_logger",
|
||||||
|
"run_git_command",
|
||||||
"get_git_staged_files",
|
"get_git_staged_files",
|
||||||
"read_file_content",
|
"read_file_content",
|
||||||
"delete_file",
|
"delete_file",
|
||||||
|
|||||||
+2
-2
@@ -3,8 +3,8 @@ requires = ["setuptools>=61", "wheel"]
|
|||||||
build-backend = "setuptools.build_meta"
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "devgen"
|
name = "devgen-cli"
|
||||||
version = "0.2.0"
|
version = "0.2.3"
|
||||||
description = "A collection of developer tools"
|
description = "A collection of developer tools"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ Setup script for devgen package.
|
|||||||
from setuptools import find_packages, setup
|
from setuptools import find_packages, setup
|
||||||
|
|
||||||
setup(
|
setup(
|
||||||
name="devgen",
|
name="devgen-cli",
|
||||||
version="0.2.0",
|
version="0.2.3",
|
||||||
packages=find_packages(),
|
packages=find_packages(),
|
||||||
include_package_data=True,
|
include_package_data=True,
|
||||||
install_requires=[
|
install_requires=[
|
||||||
|
|||||||
@@ -188,8 +188,8 @@ wheels = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "devgen"
|
name = "devgen-cli"
|
||||||
version = "0.1.0"
|
version = "0.2.2"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "anthropic" },
|
{ name = "anthropic" },
|
||||||
@@ -262,7 +262,7 @@ name = "exceptiongroup"
|
|||||||
version = "1.3.1"
|
version = "1.3.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
{ name = "typing-extensions", marker = "python_full_version < '3.11'" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
|
|||||||
Reference in new issue
Block a user