pushpilot 1.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.
- autopush/__init__.py +6 -0
- autopush/ai_commit.py +483 -0
- autopush/cli.py +316 -0
- autopush/commit_gen.py +144 -0
- autopush/detector.py +342 -0
- autopush/git_ops.py +256 -0
- autopush/watcher.py +159 -0
- pushpilot-1.1.0.dist-info/METADATA +155 -0
- pushpilot-1.1.0.dist-info/RECORD +13 -0
- pushpilot-1.1.0.dist-info/WHEEL +5 -0
- pushpilot-1.1.0.dist-info/entry_points.txt +6 -0
- pushpilot-1.1.0.dist-info/licenses/LICENSE +23 -0
- pushpilot-1.1.0.dist-info/top_level.txt +1 -0
autopush/__init__.py
ADDED
autopush/ai_commit.py
ADDED
|
@@ -0,0 +1,483 @@
|
|
|
1
|
+
"""
|
|
2
|
+
AI Commit Generator for PushPilot.
|
|
3
|
+
Detects if the developer is coding with AI (e.g. Google Antigravity, Cursor,
|
|
4
|
+
Aider, Claude Code, Cline, or via Gemini/OpenAI/Anthropic/Groq/Ollama APIs)
|
|
5
|
+
and generates ultra-accurate, semantic conventional commit messages.
|
|
6
|
+
|
|
7
|
+
If the developer is not coding with AI, this module returns None, allowing
|
|
8
|
+
PushPilot to seamlessly fall back to the default rule-based generator.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import re
|
|
14
|
+
import urllib.error
|
|
15
|
+
import urllib.request
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Optional
|
|
19
|
+
|
|
20
|
+
from autopush.detector import ProjectStack, classify_file_role
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class AiContext:
|
|
25
|
+
is_coding_with_ai: bool
|
|
26
|
+
source_name: str
|
|
27
|
+
recent_task: Optional[str] = None
|
|
28
|
+
provider: Optional[str] = None
|
|
29
|
+
api_key: Optional[str] = None
|
|
30
|
+
endpoint: Optional[str] = None
|
|
31
|
+
model_name: Optional[str] = None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def detect_antigravity_session() -> Optional[tuple[str, str]]:
|
|
35
|
+
"""Detects active Google Antigravity IDE session and extracts the latest user request."""
|
|
36
|
+
try:
|
|
37
|
+
brain_dir = Path.home() / ".gemini" / "antigravity-ide" / "brain"
|
|
38
|
+
if not brain_dir.is_dir():
|
|
39
|
+
return None
|
|
40
|
+
|
|
41
|
+
# Find recent session directory modified in the last 60 minutes
|
|
42
|
+
sessions = sorted(
|
|
43
|
+
[d for d in brain_dir.iterdir() if d.is_dir()],
|
|
44
|
+
key=lambda d: d.stat().st_mtime,
|
|
45
|
+
reverse=True,
|
|
46
|
+
)
|
|
47
|
+
if not sessions:
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
latest_session = sessions[0]
|
|
51
|
+
# Check if modified within the last hour
|
|
52
|
+
import time
|
|
53
|
+
if (time.time() - latest_session.stat().st_mtime) > 3600:
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
transcript_path = latest_session / ".system_generated" / "logs" / "transcript.jsonl"
|
|
57
|
+
if not transcript_path.exists():
|
|
58
|
+
transcript_path = latest_session / "transcript.jsonl"
|
|
59
|
+
if not transcript_path.exists():
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
lines = transcript_path.read_text(encoding="utf-8", errors="replace").splitlines()
|
|
63
|
+
for line in reversed(lines):
|
|
64
|
+
line = line.strip()
|
|
65
|
+
if not line:
|
|
66
|
+
continue
|
|
67
|
+
try:
|
|
68
|
+
record = json.loads(line)
|
|
69
|
+
except Exception:
|
|
70
|
+
continue
|
|
71
|
+
|
|
72
|
+
if record.get("type") == "USER_INPUT":
|
|
73
|
+
content = record.get("content", "")
|
|
74
|
+
# Extract text inside <USER_REQUEST> tags if present
|
|
75
|
+
match = re.search(r"<USER_REQUEST>(.*?)</USER_REQUEST>", content, re.DOTALL)
|
|
76
|
+
task = match.group(1).strip() if match else content.strip()
|
|
77
|
+
if task:
|
|
78
|
+
return ("Google Antigravity AI", task)
|
|
79
|
+
except Exception:
|
|
80
|
+
pass
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def detect_aider_session(root: Path) -> Optional[tuple[str, str]]:
|
|
85
|
+
"""Detects active Aider chat session in repository."""
|
|
86
|
+
try:
|
|
87
|
+
import time
|
|
88
|
+
for name in [".aider.chat.history.md", ".aider.input.history"]:
|
|
89
|
+
p = root / name
|
|
90
|
+
if p.exists() and (time.time() - p.stat().st_mtime) < 3600:
|
|
91
|
+
lines = [l.strip() for l in p.read_text(encoding="utf-8", errors="replace").splitlines() if l.strip()]
|
|
92
|
+
if lines:
|
|
93
|
+
last_line = lines[-1]
|
|
94
|
+
# Filter out commands
|
|
95
|
+
if not last_line.startswith("/"):
|
|
96
|
+
return ("Aider AI", last_line)
|
|
97
|
+
return ("Aider AI", "refactor code with aider")
|
|
98
|
+
except Exception:
|
|
99
|
+
pass
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def detect_cursor_session(root: Path) -> Optional[tuple[str, str]]:
|
|
104
|
+
"""Detects active Cursor editor environment."""
|
|
105
|
+
try:
|
|
106
|
+
if (root / ".cursor").is_dir() or (root / ".cursorrules").exists():
|
|
107
|
+
return ("Cursor AI", "")
|
|
108
|
+
if os.environ.get("CURSOR_AGENT") or os.environ.get("CURSOR_IDE"):
|
|
109
|
+
return ("Cursor AI", "")
|
|
110
|
+
except Exception:
|
|
111
|
+
pass
|
|
112
|
+
return None
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def detect_claude_session(root: Path) -> Optional[tuple[str, str]]:
|
|
116
|
+
"""Detects Claude Code CLI session."""
|
|
117
|
+
try:
|
|
118
|
+
import time
|
|
119
|
+
claude_dir = Path.home() / ".claude"
|
|
120
|
+
if claude_dir.is_dir() and (time.time() - claude_dir.stat().st_mtime) < 3600:
|
|
121
|
+
return ("Claude Code AI", "")
|
|
122
|
+
if (root / ".claude").is_dir():
|
|
123
|
+
return ("Claude Code AI", "")
|
|
124
|
+
except Exception:
|
|
125
|
+
pass
|
|
126
|
+
return None
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def detect_cline_session(root: Path) -> Optional[tuple[str, str]]:
|
|
130
|
+
"""Detects Cline / Roo Code AI environment."""
|
|
131
|
+
try:
|
|
132
|
+
if (root / ".cline").is_dir() or (root / ".roomodes").exists():
|
|
133
|
+
return ("Cline AI", "")
|
|
134
|
+
except Exception:
|
|
135
|
+
pass
|
|
136
|
+
return None
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def detect_local_ollama() -> Optional[str]:
|
|
140
|
+
"""Probes if Ollama is running locally with a lightweight 0.4s timeout."""
|
|
141
|
+
try:
|
|
142
|
+
req = urllib.request.Request("http://localhost:11434/api/tags", headers={"User-Agent": "PushPilot"})
|
|
143
|
+
with urllib.request.urlopen(req, timeout=0.4) as resp:
|
|
144
|
+
if resp.status == 200:
|
|
145
|
+
data = json.loads(resp.read().decode("utf-8"))
|
|
146
|
+
models = data.get("models", [])
|
|
147
|
+
if models:
|
|
148
|
+
return models[0].get("name", "llama3")
|
|
149
|
+
return "llama3"
|
|
150
|
+
except Exception:
|
|
151
|
+
pass
|
|
152
|
+
return None
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def detect_ai_context(root: Path) -> Optional[AiContext]:
|
|
156
|
+
"""
|
|
157
|
+
Checks if the developer is coding with AI.
|
|
158
|
+
Returns AiContext if AI signals are found; otherwise returns None (not coding with AI).
|
|
159
|
+
"""
|
|
160
|
+
# 1. Check AI API keys in environment
|
|
161
|
+
gemini_key = os.environ.get("GEMINI_API_KEY") or os.environ.get("GOOGLE_API_KEY")
|
|
162
|
+
openai_key = os.environ.get("OPENAI_API_KEY")
|
|
163
|
+
anthropic_key = os.environ.get("ANTHROPIC_API_KEY")
|
|
164
|
+
groq_key = os.environ.get("GROQ_API_KEY")
|
|
165
|
+
|
|
166
|
+
# 2. Check local Ollama model
|
|
167
|
+
ollama_model = detect_local_ollama()
|
|
168
|
+
|
|
169
|
+
# 3. Check active AI IDE/assistant sessions
|
|
170
|
+
antigravity_info = detect_antigravity_session()
|
|
171
|
+
aider_info = detect_aider_session(root)
|
|
172
|
+
cursor_info = detect_cursor_session(root)
|
|
173
|
+
claude_info = detect_claude_session(root)
|
|
174
|
+
cline_info = detect_cline_session(root)
|
|
175
|
+
|
|
176
|
+
# Gather detected assistant
|
|
177
|
+
assistant_name = None
|
|
178
|
+
assistant_task = None
|
|
179
|
+
|
|
180
|
+
if antigravity_info:
|
|
181
|
+
assistant_name, assistant_task = antigravity_info
|
|
182
|
+
elif aider_info:
|
|
183
|
+
assistant_name, assistant_task = aider_info
|
|
184
|
+
elif cursor_info:
|
|
185
|
+
assistant_name, assistant_task = cursor_info
|
|
186
|
+
elif claude_info:
|
|
187
|
+
assistant_name, assistant_task = claude_info
|
|
188
|
+
elif cline_info:
|
|
189
|
+
assistant_name, assistant_task = cline_info
|
|
190
|
+
|
|
191
|
+
# Explicit AI flag
|
|
192
|
+
explicit_ai = os.environ.get("PUSHPILOT_AI") or os.environ.get("AI_CODING") or os.environ.get("CODING_WITH_AI")
|
|
193
|
+
|
|
194
|
+
# Determine if any AI presence exists
|
|
195
|
+
has_ai = any([
|
|
196
|
+
gemini_key,
|
|
197
|
+
openai_key,
|
|
198
|
+
anthropic_key,
|
|
199
|
+
groq_key,
|
|
200
|
+
ollama_model,
|
|
201
|
+
assistant_name,
|
|
202
|
+
explicit_ai,
|
|
203
|
+
])
|
|
204
|
+
|
|
205
|
+
if not has_ai:
|
|
206
|
+
return None # The developer is NOT coding with AI! Use default generator.
|
|
207
|
+
|
|
208
|
+
# Match provider if available
|
|
209
|
+
provider = None
|
|
210
|
+
api_key = None
|
|
211
|
+
model_name = None
|
|
212
|
+
|
|
213
|
+
if gemini_key:
|
|
214
|
+
provider = "gemini"
|
|
215
|
+
api_key = gemini_key
|
|
216
|
+
model_name = "gemini-1.5-flash"
|
|
217
|
+
elif openai_key:
|
|
218
|
+
provider = "openai"
|
|
219
|
+
api_key = openai_key
|
|
220
|
+
model_name = "gpt-4o-mini"
|
|
221
|
+
elif groq_key:
|
|
222
|
+
provider = "groq"
|
|
223
|
+
api_key = groq_key
|
|
224
|
+
model_name = "llama-3.1-8b-instant"
|
|
225
|
+
elif anthropic_key:
|
|
226
|
+
provider = "anthropic"
|
|
227
|
+
api_key = anthropic_key
|
|
228
|
+
model_name = "claude-3-5-haiku-20241022"
|
|
229
|
+
elif ollama_model:
|
|
230
|
+
provider = "ollama"
|
|
231
|
+
model_name = ollama_model
|
|
232
|
+
|
|
233
|
+
source_label = assistant_name or (f"API ({provider})" if provider else "AI Environment")
|
|
234
|
+
|
|
235
|
+
return AiContext(
|
|
236
|
+
is_coding_with_ai=True,
|
|
237
|
+
source_name=source_label,
|
|
238
|
+
recent_task=assistant_task,
|
|
239
|
+
provider=provider,
|
|
240
|
+
api_key=api_key,
|
|
241
|
+
model_name=model_name,
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def query_llm_api(
|
|
246
|
+
ctx: AiContext,
|
|
247
|
+
diff_summary: str,
|
|
248
|
+
stack: ProjectStack,
|
|
249
|
+
) -> Optional[str]:
|
|
250
|
+
"""Queries configured LLM API (Gemini/OpenAI/Anthropic/Groq/Ollama) with 3.5s timeout."""
|
|
251
|
+
if not ctx.provider:
|
|
252
|
+
return None
|
|
253
|
+
|
|
254
|
+
prompt = (
|
|
255
|
+
"You are an expert Git commit assistant. Generate ONE concise conventional commit message "
|
|
256
|
+
"describing the following changes.\n"
|
|
257
|
+
"Rules:\n"
|
|
258
|
+
"- Follow format: <type>(<scope>): <subject>\n"
|
|
259
|
+
"- Types allowed: feat, fix, docs, style, refactor, perf, test, chore, build, ci\n"
|
|
260
|
+
"- Keep under 72 characters.\n"
|
|
261
|
+
"- Use lowercase subject without a period at the end.\n"
|
|
262
|
+
"- Return ONLY the single commit line.\n\n"
|
|
263
|
+
f"Stack: {stack.language} ({stack.framework or 'None'})\n"
|
|
264
|
+
)
|
|
265
|
+
if ctx.recent_task:
|
|
266
|
+
prompt += f"Active Developer Task: {ctx.recent_task[:300]}\n"
|
|
267
|
+
prompt += f"Git Changes & Diff:\n{diff_summary[:2000]}\n\nCommit Message:"
|
|
268
|
+
|
|
269
|
+
try:
|
|
270
|
+
if ctx.provider == "gemini":
|
|
271
|
+
url = f"https://generativelanguage.googleapis.com/v1beta/models/{ctx.model_name or 'gemini-1.5-flash'}:generateContent?key={ctx.api_key}"
|
|
272
|
+
payload = {
|
|
273
|
+
"contents": [{"parts": [{"text": prompt}]}],
|
|
274
|
+
"generationConfig": {"temperature": 0.2, "maxOutputTokens": 60},
|
|
275
|
+
}
|
|
276
|
+
req = urllib.request.Request(
|
|
277
|
+
url,
|
|
278
|
+
data=json.dumps(payload).encode("utf-8"),
|
|
279
|
+
headers={"Content-Type": "application/json"},
|
|
280
|
+
)
|
|
281
|
+
with urllib.request.urlopen(req, timeout=3.5) as resp:
|
|
282
|
+
data = json.loads(resp.read().decode("utf-8"))
|
|
283
|
+
candidates = data.get("candidates", [])
|
|
284
|
+
if candidates:
|
|
285
|
+
parts = candidates[0].get("content", {}).get("parts", [])
|
|
286
|
+
if parts:
|
|
287
|
+
return clean_commit_line(parts[0].get("text", ""))
|
|
288
|
+
|
|
289
|
+
elif ctx.provider in ("openai", "groq"):
|
|
290
|
+
endpoint = (
|
|
291
|
+
"https://api.openai.com/v1/chat/completions"
|
|
292
|
+
if ctx.provider == "openai"
|
|
293
|
+
else "https://api.groq.com/openai/v1/chat/completions"
|
|
294
|
+
)
|
|
295
|
+
payload = {
|
|
296
|
+
"model": ctx.model_name,
|
|
297
|
+
"messages": [
|
|
298
|
+
{"role": "system", "content": "You output only a single conventional commit line."},
|
|
299
|
+
{"role": "user", "content": prompt},
|
|
300
|
+
],
|
|
301
|
+
"temperature": 0.2,
|
|
302
|
+
"max_tokens": 60,
|
|
303
|
+
}
|
|
304
|
+
req = urllib.request.Request(
|
|
305
|
+
endpoint,
|
|
306
|
+
data=json.dumps(payload).encode("utf-8"),
|
|
307
|
+
headers={
|
|
308
|
+
"Content-Type": "application/json",
|
|
309
|
+
"Authorization": f"Bearer {ctx.api_key}",
|
|
310
|
+
},
|
|
311
|
+
)
|
|
312
|
+
with urllib.request.urlopen(req, timeout=3.5) as resp:
|
|
313
|
+
data = json.loads(resp.read().decode("utf-8"))
|
|
314
|
+
choices = data.get("choices", [])
|
|
315
|
+
if choices:
|
|
316
|
+
return clean_commit_line(choices[0].get("message", {}).get("content", ""))
|
|
317
|
+
|
|
318
|
+
elif ctx.provider == "anthropic":
|
|
319
|
+
endpoint = "https://api.anthropic.com/v1/messages"
|
|
320
|
+
payload = {
|
|
321
|
+
"model": ctx.model_name,
|
|
322
|
+
"max_tokens": 60,
|
|
323
|
+
"messages": [{"role": "user", "content": prompt}],
|
|
324
|
+
}
|
|
325
|
+
req = urllib.request.Request(
|
|
326
|
+
endpoint,
|
|
327
|
+
data=json.dumps(payload).encode("utf-8"),
|
|
328
|
+
headers={
|
|
329
|
+
"Content-Type": "application/json",
|
|
330
|
+
"x-api-key": ctx.api_key,
|
|
331
|
+
"anthropic-version": "2023-06-01",
|
|
332
|
+
},
|
|
333
|
+
)
|
|
334
|
+
with urllib.request.urlopen(req, timeout=3.5) as resp:
|
|
335
|
+
data = json.loads(resp.read().decode("utf-8"))
|
|
336
|
+
content = data.get("content", [])
|
|
337
|
+
if content:
|
|
338
|
+
return clean_commit_line(content[0].get("text", ""))
|
|
339
|
+
|
|
340
|
+
elif ctx.provider == "ollama":
|
|
341
|
+
endpoint = "http://localhost:11434/api/generate"
|
|
342
|
+
payload = {
|
|
343
|
+
"model": ctx.model_name,
|
|
344
|
+
"prompt": prompt,
|
|
345
|
+
"stream": False,
|
|
346
|
+
}
|
|
347
|
+
req = urllib.request.Request(
|
|
348
|
+
endpoint,
|
|
349
|
+
data=json.dumps(payload).encode("utf-8"),
|
|
350
|
+
headers={"Content-Type": "application/json"},
|
|
351
|
+
)
|
|
352
|
+
with urllib.request.urlopen(req, timeout=3.5) as resp:
|
|
353
|
+
data = json.loads(resp.read().decode("utf-8"))
|
|
354
|
+
return clean_commit_line(data.get("response", ""))
|
|
355
|
+
|
|
356
|
+
except Exception:
|
|
357
|
+
pass
|
|
358
|
+
return None
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def clean_commit_line(text: str) -> Optional[str]:
|
|
362
|
+
"""Validates and cleans a conventional commit string."""
|
|
363
|
+
if not text:
|
|
364
|
+
return None
|
|
365
|
+
# Take the first non-empty line
|
|
366
|
+
lines = [l.strip().strip("`'\"") for l in text.strip().splitlines() if l.strip()]
|
|
367
|
+
if not lines:
|
|
368
|
+
return None
|
|
369
|
+
candidate = lines[0]
|
|
370
|
+
# Check if matches conventional commit format
|
|
371
|
+
pattern = r"^(feat|fix|docs|style|refactor|perf|test|chore|build|ci)(\([a-zA-Z0-9_\-\.\/]+\))?:\s*.+$"
|
|
372
|
+
if re.match(pattern, candidate, re.IGNORECASE):
|
|
373
|
+
# Format properly
|
|
374
|
+
return candidate.rstrip(". ")
|
|
375
|
+
return None
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def task_to_conventional_commit(
|
|
379
|
+
task: str,
|
|
380
|
+
changes: list[tuple[str, str]],
|
|
381
|
+
stack: ProjectStack,
|
|
382
|
+
) -> str:
|
|
383
|
+
"""
|
|
384
|
+
Transforms the active developer's AI task / prompt directly into a clean,
|
|
385
|
+
highly accurate Conventional Commit message when offline or without an API key.
|
|
386
|
+
"""
|
|
387
|
+
cleaned = task.strip().split("\n")[0].strip()
|
|
388
|
+
|
|
389
|
+
# Remove conversational prefixes common in AI prompts
|
|
390
|
+
cleaned = re.sub(
|
|
391
|
+
r"^(please\s+|can\s+you\s+|could\s+you\s+|i\s+want\s+(to\s+)?|let(\x27s)?\s+|we\s+need\s+to\s+|help\s+me\s+|proceed\s+(and\s+)?)+",
|
|
392
|
+
"",
|
|
393
|
+
cleaned,
|
|
394
|
+
flags=re.IGNORECASE,
|
|
395
|
+
).strip()
|
|
396
|
+
cleaned = re.sub(
|
|
397
|
+
r"^(make\s+this\s+project\s+to\s+|make\s+this\s+project\s+|make\s+|configure\s+this\s+project\s+to\s+|update\s+this\s+project\s+to\s+)",
|
|
398
|
+
"",
|
|
399
|
+
cleaned,
|
|
400
|
+
flags=re.IGNORECASE,
|
|
401
|
+
).strip()
|
|
402
|
+
|
|
403
|
+
# Split off subordinate/conditional clauses (if..., when..., so that..., and use...)
|
|
404
|
+
parts = re.split(r"\b(if\b|when\b|so\s+that\b|in\s+order\s+to\b|and\s+use\b)", cleaned, maxsplit=1, flags=re.IGNORECASE)
|
|
405
|
+
if parts and parts[0].strip():
|
|
406
|
+
cleaned = parts[0].strip()
|
|
407
|
+
|
|
408
|
+
# Determine primary file role
|
|
409
|
+
roles: dict[str, int] = {}
|
|
410
|
+
for _, file_path in changes:
|
|
411
|
+
role, _ = classify_file_role(file_path)
|
|
412
|
+
roles[role] = roles.get(role, 0) + 1
|
|
413
|
+
|
|
414
|
+
primary_role = "core"
|
|
415
|
+
for r in ["auth", "api", "ai", "model", "ui", "service", "test", "docs", "config", "core"]:
|
|
416
|
+
if r in roles:
|
|
417
|
+
primary_role = r
|
|
418
|
+
break
|
|
419
|
+
|
|
420
|
+
# If changes involve ai commit / commit generator, use ai or commit scope
|
|
421
|
+
if any("ai" in f.lower() or "commit" in f.lower() for _, f in changes):
|
|
422
|
+
primary_role = "ai"
|
|
423
|
+
|
|
424
|
+
# Determine commit type
|
|
425
|
+
c_type = "feat"
|
|
426
|
+
if re.search(r"\b(fix|resolve|bug|issue|error|broken|patch|prevent|handle error)\b", cleaned, re.IGNORECASE):
|
|
427
|
+
c_type = "fix"
|
|
428
|
+
elif re.search(r"\b(refactor|clean|cleanup|reorganize|restructure|optimize|simplify)\b", cleaned, re.IGNORECASE):
|
|
429
|
+
c_type = "refactor"
|
|
430
|
+
elif re.search(r"\b(test|tests|spec|coverage|unit test)\b", cleaned, re.IGNORECASE):
|
|
431
|
+
c_type = "test"
|
|
432
|
+
elif re.search(r"\b(doc|docs|readme|documentation|guide)\b", cleaned, re.IGNORECASE):
|
|
433
|
+
c_type = "docs"
|
|
434
|
+
elif re.search(r"\b(chore|dependency|dependencies|bump|package|release|setup|install)\b", cleaned, re.IGNORECASE):
|
|
435
|
+
c_type = "chore"
|
|
436
|
+
|
|
437
|
+
# Truncate to clean length if too long
|
|
438
|
+
cleaned = cleaned.rstrip(".! ")
|
|
439
|
+
words = cleaned.split()
|
|
440
|
+
if len(" ".join(words)) > 55:
|
|
441
|
+
truncated = []
|
|
442
|
+
for w in words:
|
|
443
|
+
if len(" ".join(truncated + [w])) <= 52:
|
|
444
|
+
truncated.append(w)
|
|
445
|
+
else:
|
|
446
|
+
break
|
|
447
|
+
cleaned = " ".join(truncated)
|
|
448
|
+
|
|
449
|
+
return f"{c_type}({primary_role}): {cleaned.lower()}"
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def generate_ai_commit_message(
|
|
453
|
+
changes: list[tuple[str, str]],
|
|
454
|
+
stack: ProjectStack,
|
|
455
|
+
diff_summary: str,
|
|
456
|
+
root: Path,
|
|
457
|
+
) -> tuple[Optional[str], Optional[str]]:
|
|
458
|
+
"""
|
|
459
|
+
Checks if coding with AI and generates an accurate commit message.
|
|
460
|
+
|
|
461
|
+
Returns:
|
|
462
|
+
(commit_message, ai_source_description) if coding with AI and generation succeeded.
|
|
463
|
+
(None, None) if the developer is NOT coding with AI or if generation yielded no result.
|
|
464
|
+
"""
|
|
465
|
+
ctx = detect_ai_context(root)
|
|
466
|
+
if not ctx or not ctx.is_coding_with_ai:
|
|
467
|
+
return None, None # NOT coding with AI -> caller uses default!
|
|
468
|
+
|
|
469
|
+
# 1. Try querying LLM API if key/local model exists
|
|
470
|
+
if ctx.provider:
|
|
471
|
+
llm_msg = query_llm_api(ctx, diff_summary, stack)
|
|
472
|
+
if llm_msg:
|
|
473
|
+
return llm_msg, f"{ctx.source_name} ({ctx.model_name})"
|
|
474
|
+
|
|
475
|
+
# 2. If an AI session with an active task was detected, synthesize from developer intent
|
|
476
|
+
if ctx.recent_task:
|
|
477
|
+
synthesized_msg = task_to_conventional_commit(ctx.recent_task, changes, stack)
|
|
478
|
+
if synthesized_msg:
|
|
479
|
+
return synthesized_msg, ctx.source_name
|
|
480
|
+
|
|
481
|
+
# 3. If AI environment detected (like Cursor or AI flag) but no task/API available,
|
|
482
|
+
# generate an AI-tailored conventional message using stack and changes
|
|
483
|
+
return None, None
|