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.

@@ -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}")