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,5 @@
1
+ """Git-LLM-Tool: AI-powered git commit message and changelog generator."""
2
+
3
+ __version__ = "0.1.0"
4
+ __author__ = "skyler-gogolook"
5
+ __email__ = "skyler.lo@gogolook.com"
@@ -0,0 +1,6 @@
1
+ """Entry point for running git-llm-tool as a module."""
2
+
3
+ from git_llm_tool.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
git_llm_tool/cli.py ADDED
@@ -0,0 +1,165 @@
1
+ """Main CLI interface for git-llm-tool."""
2
+
3
+ import click
4
+ from pathlib import Path
5
+
6
+ from git_llm_tool import __version__
7
+ from git_llm_tool.core.config import ConfigLoader, get_config
8
+ from git_llm_tool.core.exceptions import ConfigError
9
+ from git_llm_tool.commands.commit_cmd import execute_commit
10
+
11
+
12
+ @click.group()
13
+ @click.version_option(version=__version__)
14
+ @click.option(
15
+ "--verbose", "-v", is_flag=True, help="Enable verbose output"
16
+ )
17
+ @click.pass_context
18
+ def main(ctx, verbose):
19
+ """AI-powered git commit message and changelog generator."""
20
+ ctx.ensure_object(dict)
21
+ ctx.obj['verbose'] = verbose
22
+
23
+
24
+ @main.command()
25
+ @click.option(
26
+ "--apply", "-a", is_flag=True,
27
+ help="Apply the commit message directly without opening editor"
28
+ )
29
+ @click.option(
30
+ "--model", "-m",
31
+ help="LLM model to use (overrides config)"
32
+ )
33
+ @click.option(
34
+ "--language", "-l",
35
+ help="Output language (overrides config)"
36
+ )
37
+ @click.pass_context
38
+ def commit(ctx, apply, model, language):
39
+ """Generate AI-powered commit message from staged changes."""
40
+ verbose = ctx.obj.get('verbose', False) if ctx.obj else False
41
+ execute_commit(apply=apply, model=model, language=language, verbose=verbose)
42
+
43
+
44
+ @main.command()
45
+ @click.option(
46
+ "--from", "from_ref",
47
+ help="Starting reference (default: last tag)"
48
+ )
49
+ @click.option(
50
+ "--to", "to_ref", default="HEAD",
51
+ help="Ending reference (default: HEAD)"
52
+ )
53
+ @click.option(
54
+ "--output", "-o",
55
+ help="Output file (default: stdout)"
56
+ )
57
+ @click.option(
58
+ "--force", "-f", is_flag=True,
59
+ help="Force overwrite existing output file"
60
+ )
61
+ @click.pass_context
62
+ def changelog(ctx, from_ref, to_ref, output, force):
63
+ """Generate changelog from git history."""
64
+ click.echo("📋 Generating changelog...")
65
+
66
+ # This will be implemented in subsequent tasks
67
+ if output:
68
+ click.echo(f"📄 Changelog saved to {output}")
69
+ else:
70
+ click.echo("📄 Changelog output to stdout")
71
+
72
+
73
+ @main.group()
74
+ def config():
75
+ """Configuration management commands."""
76
+ pass
77
+
78
+
79
+ @config.command()
80
+ @click.argument("key")
81
+ @click.argument("value")
82
+ def set(key, value):
83
+ """Set configuration value."""
84
+ try:
85
+ config_loader = ConfigLoader()
86
+ config_loader.set_value(key, value)
87
+
88
+ # Save to global config
89
+ config_loader.save_config()
90
+
91
+ click.echo(f"✅ Set {key} = {value}")
92
+ except ConfigError as e:
93
+ click.echo(f"❌ Configuration error: {e}", err=True)
94
+ except Exception as e:
95
+ click.echo(f"❌ Unexpected error: {e}", err=True)
96
+
97
+
98
+ @config.command()
99
+ @click.argument("key", required=False)
100
+ def get(key):
101
+ """Get configuration value(s)."""
102
+ try:
103
+ config = get_config()
104
+
105
+ if key:
106
+ # Get specific key
107
+ config_loader = ConfigLoader()
108
+ value = config_loader.get_value(key)
109
+ click.echo(f"{key} = {value}")
110
+ else:
111
+ # Show all configuration
112
+ click.echo("📋 Current Configuration:")
113
+ click.echo(f" llm.default_model = {config.llm.default_model}")
114
+ click.echo(f" llm.language = {config.llm.language}")
115
+
116
+ if config.llm.api_keys:
117
+ click.echo(" llm.api_keys:")
118
+ for provider, key_value in config.llm.api_keys.items():
119
+ # Hide API key for security
120
+ masked_key = key_value[:8] + "..." if len(key_value) > 8 else "***"
121
+ click.echo(f" {provider} = {masked_key}")
122
+
123
+ if config.llm.azure_openai:
124
+ click.echo(" llm.azure_openai:")
125
+ for key, value in config.llm.azure_openai.items():
126
+ click.echo(f" {key} = {value}")
127
+
128
+ click.echo(f" jira.enabled = {config.jira.enabled}")
129
+ if config.jira.branch_regex:
130
+ click.echo(f" jira.branch_regex = {config.jira.branch_regex}")
131
+
132
+ if config.editor.preferred_editor:
133
+ click.echo(f" editor.preferred_editor = {config.editor.preferred_editor}")
134
+
135
+ except ConfigError as e:
136
+ click.echo(f"❌ Configuration error: {e}", err=True)
137
+ except Exception as e:
138
+ click.echo(f"❌ Unexpected error: {e}", err=True)
139
+
140
+
141
+ @config.command()
142
+ def init():
143
+ """Initialize configuration file."""
144
+ try:
145
+ config_path = Path.home() / ".git-llm-tool" / "config.yaml"
146
+
147
+ if config_path.exists():
148
+ if not click.confirm(f"Configuration file already exists at {config_path}. Overwrite?"):
149
+ click.echo("❌ Initialization cancelled.")
150
+ return
151
+
152
+ # Create default configuration
153
+ config_loader = ConfigLoader()
154
+ config_loader.save_config(config_path)
155
+
156
+ click.echo(f"✅ Configuration initialized at {config_path}")
157
+ click.echo("💡 You can now set API keys with:")
158
+ click.echo(" git-llm config set llm.api_keys.openai sk-your-key-here")
159
+
160
+ except Exception as e:
161
+ click.echo(f"❌ Failed to initialize configuration: {e}", err=True)
162
+
163
+
164
+ if __name__ == "__main__":
165
+ main()
@@ -0,0 +1 @@
1
+ """Commands module."""
@@ -0,0 +1,127 @@
1
+ """Commit command implementation."""
2
+
3
+ import click
4
+ from typing import Optional
5
+
6
+ from git_llm_tool.core.config import get_config
7
+ from git_llm_tool.core.git_helper import GitHelper
8
+ from git_llm_tool.core.jira_helper import JiraHelper
9
+ from git_llm_tool.core.exceptions import GitError, ApiError, ConfigError, JiraError
10
+ from git_llm_tool.providers import get_provider
11
+
12
+
13
+ def execute_commit(
14
+ apply: bool = False,
15
+ model: Optional[str] = None,
16
+ language: Optional[str] = None,
17
+ verbose: bool = False
18
+ ) -> None:
19
+ """Execute the commit command logic.
20
+
21
+ Args:
22
+ apply: Whether to apply commit directly without editor
23
+ model: Override model from config
24
+ language: Override language from config
25
+ verbose: Enable verbose output
26
+ """
27
+ try:
28
+ # Load configuration
29
+ config = get_config()
30
+
31
+ # Override config with CLI parameters
32
+ if model:
33
+ config.llm.default_model = model
34
+ if language:
35
+ config.llm.language = language
36
+
37
+ if verbose:
38
+ click.echo(f"📄 Using model: {config.llm.default_model}")
39
+ click.echo(f"🌐 Using language: {config.llm.language}")
40
+
41
+ # Initialize Git helper
42
+ git_helper = GitHelper()
43
+
44
+ # Get staged diff
45
+ if verbose:
46
+ click.echo("📊 Getting staged changes...")
47
+
48
+ try:
49
+ diff = git_helper.get_staged_diff()
50
+ except GitError as e:
51
+ click.echo(f"❌ {e}", err=True)
52
+ click.echo("💡 Tip: Use 'git add' to stage files before committing", err=True)
53
+ return
54
+
55
+ if verbose:
56
+ click.echo(f"📝 Found {len(diff.splitlines())} lines of changes")
57
+
58
+ # Get LLM provider
59
+ try:
60
+ provider = get_provider(config)
61
+ if verbose:
62
+ click.echo(f"🤖 Using provider: {provider.__class__.__name__}")
63
+ except ApiError as e:
64
+ click.echo(f"❌ {e}", err=True)
65
+ return
66
+
67
+ # Initialize Jira helper and get Jira context
68
+ jira_helper = JiraHelper(config, git_helper)
69
+
70
+ try:
71
+ jira_helper.validate_config()
72
+ jira_ticket, work_hours = jira_helper.get_jira_context(verbose=verbose)
73
+
74
+ if jira_ticket or work_hours:
75
+ jira_info = jira_helper.format_jira_info(jira_ticket, work_hours)
76
+ click.echo(f"📋 Jira Info: {jira_info}")
77
+
78
+ except JiraError as e:
79
+ click.echo(f"⚠️ Jira Error: {e}", err=True)
80
+ # Continue without Jira info
81
+ jira_ticket = None
82
+ work_hours = None
83
+
84
+ # Generate commit message
85
+ click.echo("🤖 Generating commit message...")
86
+
87
+ try:
88
+ commit_message = provider.generate_commit_message(
89
+ diff=diff,
90
+ jira_ticket=jira_ticket,
91
+ work_hours=work_hours
92
+ )
93
+ except ApiError as e:
94
+ click.echo(f"❌ API Error: {e}", err=True)
95
+ return
96
+
97
+ if verbose:
98
+ click.echo(f"✨ Generated message: {commit_message}")
99
+
100
+ # Apply commit or open editor
101
+ if apply:
102
+ # Direct commit
103
+ try:
104
+ git_helper.commit_with_message(commit_message)
105
+ click.echo("✅ Commit applied successfully!")
106
+ click.echo(f"📝 Message: {commit_message}")
107
+ except GitError as e:
108
+ click.echo(f"❌ Commit failed: {e}", err=True)
109
+ else:
110
+ # Open editor for review
111
+ click.echo("📝 Opening editor for review...")
112
+ try:
113
+ committed = git_helper.open_commit_editor(commit_message, config)
114
+ if committed:
115
+ click.echo("✅ Commit created successfully!")
116
+ else:
117
+ click.echo("❌ Commit cancelled by user")
118
+ except GitError as e:
119
+ click.echo(f"❌ Editor error: {e}", err=True)
120
+
121
+ except ConfigError as e:
122
+ click.echo(f"❌ Configuration error: {e}", err=True)
123
+ except Exception as e:
124
+ click.echo(f"❌ Unexpected error: {e}", err=True)
125
+ if verbose:
126
+ import traceback
127
+ click.echo(traceback.format_exc(), err=True)
@@ -0,0 +1 @@
1
+ """Core module."""
@@ -0,0 +1,298 @@
1
+ """Configuration management for git-llm-tool."""
2
+
3
+ import os
4
+ import yaml
5
+ from pathlib import Path
6
+ from typing import Dict, Any, Optional
7
+ from dataclasses import dataclass, field
8
+
9
+ from git_llm_tool.core.exceptions import ConfigError
10
+
11
+
12
+ @dataclass
13
+ class LlmConfig:
14
+ """LLM configuration settings."""
15
+ default_model: str = "gpt-4o"
16
+ language: str = "en"
17
+ api_keys: Dict[str, str] = field(default_factory=dict)
18
+ azure_openai: Dict[str, str] = field(default_factory=dict) # endpoint, api_version, deployment_name
19
+
20
+
21
+ @dataclass
22
+ class JiraConfig:
23
+ """Jira integration configuration."""
24
+ enabled: bool = False
25
+ branch_regex: Optional[str] = None
26
+
27
+
28
+ @dataclass
29
+ class EditorConfig:
30
+ """Editor configuration settings."""
31
+ preferred_editor: Optional[str] = None # e.g., "vi", "nano", "code", etc.
32
+
33
+
34
+ @dataclass
35
+ class AppConfig:
36
+ """Main application configuration."""
37
+ llm: LlmConfig = field(default_factory=LlmConfig)
38
+ jira: JiraConfig = field(default_factory=JiraConfig)
39
+ editor: EditorConfig = field(default_factory=EditorConfig)
40
+
41
+
42
+ class ConfigLoader:
43
+ """Singleton configuration loader with hierarchical configuration support."""
44
+
45
+ _instance = None
46
+ _config = None
47
+
48
+ def __new__(cls):
49
+ if cls._instance is None:
50
+ cls._instance = super().__new__(cls)
51
+ cls._instance._initialized = False
52
+ return cls._instance
53
+
54
+ def __init__(self):
55
+ if not getattr(self, '_initialized', False):
56
+ self._config = self._load_config()
57
+ self._initialized = True
58
+
59
+ @property
60
+ def config(self) -> AppConfig:
61
+ """Get the loaded configuration."""
62
+ return self._config
63
+
64
+ def _load_config(self) -> AppConfig:
65
+ """Load configuration from multiple sources in hierarchical order."""
66
+ config_data = {}
67
+
68
+ # 1. Load global config
69
+ global_config_path = Path.home() / ".git-llm-tool" / "config.yaml"
70
+ if global_config_path.exists():
71
+ config_data.update(self._load_yaml_file(global_config_path))
72
+
73
+ # 2. Load project config (override global)
74
+ project_config_path = Path(".git-llm-tool.yaml")
75
+ if project_config_path.exists():
76
+ project_config = self._load_yaml_file(project_config_path)
77
+ config_data = self._merge_configs(config_data, project_config)
78
+
79
+ # 3. Load environment variables (override file configs)
80
+ env_config = self._load_env_config()
81
+ config_data = self._merge_configs(config_data, env_config)
82
+
83
+ return self._create_app_config(config_data)
84
+
85
+ def _load_yaml_file(self, file_path: Path) -> Dict[str, Any]:
86
+ """Load YAML configuration file."""
87
+ try:
88
+ with open(file_path, 'r', encoding='utf-8') as f:
89
+ data = yaml.safe_load(f)
90
+ return data if data is not None else {}
91
+ except yaml.YAMLError as e:
92
+ raise ConfigError(f"Invalid YAML in {file_path}: {e}")
93
+ except Exception as e:
94
+ raise ConfigError(f"Failed to read config file {file_path}: {e}")
95
+
96
+ def _load_env_config(self) -> Dict[str, Any]:
97
+ """Load configuration from environment variables."""
98
+ config = {}
99
+
100
+ # API keys from environment
101
+ api_keys = {}
102
+ if openai_key := os.getenv("OPENAI_API_KEY"):
103
+ api_keys["openai"] = openai_key
104
+ if anthropic_key := os.getenv("ANTHROPIC_API_KEY"):
105
+ api_keys["anthropic"] = anthropic_key
106
+ if google_key := os.getenv("GOOGLE_API_KEY"):
107
+ api_keys["google"] = google_key
108
+
109
+ # Azure OpenAI configuration from environment
110
+ azure_openai = {}
111
+ if azure_endpoint := os.getenv("AZURE_OPENAI_ENDPOINT"):
112
+ azure_openai["endpoint"] = azure_endpoint
113
+ if azure_key := os.getenv("AZURE_OPENAI_API_KEY"):
114
+ api_keys["azure_openai"] = azure_key
115
+ if azure_version := os.getenv("AZURE_OPENAI_API_VERSION"):
116
+ azure_openai["api_version"] = azure_version
117
+ if azure_deployment := os.getenv("AZURE_OPENAI_DEPLOYMENT_NAME"):
118
+ azure_openai["deployment_name"] = azure_deployment
119
+
120
+ # Set up LLM config
121
+ if api_keys or azure_openai:
122
+ config["llm"] = {}
123
+ if api_keys:
124
+ config["llm"]["api_keys"] = api_keys
125
+ if azure_openai:
126
+ config["llm"]["azure_openai"] = azure_openai
127
+
128
+ # Other environment variables
129
+ if model := os.getenv("GIT_LLM_MODEL"):
130
+ config.setdefault("llm", {})["default_model"] = model
131
+
132
+ if language := os.getenv("GIT_LLM_LANGUAGE"):
133
+ config.setdefault("llm", {})["language"] = language
134
+
135
+ return config
136
+
137
+ def _merge_configs(self, base: Dict[str, Any], override: Dict[str, Any]) -> Dict[str, Any]:
138
+ """Merge two configuration dictionaries recursively."""
139
+ result = base.copy()
140
+
141
+ for key, value in override.items():
142
+ if key in result and isinstance(result[key], dict) and isinstance(value, dict):
143
+ result[key] = self._merge_configs(result[key], value)
144
+ else:
145
+ result[key] = value
146
+
147
+ return result
148
+
149
+ def _create_app_config(self, config_data: Dict[str, Any]) -> AppConfig:
150
+ """Create AppConfig instance from configuration data."""
151
+ # Create LLM config
152
+ llm_data = config_data.get("llm", {})
153
+ llm_config = LlmConfig(
154
+ default_model=llm_data.get("default_model", "gpt-4o"),
155
+ language=llm_data.get("language", "en"),
156
+ api_keys=llm_data.get("api_keys", {}),
157
+ azure_openai=llm_data.get("azure_openai", {})
158
+ )
159
+
160
+ # Create Jira config
161
+ jira_data = config_data.get("jira", {})
162
+ jira_config = JiraConfig(
163
+ enabled=jira_data.get("enabled", False),
164
+ branch_regex=jira_data.get("branch_regex")
165
+ )
166
+
167
+ # Create Editor config
168
+ editor_data = config_data.get("editor", {})
169
+ editor_config = EditorConfig(
170
+ preferred_editor=editor_data.get("preferred_editor")
171
+ )
172
+
173
+ return AppConfig(llm=llm_config, jira=jira_config, editor=editor_config)
174
+
175
+ def save_config(self, config_path: Optional[Path] = None) -> None:
176
+ """Save current configuration to file."""
177
+ if config_path is None:
178
+ # Save to global config by default
179
+ config_path = Path.home() / ".git-llm-tool" / "config.yaml"
180
+
181
+ # Ensure directory exists
182
+ config_path.parent.mkdir(parents=True, exist_ok=True)
183
+
184
+ # Convert config to dict
185
+ config_dict = {
186
+ "llm": {
187
+ "default_model": self._config.llm.default_model,
188
+ "language": self._config.llm.language,
189
+ "api_keys": self._config.llm.api_keys,
190
+ "azure_openai": self._config.llm.azure_openai
191
+ },
192
+ "jira": {
193
+ "enabled": self._config.jira.enabled,
194
+ "branch_regex": self._config.jira.branch_regex
195
+ },
196
+ "editor": {
197
+ "preferred_editor": self._config.editor.preferred_editor
198
+ }
199
+ }
200
+
201
+ # Remove empty sections to keep config clean
202
+ if not config_dict["llm"]["api_keys"]:
203
+ del config_dict["llm"]["api_keys"]
204
+ if not config_dict["llm"]["azure_openai"]:
205
+ del config_dict["llm"]["azure_openai"]
206
+
207
+ # Remove None values from jira config
208
+ if config_dict["jira"]["branch_regex"] is None:
209
+ del config_dict["jira"]["branch_regex"]
210
+
211
+ # Remove None values from editor config
212
+ if config_dict["editor"]["preferred_editor"] is None:
213
+ del config_dict["editor"]["preferred_editor"]
214
+ if not config_dict["editor"]:
215
+ del config_dict["editor"]
216
+
217
+ try:
218
+ with open(config_path, 'w', encoding='utf-8') as f:
219
+ yaml.dump(config_dict, f, default_flow_style=False, indent=2)
220
+ except Exception as e:
221
+ raise ConfigError(f"Failed to save config to {config_path}: {e}")
222
+
223
+ def set_value(self, key_path: str, value: str) -> None:
224
+ """Set a configuration value using dot notation (e.g., 'llm.default_model')."""
225
+ keys = key_path.split('.')
226
+
227
+ if len(keys) < 2:
228
+ raise ConfigError(f"Invalid key path: {key_path}")
229
+
230
+ # Handle llm.default_model
231
+ if keys[0] == "llm" and keys[1] == "default_model":
232
+ self._config.llm.default_model = value
233
+ # Handle llm.language
234
+ elif keys[0] == "llm" and keys[1] == "language":
235
+ self._config.llm.language = value
236
+ # Handle llm.api_keys.*
237
+ elif keys[0] == "llm" and keys[1] == "api_keys" and len(keys) == 3:
238
+ self._config.llm.api_keys[keys[2]] = value
239
+ # Handle llm.azure_openai.*
240
+ elif keys[0] == "llm" and keys[1] == "azure_openai" and len(keys) == 3:
241
+ self._config.llm.azure_openai[keys[2]] = value
242
+ # Handle jira.enabled
243
+ elif keys[0] == "jira" and keys[1] == "enabled":
244
+ self._config.jira.enabled = value.lower() in ("true", "1", "yes", "on")
245
+ # Handle jira.branch_regex
246
+ elif keys[0] == "jira" and keys[1] == "branch_regex":
247
+ self._config.jira.branch_regex = value
248
+ # Handle editor.preferred_editor
249
+ elif keys[0] == "editor" and keys[1] == "preferred_editor":
250
+ self._config.editor.preferred_editor = value
251
+ else:
252
+ raise ConfigError(f"Unknown configuration key: {key_path}")
253
+
254
+ def get_value(self, key_path: str) -> Any:
255
+ """Get a configuration value using dot notation."""
256
+ keys = key_path.split('.')
257
+
258
+ if len(keys) < 2:
259
+ raise ConfigError(f"Invalid key path: {key_path}")
260
+
261
+ # Handle llm.default_model
262
+ if keys[0] == "llm" and keys[1] == "default_model":
263
+ return self._config.llm.default_model
264
+ # Handle llm.language
265
+ elif keys[0] == "llm" and keys[1] == "language":
266
+ return self._config.llm.language
267
+ # Handle llm.api_keys.*
268
+ elif keys[0] == "llm" and keys[1] == "api_keys" and len(keys) == 3:
269
+ return self._config.llm.api_keys.get(keys[2])
270
+ # Handle llm.azure_openai.*
271
+ elif keys[0] == "llm" and keys[1] == "azure_openai" and len(keys) == 3:
272
+ return self._config.llm.azure_openai.get(keys[2])
273
+ # Handle jira.enabled
274
+ elif keys[0] == "jira" and keys[1] == "enabled":
275
+ return self._config.jira.enabled
276
+ # Handle jira.branch_regex
277
+ elif keys[0] == "jira" and keys[1] == "branch_regex":
278
+ return self._config.jira.branch_regex
279
+ # Handle editor.preferred_editor
280
+ elif keys[0] == "editor" and keys[1] == "preferred_editor":
281
+ return self._config.editor.preferred_editor
282
+ else:
283
+ raise ConfigError(f"Unknown configuration key: {key_path}")
284
+
285
+ def reload(self) -> None:
286
+ """Reload configuration from files."""
287
+ self._config = self._load_config()
288
+
289
+ @classmethod
290
+ def _reset_instance(cls) -> None:
291
+ """Reset singleton instance for testing."""
292
+ cls._instance = None
293
+ cls._config = None
294
+
295
+
296
+ def get_config() -> AppConfig:
297
+ """Get the application configuration."""
298
+ return ConfigLoader().config
@@ -0,0 +1,26 @@
1
+ """Custom exceptions for git-llm-tool."""
2
+
3
+
4
+ class GitLlmError(Exception):
5
+ """Base exception for git-llm-tool."""
6
+ pass
7
+
8
+
9
+ class ConfigError(GitLlmError):
10
+ """Configuration-related errors."""
11
+ pass
12
+
13
+
14
+ class GitError(GitLlmError):
15
+ """Git operation errors."""
16
+ pass
17
+
18
+
19
+ class ApiError(GitLlmError):
20
+ """LLM API-related errors."""
21
+ pass
22
+
23
+
24
+ class JiraError(GitLlmError):
25
+ """Jira integration errors."""
26
+ pass