git-llm-tool 0.1.12__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.
@@ -0,0 +1,250 @@
1
+ """Git operations helper for git-llm-tool."""
2
+
3
+ import os
4
+ import subprocess
5
+ import tempfile
6
+ from pathlib import Path
7
+ from typing import List, Optional, Tuple
8
+
9
+ from git_llm_tool.core.exceptions import GitError
10
+
11
+
12
+ class GitHelper:
13
+ """Helper class for Git operations."""
14
+
15
+ def __init__(self):
16
+ """Initialize Git helper."""
17
+ self._verify_git_repo()
18
+
19
+ def _verify_git_repo(self) -> None:
20
+ """Verify that we're in a git repository."""
21
+ try:
22
+ self._run_git_command(["git", "rev-parse", "--git-dir"])
23
+ except GitError:
24
+ raise GitError("Not in a git repository")
25
+
26
+ def _run_git_command(self, command: List[str]) -> str:
27
+ """Run a git command and return output.
28
+
29
+ Args:
30
+ command: Git command as list of strings
31
+
32
+ Returns:
33
+ Command output as string
34
+
35
+ Raises:
36
+ GitError: If command fails
37
+ """
38
+ try:
39
+ result = subprocess.run(
40
+ command,
41
+ capture_output=True,
42
+ text=True,
43
+ check=True,
44
+ cwd=os.getcwd()
45
+ )
46
+ return result.stdout.strip()
47
+ except subprocess.CalledProcessError as e:
48
+ stderr = e.stderr.strip() if e.stderr else "Unknown error"
49
+ raise GitError(f"Git command failed: {' '.join(command)}\n{stderr}")
50
+ except FileNotFoundError:
51
+ raise GitError("Git command not found. Is git installed?")
52
+
53
+ def get_staged_diff(self) -> str:
54
+ """Get diff of staged changes.
55
+
56
+ Returns:
57
+ Git diff output of staged changes
58
+
59
+ Raises:
60
+ GitError: If no staged changes or git command fails
61
+ """
62
+ diff = self._run_git_command(["git", "diff", "--cached"])
63
+
64
+ if not diff.strip():
65
+ raise GitError("No staged changes found. Use 'git add' to stage files first.")
66
+
67
+ return diff
68
+
69
+ def get_current_branch(self) -> str:
70
+ """Get current branch name.
71
+
72
+ Returns:
73
+ Current branch name
74
+
75
+ Raises:
76
+ GitError: If git command fails
77
+ """
78
+ return self._run_git_command(["git", "symbolic-ref", "--short", "HEAD"])
79
+
80
+ def get_commit_messages(self, from_ref: Optional[str] = None, to_ref: str = "HEAD") -> List[str]:
81
+ """Get commit messages in a range.
82
+
83
+ Args:
84
+ from_ref: Starting reference (if None, uses last tag)
85
+ to_ref: Ending reference
86
+
87
+ Returns:
88
+ List of commit messages
89
+
90
+ Raises:
91
+ GitError: If git command fails
92
+ """
93
+ if from_ref is None:
94
+ # Try to get last tag
95
+ try:
96
+ from_ref = self._run_git_command(["git", "describe", "--tags", "--abbrev=0"])
97
+ except GitError:
98
+ # If no tags exist, use initial commit
99
+ from_ref = self._run_git_command(["git", "rev-list", "--max-parents=0", "HEAD"])
100
+
101
+ # Get commit messages in range
102
+ commit_range = f"{from_ref}..{to_ref}"
103
+ log_output = self._run_git_command([
104
+ "git", "log", commit_range, "--pretty=format:%s"
105
+ ])
106
+
107
+ if not log_output.strip():
108
+ raise GitError(f"No commits found in range {commit_range}")
109
+
110
+ return [msg.strip() for msg in log_output.split('\n') if msg.strip()]
111
+
112
+ def commit_with_message(self, message: str) -> None:
113
+ """Create a commit with the given message.
114
+
115
+ Args:
116
+ message: Commit message
117
+
118
+ Raises:
119
+ GitError: If commit fails
120
+ """
121
+ try:
122
+ self._run_git_command(["git", "commit", "-m", message])
123
+ except GitError as e:
124
+ if "nothing to commit" in str(e).lower():
125
+ raise GitError("No staged changes to commit")
126
+ raise
127
+
128
+ def open_commit_editor(self, message: str, config=None) -> bool:
129
+ """Open commit message in editor for review.
130
+
131
+ Args:
132
+ message: Initial commit message
133
+ config: Optional AppConfig instance for preferred editor
134
+
135
+ Returns:
136
+ True if commit was made, False if cancelled
137
+
138
+ Raises:
139
+ GitError: If git operations fail
140
+ """
141
+ # Create temporary file with commit message
142
+ with tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False) as temp_file:
143
+ temp_file.write(message)
144
+ temp_file_path = temp_file.name
145
+
146
+ try:
147
+ # Get git editor (with config support)
148
+ editor = self._get_git_editor(config)
149
+
150
+ # Open editor
151
+ result = subprocess.run([editor, temp_file_path])
152
+
153
+ if result.returncode != 0:
154
+ raise GitError("Editor exited with non-zero status")
155
+
156
+ # Read edited message
157
+ with open(temp_file_path, 'r') as f:
158
+ edited_message = f.read().strip()
159
+
160
+ # Check if message was cleared (user cancelled)
161
+ if not edited_message:
162
+ return False
163
+
164
+ # Create commit with edited message
165
+ self.commit_with_message(edited_message)
166
+ return True
167
+
168
+ finally:
169
+ # Clean up temporary file
170
+ try:
171
+ os.unlink(temp_file_path)
172
+ except OSError:
173
+ pass
174
+
175
+ def _get_git_editor(self, config=None) -> str:
176
+ """Get the configured git editor.
177
+
178
+ Args:
179
+ config: Optional AppConfig instance to get preferred editor
180
+
181
+ Returns:
182
+ Editor command
183
+
184
+ Raises:
185
+ GitError: If no editor is configured
186
+ """
187
+ # First priority: app config preferred editor
188
+ if config and config.editor.preferred_editor:
189
+ return config.editor.preferred_editor
190
+
191
+ # Second priority: git config
192
+ try:
193
+ editor = self._run_git_command(["git", "config", "--get", "core.editor"])
194
+ if editor:
195
+ return editor
196
+ except GitError:
197
+ pass
198
+
199
+ # Third priority: environment variables
200
+ for env_var in ["GIT_EDITOR", "VISUAL", "EDITOR"]:
201
+ editor = os.environ.get(env_var)
202
+ if editor:
203
+ return editor
204
+
205
+ # Fourth priority: default editors by platform
206
+ if os.name == 'nt':
207
+ # Windows
208
+ return "notepad"
209
+ else:
210
+ # Unix-like systems
211
+ for default_editor in ["nano", "vim", "vi"]:
212
+ try:
213
+ subprocess.run(["which", default_editor],
214
+ capture_output=True, check=True)
215
+ return default_editor
216
+ except (subprocess.CalledProcessError, FileNotFoundError):
217
+ continue
218
+
219
+ raise GitError("No suitable editor found. Please set core.editor in git config or editor.preferred_editor in git-llm config")
220
+
221
+ def get_repository_info(self) -> dict:
222
+ """Get basic repository information.
223
+
224
+ Returns:
225
+ Dictionary with repository info
226
+ """
227
+ try:
228
+ return {
229
+ "branch": self.get_current_branch(),
230
+ "has_staged_changes": bool(self._run_git_command(["git", "diff", "--cached", "--name-only"])),
231
+ "has_unstaged_changes": bool(self._run_git_command(["git", "diff", "--name-only"])),
232
+ "repository_root": self._run_git_command(["git", "rev-parse", "--show-toplevel"])
233
+ }
234
+ except GitError:
235
+ return {}
236
+
237
+ def is_clean_workspace(self) -> bool:
238
+ """Check if workspace has no uncommitted changes.
239
+
240
+ Returns:
241
+ True if workspace is clean
242
+ """
243
+ try:
244
+ staged = self._run_git_command(["git", "diff", "--cached", "--name-only"])
245
+ unstaged = self._run_git_command(["git", "diff", "--name-only"])
246
+ untracked = self._run_git_command(["git", "ls-files", "--others", "--exclude-standard"])
247
+
248
+ return not (staged or unstaged or untracked)
249
+ except GitError:
250
+ return False
@@ -0,0 +1,238 @@
1
+ """Jira integration helper for git-llm-tool."""
2
+
3
+ import re
4
+ import click
5
+ from typing import Optional, Tuple
6
+
7
+ from git_llm_tool.core.config import AppConfig
8
+ from git_llm_tool.core.git_helper import GitHelper
9
+ from git_llm_tool.core.exceptions import JiraError
10
+
11
+
12
+ class JiraHelper:
13
+ """Helper class for Jira integration."""
14
+
15
+ def __init__(self, config: AppConfig, git_helper: GitHelper):
16
+ """Initialize Jira helper.
17
+
18
+ Args:
19
+ config: Application configuration
20
+ git_helper: Git helper instance
21
+ """
22
+ self.config = config
23
+ self.git_helper = git_helper
24
+
25
+ def get_jira_context(
26
+ self, verbose: bool = False
27
+ ) -> Tuple[Optional[str], Optional[str]]:
28
+ """Get Jira ticket and work hours context.
29
+
30
+ Args:
31
+ verbose: Enable verbose output
32
+
33
+ Returns:
34
+ Tuple of (jira_ticket, work_hours)
35
+ """
36
+ if not self.config.jira.enabled:
37
+ if verbose:
38
+ click.echo("🔒 Jira integration is disabled")
39
+ return None, None
40
+
41
+ # Try to extract ticket from branch name
42
+ jira_ticket = self._extract_ticket_from_branch()
43
+
44
+ if jira_ticket:
45
+ if verbose:
46
+ click.echo(f"🎯 Auto-detected Jira ticket: {jira_ticket}")
47
+ else:
48
+ # Interactive prompt for ticket
49
+ jira_ticket = self._prompt_for_ticket()
50
+
51
+ # Interactive prompt for work hours
52
+ work_hours = self._prompt_for_work_hours()
53
+
54
+ return jira_ticket, work_hours
55
+
56
+ def _extract_ticket_from_branch(self) -> Optional[str]:
57
+ """Extract Jira ticket from current branch name using regex.
58
+
59
+ Returns:
60
+ Jira ticket number if found, None otherwise
61
+ """
62
+ try:
63
+ branch_name = self.git_helper.get_current_branch()
64
+
65
+ # Use ticket pattern to extract Jira ticket
66
+ if self.config.jira.ticket_pattern:
67
+ match = re.search(self.config.jira.ticket_pattern, branch_name)
68
+ if match:
69
+ # If the pattern has capture groups, use the first one
70
+ if match.groups():
71
+ return match.group(1)
72
+ else:
73
+ # If no capture groups, use the whole match
74
+ return match.group(0)
75
+
76
+ except Exception:
77
+ # Ignore any errors in regex matching or git operations
78
+ pass
79
+
80
+ return None
81
+
82
+ def _is_jira_ticket_format(self, text: str) -> bool:
83
+ """Check if text matches typical Jira ticket format.
84
+
85
+ Args:
86
+ text: Text to check
87
+
88
+ Returns:
89
+ True if text looks like a Jira ticket (e.g., PROJECT-123)
90
+ """
91
+ import re
92
+
93
+ # Common Jira ticket format: UPPERCASE-DIGITS
94
+ return bool(re.match(r"^[A-Z]+-\d+$", text))
95
+
96
+ def _prompt_for_ticket(self) -> Optional[str]:
97
+ """Interactively prompt user for Jira ticket.
98
+
99
+ Returns:
100
+ Jira ticket number or None if skipped
101
+ """
102
+ click.echo("\n🎫 Jira Integration")
103
+ try:
104
+ ticket = click.prompt(
105
+ "Enter Jira ticket number (or press Enter to skip)",
106
+ default="",
107
+ show_default=False,
108
+ ).strip()
109
+ except (KeyboardInterrupt, click.Abort):
110
+ click.echo("\n⏭️ Skipping Jira integration")
111
+ return None
112
+
113
+ if not ticket:
114
+ return None
115
+
116
+ # Basic validation - should look like a Jira ticket
117
+ if not re.match(r"^[A-Z]+-\d+$", ticket.upper()):
118
+ click.echo(
119
+ "⚠️ Warning: Ticket format doesn't look like standard Jira format (e.g., PROJ-123)"
120
+ )
121
+ if not click.confirm("Continue anyway?"):
122
+ return None
123
+
124
+ return ticket.upper()
125
+
126
+ def _prompt_for_work_hours(self) -> Optional[str]:
127
+ """Interactively prompt user for work hours.
128
+
129
+ Returns:
130
+ Work hours string or None if skipped
131
+ """
132
+ work_hours = click.prompt(
133
+ "Enter work hours (e.g., '1h 30m', '2h', '45m', '1d 2h', '1w 3d 4h 30m') or press Enter to skip",
134
+ default="",
135
+ show_default=False,
136
+ ).strip()
137
+
138
+ if not work_hours:
139
+ return None
140
+
141
+ # Basic validation for work hours format
142
+ if not re.match(
143
+ r"^(\d+w\s*)?(\d+d\s*)?(\d+h\s*)?(\d+m)?$",
144
+ work_hours.lower().replace(" ", ""),
145
+ ):
146
+ click.echo(
147
+ "⚠️ Warning: Work hours format should be like '1h 30m', '2h', '45m', '1d 2h', or '1w 2d 3h 30m'"
148
+ )
149
+ if not click.confirm("Continue anyway?"):
150
+ return None
151
+
152
+ # Normalize the work hours format
153
+ normalized_hours = self._normalize_work_hours(work_hours)
154
+ return normalized_hours
155
+
156
+ def _normalize_work_hours(self, work_hours: str) -> str:
157
+ """Normalize work hours to standard format: 0w 0d 0h 0m.
158
+
159
+ Args:
160
+ work_hours: Input work hours string (e.g., '1h 30m', '2h', '45m')
161
+
162
+ Returns:
163
+ Normalized work hours string in format '0w 0d 0h 0m'
164
+ """
165
+ # Initialize all time units to 0
166
+ weeks = 0
167
+ days = 0
168
+ hours = 0
169
+ minutes = 0
170
+
171
+ # Clean the input and make it lowercase
172
+ clean_input = work_hours.lower().replace(" ", "")
173
+
174
+ # Extract weeks
175
+ week_match = re.search(r"(\d+)w", clean_input)
176
+ if week_match:
177
+ weeks = int(week_match.group(1))
178
+
179
+ # Extract days
180
+ day_match = re.search(r"(\d+)d", clean_input)
181
+ if day_match:
182
+ days = int(day_match.group(1))
183
+
184
+ # Extract hours
185
+ hour_match = re.search(r"(\d+)h", clean_input)
186
+ if hour_match:
187
+ hours = int(hour_match.group(1))
188
+
189
+ # Extract minutes
190
+ minute_match = re.search(r"(\d+)m", clean_input)
191
+ if minute_match:
192
+ minutes = int(minute_match.group(1))
193
+
194
+ # Return in standard format
195
+ return f"{weeks}w {days}d {hours}h {minutes}m"
196
+
197
+ def format_jira_info(
198
+ self, jira_ticket: Optional[str], work_hours: Optional[str]
199
+ ) -> str:
200
+ """Format Jira information for display.
201
+
202
+ Args:
203
+ jira_ticket: Jira ticket number
204
+ work_hours: Work hours
205
+
206
+ Returns:
207
+ Formatted string for display
208
+ """
209
+ info_parts = []
210
+
211
+ if jira_ticket:
212
+ info_parts.append(f"🎫 Ticket: {jira_ticket}")
213
+
214
+ if work_hours:
215
+ info_parts.append(f"⏱️ Time: {work_hours}")
216
+
217
+ return " | ".join(info_parts) if info_parts else "No Jira information"
218
+
219
+ def validate_config(self) -> bool:
220
+ """Validate Jira configuration.
221
+
222
+ Returns:
223
+ True if configuration is valid
224
+
225
+ Raises:
226
+ JiraError: If configuration is invalid
227
+ """
228
+ if not self.config.jira.enabled:
229
+ return True
230
+
231
+ # Validate ticket pattern regex if provided
232
+ if self.config.jira.ticket_pattern:
233
+ try:
234
+ re.compile(self.config.jira.ticket_pattern)
235
+ except re.error as e:
236
+ raise JiraError(f"Invalid ticket pattern regex: {e}")
237
+
238
+ return True
@@ -0,0 +1,136 @@
1
+ """Rate limiting and retry mechanisms for API calls."""
2
+
3
+ import time
4
+ import random
5
+ from typing import Any, Callable, Optional
6
+ from dataclasses import dataclass
7
+ from functools import wraps
8
+ import logging
9
+
10
+ from git_llm_tool.core.exceptions import ApiError
11
+
12
+
13
+ @dataclass
14
+ class RateLimitConfig:
15
+ """Configuration for rate limiting."""
16
+ max_retries: int = 5
17
+ initial_delay: float = 1.0
18
+ max_delay: float = 60.0
19
+ backoff_multiplier: float = 2.0
20
+ jitter: bool = True
21
+ rate_limit_delay: float = 0.5 # Minimum delay between requests
22
+
23
+
24
+ class RateLimiter:
25
+ """Rate limiter with exponential backoff and jitter."""
26
+
27
+ def __init__(self, config: RateLimitConfig):
28
+ self.config = config
29
+ self.last_request_time = 0.0
30
+ self.logger = logging.getLogger(__name__)
31
+
32
+ def wait_if_needed(self):
33
+ """Ensure minimum delay between requests."""
34
+ current_time = time.time()
35
+ time_since_last = current_time - self.last_request_time
36
+
37
+ if time_since_last < self.config.rate_limit_delay:
38
+ sleep_time = self.config.rate_limit_delay - time_since_last
39
+ time.sleep(sleep_time)
40
+
41
+ self.last_request_time = time.time()
42
+
43
+ def exponential_backoff(self, attempt: int) -> float:
44
+ """Calculate delay for exponential backoff."""
45
+ delay = min(
46
+ self.config.initial_delay * (self.config.backoff_multiplier ** attempt),
47
+ self.config.max_delay
48
+ )
49
+
50
+ if self.config.jitter:
51
+ # Add jitter to prevent thundering herd
52
+ delay *= (0.5 + random.random() * 0.5)
53
+
54
+ return delay
55
+
56
+ def retry_with_backoff(self, func: Callable, *args, **kwargs) -> Any:
57
+ """Execute function with exponential backoff retry."""
58
+ last_exception = None
59
+
60
+ for attempt in range(self.config.max_retries):
61
+ try:
62
+ # Wait before making request (rate limiting)
63
+ self.wait_if_needed()
64
+
65
+ # Execute the function
66
+ return func(*args, **kwargs)
67
+
68
+ except Exception as e:
69
+ last_exception = e
70
+
71
+ # Check if it's a rate limit error
72
+ is_rate_limit_error = self._is_rate_limit_error(e)
73
+
74
+ if not is_rate_limit_error and attempt == 0:
75
+ # If it's not a rate limit error, don't retry on first attempt
76
+ # unless it's a network error
77
+ if not self._is_retryable_error(e):
78
+ raise e
79
+
80
+ if attempt == self.config.max_retries - 1:
81
+ # Last attempt, re-raise the exception
82
+ break
83
+
84
+ delay = self.exponential_backoff(attempt)
85
+ self.logger.warning(
86
+ f"API call failed (attempt {attempt + 1}/{self.config.max_retries}): {e}. "
87
+ f"Retrying in {delay:.2f}s..."
88
+ )
89
+ time.sleep(delay)
90
+
91
+ # All retries exhausted
92
+ raise ApiError(f"API call failed after {self.config.max_retries} attempts: {last_exception}")
93
+
94
+ def _is_rate_limit_error(self, error: Exception) -> bool:
95
+ """Check if error is related to rate limiting."""
96
+ error_str = str(error).lower()
97
+ rate_limit_indicators = [
98
+ "rate limit",
99
+ "too many requests",
100
+ "quota exceeded",
101
+ "429",
102
+ "throttled",
103
+ "rate_limit_exceeded"
104
+ ]
105
+ return any(indicator in error_str for indicator in rate_limit_indicators)
106
+
107
+ def _is_retryable_error(self, error: Exception) -> bool:
108
+ """Check if error is retryable."""
109
+ error_str = str(error).lower()
110
+ retryable_indicators = [
111
+ "timeout",
112
+ "connection",
113
+ "network",
114
+ "502",
115
+ "503",
116
+ "504",
117
+ "internal server error",
118
+ "service unavailable",
119
+ "gateway timeout"
120
+ ]
121
+ return any(indicator in error_str for indicator in retryable_indicators)
122
+
123
+
124
+ def rate_limited(config: Optional[RateLimitConfig] = None):
125
+ """Decorator for rate limiting API calls."""
126
+ if config is None:
127
+ config = RateLimitConfig()
128
+
129
+ rate_limiter = RateLimiter(config)
130
+
131
+ def decorator(func: Callable) -> Callable:
132
+ @wraps(func)
133
+ def wrapper(*args, **kwargs):
134
+ return rate_limiter.retry_with_backoff(func, *args, **kwargs)
135
+ return wrapper
136
+ return decorator