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.
- git_llm_tool/__init__.py +5 -0
- git_llm_tool/__main__.py +6 -0
- git_llm_tool/cli.py +167 -0
- git_llm_tool/commands/__init__.py +1 -0
- git_llm_tool/commands/changelog_cmd.py +189 -0
- git_llm_tool/commands/commit_cmd.py +134 -0
- git_llm_tool/core/__init__.py +1 -0
- git_llm_tool/core/config.py +352 -0
- git_llm_tool/core/diff_optimizer.py +206 -0
- git_llm_tool/core/exceptions.py +26 -0
- git_llm_tool/core/git_helper.py +250 -0
- git_llm_tool/core/jira_helper.py +238 -0
- git_llm_tool/core/rate_limiter.py +136 -0
- git_llm_tool/core/smart_chunker.py +262 -0
- git_llm_tool/core/token_counter.py +169 -0
- git_llm_tool/providers/__init__.py +21 -0
- git_llm_tool/providers/anthropic_langchain.py +42 -0
- git_llm_tool/providers/azure_openai_langchain.py +59 -0
- git_llm_tool/providers/base.py +203 -0
- git_llm_tool/providers/factory.py +85 -0
- git_llm_tool/providers/gemini_langchain.py +57 -0
- git_llm_tool/providers/langchain_base.py +608 -0
- git_llm_tool/providers/ollama_langchain.py +45 -0
- git_llm_tool/providers/openai_langchain.py +42 -0
- git_llm_tool-0.1.12.dist-info/LICENSE +21 -0
- git_llm_tool-0.1.12.dist-info/METADATA +645 -0
- git_llm_tool-0.1.12.dist-info/RECORD +29 -0
- git_llm_tool-0.1.12.dist-info/WHEEL +4 -0
- git_llm_tool-0.1.12.dist-info/entry_points.txt +3 -0
|
@@ -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
|