commitcraft-cli 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- commitcraft_cli-0.1.0/.github/workflows/ci.yml +31 -0
- commitcraft_cli-0.1.0/.gitignore +14 -0
- commitcraft_cli-0.1.0/.superpowers/sdd/.gitignore +1 -0
- commitcraft_cli-0.1.0/.superpowers/sdd/progress.md +39 -0
- commitcraft_cli-0.1.0/.superpowers/sdd/review-1d0a3e5..59992b8.diff +141 -0
- commitcraft_cli-0.1.0/.superpowers/sdd/review-2ecf6ef..ba368ce.diff +211 -0
- commitcraft_cli-0.1.0/.superpowers/sdd/review-305ed0e..e0ac712.diff +8 -0
- commitcraft_cli-0.1.0/.superpowers/sdd/review-30a7e6e..f4f700d.diff +172 -0
- commitcraft_cli-0.1.0/.superpowers/sdd/review-4b825dc..4c64c74.diff +5338 -0
- commitcraft_cli-0.1.0/.superpowers/sdd/review-4b825dc..e0ac712.diff +148 -0
- commitcraft_cli-0.1.0/.superpowers/sdd/review-4c64c74..c18dbf7.diff +159 -0
- commitcraft_cli-0.1.0/.superpowers/sdd/review-59992b8..ec92295.diff +217 -0
- commitcraft_cli-0.1.0/.superpowers/sdd/review-5ebae75..791fe7b.diff +187 -0
- commitcraft_cli-0.1.0/.superpowers/sdd/review-791fe7b..02d0776.diff +184 -0
- commitcraft_cli-0.1.0/.superpowers/sdd/review-791fe7b..2ecf6ef.diff +185 -0
- commitcraft_cli-0.1.0/.superpowers/sdd/review-7c6089c..fa0a2f9.diff +76 -0
- commitcraft_cli-0.1.0/.superpowers/sdd/review-805556e..7c6089c.diff +69 -0
- commitcraft_cli-0.1.0/.superpowers/sdd/review-98fd4b8..4c64c74.diff +4134 -0
- commitcraft_cli-0.1.0/.superpowers/sdd/review-ba368ce..1d0a3e5.diff +74 -0
- commitcraft_cli-0.1.0/.superpowers/sdd/review-e0ac712..5ebae75.diff +301 -0
- commitcraft_cli-0.1.0/.superpowers/sdd/review-ec92295..6b23b58.diff +231 -0
- commitcraft_cli-0.1.0/.superpowers/sdd/review-f4f700d..1d8ef6a.diff +292 -0
- commitcraft_cli-0.1.0/.superpowers/sdd/review-f4f700d..805556e.diff +343 -0
- commitcraft_cli-0.1.0/.superpowers/sdd/review-fa0a2f9..98fd4b8.diff +107 -0
- commitcraft_cli-0.1.0/.superpowers/sdd/task-1-brief.md +153 -0
- commitcraft_cli-0.1.0/.superpowers/sdd/task-1-report.md +91 -0
- commitcraft_cli-0.1.0/.superpowers/sdd/task-10-brief.md +170 -0
- commitcraft_cli-0.1.0/.superpowers/sdd/task-10-report.md +68 -0
- commitcraft_cli-0.1.0/.superpowers/sdd/task-11-brief.md +270 -0
- commitcraft_cli-0.1.0/.superpowers/sdd/task-11-report.md +58 -0
- commitcraft_cli-0.1.0/.superpowers/sdd/task-12-brief.md +57 -0
- commitcraft_cli-0.1.0/.superpowers/sdd/task-12-report.md +33 -0
- commitcraft_cli-0.1.0/.superpowers/sdd/task-13-brief.md +66 -0
- commitcraft_cli-0.1.0/.superpowers/sdd/task-14-brief.md +88 -0
- commitcraft_cli-0.1.0/.superpowers/sdd/task-14-report.md +46 -0
- commitcraft_cli-0.1.0/.superpowers/sdd/task-15-brief.md +378 -0
- commitcraft_cli-0.1.0/.superpowers/sdd/task-15-report.md +41 -0
- commitcraft_cli-0.1.0/.superpowers/sdd/task-2-brief.md +314 -0
- commitcraft_cli-0.1.0/.superpowers/sdd/task-2-report.md +82 -0
- commitcraft_cli-0.1.0/.superpowers/sdd/task-3-brief.md +203 -0
- commitcraft_cli-0.1.0/.superpowers/sdd/task-3-report.md +75 -0
- commitcraft_cli-0.1.0/.superpowers/sdd/task-4-brief.md +211 -0
- commitcraft_cli-0.1.0/.superpowers/sdd/task-4-report.md +72 -0
- commitcraft_cli-0.1.0/.superpowers/sdd/task-5-brief.md +246 -0
- commitcraft_cli-0.1.0/.superpowers/sdd/task-5-report.md +88 -0
- commitcraft_cli-0.1.0/.superpowers/sdd/task-6-brief.md +96 -0
- commitcraft_cli-0.1.0/.superpowers/sdd/task-6-report.md +66 -0
- commitcraft_cli-0.1.0/.superpowers/sdd/task-7-brief.md +165 -0
- commitcraft_cli-0.1.0/.superpowers/sdd/task-7-report.md +62 -0
- commitcraft_cli-0.1.0/.superpowers/sdd/task-8-brief.md +208 -0
- commitcraft_cli-0.1.0/.superpowers/sdd/task-8-report.md +116 -0
- commitcraft_cli-0.1.0/.superpowers/sdd/task-9-brief.md +220 -0
- commitcraft_cli-0.1.0/.superpowers/sdd/task-9-report.md +23 -0
- commitcraft_cli-0.1.0/CONTRIBUTING.md +40 -0
- commitcraft_cli-0.1.0/LICENSE +21 -0
- commitcraft_cli-0.1.0/PKG-INFO +113 -0
- commitcraft_cli-0.1.0/README.md +92 -0
- commitcraft_cli-0.1.0/commitcraft/__init__.py +1 -0
- commitcraft_cli-0.1.0/commitcraft/analysis/__init__.py +0 -0
- commitcraft_cli-0.1.0/commitcraft/analysis/classifier.py +57 -0
- commitcraft_cli-0.1.0/commitcraft/analysis/filters.py +67 -0
- commitcraft_cli-0.1.0/commitcraft/analysis/rule_engine.py +101 -0
- commitcraft_cli-0.1.0/commitcraft/cli.py +172 -0
- commitcraft_cli-0.1.0/commitcraft/config/__init__.py +0 -0
- commitcraft_cli-0.1.0/commitcraft/config/models.py +25 -0
- commitcraft_cli-0.1.0/commitcraft/config/store.py +48 -0
- commitcraft_cli-0.1.0/commitcraft/config/wizard.py +44 -0
- commitcraft_cli-0.1.0/commitcraft/context/__init__.py +0 -0
- commitcraft_cli-0.1.0/commitcraft/context/builder.py +59 -0
- commitcraft_cli-0.1.0/commitcraft/generators/__init__.py +0 -0
- commitcraft_cli-0.1.0/commitcraft/generators/commit.py +33 -0
- commitcraft_cli-0.1.0/commitcraft/generators/pr.py +12 -0
- commitcraft_cli-0.1.0/commitcraft/generators/release_notes.py +26 -0
- commitcraft_cli-0.1.0/commitcraft/git/__init__.py +0 -0
- commitcraft_cli-0.1.0/commitcraft/git/diff_parser.py +132 -0
- commitcraft_cli-0.1.0/commitcraft/git/history.py +73 -0
- commitcraft_cli-0.1.0/commitcraft/providers/__init__.py +0 -0
- commitcraft_cli-0.1.0/commitcraft/providers/anthropic_provider.py +44 -0
- commitcraft_cli-0.1.0/commitcraft/providers/base.py +19 -0
- commitcraft_cli-0.1.0/commitcraft/providers/gemini_provider.py +40 -0
- commitcraft_cli-0.1.0/commitcraft/providers/ollama_provider.py +61 -0
- commitcraft_cli-0.1.0/commitcraft/providers/openai_provider.py +43 -0
- commitcraft_cli-0.1.0/commitcraft/utils/__init__.py +0 -0
- commitcraft_cli-0.1.0/commitcraft/utils/token_estimator.py +3 -0
- commitcraft_cli-0.1.0/docs/adding_a_provider.md +83 -0
- commitcraft_cli-0.1.0/docs/superpowers/plans/2026-06-30-commitcraft.md +2934 -0
- commitcraft_cli-0.1.0/pyproject.toml +44 -0
- commitcraft_cli-0.1.0/tests/__init__.py +0 -0
- commitcraft_cli-0.1.0/tests/fixtures/complex_multi_file.diff +29 -0
- commitcraft_cli-0.1.0/tests/fixtures/lockfile_only.diff +12 -0
- commitcraft_cli-0.1.0/tests/fixtures/simple_readme.diff +10 -0
- commitcraft_cli-0.1.0/tests/test_classifier.py +104 -0
- commitcraft_cli-0.1.0/tests/test_commit_generator.py +87 -0
- commitcraft_cli-0.1.0/tests/test_config_store.py +56 -0
- commitcraft_cli-0.1.0/tests/test_context_builder.py +60 -0
- commitcraft_cli-0.1.0/tests/test_diff_parser.py +63 -0
- commitcraft_cli-0.1.0/tests/test_filters.py +86 -0
- commitcraft_cli-0.1.0/tests/test_generators.py +81 -0
- commitcraft_cli-0.1.0/tests/test_history.py +103 -0
- commitcraft_cli-0.1.0/tests/test_providers.py +107 -0
- commitcraft_cli-0.1.0/tests/test_providers_extra.py +149 -0
- commitcraft_cli-0.1.0/tests/test_rule_engine.py +98 -0
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
branches: [main]
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
test:
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
strategy:
|
|
13
|
+
matrix:
|
|
14
|
+
python-version: ["3.11", "3.12"]
|
|
15
|
+
|
|
16
|
+
steps:
|
|
17
|
+
- uses: actions/checkout@v4
|
|
18
|
+
|
|
19
|
+
- name: Set up Python ${{ matrix.python-version }}
|
|
20
|
+
uses: actions/setup-python@v5
|
|
21
|
+
with:
|
|
22
|
+
python-version: ${{ matrix.python-version }}
|
|
23
|
+
|
|
24
|
+
- name: Install dependencies
|
|
25
|
+
run: pip install -e ".[dev]"
|
|
26
|
+
|
|
27
|
+
- name: Lint
|
|
28
|
+
run: ruff check commitcraft/ tests/
|
|
29
|
+
|
|
30
|
+
- name: Test
|
|
31
|
+
run: pytest tests/ -v --cov=commitcraft --cov-report=term-missing
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
*
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# commitcraft — SDD Progress Ledger
|
|
2
|
+
|
|
3
|
+
Plan: docs/superpowers/plans/2026-06-30-commitcraft.md
|
|
4
|
+
|
|
5
|
+
## Tasks
|
|
6
|
+
|
|
7
|
+
- [x] Task 1: Project Scaffolding
|
|
8
|
+
- [x] Task 2: Git Diff Parser
|
|
9
|
+
- [x] Task 3: File Filters
|
|
10
|
+
- [x] Task 4: Complexity Classifier
|
|
11
|
+
- [x] Task 5: Rule Engine
|
|
12
|
+
- [x] Task 6: Provider ABC + Config Models
|
|
13
|
+
- [x] Task 7: Ollama Provider
|
|
14
|
+
- [x] Task 8: Anthropic, OpenAI, Gemini Providers
|
|
15
|
+
- [x] Task 9: Config Store + Provider Factory + Setup Wizard
|
|
16
|
+
- [x] Task 10: Context Builder
|
|
17
|
+
- [x] Task 11: Commit Generator + Full `commitcraft commit` Command
|
|
18
|
+
- [x] Task 12: PR Description Generator
|
|
19
|
+
- [x] Task 13: Release Notes Generator
|
|
20
|
+
- [x] Task 14: History Analyzer
|
|
21
|
+
- [x] Task 15: README, CONTRIBUTING, Provider Docs, CI
|
|
22
|
+
|
|
23
|
+
## Completed Tasks
|
|
24
|
+
|
|
25
|
+
Task 1: complete (commits 4b825dc..e0ac712, review clean)
|
|
26
|
+
Task 2: complete (commits e0ac712..5ebae75, review clean; minor: inline regex comments, import inside fn body)
|
|
27
|
+
Task 3: complete (commits 5ebae75..791fe7b, review clean)
|
|
28
|
+
Task 4: complete (commits 791fe7b..2ecf6ef, fix round: added type annotations to helpers)
|
|
29
|
+
Task 5: complete (commits 2ecf6ef..ba368ce, review clean; Phase 1 done: 39/39 tests)
|
|
30
|
+
Task 6: complete (commits ba368ce..1d0a3e5, review clean)
|
|
31
|
+
Task 7: complete (commits 1d0a3e5..59992b8, review clean)
|
|
32
|
+
Task 8: complete (commits 59992b8..ec92295, review clean)
|
|
33
|
+
Task 9: complete (commits ec92295..30a7e6e, fix: removed unused Confirm import; 54/54 tests)
|
|
34
|
+
Task 10: complete (commits 30a7e6e..f4f700d, review clean)
|
|
35
|
+
Task 11: complete (commits f4f700d..805556e, fix round: removed dead classify block + pycache; 65/65)
|
|
36
|
+
Task 12: complete (commits 805556e..7c6089c, review clean)
|
|
37
|
+
Task 13: complete (commits 7c6089c..fa0a2f9, review clean)
|
|
38
|
+
Task 14: complete (commits fa0a2f9..98fd4b8, review clean)
|
|
39
|
+
Task 15: complete (commits 98fd4b8..4c64c74, ruff fixed 55 pre-existing issues; review clean)
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# Review package: 1d0a3e5..HEAD
|
|
2
|
+
|
|
3
|
+
## Commits
|
|
4
|
+
59992b8 feat: implement Ollama provider with commit/PR/release-notes generation
|
|
5
|
+
|
|
6
|
+
## Files changed
|
|
7
|
+
commitcraft/providers/ollama_provider.py | 60 ++++++++++++++++++++++++++++++++
|
|
8
|
+
tests/test_providers.py | 58 ++++++++++++++++++++++++++++++
|
|
9
|
+
2 files changed, 118 insertions(+)
|
|
10
|
+
|
|
11
|
+
## Diff
|
|
12
|
+
diff --git a/commitcraft/providers/ollama_provider.py b/commitcraft/providers/ollama_provider.py
|
|
13
|
+
new file mode 100644
|
|
14
|
+
index 0000000..3e69a91
|
|
15
|
+
--- /dev/null
|
|
16
|
+
+++ b/commitcraft/providers/ollama_provider.py
|
|
17
|
+
@@ -0,0 +1,60 @@
|
|
18
|
+
+import httpx
|
|
19
|
+
+from commitcraft.config.models import CommitcraftConfig
|
|
20
|
+
+from commitcraft.providers.base import Provider
|
|
21
|
+
+
|
|
22
|
+
+_COMMIT_SYSTEM = (
|
|
23
|
+
+ "You are an expert at writing conventional commit messages. "
|
|
24
|
+
+ "Given a summary of code changes, write a single conventional commit message. "
|
|
25
|
+
+ "Format: <type>(<optional scope>): <description>. "
|
|
26
|
+
+ "Types: feat, fix, docs, style, refactor, test, chore, perf, ci, build. "
|
|
27
|
+
+ "Output ONLY the commit message — no explanation, no markdown, no quotes."
|
|
28
|
+
+)
|
|
29
|
+
+
|
|
30
|
+
+_PR_SYSTEM = (
|
|
31
|
+
+ "You are an expert at writing GitHub pull request descriptions. "
|
|
32
|
+
+ "Given a summary of code changes, write a clear PR description in markdown with: "
|
|
33
|
+
+ "a '## Summary' section (3-5 bullet points) and a '## Changes' section. "
|
|
34
|
+
+ "Be concise and factual."
|
|
35
|
+
+)
|
|
36
|
+
+
|
|
37
|
+
+_RELEASE_SYSTEM = (
|
|
38
|
+
+ "You are an expert at writing software release notes. "
|
|
39
|
+
+ "Given a list of commits or change summaries, produce structured release notes "
|
|
40
|
+
+ "grouped by: Features, Bug Fixes, Documentation, Other. Use markdown."
|
|
41
|
+
+)
|
|
42
|
+
+
|
|
43
|
+
+
|
|
44
|
+
+class OllamaProvider(Provider):
|
|
45
|
+
+ def __init__(self, config: CommitcraftConfig) -> None:
|
|
46
|
+
+ self._config = config
|
|
47
|
+
+ self._base_url = config.ollama_base_url
|
|
48
|
+
+ self._model = config.ollama_model
|
|
49
|
+
+
|
|
50
|
+
+ @property
|
|
51
|
+
+ def name(self) -> str:
|
|
52
|
+
+ return "ollama"
|
|
53
|
+
+
|
|
54
|
+
+ def _generate(self, system: str, prompt: str) -> str:
|
|
55
|
+
+ response = httpx.post(
|
|
56
|
+
+ f"{self._base_url}/api/generate",
|
|
57
|
+
+ json={"model": self._model, "system": system, "prompt": prompt, "stream": False},
|
|
58
|
+
+ timeout=60.0,
|
|
59
|
+
+ )
|
|
60
|
+
+ response.raise_for_status()
|
|
61
|
+
+ return response.json()["response"].strip()
|
|
62
|
+
+
|
|
63
|
+
+ def generate_commit_message(self, context: str) -> str:
|
|
64
|
+
+ return self._generate(_COMMIT_SYSTEM, context)
|
|
65
|
+
+
|
|
66
|
+
+ def generate_pr_description(self, context: str) -> str:
|
|
67
|
+
+ return self._generate(_PR_SYSTEM, context)
|
|
68
|
+
+
|
|
69
|
+
+ def generate_release_notes(self, context: str) -> str:
|
|
70
|
+
+ return self._generate(_RELEASE_SYSTEM, context)
|
|
71
|
+
+
|
|
72
|
+
+ def health_check(self) -> bool:
|
|
73
|
+
+ try:
|
|
74
|
+
+ httpx.get(f"{self._base_url}/api/tags", timeout=5.0).raise_for_status()
|
|
75
|
+
+ return True
|
|
76
|
+
+ except Exception:
|
|
77
|
+
+ return False
|
|
78
|
+
diff --git a/tests/test_providers.py b/tests/test_providers.py
|
|
79
|
+
new file mode 100644
|
|
80
|
+
index 0000000..05045e6
|
|
81
|
+
--- /dev/null
|
|
82
|
+
+++ b/tests/test_providers.py
|
|
83
|
+
@@ -0,0 +1,58 @@
|
|
84
|
+
+import pytest
|
|
85
|
+
+from unittest.mock import patch, MagicMock
|
|
86
|
+
+from commitcraft.config.models import CommitcraftConfig, ProviderName
|
|
87
|
+
+from commitcraft.providers.ollama_provider import OllamaProvider
|
|
88
|
+
+
|
|
89
|
+
+
|
|
90
|
+
+@pytest.fixture
|
|
91
|
+
+def config():
|
|
92
|
+
+ return CommitcraftConfig(
|
|
93
|
+
+ provider=ProviderName.OLLAMA,
|
|
94
|
+
+ ollama_model="llama3.2",
|
|
95
|
+
+ ollama_base_url="http://localhost:11434",
|
|
96
|
+
+ )
|
|
97
|
+
+
|
|
98
|
+
+
|
|
99
|
+
+def test_ollama_provider_name(config):
|
|
100
|
+
+ p = OllamaProvider(config)
|
|
101
|
+
+ assert p.name == "ollama"
|
|
102
|
+
+
|
|
103
|
+
+
|
|
104
|
+
+def test_ollama_generate_commit_message(config):
|
|
105
|
+
+ mock_response = MagicMock()
|
|
106
|
+
+ mock_response.json.return_value = {"response": "feat: add user authentication"}
|
|
107
|
+
+ mock_response.raise_for_status = MagicMock()
|
|
108
|
+
+
|
|
109
|
+
+ with patch("httpx.post", return_value=mock_response) as mock_post:
|
|
110
|
+
+ p = OllamaProvider(config)
|
|
111
|
+
+ result = p.generate_commit_message("context: added login function")
|
|
112
|
+
+ assert result == "feat: add user authentication"
|
|
113
|
+
+ mock_post.assert_called_once()
|
|
114
|
+
+ call_args = mock_post.call_args
|
|
115
|
+
+ assert "llama3.2" in str(call_args)
|
|
116
|
+
+
|
|
117
|
+
+
|
|
118
|
+
+def test_ollama_generate_pr_description(config):
|
|
119
|
+
+ mock_response = MagicMock()
|
|
120
|
+
+ mock_response.json.return_value = {"response": "## Summary\n- Added auth"}
|
|
121
|
+
+ mock_response.raise_for_status = MagicMock()
|
|
122
|
+
+
|
|
123
|
+
+ with patch("httpx.post", return_value=mock_response):
|
|
124
|
+
+ p = OllamaProvider(config)
|
|
125
|
+
+ result = p.generate_pr_description("branch diff context")
|
|
126
|
+
+ assert "Summary" in result
|
|
127
|
+
+
|
|
128
|
+
+
|
|
129
|
+
+def test_ollama_health_check_success(config):
|
|
130
|
+
+ mock_response = MagicMock()
|
|
131
|
+
+ mock_response.raise_for_status = MagicMock()
|
|
132
|
+
+
|
|
133
|
+
+ with patch("httpx.get", return_value=mock_response):
|
|
134
|
+
+ p = OllamaProvider(config)
|
|
135
|
+
+ assert p.health_check() is True
|
|
136
|
+
+
|
|
137
|
+
+
|
|
138
|
+
+def test_ollama_health_check_failure(config):
|
|
139
|
+
+ with patch("httpx.get", side_effect=Exception("Connection refused")):
|
|
140
|
+
+ p = OllamaProvider(config)
|
|
141
|
+
+ assert p.health_check() is False
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
# Review package: 2ecf6ef..HEAD
|
|
2
|
+
|
|
3
|
+
## Commits
|
|
4
|
+
ba368ce feat: implement rule engine for zero-AI commit message generation
|
|
5
|
+
|
|
6
|
+
## Files changed
|
|
7
|
+
commitcraft/analysis/rule_engine.py | 91 ++++++++++++++++++++++++++++++++++
|
|
8
|
+
tests/test_rule_engine.py | 97 +++++++++++++++++++++++++++++++++++++
|
|
9
|
+
2 files changed, 188 insertions(+)
|
|
10
|
+
|
|
11
|
+
## Diff
|
|
12
|
+
diff --git a/commitcraft/analysis/rule_engine.py b/commitcraft/analysis/rule_engine.py
|
|
13
|
+
new file mode 100644
|
|
14
|
+
index 0000000..a521dfc
|
|
15
|
+
--- /dev/null
|
|
16
|
+
+++ b/commitcraft/analysis/rule_engine.py
|
|
17
|
+
@@ -0,0 +1,91 @@
|
|
18
|
+
+from dataclasses import dataclass
|
|
19
|
+
+from pathlib import Path
|
|
20
|
+
+from commitcraft.analysis.filters import FilteredDiff
|
|
21
|
+
+
|
|
22
|
+
+LOCKFILE_NAMES = {
|
|
23
|
+
+ "package-lock.json", "yarn.lock", "poetry.lock",
|
|
24
|
+
+ "Pipfile.lock", "composer.lock", "Gemfile.lock", "pnpm-lock.yaml",
|
|
25
|
+
+}
|
|
26
|
+
+
|
|
27
|
+
+DEPENDENCY_MANIFESTS = {
|
|
28
|
+
+ "requirements.txt", "requirements-dev.txt", "setup.cfg",
|
|
29
|
+
+ "package.json", "Pipfile", "pyproject.toml",
|
|
30
|
+
+}
|
|
31
|
+
+
|
|
32
|
+
+CONFIG_PATTERNS = {".yml", ".yaml", ".json", ".toml", ".ini", ".cfg", ".env"}
|
|
33
|
+
+CONFIG_DIRS = {".github", ".circleci", ".gitlab", "config", ".husky"}
|
|
34
|
+
+
|
|
35
|
+
+
|
|
36
|
+
+@dataclass
|
|
37
|
+
+class RuleResult:
|
|
38
|
+
+ matched: bool
|
|
39
|
+
+ message: str | None
|
|
40
|
+
+ rule_name: str | None
|
|
41
|
+
+
|
|
42
|
+
+
|
|
43
|
+
+def _all_paths(filtered: FilteredDiff) -> list[str]:
|
|
44
|
+
+ return [f.path for f in filtered.files]
|
|
45
|
+
+
|
|
46
|
+
+
|
|
47
|
+
+def _readme_only(paths: list[str]) -> RuleResult | None:
|
|
48
|
+
+ if len(paths) == 1 and Path(paths[0]).name.upper().startswith("README"):
|
|
49
|
+
+ return RuleResult(matched=True, message="docs: update README", rule_name="readme_only")
|
|
50
|
+
+ return None
|
|
51
|
+
+
|
|
52
|
+
+
|
|
53
|
+
+def _lockfile_only(paths: list[str]) -> RuleResult | None:
|
|
54
|
+
+ if all(Path(p).name in LOCKFILE_NAMES for p in paths):
|
|
55
|
+
+ return RuleResult(matched=True, message="chore: update lockfile", rule_name="lockfile_only")
|
|
56
|
+
+ return None
|
|
57
|
+
+
|
|
58
|
+
+
|
|
59
|
+
+def _dependency_bump(paths: list[str]) -> RuleResult | None:
|
|
60
|
+
+ if all(Path(p).name in DEPENDENCY_MANIFESTS for p in paths):
|
|
61
|
+
+ return RuleResult(matched=True, message="chore: bump dependencies", rule_name="dependency_bump")
|
|
62
|
+
+ return None
|
|
63
|
+
+
|
|
64
|
+
+
|
|
65
|
+
+def _docs_only(paths: list[str]) -> RuleResult | None:
|
|
66
|
+
+ doc_exts = {".md", ".rst", ".txt"}
|
|
67
|
+
+ doc_dirs = {"docs", "doc", "documentation"}
|
|
68
|
+
+ if all(
|
|
69
|
+
+ Path(p).suffix.lower() in doc_exts or Path(p).parts[0].lower() in doc_dirs
|
|
70
|
+
+ for p in paths
|
|
71
|
+
+ ):
|
|
72
|
+
+ return RuleResult(matched=True, message="docs: update documentation", rule_name="docs_only")
|
|
73
|
+
+ return None
|
|
74
|
+
+
|
|
75
|
+
+
|
|
76
|
+
+def _single_test_file(paths: list[str]) -> RuleResult | None:
|
|
77
|
+
+ if len(paths) == 1 and "test" in paths[0].lower():
|
|
78
|
+
+ name = Path(paths[0]).stem
|
|
79
|
+
+ return RuleResult(matched=True, message=f"test: add tests for {name}", rule_name="single_test_file")
|
|
80
|
+
+ return None
|
|
81
|
+
+
|
|
82
|
+
+
|
|
83
|
+
+def _config_only(paths: list[str]) -> RuleResult | None:
|
|
84
|
+
+ def is_config(p: str) -> bool:
|
|
85
|
+
+ parts = Path(p).parts
|
|
86
|
+
+ return (
|
|
87
|
+
+ Path(p).suffix.lower() in CONFIG_PATTERNS
|
|
88
|
+
+ and (parts[0] in CONFIG_DIRS or not any("src" in part or "lib" in part for part in parts))
|
|
89
|
+
+ )
|
|
90
|
+
+ if paths and all(is_config(p) for p in paths):
|
|
91
|
+
+ return RuleResult(matched=True, message="chore: update config", rule_name="config_only")
|
|
92
|
+
+ return None
|
|
93
|
+
+
|
|
94
|
+
+
|
|
95
|
+
+_RULES = [_readme_only, _lockfile_only, _dependency_bump, _docs_only, _single_test_file, _config_only]
|
|
96
|
+
+
|
|
97
|
+
+
|
|
98
|
+
+def apply_rules(filtered: FilteredDiff) -> RuleResult:
|
|
99
|
+
+ if not filtered.files:
|
|
100
|
+
+ return RuleResult(matched=False, message=None, rule_name=None)
|
|
101
|
+
+
|
|
102
|
+
+ paths = _all_paths(filtered)
|
|
103
|
+
+ for rule in _RULES:
|
|
104
|
+
+ result = rule(paths)
|
|
105
|
+
+ if result is not None:
|
|
106
|
+
+ return result
|
|
107
|
+
+
|
|
108
|
+
+ return RuleResult(matched=False, message=None, rule_name=None)
|
|
109
|
+
diff --git a/tests/test_rule_engine.py b/tests/test_rule_engine.py
|
|
110
|
+
new file mode 100644
|
|
111
|
+
index 0000000..f8ec227
|
|
112
|
+
--- /dev/null
|
|
113
|
+
+++ b/tests/test_rule_engine.py
|
|
114
|
+
@@ -0,0 +1,97 @@
|
|
115
|
+
+import pytest
|
|
116
|
+
+from commitcraft.git.diff_parser import DiffFile
|
|
117
|
+
+from commitcraft.analysis.filters import FilteredDiff
|
|
118
|
+
+from commitcraft.analysis.rule_engine import apply_rules, RuleResult
|
|
119
|
+
+
|
|
120
|
+
+
|
|
121
|
+
+def _file(path: str, added: int = 3, removed: int = 1) -> DiffFile:
|
|
122
|
+
+ return DiffFile(
|
|
123
|
+
+ path=path, file_type="text", is_new=False, is_deleted=False,
|
|
124
|
+
+ added_lines=added, removed_lines=removed, raw_hunks="",
|
|
125
|
+
+ has_function_changes=False, has_class_changes=False,
|
|
126
|
+
+ )
|
|
127
|
+
+
|
|
128
|
+
+
|
|
129
|
+
+def _diff(*files: DiffFile) -> FilteredDiff:
|
|
130
|
+
+ return FilteredDiff(
|
|
131
|
+
+ files=list(files),
|
|
132
|
+
+ total_added=sum(f.added_lines for f in files),
|
|
133
|
+
+ total_removed=sum(f.removed_lines for f in files),
|
|
134
|
+
+ )
|
|
135
|
+
+
|
|
136
|
+
+
|
|
137
|
+
+def test_readme_only_matches():
|
|
138
|
+
+ result = apply_rules(_diff(_file("README.md")))
|
|
139
|
+
+ assert result.matched is True
|
|
140
|
+
+ assert result.message == "docs: update README"
|
|
141
|
+
+ assert result.rule_name == "readme_only"
|
|
142
|
+
+
|
|
143
|
+
+
|
|
144
|
+
+def test_lockfile_package_lock_matches():
|
|
145
|
+
+ result = apply_rules(_diff(_file("package-lock.json")))
|
|
146
|
+
+ assert result.matched is True
|
|
147
|
+
+ assert "chore" in result.message
|
|
148
|
+
+ assert result.rule_name == "lockfile_only"
|
|
149
|
+
+
|
|
150
|
+
+
|
|
151
|
+
+def test_lockfile_poetry_lock_matches():
|
|
152
|
+
+ result = apply_rules(_diff(_file("poetry.lock")))
|
|
153
|
+
+ assert result.matched is True
|
|
154
|
+
+ assert result.rule_name == "lockfile_only"
|
|
155
|
+
+
|
|
156
|
+
+
|
|
157
|
+
+def test_lockfile_yarn_lock_matches():
|
|
158
|
+
+ result = apply_rules(_diff(_file("yarn.lock")))
|
|
159
|
+
+ assert result.matched is True
|
|
160
|
+
+ assert result.rule_name == "lockfile_only"
|
|
161
|
+
+
|
|
162
|
+
+
|
|
163
|
+
+def test_requirements_txt_matches_dependency_bump():
|
|
164
|
+
+ result = apply_rules(_diff(_file("requirements.txt")))
|
|
165
|
+
+ assert result.matched is True
|
|
166
|
+
+ assert result.message == "chore: bump dependencies"
|
|
167
|
+
+ assert result.rule_name == "dependency_bump"
|
|
168
|
+
+
|
|
169
|
+
+
|
|
170
|
+
+def test_package_json_without_lock_matches_dependency_bump():
|
|
171
|
+
+ result = apply_rules(_diff(_file("package.json")))
|
|
172
|
+
+ assert result.matched is True
|
|
173
|
+
+ assert result.rule_name == "dependency_bump"
|
|
174
|
+
+
|
|
175
|
+
+
|
|
176
|
+
+def test_docs_directory_only():
|
|
177
|
+
+ result = apply_rules(_diff(_file("docs/guide.md"), _file("docs/api.md")))
|
|
178
|
+
+ assert result.matched is True
|
|
179
|
+
+ assert result.message == "docs: update documentation"
|
|
180
|
+
+ assert result.rule_name == "docs_only"
|
|
181
|
+
+
|
|
182
|
+
+
|
|
183
|
+
+def test_single_test_file_only():
|
|
184
|
+
+ f = _file("tests/test_utils.py")
|
|
185
|
+
+ result = apply_rules(_diff(f))
|
|
186
|
+
+ assert result.matched is True
|
|
187
|
+
+ assert "test" in result.message.lower()
|
|
188
|
+
+ assert result.rule_name == "single_test_file"
|
|
189
|
+
+
|
|
190
|
+
+
|
|
191
|
+
+def test_config_file_only():
|
|
192
|
+
+ result = apply_rules(_diff(_file(".github/workflows/ci.yml")))
|
|
193
|
+
+ assert result.matched is True
|
|
194
|
+
+ assert result.rule_name == "config_only"
|
|
195
|
+
+
|
|
196
|
+
+
|
|
197
|
+
+def test_complex_change_no_match():
|
|
198
|
+
+ result = apply_rules(_diff(_file("src/auth.py"), _file("src/db.py"), _file("tests/test_auth.py")))
|
|
199
|
+
+ assert result.matched is False
|
|
200
|
+
+ assert result.message is None
|
|
201
|
+
+ assert result.rule_name is None
|
|
202
|
+
+
|
|
203
|
+
+
|
|
204
|
+
+def test_mixed_readme_and_source_no_match():
|
|
205
|
+
+ result = apply_rules(_diff(_file("README.md"), _file("src/main.py")))
|
|
206
|
+
+ assert result.matched is False
|
|
207
|
+
+
|
|
208
|
+
+
|
|
209
|
+
+def test_result_is_rulesresult_type():
|
|
210
|
+
+ result = apply_rules(_diff(_file("README.md")))
|
|
211
|
+
+ assert isinstance(result, RuleResult)
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
# Review package: 30a7e6e..HEAD
|
|
2
|
+
|
|
3
|
+
## Commits
|
|
4
|
+
f4f700d feat: context builder produces condensed LLM prompts from filtered diff
|
|
5
|
+
|
|
6
|
+
## Files changed
|
|
7
|
+
commitcraft/context/__init__.py | 0
|
|
8
|
+
.../context/__pycache__/__init__.cpython-312.pyc | Bin 0 -> 152 bytes
|
|
9
|
+
.../context/__pycache__/builder.cpython-312.pyc | Bin 0 -> 3321 bytes
|
|
10
|
+
commitcraft/context/builder.py | 52 ++++++++++++++++++
|
|
11
|
+
commitcraft/utils/__init__.py | 0
|
|
12
|
+
.../utils/__pycache__/__init__.cpython-312.pyc | Bin 0 -> 150 bytes
|
|
13
|
+
.../__pycache__/token_estimator.cpython-312.pyc | Bin 0 -> 396 bytes
|
|
14
|
+
commitcraft/utils/token_estimator.py | 3 ++
|
|
15
|
+
tests/test_context_builder.py | 59 +++++++++++++++++++++
|
|
16
|
+
9 files changed, 114 insertions(+)
|
|
17
|
+
|
|
18
|
+
## Diff
|
|
19
|
+
diff --git a/commitcraft/context/__init__.py b/commitcraft/context/__init__.py
|
|
20
|
+
new file mode 100644
|
|
21
|
+
index 0000000..e69de29
|
|
22
|
+
diff --git a/commitcraft/context/__pycache__/__init__.cpython-312.pyc b/commitcraft/context/__pycache__/__init__.cpython-312.pyc
|
|
23
|
+
new file mode 100644
|
|
24
|
+
index 0000000..fd5bf45
|
|
25
|
+
Binary files /dev/null and b/commitcraft/context/__pycache__/__init__.cpython-312.pyc differ
|
|
26
|
+
diff --git a/commitcraft/context/__pycache__/builder.cpython-312.pyc b/commitcraft/context/__pycache__/builder.cpython-312.pyc
|
|
27
|
+
new file mode 100644
|
|
28
|
+
index 0000000..8f9985d
|
|
29
|
+
Binary files /dev/null and b/commitcraft/context/__pycache__/builder.cpython-312.pyc differ
|
|
30
|
+
diff --git a/commitcraft/context/builder.py b/commitcraft/context/builder.py
|
|
31
|
+
new file mode 100644
|
|
32
|
+
index 0000000..4a61ff6
|
|
33
|
+
--- /dev/null
|
|
34
|
+
+++ b/commitcraft/context/builder.py
|
|
35
|
+
@@ -0,0 +1,52 @@
|
|
36
|
+
+from commitcraft.analysis.filters import FilteredDiff
|
|
37
|
+
+
|
|
38
|
+
+
|
|
39
|
+
+def build_commit_context(filtered: FilteredDiff, recent_commits: list[str]) -> str:
|
|
40
|
+
+ lines: list[str] = []
|
|
41
|
+
+
|
|
42
|
+
+ lines.append("=== Changed Files ===")
|
|
43
|
+
+ for f in filtered.files:
|
|
44
|
+
+ markers = []
|
|
45
|
+
+ if f.is_new:
|
|
46
|
+
+ markers.append("NEW")
|
|
47
|
+
+ if f.is_deleted:
|
|
48
|
+
+ markers.append("DELETED")
|
|
49
|
+
+ if f.has_function_changes:
|
|
50
|
+
+ markers.append("has function/def changes")
|
|
51
|
+
+ if f.has_class_changes:
|
|
52
|
+
+ markers.append("has class changes")
|
|
53
|
+
+ marker_str = f" [{', '.join(markers)}]" if markers else ""
|
|
54
|
+
+ lines.append(f" {f.path} (+{f.added_lines}/-{f.removed_lines}){marker_str}")
|
|
55
|
+
+
|
|
56
|
+
+ lines.append(f"\nTotal: +{filtered.total_added}/-{filtered.total_removed} lines across {len(filtered.files)} file(s)")
|
|
57
|
+
+
|
|
58
|
+
+ if recent_commits:
|
|
59
|
+
+ lines.append("\n=== Recent Commit Style (follow this format) ===")
|
|
60
|
+
+ for c in recent_commits[:5]:
|
|
61
|
+
+ lines.append(f" {c}")
|
|
62
|
+
+
|
|
63
|
+
+ lines.append("\n=== Task ===")
|
|
64
|
+
+ lines.append("Write a single conventional commit message for the changes above.")
|
|
65
|
+
+ lines.append("Output ONLY the commit message. No explanation.")
|
|
66
|
+
+
|
|
67
|
+
+ return "\n".join(lines)
|
|
68
|
+
+
|
|
69
|
+
+
|
|
70
|
+
+def build_pr_context(branch_diff: str, branch_name: str, recent_commits: list[str]) -> str:
|
|
71
|
+
+ lines: list[str] = []
|
|
72
|
+
+ lines.append(f"Branch: {branch_name}")
|
|
73
|
+
+ lines.append(f"\nRecent commits on this branch:")
|
|
74
|
+
+ for c in recent_commits[:10]:
|
|
75
|
+
+ lines.append(f" {c}")
|
|
76
|
+
+ lines.append("\n=== Task ===")
|
|
77
|
+
+ lines.append("Write a GitHub PR description for this branch. Use markdown with ## Summary and ## Changes sections.")
|
|
78
|
+
+ return "\n".join(lines)
|
|
79
|
+
+
|
|
80
|
+
+
|
|
81
|
+
+def build_release_context(tag_diff: str, tag_range: str) -> str:
|
|
82
|
+
+ lines: list[str] = [
|
|
83
|
+
+ f"Release range: {tag_range}",
|
|
84
|
+
+ "\n=== Task ===",
|
|
85
|
+
+ "Write structured release notes grouped by: Features, Bug Fixes, Documentation, Other. Use markdown.",
|
|
86
|
+
+ ]
|
|
87
|
+
+ return "\n".join(lines)
|
|
88
|
+
diff --git a/commitcraft/utils/__init__.py b/commitcraft/utils/__init__.py
|
|
89
|
+
new file mode 100644
|
|
90
|
+
index 0000000..e69de29
|
|
91
|
+
diff --git a/commitcraft/utils/__pycache__/__init__.cpython-312.pyc b/commitcraft/utils/__pycache__/__init__.cpython-312.pyc
|
|
92
|
+
new file mode 100644
|
|
93
|
+
index 0000000..3c18b7b
|
|
94
|
+
Binary files /dev/null and b/commitcraft/utils/__pycache__/__init__.cpython-312.pyc differ
|
|
95
|
+
diff --git a/commitcraft/utils/__pycache__/token_estimator.cpython-312.pyc b/commitcraft/utils/__pycache__/token_estimator.cpython-312.pyc
|
|
96
|
+
new file mode 100644
|
|
97
|
+
index 0000000..444a5ab
|
|
98
|
+
Binary files /dev/null and b/commitcraft/utils/__pycache__/token_estimator.cpython-312.pyc differ
|
|
99
|
+
diff --git a/commitcraft/utils/token_estimator.py b/commitcraft/utils/token_estimator.py
|
|
100
|
+
new file mode 100644
|
|
101
|
+
index 0000000..edafb04
|
|
102
|
+
--- /dev/null
|
|
103
|
+
+++ b/commitcraft/utils/token_estimator.py
|
|
104
|
+
@@ -0,0 +1,3 @@
|
|
105
|
+
+def estimate_tokens(text: str) -> int:
|
|
106
|
+
+ # Rough approximation: 1 token ≈ 4 characters (widely used heuristic)
|
|
107
|
+
+ return max(1, len(text) // 4)
|
|
108
|
+
diff --git a/tests/test_context_builder.py b/tests/test_context_builder.py
|
|
109
|
+
new file mode 100644
|
|
110
|
+
index 0000000..f30177a
|
|
111
|
+
--- /dev/null
|
|
112
|
+
+++ b/tests/test_context_builder.py
|
|
113
|
+
@@ -0,0 +1,59 @@
|
|
114
|
+
+import pytest
|
|
115
|
+
+from commitcraft.git.diff_parser import DiffFile
|
|
116
|
+
+from commitcraft.analysis.filters import FilteredDiff
|
|
117
|
+
+from commitcraft.context.builder import build_commit_context, build_pr_context
|
|
118
|
+
+from commitcraft.utils.token_estimator import estimate_tokens
|
|
119
|
+
+
|
|
120
|
+
+
|
|
121
|
+
+def _file(path: str, added: int = 10, removed: int = 3, has_func: bool = False) -> DiffFile:
|
|
122
|
+
+ return DiffFile(
|
|
123
|
+
+ path=path, file_type="python", is_new=False, is_deleted=False,
|
|
124
|
+
+ added_lines=added, removed_lines=removed, raw_hunks=f"@@ hunk for {path} @@\n+new line",
|
|
125
|
+
+ has_function_changes=has_func, has_class_changes=False,
|
|
126
|
+
+ )
|
|
127
|
+
+
|
|
128
|
+
+
|
|
129
|
+
+def test_commit_context_contains_file_list():
|
|
130
|
+
+ diff = FilteredDiff(
|
|
131
|
+
+ files=[_file("src/auth.py"), _file("tests/test_auth.py")],
|
|
132
|
+
+ total_added=20, total_removed=6,
|
|
133
|
+
+ )
|
|
134
|
+
+ ctx = build_commit_context(diff, recent_commits=["feat: add login", "fix: fix typo"])
|
|
135
|
+
+ assert "src/auth.py" in ctx
|
|
136
|
+
+ assert "tests/test_auth.py" in ctx
|
|
137
|
+
+
|
|
138
|
+
+
|
|
139
|
+
+def test_commit_context_contains_line_counts():
|
|
140
|
+
+ diff = FilteredDiff(files=[_file("src/a.py", added=15, removed=3)], total_added=15, total_removed=3)
|
|
141
|
+
+ ctx = build_commit_context(diff, recent_commits=[])
|
|
142
|
+
+ assert "15" in ctx or "+15" in ctx
|
|
143
|
+
+
|
|
144
|
+
+
|
|
145
|
+
+def test_commit_context_mentions_function_changes():
|
|
146
|
+
+ diff = FilteredDiff(files=[_file("src/a.py", has_func=True)], total_added=10, total_removed=0)
|
|
147
|
+
+ ctx = build_commit_context(diff, recent_commits=[])
|
|
148
|
+
+ assert "function" in ctx.lower() or "def" in ctx.lower()
|
|
149
|
+
+
|
|
150
|
+
+
|
|
151
|
+
+def test_commit_context_includes_recent_commit_style():
|
|
152
|
+
+ recent = ["feat: add authentication", "fix: resolve login bug"]
|
|
153
|
+
+ diff = FilteredDiff(files=[_file("src/a.py")], total_added=5, total_removed=0)
|
|
154
|
+
+ ctx = build_commit_context(diff, recent_commits=recent)
|
|
155
|
+
+ assert "feat: add authentication" in ctx
|
|
156
|
+
+
|
|
157
|
+
+
|
|
158
|
+
+def test_commit_context_is_short():
|
|
159
|
+
+ diff = FilteredDiff(files=[_file("src/a.py", added=500)], total_added=500, total_removed=0)
|
|
160
|
+
+ ctx = build_commit_context(diff, recent_commits=[])
|
|
161
|
+
+ assert estimate_tokens(ctx) < 800
|
|
162
|
+
+
|
|
163
|
+
+
|
|
164
|
+
+def test_pr_context_contains_branch_name():
|
|
165
|
+
+ ctx = build_pr_context("diff content", "feature/add-auth", ["feat: add auth"])
|
|
166
|
+
+ assert "feature/add-auth" in ctx or "add-auth" in ctx
|
|
167
|
+
+
|
|
168
|
+
+
|
|
169
|
+
+def test_estimate_tokens_rough():
|
|
170
|
+
+ text = "hello world " * 100 # 200 words ~ 300 tokens
|
|
171
|
+
+ tokens = estimate_tokens(text)
|
|
172
|
+
+ assert 150 < tokens < 500
|