comit-cli 1.0.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.
- comit/__init__.py +1 -0
- comit/ai/__init__.py +39 -0
- comit/ai/base.py +42 -0
- comit/ai/factory.py +59 -0
- comit/ai/gemini_provider.py +125 -0
- comit/ai/groq_provider.py +121 -0
- comit/ai/ollama_provider.py +107 -0
- comit/ai/openai_provider.py +107 -0
- comit/cli.py +446 -0
- comit/commit/__init__.py +21 -0
- comit/commit/context.py +60 -0
- comit/commit/validator.py +270 -0
- comit/config.py +279 -0
- comit/git.py +281 -0
- comit/prompts.py +148 -0
- comit/review/__init__.py +29 -0
- comit/review/base.py +13 -0
- comit/review/databases.py +40 -0
- comit/review/engine.py +92 -0
- comit/review/generated_files.py +75 -0
- comit/review/git_files.py +54 -0
- comit/review/large_files.py +38 -0
- comit/review/models.py +59 -0
- comit/review/private_keys.py +69 -0
- comit/review/secrets.py +230 -0
- comit/review/sensitive_files.py +79 -0
- comit/ui.py +412 -0
- comit_cli-1.0.0.dist-info/METADATA +524 -0
- comit_cli-1.0.0.dist-info/RECORD +32 -0
- comit_cli-1.0.0.dist-info/WHEEL +5 -0
- comit_cli-1.0.0.dist-info/entry_points.txt +3 -0
- comit_cli-1.0.0.dist-info/top_level.txt +1 -0
comit/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "1.0.0"
|
comit/ai/__init__.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from comit.ai.base import (
|
|
4
|
+
ComitAIError,
|
|
5
|
+
APIKeyMissingError,
|
|
6
|
+
AIAuthenticationError,
|
|
7
|
+
AIServiceError,
|
|
8
|
+
AIResponseError,
|
|
9
|
+
UnsupportedProviderError,
|
|
10
|
+
AIProvider,
|
|
11
|
+
)
|
|
12
|
+
from comit.ai.groq_provider import GroqProvider
|
|
13
|
+
from comit.ai.gemini_provider import GeminiProvider
|
|
14
|
+
from comit.ai.openai_provider import OpenAIProvider
|
|
15
|
+
from comit.ai.ollama_provider import OllamaProvider
|
|
16
|
+
from comit.ai.factory import (
|
|
17
|
+
SUPPORTED_PROVIDERS,
|
|
18
|
+
get_provider,
|
|
19
|
+
get_default_provider,
|
|
20
|
+
generate_commit_message,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"ComitAIError",
|
|
25
|
+
"APIKeyMissingError",
|
|
26
|
+
"AIAuthenticationError",
|
|
27
|
+
"AIServiceError",
|
|
28
|
+
"AIResponseError",
|
|
29
|
+
"UnsupportedProviderError",
|
|
30
|
+
"AIProvider",
|
|
31
|
+
"GroqProvider",
|
|
32
|
+
"GeminiProvider",
|
|
33
|
+
"OpenAIProvider",
|
|
34
|
+
"OllamaProvider",
|
|
35
|
+
"SUPPORTED_PROVIDERS",
|
|
36
|
+
"get_provider",
|
|
37
|
+
"get_default_provider",
|
|
38
|
+
"generate_commit_message",
|
|
39
|
+
]
|
comit/ai/base.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from typing import List, Optional, Union
|
|
5
|
+
|
|
6
|
+
from comit.commit.context import CommitContext
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ComitAIError(Exception):
|
|
10
|
+
pass
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class APIKeyMissingError(ComitAIError):
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class AIAuthenticationError(ComitAIError):
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class AIServiceError(ComitAIError):
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class AIResponseError(ComitAIError):
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class UnsupportedProviderError(ComitAIError):
|
|
30
|
+
pass
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class AIProvider(ABC):
|
|
34
|
+
@abstractmethod
|
|
35
|
+
def generate_commit_message(
|
|
36
|
+
self,
|
|
37
|
+
context: Union[CommitContext, str, None] = None,
|
|
38
|
+
recent_commits: Optional[List[str]] = None,
|
|
39
|
+
avoid_messages: Optional[List[str]] = None,
|
|
40
|
+
diff: Optional[str] = None,
|
|
41
|
+
) -> str:
|
|
42
|
+
pass
|
comit/ai/factory.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import List, Optional, Union
|
|
4
|
+
|
|
5
|
+
from comit.ai.base import (
|
|
6
|
+
AIProvider,
|
|
7
|
+
UnsupportedProviderError,
|
|
8
|
+
)
|
|
9
|
+
from comit.ai.groq_provider import GroqProvider
|
|
10
|
+
from comit.ai.gemini_provider import GeminiProvider
|
|
11
|
+
from comit.ai.openai_provider import OpenAIProvider
|
|
12
|
+
from comit.ai.ollama_provider import OllamaProvider
|
|
13
|
+
from comit.commit.context import CommitContext
|
|
14
|
+
from comit.config import get_provider as config_get_provider
|
|
15
|
+
|
|
16
|
+
SUPPORTED_PROVIDERS = ("groq", "gemini", "openai", "ollama")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def get_provider(
|
|
20
|
+
provider_name: Optional[str] = None,
|
|
21
|
+
model: Optional[str] = None,
|
|
22
|
+
api_key: Optional[str] = None,
|
|
23
|
+
) -> AIProvider:
|
|
24
|
+
name = (provider_name or config_get_provider()).strip().lower()
|
|
25
|
+
|
|
26
|
+
if name == "groq":
|
|
27
|
+
return GroqProvider(api_key=api_key, model=model)
|
|
28
|
+
elif name == "gemini":
|
|
29
|
+
return GeminiProvider(api_key=api_key, model=model)
|
|
30
|
+
elif name == "openai":
|
|
31
|
+
return OpenAIProvider(api_key=api_key, model=model)
|
|
32
|
+
elif name == "ollama":
|
|
33
|
+
return OllamaProvider(model=model)
|
|
34
|
+
else:
|
|
35
|
+
supported_str = ", ".join(SUPPORTED_PROVIDERS)
|
|
36
|
+
raise UnsupportedProviderError(
|
|
37
|
+
f"Unsupported AI provider '{name}'. Supported providers: {supported_str}"
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def get_default_provider() -> AIProvider:
|
|
42
|
+
return get_provider()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def generate_commit_message(
|
|
46
|
+
context: Union[CommitContext, str, None] = None,
|
|
47
|
+
recent_commits: Optional[List[str]] = None,
|
|
48
|
+
avoid_messages: Optional[List[str]] = None,
|
|
49
|
+
diff: Optional[str] = None,
|
|
50
|
+
provider: Optional[AIProvider] = None,
|
|
51
|
+
) -> str:
|
|
52
|
+
if provider is None:
|
|
53
|
+
provider = get_default_provider()
|
|
54
|
+
target = context if context is not None else (diff or "")
|
|
55
|
+
return provider.generate_commit_message(
|
|
56
|
+
context=target,
|
|
57
|
+
recent_commits=recent_commits,
|
|
58
|
+
avoid_messages=avoid_messages,
|
|
59
|
+
)
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import List, Optional, Union
|
|
4
|
+
|
|
5
|
+
from comit.ai.base import (
|
|
6
|
+
AIProvider,
|
|
7
|
+
APIKeyMissingError,
|
|
8
|
+
AIAuthenticationError,
|
|
9
|
+
AIServiceError,
|
|
10
|
+
AIResponseError,
|
|
11
|
+
)
|
|
12
|
+
from comit.commit.context import CommitContext
|
|
13
|
+
from comit.config import get_api_key, get_model
|
|
14
|
+
from comit.prompts import SYSTEM_PROMPT, build_commit_prompt, sanitize_commit_message
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class GeminiProvider(AIProvider):
|
|
18
|
+
def __init__(self, api_key: Optional[str] = None, model: Optional[str] = None):
|
|
19
|
+
self.api_key = get_api_key(api_key, provider="gemini")
|
|
20
|
+
if not self.api_key:
|
|
21
|
+
raise APIKeyMissingError(
|
|
22
|
+
"GEMINI_API_KEY is not configured.\n"
|
|
23
|
+
"Run 'git ai settings set-key --provider gemini' or export GEMINI_API_KEY in your shell."
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
self.model = get_model(model, provider="gemini")
|
|
27
|
+
self._client = None
|
|
28
|
+
|
|
29
|
+
def _get_client(self):
|
|
30
|
+
if self._client is None:
|
|
31
|
+
try:
|
|
32
|
+
from google import genai
|
|
33
|
+
self._client = genai.Client(api_key=self.api_key)
|
|
34
|
+
except ImportError as exc:
|
|
35
|
+
raise AIServiceError(
|
|
36
|
+
"The 'google-genai' package is not installed. Please run: pip install google-genai"
|
|
37
|
+
) from exc
|
|
38
|
+
except Exception as exc:
|
|
39
|
+
raise AIServiceError(f"Failed to initialize Gemini client: {exc}") from exc
|
|
40
|
+
return self._client
|
|
41
|
+
|
|
42
|
+
def generate_commit_message(
|
|
43
|
+
self,
|
|
44
|
+
context: Union[CommitContext, str, None] = None,
|
|
45
|
+
recent_commits: Optional[List[str]] = None,
|
|
46
|
+
avoid_messages: Optional[List[str]] = None,
|
|
47
|
+
diff: Optional[str] = None,
|
|
48
|
+
) -> str:
|
|
49
|
+
target = context if context is not None else (diff or "")
|
|
50
|
+
client = self._get_client()
|
|
51
|
+
user_prompt = build_commit_prompt(target, recent_commits=recent_commits, avoid_messages=avoid_messages)
|
|
52
|
+
temperature = 0.7 if avoid_messages else 0.2
|
|
53
|
+
|
|
54
|
+
try:
|
|
55
|
+
from google.genai import types
|
|
56
|
+
config = types.GenerateContentConfig(
|
|
57
|
+
system_instruction=SYSTEM_PROMPT,
|
|
58
|
+
temperature=temperature,
|
|
59
|
+
max_output_tokens=250,
|
|
60
|
+
)
|
|
61
|
+
except ImportError:
|
|
62
|
+
config = {
|
|
63
|
+
"system_instruction": SYSTEM_PROMPT,
|
|
64
|
+
"temperature": temperature,
|
|
65
|
+
"max_output_tokens": 250,
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
try:
|
|
69
|
+
response = client.models.generate_content(
|
|
70
|
+
model=self.model,
|
|
71
|
+
contents=user_prompt,
|
|
72
|
+
config=config,
|
|
73
|
+
)
|
|
74
|
+
except Exception as exc:
|
|
75
|
+
err_msg = str(exc).lower()
|
|
76
|
+
if "api_key_invalid" in err_msg or "unauthenticated" in err_msg or "401" in err_msg or "403" in err_msg or "permission denied" in err_msg:
|
|
77
|
+
raise AIAuthenticationError(
|
|
78
|
+
"Gemini authentication failed. Please verify that your GEMINI_API_KEY is valid."
|
|
79
|
+
) from exc
|
|
80
|
+
if "resource_exhausted" in err_msg or "429" in err_msg or "quota" in err_msg:
|
|
81
|
+
raise AIServiceError(
|
|
82
|
+
"Gemini rate limit reached. Please wait a moment and try again."
|
|
83
|
+
) from exc
|
|
84
|
+
raise AIServiceError(f"Gemini API returned an error: {exc}") from exc
|
|
85
|
+
|
|
86
|
+
if not response or not hasattr(response, "text") or not response.text:
|
|
87
|
+
raise AIResponseError("Gemini API returned an empty response. Please try again.")
|
|
88
|
+
|
|
89
|
+
cleaned = sanitize_commit_message(response.text)
|
|
90
|
+
|
|
91
|
+
if avoid_messages and cleaned in avoid_messages:
|
|
92
|
+
try:
|
|
93
|
+
stronger_prompt = (
|
|
94
|
+
f"{user_prompt}\n\n"
|
|
95
|
+
"CRITICAL: The previous message was already generated. You MUST produce a distinct alternative phrasing."
|
|
96
|
+
)
|
|
97
|
+
try:
|
|
98
|
+
from google.genai import types
|
|
99
|
+
retry_config = types.GenerateContentConfig(
|
|
100
|
+
system_instruction=SYSTEM_PROMPT,
|
|
101
|
+
temperature=0.9,
|
|
102
|
+
max_output_tokens=250,
|
|
103
|
+
)
|
|
104
|
+
except ImportError:
|
|
105
|
+
retry_config = {
|
|
106
|
+
"system_instruction": SYSTEM_PROMPT,
|
|
107
|
+
"temperature": 0.9,
|
|
108
|
+
"max_output_tokens": 250,
|
|
109
|
+
}
|
|
110
|
+
retry_response = client.models.generate_content(
|
|
111
|
+
model=self.model,
|
|
112
|
+
contents=stronger_prompt,
|
|
113
|
+
config=retry_config,
|
|
114
|
+
)
|
|
115
|
+
if retry_response and hasattr(retry_response, "text") and retry_response.text:
|
|
116
|
+
retry_cleaned = sanitize_commit_message(retry_response.text)
|
|
117
|
+
if retry_cleaned:
|
|
118
|
+
return retry_cleaned
|
|
119
|
+
except Exception:
|
|
120
|
+
pass
|
|
121
|
+
|
|
122
|
+
if not cleaned:
|
|
123
|
+
raise AIResponseError("Failed to extract a valid commit message from the AI response.")
|
|
124
|
+
|
|
125
|
+
return cleaned
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import List, Optional, Union
|
|
4
|
+
|
|
5
|
+
from comit.ai.base import (
|
|
6
|
+
AIProvider,
|
|
7
|
+
APIKeyMissingError,
|
|
8
|
+
AIAuthenticationError,
|
|
9
|
+
AIServiceError,
|
|
10
|
+
AIResponseError,
|
|
11
|
+
)
|
|
12
|
+
from comit.commit.context import CommitContext
|
|
13
|
+
from comit.config import get_api_key, get_model
|
|
14
|
+
from comit.prompts import SYSTEM_PROMPT, build_commit_prompt, sanitize_commit_message
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class GroqProvider(AIProvider):
|
|
18
|
+
def __init__(self, api_key: Optional[str] = None, model: Optional[str] = None):
|
|
19
|
+
self.api_key = get_api_key(api_key, provider="groq")
|
|
20
|
+
if not self.api_key:
|
|
21
|
+
raise APIKeyMissingError(
|
|
22
|
+
"GROQ_API_KEY is not configured.\n"
|
|
23
|
+
"Run 'git ai settings set-key' or export GROQ_API_KEY in your shell."
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
self.model = get_model(model, provider="groq")
|
|
27
|
+
self._client = None
|
|
28
|
+
|
|
29
|
+
def _get_client(self):
|
|
30
|
+
if self._client is None:
|
|
31
|
+
try:
|
|
32
|
+
from groq import Groq
|
|
33
|
+
self._client = Groq(api_key=self.api_key)
|
|
34
|
+
except ImportError as exc:
|
|
35
|
+
raise AIServiceError(
|
|
36
|
+
"The 'groq' package is not installed. Please run: pip install groq"
|
|
37
|
+
) from exc
|
|
38
|
+
except Exception as exc:
|
|
39
|
+
raise AIServiceError(f"Failed to initialize Groq client: {exc}") from exc
|
|
40
|
+
return self._client
|
|
41
|
+
|
|
42
|
+
def generate_commit_message(
|
|
43
|
+
self,
|
|
44
|
+
context: Union[CommitContext, str, None] = None,
|
|
45
|
+
recent_commits: Optional[List[str]] = None,
|
|
46
|
+
avoid_messages: Optional[List[str]] = None,
|
|
47
|
+
diff: Optional[str] = None,
|
|
48
|
+
) -> str:
|
|
49
|
+
try:
|
|
50
|
+
import groq
|
|
51
|
+
except ImportError as exc:
|
|
52
|
+
raise AIServiceError(
|
|
53
|
+
"The 'groq' package is not installed. Please run: pip install groq"
|
|
54
|
+
) from exc
|
|
55
|
+
|
|
56
|
+
target = context if context is not None else (diff or "")
|
|
57
|
+
client = self._get_client()
|
|
58
|
+
user_prompt = build_commit_prompt(target, recent_commits=recent_commits, avoid_messages=avoid_messages)
|
|
59
|
+
temperature = 0.7 if avoid_messages else 0.2
|
|
60
|
+
|
|
61
|
+
try:
|
|
62
|
+
response = client.chat.completions.create(
|
|
63
|
+
model=self.model,
|
|
64
|
+
messages=[
|
|
65
|
+
{"role": "system", "content": SYSTEM_PROMPT},
|
|
66
|
+
{"role": "user", "content": user_prompt},
|
|
67
|
+
],
|
|
68
|
+
temperature=temperature,
|
|
69
|
+
max_tokens=250,
|
|
70
|
+
)
|
|
71
|
+
except groq.AuthenticationError as exc:
|
|
72
|
+
raise AIAuthenticationError(
|
|
73
|
+
"Groq authentication failed. Please verify that your GROQ_API_KEY is valid."
|
|
74
|
+
) from exc
|
|
75
|
+
except groq.RateLimitError as exc:
|
|
76
|
+
raise AIServiceError(
|
|
77
|
+
"Groq rate limit reached. Please wait a moment and try again."
|
|
78
|
+
) from exc
|
|
79
|
+
except groq.APIConnectionError as exc:
|
|
80
|
+
raise AIServiceError(
|
|
81
|
+
"Could not connect to the Groq API. Please check your internet connection."
|
|
82
|
+
) from exc
|
|
83
|
+
except groq.APIStatusError as exc:
|
|
84
|
+
raise AIServiceError(
|
|
85
|
+
f"Groq API returned an error (status {exc.status_code}): {exc.message}"
|
|
86
|
+
) from exc
|
|
87
|
+
except Exception as exc:
|
|
88
|
+
raise AIServiceError(f"Unexpected error while calling Groq API: {exc}") from exc
|
|
89
|
+
|
|
90
|
+
if not response.choices or not response.choices[0].message.content:
|
|
91
|
+
raise AIResponseError("Groq API returned an empty response. Please try again.")
|
|
92
|
+
|
|
93
|
+
raw_message = response.choices[0].message.content
|
|
94
|
+
cleaned = sanitize_commit_message(raw_message)
|
|
95
|
+
|
|
96
|
+
if avoid_messages and cleaned in avoid_messages:
|
|
97
|
+
try:
|
|
98
|
+
stronger_prompt = (
|
|
99
|
+
f"{user_prompt}\n\n"
|
|
100
|
+
"CRITICAL: The previous message was already generated. You MUST produce a distinct alternative phrasing."
|
|
101
|
+
)
|
|
102
|
+
retry_response = client.chat.completions.create(
|
|
103
|
+
model=self.model,
|
|
104
|
+
messages=[
|
|
105
|
+
{"role": "system", "content": SYSTEM_PROMPT},
|
|
106
|
+
{"role": "user", "content": stronger_prompt},
|
|
107
|
+
],
|
|
108
|
+
temperature=0.9,
|
|
109
|
+
max_tokens=250,
|
|
110
|
+
)
|
|
111
|
+
if retry_response.choices and retry_response.choices[0].message.content:
|
|
112
|
+
retry_cleaned = sanitize_commit_message(retry_response.choices[0].message.content)
|
|
113
|
+
if retry_cleaned:
|
|
114
|
+
return retry_cleaned
|
|
115
|
+
except Exception:
|
|
116
|
+
pass
|
|
117
|
+
|
|
118
|
+
if not cleaned:
|
|
119
|
+
raise AIResponseError("Failed to extract a valid commit message from the AI response.")
|
|
120
|
+
|
|
121
|
+
return cleaned
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import List, Optional, Union
|
|
4
|
+
|
|
5
|
+
from comit.ai.base import (
|
|
6
|
+
AIProvider,
|
|
7
|
+
AIServiceError,
|
|
8
|
+
AIResponseError,
|
|
9
|
+
)
|
|
10
|
+
from comit.commit.context import CommitContext
|
|
11
|
+
from comit.config import get_model, get_ollama_host
|
|
12
|
+
from comit.prompts import SYSTEM_PROMPT, build_commit_prompt, sanitize_commit_message
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class OllamaProvider(AIProvider):
|
|
16
|
+
def __init__(self, model: Optional[str] = None, host: Optional[str] = None):
|
|
17
|
+
self.model = get_model(model, provider="ollama")
|
|
18
|
+
self.host = get_ollama_host(host)
|
|
19
|
+
self._client = None
|
|
20
|
+
|
|
21
|
+
def _get_client(self):
|
|
22
|
+
if self._client is None:
|
|
23
|
+
try:
|
|
24
|
+
import ollama
|
|
25
|
+
self._client = ollama.Client(host=self.host)
|
|
26
|
+
except ImportError as exc:
|
|
27
|
+
raise AIServiceError(
|
|
28
|
+
"The 'ollama' package is not installed. Please run: pip install ollama"
|
|
29
|
+
) from exc
|
|
30
|
+
except Exception as exc:
|
|
31
|
+
raise AIServiceError(f"Failed to initialize Ollama client: {exc}") from exc
|
|
32
|
+
return self._client
|
|
33
|
+
|
|
34
|
+
def generate_commit_message(
|
|
35
|
+
self,
|
|
36
|
+
context: Union[CommitContext, str, None] = None,
|
|
37
|
+
recent_commits: Optional[List[str]] = None,
|
|
38
|
+
avoid_messages: Optional[List[str]] = None,
|
|
39
|
+
diff: Optional[str] = None,
|
|
40
|
+
) -> str:
|
|
41
|
+
target = context if context is not None else (diff or "")
|
|
42
|
+
client = self._get_client()
|
|
43
|
+
user_prompt = build_commit_prompt(target, recent_commits=recent_commits, avoid_messages=avoid_messages)
|
|
44
|
+
temperature = 0.7 if avoid_messages else 0.2
|
|
45
|
+
|
|
46
|
+
try:
|
|
47
|
+
response = client.chat(
|
|
48
|
+
model=self.model,
|
|
49
|
+
messages=[
|
|
50
|
+
{"role": "system", "content": SYSTEM_PROMPT},
|
|
51
|
+
{"role": "user", "content": user_prompt},
|
|
52
|
+
],
|
|
53
|
+
options={"temperature": temperature, "num_predict": 250},
|
|
54
|
+
)
|
|
55
|
+
except Exception as exc:
|
|
56
|
+
err_msg = str(exc)
|
|
57
|
+
if "ConnectError" in err_msg or "ConnectionRefused" in err_msg or "connection refused" in err_msg.lower() or "Failed to connect" in err_msg:
|
|
58
|
+
raise AIServiceError(
|
|
59
|
+
f"Could not connect to Ollama server at {self.host}. Please ensure the Ollama service is running."
|
|
60
|
+
) from exc
|
|
61
|
+
if "not found" in err_msg.lower() and "model" in err_msg.lower():
|
|
62
|
+
raise AIServiceError(
|
|
63
|
+
f"Ollama model '{self.model}' was not found. Please run 'ollama pull {self.model}' first."
|
|
64
|
+
) from exc
|
|
65
|
+
raise AIServiceError(f"Ollama error: {exc}") from exc
|
|
66
|
+
|
|
67
|
+
content = ""
|
|
68
|
+
if isinstance(response, dict):
|
|
69
|
+
content = response.get("message", {}).get("content", "")
|
|
70
|
+
elif hasattr(response, "message") and hasattr(response.message, "content"):
|
|
71
|
+
content = response.message.content
|
|
72
|
+
|
|
73
|
+
if not content:
|
|
74
|
+
raise AIResponseError("Ollama returned an empty response. Please try again.")
|
|
75
|
+
|
|
76
|
+
cleaned = sanitize_commit_message(content)
|
|
77
|
+
|
|
78
|
+
if avoid_messages and cleaned in avoid_messages:
|
|
79
|
+
try:
|
|
80
|
+
stronger_prompt = (
|
|
81
|
+
f"{user_prompt}\n\n"
|
|
82
|
+
"CRITICAL: The previous message was already generated. You MUST produce a distinct alternative phrasing."
|
|
83
|
+
)
|
|
84
|
+
retry_response = client.chat(
|
|
85
|
+
model=self.model,
|
|
86
|
+
messages=[
|
|
87
|
+
{"role": "system", "content": SYSTEM_PROMPT},
|
|
88
|
+
{"role": "user", "content": stronger_prompt},
|
|
89
|
+
],
|
|
90
|
+
options={"temperature": 0.9, "num_predict": 250},
|
|
91
|
+
)
|
|
92
|
+
retry_content = ""
|
|
93
|
+
if isinstance(retry_response, dict):
|
|
94
|
+
retry_content = retry_response.get("message", {}).get("content", "")
|
|
95
|
+
elif hasattr(retry_response, "message") and hasattr(retry_response.message, "content"):
|
|
96
|
+
retry_content = retry_response.message.content
|
|
97
|
+
if retry_content:
|
|
98
|
+
retry_cleaned = sanitize_commit_message(retry_content)
|
|
99
|
+
if retry_cleaned:
|
|
100
|
+
return retry_cleaned
|
|
101
|
+
except Exception:
|
|
102
|
+
pass
|
|
103
|
+
|
|
104
|
+
if not cleaned:
|
|
105
|
+
raise AIResponseError("Failed to extract a valid commit message from the AI response.")
|
|
106
|
+
|
|
107
|
+
return cleaned
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import List, Optional, Union
|
|
4
|
+
|
|
5
|
+
from comit.ai.base import (
|
|
6
|
+
AIProvider,
|
|
7
|
+
APIKeyMissingError,
|
|
8
|
+
AIAuthenticationError,
|
|
9
|
+
AIServiceError,
|
|
10
|
+
AIResponseError,
|
|
11
|
+
)
|
|
12
|
+
from comit.commit.context import CommitContext
|
|
13
|
+
from comit.config import get_api_key, get_model
|
|
14
|
+
from comit.prompts import SYSTEM_PROMPT, build_commit_prompt, sanitize_commit_message
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class OpenAIProvider(AIProvider):
|
|
18
|
+
def __init__(self, api_key: Optional[str] = None, model: Optional[str] = None):
|
|
19
|
+
self.api_key = get_api_key(api_key, provider="openai")
|
|
20
|
+
if not self.api_key:
|
|
21
|
+
raise APIKeyMissingError(
|
|
22
|
+
"OPENAI_API_KEY is not configured.\n"
|
|
23
|
+
"Run 'git ai settings set-key --provider openai' or export OPENAI_API_KEY in your shell."
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
self.model = get_model(model, provider="openai")
|
|
27
|
+
self._client = None
|
|
28
|
+
|
|
29
|
+
def _get_client(self):
|
|
30
|
+
if self._client is None:
|
|
31
|
+
try:
|
|
32
|
+
from openai import OpenAI
|
|
33
|
+
self._client = OpenAI(api_key=self.api_key)
|
|
34
|
+
except ImportError as exc:
|
|
35
|
+
raise AIServiceError(
|
|
36
|
+
"The 'openai' package is not installed. Please run: pip install openai"
|
|
37
|
+
) from exc
|
|
38
|
+
except Exception as exc:
|
|
39
|
+
raise AIServiceError(f"Failed to initialize OpenAI client: {exc}") from exc
|
|
40
|
+
return self._client
|
|
41
|
+
|
|
42
|
+
def generate_commit_message(
|
|
43
|
+
self,
|
|
44
|
+
context: Union[CommitContext, str, None] = None,
|
|
45
|
+
recent_commits: Optional[List[str]] = None,
|
|
46
|
+
avoid_messages: Optional[List[str]] = None,
|
|
47
|
+
diff: Optional[str] = None,
|
|
48
|
+
) -> str:
|
|
49
|
+
target = context if context is not None else (diff or "")
|
|
50
|
+
client = self._get_client()
|
|
51
|
+
user_prompt = build_commit_prompt(target, recent_commits=recent_commits, avoid_messages=avoid_messages)
|
|
52
|
+
temperature = 0.7 if avoid_messages else 0.2
|
|
53
|
+
|
|
54
|
+
try:
|
|
55
|
+
response = client.chat.completions.create(
|
|
56
|
+
model=self.model,
|
|
57
|
+
messages=[
|
|
58
|
+
{"role": "system", "content": SYSTEM_PROMPT},
|
|
59
|
+
{"role": "user", "content": user_prompt},
|
|
60
|
+
],
|
|
61
|
+
temperature=temperature,
|
|
62
|
+
max_tokens=250,
|
|
63
|
+
)
|
|
64
|
+
except Exception as exc:
|
|
65
|
+
err_msg = str(exc).lower()
|
|
66
|
+
if "authentication" in err_msg or "incorrect api key" in err_msg or "401" in err_msg or "invalid_api_key" in err_msg:
|
|
67
|
+
raise AIAuthenticationError(
|
|
68
|
+
"OpenAI authentication failed. Please verify that your OPENAI_API_KEY is valid."
|
|
69
|
+
) from exc
|
|
70
|
+
if "rate limit" in err_msg or "429" in err_msg or "quota" in err_msg:
|
|
71
|
+
raise AIServiceError(
|
|
72
|
+
"OpenAI rate limit reached. Please wait a moment and try again."
|
|
73
|
+
) from exc
|
|
74
|
+
raise AIServiceError(f"OpenAI API returned an error: {exc}") from exc
|
|
75
|
+
|
|
76
|
+
if not response or not hasattr(response, "choices") or not response.choices or not response.choices[0].message.content:
|
|
77
|
+
raise AIResponseError("OpenAI API returned an empty response. Please try again.")
|
|
78
|
+
|
|
79
|
+
raw_message = response.choices[0].message.content
|
|
80
|
+
cleaned = sanitize_commit_message(raw_message)
|
|
81
|
+
|
|
82
|
+
if avoid_messages and cleaned in avoid_messages:
|
|
83
|
+
try:
|
|
84
|
+
stronger_prompt = (
|
|
85
|
+
f"{user_prompt}\n\n"
|
|
86
|
+
"CRITICAL: The previous message was already generated. You MUST produce a distinct alternative phrasing."
|
|
87
|
+
)
|
|
88
|
+
retry_response = client.chat.completions.create(
|
|
89
|
+
model=self.model,
|
|
90
|
+
messages=[
|
|
91
|
+
{"role": "system", "content": SYSTEM_PROMPT},
|
|
92
|
+
{"role": "user", "content": stronger_prompt},
|
|
93
|
+
],
|
|
94
|
+
temperature=0.9,
|
|
95
|
+
max_tokens=250,
|
|
96
|
+
)
|
|
97
|
+
if retry_response and retry_response.choices and retry_response.choices[0].message.content:
|
|
98
|
+
retry_cleaned = sanitize_commit_message(retry_response.choices[0].message.content)
|
|
99
|
+
if retry_cleaned:
|
|
100
|
+
return retry_cleaned
|
|
101
|
+
except Exception:
|
|
102
|
+
pass
|
|
103
|
+
|
|
104
|
+
if not cleaned:
|
|
105
|
+
raise AIResponseError("Failed to extract a valid commit message from the AI response.")
|
|
106
|
+
|
|
107
|
+
return cleaned
|