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
git_llm_tool/__init__.py
ADDED
git_llm_tool/__main__.py
ADDED
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
|