git-llm-tool 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.
Potentially problematic release.
This version of git-llm-tool might be problematic. Click here for more details.
- git_llm_tool/__init__.py +5 -0
- git_llm_tool/__main__.py +6 -0
- git_llm_tool/cli.py +165 -0
- git_llm_tool/commands/__init__.py +1 -0
- git_llm_tool/commands/commit_cmd.py +127 -0
- git_llm_tool/core/__init__.py +1 -0
- git_llm_tool/core/config.py +298 -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 +168 -0
- git_llm_tool/providers/__init__.py +17 -0
- git_llm_tool/providers/anthropic.py +90 -0
- git_llm_tool/providers/azure_openai.py +112 -0
- git_llm_tool/providers/base.py +202 -0
- git_llm_tool/providers/factory.py +77 -0
- git_llm_tool/providers/gemini.py +83 -0
- git_llm_tool/providers/openai.py +93 -0
- git_llm_tool-0.1.0.dist-info/LICENSE +21 -0
- git_llm_tool-0.1.0.dist-info/METADATA +415 -0
- git_llm_tool-0.1.0.dist-info/RECORD +22 -0
- git_llm_tool-0.1.0.dist-info/WHEEL +4 -0
- git_llm_tool-0.1.0.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,168 @@
|
|
|
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(self, verbose: bool = False) -> Tuple[Optional[str], Optional[str]]:
|
|
26
|
+
"""Get Jira ticket and work hours context.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
verbose: Enable verbose output
|
|
30
|
+
|
|
31
|
+
Returns:
|
|
32
|
+
Tuple of (jira_ticket, work_hours)
|
|
33
|
+
"""
|
|
34
|
+
if not self.config.jira.enabled:
|
|
35
|
+
if verbose:
|
|
36
|
+
click.echo("🔒 Jira integration is disabled")
|
|
37
|
+
return None, None
|
|
38
|
+
|
|
39
|
+
# Try to extract ticket from branch name
|
|
40
|
+
jira_ticket = self._extract_ticket_from_branch()
|
|
41
|
+
|
|
42
|
+
if jira_ticket:
|
|
43
|
+
if verbose:
|
|
44
|
+
click.echo(f"🎯 Auto-detected Jira ticket: {jira_ticket}")
|
|
45
|
+
else:
|
|
46
|
+
# Interactive prompt for ticket
|
|
47
|
+
jira_ticket = self._prompt_for_ticket()
|
|
48
|
+
|
|
49
|
+
# Interactive prompt for work hours
|
|
50
|
+
work_hours = self._prompt_for_work_hours()
|
|
51
|
+
|
|
52
|
+
return jira_ticket, work_hours
|
|
53
|
+
|
|
54
|
+
def _extract_ticket_from_branch(self) -> Optional[str]:
|
|
55
|
+
"""Extract Jira ticket from current branch name using regex.
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
Jira ticket number if found, None otherwise
|
|
59
|
+
"""
|
|
60
|
+
if not self.config.jira.branch_regex:
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
try:
|
|
64
|
+
branch_name = self.git_helper.get_current_branch()
|
|
65
|
+
pattern = self.config.jira.branch_regex
|
|
66
|
+
|
|
67
|
+
match = re.search(pattern, branch_name)
|
|
68
|
+
if match:
|
|
69
|
+
# If regex 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 _prompt_for_ticket(self) -> Optional[str]:
|
|
83
|
+
"""Interactively prompt user for Jira ticket.
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
Jira ticket number or None if skipped
|
|
87
|
+
"""
|
|
88
|
+
click.echo("\n🎫 Jira Integration")
|
|
89
|
+
ticket = click.prompt(
|
|
90
|
+
"Enter Jira ticket number (or press Enter to skip)",
|
|
91
|
+
default="",
|
|
92
|
+
show_default=False
|
|
93
|
+
).strip()
|
|
94
|
+
|
|
95
|
+
if not ticket:
|
|
96
|
+
return None
|
|
97
|
+
|
|
98
|
+
# Basic validation - should look like a Jira ticket
|
|
99
|
+
if not re.match(r'^[A-Z]+-\d+$', ticket.upper()):
|
|
100
|
+
click.echo("⚠️ Warning: Ticket format doesn't look like standard Jira format (e.g., PROJ-123)")
|
|
101
|
+
if not click.confirm("Continue anyway?"):
|
|
102
|
+
return None
|
|
103
|
+
|
|
104
|
+
return ticket.upper()
|
|
105
|
+
|
|
106
|
+
def _prompt_for_work_hours(self) -> Optional[str]:
|
|
107
|
+
"""Interactively prompt user for work hours.
|
|
108
|
+
|
|
109
|
+
Returns:
|
|
110
|
+
Work hours string or None if skipped
|
|
111
|
+
"""
|
|
112
|
+
work_hours = click.prompt(
|
|
113
|
+
"Enter work hours (e.g., '1h 30m', '2h', '45m') or press Enter to skip",
|
|
114
|
+
default="",
|
|
115
|
+
show_default=False
|
|
116
|
+
).strip()
|
|
117
|
+
|
|
118
|
+
if not work_hours:
|
|
119
|
+
return None
|
|
120
|
+
|
|
121
|
+
# Basic validation for work hours format
|
|
122
|
+
if not re.match(r'^(\d+h\s*)?(\d+m)?$', work_hours.lower().replace(' ', '')):
|
|
123
|
+
click.echo("⚠️ Warning: Work hours format should be like '1h 30m', '2h', or '45m'")
|
|
124
|
+
if not click.confirm("Continue anyway?"):
|
|
125
|
+
return None
|
|
126
|
+
|
|
127
|
+
return work_hours
|
|
128
|
+
|
|
129
|
+
def format_jira_info(self, jira_ticket: Optional[str], work_hours: Optional[str]) -> str:
|
|
130
|
+
"""Format Jira information for display.
|
|
131
|
+
|
|
132
|
+
Args:
|
|
133
|
+
jira_ticket: Jira ticket number
|
|
134
|
+
work_hours: Work hours
|
|
135
|
+
|
|
136
|
+
Returns:
|
|
137
|
+
Formatted string for display
|
|
138
|
+
"""
|
|
139
|
+
info_parts = []
|
|
140
|
+
|
|
141
|
+
if jira_ticket:
|
|
142
|
+
info_parts.append(f"🎫 Ticket: {jira_ticket}")
|
|
143
|
+
|
|
144
|
+
if work_hours:
|
|
145
|
+
info_parts.append(f"⏱️ Time: {work_hours}")
|
|
146
|
+
|
|
147
|
+
return " | ".join(info_parts) if info_parts else "No Jira information"
|
|
148
|
+
|
|
149
|
+
def validate_config(self) -> bool:
|
|
150
|
+
"""Validate Jira configuration.
|
|
151
|
+
|
|
152
|
+
Returns:
|
|
153
|
+
True if configuration is valid
|
|
154
|
+
|
|
155
|
+
Raises:
|
|
156
|
+
JiraError: If configuration is invalid
|
|
157
|
+
"""
|
|
158
|
+
if not self.config.jira.enabled:
|
|
159
|
+
return True
|
|
160
|
+
|
|
161
|
+
# Validate regex if provided
|
|
162
|
+
if self.config.jira.branch_regex:
|
|
163
|
+
try:
|
|
164
|
+
re.compile(self.config.jira.branch_regex)
|
|
165
|
+
except re.error as e:
|
|
166
|
+
raise JiraError(f"Invalid branch regex pattern: {e}")
|
|
167
|
+
|
|
168
|
+
return True
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""LLM providers module."""
|
|
2
|
+
|
|
3
|
+
from git_llm_tool.providers.base import LlmProvider
|
|
4
|
+
from git_llm_tool.providers.openai import OpenAiProvider
|
|
5
|
+
from git_llm_tool.providers.azure_openai import AzureOpenAiProvider
|
|
6
|
+
from git_llm_tool.providers.anthropic import AnthropicProvider
|
|
7
|
+
from git_llm_tool.providers.gemini import GeminiProvider
|
|
8
|
+
from git_llm_tool.providers.factory import get_provider
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"LlmProvider",
|
|
12
|
+
"OpenAiProvider",
|
|
13
|
+
"AzureOpenAiProvider",
|
|
14
|
+
"AnthropicProvider",
|
|
15
|
+
"GeminiProvider",
|
|
16
|
+
"get_provider"
|
|
17
|
+
]
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Anthropic Claude LLM provider implementation."""
|
|
2
|
+
|
|
3
|
+
from typing import Optional
|
|
4
|
+
import anthropic
|
|
5
|
+
|
|
6
|
+
from git_llm_tool.core.config import AppConfig
|
|
7
|
+
from git_llm_tool.core.exceptions import ApiError
|
|
8
|
+
from git_llm_tool.providers.base import LlmProvider
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class AnthropicProvider(LlmProvider):
|
|
12
|
+
"""Anthropic Claude provider implementation."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, config: AppConfig):
|
|
15
|
+
"""Initialize Anthropic provider."""
|
|
16
|
+
super().__init__(config)
|
|
17
|
+
|
|
18
|
+
# Get API key
|
|
19
|
+
api_key = config.llm.api_keys.get("anthropic")
|
|
20
|
+
if not api_key:
|
|
21
|
+
raise ApiError("Anthropic API key not found in configuration")
|
|
22
|
+
|
|
23
|
+
# Initialize Anthropic client
|
|
24
|
+
self.client = anthropic.Anthropic(api_key=api_key)
|
|
25
|
+
|
|
26
|
+
# Determine model
|
|
27
|
+
model = config.llm.default_model
|
|
28
|
+
if not model.startswith("claude-"):
|
|
29
|
+
# Fallback to Claude 3.5 Sonnet if model doesn't look like Anthropic model
|
|
30
|
+
model = "claude-3-5-sonnet-20241024"
|
|
31
|
+
self.model = model
|
|
32
|
+
|
|
33
|
+
def generate_commit_message(
|
|
34
|
+
self,
|
|
35
|
+
diff: str,
|
|
36
|
+
jira_ticket: Optional[str] = None,
|
|
37
|
+
work_hours: Optional[str] = None,
|
|
38
|
+
**kwargs
|
|
39
|
+
) -> str:
|
|
40
|
+
"""Generate commit message using Anthropic API."""
|
|
41
|
+
prompt = self._build_commit_prompt(diff, jira_ticket, work_hours)
|
|
42
|
+
return self._make_api_call(prompt, **kwargs)
|
|
43
|
+
|
|
44
|
+
def generate_changelog(
|
|
45
|
+
self,
|
|
46
|
+
commit_messages: list[str],
|
|
47
|
+
**kwargs
|
|
48
|
+
) -> str:
|
|
49
|
+
"""Generate changelog using Anthropic API."""
|
|
50
|
+
prompt = self._build_changelog_prompt(commit_messages)
|
|
51
|
+
return self._make_api_call(prompt, **kwargs)
|
|
52
|
+
|
|
53
|
+
def _make_api_call(self, prompt: str, **kwargs) -> str:
|
|
54
|
+
"""Make API call to Anthropic."""
|
|
55
|
+
try:
|
|
56
|
+
# Default parameters
|
|
57
|
+
api_params = {
|
|
58
|
+
"model": self.model,
|
|
59
|
+
"max_tokens": kwargs.get("max_tokens", 150),
|
|
60
|
+
"temperature": kwargs.get("temperature", 0.7),
|
|
61
|
+
"messages": [
|
|
62
|
+
{
|
|
63
|
+
"role": "user",
|
|
64
|
+
"content": prompt
|
|
65
|
+
}
|
|
66
|
+
]
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
# Make API call
|
|
70
|
+
response = self.client.messages.create(**api_params)
|
|
71
|
+
|
|
72
|
+
# Extract response text
|
|
73
|
+
if response.content and len(response.content) > 0:
|
|
74
|
+
# Anthropic returns a list of content blocks
|
|
75
|
+
content = response.content[0]
|
|
76
|
+
if hasattr(content, 'text'):
|
|
77
|
+
return content.text.strip()
|
|
78
|
+
|
|
79
|
+
raise ApiError("Empty response from Anthropic API")
|
|
80
|
+
|
|
81
|
+
except anthropic.AuthenticationError:
|
|
82
|
+
raise ApiError("Invalid Anthropic API key")
|
|
83
|
+
except anthropic.RateLimitError:
|
|
84
|
+
raise ApiError("Anthropic API rate limit exceeded")
|
|
85
|
+
except anthropic.APIConnectionError:
|
|
86
|
+
raise ApiError("Failed to connect to Anthropic API")
|
|
87
|
+
except anthropic.APIError as e:
|
|
88
|
+
raise ApiError(f"Anthropic API error: {e}")
|
|
89
|
+
except Exception as e:
|
|
90
|
+
raise ApiError(f"Unexpected error calling Anthropic API: {e}")
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Azure OpenAI LLM provider implementation."""
|
|
2
|
+
|
|
3
|
+
from typing import Optional
|
|
4
|
+
import openai
|
|
5
|
+
|
|
6
|
+
from git_llm_tool.core.config import AppConfig
|
|
7
|
+
from git_llm_tool.core.exceptions import ApiError
|
|
8
|
+
from git_llm_tool.providers.base import LlmProvider
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class AzureOpenAiProvider(LlmProvider):
|
|
12
|
+
"""Azure OpenAI provider implementation."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, config: AppConfig):
|
|
15
|
+
"""Initialize Azure OpenAI provider."""
|
|
16
|
+
super().__init__(config)
|
|
17
|
+
|
|
18
|
+
# Get Azure OpenAI configuration
|
|
19
|
+
azure_config = config.llm.azure_openai
|
|
20
|
+
if not azure_config.get("endpoint"):
|
|
21
|
+
raise ApiError("Azure OpenAI endpoint not found in configuration")
|
|
22
|
+
|
|
23
|
+
api_key = config.llm.api_keys.get("azure_openai")
|
|
24
|
+
if not api_key:
|
|
25
|
+
raise ApiError("Azure OpenAI API key not found in configuration")
|
|
26
|
+
|
|
27
|
+
# Default values for Azure OpenAI
|
|
28
|
+
api_version = azure_config.get("api_version", "2024-02-15-preview")
|
|
29
|
+
deployment_name = azure_config.get("deployment_name")
|
|
30
|
+
|
|
31
|
+
# Initialize Azure OpenAI client
|
|
32
|
+
self.client = openai.AzureOpenAI(
|
|
33
|
+
api_key=api_key,
|
|
34
|
+
api_version=api_version,
|
|
35
|
+
azure_endpoint=azure_config["endpoint"]
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
# Use deployment name if provided, otherwise use model name
|
|
39
|
+
if deployment_name:
|
|
40
|
+
self.model = deployment_name
|
|
41
|
+
else:
|
|
42
|
+
# For Azure, we typically use deployment names instead of model names
|
|
43
|
+
model = config.llm.default_model
|
|
44
|
+
if model.startswith(("gpt-", "o1-")):
|
|
45
|
+
self.model = model
|
|
46
|
+
else:
|
|
47
|
+
# Default to gpt-4o deployment if model doesn't look like OpenAI model
|
|
48
|
+
self.model = "gpt-4o"
|
|
49
|
+
|
|
50
|
+
def generate_commit_message(
|
|
51
|
+
self,
|
|
52
|
+
diff: str,
|
|
53
|
+
jira_ticket: Optional[str] = None,
|
|
54
|
+
work_hours: Optional[str] = None,
|
|
55
|
+
**kwargs
|
|
56
|
+
) -> str:
|
|
57
|
+
"""Generate commit message using Azure OpenAI API."""
|
|
58
|
+
prompt = self._build_commit_prompt(diff, jira_ticket, work_hours)
|
|
59
|
+
return self._make_api_call(prompt, **kwargs)
|
|
60
|
+
|
|
61
|
+
def generate_changelog(
|
|
62
|
+
self,
|
|
63
|
+
commit_messages: list[str],
|
|
64
|
+
**kwargs
|
|
65
|
+
) -> str:
|
|
66
|
+
"""Generate changelog using Azure OpenAI API."""
|
|
67
|
+
prompt = self._build_changelog_prompt(commit_messages)
|
|
68
|
+
return self._make_api_call(prompt, **kwargs)
|
|
69
|
+
|
|
70
|
+
def _make_api_call(self, prompt: str, **kwargs) -> str:
|
|
71
|
+
"""Make API call to Azure OpenAI."""
|
|
72
|
+
try:
|
|
73
|
+
# Default parameters
|
|
74
|
+
api_params = {
|
|
75
|
+
"model": self.model,
|
|
76
|
+
"messages": [
|
|
77
|
+
{
|
|
78
|
+
"role": "system",
|
|
79
|
+
"content": "You are a helpful assistant that generates git commit messages and changelogs."
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
"role": "user",
|
|
83
|
+
"content": prompt
|
|
84
|
+
}
|
|
85
|
+
],
|
|
86
|
+
"max_tokens": kwargs.get("max_tokens", 150),
|
|
87
|
+
"temperature": kwargs.get("temperature", 0.7),
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
# Make API call
|
|
91
|
+
response = self.client.chat.completions.create(**api_params)
|
|
92
|
+
|
|
93
|
+
# Extract response text
|
|
94
|
+
if response.choices and len(response.choices) > 0:
|
|
95
|
+
content = response.choices[0].message.content
|
|
96
|
+
if content:
|
|
97
|
+
return content.strip()
|
|
98
|
+
|
|
99
|
+
raise ApiError("Empty response from Azure OpenAI API")
|
|
100
|
+
|
|
101
|
+
except openai.AuthenticationError:
|
|
102
|
+
raise ApiError("Invalid Azure OpenAI API key")
|
|
103
|
+
except openai.RateLimitError:
|
|
104
|
+
raise ApiError("Azure OpenAI API rate limit exceeded")
|
|
105
|
+
except openai.APIConnectionError:
|
|
106
|
+
raise ApiError("Failed to connect to Azure OpenAI API")
|
|
107
|
+
except openai.NotFoundError:
|
|
108
|
+
raise ApiError(f"Azure OpenAI deployment '{self.model}' not found. Check your deployment name.")
|
|
109
|
+
except openai.APIError as e:
|
|
110
|
+
raise ApiError(f"Azure OpenAI API error: {e}")
|
|
111
|
+
except Exception as e:
|
|
112
|
+
raise ApiError(f"Unexpected error calling Azure OpenAI API: {e}")
|