gac 3.8.1__py3-none-any.whl → 3.10.10__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.
- gac/__init__.py +4 -6
- gac/__version__.py +1 -1
- gac/ai_utils.py +18 -49
- gac/cli.py +14 -10
- gac/commit_executor.py +59 -0
- gac/config.py +28 -3
- gac/config_cli.py +19 -7
- gac/constants/__init__.py +34 -0
- gac/constants/commit.py +63 -0
- gac/constants/defaults.py +40 -0
- gac/constants/file_patterns.py +110 -0
- gac/constants/languages.py +119 -0
- gac/diff_cli.py +0 -22
- gac/errors.py +8 -2
- gac/git.py +6 -6
- gac/git_state_validator.py +193 -0
- gac/grouped_commit_workflow.py +458 -0
- gac/init_cli.py +2 -1
- gac/interactive_mode.py +179 -0
- gac/language_cli.py +0 -1
- gac/main.py +222 -959
- gac/model_cli.py +2 -1
- gac/model_identifier.py +70 -0
- gac/oauth/claude_code.py +2 -2
- gac/oauth/qwen_oauth.py +4 -0
- gac/oauth/token_store.py +2 -2
- gac/oauth_retry.py +161 -0
- gac/postprocess.py +155 -0
- gac/prompt.py +20 -490
- gac/prompt_builder.py +88 -0
- gac/providers/README.md +437 -0
- gac/providers/__init__.py +70 -81
- gac/providers/anthropic.py +12 -56
- gac/providers/azure_openai.py +48 -92
- gac/providers/base.py +329 -0
- gac/providers/cerebras.py +10 -43
- gac/providers/chutes.py +16 -72
- gac/providers/claude_code.py +64 -97
- gac/providers/custom_anthropic.py +51 -85
- gac/providers/custom_openai.py +29 -87
- gac/providers/deepseek.py +10 -43
- gac/providers/error_handler.py +139 -0
- gac/providers/fireworks.py +10 -43
- gac/providers/gemini.py +66 -73
- gac/providers/groq.py +10 -62
- gac/providers/kimi_coding.py +19 -59
- gac/providers/lmstudio.py +62 -52
- gac/providers/minimax.py +10 -43
- gac/providers/mistral.py +10 -43
- gac/providers/moonshot.py +10 -43
- gac/providers/ollama.py +54 -41
- gac/providers/openai.py +30 -46
- gac/providers/openrouter.py +15 -62
- gac/providers/protocol.py +71 -0
- gac/providers/qwen.py +55 -67
- gac/providers/registry.py +58 -0
- gac/providers/replicate.py +137 -91
- gac/providers/streamlake.py +26 -56
- gac/providers/synthetic.py +35 -47
- gac/providers/together.py +10 -43
- gac/providers/zai.py +21 -59
- gac/py.typed +0 -0
- gac/security.py +1 -1
- gac/templates/__init__.py +1 -0
- gac/templates/question_generation.txt +60 -0
- gac/templates/system_prompt.txt +224 -0
- gac/templates/user_prompt.txt +28 -0
- gac/utils.py +6 -5
- gac/workflow_context.py +162 -0
- {gac-3.8.1.dist-info → gac-3.10.10.dist-info}/METADATA +1 -1
- gac-3.10.10.dist-info/RECORD +79 -0
- gac/constants.py +0 -328
- gac-3.8.1.dist-info/RECORD +0 -56
- {gac-3.8.1.dist-info → gac-3.10.10.dist-info}/WHEEL +0 -0
- {gac-3.8.1.dist-info → gac-3.10.10.dist-info}/entry_points.txt +0 -0
- {gac-3.8.1.dist-info → gac-3.10.10.dist-info}/licenses/LICENSE +0 -0
gac/model_cli.py
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import os
|
|
4
4
|
from pathlib import Path
|
|
5
|
+
from typing import cast
|
|
5
6
|
|
|
6
7
|
import click
|
|
7
8
|
import questionary
|
|
@@ -61,7 +62,7 @@ def _prompt_required_text(prompt: str) -> str | None:
|
|
|
61
62
|
return None
|
|
62
63
|
value = response.strip()
|
|
63
64
|
if value:
|
|
64
|
-
return value
|
|
65
|
+
return cast(str, value)
|
|
65
66
|
click.echo("A value is required. Please try again.")
|
|
66
67
|
|
|
67
68
|
|
gac/model_identifier.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Model identifier value object for parsing and validating model strings."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from gac.errors import ConfigError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class ModelIdentifier:
|
|
12
|
+
"""Represents a parsed model identifier in the format 'provider:model_name'.
|
|
13
|
+
|
|
14
|
+
This is an immutable value object that ensures model identifiers are
|
|
15
|
+
properly validated and provides convenient access to the components.
|
|
16
|
+
|
|
17
|
+
Attributes:
|
|
18
|
+
provider: The provider name (e.g., 'openai', 'anthropic', 'claude-code')
|
|
19
|
+
model_name: The model name (e.g., 'gpt-4o-mini', 'claude-haiku-4-5')
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
provider: str
|
|
23
|
+
model_name: str
|
|
24
|
+
|
|
25
|
+
@classmethod
|
|
26
|
+
def parse(cls, model_string: str) -> ModelIdentifier:
|
|
27
|
+
"""Parse a model string into a ModelIdentifier.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
model_string: A string in the format 'provider:model_name'
|
|
31
|
+
|
|
32
|
+
Returns:
|
|
33
|
+
A ModelIdentifier instance
|
|
34
|
+
|
|
35
|
+
Raises:
|
|
36
|
+
ConfigError: If the format is invalid or components are empty
|
|
37
|
+
"""
|
|
38
|
+
normalized = model_string.strip()
|
|
39
|
+
|
|
40
|
+
if ":" not in normalized:
|
|
41
|
+
raise ConfigError(
|
|
42
|
+
f"Invalid model format: '{model_string}'. Expected 'provider:model', "
|
|
43
|
+
"e.g. 'openai:gpt-4o-mini'. Use 'gac config set model <provider:model>' "
|
|
44
|
+
"to update your configuration."
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
provider, model_name = normalized.split(":", 1)
|
|
48
|
+
|
|
49
|
+
if not provider or not model_name:
|
|
50
|
+
raise ConfigError(
|
|
51
|
+
f"Invalid model format: '{model_string}'. Both provider and model name "
|
|
52
|
+
"are required (example: 'anthropic:claude-haiku-4-5')."
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
return cls(provider=provider, model_name=model_name)
|
|
56
|
+
|
|
57
|
+
def __str__(self) -> str:
|
|
58
|
+
"""Return the canonical string representation."""
|
|
59
|
+
return f"{self.provider}:{self.model_name}"
|
|
60
|
+
|
|
61
|
+
def starts_with_provider(self, prefix: str) -> bool:
|
|
62
|
+
"""Check if the provider starts with the given prefix.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
prefix: The prefix to check (e.g., 'claude-code', 'qwen')
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
True if the provider matches or the full identifier starts with prefix
|
|
69
|
+
"""
|
|
70
|
+
return self.provider == prefix or str(self).startswith(f"{prefix}:")
|
gac/oauth/claude_code.py
CHANGED
|
@@ -12,7 +12,7 @@ import time
|
|
|
12
12
|
import webbrowser
|
|
13
13
|
from dataclasses import dataclass
|
|
14
14
|
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
15
|
-
from typing import Any, TypedDict
|
|
15
|
+
from typing import Any, TypedDict, cast
|
|
16
16
|
from urllib.parse import parse_qs, urlencode, urlparse
|
|
17
17
|
|
|
18
18
|
import httpx
|
|
@@ -331,7 +331,7 @@ def perform_oauth_flow(quiet: bool = False) -> dict[str, Any] | None:
|
|
|
331
331
|
print("✓ Authorization code received")
|
|
332
332
|
print(" Exchanging for access token...\n")
|
|
333
333
|
|
|
334
|
-
tokens = exchange_code_for_tokens(result.code, context)
|
|
334
|
+
tokens = exchange_code_for_tokens(cast(str, result.code), context)
|
|
335
335
|
if not tokens:
|
|
336
336
|
if not quiet:
|
|
337
337
|
print("❌ Token exchange failed. Please try again.")
|
gac/oauth/qwen_oauth.py
CHANGED
|
@@ -17,6 +17,7 @@ import httpx
|
|
|
17
17
|
from gac import __version__
|
|
18
18
|
from gac.errors import AIError
|
|
19
19
|
from gac.oauth.token_store import OAuthToken, TokenStore
|
|
20
|
+
from gac.utils import get_ssl_verify
|
|
20
21
|
|
|
21
22
|
logger = logging.getLogger(__name__)
|
|
22
23
|
|
|
@@ -86,6 +87,7 @@ class QwenDeviceFlow:
|
|
|
86
87
|
"User-Agent": USER_AGENT,
|
|
87
88
|
},
|
|
88
89
|
timeout=30,
|
|
90
|
+
verify=get_ssl_verify(),
|
|
89
91
|
)
|
|
90
92
|
|
|
91
93
|
if not response.is_success:
|
|
@@ -132,6 +134,7 @@ class QwenDeviceFlow:
|
|
|
132
134
|
"User-Agent": USER_AGENT,
|
|
133
135
|
},
|
|
134
136
|
timeout=30,
|
|
137
|
+
verify=get_ssl_verify(),
|
|
135
138
|
)
|
|
136
139
|
|
|
137
140
|
if response.is_success:
|
|
@@ -197,6 +200,7 @@ class QwenDeviceFlow:
|
|
|
197
200
|
"User-Agent": USER_AGENT,
|
|
198
201
|
},
|
|
199
202
|
timeout=30,
|
|
203
|
+
verify=get_ssl_verify(),
|
|
200
204
|
)
|
|
201
205
|
|
|
202
206
|
if not response.is_success:
|
gac/oauth/token_store.py
CHANGED
|
@@ -5,7 +5,7 @@ import os
|
|
|
5
5
|
import stat
|
|
6
6
|
from dataclasses import dataclass
|
|
7
7
|
from pathlib import Path
|
|
8
|
-
from typing import TypedDict
|
|
8
|
+
from typing import TypedDict, cast
|
|
9
9
|
|
|
10
10
|
|
|
11
11
|
class OAuthToken(TypedDict, total=False):
|
|
@@ -65,7 +65,7 @@ class TokenStore:
|
|
|
65
65
|
with open(token_path) as f:
|
|
66
66
|
token_data = json.load(f)
|
|
67
67
|
if isinstance(token_data, dict) and isinstance(token_data.get("access_token"), str):
|
|
68
|
-
return token_data
|
|
68
|
+
return cast(OAuthToken, token_data)
|
|
69
69
|
return None
|
|
70
70
|
|
|
71
71
|
def remove_token(self, provider: str) -> None:
|
gac/oauth_retry.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""OAuth retry handling for expired tokens.
|
|
2
|
+
|
|
3
|
+
This module provides a unified mechanism for handling OAuth token expiration
|
|
4
|
+
across different providers (Claude Code, Qwen, etc.).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import TYPE_CHECKING
|
|
13
|
+
|
|
14
|
+
from rich.console import Console
|
|
15
|
+
|
|
16
|
+
from gac.errors import AIError, ConfigError
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from gac.workflow_context import WorkflowContext
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
console = Console()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class OAuthProviderConfig:
|
|
27
|
+
"""Configuration for OAuth retry handling for a specific provider."""
|
|
28
|
+
|
|
29
|
+
provider_prefix: str
|
|
30
|
+
display_name: str
|
|
31
|
+
manual_auth_hint: str
|
|
32
|
+
authenticate: Callable[[bool], bool]
|
|
33
|
+
extra_error_check: Callable[[AIError], bool] | None = None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _create_claude_code_authenticator() -> Callable[[bool], bool]:
|
|
37
|
+
"""Create authenticator function for Claude Code."""
|
|
38
|
+
|
|
39
|
+
def authenticate(quiet: bool) -> bool:
|
|
40
|
+
from gac.oauth.claude_code import authenticate_and_save
|
|
41
|
+
|
|
42
|
+
return authenticate_and_save(quiet=quiet)
|
|
43
|
+
|
|
44
|
+
return authenticate
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _create_qwen_authenticator() -> Callable[[bool], bool]:
|
|
48
|
+
"""Create authenticator function for Qwen."""
|
|
49
|
+
|
|
50
|
+
def authenticate(quiet: bool) -> bool:
|
|
51
|
+
from gac.oauth import QwenOAuthProvider, TokenStore
|
|
52
|
+
|
|
53
|
+
try:
|
|
54
|
+
oauth_provider = QwenOAuthProvider(TokenStore())
|
|
55
|
+
oauth_provider.initiate_auth(open_browser=True)
|
|
56
|
+
return True
|
|
57
|
+
except (AIError, ConfigError, OSError):
|
|
58
|
+
return False
|
|
59
|
+
|
|
60
|
+
return authenticate
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _claude_code_extra_check(e: AIError) -> bool:
|
|
64
|
+
"""Extra check for Claude Code - verify error message contains expired/oauth."""
|
|
65
|
+
error_str = str(e).lower()
|
|
66
|
+
return "expired" in error_str or "oauth" in error_str
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
OAUTH_PROVIDERS: list[OAuthProviderConfig] = [
|
|
70
|
+
OAuthProviderConfig(
|
|
71
|
+
provider_prefix="claude-code:",
|
|
72
|
+
display_name="Claude Code",
|
|
73
|
+
manual_auth_hint="Run 'gac model' to re-authenticate manually.",
|
|
74
|
+
authenticate=_create_claude_code_authenticator(),
|
|
75
|
+
extra_error_check=_claude_code_extra_check,
|
|
76
|
+
),
|
|
77
|
+
OAuthProviderConfig(
|
|
78
|
+
provider_prefix="qwen:",
|
|
79
|
+
display_name="Qwen",
|
|
80
|
+
manual_auth_hint="Run 'gac auth qwen login' to re-authenticate manually.",
|
|
81
|
+
authenticate=_create_qwen_authenticator(),
|
|
82
|
+
extra_error_check=None,
|
|
83
|
+
),
|
|
84
|
+
]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _find_oauth_provider(model: str, error: AIError) -> OAuthProviderConfig | None:
|
|
88
|
+
"""Find the OAuth provider config that matches the model and error."""
|
|
89
|
+
if error.error_type != "authentication":
|
|
90
|
+
return None
|
|
91
|
+
|
|
92
|
+
for provider in OAUTH_PROVIDERS:
|
|
93
|
+
if not model.startswith(provider.provider_prefix):
|
|
94
|
+
continue
|
|
95
|
+
if provider.extra_error_check and not provider.extra_error_check(error):
|
|
96
|
+
continue
|
|
97
|
+
return provider
|
|
98
|
+
|
|
99
|
+
return None
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _attempt_reauth_and_retry(
|
|
103
|
+
provider: OAuthProviderConfig,
|
|
104
|
+
quiet: bool,
|
|
105
|
+
retry_workflow: Callable[[], int],
|
|
106
|
+
) -> int:
|
|
107
|
+
"""Attempt re-authentication and retry the workflow.
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
provider: The OAuth provider configuration
|
|
111
|
+
quiet: Whether to suppress output
|
|
112
|
+
retry_workflow: Callable that retries the workflow on success
|
|
113
|
+
|
|
114
|
+
Returns:
|
|
115
|
+
Exit code: 0 for success, 1 for failure
|
|
116
|
+
"""
|
|
117
|
+
console.print(f"[yellow]⚠ {provider.display_name} OAuth token has expired[/yellow]")
|
|
118
|
+
console.print("[cyan]🔐 Starting automatic re-authentication...[/cyan]")
|
|
119
|
+
|
|
120
|
+
try:
|
|
121
|
+
if provider.authenticate(quiet):
|
|
122
|
+
console.print("[green]✓ Re-authentication successful![/green]")
|
|
123
|
+
console.print("[cyan]Retrying commit...[/cyan]\n")
|
|
124
|
+
return retry_workflow()
|
|
125
|
+
else:
|
|
126
|
+
console.print("[red]Re-authentication failed.[/red]")
|
|
127
|
+
console.print(f"[yellow]{provider.manual_auth_hint}[/yellow]")
|
|
128
|
+
return 1
|
|
129
|
+
except (AIError, ConfigError, OSError) as auth_error:
|
|
130
|
+
console.print(f"[red]Re-authentication error: {auth_error}[/red]")
|
|
131
|
+
console.print(f"[yellow]{provider.manual_auth_hint}[/yellow]")
|
|
132
|
+
return 1
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def handle_oauth_retry(e: AIError, ctx: WorkflowContext) -> int:
|
|
136
|
+
"""Handle OAuth retry logic for expired tokens.
|
|
137
|
+
|
|
138
|
+
Checks if the error is an OAuth-related authentication error for a known
|
|
139
|
+
provider, attempts re-authentication, and retries the workflow on success.
|
|
140
|
+
|
|
141
|
+
Args:
|
|
142
|
+
e: The AIError that triggered this handler
|
|
143
|
+
ctx: WorkflowContext containing all workflow configuration and state
|
|
144
|
+
|
|
145
|
+
Returns:
|
|
146
|
+
Exit code: 0 for success, 1 for failure
|
|
147
|
+
"""
|
|
148
|
+
logger.error(str(e))
|
|
149
|
+
|
|
150
|
+
provider = _find_oauth_provider(ctx.model, e)
|
|
151
|
+
|
|
152
|
+
if provider is None:
|
|
153
|
+
console.print(f"[red]Failed to generate commit message: {e!s}[/red]")
|
|
154
|
+
return 1
|
|
155
|
+
|
|
156
|
+
def retry_workflow() -> int:
|
|
157
|
+
from gac.main import _execute_single_commit_workflow
|
|
158
|
+
|
|
159
|
+
return _execute_single_commit_workflow(ctx)
|
|
160
|
+
|
|
161
|
+
return _attempt_reauth_and_retry(provider, ctx.quiet, retry_workflow)
|
gac/postprocess.py
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""Post-processing for AI-generated commit messages.
|
|
2
|
+
|
|
3
|
+
This module handles cleaning and normalization of commit messages generated
|
|
4
|
+
by AI models, removing artifacts like think tags, code blocks, and XML tags.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import re
|
|
8
|
+
|
|
9
|
+
from gac.constants import CommitMessageConstants
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _remove_think_tags(message: str) -> str:
|
|
13
|
+
"""Remove AI reasoning <think> tags and their content from the message.
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
message: The message to clean
|
|
17
|
+
|
|
18
|
+
Returns:
|
|
19
|
+
Message with <think> tags removed
|
|
20
|
+
"""
|
|
21
|
+
while re.search(r"<think>(?:(?!</think>)[^\n])*\n.*?</think>", message, flags=re.DOTALL | re.IGNORECASE):
|
|
22
|
+
message = re.sub(
|
|
23
|
+
r"<think>(?:(?!</think>)[^\n])*\n.*?</think>\s*", "", message, flags=re.DOTALL | re.IGNORECASE, count=1
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
message = re.sub(r"\n\n+\s*<think>.*?</think>\s*", "", message, flags=re.DOTALL | re.IGNORECASE)
|
|
27
|
+
message = re.sub(r"<think>.*?</think>\s*\n\n+", "", message, flags=re.DOTALL | re.IGNORECASE)
|
|
28
|
+
|
|
29
|
+
message = re.sub(r"<think>\s*\n.*$", "", message, flags=re.DOTALL | re.IGNORECASE)
|
|
30
|
+
|
|
31
|
+
conventional_prefixes_pattern = r"(" + "|".join(CommitMessageConstants.CONVENTIONAL_PREFIXES) + r")[\(:)]"
|
|
32
|
+
if re.search(r"^.*?</think>", message, flags=re.DOTALL | re.IGNORECASE):
|
|
33
|
+
prefix_match = re.search(conventional_prefixes_pattern, message, flags=re.IGNORECASE)
|
|
34
|
+
think_match = re.search(r"</think>", message, flags=re.IGNORECASE)
|
|
35
|
+
|
|
36
|
+
if not prefix_match or (think_match and think_match.start() < prefix_match.start()):
|
|
37
|
+
message = re.sub(r"^.*?</think>\s*", "", message, flags=re.DOTALL | re.IGNORECASE)
|
|
38
|
+
|
|
39
|
+
message = re.sub(r"</think>\s*$", "", message, flags=re.IGNORECASE)
|
|
40
|
+
|
|
41
|
+
return message
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _remove_code_blocks(message: str) -> str:
|
|
45
|
+
"""Remove markdown code blocks from the message.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
message: The message to clean
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
Message with code blocks removed
|
|
52
|
+
"""
|
|
53
|
+
return re.sub(r"```[\w]*\n|```", "", message)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _extract_commit_from_reasoning(message: str) -> str:
|
|
57
|
+
"""Extract the actual commit message from reasoning/preamble text.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
message: The message potentially containing reasoning
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
Extracted commit message
|
|
64
|
+
"""
|
|
65
|
+
for indicator in CommitMessageConstants.COMMIT_INDICATORS:
|
|
66
|
+
if indicator.lower() in message.lower():
|
|
67
|
+
message = message.split(indicator, 1)[1].strip()
|
|
68
|
+
break
|
|
69
|
+
|
|
70
|
+
lines = message.split("\n")
|
|
71
|
+
for i, line in enumerate(lines):
|
|
72
|
+
if any(line.strip().startswith(f"{prefix}:") for prefix in CommitMessageConstants.CONVENTIONAL_PREFIXES):
|
|
73
|
+
message = "\n".join(lines[i:])
|
|
74
|
+
break
|
|
75
|
+
|
|
76
|
+
return message
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _remove_xml_tags(message: str) -> str:
|
|
80
|
+
"""Remove XML tags that might have leaked into the message.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
message: The message to clean
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
Message with XML tags removed
|
|
87
|
+
"""
|
|
88
|
+
for tag in CommitMessageConstants.XML_TAGS_TO_REMOVE:
|
|
89
|
+
message = message.replace(tag, "")
|
|
90
|
+
return message
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _fix_double_prefix(message: str) -> str:
|
|
94
|
+
"""Fix double type prefix issues like 'chore: feat(scope):' to 'feat(scope):'.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
message: The message to fix
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
Message with double prefix corrected
|
|
101
|
+
"""
|
|
102
|
+
double_prefix_pattern = re.compile(
|
|
103
|
+
r"^("
|
|
104
|
+
+ r"|\s*".join(CommitMessageConstants.CONVENTIONAL_PREFIXES)
|
|
105
|
+
+ r"):\s*("
|
|
106
|
+
+ r"|\s*".join(CommitMessageConstants.CONVENTIONAL_PREFIXES)
|
|
107
|
+
+ r")\(([^)]+)\):"
|
|
108
|
+
)
|
|
109
|
+
match = double_prefix_pattern.match(message)
|
|
110
|
+
|
|
111
|
+
if match:
|
|
112
|
+
second_type = match.group(2)
|
|
113
|
+
scope = match.group(3)
|
|
114
|
+
description = message[match.end() :].strip()
|
|
115
|
+
message = f"{second_type}({scope}): {description}"
|
|
116
|
+
|
|
117
|
+
return message
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _normalize_whitespace(message: str) -> str:
|
|
121
|
+
"""Normalize whitespace, ensuring no more than one blank line between paragraphs.
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
message: The message to normalize
|
|
125
|
+
|
|
126
|
+
Returns:
|
|
127
|
+
Message with normalized whitespace
|
|
128
|
+
"""
|
|
129
|
+
return re.sub(r"\n(?:[ \t]*\n){2,}", "\n\n", message).strip()
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def clean_commit_message(message: str) -> str:
|
|
133
|
+
"""Clean up a commit message generated by an AI model.
|
|
134
|
+
|
|
135
|
+
This function:
|
|
136
|
+
1. Removes any preamble or reasoning text
|
|
137
|
+
2. Removes code block markers and formatting
|
|
138
|
+
3. Removes XML tags that might have leaked into the response
|
|
139
|
+
4. Fixes double type prefix issues (e.g., "chore: feat(scope):")
|
|
140
|
+
5. Normalizes whitespace
|
|
141
|
+
|
|
142
|
+
Args:
|
|
143
|
+
message: Raw commit message from AI
|
|
144
|
+
|
|
145
|
+
Returns:
|
|
146
|
+
Cleaned commit message ready for use
|
|
147
|
+
"""
|
|
148
|
+
message = message.strip()
|
|
149
|
+
message = _remove_think_tags(message)
|
|
150
|
+
message = _remove_code_blocks(message)
|
|
151
|
+
message = _extract_commit_from_reasoning(message)
|
|
152
|
+
message = _remove_xml_tags(message)
|
|
153
|
+
message = _fix_double_prefix(message)
|
|
154
|
+
message = _normalize_whitespace(message)
|
|
155
|
+
return message
|