smart-commit-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.
- smart_commit/__init__.py +6 -0
- smart_commit/ai/__init__.py +7 -0
- smart_commit/ai/client.py +123 -0
- smart_commit/ai/reviewer.py +91 -0
- smart_commit/ai.py +136 -0
- smart_commit/analysis/__init__.py +7 -0
- smart_commit/analysis/ast_parser.py +173 -0
- smart_commit/analysis/dependency_graph.py +114 -0
- smart_commit/analysis/diff_parser.py +172 -0
- smart_commit/analysis/summarizer.py +101 -0
- smart_commit/analysis/symbol_extractor.py +88 -0
- smart_commit/benchmark.py +88 -0
- smart_commit/cache.py +65 -0
- smart_commit/cli.py +391 -0
- smart_commit/clustering/__init__.py +7 -0
- smart_commit/clustering/graph_clustering.py +78 -0
- smart_commit/clustering/heuristic.py +178 -0
- smart_commit/clustering/semantic.py +201 -0
- smart_commit/clustering.py +186 -0
- smart_commit/commit_messages.py +232 -0
- smart_commit/committer.py +91 -0
- smart_commit/config.py +88 -0
- smart_commit/constants.py +92 -0
- smart_commit/diff_parser.py +158 -0
- smart_commit/embeddings/__init__.py +7 -0
- smart_commit/embeddings/generator.py +128 -0
- smart_commit/embeddings/similarity.py +159 -0
- smart_commit/embeddings.py +108 -0
- smart_commit/exceptions.py +102 -0
- smart_commit/execution/__init__.py +6 -0
- smart_commit/execution/committer.py +102 -0
- smart_commit/git/__init__.py +8 -0
- smart_commit/git/safety.py +75 -0
- smart_commit/git/scanner.py +182 -0
- smart_commit/git/service.py +129 -0
- smart_commit/git_service.py +302 -0
- smart_commit/logger.py +99 -0
- smart_commit/models.py +73 -0
- smart_commit/plugins/__init__.py +6 -0
- smart_commit/plugins/base.py +135 -0
- smart_commit/plugins/django.py +40 -0
- smart_commit/plugins/fastapi.py +39 -0
- smart_commit/plugins/react.py +49 -0
- smart_commit/preview/__init__.py +6 -0
- smart_commit/preview/editor.py +224 -0
- smart_commit/preview/renderer.py +94 -0
- smart_commit/preview.py +131 -0
- smart_commit/summarizer.py +93 -0
- smart_commit/utils.py +83 -0
- smart_commit_cli-0.1.0.dist-info/METADATA +804 -0
- smart_commit_cli-0.1.0.dist-info/RECORD +53 -0
- smart_commit_cli-0.1.0.dist-info/WHEEL +4 -0
- smart_commit_cli-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
from typing import List, Optional
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from ..models import FileDiff, CommitCluster
|
|
6
|
+
from ..embeddings import EmbeddingsGenerator, SimilarityGraph
|
|
7
|
+
from ..analysis.dependency_graph import DependencyGraph
|
|
8
|
+
from ..logger import get_logger
|
|
9
|
+
from ..constants import DEFAULT_SIMILARITY_THRESHOLD, MAX_CLUSTER_SIZE
|
|
10
|
+
from .heuristic import HeuristicClustering
|
|
11
|
+
from .graph_clustering import GraphClustering
|
|
12
|
+
|
|
13
|
+
logger = get_logger("clustering.semantic")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class SemanticClustering:
|
|
17
|
+
"""Clusters files using semantic similarity and graph clustering."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, threshold: float = DEFAULT_SIMILARITY_THRESHOLD):
|
|
20
|
+
"""Initialize semantic clustering."""
|
|
21
|
+
self.threshold = threshold
|
|
22
|
+
self.embeddings_gen = None
|
|
23
|
+
self.similarity_graph = None
|
|
24
|
+
self.graph_clustering = GraphClustering()
|
|
25
|
+
self.dep_graph = None
|
|
26
|
+
try:
|
|
27
|
+
self.embeddings_gen = EmbeddingsGenerator()
|
|
28
|
+
except Exception as exc:
|
|
29
|
+
logger.warning("Embeddings unavailable: %s", exc)
|
|
30
|
+
|
|
31
|
+
def cluster_files(self, file_diffs: list[FileDiff], repo_path: Optional[Path] = None) -> list[CommitCluster]:
|
|
32
|
+
"""Cluster files using semantic and graph-based clustering."""
|
|
33
|
+
if len(file_diffs) <= 1:
|
|
34
|
+
return [
|
|
35
|
+
CommitCluster(
|
|
36
|
+
id="cluster_0",
|
|
37
|
+
files=file_diffs,
|
|
38
|
+
confidence=0.99,
|
|
39
|
+
reason="Single file commit.",
|
|
40
|
+
)
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
# Init dep graph if possible
|
|
44
|
+
if repo_path and not self.dep_graph:
|
|
45
|
+
self.dep_graph = DependencyGraph(repo_path)
|
|
46
|
+
|
|
47
|
+
if not self.similarity_graph and self.embeddings_gen:
|
|
48
|
+
self.similarity_graph = SimilarityGraph(self.embeddings_gen, self.dep_graph)
|
|
49
|
+
|
|
50
|
+
if not self.embeddings_gen or not self.similarity_graph:
|
|
51
|
+
return HeuristicClustering().cluster_files(file_diffs, MAX_CLUSTER_SIZE)
|
|
52
|
+
|
|
53
|
+
# Build similarity matrix
|
|
54
|
+
logger.info("Building similarity graph...")
|
|
55
|
+
similarity_matrix = self.similarity_graph.build_graph(file_diffs)
|
|
56
|
+
|
|
57
|
+
# Generate embeddings
|
|
58
|
+
logger.info("Generating embeddings...")
|
|
59
|
+
embeddings = []
|
|
60
|
+
for file_diff in file_diffs:
|
|
61
|
+
embedding = self.embeddings_gen.generate_file_embedding(file_diff)
|
|
62
|
+
embeddings.append(embedding)
|
|
63
|
+
|
|
64
|
+
# Perform clustering
|
|
65
|
+
logger.info("Clustering files...")
|
|
66
|
+
|
|
67
|
+
# Select strategy: graph for large, agglomerative for small
|
|
68
|
+
if self.graph_clustering.is_available() and len(file_diffs) > 15:
|
|
69
|
+
logger.info("Using Graph Clustering (Louvain)")
|
|
70
|
+
cluster_labels = self.graph_clustering.cluster_files(similarity_matrix, file_diffs)
|
|
71
|
+
else:
|
|
72
|
+
try:
|
|
73
|
+
cluster_labels = self._agglomerative_clustering(similarity_matrix, file_diffs)
|
|
74
|
+
except Exception as e:
|
|
75
|
+
logger.warning(f"Agglomerative clustering failed: {e}, falling back")
|
|
76
|
+
cluster_labels = list(range(len(file_diffs)))
|
|
77
|
+
|
|
78
|
+
# Group by cluster
|
|
79
|
+
clusters_dict = {}
|
|
80
|
+
for idx, label in enumerate(cluster_labels):
|
|
81
|
+
if label not in clusters_dict:
|
|
82
|
+
clusters_dict[label] = []
|
|
83
|
+
clusters_dict[label].append(file_diffs[idx])
|
|
84
|
+
|
|
85
|
+
# Create clusters with embeddings
|
|
86
|
+
clusters = []
|
|
87
|
+
for cluster_idx, (label, files) in enumerate(sorted(clusters_dict.items())):
|
|
88
|
+
valid_embeddings = [e for e in [embeddings[file_diffs.index(f)] for f in files] if e]
|
|
89
|
+
cluster_embedding = np.mean(valid_embeddings, axis=0).tolist() if valid_embeddings else None
|
|
90
|
+
|
|
91
|
+
reason = self._generate_reason(files, similarity_matrix, file_diffs)
|
|
92
|
+
cluster = CommitCluster(
|
|
93
|
+
id=f"cluster_{cluster_idx}",
|
|
94
|
+
files=files,
|
|
95
|
+
embedding=cluster_embedding,
|
|
96
|
+
confidence=self._cluster_confidence(files, similarity_matrix, file_diffs),
|
|
97
|
+
reason=reason,
|
|
98
|
+
)
|
|
99
|
+
clusters.append(cluster)
|
|
100
|
+
|
|
101
|
+
logger.info(f"Created {len(clusters)} clusters from {len(file_diffs)} files")
|
|
102
|
+
return clusters
|
|
103
|
+
|
|
104
|
+
def _agglomerative_clustering(self, similarity_matrix: np.ndarray,
|
|
105
|
+
file_diffs: list[FileDiff]) -> list[int]:
|
|
106
|
+
"""Perform agglomerative clustering."""
|
|
107
|
+
try:
|
|
108
|
+
from sklearn.cluster import AgglomerativeClustering
|
|
109
|
+
distance_matrix = 1.0 - similarity_matrix
|
|
110
|
+
n_clusters = max(1, len(file_diffs) // 3)
|
|
111
|
+
|
|
112
|
+
clustering = AgglomerativeClustering(
|
|
113
|
+
n_clusters=n_clusters,
|
|
114
|
+
linkage='average',
|
|
115
|
+
metric='precomputed'
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
labels = clustering.fit_predict(distance_matrix)
|
|
119
|
+
return labels.tolist()
|
|
120
|
+
|
|
121
|
+
except ImportError:
|
|
122
|
+
logger.warning("scikit-learn not available, using greedy clustering")
|
|
123
|
+
return self._greedy_clustering(similarity_matrix)
|
|
124
|
+
|
|
125
|
+
@staticmethod
|
|
126
|
+
def _greedy_clustering(similarity_matrix: np.ndarray) -> list[int]:
|
|
127
|
+
"""Greedy clustering fallback."""
|
|
128
|
+
n = len(similarity_matrix)
|
|
129
|
+
labels = list(range(n))
|
|
130
|
+
|
|
131
|
+
for i in range(n):
|
|
132
|
+
for j in range(i + 1, n):
|
|
133
|
+
if similarity_matrix[i][j] > 0.6:
|
|
134
|
+
if labels[j] != labels[i]:
|
|
135
|
+
old_label = labels[j]
|
|
136
|
+
new_label = labels[i]
|
|
137
|
+
labels = [new_label if x == old_label else x for x in labels]
|
|
138
|
+
|
|
139
|
+
unique_labels = list(set(labels))
|
|
140
|
+
label_map = {old: new for new, old in enumerate(unique_labels)}
|
|
141
|
+
return [label_map[x] for x in labels]
|
|
142
|
+
|
|
143
|
+
@staticmethod
|
|
144
|
+
def _cluster_confidence(
|
|
145
|
+
files: list[FileDiff],
|
|
146
|
+
similarity_matrix: np.ndarray,
|
|
147
|
+
all_files: list[FileDiff],
|
|
148
|
+
) -> float:
|
|
149
|
+
if len(files) <= 1:
|
|
150
|
+
return 0.99
|
|
151
|
+
|
|
152
|
+
indexes = [all_files.index(file_diff) for file_diff in files]
|
|
153
|
+
scores = []
|
|
154
|
+
for left_index, left in enumerate(indexes):
|
|
155
|
+
for right in indexes[left_index + 1 :]:
|
|
156
|
+
scores.append(float(similarity_matrix[left][right]))
|
|
157
|
+
|
|
158
|
+
if not scores:
|
|
159
|
+
return 0.74
|
|
160
|
+
return max(0.55, min(0.98, sum(scores) / len(scores)))
|
|
161
|
+
|
|
162
|
+
@staticmethod
|
|
163
|
+
def _generate_reason(
|
|
164
|
+
files: list[FileDiff],
|
|
165
|
+
similarity_matrix: np.ndarray,
|
|
166
|
+
all_files: list[FileDiff],
|
|
167
|
+
) -> str:
|
|
168
|
+
"""Generate human-readable subsystem-level reason for grouping."""
|
|
169
|
+
if len(files) <= 1:
|
|
170
|
+
if files and files[0].is_renamed:
|
|
171
|
+
return "File rename operation."
|
|
172
|
+
return "Single file commit."
|
|
173
|
+
|
|
174
|
+
dirs = set(f.file_path.rsplit('/', 1)[0] if '/' in f.file_path else 'root' for f in files)
|
|
175
|
+
primary_dir = list(dirs)[0] if dirs else "core"
|
|
176
|
+
|
|
177
|
+
# Subsystem-level explanation
|
|
178
|
+
subsystem = f"'{primary_dir}' subsystem" if len(dirs) == 1 else "multiple subsystems"
|
|
179
|
+
|
|
180
|
+
# Aggregate significant symbols
|
|
181
|
+
all_added = []
|
|
182
|
+
all_modified = []
|
|
183
|
+
for f in files:
|
|
184
|
+
all_added.extend(f.extracted_symbols.added + f.extracted_symbols.classes)
|
|
185
|
+
all_modified.extend(f.extracted_symbols.modified + f.extracted_symbols.functions)
|
|
186
|
+
|
|
187
|
+
all_added = list(set(all_added))
|
|
188
|
+
all_modified = list(set(all_modified))
|
|
189
|
+
|
|
190
|
+
actions = []
|
|
191
|
+
if all_added:
|
|
192
|
+
actions.append(f"introducing {', '.join(all_added[:2])}")
|
|
193
|
+
if all_modified:
|
|
194
|
+
actions.append(f"updating {', '.join(all_modified[:2])}")
|
|
195
|
+
|
|
196
|
+
if actions:
|
|
197
|
+
reason = f"These files modify the {subsystem} by {' and '.join(actions)}."
|
|
198
|
+
else:
|
|
199
|
+
reason = f"These files heavily relate to the {subsystem} based on shared dependencies and structural similarity."
|
|
200
|
+
|
|
201
|
+
return reason
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""Semantic clustering of file changes."""
|
|
2
|
+
|
|
3
|
+
from typing import List, Optional
|
|
4
|
+
import numpy as np
|
|
5
|
+
from .models import CommitCluster, FileDiff
|
|
6
|
+
from .embeddings import EmbeddingsGenerator
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Clustering:
|
|
10
|
+
"""Groups related file changes using embeddings and ML."""
|
|
11
|
+
|
|
12
|
+
def __init__(self, method: str = "agglomerative", threshold: float = 0.5):
|
|
13
|
+
"""Initialize clustering.
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
method: Clustering algorithm ('agglomerative' or 'hdbscan').
|
|
17
|
+
threshold: Similarity threshold for grouping.
|
|
18
|
+
"""
|
|
19
|
+
self.method = method
|
|
20
|
+
self.threshold = threshold
|
|
21
|
+
self.embeddings_gen = EmbeddingsGenerator(use_local=True)
|
|
22
|
+
|
|
23
|
+
def cluster_files(self, file_diffs: List[FileDiff]) -> List[CommitCluster]:
|
|
24
|
+
"""Cluster files into logical groups.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
file_diffs: List of FileDiff objects.
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
List of CommitCluster objects.
|
|
31
|
+
"""
|
|
32
|
+
if not file_diffs:
|
|
33
|
+
return []
|
|
34
|
+
|
|
35
|
+
# Generate embeddings for all files
|
|
36
|
+
embeddings = []
|
|
37
|
+
for file_diff in file_diffs:
|
|
38
|
+
embedding = self.embeddings_gen.generate_file_embedding(file_diff)
|
|
39
|
+
embeddings.append(embedding)
|
|
40
|
+
|
|
41
|
+
# Perform clustering
|
|
42
|
+
if self.method == "agglomerative":
|
|
43
|
+
cluster_labels = self._agglomerative_clustering(embeddings)
|
|
44
|
+
else:
|
|
45
|
+
cluster_labels = self._rule_based_clustering(file_diffs)
|
|
46
|
+
|
|
47
|
+
# Group files by cluster label
|
|
48
|
+
clusters_dict = {}
|
|
49
|
+
for idx, label in enumerate(cluster_labels):
|
|
50
|
+
if label not in clusters_dict:
|
|
51
|
+
clusters_dict[label] = []
|
|
52
|
+
clusters_dict[label].append(file_diffs[idx])
|
|
53
|
+
|
|
54
|
+
# Create CommitCluster objects
|
|
55
|
+
clusters = []
|
|
56
|
+
for idx, (label, files) in enumerate(sorted(clusters_dict.items())):
|
|
57
|
+
cluster = CommitCluster(
|
|
58
|
+
id=f"cluster_{idx}",
|
|
59
|
+
files=files,
|
|
60
|
+
embedding=embeddings[files[0] == file_diffs[0] and next(i for i, f in enumerate(file_diffs) if f == files[0])]
|
|
61
|
+
if files else None
|
|
62
|
+
)
|
|
63
|
+
clusters.append(cluster)
|
|
64
|
+
|
|
65
|
+
return clusters
|
|
66
|
+
|
|
67
|
+
def _agglomerative_clustering(self, embeddings: List[Optional[List[float]]]) -> List[int]:
|
|
68
|
+
"""Perform agglomerative clustering.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
embeddings: List of embedding vectors.
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
Cluster labels for each embedding.
|
|
75
|
+
"""
|
|
76
|
+
try:
|
|
77
|
+
from sklearn.cluster import AgglomerativeClustering
|
|
78
|
+
|
|
79
|
+
# Filter out None embeddings
|
|
80
|
+
valid_embeddings = []
|
|
81
|
+
valid_indices = []
|
|
82
|
+
|
|
83
|
+
for idx, emb in enumerate(embeddings):
|
|
84
|
+
if emb is not None:
|
|
85
|
+
valid_embeddings.append(emb)
|
|
86
|
+
valid_indices.append(idx)
|
|
87
|
+
|
|
88
|
+
if len(valid_embeddings) < 2:
|
|
89
|
+
return list(range(len(embeddings)))
|
|
90
|
+
|
|
91
|
+
# Compute distance threshold
|
|
92
|
+
n_clusters = max(1, len(valid_embeddings) // 3)
|
|
93
|
+
|
|
94
|
+
clustering = AgglomerativeClustering(
|
|
95
|
+
n_clusters=n_clusters,
|
|
96
|
+
linkage='average'
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
labels = clustering.fit_predict(valid_embeddings)
|
|
100
|
+
|
|
101
|
+
# Map back to original indices
|
|
102
|
+
result = [0] * len(embeddings)
|
|
103
|
+
for i, idx in enumerate(valid_indices):
|
|
104
|
+
result[idx] = labels[i]
|
|
105
|
+
|
|
106
|
+
return result
|
|
107
|
+
|
|
108
|
+
except ImportError:
|
|
109
|
+
return self._rule_based_clustering_simple(embeddings)
|
|
110
|
+
|
|
111
|
+
def _rule_based_clustering(self, file_diffs: List[FileDiff]) -> List[int]:
|
|
112
|
+
"""Perform rule-based clustering by directory and extension.
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
file_diffs: List of FileDiff objects.
|
|
116
|
+
|
|
117
|
+
Returns:
|
|
118
|
+
Cluster labels for each file.
|
|
119
|
+
"""
|
|
120
|
+
labels = []
|
|
121
|
+
|
|
122
|
+
for file_diff in file_diffs:
|
|
123
|
+
# Extract directory and extension
|
|
124
|
+
path_parts = file_diff.file_path.split('/')
|
|
125
|
+
directory = path_parts[0] if path_parts else "root"
|
|
126
|
+
|
|
127
|
+
# Convert to a simple hash label
|
|
128
|
+
label = hash((directory, file_diff.file_path.split('.')[-1])) % 100
|
|
129
|
+
labels.append(label)
|
|
130
|
+
|
|
131
|
+
return labels
|
|
132
|
+
|
|
133
|
+
def _rule_based_clustering_simple(self, embeddings: List[Optional[List[float]]]) -> List[int]:
|
|
134
|
+
"""Simple rule-based clustering as fallback.
|
|
135
|
+
|
|
136
|
+
Args:
|
|
137
|
+
embeddings: List of embedding vectors (unused, kept for compatibility).
|
|
138
|
+
|
|
139
|
+
Returns:
|
|
140
|
+
Cluster labels.
|
|
141
|
+
"""
|
|
142
|
+
# Each file gets its own cluster if no embeddings
|
|
143
|
+
return list(range(len(embeddings)))
|
|
144
|
+
|
|
145
|
+
def merge_similar_clusters(self, clusters: List[CommitCluster]) -> List[CommitCluster]:
|
|
146
|
+
"""Merge clusters that are very similar.
|
|
147
|
+
|
|
148
|
+
Args:
|
|
149
|
+
clusters: List of CommitCluster objects.
|
|
150
|
+
|
|
151
|
+
Returns:
|
|
152
|
+
Merged list of clusters.
|
|
153
|
+
"""
|
|
154
|
+
if len(clusters) < 2:
|
|
155
|
+
return clusters
|
|
156
|
+
|
|
157
|
+
merged = []
|
|
158
|
+
used = set()
|
|
159
|
+
|
|
160
|
+
for i, cluster1 in enumerate(clusters):
|
|
161
|
+
if i in used:
|
|
162
|
+
continue
|
|
163
|
+
|
|
164
|
+
merged_files = cluster1.files.copy()
|
|
165
|
+
|
|
166
|
+
for j, cluster2 in enumerate(clusters[i+1:], i+1):
|
|
167
|
+
if j in used:
|
|
168
|
+
continue
|
|
169
|
+
|
|
170
|
+
similarity = self.embeddings_gen.compute_cluster_similarity(cluster1, cluster2)
|
|
171
|
+
|
|
172
|
+
if similarity > self.threshold:
|
|
173
|
+
merged_files.extend(cluster2.files)
|
|
174
|
+
used.add(j)
|
|
175
|
+
|
|
176
|
+
# Create new merged cluster
|
|
177
|
+
merged_cluster = CommitCluster(
|
|
178
|
+
id=cluster1.id,
|
|
179
|
+
files=merged_files,
|
|
180
|
+
embedding=cluster1.embedding
|
|
181
|
+
)
|
|
182
|
+
merged.append(merged_cluster)
|
|
183
|
+
used.add(i)
|
|
184
|
+
|
|
185
|
+
return merged
|
|
186
|
+
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
"""Commit message generation."""
|
|
2
|
+
|
|
3
|
+
from typing import Optional
|
|
4
|
+
from .models import CommitCluster, CommitType
|
|
5
|
+
from .config import Config
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class CommitMessageGenerator:
|
|
9
|
+
"""Generates Conventional Commit messages."""
|
|
10
|
+
|
|
11
|
+
def __init__(self, config: Optional[Config] = None):
|
|
12
|
+
"""Initialize commit message generator.
|
|
13
|
+
|
|
14
|
+
Args:
|
|
15
|
+
config: Configuration object.
|
|
16
|
+
"""
|
|
17
|
+
self.config = config or Config.load()
|
|
18
|
+
|
|
19
|
+
def generate_message(self, cluster: CommitCluster) -> str:
|
|
20
|
+
"""Generate a full commit message for a cluster.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
cluster: CommitCluster to generate message for.
|
|
24
|
+
|
|
25
|
+
Returns:
|
|
26
|
+
Commit message string.
|
|
27
|
+
"""
|
|
28
|
+
title = self.generate_title(cluster)
|
|
29
|
+
description = self.generate_description(cluster)
|
|
30
|
+
|
|
31
|
+
if description:
|
|
32
|
+
return f"{title}\n\n{description}"
|
|
33
|
+
|
|
34
|
+
return title
|
|
35
|
+
|
|
36
|
+
def generate_title(self, cluster: CommitCluster) -> str:
|
|
37
|
+
"""Generate the commit title (first line).
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
cluster: CommitCluster to analyze.
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
Commit title.
|
|
44
|
+
"""
|
|
45
|
+
commit_type = cluster.commit_type or self._infer_commit_type(cluster)
|
|
46
|
+
scope = cluster.scope or self._infer_scope(cluster)
|
|
47
|
+
message = cluster.message or self._generate_message_text(cluster)
|
|
48
|
+
message = self._normalize_subject(message)
|
|
49
|
+
|
|
50
|
+
# Format as conventional commit
|
|
51
|
+
title = f"{commit_type.value}({scope}): {message}" if scope else f"{commit_type.value}: {message}"
|
|
52
|
+
return self._truncate_title(title)
|
|
53
|
+
|
|
54
|
+
def _infer_commit_type(self, cluster: CommitCluster) -> CommitType:
|
|
55
|
+
paths = [file_diff.file_path.lower() for file_diff in cluster.files]
|
|
56
|
+
if paths and all(self._get_file_type(path) == "docs" for path in paths):
|
|
57
|
+
return CommitType.DOCS
|
|
58
|
+
if any("test" in path or "spec" in path for path in paths):
|
|
59
|
+
return CommitType.TEST
|
|
60
|
+
if any(
|
|
61
|
+
path.endswith((".toml", ".yaml", ".yml", ".json", ".ini", ".cfg", ".env"))
|
|
62
|
+
or path in {".gitignore", ".env.example"}
|
|
63
|
+
for path in paths
|
|
64
|
+
):
|
|
65
|
+
return CommitType.CHORE
|
|
66
|
+
return self._infer_commit_type_from_summary(cluster.summary)
|
|
67
|
+
|
|
68
|
+
@staticmethod
|
|
69
|
+
def _infer_commit_type_from_summary(summary: Optional[str]) -> CommitType:
|
|
70
|
+
"""Infer commit type from summary text.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
summary: Summary text.
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
Inferred CommitType.
|
|
77
|
+
"""
|
|
78
|
+
if not summary:
|
|
79
|
+
return CommitType.CHORE
|
|
80
|
+
|
|
81
|
+
summary_lower = summary.lower()
|
|
82
|
+
|
|
83
|
+
if any(x in summary_lower for x in ['fix', 'bug', 'issue', 'broken']):
|
|
84
|
+
return CommitType.FIX
|
|
85
|
+
elif any(x in summary_lower for x in ['feat', 'feature', 'add', 'implement', 'new']):
|
|
86
|
+
return CommitType.FEAT
|
|
87
|
+
elif any(x in summary_lower for x in ['refactor', 'clean', 'improve', 'simplify']):
|
|
88
|
+
return CommitType.REFACTOR
|
|
89
|
+
elif any(x in summary_lower for x in ['test', 'spec', 'unit']):
|
|
90
|
+
return CommitType.TEST
|
|
91
|
+
elif any(x in summary_lower for x in ['doc', 'readme', 'comment', 'document']):
|
|
92
|
+
return CommitType.DOCS
|
|
93
|
+
elif any(x in summary_lower for x in ['style', 'format', 'lint']):
|
|
94
|
+
return CommitType.STYLE
|
|
95
|
+
elif any(x in summary_lower for x in ['perf', 'performance', 'optimize', 'speed']):
|
|
96
|
+
return CommitType.PERF
|
|
97
|
+
|
|
98
|
+
return CommitType.CHORE
|
|
99
|
+
|
|
100
|
+
def generate_description(self, cluster: CommitCluster) -> str:
|
|
101
|
+
"""Generate commit description (body).
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
cluster: CommitCluster to analyze.
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
Commit description.
|
|
108
|
+
"""
|
|
109
|
+
if not cluster.files:
|
|
110
|
+
return ""
|
|
111
|
+
|
|
112
|
+
files_by_type = {}
|
|
113
|
+
for file_diff in cluster.files:
|
|
114
|
+
file_type = self._get_file_type(file_diff.file_path)
|
|
115
|
+
if file_type not in files_by_type:
|
|
116
|
+
files_by_type[file_type] = []
|
|
117
|
+
files_by_type[file_type].append(file_diff.file_path)
|
|
118
|
+
|
|
119
|
+
description_lines = ["Changed files:"]
|
|
120
|
+
|
|
121
|
+
for file_type, files in sorted(files_by_type.items()):
|
|
122
|
+
description_lines.append(f"\n{file_type}:")
|
|
123
|
+
for file_path in files[:5]: # Limit to 5 per type
|
|
124
|
+
description_lines.append(f" - {file_path}")
|
|
125
|
+
|
|
126
|
+
if len(files) > 5:
|
|
127
|
+
description_lines.append(f" - and {len(files) - 5} more...")
|
|
128
|
+
|
|
129
|
+
return "\n".join(description_lines)
|
|
130
|
+
|
|
131
|
+
def _infer_scope(self, cluster: CommitCluster) -> Optional[str]:
|
|
132
|
+
"""Infer scope from files in cluster.
|
|
133
|
+
|
|
134
|
+
Args:
|
|
135
|
+
cluster: CommitCluster to analyze.
|
|
136
|
+
|
|
137
|
+
Returns:
|
|
138
|
+
Scope string or None.
|
|
139
|
+
"""
|
|
140
|
+
if not cluster.files:
|
|
141
|
+
return None
|
|
142
|
+
|
|
143
|
+
# Extract common directory or feature name
|
|
144
|
+
file_path = cluster.files[0].file_path
|
|
145
|
+
parts = file_path.split('/')
|
|
146
|
+
|
|
147
|
+
ignored = {"src", "lib", "app", "apps", "packages", "tests", "test", "smart_commit"}
|
|
148
|
+
for part in parts[:-1]:
|
|
149
|
+
clean = part.replace("_", "-").lower()
|
|
150
|
+
if clean and clean not in ignored:
|
|
151
|
+
return clean
|
|
152
|
+
|
|
153
|
+
# Try to infer from file names
|
|
154
|
+
if any('auth' in f.file_path.lower() for f in cluster.files):
|
|
155
|
+
return "auth"
|
|
156
|
+
elif any('api' in f.file_path.lower() for f in cluster.files):
|
|
157
|
+
return "api"
|
|
158
|
+
elif any('ui' in f.file_path.lower() or 'component' in f.file_path.lower() for f in cluster.files):
|
|
159
|
+
return "ui"
|
|
160
|
+
|
|
161
|
+
return None
|
|
162
|
+
|
|
163
|
+
def _generate_message_text(self, cluster: CommitCluster) -> str:
|
|
164
|
+
"""Generate the message text from summary.
|
|
165
|
+
|
|
166
|
+
Args:
|
|
167
|
+
cluster: CommitCluster to analyze.
|
|
168
|
+
|
|
169
|
+
Returns:
|
|
170
|
+
Message text.
|
|
171
|
+
"""
|
|
172
|
+
if cluster.summary:
|
|
173
|
+
summary = cluster.summary.strip().split(";")[0]
|
|
174
|
+
normalized = self._normalize_subject(summary)
|
|
175
|
+
if normalized in {"add content", "update content", "remove content"}:
|
|
176
|
+
return self._fallback_subject(cluster, normalized.split()[0])
|
|
177
|
+
return normalized
|
|
178
|
+
|
|
179
|
+
return self._fallback_subject(cluster, "update")
|
|
180
|
+
|
|
181
|
+
def _fallback_subject(self, cluster: CommitCluster, verb: str) -> str:
|
|
182
|
+
if len(cluster.files) == 1:
|
|
183
|
+
return f"{verb} {cluster.files[0].file_path.split('/')[-1]}"
|
|
184
|
+
|
|
185
|
+
scope = cluster.scope or self._infer_scope(cluster)
|
|
186
|
+
if scope:
|
|
187
|
+
return f"{verb} {scope} changes"
|
|
188
|
+
return f"{verb} related files"
|
|
189
|
+
|
|
190
|
+
@staticmethod
|
|
191
|
+
def _normalize_subject(text: str) -> str:
|
|
192
|
+
text = text.strip().rstrip(".")
|
|
193
|
+
if not text:
|
|
194
|
+
return "update files"
|
|
195
|
+
text = text[0].lower() + text[1:]
|
|
196
|
+
replacements = {
|
|
197
|
+
"added new content": "add content",
|
|
198
|
+
"modified content": "update content",
|
|
199
|
+
"removed content": "remove content",
|
|
200
|
+
}
|
|
201
|
+
return replacements.get(text, text)
|
|
202
|
+
|
|
203
|
+
@staticmethod
|
|
204
|
+
def _truncate_title(title: str, limit: int = 72) -> str:
|
|
205
|
+
if len(title) <= limit:
|
|
206
|
+
return title
|
|
207
|
+
truncated = title[:limit].rsplit(" ", 1)[0]
|
|
208
|
+
return truncated if len(truncated) >= 30 else title[:limit].rstrip()
|
|
209
|
+
|
|
210
|
+
@staticmethod
|
|
211
|
+
def _get_file_type(file_path: str) -> str:
|
|
212
|
+
"""Get file type category.
|
|
213
|
+
|
|
214
|
+
Args:
|
|
215
|
+
file_path: Path to file.
|
|
216
|
+
|
|
217
|
+
Returns:
|
|
218
|
+
File type category.
|
|
219
|
+
"""
|
|
220
|
+
file_lower = file_path.lower()
|
|
221
|
+
|
|
222
|
+
if any(x in file_lower for x in ['.jsx', '.tsx', '.vue', '.css']):
|
|
223
|
+
return "frontend"
|
|
224
|
+
elif any(x in file_lower for x in ['.py', '.java', '.go', '.rs', '.rb']):
|
|
225
|
+
return "backend"
|
|
226
|
+
elif any(x in file_lower for x in ['.test.', '.spec.']):
|
|
227
|
+
return "tests"
|
|
228
|
+
elif any(x in file_lower for x in ['.md', '.txt']):
|
|
229
|
+
return "docs"
|
|
230
|
+
|
|
231
|
+
return "other"
|
|
232
|
+
|