commitcraft-cli 0.1.0__py3-none-any.whl
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/__init__.py +1 -0
- commitcraft/analysis/__init__.py +0 -0
- commitcraft/analysis/classifier.py +57 -0
- commitcraft/analysis/filters.py +67 -0
- commitcraft/analysis/rule_engine.py +101 -0
- commitcraft/cli.py +172 -0
- commitcraft/config/__init__.py +0 -0
- commitcraft/config/models.py +25 -0
- commitcraft/config/store.py +48 -0
- commitcraft/config/wizard.py +44 -0
- commitcraft/context/__init__.py +0 -0
- commitcraft/context/builder.py +59 -0
- commitcraft/generators/__init__.py +0 -0
- commitcraft/generators/commit.py +33 -0
- commitcraft/generators/pr.py +12 -0
- commitcraft/generators/release_notes.py +26 -0
- commitcraft/git/__init__.py +0 -0
- commitcraft/git/diff_parser.py +132 -0
- commitcraft/git/history.py +73 -0
- commitcraft/providers/__init__.py +0 -0
- commitcraft/providers/anthropic_provider.py +44 -0
- commitcraft/providers/base.py +19 -0
- commitcraft/providers/gemini_provider.py +40 -0
- commitcraft/providers/ollama_provider.py +61 -0
- commitcraft/providers/openai_provider.py +43 -0
- commitcraft/utils/__init__.py +0 -0
- commitcraft/utils/token_estimator.py +3 -0
- commitcraft_cli-0.1.0.dist-info/METADATA +113 -0
- commitcraft_cli-0.1.0.dist-info/RECORD +32 -0
- commitcraft_cli-0.1.0.dist-info/WHEEL +4 -0
- commitcraft_cli-0.1.0.dist-info/entry_points.txt +2 -0
- commitcraft_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
commitcraft/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
File without changes
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from commitcraft.analysis.filters import FilteredDiff
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class ClassificationResult:
|
|
9
|
+
score: int
|
|
10
|
+
is_simple: bool
|
|
11
|
+
signals: dict[str, int | bool] = field(default_factory=dict)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _count_directories(files: list) -> int:
|
|
15
|
+
dirs = {str(Path(f.path).parent) for f in files}
|
|
16
|
+
return len(dirs)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _has_test_alongside_source(files: list) -> bool:
|
|
20
|
+
paths = [f.path for f in files]
|
|
21
|
+
has_test = any("test" in p.lower() for p in paths)
|
|
22
|
+
has_source = any("test" not in p.lower() for p in paths)
|
|
23
|
+
return has_test and has_source
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def classify(filtered: FilteredDiff, threshold: int = 30) -> ClassificationResult:
|
|
27
|
+
if not filtered.files:
|
|
28
|
+
return ClassificationResult(score=0, is_simple=True, signals={
|
|
29
|
+
"file_count": 0, "directory_spread": 0,
|
|
30
|
+
"function_changes": 0, "test_alongside_source": False, "line_count": 0,
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
file_count = len(filtered.files)
|
|
34
|
+
dir_spread = _count_directories(filtered.files)
|
|
35
|
+
func_changes = sum(1 for f in filtered.files if f.has_function_changes or f.has_class_changes)
|
|
36
|
+
test_with_source = _has_test_alongside_source(filtered.files)
|
|
37
|
+
total_lines = filtered.total_added + filtered.total_removed
|
|
38
|
+
|
|
39
|
+
# Weighted scoring (caps at 100)
|
|
40
|
+
score = 0
|
|
41
|
+
score += min(file_count * 5, 25) # up to 25 pts for file count
|
|
42
|
+
score += min(dir_spread * 5, 20) # up to 20 pts for directory spread
|
|
43
|
+
score += min(func_changes * 10, 30) # up to 30 pts for function/class changes
|
|
44
|
+
score += 10 if test_with_source else 0 # 10 pts for test+source together
|
|
45
|
+
score += min(total_lines // 20, 15) # up to 15 pts for line count
|
|
46
|
+
|
|
47
|
+
score = min(score, 100)
|
|
48
|
+
|
|
49
|
+
signals = {
|
|
50
|
+
"file_count": file_count,
|
|
51
|
+
"directory_spread": dir_spread,
|
|
52
|
+
"function_changes": func_changes,
|
|
53
|
+
"test_alongside_source": test_with_source,
|
|
54
|
+
"line_count": total_lines,
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return ClassificationResult(score=score, is_simple=score < threshold, signals=signals)
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import fnmatch
|
|
2
|
+
from dataclasses import dataclass, field
|
|
3
|
+
|
|
4
|
+
from commitcraft.git.diff_parser import DiffFile, ParsedDiff
|
|
5
|
+
|
|
6
|
+
DEFAULT_IGNORE_PATTERNS: list[str] = [
|
|
7
|
+
"package-lock.json",
|
|
8
|
+
"yarn.lock",
|
|
9
|
+
"poetry.lock",
|
|
10
|
+
"Pipfile.lock",
|
|
11
|
+
"composer.lock",
|
|
12
|
+
"Gemfile.lock",
|
|
13
|
+
"pnpm-lock.yaml",
|
|
14
|
+
"dist/*",
|
|
15
|
+
"build/*",
|
|
16
|
+
".next/*",
|
|
17
|
+
"node_modules/*",
|
|
18
|
+
"*.min.js",
|
|
19
|
+
"*.min.css",
|
|
20
|
+
"*.map",
|
|
21
|
+
"__pycache__/*",
|
|
22
|
+
"*.pyc",
|
|
23
|
+
".mypy_cache/*",
|
|
24
|
+
".ruff_cache/*",
|
|
25
|
+
"coverage/*",
|
|
26
|
+
".coverage",
|
|
27
|
+
"*.egg-info/*",
|
|
28
|
+
"site-packages/*",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class FilteredDiff:
|
|
34
|
+
files: list[DiffFile] = field(default_factory=list)
|
|
35
|
+
filtered_out: list[str] = field(default_factory=list)
|
|
36
|
+
total_added: int = 0
|
|
37
|
+
total_removed: int = 0
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _is_ignored(path: str, patterns: list[str]) -> bool:
|
|
41
|
+
for pattern in patterns:
|
|
42
|
+
if fnmatch.fnmatch(path, pattern):
|
|
43
|
+
return True
|
|
44
|
+
# also match if path starts with a prefix pattern
|
|
45
|
+
# e.g. "dist/*" should match "dist/foo/bar.js"
|
|
46
|
+
prefix = pattern.rstrip("/*")
|
|
47
|
+
if path.startswith(prefix + "/") or path.startswith(prefix + "\\"):
|
|
48
|
+
return True
|
|
49
|
+
return False
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def filter_diff(
|
|
53
|
+
parsed: ParsedDiff,
|
|
54
|
+
extra_patterns: list[str] | None = None,
|
|
55
|
+
) -> FilteredDiff:
|
|
56
|
+
patterns = DEFAULT_IGNORE_PATTERNS + (extra_patterns or [])
|
|
57
|
+
result = FilteredDiff()
|
|
58
|
+
|
|
59
|
+
for f in parsed.files:
|
|
60
|
+
if _is_ignored(f.path, patterns):
|
|
61
|
+
result.filtered_out.append(f.path)
|
|
62
|
+
else:
|
|
63
|
+
result.files.append(f)
|
|
64
|
+
result.total_added += f.added_lines
|
|
65
|
+
result.total_removed += f.removed_lines
|
|
66
|
+
|
|
67
|
+
return result
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from commitcraft.analysis.filters import FilteredDiff
|
|
5
|
+
|
|
6
|
+
LOCKFILE_NAMES = {
|
|
7
|
+
"package-lock.json", "yarn.lock", "poetry.lock",
|
|
8
|
+
"Pipfile.lock", "composer.lock", "Gemfile.lock", "pnpm-lock.yaml",
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
DEPENDENCY_MANIFESTS = {
|
|
12
|
+
"requirements.txt", "requirements-dev.txt", "setup.cfg",
|
|
13
|
+
"package.json", "Pipfile", "pyproject.toml",
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
CONFIG_PATTERNS = {".yml", ".yaml", ".json", ".toml", ".ini", ".cfg", ".env"}
|
|
17
|
+
CONFIG_DIRS = {".github", ".circleci", ".gitlab", "config", ".husky"}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class RuleResult:
|
|
22
|
+
matched: bool
|
|
23
|
+
message: str | None
|
|
24
|
+
rule_name: str | None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _all_paths(filtered: FilteredDiff) -> list[str]:
|
|
28
|
+
return [f.path for f in filtered.files]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _readme_only(paths: list[str]) -> RuleResult | None:
|
|
32
|
+
if len(paths) == 1 and Path(paths[0]).name.upper().startswith("README"):
|
|
33
|
+
return RuleResult(matched=True, message="docs: update README", rule_name="readme_only")
|
|
34
|
+
return None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _lockfile_only(paths: list[str]) -> RuleResult | None:
|
|
38
|
+
if all(Path(p).name in LOCKFILE_NAMES for p in paths):
|
|
39
|
+
return RuleResult(matched=True, message="chore: update lockfile", rule_name="lockfile_only")
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _dependency_bump(paths: list[str]) -> RuleResult | None:
|
|
44
|
+
if all(Path(p).name in DEPENDENCY_MANIFESTS for p in paths):
|
|
45
|
+
return RuleResult(
|
|
46
|
+
matched=True, message="chore: bump dependencies", rule_name="dependency_bump"
|
|
47
|
+
)
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _docs_only(paths: list[str]) -> RuleResult | None:
|
|
52
|
+
doc_exts = {".md", ".rst", ".txt"}
|
|
53
|
+
doc_dirs = {"docs", "doc", "documentation"}
|
|
54
|
+
if all(
|
|
55
|
+
Path(p).suffix.lower() in doc_exts or Path(p).parts[0].lower() in doc_dirs
|
|
56
|
+
for p in paths
|
|
57
|
+
):
|
|
58
|
+
return RuleResult(matched=True, message="docs: update documentation", rule_name="docs_only")
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _single_test_file(paths: list[str]) -> RuleResult | None:
|
|
63
|
+
if len(paths) == 1 and "test" in paths[0].lower():
|
|
64
|
+
name = Path(paths[0]).stem
|
|
65
|
+
return RuleResult(
|
|
66
|
+
matched=True, message=f"test: add tests for {name}", rule_name="single_test_file"
|
|
67
|
+
)
|
|
68
|
+
return None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _config_only(paths: list[str]) -> RuleResult | None:
|
|
72
|
+
def is_config(p: str) -> bool:
|
|
73
|
+
parts = Path(p).parts
|
|
74
|
+
return (
|
|
75
|
+
Path(p).suffix.lower() in CONFIG_PATTERNS
|
|
76
|
+
and (
|
|
77
|
+
parts[0] in CONFIG_DIRS
|
|
78
|
+
or not any("src" in part or "lib" in part for part in parts)
|
|
79
|
+
)
|
|
80
|
+
)
|
|
81
|
+
if paths and all(is_config(p) for p in paths):
|
|
82
|
+
return RuleResult(matched=True, message="chore: update config", rule_name="config_only")
|
|
83
|
+
return None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
_RULES = [
|
|
87
|
+
_readme_only, _lockfile_only, _dependency_bump, _docs_only, _single_test_file, _config_only
|
|
88
|
+
]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def apply_rules(filtered: FilteredDiff) -> RuleResult:
|
|
92
|
+
if not filtered.files:
|
|
93
|
+
return RuleResult(matched=False, message=None, rule_name=None)
|
|
94
|
+
|
|
95
|
+
paths = _all_paths(filtered)
|
|
96
|
+
for rule in _RULES:
|
|
97
|
+
result = rule(paths)
|
|
98
|
+
if result is not None:
|
|
99
|
+
return result
|
|
100
|
+
|
|
101
|
+
return RuleResult(matched=False, message=None, rule_name=None)
|
commitcraft/cli.py
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
from rich.console import Console
|
|
5
|
+
|
|
6
|
+
app = typer.Typer(
|
|
7
|
+
name="commitcraft",
|
|
8
|
+
help="Smart conventional commit messages — rule engine first, LLM only when needed.",
|
|
9
|
+
no_args_is_help=True,
|
|
10
|
+
)
|
|
11
|
+
console = Console()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@app.command()
|
|
15
|
+
def init():
|
|
16
|
+
"""First-time setup: choose provider, store config."""
|
|
17
|
+
from commitcraft.config.wizard import run_wizard
|
|
18
|
+
run_wizard()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@app.command()
|
|
22
|
+
def commit(no_stage: bool = typer.Option(False, "--no-stage", help="Skip git add .")):
|
|
23
|
+
"""Analyze staged diff and generate a conventional commit message."""
|
|
24
|
+
import subprocess
|
|
25
|
+
|
|
26
|
+
from rich.panel import Panel
|
|
27
|
+
from rich.prompt import Prompt
|
|
28
|
+
|
|
29
|
+
from commitcraft.config.store import load_config
|
|
30
|
+
from commitcraft.generators.commit import generate_commit_message
|
|
31
|
+
from commitcraft.git.diff_parser import get_staged_diff
|
|
32
|
+
from commitcraft.utils.token_estimator import estimate_tokens
|
|
33
|
+
|
|
34
|
+
cfg = load_config()
|
|
35
|
+
|
|
36
|
+
if not no_stage:
|
|
37
|
+
console.print("[dim]Running git add .[/dim]")
|
|
38
|
+
subprocess.run(["git", "add", "."], check=True)
|
|
39
|
+
|
|
40
|
+
console.print("[dim]Analyzing staged changes...[/dim]")
|
|
41
|
+
staged_diff = get_staged_diff()
|
|
42
|
+
|
|
43
|
+
if not staged_diff.strip():
|
|
44
|
+
console.print("[yellow]No staged changes found.[/yellow]")
|
|
45
|
+
raise typer.Exit(1)
|
|
46
|
+
|
|
47
|
+
message, source = generate_commit_message(staged_diff, cfg)
|
|
48
|
+
|
|
49
|
+
if not message:
|
|
50
|
+
console.print("[red]Could not generate a commit message.[/red]")
|
|
51
|
+
raise typer.Exit(1)
|
|
52
|
+
|
|
53
|
+
source_label = f"[dim](source: {source})[/dim]"
|
|
54
|
+
if cfg.show_cost and source not in ("rule_engine", "none"):
|
|
55
|
+
from commitcraft.analysis.filters import filter_diff
|
|
56
|
+
from commitcraft.context.builder import build_commit_context
|
|
57
|
+
from commitcraft.git.diff_parser import parse_diff
|
|
58
|
+
parsed = parse_diff(staged_diff)
|
|
59
|
+
filtered = filter_diff(parsed, extra_patterns=cfg.ignore_patterns)
|
|
60
|
+
ctx = build_commit_context(filtered, [])
|
|
61
|
+
tokens = estimate_tokens(ctx)
|
|
62
|
+
source_label += f" [dim](~{tokens} tokens)[/dim]"
|
|
63
|
+
|
|
64
|
+
console.print(Panel(f"[bold green]{message}[/bold green]", title="Suggested commit message"))
|
|
65
|
+
console.print(source_label)
|
|
66
|
+
|
|
67
|
+
choice = Prompt.ask("Accept?", choices=["y", "n", "e"], default="y")
|
|
68
|
+
|
|
69
|
+
if choice == "n":
|
|
70
|
+
console.print("[yellow]Cancelled.[/yellow]")
|
|
71
|
+
raise typer.Exit(0)
|
|
72
|
+
|
|
73
|
+
if choice == "e":
|
|
74
|
+
import os
|
|
75
|
+
import tempfile
|
|
76
|
+
editor = os.environ.get("EDITOR", "vi")
|
|
77
|
+
with tempfile.NamedTemporaryFile(suffix=".txt", mode="w", delete=False) as tmp:
|
|
78
|
+
tmp.write(message)
|
|
79
|
+
tmp_path = tmp.name
|
|
80
|
+
subprocess.run([editor, tmp_path])
|
|
81
|
+
message = Path(tmp_path).read_text().strip()
|
|
82
|
+
os.unlink(tmp_path)
|
|
83
|
+
|
|
84
|
+
subprocess.run(["git", "commit", "-m", message], check=True)
|
|
85
|
+
console.print(f"[green]Committed: [bold]{message}[/bold][/green]")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@app.command()
|
|
89
|
+
def pr(base: str = typer.Option("main", "--base", "-b", help="Base branch to diff against")):
|
|
90
|
+
"""Generate a PR description from the current branch diff."""
|
|
91
|
+
from rich.panel import Panel
|
|
92
|
+
|
|
93
|
+
from commitcraft.config.store import load_config
|
|
94
|
+
from commitcraft.generators.pr import generate_pr_description
|
|
95
|
+
|
|
96
|
+
cfg = load_config()
|
|
97
|
+
console.print("[dim]Generating PR description...[/dim]")
|
|
98
|
+
description = generate_pr_description(cfg, base=base)
|
|
99
|
+
console.print(Panel(description, title="PR Description"))
|
|
100
|
+
console.print("\n[dim]Copy the above into your PR description.[/dim]")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@app.command("release-notes")
|
|
104
|
+
def release_notes(tag_range: str = typer.Argument(..., help="e.g. v1.0.0..v1.1.0")):
|
|
105
|
+
"""Categorize and summarize changes between two tags."""
|
|
106
|
+
from rich.panel import Panel
|
|
107
|
+
|
|
108
|
+
from commitcraft.config.store import load_config
|
|
109
|
+
from commitcraft.generators.release_notes import generate_release_notes
|
|
110
|
+
|
|
111
|
+
cfg = load_config()
|
|
112
|
+
console.print(f"[dim]Generating release notes for {tag_range}...[/dim]")
|
|
113
|
+
notes = generate_release_notes(tag_range, cfg)
|
|
114
|
+
console.print(Panel(notes, title=f"Release Notes: {tag_range}"))
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@app.command()
|
|
118
|
+
def history():
|
|
119
|
+
"""Analyze commit history for patterns and generic messages."""
|
|
120
|
+
from rich.table import Table
|
|
121
|
+
|
|
122
|
+
from commitcraft.git.history import (
|
|
123
|
+
analyze_commit_patterns,
|
|
124
|
+
detect_generic_commits,
|
|
125
|
+
get_all_commits,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
commits = get_all_commits(n=100)
|
|
129
|
+
if not commits:
|
|
130
|
+
console.print("[yellow]No commits found.[/yellow]")
|
|
131
|
+
raise typer.Exit(0)
|
|
132
|
+
|
|
133
|
+
generic = detect_generic_commits(commits)
|
|
134
|
+
patterns = analyze_commit_patterns(commits)
|
|
135
|
+
|
|
136
|
+
console.print(f"\n[bold]Analyzed {len(commits)} commits[/bold]\n")
|
|
137
|
+
|
|
138
|
+
table = Table(title="Commit Type Distribution")
|
|
139
|
+
table.add_column("Type", style="cyan")
|
|
140
|
+
table.add_column("Count", justify="right")
|
|
141
|
+
for ctype, count in sorted(patterns.items(), key=lambda x: -x[1]):
|
|
142
|
+
table.add_row(ctype, str(count))
|
|
143
|
+
console.print(table)
|
|
144
|
+
|
|
145
|
+
if generic:
|
|
146
|
+
console.print(f"\n[yellow]Found {len(generic)} generic commit message(s):[/yellow]")
|
|
147
|
+
for g in generic:
|
|
148
|
+
console.print(f" [red]•[/red] {g}")
|
|
149
|
+
console.print("\n[dim]Consider using commitcraft to write better commit messages.[/dim]")
|
|
150
|
+
else:
|
|
151
|
+
console.print("\n[green]✓[/green] No generic commit messages found. Great commit hygiene!")
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@app.command()
|
|
155
|
+
def config():
|
|
156
|
+
"""Show or change current settings."""
|
|
157
|
+
from rich.table import Table
|
|
158
|
+
|
|
159
|
+
from commitcraft.config.store import config_path, load_config
|
|
160
|
+
cfg = load_config()
|
|
161
|
+
table = Table(title="commitcraft config", show_header=True)
|
|
162
|
+
table.add_column("Setting", style="cyan")
|
|
163
|
+
table.add_column("Value")
|
|
164
|
+
for key, value in cfg.model_dump().items():
|
|
165
|
+
display = "***" if "api_key" in key and value else str(value)
|
|
166
|
+
table.add_row(key, display)
|
|
167
|
+
console.print(table)
|
|
168
|
+
console.print(f"\n[dim]Config file: {config_path()}[/dim]")
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
if __name__ == "__main__":
|
|
172
|
+
app()
|
|
File without changes
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from enum import Enum
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, Field
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ProviderName(str, Enum):
|
|
7
|
+
OLLAMA = "ollama"
|
|
8
|
+
OPENAI = "openai"
|
|
9
|
+
ANTHROPIC = "anthropic"
|
|
10
|
+
GEMINI = "gemini"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class CommitcraftConfig(BaseModel):
|
|
14
|
+
provider: ProviderName = ProviderName.OLLAMA
|
|
15
|
+
ollama_model: str = "llama3.2"
|
|
16
|
+
ollama_base_url: str = "http://localhost:11434"
|
|
17
|
+
openai_api_key: str | None = None
|
|
18
|
+
openai_model: str = "gpt-4o-mini"
|
|
19
|
+
anthropic_api_key: str | None = None
|
|
20
|
+
anthropic_model: str = "claude-haiku-4-5-20251001"
|
|
21
|
+
gemini_api_key: str | None = None
|
|
22
|
+
gemini_model: str = "gemini-1.5-flash"
|
|
23
|
+
complexity_threshold: int = Field(default=30, ge=0, le=100)
|
|
24
|
+
ignore_patterns: list[str] = Field(default_factory=list)
|
|
25
|
+
show_cost: bool = False
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
import yaml
|
|
4
|
+
|
|
5
|
+
from commitcraft.config.models import CommitcraftConfig, ProviderName
|
|
6
|
+
from commitcraft.providers.base import Provider
|
|
7
|
+
|
|
8
|
+
CONFIG_DIR = Path.home() / ".commitcraft"
|
|
9
|
+
_CONFIG_FILE = "config.yaml"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def config_path() -> Path:
|
|
13
|
+
return CONFIG_DIR / _CONFIG_FILE
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def load_config() -> CommitcraftConfig:
|
|
17
|
+
path = config_path()
|
|
18
|
+
if not path.exists():
|
|
19
|
+
return CommitcraftConfig()
|
|
20
|
+
with path.open() as f:
|
|
21
|
+
data = yaml.safe_load(f) or {}
|
|
22
|
+
return CommitcraftConfig(**data)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def save_config(config: CommitcraftConfig) -> None:
|
|
26
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
27
|
+
path = config_path()
|
|
28
|
+
with path.open("w") as f:
|
|
29
|
+
yaml.safe_dump(config.model_dump(mode="json"), f, default_flow_style=False)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def get_provider(config: CommitcraftConfig) -> Provider:
|
|
33
|
+
from commitcraft.providers.anthropic_provider import AnthropicProvider
|
|
34
|
+
from commitcraft.providers.gemini_provider import GeminiProvider
|
|
35
|
+
from commitcraft.providers.ollama_provider import OllamaProvider
|
|
36
|
+
from commitcraft.providers.openai_provider import OpenAIProvider
|
|
37
|
+
|
|
38
|
+
match config.provider:
|
|
39
|
+
case ProviderName.OLLAMA:
|
|
40
|
+
return OllamaProvider(config)
|
|
41
|
+
case ProviderName.ANTHROPIC:
|
|
42
|
+
return AnthropicProvider(config)
|
|
43
|
+
case ProviderName.OPENAI:
|
|
44
|
+
return OpenAIProvider(config)
|
|
45
|
+
case ProviderName.GEMINI:
|
|
46
|
+
return GeminiProvider(config)
|
|
47
|
+
case _:
|
|
48
|
+
raise ValueError(f"Unknown provider: {config.provider}")
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
from rich.console import Console
|
|
2
|
+
from rich.prompt import Prompt
|
|
3
|
+
|
|
4
|
+
from commitcraft.config.models import CommitcraftConfig, ProviderName
|
|
5
|
+
from commitcraft.config.store import save_config
|
|
6
|
+
|
|
7
|
+
console = Console()
|
|
8
|
+
|
|
9
|
+
_PROVIDER_CHOICES = {
|
|
10
|
+
"1": (ProviderName.OLLAMA, "Ollama (free, runs locally — requires Ollama installed)"),
|
|
11
|
+
"2": (ProviderName.OPENAI, "OpenAI (requires your own API key)"),
|
|
12
|
+
"3": (ProviderName.GEMINI, "Google Gemini (requires your own API key)"),
|
|
13
|
+
"4": (ProviderName.ANTHROPIC, "Anthropic Claude (requires your own API key)"),
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def run_wizard() -> CommitcraftConfig:
|
|
18
|
+
console.print("\n[bold cyan]Welcome to commitcraft![/bold cyan]\n")
|
|
19
|
+
console.print("Which provider would you like to use?\n")
|
|
20
|
+
|
|
21
|
+
for key, (_, label) in _PROVIDER_CHOICES.items():
|
|
22
|
+
console.print(f" [bold]{key})[/bold] {label}")
|
|
23
|
+
|
|
24
|
+
choice = Prompt.ask("\nEnter choice", choices=list(_PROVIDER_CHOICES.keys()), default="1")
|
|
25
|
+
provider, _ = _PROVIDER_CHOICES[choice]
|
|
26
|
+
|
|
27
|
+
cfg = CommitcraftConfig(provider=provider)
|
|
28
|
+
|
|
29
|
+
if provider == ProviderName.OPENAI:
|
|
30
|
+
cfg.openai_api_key = Prompt.ask("Enter your OpenAI API key", password=True)
|
|
31
|
+
cfg.openai_model = Prompt.ask("Model", default="gpt-4o-mini")
|
|
32
|
+
elif provider == ProviderName.ANTHROPIC:
|
|
33
|
+
cfg.anthropic_api_key = Prompt.ask("Enter your Anthropic API key", password=True)
|
|
34
|
+
cfg.anthropic_model = Prompt.ask("Model", default="claude-haiku-4-5-20251001")
|
|
35
|
+
elif provider == ProviderName.GEMINI:
|
|
36
|
+
cfg.gemini_api_key = Prompt.ask("Enter your Gemini API key", password=True)
|
|
37
|
+
cfg.gemini_model = Prompt.ask("Model", default="gemini-1.5-flash")
|
|
38
|
+
elif provider == ProviderName.OLLAMA:
|
|
39
|
+
cfg.ollama_base_url = Prompt.ask("Ollama base URL", default="http://localhost:11434")
|
|
40
|
+
cfg.ollama_model = Prompt.ask("Model", default="llama3.2")
|
|
41
|
+
|
|
42
|
+
save_config(cfg)
|
|
43
|
+
console.print(f"\n[green]✓[/green] Config saved. Provider: [bold]{provider.value}[/bold]")
|
|
44
|
+
return cfg
|
|
File without changes
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
from commitcraft.analysis.filters import FilteredDiff
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def build_commit_context(filtered: FilteredDiff, recent_commits: list[str]) -> str:
|
|
5
|
+
lines: list[str] = []
|
|
6
|
+
|
|
7
|
+
lines.append("=== Changed Files ===")
|
|
8
|
+
for f in filtered.files:
|
|
9
|
+
markers = []
|
|
10
|
+
if f.is_new:
|
|
11
|
+
markers.append("NEW")
|
|
12
|
+
if f.is_deleted:
|
|
13
|
+
markers.append("DELETED")
|
|
14
|
+
if f.has_function_changes:
|
|
15
|
+
markers.append("has function/def changes")
|
|
16
|
+
if f.has_class_changes:
|
|
17
|
+
markers.append("has class changes")
|
|
18
|
+
marker_str = f" [{', '.join(markers)}]" if markers else ""
|
|
19
|
+
lines.append(f" {f.path} (+{f.added_lines}/-{f.removed_lines}){marker_str}")
|
|
20
|
+
|
|
21
|
+
lines.append(
|
|
22
|
+
f"\nTotal: +{filtered.total_added}/-{filtered.total_removed}"
|
|
23
|
+
f" lines across {len(filtered.files)} file(s)"
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
if recent_commits:
|
|
27
|
+
lines.append("\n=== Recent Commit Style (follow this format) ===")
|
|
28
|
+
for c in recent_commits[:5]:
|
|
29
|
+
lines.append(f" {c}")
|
|
30
|
+
|
|
31
|
+
lines.append("\n=== Task ===")
|
|
32
|
+
lines.append("Write a single conventional commit message for the changes above.")
|
|
33
|
+
lines.append("Output ONLY the commit message. No explanation.")
|
|
34
|
+
|
|
35
|
+
return "\n".join(lines)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def build_pr_context(branch_diff: str, branch_name: str, recent_commits: list[str]) -> str:
|
|
39
|
+
lines: list[str] = []
|
|
40
|
+
lines.append(f"Branch: {branch_name}")
|
|
41
|
+
lines.append("\nRecent commits on this branch:")
|
|
42
|
+
for c in recent_commits[:10]:
|
|
43
|
+
lines.append(f" {c}")
|
|
44
|
+
lines.append("\n=== Task ===")
|
|
45
|
+
lines.append(
|
|
46
|
+
"Write a GitHub PR description for this branch."
|
|
47
|
+
" Use markdown with ## Summary and ## Changes sections."
|
|
48
|
+
)
|
|
49
|
+
return "\n".join(lines)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def build_release_context(tag_diff: str, tag_range: str) -> str:
|
|
53
|
+
lines: list[str] = [
|
|
54
|
+
f"Release range: {tag_range}",
|
|
55
|
+
"\n=== Task ===",
|
|
56
|
+
"Write structured release notes grouped by: Features, Bug Fixes, Documentation, Other."
|
|
57
|
+
" Use markdown.",
|
|
58
|
+
]
|
|
59
|
+
return "\n".join(lines)
|
|
File without changes
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from commitcraft.analysis.classifier import classify
|
|
2
|
+
from commitcraft.analysis.filters import filter_diff
|
|
3
|
+
from commitcraft.analysis.rule_engine import apply_rules
|
|
4
|
+
from commitcraft.config.models import CommitcraftConfig
|
|
5
|
+
from commitcraft.config.store import get_provider
|
|
6
|
+
from commitcraft.context.builder import build_commit_context
|
|
7
|
+
from commitcraft.git.diff_parser import parse_diff
|
|
8
|
+
from commitcraft.git.history import get_recent_commits
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def generate_commit_message(staged_diff: str, config: CommitcraftConfig) -> tuple[str, str]:
|
|
12
|
+
if not staged_diff.strip():
|
|
13
|
+
return "", "none"
|
|
14
|
+
|
|
15
|
+
parsed = parse_diff(staged_diff)
|
|
16
|
+
filtered = filter_diff(parsed, extra_patterns=config.ignore_patterns)
|
|
17
|
+
|
|
18
|
+
if not filtered.files:
|
|
19
|
+
return "chore: update generated/dependency files", "rule_engine"
|
|
20
|
+
|
|
21
|
+
rule_result = apply_rules(filtered)
|
|
22
|
+
if rule_result.matched:
|
|
23
|
+
return rule_result.message, "rule_engine"
|
|
24
|
+
|
|
25
|
+
classification = classify(filtered, threshold=config.complexity_threshold)
|
|
26
|
+
if classification.is_simple:
|
|
27
|
+
return "chore: minor changes", "rule_engine"
|
|
28
|
+
|
|
29
|
+
recent = get_recent_commits(n=5)
|
|
30
|
+
context = build_commit_context(filtered, recent)
|
|
31
|
+
provider = get_provider(config)
|
|
32
|
+
message = provider.generate_commit_message(context)
|
|
33
|
+
return message, provider.name
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from commitcraft.config.models import CommitcraftConfig
|
|
2
|
+
from commitcraft.config.store import get_provider
|
|
3
|
+
from commitcraft.context.builder import build_pr_context
|
|
4
|
+
from commitcraft.git.history import get_branch_commits, get_current_branch
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def generate_pr_description(config: CommitcraftConfig, base: str = "main") -> str:
|
|
8
|
+
branch = get_current_branch()
|
|
9
|
+
commits = get_branch_commits(base=base)
|
|
10
|
+
context = build_pr_context("", branch, commits)
|
|
11
|
+
provider = get_provider(config)
|
|
12
|
+
return provider.generate_pr_description(context)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import subprocess
|
|
2
|
+
|
|
3
|
+
from commitcraft.config.models import CommitcraftConfig
|
|
4
|
+
from commitcraft.config.store import get_provider
|
|
5
|
+
from commitcraft.context.builder import build_release_context
|
|
6
|
+
from commitcraft.git.diff_parser import get_tag_diff
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _get_commits_in_range(tag_range: str) -> list[str]:
|
|
10
|
+
try:
|
|
11
|
+
result = subprocess.run(
|
|
12
|
+
["git", "log", tag_range, "--pretty=format:%s"],
|
|
13
|
+
capture_output=True, text=True, check=True,
|
|
14
|
+
)
|
|
15
|
+
return [line.strip() for line in result.stdout.splitlines() if line.strip()]
|
|
16
|
+
except subprocess.CalledProcessError:
|
|
17
|
+
return []
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def generate_release_notes(tag_range: str, config: CommitcraftConfig) -> str:
|
|
21
|
+
commits = _get_commits_in_range(tag_range)
|
|
22
|
+
tag_diff = get_tag_diff(tag_range)
|
|
23
|
+
context = build_release_context(tag_diff, tag_range)
|
|
24
|
+
context += "\n\n=== Commits in range ===\n" + "\n".join(f" {c}" for c in commits)
|
|
25
|
+
provider = get_provider(config)
|
|
26
|
+
return provider.generate_release_notes(context)
|
|
File without changes
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import re
|
|
2
|
+
import subprocess
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
|
|
5
|
+
FUNCTION_PATTERNS = [
|
|
6
|
+
re.compile(r"^\+\s*(def |async def )\w+"), # Python
|
|
7
|
+
re.compile(r"^\+\s*(function |const \w+ = |let \w+ = |var \w+ = )\w*\s*[\(\{]"), # JS/TS
|
|
8
|
+
re.compile(r"^\+\s*(public|private|protected|static)?\s+\w+\s+\w+\s*\("), # Java/C#
|
|
9
|
+
re.compile(r"^\+\s*func \w+"), # Go
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
CLASS_PATTERNS = [
|
|
13
|
+
re.compile(r"^\+\s*class \w+"), # Python/JS/TS
|
|
14
|
+
re.compile(r"^\+\s*(public|private)?\s*class \w+"), # Java/C#
|
|
15
|
+
re.compile(r"^\+\s*type \w+ struct"), # Go
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
EXTENSION_TO_TYPE: dict[str, str] = {
|
|
19
|
+
".py": "python", ".js": "javascript", ".ts": "typescript",
|
|
20
|
+
".tsx": "typescript", ".jsx": "javascript", ".go": "go",
|
|
21
|
+
".rs": "rust", ".java": "java", ".cs": "csharp", ".rb": "ruby",
|
|
22
|
+
".php": "php", ".cpp": "cpp", ".c": "c", ".h": "c",
|
|
23
|
+
".md": "markdown", ".json": "json", ".yaml": "yaml", ".yml": "yaml",
|
|
24
|
+
".toml": "toml", ".sh": "shell", ".bash": "shell",
|
|
25
|
+
".html": "html", ".css": "css", ".scss": "css",
|
|
26
|
+
".txt": "text", ".lock": "lock",
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class DiffFile:
|
|
32
|
+
path: str
|
|
33
|
+
file_type: str
|
|
34
|
+
is_new: bool
|
|
35
|
+
is_deleted: bool
|
|
36
|
+
added_lines: int
|
|
37
|
+
removed_lines: int
|
|
38
|
+
raw_hunks: str
|
|
39
|
+
has_function_changes: bool
|
|
40
|
+
has_class_changes: bool
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class ParsedDiff:
|
|
45
|
+
files: list[DiffFile] = field(default_factory=list)
|
|
46
|
+
total_added: int = 0
|
|
47
|
+
total_removed: int = 0
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _file_type(path: str) -> str:
|
|
51
|
+
from pathlib import Path
|
|
52
|
+
suffix = Path(path).suffix.lower()
|
|
53
|
+
return EXTENSION_TO_TYPE.get(suffix, "unknown")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _detect_changes(hunk_text: str) -> tuple[bool, bool]:
|
|
57
|
+
has_func = any(p.search(line) for line in hunk_text.splitlines() for p in FUNCTION_PATTERNS)
|
|
58
|
+
has_class = any(p.search(line) for line in hunk_text.splitlines() for p in CLASS_PATTERNS)
|
|
59
|
+
return has_func, has_class
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def parse_diff(raw: str) -> ParsedDiff:
|
|
63
|
+
if not raw.strip():
|
|
64
|
+
return ParsedDiff()
|
|
65
|
+
|
|
66
|
+
result = ParsedDiff()
|
|
67
|
+
file_blocks = re.split(r"(?=^diff --git )", raw, flags=re.MULTILINE)
|
|
68
|
+
|
|
69
|
+
for block in file_blocks:
|
|
70
|
+
if not block.startswith("diff --git "):
|
|
71
|
+
continue
|
|
72
|
+
|
|
73
|
+
header_match = re.match(r"diff --git a/(.+?) b/(.+)", block)
|
|
74
|
+
if not header_match:
|
|
75
|
+
continue
|
|
76
|
+
|
|
77
|
+
path = header_match.group(2)
|
|
78
|
+
is_new = bool(
|
|
79
|
+
re.search(r"^new file mode", block, re.MULTILINE)
|
|
80
|
+
or re.search(r"^--- /dev/null", block, re.MULTILINE)
|
|
81
|
+
)
|
|
82
|
+
is_deleted = bool(re.search(r"^deleted file mode", block, re.MULTILINE))
|
|
83
|
+
|
|
84
|
+
added = len(re.findall(r"^\+(?!\+\+)", block, re.MULTILINE))
|
|
85
|
+
removed = len(re.findall(r"^-(?!--)", block, re.MULTILINE))
|
|
86
|
+
|
|
87
|
+
hunk_text = "\n".join(
|
|
88
|
+
line for line in block.splitlines()
|
|
89
|
+
if line.startswith("+") or line.startswith("-")
|
|
90
|
+
)
|
|
91
|
+
has_func, has_class = _detect_changes(hunk_text)
|
|
92
|
+
|
|
93
|
+
diff_file = DiffFile(
|
|
94
|
+
path=path,
|
|
95
|
+
file_type=_file_type(path),
|
|
96
|
+
is_new=is_new,
|
|
97
|
+
is_deleted=is_deleted,
|
|
98
|
+
added_lines=added,
|
|
99
|
+
removed_lines=removed,
|
|
100
|
+
raw_hunks=block,
|
|
101
|
+
has_function_changes=has_func,
|
|
102
|
+
has_class_changes=has_class,
|
|
103
|
+
)
|
|
104
|
+
result.files.append(diff_file)
|
|
105
|
+
result.total_added += added
|
|
106
|
+
result.total_removed += removed
|
|
107
|
+
|
|
108
|
+
return result
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def get_staged_diff() -> str:
|
|
112
|
+
result = subprocess.run(
|
|
113
|
+
["git", "diff", "--cached"],
|
|
114
|
+
capture_output=True, text=True, check=True,
|
|
115
|
+
)
|
|
116
|
+
return result.stdout
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def get_branch_diff(base: str = "main") -> str:
|
|
120
|
+
result = subprocess.run(
|
|
121
|
+
["git", "diff", f"{base}...HEAD"],
|
|
122
|
+
capture_output=True, text=True, check=True,
|
|
123
|
+
)
|
|
124
|
+
return result.stdout
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def get_tag_diff(tag_range: str) -> str:
|
|
128
|
+
result = subprocess.run(
|
|
129
|
+
["git", "diff", tag_range],
|
|
130
|
+
capture_output=True, text=True, check=True,
|
|
131
|
+
)
|
|
132
|
+
return result.stdout
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import re
|
|
2
|
+
import subprocess
|
|
3
|
+
from collections import Counter
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def get_recent_commits(n: int = 10) -> list[str]:
|
|
7
|
+
try:
|
|
8
|
+
result = subprocess.run(
|
|
9
|
+
["git", "log", f"--max-count={n}", "--pretty=format:%s"],
|
|
10
|
+
capture_output=True, text=True, check=True,
|
|
11
|
+
)
|
|
12
|
+
return [line.strip() for line in result.stdout.splitlines() if line.strip()]
|
|
13
|
+
except subprocess.CalledProcessError:
|
|
14
|
+
return []
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def get_branch_commits(base: str = "main", n: int = 20) -> list[str]:
|
|
18
|
+
try:
|
|
19
|
+
result = subprocess.run(
|
|
20
|
+
["git", "log", f"{base}...HEAD", f"--max-count={n}", "--pretty=format:%s"],
|
|
21
|
+
capture_output=True, text=True, check=True,
|
|
22
|
+
)
|
|
23
|
+
return [line.strip() for line in result.stdout.splitlines() if line.strip()]
|
|
24
|
+
except subprocess.CalledProcessError:
|
|
25
|
+
return []
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def get_current_branch() -> str:
|
|
29
|
+
try:
|
|
30
|
+
result = subprocess.run(
|
|
31
|
+
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
|
32
|
+
capture_output=True, text=True, check=True,
|
|
33
|
+
)
|
|
34
|
+
return result.stdout.strip()
|
|
35
|
+
except subprocess.CalledProcessError:
|
|
36
|
+
return "HEAD"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def analyze_commit_patterns(commits: list[str]) -> dict[str, int]:
|
|
40
|
+
types = []
|
|
41
|
+
for msg in commits:
|
|
42
|
+
m = re.match(r"^(\w+)[\(:]", msg)
|
|
43
|
+
if m:
|
|
44
|
+
types.append(m.group(1))
|
|
45
|
+
return dict(Counter(types))
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
_GENERIC_PATTERNS = [
|
|
49
|
+
re.compile(r"^initial commit$", re.IGNORECASE),
|
|
50
|
+
re.compile(r"^wip$", re.IGNORECASE),
|
|
51
|
+
re.compile(r"^fix$", re.IGNORECASE),
|
|
52
|
+
re.compile(r"^update$", re.IGNORECASE),
|
|
53
|
+
re.compile(r"^changes?$", re.IGNORECASE),
|
|
54
|
+
re.compile(r"^misc$", re.IGNORECASE),
|
|
55
|
+
re.compile(r"^temp$", re.IGNORECASE),
|
|
56
|
+
re.compile(r"^\.+$"),
|
|
57
|
+
re.compile(r"^commit \d+$", re.IGNORECASE),
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def detect_generic_commits(commits: list[str]) -> list[str]:
|
|
62
|
+
return [c for c in commits if any(p.match(c.strip()) for p in _GENERIC_PATTERNS)]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def get_all_commits(n: int = 100) -> list[str]:
|
|
66
|
+
try:
|
|
67
|
+
result = subprocess.run(
|
|
68
|
+
["git", "log", f"--max-count={n}", "--pretty=format:%s"],
|
|
69
|
+
capture_output=True, text=True, check=True,
|
|
70
|
+
)
|
|
71
|
+
return [line.strip() for line in result.stdout.splitlines() if line.strip()]
|
|
72
|
+
except subprocess.CalledProcessError:
|
|
73
|
+
return []
|
|
File without changes
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import anthropic
|
|
2
|
+
|
|
3
|
+
from commitcraft.config.models import CommitcraftConfig
|
|
4
|
+
from commitcraft.providers.base import Provider
|
|
5
|
+
from commitcraft.providers.ollama_provider import _COMMIT_SYSTEM, _PR_SYSTEM, _RELEASE_SYSTEM
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class AnthropicProvider(Provider):
|
|
9
|
+
def __init__(self, config: CommitcraftConfig) -> None:
|
|
10
|
+
self._config = config
|
|
11
|
+
self._client = anthropic.Anthropic(api_key=config.anthropic_api_key)
|
|
12
|
+
self._model = config.anthropic_model
|
|
13
|
+
|
|
14
|
+
@property
|
|
15
|
+
def name(self) -> str:
|
|
16
|
+
return "anthropic"
|
|
17
|
+
|
|
18
|
+
def _generate(self, system: str, prompt: str) -> str:
|
|
19
|
+
message = self._client.messages.create(
|
|
20
|
+
model=self._model,
|
|
21
|
+
max_tokens=512,
|
|
22
|
+
system=system,
|
|
23
|
+
messages=[{"role": "user", "content": prompt}],
|
|
24
|
+
)
|
|
25
|
+
return message.content[0].text.strip()
|
|
26
|
+
|
|
27
|
+
def generate_commit_message(self, context: str) -> str:
|
|
28
|
+
return self._generate(_COMMIT_SYSTEM, context)
|
|
29
|
+
|
|
30
|
+
def generate_pr_description(self, context: str) -> str:
|
|
31
|
+
return self._generate(_PR_SYSTEM, context)
|
|
32
|
+
|
|
33
|
+
def generate_release_notes(self, context: str) -> str:
|
|
34
|
+
return self._generate(_RELEASE_SYSTEM, context)
|
|
35
|
+
|
|
36
|
+
def health_check(self) -> bool:
|
|
37
|
+
try:
|
|
38
|
+
self._client.messages.create(
|
|
39
|
+
model=self._model, max_tokens=1,
|
|
40
|
+
messages=[{"role": "user", "content": "ping"}],
|
|
41
|
+
)
|
|
42
|
+
return True
|
|
43
|
+
except Exception:
|
|
44
|
+
return False
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class Provider(ABC):
|
|
5
|
+
@property
|
|
6
|
+
@abstractmethod
|
|
7
|
+
def name(self) -> str: ...
|
|
8
|
+
|
|
9
|
+
@abstractmethod
|
|
10
|
+
def generate_commit_message(self, context: str) -> str: ...
|
|
11
|
+
|
|
12
|
+
@abstractmethod
|
|
13
|
+
def generate_pr_description(self, context: str) -> str: ...
|
|
14
|
+
|
|
15
|
+
@abstractmethod
|
|
16
|
+
def generate_release_notes(self, context: str) -> str: ...
|
|
17
|
+
|
|
18
|
+
@abstractmethod
|
|
19
|
+
def health_check(self) -> bool: ...
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import google.generativeai as genai
|
|
2
|
+
|
|
3
|
+
from commitcraft.config.models import CommitcraftConfig
|
|
4
|
+
from commitcraft.providers.base import Provider
|
|
5
|
+
from commitcraft.providers.ollama_provider import _COMMIT_SYSTEM, _PR_SYSTEM, _RELEASE_SYSTEM
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class GeminiProvider(Provider):
|
|
9
|
+
def __init__(self, config: CommitcraftConfig) -> None:
|
|
10
|
+
self._config = config
|
|
11
|
+
genai.configure(api_key=config.gemini_api_key)
|
|
12
|
+
self._model_name = config.gemini_model
|
|
13
|
+
|
|
14
|
+
@property
|
|
15
|
+
def name(self) -> str:
|
|
16
|
+
return "gemini"
|
|
17
|
+
|
|
18
|
+
def _generate(self, system: str, prompt: str) -> str:
|
|
19
|
+
model = genai.GenerativeModel(
|
|
20
|
+
model_name=self._model_name,
|
|
21
|
+
system_instruction=system,
|
|
22
|
+
)
|
|
23
|
+
response = model.generate_content(prompt)
|
|
24
|
+
return response.text.strip()
|
|
25
|
+
|
|
26
|
+
def generate_commit_message(self, context: str) -> str:
|
|
27
|
+
return self._generate(_COMMIT_SYSTEM, context)
|
|
28
|
+
|
|
29
|
+
def generate_pr_description(self, context: str) -> str:
|
|
30
|
+
return self._generate(_PR_SYSTEM, context)
|
|
31
|
+
|
|
32
|
+
def generate_release_notes(self, context: str) -> str:
|
|
33
|
+
return self._generate(_RELEASE_SYSTEM, context)
|
|
34
|
+
|
|
35
|
+
def health_check(self) -> bool:
|
|
36
|
+
try:
|
|
37
|
+
genai.list_models()
|
|
38
|
+
return True
|
|
39
|
+
except Exception:
|
|
40
|
+
return False
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import httpx
|
|
2
|
+
|
|
3
|
+
from commitcraft.config.models import CommitcraftConfig
|
|
4
|
+
from commitcraft.providers.base import Provider
|
|
5
|
+
|
|
6
|
+
_COMMIT_SYSTEM = (
|
|
7
|
+
"You are an expert at writing conventional commit messages. "
|
|
8
|
+
"Given a summary of code changes, write a single conventional commit message. "
|
|
9
|
+
"Format: <type>(<optional scope>): <description>. "
|
|
10
|
+
"Types: feat, fix, docs, style, refactor, test, chore, perf, ci, build. "
|
|
11
|
+
"Output ONLY the commit message — no explanation, no markdown, no quotes."
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
_PR_SYSTEM = (
|
|
15
|
+
"You are an expert at writing GitHub pull request descriptions. "
|
|
16
|
+
"Given a summary of code changes, write a clear PR description in markdown with: "
|
|
17
|
+
"a '## Summary' section (3-5 bullet points) and a '## Changes' section. "
|
|
18
|
+
"Be concise and factual."
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
_RELEASE_SYSTEM = (
|
|
22
|
+
"You are an expert at writing software release notes. "
|
|
23
|
+
"Given a list of commits or change summaries, produce structured release notes "
|
|
24
|
+
"grouped by: Features, Bug Fixes, Documentation, Other. Use markdown."
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class OllamaProvider(Provider):
|
|
29
|
+
def __init__(self, config: CommitcraftConfig) -> None:
|
|
30
|
+
self._config = config
|
|
31
|
+
self._base_url = config.ollama_base_url
|
|
32
|
+
self._model = config.ollama_model
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def name(self) -> str:
|
|
36
|
+
return "ollama"
|
|
37
|
+
|
|
38
|
+
def _generate(self, system: str, prompt: str) -> str:
|
|
39
|
+
response = httpx.post(
|
|
40
|
+
f"{self._base_url}/api/generate",
|
|
41
|
+
json={"model": self._model, "system": system, "prompt": prompt, "stream": False},
|
|
42
|
+
timeout=60.0,
|
|
43
|
+
)
|
|
44
|
+
response.raise_for_status()
|
|
45
|
+
return response.json()["response"].strip()
|
|
46
|
+
|
|
47
|
+
def generate_commit_message(self, context: str) -> str:
|
|
48
|
+
return self._generate(_COMMIT_SYSTEM, context)
|
|
49
|
+
|
|
50
|
+
def generate_pr_description(self, context: str) -> str:
|
|
51
|
+
return self._generate(_PR_SYSTEM, context)
|
|
52
|
+
|
|
53
|
+
def generate_release_notes(self, context: str) -> str:
|
|
54
|
+
return self._generate(_RELEASE_SYSTEM, context)
|
|
55
|
+
|
|
56
|
+
def health_check(self) -> bool:
|
|
57
|
+
try:
|
|
58
|
+
httpx.get(f"{self._base_url}/api/tags", timeout=5.0).raise_for_status()
|
|
59
|
+
return True
|
|
60
|
+
except Exception:
|
|
61
|
+
return False
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
from openai import OpenAI
|
|
2
|
+
|
|
3
|
+
from commitcraft.config.models import CommitcraftConfig
|
|
4
|
+
from commitcraft.providers.base import Provider
|
|
5
|
+
from commitcraft.providers.ollama_provider import _COMMIT_SYSTEM, _PR_SYSTEM, _RELEASE_SYSTEM
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class OpenAIProvider(Provider):
|
|
9
|
+
def __init__(self, config: CommitcraftConfig) -> None:
|
|
10
|
+
self._config = config
|
|
11
|
+
self._client = OpenAI(api_key=config.openai_api_key)
|
|
12
|
+
self._model = config.openai_model
|
|
13
|
+
|
|
14
|
+
@property
|
|
15
|
+
def name(self) -> str:
|
|
16
|
+
return "openai"
|
|
17
|
+
|
|
18
|
+
def _generate(self, system: str, prompt: str) -> str:
|
|
19
|
+
response = self._client.chat.completions.create(
|
|
20
|
+
model=self._model,
|
|
21
|
+
messages=[
|
|
22
|
+
{"role": "system", "content": system},
|
|
23
|
+
{"role": "user", "content": prompt},
|
|
24
|
+
],
|
|
25
|
+
max_tokens=512,
|
|
26
|
+
)
|
|
27
|
+
return (response.choices[0].message.content or "").strip()
|
|
28
|
+
|
|
29
|
+
def generate_commit_message(self, context: str) -> str:
|
|
30
|
+
return self._generate(_COMMIT_SYSTEM, context)
|
|
31
|
+
|
|
32
|
+
def generate_pr_description(self, context: str) -> str:
|
|
33
|
+
return self._generate(_PR_SYSTEM, context)
|
|
34
|
+
|
|
35
|
+
def generate_release_notes(self, context: str) -> str:
|
|
36
|
+
return self._generate(_RELEASE_SYSTEM, context)
|
|
37
|
+
|
|
38
|
+
def health_check(self) -> bool:
|
|
39
|
+
try:
|
|
40
|
+
self._client.models.list()
|
|
41
|
+
return True
|
|
42
|
+
except Exception:
|
|
43
|
+
return False
|
|
File without changes
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: commitcraft-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Smart conventional commit messages with a rule engine + minimal-context LLM
|
|
5
|
+
License: MIT
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Requires-Python: >=3.11
|
|
8
|
+
Requires-Dist: anthropic>=0.30.0
|
|
9
|
+
Requires-Dist: google-generativeai>=0.7.0
|
|
10
|
+
Requires-Dist: httpx>=0.27.0
|
|
11
|
+
Requires-Dist: openai>=1.0.0
|
|
12
|
+
Requires-Dist: pydantic>=2.0.0
|
|
13
|
+
Requires-Dist: pyyaml>=6.0.0
|
|
14
|
+
Requires-Dist: rich>=13.0.0
|
|
15
|
+
Requires-Dist: typer>=0.12.0
|
|
16
|
+
Provides-Extra: dev
|
|
17
|
+
Requires-Dist: pytest-cov>=5.0.0; extra == 'dev'
|
|
18
|
+
Requires-Dist: pytest>=8.0.0; extra == 'dev'
|
|
19
|
+
Requires-Dist: ruff>=0.4.0; extra == 'dev'
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
# commitcraft
|
|
23
|
+
|
|
24
|
+
> Smart conventional commit messages — a rule engine decides when AI is even needed.
|
|
25
|
+
|
|
26
|
+
[](https://github.com/mavesensei/commitcraft/actions)
|
|
27
|
+
[](LICENSE)
|
|
28
|
+
|
|
29
|
+
## What makes it different
|
|
30
|
+
|
|
31
|
+
Most "AI commit message" tools blindly send your entire diff to an LLM. commitcraft doesn't.
|
|
32
|
+
|
|
33
|
+
It first runs a **local rule engine** on your diff. README-only change? Lockfile bump? Single test file? Those get a conventional commit message generated instantly, locally, with zero AI cost. Only when changes are genuinely complex does it reach out to an LLM — and even then it sends a condensed summary, not the full diff.
|
|
34
|
+
|
|
35
|
+
**The story isn't "I used AI." It's "I built a system that decides when AI should be used."**
|
|
36
|
+
|
|
37
|
+
## Pipeline
|
|
38
|
+
|
|
39
|
+
```
|
|
40
|
+
git diff (staged)
|
|
41
|
+
│
|
|
42
|
+
▼
|
|
43
|
+
Parser → extract changed files, line counts, file types
|
|
44
|
+
│
|
|
45
|
+
▼
|
|
46
|
+
Filter → strip lockfiles, dist/, build/, *.min.js, etc.
|
|
47
|
+
│
|
|
48
|
+
▼
|
|
49
|
+
Rule Engine → simple change? generate commit locally (zero cost)
|
|
50
|
+
│ e.g. README-only → "docs: update README"
|
|
51
|
+
│ lockfile-only → "chore: update lockfile"
|
|
52
|
+
│
|
|
53
|
+
▼ (complex change only)
|
|
54
|
+
Classifier → complexity score 0-100 from:
|
|
55
|
+
• file count and directory spread
|
|
56
|
+
• function/class definition changes (regex)
|
|
57
|
+
• test files alongside source files
|
|
58
|
+
• total line count
|
|
59
|
+
│
|
|
60
|
+
▼
|
|
61
|
+
Context Builder → condensed summary (not the full diff)
|
|
62
|
+
│ ~100-500 tokens instead of thousands
|
|
63
|
+
▼
|
|
64
|
+
Your chosen Provider (Ollama / OpenAI / Gemini / Anthropic)
|
|
65
|
+
│
|
|
66
|
+
▼
|
|
67
|
+
Conventional commit message
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## Installation
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
pip install commitcraft
|
|
74
|
+
commitcraft init # one-time setup: choose your provider
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Quickstart
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
# Make some changes, then:
|
|
81
|
+
commitcraft commit
|
|
82
|
+
|
|
83
|
+
# Generate a PR description for the current branch:
|
|
84
|
+
commitcraft pr
|
|
85
|
+
|
|
86
|
+
# Generate release notes between two tags:
|
|
87
|
+
commitcraft release-notes v1.0.0..v1.1.0
|
|
88
|
+
|
|
89
|
+
# Analyze your commit history:
|
|
90
|
+
commitcraft history
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## Provider setup
|
|
94
|
+
|
|
95
|
+
On first run (`commitcraft init`), you choose your provider:
|
|
96
|
+
|
|
97
|
+
| Provider | Cost | Requires |
|
|
98
|
+
|---|---|---|
|
|
99
|
+
| **Ollama** | Free (local) | Ollama installed + a model pulled |
|
|
100
|
+
| **OpenAI** | Your API key | `openai_api_key` in config |
|
|
101
|
+
| **Anthropic Claude** | Your API key | `anthropic_api_key` in config |
|
|
102
|
+
| **Google Gemini** | Your API key | `gemini_api_key` in config |
|
|
103
|
+
|
|
104
|
+
Your API key is stored locally at `~/.commitcraft/config.yaml`. It never leaves your machine except to call your chosen provider directly.
|
|
105
|
+
|
|
106
|
+
## Contributing
|
|
107
|
+
|
|
108
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md) and [docs/adding_a_provider.md](docs/adding_a_provider.md).
|
|
109
|
+
|
|
110
|
+
## License
|
|
111
|
+
|
|
112
|
+
MIT © mavesensei
|
|
113
|
+
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
commitcraft/__init__.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
|
|
2
|
+
commitcraft/cli.py,sha256=XksgFlnwMfARFuPrDyocxmXwg8hIt44oowGCy6qR1N0,5959
|
|
3
|
+
commitcraft/analysis/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
4
|
+
commitcraft/analysis/classifier.py,sha256=OBTsHbiYsa0_sKY2DtU2ZszvCmqnaD2Ypc_M1ozs9WQ,2062
|
|
5
|
+
commitcraft/analysis/filters.py,sha256=xBOnRlLqEehGpus36EAlegJXQiF7CBQWCAdtVAtgg9Y,1676
|
|
6
|
+
commitcraft/analysis/rule_engine.py,sha256=vW8N26iUjhK1ZcQSF2AxGuPaK23ZXUBWbPc-Wr_LrwY,3184
|
|
7
|
+
commitcraft/config/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
commitcraft/config/models.py,sha256=e9pIh7auLf4cbskmhcAEgGzKEXrDkXTdzlVlj4W5Iig,768
|
|
9
|
+
commitcraft/config/store.py,sha256=VeJPu6QHmmWxmdy2NGwMO9hJFleD1ABL388aJf4N-58,1533
|
|
10
|
+
commitcraft/config/wizard.py,sha256=misy4ajxQ5nHLisPdmXQi0SoXmOMkxS608RVUlKDco8,1981
|
|
11
|
+
commitcraft/context/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
12
|
+
commitcraft/context/builder.py,sha256=gJN_d5hwZ3GvP5rCQfU40qBVz6O41kG1R4DlprrwUFE,2043
|
|
13
|
+
commitcraft/generators/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
14
|
+
commitcraft/generators/commit.py,sha256=2i4vrCNRArvooOdXbw45MII3chLB7H_tmMt6QT7yJxw,1304
|
|
15
|
+
commitcraft/generators/pr.py,sha256=vz3opiaT9NaaZ-Iigfvo9SBI9Ad9LZ6Ab5Hr1tgut9U,542
|
|
16
|
+
commitcraft/generators/release_notes.py,sha256=-nPhJUeJveNz-wCl_JCjzNq7QZcf4yWrG5RdhZ9JX-M,1010
|
|
17
|
+
commitcraft/git/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
18
|
+
commitcraft/git/diff_parser.py,sha256=cw1gejJty7J2MsMGJPRLuGMvgoKa_M3XS0GrFeTCLAQ,4018
|
|
19
|
+
commitcraft/git/history.py,sha256=PPNv7lASAoiYwjAD3wIVCy_3cPZnYF2XS0TOW06itD0,2313
|
|
20
|
+
commitcraft/providers/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
21
|
+
commitcraft/providers/anthropic_provider.py,sha256=rKg1e3TC2oqUuYpaw27nwmnFhXjKKKIt65tYJRSSLPE,1481
|
|
22
|
+
commitcraft/providers/base.py,sha256=Axnq_fd1SgvBvX2z0FAQyeKWfncfvd_mDWO4P4kQC9U,439
|
|
23
|
+
commitcraft/providers/gemini_provider.py,sha256=uELZK81MQzkNAsFdev5kxqCwWcrb66vAoNcCRAk2YMs,1302
|
|
24
|
+
commitcraft/providers/ollama_provider.py,sha256=hNzuqzXsUhXn3CCKzZWlX0goXMPPLP6dF1WxkjAT1aw,2216
|
|
25
|
+
commitcraft/providers/openai_provider.py,sha256=yLt4w3lhRwsmhiGu7ci19V6ZNU8aMzEisWMVY5hkAzU,1423
|
|
26
|
+
commitcraft/utils/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
27
|
+
commitcraft/utils/token_estimator.py,sha256=VCL6Vl2sAnZFTlEQz5KeBXWAM9HTaOT3w93aFgU8KCU,149
|
|
28
|
+
commitcraft_cli-0.1.0.dist-info/METADATA,sha256=XuqbytEOM-oXYOfrmyU7t3F0CXwxPBv8HKyzcCB5_PE,3505
|
|
29
|
+
commitcraft_cli-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
30
|
+
commitcraft_cli-0.1.0.dist-info/entry_points.txt,sha256=b-w1XlpnrfOwWJ8P5YHtyWQjTeFZsFt8pIh23iKz8nY,52
|
|
31
|
+
commitcraft_cli-0.1.0.dist-info/licenses/LICENSE,sha256=HeWpEYKYnDAM0XJEdR6q2HU2XjfKW4VFkunJLLEv7yY,1067
|
|
32
|
+
commitcraft_cli-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 mavesensei
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|