diffsage 1.2.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.
- diffsage/__init__.py +0 -0
- diffsage/__main__.py +4 -0
- diffsage/cli.py +38 -0
- diffsage/commands/__init__.py +0 -0
- diffsage/commands/ask.py +62 -0
- diffsage/commands/auth.py +153 -0
- diffsage/commands/commit.py +125 -0
- diffsage/commands/config.py +216 -0
- diffsage/commands/doctor.py +18 -0
- diffsage/config/__init__.py +0 -0
- diffsage/config/defaults.py +20 -0
- diffsage/config/file_loader.py +21 -0
- diffsage/config/loader.py +8 -0
- diffsage/config/paths.py +61 -0
- diffsage/config/resolver.py +118 -0
- diffsage/config/schema.py +53 -0
- diffsage/config/scope.py +8 -0
- diffsage/config/settings.py +9 -0
- diffsage/exceptions/__init__.py +34 -0
- diffsage/exceptions/base.py +32 -0
- diffsage/exceptions/credentials.py +8 -0
- diffsage/exceptions/git.py +9 -0
- diffsage/exceptions/provider.py +17 -0
- diffsage/git/__init__.py +0 -0
- diffsage/git/client.py +155 -0
- diffsage/logging/__init__.py +0 -0
- diffsage/logging/formatter.py +0 -0
- diffsage/logging/logger.py +41 -0
- diffsage/models/__init__.py +0 -0
- diffsage/models/commit_message.py +9 -0
- diffsage/models/config.py +16 -0
- diffsage/models/credentials.py +8 -0
- diffsage/models/doctor.py +13 -0
- diffsage/models/git.py +32 -0
- diffsage/models/provider.py +20 -0
- diffsage/parsers/commit_message_parser.py +43 -0
- diffsage/prompts/__init__.py +0 -0
- diffsage/providers/__init__.py +0 -0
- diffsage/providers/base.py +8 -0
- diffsage/providers/factory.py +14 -0
- diffsage/providers/gemini_provider.py +77 -0
- diffsage/services/__init__.py +0 -0
- diffsage/services/ai_service.py +63 -0
- diffsage/services/commit_service.py +38 -0
- diffsage/services/config_service.py +109 -0
- diffsage/services/credentials_service.py +61 -0
- diffsage/services/doctor_service.py +47 -0
- diffsage/services/editor_service.py +75 -0
- diffsage/services/git_service.py +20 -0
- diffsage/services/prompt_service.py +60 -0
- diffsage/storage/__init__.py +0 -0
- diffsage/storage/base.py +0 -0
- diffsage/storage/config_repository.py +85 -0
- diffsage/storage/credentials_repository.py +121 -0
- diffsage/ui/__init__.py +0 -0
- diffsage/ui/ask_view.py +10 -0
- diffsage/ui/auth_view.py +51 -0
- diffsage/ui/base.py +15 -0
- diffsage/ui/commit_view.py +100 -0
- diffsage/ui/config_view.py +58 -0
- diffsage/ui/doctor_view.py +39 -0
- diffsage-1.2.0.dist-info/METADATA +471 -0
- diffsage-1.2.0.dist-info/RECORD +67 -0
- diffsage-1.2.0.dist-info/WHEEL +5 -0
- diffsage-1.2.0.dist-info/entry_points.txt +2 -0
- diffsage-1.2.0.dist-info/licenses/LICENSE +21 -0
- diffsage-1.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
from diffsage.exceptions import InvalidCommitMessageError
|
|
2
|
+
from diffsage.models.commit_message import CommitMessage
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class CommitMessageParser:
|
|
6
|
+
@staticmethod
|
|
7
|
+
def parse(message: str) -> CommitMessage:
|
|
8
|
+
lines = message.splitlines()
|
|
9
|
+
|
|
10
|
+
if not lines:
|
|
11
|
+
raise InvalidCommitMessageError("Commit message is empty.")
|
|
12
|
+
|
|
13
|
+
header = lines[0]
|
|
14
|
+
body = lines[1:]
|
|
15
|
+
|
|
16
|
+
if ":" not in header:
|
|
17
|
+
raise InvalidCommitMessageError(
|
|
18
|
+
"Commit message header must use '<type>: <subject>' format."
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
header_parts = header.split(":", maxsplit=1)
|
|
22
|
+
prefix = header_parts[0].strip()
|
|
23
|
+
subject = header_parts[1].strip()
|
|
24
|
+
|
|
25
|
+
if "(" in prefix:
|
|
26
|
+
type_part = prefix.split("(", maxsplit=1)[0]
|
|
27
|
+
scope_part = prefix.split("(", maxsplit=1)[1].rstrip(")")
|
|
28
|
+
else:
|
|
29
|
+
type_part = prefix
|
|
30
|
+
scope_part = None
|
|
31
|
+
|
|
32
|
+
if not type_part:
|
|
33
|
+
raise InvalidCommitMessageError("Commit message type is missing.")
|
|
34
|
+
|
|
35
|
+
if not subject:
|
|
36
|
+
raise InvalidCommitMessageError("Commit message subject is missing.")
|
|
37
|
+
|
|
38
|
+
return CommitMessage(
|
|
39
|
+
type=type_part,
|
|
40
|
+
scope=scope_part,
|
|
41
|
+
subject=subject,
|
|
42
|
+
body=body,
|
|
43
|
+
)
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from diffsage.config.settings import Settings
|
|
2
|
+
from diffsage.exceptions.base import ConfigError
|
|
3
|
+
from diffsage.models.credentials import Credential
|
|
4
|
+
from diffsage.providers.base import BaseProvider
|
|
5
|
+
from diffsage.providers.gemini_provider import GeminiProvider
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def create_provider(settings: Settings, credential: Credential) -> BaseProvider:
|
|
9
|
+
"""Create the configured AI provider."""
|
|
10
|
+
|
|
11
|
+
if settings.provider == "gemini":
|
|
12
|
+
return GeminiProvider(settings, credential)
|
|
13
|
+
|
|
14
|
+
raise ConfigError(f"Unsupported provider: {settings.provider}")
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import time
|
|
2
|
+
|
|
3
|
+
from google import genai
|
|
4
|
+
from google.genai import errors as genai_errors
|
|
5
|
+
from google.genai import types
|
|
6
|
+
|
|
7
|
+
from diffsage.config.settings import Settings
|
|
8
|
+
from diffsage.exceptions import (
|
|
9
|
+
AuthenticationError,
|
|
10
|
+
ModelNotFoundError,
|
|
11
|
+
ProviderError,
|
|
12
|
+
ProviderUnavailableError,
|
|
13
|
+
RateLimitError,
|
|
14
|
+
)
|
|
15
|
+
from diffsage.models.credentials import Credential
|
|
16
|
+
from diffsage.models.provider import ProviderRequest, ProviderResponse
|
|
17
|
+
from diffsage.providers.base import BaseProvider
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class GeminiProvider(BaseProvider):
|
|
21
|
+
"""Google Gemini provider implementation."""
|
|
22
|
+
|
|
23
|
+
def __init__(self, settings: Settings, credential: Credential) -> None:
|
|
24
|
+
self._settings = settings
|
|
25
|
+
self._credential = credential
|
|
26
|
+
|
|
27
|
+
self._client = genai.Client(api_key=self._credential.api_key)
|
|
28
|
+
|
|
29
|
+
def generate(self, request: ProviderRequest) -> ProviderResponse:
|
|
30
|
+
start_time = time.perf_counter()
|
|
31
|
+
|
|
32
|
+
timeout_ms = self._settings.timeout * 1000
|
|
33
|
+
|
|
34
|
+
config = types.GenerateContentConfig(
|
|
35
|
+
temperature=request.temperature,
|
|
36
|
+
max_output_tokens=request.max_tokens,
|
|
37
|
+
http_options=types.HttpOptions(timeout=timeout_ms),
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
try:
|
|
41
|
+
response = self._client.models.generate_content(
|
|
42
|
+
model=request.model, contents=request.prompt, config=config
|
|
43
|
+
)
|
|
44
|
+
except genai_errors.APIError as e:
|
|
45
|
+
status = e.status
|
|
46
|
+
|
|
47
|
+
if status == "NOT_FOUND":
|
|
48
|
+
raise ModelNotFoundError(f"Model '{request.model}' was not found.") from e
|
|
49
|
+
|
|
50
|
+
elif status == "UNAUTHENTICATED":
|
|
51
|
+
raise AuthenticationError("Authentication with Gemini failed.") from e
|
|
52
|
+
|
|
53
|
+
elif status == "INVALID_ARGUMENT":
|
|
54
|
+
if e.code == 400:
|
|
55
|
+
raise AuthenticationError(
|
|
56
|
+
"Authentication with Gemini failed. Please check your API Key."
|
|
57
|
+
) from e
|
|
58
|
+
|
|
59
|
+
elif status == "RESOURCE_EXHAUSTED":
|
|
60
|
+
raise RateLimitError("Gemini API rate limit exceeded.") from e
|
|
61
|
+
|
|
62
|
+
elif status in ("UNAVAILABLE", "DEADLINE_EXCEEDED"):
|
|
63
|
+
raise ProviderUnavailableError("Gemini service is currently unavailable.") from e
|
|
64
|
+
|
|
65
|
+
raise ProviderError(f"Gemini API request failed: {e}") from e
|
|
66
|
+
|
|
67
|
+
latency_ms = round((time.perf_counter() - start_time) * 1000)
|
|
68
|
+
|
|
69
|
+
return ProviderResponse(
|
|
70
|
+
content=response.candidates[0].content.parts[0].text,
|
|
71
|
+
provider="gemini",
|
|
72
|
+
model=response.model_version,
|
|
73
|
+
input_tokens=response.usage_metadata.prompt_token_count,
|
|
74
|
+
output_tokens=response.usage_metadata.candidates_token_count,
|
|
75
|
+
finish_reason=response.candidates[0].finish_reason.value,
|
|
76
|
+
latency_ms=latency_ms,
|
|
77
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import time
|
|
2
|
+
|
|
3
|
+
from diffsage.config.settings import Settings
|
|
4
|
+
from diffsage.exceptions import CredentialNotFoundError, ProviderUnavailableError
|
|
5
|
+
from diffsage.models.provider import ProviderRequest, ProviderResponse
|
|
6
|
+
from diffsage.providers.base import BaseProvider
|
|
7
|
+
from diffsage.providers.factory import create_provider
|
|
8
|
+
from diffsage.services.credentials_service import CredentialService
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class AIService:
|
|
12
|
+
"""Coordinates AI provider interactions."""
|
|
13
|
+
|
|
14
|
+
def __init__(
|
|
15
|
+
self,
|
|
16
|
+
settings: Settings,
|
|
17
|
+
credential_service: CredentialService,
|
|
18
|
+
provider: BaseProvider | None = None,
|
|
19
|
+
) -> None:
|
|
20
|
+
self._settings = settings
|
|
21
|
+
|
|
22
|
+
if provider is not None:
|
|
23
|
+
self._provider = provider
|
|
24
|
+
else:
|
|
25
|
+
credential = credential_service.get_credential(
|
|
26
|
+
settings.provider,
|
|
27
|
+
"default",
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
if credential is None:
|
|
31
|
+
raise CredentialNotFoundError(
|
|
32
|
+
settings.provider,
|
|
33
|
+
"default",
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
self._provider = create_provider(settings, credential)
|
|
37
|
+
|
|
38
|
+
def _ask_once(self, prompt: str) -> ProviderResponse:
|
|
39
|
+
request = ProviderRequest(
|
|
40
|
+
prompt=prompt,
|
|
41
|
+
model=self._settings.ai_model,
|
|
42
|
+
temperature=0.2,
|
|
43
|
+
max_tokens=1000,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
return self._provider.generate(request)
|
|
47
|
+
|
|
48
|
+
def _backoff_delay(self, attempt: int) -> int:
|
|
49
|
+
return 2**attempt
|
|
50
|
+
|
|
51
|
+
def ask(self, prompt: str) -> ProviderResponse:
|
|
52
|
+
total_attempts = self._settings.max_retries + 1
|
|
53
|
+
|
|
54
|
+
for attempt in range(total_attempts):
|
|
55
|
+
try:
|
|
56
|
+
return self._ask_once(prompt)
|
|
57
|
+
|
|
58
|
+
except ProviderUnavailableError:
|
|
59
|
+
if attempt == total_attempts - 1:
|
|
60
|
+
raise
|
|
61
|
+
|
|
62
|
+
delay = self._backoff_delay(attempt)
|
|
63
|
+
time.sleep(delay)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
from diffsage.exceptions import NoStagedChangesError, NotGitRepositoryError
|
|
2
|
+
from diffsage.git.client import GitClient
|
|
3
|
+
from diffsage.logging.logger import get_logger
|
|
4
|
+
from diffsage.services.ai_service import AIService
|
|
5
|
+
from diffsage.services.git_service import GitService
|
|
6
|
+
from diffsage.services.prompt_service import PromptService
|
|
7
|
+
|
|
8
|
+
logger = get_logger(__name__)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class CommitService:
|
|
12
|
+
def __init__(
|
|
13
|
+
self,
|
|
14
|
+
git_client: GitClient,
|
|
15
|
+
git_service: GitService,
|
|
16
|
+
prompt_service: PromptService,
|
|
17
|
+
ai_service: AIService,
|
|
18
|
+
) -> None:
|
|
19
|
+
self._git_client = git_client
|
|
20
|
+
self._git_service = git_service
|
|
21
|
+
self._prompt_service = prompt_service
|
|
22
|
+
self._ai_service = ai_service
|
|
23
|
+
|
|
24
|
+
def generate_commit_message(self) -> str:
|
|
25
|
+
if not self._git_client.is_git_repository():
|
|
26
|
+
raise NotGitRepositoryError
|
|
27
|
+
|
|
28
|
+
if not self._git_client.staged_diff():
|
|
29
|
+
raise NoStagedChangesError
|
|
30
|
+
|
|
31
|
+
context = self._git_service.build_commit_context()
|
|
32
|
+
prompt = self._prompt_service.build_commit_prompt(context)
|
|
33
|
+
|
|
34
|
+
logger.info("Generating commit message.")
|
|
35
|
+
response = self._ai_service.ask(prompt)
|
|
36
|
+
logger.info("Commit message generated successfully.")
|
|
37
|
+
|
|
38
|
+
return response.content
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
from diffsage.config.loader import load_settings
|
|
2
|
+
from diffsage.config.settings import Settings
|
|
3
|
+
from diffsage.exceptions import InvalidConfigurationValueError, UnknownConfigurationKeyError
|
|
4
|
+
from diffsage.models.config import ConfigReport, ConfigValueReport
|
|
5
|
+
from diffsage.storage.config_repository import ConfigRepository
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ConfigService:
|
|
9
|
+
_ATTRIBUTE_MAP = {
|
|
10
|
+
"provider": "provider",
|
|
11
|
+
"model": "ai_model",
|
|
12
|
+
"timeout": "timeout",
|
|
13
|
+
"max_retries": "max_retries",
|
|
14
|
+
"log_level": "log_level",
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
_NORMALIZERS = {
|
|
18
|
+
"provider": str.lower,
|
|
19
|
+
"model": str.lower,
|
|
20
|
+
"log_level": str.upper,
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
def __init__(self, settings: Settings, repository: ConfigRepository) -> None:
|
|
24
|
+
self._settings = settings
|
|
25
|
+
self._repository = repository
|
|
26
|
+
|
|
27
|
+
def _create_report(self, settings: Settings) -> ConfigReport:
|
|
28
|
+
return ConfigReport(
|
|
29
|
+
provider=settings.provider,
|
|
30
|
+
model=settings.ai_model,
|
|
31
|
+
timeout=settings.timeout,
|
|
32
|
+
max_retries=settings.max_retries,
|
|
33
|
+
log_level=settings.log_level,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
def get_configuration_raw(self) -> ConfigReport:
|
|
37
|
+
config = self._repository.list()
|
|
38
|
+
|
|
39
|
+
return ConfigReport(
|
|
40
|
+
provider=config.get("provider"),
|
|
41
|
+
model=config.get("model"),
|
|
42
|
+
timeout=config.get("timeout"),
|
|
43
|
+
max_retries=config.get("max_retries"),
|
|
44
|
+
log_level=config.get("log_level"),
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
def get_configuration_value_raw(self, key: str) -> ConfigValueReport:
|
|
48
|
+
if key not in self._ATTRIBUTE_MAP:
|
|
49
|
+
raise UnknownConfigurationKeyError(key)
|
|
50
|
+
|
|
51
|
+
value = self._repository.get(key)
|
|
52
|
+
|
|
53
|
+
return ConfigValueReport(
|
|
54
|
+
key=key,
|
|
55
|
+
value=value,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
def get_configuration(self) -> ConfigReport:
|
|
59
|
+
return self._create_report(self._settings)
|
|
60
|
+
|
|
61
|
+
def get_value(self, key: str) -> ConfigValueReport:
|
|
62
|
+
current_value = self._ATTRIBUTE_MAP.get(key)
|
|
63
|
+
|
|
64
|
+
if current_value is None:
|
|
65
|
+
raise UnknownConfigurationKeyError(key)
|
|
66
|
+
|
|
67
|
+
value = getattr(self._settings, current_value)
|
|
68
|
+
|
|
69
|
+
return ConfigValueReport(key=key, value=str(value))
|
|
70
|
+
|
|
71
|
+
def set_value(self, key: str, value: str) -> ConfigReport:
|
|
72
|
+
attribute_name = self._ATTRIBUTE_MAP.get(key)
|
|
73
|
+
|
|
74
|
+
if attribute_name is None:
|
|
75
|
+
raise UnknownConfigurationKeyError(key)
|
|
76
|
+
|
|
77
|
+
value = value.strip()
|
|
78
|
+
normalizer = self._NORMALIZERS.get(key)
|
|
79
|
+
|
|
80
|
+
if normalizer is not None:
|
|
81
|
+
value = normalizer(value)
|
|
82
|
+
|
|
83
|
+
expected_value = getattr(self._settings, attribute_name)
|
|
84
|
+
|
|
85
|
+
try:
|
|
86
|
+
if isinstance(expected_value, int):
|
|
87
|
+
converted_value = int(value)
|
|
88
|
+
else:
|
|
89
|
+
converted_value = value
|
|
90
|
+
except ValueError:
|
|
91
|
+
raise InvalidConfigurationValueError(value) from None
|
|
92
|
+
|
|
93
|
+
self._repository.set(key, converted_value)
|
|
94
|
+
|
|
95
|
+
updated_settings = load_settings()
|
|
96
|
+
|
|
97
|
+
return self._create_report(updated_settings)
|
|
98
|
+
|
|
99
|
+
def unset_value(self, key: str) -> ConfigReport:
|
|
100
|
+
attribute_name = self._ATTRIBUTE_MAP.get(key)
|
|
101
|
+
|
|
102
|
+
if attribute_name is None:
|
|
103
|
+
raise UnknownConfigurationKeyError(key)
|
|
104
|
+
|
|
105
|
+
self._repository.unset(key)
|
|
106
|
+
|
|
107
|
+
updated_settings = load_settings()
|
|
108
|
+
|
|
109
|
+
return self._create_report(updated_settings)
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
from diffsage.models.credentials import Credential
|
|
2
|
+
from diffsage.storage.credentials_repository import CredentialsRepository
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class CredentialService:
|
|
6
|
+
_NORMALIZERS = {
|
|
7
|
+
"provider": str.lower,
|
|
8
|
+
"name": str.lower,
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
def __init__(self, repository: CredentialsRepository) -> None:
|
|
12
|
+
self._repository = repository
|
|
13
|
+
|
|
14
|
+
def set_credential(self, provider: str, api_key: str, name: str = "default") -> None:
|
|
15
|
+
provider = self._NORMALIZERS["provider"](provider.strip())
|
|
16
|
+
name = self._NORMALIZERS["name"](name.strip())
|
|
17
|
+
api_key = api_key.strip()
|
|
18
|
+
|
|
19
|
+
if not provider:
|
|
20
|
+
raise ValueError("Provider cannot be empty.")
|
|
21
|
+
|
|
22
|
+
if not name:
|
|
23
|
+
raise ValueError("Credential profile name cannot be empty.")
|
|
24
|
+
|
|
25
|
+
if not api_key:
|
|
26
|
+
raise ValueError("API key cannot be empty.")
|
|
27
|
+
|
|
28
|
+
credential = Credential(
|
|
29
|
+
provider=provider,
|
|
30
|
+
name=name,
|
|
31
|
+
api_key=api_key,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
self._repository.save(credential)
|
|
35
|
+
|
|
36
|
+
def get_credential(self, provider: str, name: str = "default") -> Credential | None:
|
|
37
|
+
provider = self._NORMALIZERS["provider"](provider.strip())
|
|
38
|
+
name = self._NORMALIZERS["name"](name.strip())
|
|
39
|
+
|
|
40
|
+
if not provider:
|
|
41
|
+
raise ValueError("Provider cannot be empty.")
|
|
42
|
+
|
|
43
|
+
if not name:
|
|
44
|
+
raise ValueError("Credential profile name cannot be empty.")
|
|
45
|
+
|
|
46
|
+
return self._repository.get(provider, name)
|
|
47
|
+
|
|
48
|
+
def list_credentials(self) -> list[Credential]:
|
|
49
|
+
return self._repository.list()
|
|
50
|
+
|
|
51
|
+
def delete_credential(self, provider: str, name: str = "default") -> bool:
|
|
52
|
+
provider = self._NORMALIZERS["provider"](provider.strip())
|
|
53
|
+
name = self._NORMALIZERS["name"](name.strip())
|
|
54
|
+
|
|
55
|
+
if not provider:
|
|
56
|
+
raise ValueError("Provider cannot be empty.")
|
|
57
|
+
|
|
58
|
+
if not name:
|
|
59
|
+
raise ValueError("Credential profile cannot be empty.")
|
|
60
|
+
|
|
61
|
+
return self._repository.delete(provider, name)
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import platform
|
|
3
|
+
import shutil
|
|
4
|
+
import subprocess
|
|
5
|
+
|
|
6
|
+
from diffsage.config.loader import load_settings
|
|
7
|
+
from diffsage.logging.logger import get_logger
|
|
8
|
+
from diffsage.models.doctor import DoctorReport
|
|
9
|
+
|
|
10
|
+
logger = get_logger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class DoctorService:
|
|
14
|
+
"""Runs environment diagnostics."""
|
|
15
|
+
|
|
16
|
+
def run(self) -> DoctorReport:
|
|
17
|
+
logger.info("Loading application settings.")
|
|
18
|
+
|
|
19
|
+
settings = load_settings()
|
|
20
|
+
|
|
21
|
+
git_path = shutil.which("git")
|
|
22
|
+
git_installed = git_path is not None
|
|
23
|
+
git_version = None
|
|
24
|
+
|
|
25
|
+
if git_installed:
|
|
26
|
+
try:
|
|
27
|
+
result = subprocess.run(
|
|
28
|
+
["git", "--version"],
|
|
29
|
+
capture_output=True,
|
|
30
|
+
text=True,
|
|
31
|
+
check=True,
|
|
32
|
+
)
|
|
33
|
+
git_version = result.stdout.strip()
|
|
34
|
+
except subprocess.SubprocessError:
|
|
35
|
+
git_version = None
|
|
36
|
+
|
|
37
|
+
return DoctorReport(
|
|
38
|
+
python_version=platform.python_version(),
|
|
39
|
+
git_installed=git_installed,
|
|
40
|
+
git_version=git_version,
|
|
41
|
+
provider=settings.provider,
|
|
42
|
+
timeout=settings.timeout,
|
|
43
|
+
max_retries=settings.max_retries,
|
|
44
|
+
log_level=settings.log_level,
|
|
45
|
+
virtual_environment=os.getenv("VIRTUAL_ENV") is not None,
|
|
46
|
+
configuration_loaded=True,
|
|
47
|
+
)
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import platform
|
|
3
|
+
import shlex
|
|
4
|
+
import subprocess
|
|
5
|
+
import tempfile
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class EditorService:
|
|
10
|
+
def edit(self, text: str) -> str:
|
|
11
|
+
editor = self._resolve_editor()
|
|
12
|
+
|
|
13
|
+
temp_file = self._create_temp_file(text)
|
|
14
|
+
|
|
15
|
+
command = shlex.split(editor)
|
|
16
|
+
command.append(str(temp_file))
|
|
17
|
+
|
|
18
|
+
try:
|
|
19
|
+
subprocess.run(command, check=False)
|
|
20
|
+
text = temp_file.read_text(encoding="utf-8")
|
|
21
|
+
return text.rstrip()
|
|
22
|
+
|
|
23
|
+
except FileNotFoundError as e:
|
|
24
|
+
raise RuntimeError(
|
|
25
|
+
f'Unable to launch "{editor}". Check your EDITOR or VISUAL configuration.'
|
|
26
|
+
) from e
|
|
27
|
+
|
|
28
|
+
finally:
|
|
29
|
+
temp_file.unlink(missing_ok=True)
|
|
30
|
+
|
|
31
|
+
def _resolve_editor(self) -> str:
|
|
32
|
+
editor = os.environ.get("VISUAL")
|
|
33
|
+
if editor:
|
|
34
|
+
return editor
|
|
35
|
+
|
|
36
|
+
editor = os.environ.get("EDITOR")
|
|
37
|
+
if editor:
|
|
38
|
+
return editor
|
|
39
|
+
|
|
40
|
+
try:
|
|
41
|
+
result = subprocess.run(
|
|
42
|
+
[
|
|
43
|
+
"git",
|
|
44
|
+
"config",
|
|
45
|
+
"--global",
|
|
46
|
+
"core.editor",
|
|
47
|
+
],
|
|
48
|
+
capture_output=True,
|
|
49
|
+
text=True,
|
|
50
|
+
check=True,
|
|
51
|
+
)
|
|
52
|
+
editor = result.stdout.strip()
|
|
53
|
+
if editor:
|
|
54
|
+
return editor
|
|
55
|
+
|
|
56
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
57
|
+
pass
|
|
58
|
+
|
|
59
|
+
if platform.system() == "Windows":
|
|
60
|
+
return "notepad"
|
|
61
|
+
|
|
62
|
+
return "nano"
|
|
63
|
+
|
|
64
|
+
def _create_temp_file(self, text: str) -> Path:
|
|
65
|
+
with tempfile.NamedTemporaryFile(
|
|
66
|
+
delete=False,
|
|
67
|
+
mode="w",
|
|
68
|
+
encoding="utf-8",
|
|
69
|
+
suffix=".txt",
|
|
70
|
+
) as temp:
|
|
71
|
+
temp.write(text)
|
|
72
|
+
temp.flush()
|
|
73
|
+
path = Path(temp.name)
|
|
74
|
+
|
|
75
|
+
return path
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from diffsage.git.client import GitClient
|
|
2
|
+
from diffsage.models.git import CommitContext
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class GitService:
|
|
6
|
+
def __init__(self, git_client: GitClient):
|
|
7
|
+
self._git_client = git_client
|
|
8
|
+
|
|
9
|
+
def build_commit_context(
|
|
10
|
+
self,
|
|
11
|
+
recent_commit_limit: int = 10,
|
|
12
|
+
) -> CommitContext:
|
|
13
|
+
return CommitContext(
|
|
14
|
+
branch=self._git_client.current_branch(),
|
|
15
|
+
staged_diff=self._git_client.staged_diff(),
|
|
16
|
+
unstaged_diff=self._git_client.unstaged_diff(),
|
|
17
|
+
recent_commits=self._git_client.recent_commits(
|
|
18
|
+
limit=recent_commit_limit,
|
|
19
|
+
),
|
|
20
|
+
)
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
from diffsage.models.git import CommitContext
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class PromptService:
|
|
5
|
+
def _build_commit_context(
|
|
6
|
+
self,
|
|
7
|
+
context: CommitContext,
|
|
8
|
+
) -> str:
|
|
9
|
+
lines: list[str] = []
|
|
10
|
+
|
|
11
|
+
lines.extend(
|
|
12
|
+
[
|
|
13
|
+
"Branch:",
|
|
14
|
+
context.branch,
|
|
15
|
+
"",
|
|
16
|
+
"Staged Diff:",
|
|
17
|
+
context.staged_diff,
|
|
18
|
+
"",
|
|
19
|
+
"Unstaged Diff:",
|
|
20
|
+
context.unstaged_diff,
|
|
21
|
+
"",
|
|
22
|
+
"Recent Commits:",
|
|
23
|
+
]
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
for commit in context.recent_commits:
|
|
27
|
+
lines.append(f"{commit.hash} | {commit.author} | {commit.message} | {commit.date}")
|
|
28
|
+
|
|
29
|
+
return "\n".join(lines)
|
|
30
|
+
|
|
31
|
+
def _build_commit_instructions(self) -> str:
|
|
32
|
+
"""Build a prompt for generating a Git commit message."""
|
|
33
|
+
|
|
34
|
+
return """
|
|
35
|
+
Generate a concise Conventional Commit message for the following Git diff.
|
|
36
|
+
|
|
37
|
+
Requirements:
|
|
38
|
+
- Return only the commit message.
|
|
39
|
+
- Follow the Conventional Commits specification.
|
|
40
|
+
- Keep the subject under 72 characters.
|
|
41
|
+
- Write the subject in the imperative mood.
|
|
42
|
+
- Leave exactly one blank line after the subject.
|
|
43
|
+
- For simple changes, return only the subject line.
|
|
44
|
+
- For changes affecting multiple files, features,
|
|
45
|
+
refactoring, architecture, or significant fixes, always include a body.
|
|
46
|
+
- If a body is included, it MUST contain 3–6 bullet points.
|
|
47
|
+
- Use dashes (-) for bullets.
|
|
48
|
+
- Do not write body paragraphs.
|
|
49
|
+
- Start each bullet with an imperative verb (e.g., Add, Update, Remove, Refactor, Fix).
|
|
50
|
+
- End each bullet with a period.
|
|
51
|
+
- Each bullet should describe one significant change.
|
|
52
|
+
- Keep each bullet concise.
|
|
53
|
+
- Do not include Markdown headings, explanations, or code fences.
|
|
54
|
+
|
|
55
|
+
Repository Context:
|
|
56
|
+
|
|
57
|
+
""".strip()
|
|
58
|
+
|
|
59
|
+
def build_commit_prompt(self, context: CommitContext) -> str:
|
|
60
|
+
return f"{self._build_commit_instructions()}\n\n{self._build_commit_context(context)}"
|
|
File without changes
|
diffsage/storage/base.py
ADDED
|
File without changes
|