octotui 0.1.1__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.
@@ -0,0 +1,183 @@
1
+ """GAC (Git Auto Commit) integration for Octotui.
2
+
3
+ This module provides the main integration class for GAC functionality,
4
+ including commit message generation using AI models.
5
+ """
6
+
7
+ from pathlib import Path
8
+ from typing import Optional, Dict
9
+
10
+ # TODO: Add unit tests for commit message generation
11
+ # TODO: Add integration tests for GAC configuration loading
12
+ # TODO: Test error handling for missing API keys
13
+
14
+ # Graceful GAC import handling - app should work even if GAC is not installed
15
+ try:
16
+ import gac
17
+
18
+ GAC_AVAILABLE = True
19
+ except (ImportError, ModuleNotFoundError):
20
+ GAC_AVAILABLE = False
21
+ gac = None # type: ignore
22
+
23
+ from octotui.gac_provider_registry import GACProviderRegistry
24
+
25
+
26
+ class GACIntegration:
27
+ """Integration class for GAC (Git Auto Commit) functionality.
28
+
29
+ This class handles loading GAC configuration and generating commit messages
30
+ using the GAC library with proper validation and error handling.
31
+ """
32
+
33
+ def __init__(self, git_sidebar):
34
+ """Initialize GAC integration.
35
+
36
+ Args:
37
+ git_sidebar: GitStatusSidebar instance for accessing git status/diff
38
+ """
39
+ self.git_sidebar = git_sidebar
40
+ self.config = self._load_config()
41
+
42
+ def _load_config(self) -> Optional[Dict[str, str]]:
43
+ """Load GAC configuration from ~/.gac.env file.
44
+
45
+ Returns:
46
+ Dict of config values, or None if file doesn't exist
47
+ """
48
+ gac_env_file = Path.home() / ".gac.env"
49
+ if not gac_env_file.exists():
50
+ return None
51
+
52
+ config = {}
53
+ try:
54
+ with open(gac_env_file, "r") as f:
55
+ for line in f:
56
+ line = line.strip()
57
+ if line and "=" in line and not line.startswith("#"):
58
+ key, value = line.split("=", 1)
59
+ config[key.strip()] = value.strip().strip("\"'")
60
+ self.config = config
61
+ return config
62
+ except Exception:
63
+ return None
64
+
65
+ def is_configured(self) -> bool:
66
+ """Check if GAC is properly configured.
67
+
68
+ Returns:
69
+ True if configuration exists and is valid
70
+ """
71
+ self.config = self._load_config()
72
+ if not self.config:
73
+ return False
74
+ return True
75
+
76
+ def generate_commit_message(
77
+ self,
78
+ staged_only: bool = True,
79
+ one_liner: bool = False,
80
+ verbose: bool = False,
81
+ hint: str = "",
82
+ scope: Optional[str] = None,
83
+ ) -> Optional[str]:
84
+ """Generate a commit message using GAC with stable tuple-based API.
85
+
86
+ This method uses GAC's stable public API with tuple-based prompts:
87
+ - gac.build_prompt() returns (system_prompt, user_prompt)
88
+ - gac.generate_commit_message() accepts prompt=(system, user)
89
+
90
+ Args:
91
+ staged_only: Only include staged changes in the commit
92
+ one_liner: Generate a single-line commit message
93
+ verbose: Generate a more detailed commit message
94
+ hint: Optional hint to guide the AI commit message generation
95
+ scope: Optional scope for the commit (currently unused by GAC)
96
+
97
+ Returns:
98
+ Generated commit message string, or None if generation fails
99
+
100
+ Raises:
101
+ ImportError: If GAC package is not installed
102
+ ValueError: If GAC is not configured, no changes exist, or generation fails
103
+ """
104
+ # Check if GAC is available before attempting to use it
105
+ if not GAC_AVAILABLE or gac is None:
106
+ raise ImportError(
107
+ "GAC package not found. Install with: uv pip install 'gac>=0.18.0'\n"
108
+ "GAC provides AI-powered commit message generation."
109
+ )
110
+
111
+ if not self.is_configured():
112
+ raise ValueError("GAC is not configured. Please configure it first.")
113
+
114
+ try:
115
+ # Get git status and diff from sidebar
116
+ status = self.git_sidebar.get_git_status()
117
+ if staged_only:
118
+ diff = self.git_sidebar.get_staged_diff()
119
+ else:
120
+ diff = self.git_sidebar.get_full_diff()
121
+
122
+ if not diff.strip():
123
+ raise ValueError("No changes to commit")
124
+
125
+ # Get diff stat for file changes summary (required by GAC)
126
+ # This provides a summary like: "file1.py | 10 +++++-----"
127
+ try:
128
+ if staged_only:
129
+ diff_stat = (
130
+ self.git_sidebar.repo.git.diff("--cached", "--stat")
131
+ if self.git_sidebar.repo
132
+ else ""
133
+ )
134
+ else:
135
+ diff_stat = (
136
+ self.git_sidebar.repo.git.diff("--stat")
137
+ if self.git_sidebar.repo
138
+ else ""
139
+ )
140
+ except Exception:
141
+ diff_stat = "" # Fallback to empty if stat fails
142
+
143
+ # Build the prompt using GAC's stable API (safe because we checked GAC_AVAILABLE above)
144
+ # Returns tuple: (system_prompt, user_prompt)
145
+ # Note: verbose parameter not supported in GAC 1.4.1 - upgrade GAC to use it:
146
+ # uv pip install --upgrade gac
147
+ system_prompt, user_prompt = gac.build_prompt( # type: ignore
148
+ status=status,
149
+ processed_diff=diff,
150
+ diff_stat=diff_stat, # Required parameter for GAC
151
+ one_liner=one_liner,
152
+ hint=hint,
153
+ )
154
+
155
+ # Get the configured model
156
+ model = self.config.get("GAC_MODEL")
157
+ if not model:
158
+ raise ValueError("GAC_MODEL not set in configuration")
159
+
160
+ # Validate model format (provider:model)
161
+ registry = GACProviderRegistry()
162
+ is_valid, error_msg = registry.validate_model_format(model)
163
+ if not is_valid:
164
+ raise ValueError(f"Invalid model format: {error_msg}")
165
+
166
+ # Generate commit message using tuple-based prompt format
167
+ # This is the stable public API as of GAC latest versions
168
+ commit_message = gac.generate_commit_message( # type: ignore
169
+ model=model, prompt=(system_prompt, user_prompt), quiet=True
170
+ )
171
+
172
+ if not commit_message or not commit_message.strip():
173
+ raise ValueError("GAC returned an empty commit message")
174
+
175
+ return commit_message.strip()
176
+
177
+ except ValueError:
178
+ # Re-raise ValueError as-is (these are our validation errors)
179
+ raise
180
+ except Exception as e:
181
+ # Wrap other exceptions with more context
182
+ error_type = type(e).__name__
183
+ raise ValueError(f"GAC generation failed ({error_type}): {str(e)}")
@@ -0,0 +1,199 @@
1
+ """GAC provider registry - extracts providers from GAC at runtime."""
2
+
3
+ import logging
4
+ import re
5
+ from typing import Dict, Tuple, Optional
6
+ from pathlib import Path
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+ # Try to import from GAC
11
+ try:
12
+ import gac
13
+ from gac import init_cli
14
+
15
+ GAC_AVAILABLE = True
16
+ except (ImportError, ModuleNotFoundError):
17
+ GAC_AVAILABLE = False
18
+ gac = None # type: ignore
19
+ init_cli = None # type: ignore
20
+
21
+
22
+ def _extract_providers_from_gac() -> Optional[Dict[str, Tuple[str, str]]]:
23
+ """Extract provider list from GAC's init_cli module by reading source file.
24
+
25
+ Returns:
26
+ Dict mapping provider_key -> (display_name, default_model), or None if extraction fails
27
+ """
28
+ if not GAC_AVAILABLE or init_cli is None:
29
+ return None
30
+
31
+ try:
32
+ # Get the module file path
33
+ module_file = Path(init_cli.__file__)
34
+ if not module_file.exists():
35
+ logger.debug(f"GAC init_cli file not found: {module_file}")
36
+ return None
37
+
38
+ # Read the source file
39
+ source = module_file.read_text(encoding="utf-8")
40
+
41
+ # Find the providers list in _configure_model function
42
+ # Pattern: providers = [ ... ("Name", "model"), ... ]
43
+ # Use bracket-counting to handle nested brackets (e.g., list comprehensions)
44
+ start_match = re.search(r"providers\s*=\s*\[", source)
45
+ if not start_match:
46
+ logger.debug("Could not find providers list in GAC's init_cli")
47
+ return None
48
+
49
+ # Find the matching closing bracket by counting brackets
50
+ start_pos = start_match.end()
51
+ bracket_count = 1
52
+ pos = start_pos
53
+
54
+ while pos < len(source) and bracket_count > 0:
55
+ if source[pos] == "[":
56
+ bracket_count += 1
57
+ elif source[pos] == "]":
58
+ bracket_count -= 1
59
+ pos += 1
60
+
61
+ if bracket_count != 0:
62
+ logger.debug("Could not find matching closing bracket for providers list")
63
+ return None
64
+
65
+ providers_text = source[start_pos : pos - 1]
66
+
67
+ # Parse tuples: ("Display Name", "model")
68
+ tuple_pattern = r'\("([^"]+)",\s*"([^"]*)"\)'
69
+ matches = re.findall(tuple_pattern, providers_text)
70
+
71
+ if not matches:
72
+ logger.debug("Could not parse provider tuples from GAC")
73
+ return None
74
+
75
+ # Convert to our format: provider_key -> (display_name, default_model)
76
+ result = {}
77
+ for display_name, default_model in matches:
78
+ # Generate provider key using GAC's logic (from line 112 of init_cli.py)
79
+ provider_key = (
80
+ display_name.lower()
81
+ .replace(".", "")
82
+ .replace(" ", "-")
83
+ .replace("(", "")
84
+ .replace(")", "")
85
+ )
86
+ result[provider_key] = (display_name, default_model)
87
+
88
+ logger.debug(f"Successfully extracted {len(result)} providers from GAC")
89
+ return result
90
+
91
+ except Exception as e:
92
+ logger.debug(f"Failed to extract providers from GAC: {e}")
93
+ return None
94
+
95
+
96
+ class GACProviderRegistry:
97
+ """Registry for GAC AI providers - extracts from GAC at runtime."""
98
+
99
+ # Cache for extracted providers
100
+ _cached_providers: Optional[Dict[str, Tuple[str, str]]] = None
101
+
102
+ @classmethod
103
+ def get_supported_providers(cls) -> Dict[str, Tuple[str, str]]:
104
+ """Get dictionary of supported providers from GAC.
105
+
106
+ Returns:
107
+ Dict mapping provider_key -> (display_name, default_model)
108
+ """
109
+ # Use cache if available
110
+ if cls._cached_providers is not None:
111
+ return cls._cached_providers.copy()
112
+
113
+ # Try to extract from GAC
114
+ if GAC_AVAILABLE:
115
+ providers = _extract_providers_from_gac()
116
+ if providers:
117
+ cls._cached_providers = providers
118
+ logger.info(f"Loaded {len(providers)} providers from GAC")
119
+ return providers.copy()
120
+
121
+ # Fallback: return empty dict with warning
122
+ logger.warning(
123
+ "Could not load providers from GAC - GAC may not be installed or provider extraction failed. Provider list will be empty."
124
+ )
125
+ return {}
126
+
127
+ @classmethod
128
+ def get_suggested_models(cls, provider: str) -> list[str]:
129
+ """Get list of suggested models for a provider.
130
+
131
+ Args:
132
+ provider: Provider key (e.g., "anthropic", "openai")
133
+
134
+ Returns:
135
+ List of suggested model names
136
+ """
137
+ providers = cls.get_supported_providers()
138
+ if provider not in providers:
139
+ return []
140
+
141
+ _, default_model = providers[provider]
142
+ if not default_model:
143
+ return []
144
+
145
+ return [default_model]
146
+
147
+ @classmethod
148
+ def get_default_model(cls, provider: str) -> str:
149
+ """Get the default model for a provider.
150
+
151
+ Args:
152
+ provider: Provider key
153
+
154
+ Returns:
155
+ Default model name, or empty string if none
156
+ """
157
+ providers = cls.get_supported_providers()
158
+ if provider not in providers:
159
+ return ""
160
+
161
+ _, default_model = providers[provider]
162
+ return default_model
163
+
164
+ @classmethod
165
+ def is_local_provider(cls, provider: str) -> bool:
166
+ """Check if provider is local (no API key needed).
167
+
168
+ Args:
169
+ provider: Provider key
170
+
171
+ Returns:
172
+ True if local provider
173
+ """
174
+ return provider in ("ollama", "lm-studio")
175
+
176
+ @classmethod
177
+ def validate_model_format(cls, model: str) -> Tuple[bool, str]:
178
+ """Validate model string format.
179
+
180
+ Args:
181
+ model: Full model string (provider:model)
182
+
183
+ Returns:
184
+ Tuple of (is_valid, error_message)
185
+ """
186
+ if not model:
187
+ return False, "Model cannot be empty"
188
+
189
+ if ":" not in model:
190
+ return False, "Model must be in format 'provider:model'"
191
+
192
+ provider, model_name = model.split(":", 1)
193
+
194
+ if not provider.strip():
195
+ return False, "Provider name cannot be empty"
196
+ if not model_name.strip():
197
+ return False, "Model name cannot be empty"
198
+
199
+ return True, ""