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 ADDED
@@ -0,0 +1,6 @@
1
+ """
2
+ Autopush - Professional Multi-Language & Multi-Framework Git Automation
3
+ """
4
+
5
+ __version__ = "1.1.0"
6
+ __author__ = "PushPilot Developers"
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