Compare commits
41 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| eb35578055 | |||
| 853b5d757e | |||
| bb42985efb | |||
| f723146e51 | |||
| df72b5e819 | |||
| 75e8ed4bb3 | |||
| d25a697ce9 | |||
| bae3d509c3 | |||
| f6e938c2de | |||
| 2b3b3e8a2e | |||
| fef0ba7d4c | |||
| 9f604d31f7 | |||
| 6dfa82e092 | |||
| 1b7705cf1a | |||
| e1c18d0451 | |||
| 7e5b37eb71 | |||
| ecb169e003 | |||
| 02e93f863a | |||
| 180cacec79 | |||
| 39e8277504 | |||
| 853b738b4b | |||
| aabb84cdcf | |||
| e5447c8296 | |||
| 52041d975e | |||
| b74ff8a624 | |||
| 3de0498d5e | |||
| edd15de989 | |||
| 1ebc45808e | |||
| cf6a1a838b | |||
| 1b14e0f6cd | |||
| 68698fa359 | |||
| b903f80399 | |||
| 3d1c1d11ac | |||
| 99ca60c7b4 | |||
| b86f66a574 | |||
| 4a8be31b24 | |||
| 49f8c47c69 | |||
| 2f9148bbbe | |||
| eeb016d39d | |||
| 7d4a7ab61c | |||
| 53dfd70fc8 |
@@ -14,11 +14,11 @@ jobs:
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
- name: Install dependencies
|
||||
- name: Run tests and linting
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
cd app
|
||||
pip install -e ".[dev]"
|
||||
- name: Run tests
|
||||
run: pytest tests/ -v --cov=src --cov-report=term-missing
|
||||
- name: Check linting
|
||||
run: pip install ruff && ruff check .
|
||||
python -m pytest tests/ -v --cov=src --cov-report=term-missing
|
||||
pip install ruff
|
||||
ruff check src/
|
||||
|
||||
6
app/ruff.toml
Normal file
6
app/ruff.toml
Normal file
@@ -0,0 +1,6 @@
|
||||
[lint]
|
||||
extend-ignore = ["F821"]
|
||||
exclude = [
|
||||
"__pycache__",
|
||||
"*.egg-info",
|
||||
]
|
||||
@@ -1,10 +1,13 @@
|
||||
"""Changelog generation from git history."""
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
from git_commit_generator.config import Config, get_config
|
||||
from git_commit_generator.git_utils import GitUtils, get_git_utils
|
||||
from git_commit_generator.ollama_client import OllamaClient, get_ollama_client
|
||||
from git_commit_generator.ollama_client import OllamaClient
|
||||
from git_commit_generator.git_utils import GitUtils
|
||||
|
||||
if TYPE_CHECKING:
|
||||
pass
|
||||
|
||||
|
||||
class ChangelogGenerator:
|
||||
@@ -28,6 +31,7 @@ class ChangelogGenerator:
|
||||
self,
|
||||
config: Optional[Config] = None,
|
||||
ollama_client: Optional[OllamaClient] = None,
|
||||
git_utils: Optional[GitUtils] = None,
|
||||
repo_path: Optional[str] = None,
|
||||
):
|
||||
"""Initialize changelog generator."""
|
||||
@@ -36,7 +40,7 @@ class ChangelogGenerator:
|
||||
host=self.config.ollama_host,
|
||||
model=self.config.ollama_model
|
||||
)
|
||||
self.git_utils = GitUtils(repo_path)
|
||||
self.git_utils = git_utils or GitUtils(repo_path)
|
||||
|
||||
def generate(
|
||||
self,
|
||||
@@ -160,11 +164,13 @@ Group by type (feat, fix, docs, etc.) and format properly."""
|
||||
def get_changelog_generator(
|
||||
config: Optional[Config] = None,
|
||||
ollama_client: Optional[OllamaClient] = None,
|
||||
git_utils: Optional[GitUtils] = None,
|
||||
repo_path: Optional[str] = None,
|
||||
) -> ChangelogGenerator:
|
||||
"""Get ChangelogGenerator instance."""
|
||||
return ChangelogGenerator(
|
||||
config=config,
|
||||
ollama_client=ollama_client,
|
||||
git_utils=git_utils,
|
||||
repo_path=repo_path,
|
||||
)
|
||||
|
||||
@@ -5,12 +5,12 @@ from typing import Optional
|
||||
import click
|
||||
from rich import print as rprint
|
||||
|
||||
from git_commit_generator.changelog_generator import ChangelogGenerator, get_changelog_generator
|
||||
from git_commit_generator.changelog_generator import get_changelog_generator
|
||||
from git_commit_generator.config import Config, get_config
|
||||
from git_commit_generator.git_utils import GitUtils, get_git_utils
|
||||
from git_commit_generator.git_utils import get_git_utils
|
||||
from git_commit_generator.interactive import Action, InteractiveMode, get_interactive_mode
|
||||
from git_commit_generator.message_generator import MessageGenerator, get_message_generator
|
||||
from git_commit_generator.ollama_client import OllamaClient, get_ollama_client
|
||||
from git_commit_generator.ollama_client import get_ollama_client
|
||||
|
||||
|
||||
@click.group()
|
||||
@@ -97,7 +97,7 @@ def generate(
|
||||
unstaged=not staged,
|
||||
model=model,
|
||||
)
|
||||
rprint(f"\n[bold green]Generated commit message:[/bold green]")
|
||||
rprint("\n[bold green]Generated commit message:[/bold green]")
|
||||
rprint(f"[cyan]{message}[/cyan]")
|
||||
|
||||
if output:
|
||||
@@ -146,7 +146,7 @@ def _run_interactive_mode(
|
||||
elif action == Action.ACCEPT:
|
||||
final_message = edited_message or message
|
||||
if interactive_mode.confirm_commit(final_message):
|
||||
rprint(f"\n[bold]Commit message:[/bold]")
|
||||
rprint("\n[bold]Commit message:[/bold]")
|
||||
rprint(f"[green]{final_message}[/green]")
|
||||
|
||||
if output:
|
||||
@@ -308,7 +308,7 @@ def status(ctx: click.Context) -> None:
|
||||
|
||||
rprint("[bold]Git Commit Message Generator Status[/bold]\n")
|
||||
|
||||
rprint(f"[bold]Configuration:[/bold]")
|
||||
rprint("[bold]Configuration:[/bold]")
|
||||
rprint(f" Ollama Host: {host}")
|
||||
rprint(f" Default Model: {model}")
|
||||
rprint(f" Prompt Directory: {config.prompt_dir}")
|
||||
@@ -320,7 +320,7 @@ def status(ctx: click.Context) -> None:
|
||||
|
||||
if connected:
|
||||
models = ollama_client.list_models()
|
||||
rprint(f"\n[bold]Available Models:[/bold]")
|
||||
rprint("\n[bold]Available Models:[/bold]")
|
||||
if models:
|
||||
for m in models[:5]:
|
||||
rprint(f" - {m.get('name', 'unknown')}")
|
||||
@@ -335,7 +335,7 @@ def status(ctx: click.Context) -> None:
|
||||
try:
|
||||
git_utils = get_git_utils()
|
||||
is_repo = git_utils.is_repo()
|
||||
rprint(f"\n[bold]Git Repository:[/bold]")
|
||||
rprint("\n[bold]Git Repository:[/bold]")
|
||||
rprint(f" Repository Detected: {'Yes' if is_repo else 'No'}")
|
||||
except Exception:
|
||||
rprint("\n[bold]Git Repository:[/bold]")
|
||||
|
||||
@@ -61,7 +61,7 @@ class InteractiveMode:
|
||||
|
||||
def confirm_commit(self, message: str) -> bool:
|
||||
"""Confirm the commit message before proceeding."""
|
||||
self.console.print(f"\n[bold]Final commit message:[/bold]")
|
||||
self.console.print("\n[bold]Final commit message:[/bold]")
|
||||
self.console.print(f"[green]{message}[/green]")
|
||||
|
||||
confirm = Prompt.ask(
|
||||
@@ -85,7 +85,7 @@ class InteractiveMode:
|
||||
) -> None:
|
||||
"""Show Ollama connection status."""
|
||||
if connected:
|
||||
status = f"[green]Connected to Ollama[/green]"
|
||||
status = "[green]Connected to Ollama[/green]"
|
||||
if model:
|
||||
status += f" (model: {model})"
|
||||
self.console.print(status)
|
||||
|
||||
208
app/src/git_commit_generator/message_generator.py
Normal file
208
app/src/git_commit_generator/message_generator.py
Normal file
@@ -0,0 +1,208 @@
|
||||
"""Message generator for commit messages."""
|
||||
from typing import Any
|
||||
|
||||
from git_commit_generator.config import Config, get_config
|
||||
from git_commit_generator.git_utils import GitUtils, get_git_utils
|
||||
from git_commit_generator.ollama_client import OllamaClient, get_ollama_client
|
||||
|
||||
|
||||
class MessageGenerator:
|
||||
"""Generates conventional commit messages using LLM."""
|
||||
|
||||
CHANGE_TYPE_INDICATORS = {
|
||||
"test": ["+def test_", "+class Test", "test_", "assert ", "pytest", "+ assert "],
|
||||
"feat": ["+def ", "+class ", "+async def ", "new feature", "add "],
|
||||
"fix": ["-def ", "bug", "fix", "resolve", "error", "issue"],
|
||||
"docs": ["#", "doc", "readme", "documentation"],
|
||||
"refactor": ["refactor", "rename", "reorganize"],
|
||||
"chore": ["chore", "dependency", "config"],
|
||||
}
|
||||
|
||||
def __init__(self, config: Config, ollama_client: OllamaClient, git_utils: GitUtils | None = None):
|
||||
"""Initialize message generator.
|
||||
|
||||
Args:
|
||||
config: Configuration object.
|
||||
ollama_client: Ollama client instance.
|
||||
git_utils: Git utils instance (optional).
|
||||
"""
|
||||
self.config = config
|
||||
self.ollama_client = ollama_client
|
||||
self.git_utils = git_utils or get_git_utils()
|
||||
|
||||
def detect_change_type(self, diff: str) -> str:
|
||||
"""Detect the type of change from diff.
|
||||
|
||||
Args:
|
||||
diff: Git diff content.
|
||||
|
||||
Returns:
|
||||
Change type (feat, fix, docs, test, refactor, chore).
|
||||
"""
|
||||
diff_lower = diff.lower()
|
||||
|
||||
for change_type, indicators in self.CHANGE_TYPE_INDICATORS.items():
|
||||
for indicator in indicators:
|
||||
if indicator.lower() in diff_lower:
|
||||
return change_type
|
||||
|
||||
return "feat"
|
||||
|
||||
def detect_scope(self, files: list[str]) -> str:
|
||||
"""Detect the scope from file paths.
|
||||
|
||||
Args:
|
||||
files: List of file paths.
|
||||
|
||||
Returns:
|
||||
Scope name (directory or 'core' for root files).
|
||||
"""
|
||||
if not files:
|
||||
return "core"
|
||||
|
||||
scopes: dict[str, int] = {}
|
||||
|
||||
for file_path in files:
|
||||
parts = file_path.split("/")
|
||||
if len(parts) > 1:
|
||||
scope = parts[0]
|
||||
scopes[scope] = scopes.get(scope, 0) + 1
|
||||
else:
|
||||
scopes["core"] = scopes.get("core", 0) + 1
|
||||
|
||||
if not scopes:
|
||||
return "core"
|
||||
|
||||
return max(scopes.items(), key=lambda x: x[1])[0]
|
||||
|
||||
def parse_conventional_message(self, message: str) -> dict[str, Any]:
|
||||
"""Parse a conventional commit message.
|
||||
|
||||
Args:
|
||||
message: Commit message to parse.
|
||||
|
||||
Returns:
|
||||
Dictionary with type, scope, description, and full_message.
|
||||
"""
|
||||
message = self._clean_message(message)
|
||||
|
||||
if ": " in message:
|
||||
parts = message.split(": ", 1)
|
||||
type_scope = parts[0]
|
||||
description = parts[1] if len(parts) > 1 else ""
|
||||
elif ": " not in message and " " in message:
|
||||
type_scope = message.split(" ")[0]
|
||||
description = message[len(type_scope) + 1:] if len(message) > len(type_scope) + 1 else ""
|
||||
else:
|
||||
return {
|
||||
"type": "feat",
|
||||
"scope": "",
|
||||
"description": message,
|
||||
"full_message": message,
|
||||
}
|
||||
|
||||
if "(" in type_scope and ")" in type_scope:
|
||||
scope_start = type_scope.find("(")
|
||||
scope_end = type_scope.find(")")
|
||||
change_type = type_scope[:scope_start]
|
||||
scope = type_scope[scope_start + 1:scope_end]
|
||||
else:
|
||||
change_type = type_scope
|
||||
scope = ""
|
||||
|
||||
return {
|
||||
"type": change_type,
|
||||
"scope": scope,
|
||||
"description": description,
|
||||
"full_message": message,
|
||||
}
|
||||
|
||||
def format_conventional_message(self, message_type: str, scope: str, description: str) -> str:
|
||||
"""Format a conventional commit message.
|
||||
|
||||
Args:
|
||||
message_type: Type of change (feat, fix, etc.).
|
||||
scope: Scope of the change.
|
||||
description: Description of the change.
|
||||
|
||||
Returns:
|
||||
Formatted commit message.
|
||||
"""
|
||||
if scope:
|
||||
return f"{message_type}({scope}): {description}"
|
||||
return f"{message_type}: {description}"
|
||||
|
||||
def _clean_message(self, message: str) -> str:
|
||||
"""Clean a commit message.
|
||||
|
||||
Args:
|
||||
message: Raw message.
|
||||
|
||||
Returns:
|
||||
Cleaned message.
|
||||
"""
|
||||
message = message.strip()
|
||||
if (message.startswith('"') and message.endswith('"')) or (
|
||||
message.startswith("'") and message.endswith("'")
|
||||
):
|
||||
message = message[1:-1]
|
||||
return message.strip()
|
||||
|
||||
def generate(self) -> str:
|
||||
"""Generate a commit message.
|
||||
|
||||
Returns:
|
||||
Generated commit message.
|
||||
|
||||
Raises:
|
||||
ValueError: If no changes are detected.
|
||||
"""
|
||||
diff = self.git_utils.get_all_changes()
|
||||
|
||||
if not diff:
|
||||
raise ValueError("No changes detected. Stage some changes first.")
|
||||
|
||||
change_type = self.detect_change_type(diff)
|
||||
files = self.git_utils.get_staged_files()
|
||||
scope = self.detect_scope(files) if files else "core"
|
||||
|
||||
prompt = self.config.read_prompt("commit_message.txt")
|
||||
|
||||
raw_message = self.ollama_client.generate_commit_message(diff, prompt)
|
||||
message = self._clean_message(raw_message)
|
||||
|
||||
parsed = self.parse_conventional_message(message)
|
||||
parsed["type"] = change_type
|
||||
parsed["scope"] = scope
|
||||
|
||||
formatted = self.format_conventional_message(
|
||||
message_type=parsed["type"],
|
||||
scope=parsed["scope"],
|
||||
description=parsed["description"],
|
||||
)
|
||||
|
||||
return formatted
|
||||
|
||||
|
||||
def get_message_generator(
|
||||
config: Config | None = None,
|
||||
ollama_client: OllamaClient | None = None,
|
||||
git_utils: GitUtils | None = None,
|
||||
) -> MessageGenerator:
|
||||
"""Get MessageGenerator instance.
|
||||
|
||||
Args:
|
||||
config: Configuration object (optional).
|
||||
ollama_client: Ollama client (optional).
|
||||
git_utils: Git utils (optional).
|
||||
|
||||
Returns:
|
||||
MessageGenerator instance.
|
||||
"""
|
||||
config = config or get_config()
|
||||
ollama_client = ollama_client or get_ollama_client()
|
||||
return MessageGenerator(
|
||||
config=config,
|
||||
ollama_client=ollama_client,
|
||||
git_utils=git_utils,
|
||||
)
|
||||
@@ -1,6 +1,5 @@
|
||||
"""Ollama client wrapper for LLM interactions."""
|
||||
"""Ollama client wrapper for LLM interactions."""
|
||||
import ollama as ollama_lib
|
||||
from ollama import ChatResponse, ListResponse
|
||||
|
||||
|
||||
class OllamaClient:
|
||||
@@ -24,7 +23,7 @@ class OllamaClient:
|
||||
True if connection successful, False otherwise.
|
||||
"""
|
||||
try:
|
||||
response: ListResponse = ollama_lib.list()
|
||||
ollama_lib.list()
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
@@ -39,9 +38,10 @@ class OllamaClient:
|
||||
True if model is available, False otherwise.
|
||||
"""
|
||||
try:
|
||||
response: ListResponse = ollama_lib.list()
|
||||
list_result = ollama_lib.list()
|
||||
models = list_result.get("models", [])
|
||||
return any(m["name"] == model or m["name"].startswith(model)
|
||||
for m in response.get("models", []))
|
||||
for m in models)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
@@ -58,7 +58,7 @@ class OllamaClient:
|
||||
Returns:
|
||||
Generated commit message.
|
||||
"""
|
||||
response: ChatResponse = ollama_lib.chat(
|
||||
response = ollama_lib.chat(
|
||||
model=model or self.model,
|
||||
messages=[
|
||||
{
|
||||
@@ -87,7 +87,7 @@ class OllamaClient:
|
||||
Returns:
|
||||
Generated changelog.
|
||||
"""
|
||||
response: ChatResponse = ollama_lib.chat(
|
||||
response = ollama_lib.chat(
|
||||
model=model or self.model,
|
||||
messages=[
|
||||
{
|
||||
@@ -110,8 +110,8 @@ class OllamaClient:
|
||||
List of available models with their details.
|
||||
"""
|
||||
try:
|
||||
response: ListResponse = ollama_lib.list()
|
||||
return response.get("models", [])
|
||||
list_result = ollama_lib.list()
|
||||
return list_result.get("models", [])
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
@@ -13,28 +13,24 @@ class TestChangelogGenerator:
|
||||
def changelog_generator(self):
|
||||
"""Create a ChangelogGenerator instance with mocked dependencies."""
|
||||
with patch("git_commit_generator.changelog_generator.get_config") as mock_config:
|
||||
with patch("git_commit_generator.changelog_generator.get_ollama_client") as mock_ollama:
|
||||
config = MagicMock()
|
||||
config.ollama_host = "http://localhost:11434"
|
||||
config.ollama_model = "llama3"
|
||||
config.read_prompt.return_value = "Generate a changelog."
|
||||
mock_config.return_value = config
|
||||
config = MagicMock()
|
||||
config.ollama_host = "http://localhost:11434"
|
||||
config.ollama_model = "llama3"
|
||||
config.read_prompt.return_value = "Generate a changelog."
|
||||
mock_config.return_value = config
|
||||
|
||||
ollama_client = MagicMock()
|
||||
ollama_client.generate_changelog.return_value = "# Changelog\n\n## Features\n- New feature"
|
||||
mock_ollama.return_value = ollama_client
|
||||
ollama_client = MagicMock()
|
||||
ollama_client.generate_changelog.return_value = "# Changelog\n\n## Features\n- New feature"
|
||||
|
||||
with patch("git_commit_generator.changelog_generator.get_git_utils") as mock_git:
|
||||
git_utils = MagicMock()
|
||||
git_utils.get_commit_history.return_value = []
|
||||
mock_git.return_value = git_utils
|
||||
git_utils = MagicMock()
|
||||
git_utils.get_commit_history.return_value = []
|
||||
|
||||
generator = ChangelogGenerator(
|
||||
config=config,
|
||||
ollama_client=ollama_client,
|
||||
)
|
||||
generator.git_utils = git_utils
|
||||
yield generator
|
||||
generator = ChangelogGenerator(
|
||||
config=config,
|
||||
ollama_client=ollama_client,
|
||||
git_utils=git_utils,
|
||||
)
|
||||
yield generator
|
||||
|
||||
def test_group_commits_by_type(self, changelog_generator):
|
||||
"""Test grouping commits by type."""
|
||||
@@ -52,10 +48,11 @@ class TestChangelogGenerator:
|
||||
assert len(result["fix"]) == 1
|
||||
|
||||
def test_format_simple_changelog(self, changelog_generator):
|
||||
"""Test formatting simple changelog."""
|
||||
"""Test simple changelog formatting."""
|
||||
grouped = {
|
||||
"feat": [
|
||||
{"type": "feat", "scope": "api", "description": "add endpoint"},
|
||||
{"type": "feat", "scope": "ui", "description": "add button"},
|
||||
],
|
||||
"fix": [
|
||||
{"type": "fix", "scope": "db", "description": "fix bug"},
|
||||
@@ -68,48 +65,50 @@ class TestChangelogGenerator:
|
||||
assert "## Features" in result
|
||||
assert "## Bug Fixes" in result
|
||||
assert "**feat(api):** add endpoint" in result
|
||||
assert "**feat(ui):** add button" in result
|
||||
assert "**fix(db):** fix bug" in result
|
||||
|
||||
def test_generate_simple_no_commits_raises_error(self, changelog_generator):
|
||||
"""Test that simple generation raises error with no commits."""
|
||||
"""Test that generate_simple raises error when no commits."""
|
||||
changelog_generator.git_utils.get_conventional_commits.return_value = []
|
||||
|
||||
with pytest.raises(ValueError, match="No conventional commits found"):
|
||||
with pytest.raises(ValueError, match="No conventional commits"):
|
||||
changelog_generator.generate_simple()
|
||||
|
||||
def test_format_commits_for_prompt(self, changelog_generator):
|
||||
"""Test formatting commits for LLM prompt."""
|
||||
"""Test commit formatting for LLM prompt."""
|
||||
commits = [
|
||||
{
|
||||
"hash": "abc1234",
|
||||
"message": "feat: add feature",
|
||||
"author": "Test User",
|
||||
"date": "2024-01-15T10:30:00",
|
||||
},
|
||||
{"hash": "abc123", "message": "feat: add feature", "author": "John", "date": "2024-01-01"},
|
||||
{"hash": "def456", "message": "fix: fix bug", "author": "Jane", "date": "2024-01-02"},
|
||||
]
|
||||
|
||||
result = changelog_generator._format_commits_for_prompt(commits)
|
||||
|
||||
assert "[abc1234] feat: add feature" in result
|
||||
assert "Test User" in result
|
||||
assert "[abc123] feat: add feature" in result
|
||||
assert "[def456] fix: fix bug" in result
|
||||
assert "John" in result
|
||||
assert "2024-01-01" in result
|
||||
|
||||
def test_clean_changelog(self, changelog_generator):
|
||||
"""Test cleaning changelog output."""
|
||||
raw_changelog = """```
|
||||
"""Test changelog cleaning."""
|
||||
raw = """
|
||||
# Changelog
|
||||
|
||||
## Features
|
||||
- Feature 1
|
||||
```"""
|
||||
- New feature
|
||||
|
||||
result = changelog_generator._clean_changelog(raw_changelog)
|
||||
```python
|
||||
print("hello")
|
||||
```
|
||||
"""
|
||||
result = changelog_generator._clean_changelog(raw)
|
||||
|
||||
assert "```" not in result
|
||||
assert "```python" not in result
|
||||
assert "# Changelog" in result
|
||||
assert "## Features" in result
|
||||
|
||||
def test_generate_no_commits_raises_error(self, changelog_generator):
|
||||
"""Test that generation raises error with no commits."""
|
||||
"""Test that generate raises error when no commits."""
|
||||
changelog_generator.git_utils.get_commit_history.return_value = []
|
||||
|
||||
with pytest.raises(ValueError, match="No commits found"):
|
||||
|
||||
Reference in New Issue
Block a user