shift-this-version 1.3.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.
- shift_this_version/__init__.py +23 -0
- shift_this_version/analyzer.py +514 -0
- shift_this_version/cli.py +751 -0
- shift_this_version/config.py +59 -0
- shift_this_version/git_ops.py +248 -0
- shift_this_version/updater.py +272 -0
- shift_this_version-1.3.0.dist-info/METADATA +395 -0
- shift_this_version-1.3.0.dist-info/RECORD +10 -0
- shift_this_version-1.3.0.dist-info/WHEEL +4 -0
- shift_this_version-1.3.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""
|
|
2
|
+
shift-this-version: Smart SemVer Bumper driven by Code Diff & AI
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from .analyzer import analyze, BumpAnalysis
|
|
6
|
+
from .updater import calculate_next_version, find_version_targets, apply_version_bump, VersionTarget
|
|
7
|
+
from .git_ops import get_filtered_diff, get_commits_since, get_latest_tag, get_diff_summary
|
|
8
|
+
|
|
9
|
+
__version__ = "1.3.0"
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"analyze",
|
|
13
|
+
"BumpAnalysis",
|
|
14
|
+
"calculate_next_version",
|
|
15
|
+
"find_version_targets",
|
|
16
|
+
"apply_version_bump",
|
|
17
|
+
"VersionTarget",
|
|
18
|
+
"get_filtered_diff",
|
|
19
|
+
"get_commits_since",
|
|
20
|
+
"get_latest_tag",
|
|
21
|
+
"get_diff_summary",
|
|
22
|
+
"__version__"
|
|
23
|
+
]
|
|
@@ -0,0 +1,514 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import json
|
|
3
|
+
import re
|
|
4
|
+
from typing import List, Literal, Optional, Tuple
|
|
5
|
+
import httpx
|
|
6
|
+
from pydantic import BaseModel, Field, model_validator
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
class BumpAnalysis(BaseModel):
|
|
10
|
+
bump_type: Literal["major", "minor", "patch", "none"] = Field(
|
|
11
|
+
default="patch",
|
|
12
|
+
description="The SemVer bump level: major, minor, patch, or none"
|
|
13
|
+
)
|
|
14
|
+
confidence: float = Field(
|
|
15
|
+
default=1.0,
|
|
16
|
+
description="Confidence score between 0.0 and 1.0"
|
|
17
|
+
)
|
|
18
|
+
commit_message: str = Field(
|
|
19
|
+
default="",
|
|
20
|
+
description="Concise Conventional Commit message (e.g. 'feat: ...' or 'fix: ...') summarizing the code changes"
|
|
21
|
+
)
|
|
22
|
+
reasoning: str = Field(
|
|
23
|
+
default="AI completed SemVer analysis.",
|
|
24
|
+
description="Detailed explanation of why this bump level was chosen based on the diff and commits"
|
|
25
|
+
)
|
|
26
|
+
breaking_changes: List[str] = Field(
|
|
27
|
+
default_factory=list,
|
|
28
|
+
description="List of breaking changes identified, if any"
|
|
29
|
+
)
|
|
30
|
+
key_changes: List[str] = Field(
|
|
31
|
+
default_factory=list,
|
|
32
|
+
description="List of key changes, features, or bug fixes detected"
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
@model_validator(mode="before")
|
|
36
|
+
@classmethod
|
|
37
|
+
def normalize_input(cls, data: Any) -> Any:
|
|
38
|
+
if not isinstance(data, dict):
|
|
39
|
+
return data
|
|
40
|
+
|
|
41
|
+
# Check for provider error messages
|
|
42
|
+
if "error" in data:
|
|
43
|
+
err = data["error"]
|
|
44
|
+
err_msg = err.get("message", str(err)) if isinstance(err, dict) else str(err)
|
|
45
|
+
raise ValueError(f"AI Provider error: {err_msg}")
|
|
46
|
+
|
|
47
|
+
# Unwrap if inside nested containers: "analysis", "result", "data", "response", "semver"
|
|
48
|
+
for container_key in ["analysis", "result", "data", "response", "semver", "version_bump", "recommendation"]:
|
|
49
|
+
if container_key in data and isinstance(data[container_key], dict):
|
|
50
|
+
inner = data[container_key]
|
|
51
|
+
for k, v in inner.items():
|
|
52
|
+
data.setdefault(k, v)
|
|
53
|
+
|
|
54
|
+
# Normalize bump_type synonyms and aliases
|
|
55
|
+
if "bump_type" not in data or not data["bump_type"]:
|
|
56
|
+
for alt in ["bumpType", "bump", "type", "level", "semver_bump", "version_bump", "action", "recommendation"]:
|
|
57
|
+
if alt in data and isinstance(data[alt], str):
|
|
58
|
+
data["bump_type"] = data[alt]
|
|
59
|
+
break
|
|
60
|
+
|
|
61
|
+
if "bump_type" in data and isinstance(data["bump_type"], str):
|
|
62
|
+
val = data["bump_type"].lower().strip()
|
|
63
|
+
if "major" in val:
|
|
64
|
+
data["bump_type"] = "major"
|
|
65
|
+
elif "minor" in val:
|
|
66
|
+
data["bump_type"] = "minor"
|
|
67
|
+
elif "patch" in val:
|
|
68
|
+
data["bump_type"] = "patch"
|
|
69
|
+
elif "none" in val:
|
|
70
|
+
data["bump_type"] = "none"
|
|
71
|
+
else:
|
|
72
|
+
data["bump_type"] = "patch"
|
|
73
|
+
else:
|
|
74
|
+
data["bump_type"] = "patch"
|
|
75
|
+
|
|
76
|
+
# Normalize reasoning synonyms and aliases
|
|
77
|
+
if "reasoning" not in data or not data["reasoning"]:
|
|
78
|
+
for alt in ["reason", "explanation", "rationale", "description", "details", "summary", "justification", "notes"]:
|
|
79
|
+
if alt in data and data[alt]:
|
|
80
|
+
data["reasoning"] = str(data[alt])
|
|
81
|
+
break
|
|
82
|
+
if not data.get("reasoning"):
|
|
83
|
+
data["reasoning"] = "AI analyzed code diff and recommended this SemVer shift."
|
|
84
|
+
|
|
85
|
+
# Normalize commit_message synonyms
|
|
86
|
+
if "commit_message" not in data or not data["commit_message"]:
|
|
87
|
+
for alt in ["commitMessage", "commit_msg", "suggested_commit_message", "conventional_commit", "message"]:
|
|
88
|
+
if alt in data and data[alt]:
|
|
89
|
+
data["commit_message"] = str(data[alt])
|
|
90
|
+
break
|
|
91
|
+
|
|
92
|
+
# Normalize confidence
|
|
93
|
+
if "confidence" not in data:
|
|
94
|
+
for alt in ["confidence_score", "score"]:
|
|
95
|
+
if alt in data:
|
|
96
|
+
data["confidence"] = data[alt]
|
|
97
|
+
break
|
|
98
|
+
try:
|
|
99
|
+
conf = float(data.get("confidence", 1.0))
|
|
100
|
+
if conf > 1.0:
|
|
101
|
+
conf = conf / 100.0
|
|
102
|
+
data["confidence"] = max(0.0, min(1.0, conf))
|
|
103
|
+
except (ValueError, TypeError):
|
|
104
|
+
data["confidence"] = 0.95
|
|
105
|
+
|
|
106
|
+
# Normalize breaking_changes
|
|
107
|
+
if "breaking_changes" not in data:
|
|
108
|
+
for alt in ["breakingChanges", "breaking"]:
|
|
109
|
+
if alt in data:
|
|
110
|
+
data["breaking_changes"] = data[alt]
|
|
111
|
+
break
|
|
112
|
+
if isinstance(data.get("breaking_changes"), str):
|
|
113
|
+
data["breaking_changes"] = [data["breaking_changes"]]
|
|
114
|
+
elif not isinstance(data.get("breaking_changes"), list):
|
|
115
|
+
data["breaking_changes"] = []
|
|
116
|
+
|
|
117
|
+
# Normalize key_changes
|
|
118
|
+
if "key_changes" not in data:
|
|
119
|
+
for alt in ["keyChanges", "changes"]:
|
|
120
|
+
if alt in data:
|
|
121
|
+
data["key_changes"] = data[alt]
|
|
122
|
+
break
|
|
123
|
+
if isinstance(data.get("key_changes"), str):
|
|
124
|
+
data["key_changes"] = [data["key_changes"]]
|
|
125
|
+
elif not isinstance(data.get("key_changes"), list):
|
|
126
|
+
data["key_changes"] = []
|
|
127
|
+
|
|
128
|
+
return data
|
|
129
|
+
|
|
130
|
+
SYSTEM_PROMPT = """You are an expert software engineer and Semantic Versioning (SemVer 2.0.0) analyst.
|
|
131
|
+
Your task is to analyze the provided Git commit logs and code diff, and decide whether the next release should be:
|
|
132
|
+
- "major": Contains backwards-incompatible API changes, breaking changes, removed public endpoints/functions/classes/parameters, or major breaking redesigns.
|
|
133
|
+
- "minor": Adds new functionality or features in a backwards-compatible manner, or introduces new deprecations without removing old APIs.
|
|
134
|
+
- "patch": Backwards-compatible bug fixes, minor refactoring, dependency updates, internal optimizations, or documentation changes.
|
|
135
|
+
- "none": No functional code or behavior changes warranting a version increment.
|
|
136
|
+
|
|
137
|
+
Also formulate a clear, concise Conventional Commit message (e.g., "feat: ...", "fix: ...", "refactor: ...") that accurately summarizes the overall code diff.
|
|
138
|
+
|
|
139
|
+
Analyze with strict attention to public API contracts, function signatures, and exported variables.
|
|
140
|
+
You MUST output valid JSON matching this exact structure:
|
|
141
|
+
{
|
|
142
|
+
"bump_type": "major" | "minor" | "patch" | "none",
|
|
143
|
+
"confidence": 0.95,
|
|
144
|
+
"commit_message": "feat(core): concise Conventional Commit message summarizing the changes",
|
|
145
|
+
"reasoning": "Explanation of the decision",
|
|
146
|
+
"breaking_changes": ["detail of breaking change 1", ...],
|
|
147
|
+
"key_changes": ["summary of change 1", ...]
|
|
148
|
+
}
|
|
149
|
+
Output ONLY the JSON object. Do not wrap in markdown or include additional text.
|
|
150
|
+
"""
|
|
151
|
+
|
|
152
|
+
from shift_this_version import config
|
|
153
|
+
|
|
154
|
+
def extract_json_from_text(text: str) -> dict:
|
|
155
|
+
"""ทำความสะอาดและแปลงข้อความตอบกลับจาก LLM ให้เป็น JSON dict อย่างแม่นยำและทนทาน"""
|
|
156
|
+
if not text or not text.strip():
|
|
157
|
+
raise ValueError("Received empty response from AI model.")
|
|
158
|
+
|
|
159
|
+
cleaned = text.strip()
|
|
160
|
+
|
|
161
|
+
# 1. ลบ thinking block จาก reasoning models (<think>...</think>)
|
|
162
|
+
cleaned = re.sub(r"<think>[\s\S]*?</think>", "", cleaned, flags=re.IGNORECASE).strip()
|
|
163
|
+
|
|
164
|
+
# 2. ตรวจหา markdown code fence ```json ... ``` หรือ ``` ... ```
|
|
165
|
+
match = re.search(r"```(?:json)?\s*([\s\S]*?)\s*```", cleaned, flags=re.IGNORECASE)
|
|
166
|
+
if match:
|
|
167
|
+
candidate = match.group(1).strip()
|
|
168
|
+
try:
|
|
169
|
+
parsed = json.loads(candidate)
|
|
170
|
+
if isinstance(parsed, dict):
|
|
171
|
+
return parsed
|
|
172
|
+
elif isinstance(parsed, list) and parsed and isinstance(parsed[0], dict):
|
|
173
|
+
return parsed[0]
|
|
174
|
+
except Exception:
|
|
175
|
+
pass
|
|
176
|
+
|
|
177
|
+
# 3. ลอง parse ข้อความทั้งหมดโดยตรง
|
|
178
|
+
try:
|
|
179
|
+
parsed = json.loads(cleaned)
|
|
180
|
+
if isinstance(parsed, dict):
|
|
181
|
+
return parsed
|
|
182
|
+
elif isinstance(parsed, list) and parsed and isinstance(parsed[0], dict):
|
|
183
|
+
return parsed[0]
|
|
184
|
+
except Exception:
|
|
185
|
+
pass
|
|
186
|
+
|
|
187
|
+
# 4. หากมีข้อความเกริ่นนำหรือสรุปท้าย ให้หา outermost { ... }
|
|
188
|
+
first_brace = cleaned.find("{")
|
|
189
|
+
last_brace = cleaned.rfind("}")
|
|
190
|
+
if first_brace != -1 and last_brace != -1 and last_brace > first_brace:
|
|
191
|
+
candidate = cleaned[first_brace:last_brace + 1]
|
|
192
|
+
try:
|
|
193
|
+
parsed = json.loads(candidate)
|
|
194
|
+
if isinstance(parsed, dict):
|
|
195
|
+
return parsed
|
|
196
|
+
elif isinstance(parsed, list) and parsed and isinstance(parsed[0], dict):
|
|
197
|
+
return parsed[0]
|
|
198
|
+
except Exception:
|
|
199
|
+
# ลองแก้ไข trailing commas
|
|
200
|
+
try:
|
|
201
|
+
fixed = re.sub(r",\s*([\]}])", r"\1", candidate)
|
|
202
|
+
parsed = json.loads(fixed)
|
|
203
|
+
if isinstance(parsed, dict):
|
|
204
|
+
return parsed
|
|
205
|
+
except Exception:
|
|
206
|
+
pass
|
|
207
|
+
|
|
208
|
+
snippet = cleaned[:200] + ("..." if len(cleaned) > 200 else "")
|
|
209
|
+
raise ValueError(f"Could not parse valid JSON from AI response: {snippet}")
|
|
210
|
+
|
|
211
|
+
def get_key_for_provider(provider: str, explicit_key: Optional[str] = None) -> Optional[str]:
|
|
212
|
+
"""
|
|
213
|
+
ดึง API Key ตามลำดับความสำคัญ:
|
|
214
|
+
1. ส่งผ่าน parameter/flag โดยตรง
|
|
215
|
+
2. ค่าที่บันทึกไว้ใน ~/.shift-this-version/config.json
|
|
216
|
+
3. Tool-specific Environment variable (เช่น SHIFT_GEMINI_API_KEY)
|
|
217
|
+
4. General Environment variable (เช่น GEMINI_API_KEY)
|
|
218
|
+
"""
|
|
219
|
+
if explicit_key:
|
|
220
|
+
return explicit_key
|
|
221
|
+
|
|
222
|
+
prov = provider.lower()
|
|
223
|
+
# 1. จาก config.json
|
|
224
|
+
configured = config.get_configured_key(prov)
|
|
225
|
+
if configured:
|
|
226
|
+
return configured
|
|
227
|
+
|
|
228
|
+
# 2. จาก Environment Variables
|
|
229
|
+
env_names = {
|
|
230
|
+
"gemini": ["SHIFT_GEMINI_API_KEY", "GEMINI_API_KEY"],
|
|
231
|
+
"anthropic": ["SHIFT_ANTHROPIC_API_KEY", "ANTHROPIC_API_KEY"],
|
|
232
|
+
"openrouter": ["SHIFT_OPENROUTER_API_KEY", "OPENROUTER_API_KEY"],
|
|
233
|
+
"openai": ["SHIFT_OPENAI_API_KEY", "OPENAI_API_KEY"],
|
|
234
|
+
"deepseek": ["SHIFT_DEEPSEEK_API_KEY", "DEEPSEEK_API_KEY"],
|
|
235
|
+
"groq": ["SHIFT_GROQ_API_KEY", "GROQ_API_KEY"],
|
|
236
|
+
"custom": ["SHIFT_CUSTOM_API_KEY", "CUSTOM_API_KEY"],
|
|
237
|
+
}
|
|
238
|
+
for env_var in env_names.get(prov, []):
|
|
239
|
+
val = os.getenv(env_var)
|
|
240
|
+
if val:
|
|
241
|
+
return val
|
|
242
|
+
return None
|
|
243
|
+
|
|
244
|
+
def is_ollama_running(host: str = "http://localhost:11434") -> bool:
|
|
245
|
+
"""Check if local Ollama daemon is reachable with a fast 1.0s timeout."""
|
|
246
|
+
try:
|
|
247
|
+
with httpx.Client(timeout=1.0) as client:
|
|
248
|
+
resp = client.get(f"{host.rstrip('/')}/api/tags")
|
|
249
|
+
return resp.status_code == 200
|
|
250
|
+
except Exception:
|
|
251
|
+
return False
|
|
252
|
+
|
|
253
|
+
def detect_default_provider() -> Tuple[Optional[str], Optional[str]]:
|
|
254
|
+
"""ตรวจจับ provider และ key ที่พร้อมใช้งานอัตโนมัติ"""
|
|
255
|
+
# 1. ดูจาก default_provider ใน config.json
|
|
256
|
+
cfg_default = config.get_default_provider()
|
|
257
|
+
if cfg_default:
|
|
258
|
+
key = get_key_for_provider(cfg_default)
|
|
259
|
+
if key or cfg_default in ("ollama", "custom"):
|
|
260
|
+
return cfg_default, key
|
|
261
|
+
|
|
262
|
+
# 2. ตรวจเช็คทีละ provider ตามลำดับความนิยม
|
|
263
|
+
for prov in ["gemini", "anthropic", "openrouter", "deepseek", "groq", "openai"]:
|
|
264
|
+
key = get_key_for_provider(prov)
|
|
265
|
+
if key:
|
|
266
|
+
return prov, key
|
|
267
|
+
|
|
268
|
+
# 3. ตรวจสอบว่ามี Ollama รันอยู่จริงหรือไม่ ก่อนจะเลือกใช้งาน
|
|
269
|
+
ollama_host = config.get_configured_host("ollama") or os.getenv("OLLAMA_HOST", "http://localhost:11434")
|
|
270
|
+
if is_ollama_running(ollama_host):
|
|
271
|
+
return "ollama", ollama_host
|
|
272
|
+
|
|
273
|
+
# 4. หากไม่มี provider หรือ key ที่พร้อมใช้งานเลย คืน None
|
|
274
|
+
return None, None
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def call_gemini(diff: str, commits: List[str], api_key: str, model: str = "gemini-2.5-flash") -> BumpAnalysis:
|
|
279
|
+
"""เรียก Google Gemini REST API"""
|
|
280
|
+
url = f"https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent?key={api_key}"
|
|
281
|
+
payload = {
|
|
282
|
+
"system_instruction": {"parts": [{"text": SYSTEM_PROMPT}]},
|
|
283
|
+
"contents": [{
|
|
284
|
+
"parts": [{
|
|
285
|
+
"text": f"Commits since last release:\n" + "\n".join(commits) + f"\n\nCode Diff:\n{diff}"
|
|
286
|
+
}]
|
|
287
|
+
}],
|
|
288
|
+
"generationConfig": {
|
|
289
|
+
"response_mime_type": "application/json",
|
|
290
|
+
"temperature": 0.1
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
with httpx.Client(timeout=60.0) as client:
|
|
294
|
+
resp = client.post(url, json=payload)
|
|
295
|
+
resp.raise_for_status()
|
|
296
|
+
data = resp.json()
|
|
297
|
+
raw_text = data["candidates"][0]["content"]["parts"][0]["text"]
|
|
298
|
+
parsed = extract_json_from_text(raw_text)
|
|
299
|
+
return BumpAnalysis(**parsed)
|
|
300
|
+
|
|
301
|
+
def call_anthropic(diff: str, commits: List[str], api_key: str, model: str = "claude-3-5-haiku-20241022") -> BumpAnalysis:
|
|
302
|
+
"""เรียก Anthropic Claude Messages REST API"""
|
|
303
|
+
url = "https://api.anthropic.com/v1/messages"
|
|
304
|
+
headers = {
|
|
305
|
+
"x-api-key": api_key,
|
|
306
|
+
"anthropic-version": "2023-06-01",
|
|
307
|
+
"content-type": "application/json"
|
|
308
|
+
}
|
|
309
|
+
user_content = f"Commits since last release:\n" + "\n".join(commits) + f"\n\nCode Diff:\n{diff}"
|
|
310
|
+
payload = {
|
|
311
|
+
"model": model,
|
|
312
|
+
"max_tokens": 1024,
|
|
313
|
+
"system": SYSTEM_PROMPT,
|
|
314
|
+
"messages": [
|
|
315
|
+
{"role": "user", "content": user_content}
|
|
316
|
+
],
|
|
317
|
+
"temperature": 0.1
|
|
318
|
+
}
|
|
319
|
+
with httpx.Client(timeout=60.0) as client:
|
|
320
|
+
resp = client.post(url, headers=headers, json=payload)
|
|
321
|
+
resp.raise_for_status()
|
|
322
|
+
data = resp.json()
|
|
323
|
+
raw_text = data["content"][0]["text"]
|
|
324
|
+
parsed = extract_json_from_text(raw_text)
|
|
325
|
+
return BumpAnalysis(**parsed)
|
|
326
|
+
|
|
327
|
+
def call_openai_compatible(
|
|
328
|
+
diff: str,
|
|
329
|
+
commits: List[str],
|
|
330
|
+
api_key: str,
|
|
331
|
+
base_url: str,
|
|
332
|
+
model: str,
|
|
333
|
+
extra_headers: Optional[dict] = None
|
|
334
|
+
) -> BumpAnalysis:
|
|
335
|
+
"""เรียก OpenAI-compatible API (รองรับ OpenAI, OpenRouter, DeepSeek, Groq, Custom)"""
|
|
336
|
+
headers = {
|
|
337
|
+
"Authorization": f"Bearer {api_key}",
|
|
338
|
+
"Content-Type": "application/json"
|
|
339
|
+
}
|
|
340
|
+
if extra_headers:
|
|
341
|
+
headers.update(extra_headers)
|
|
342
|
+
|
|
343
|
+
url = f"{base_url.rstrip('/')}/chat/completions"
|
|
344
|
+
user_content = f"Commits since last release:\n" + "\n".join(commits) + f"\n\nCode Diff:\n{diff}"
|
|
345
|
+
payload = {
|
|
346
|
+
"model": model,
|
|
347
|
+
"messages": [
|
|
348
|
+
{"role": "system", "content": SYSTEM_PROMPT},
|
|
349
|
+
{"role": "user", "content": user_content}
|
|
350
|
+
],
|
|
351
|
+
"response_format": {"type": "json_object"},
|
|
352
|
+
"temperature": 0.1
|
|
353
|
+
}
|
|
354
|
+
with httpx.Client(timeout=60.0) as client:
|
|
355
|
+
resp = client.post(url, headers=headers, json=payload)
|
|
356
|
+
# ถ้า provider ไม่รองรับ response_format ให้ retry โดยเอา response_format ออก
|
|
357
|
+
if resp.status_code == 400 and "response_format" in resp.text.lower():
|
|
358
|
+
payload.pop("response_format", None)
|
|
359
|
+
resp = client.post(url, headers=headers, json=payload)
|
|
360
|
+
|
|
361
|
+
resp.raise_for_status()
|
|
362
|
+
data = resp.json()
|
|
363
|
+
if "error" in data:
|
|
364
|
+
err = data["error"]
|
|
365
|
+
err_msg = err.get("message", str(err)) if isinstance(err, dict) else str(err)
|
|
366
|
+
raise ValueError(f"AI Provider error: {err_msg}")
|
|
367
|
+
|
|
368
|
+
choices = data.get("choices", [])
|
|
369
|
+
if not choices:
|
|
370
|
+
raise ValueError(f"No completion choices returned by AI provider: {data}")
|
|
371
|
+
|
|
372
|
+
msg = choices[0].get("message", {})
|
|
373
|
+
raw_text = msg.get("content") or msg.get("reasoning_content") or ""
|
|
374
|
+
parsed = extract_json_from_text(raw_text)
|
|
375
|
+
return BumpAnalysis(**parsed)
|
|
376
|
+
|
|
377
|
+
def call_ollama(diff: str, commits: List[str], host: str = "http://localhost:11434", model: str = "llama3.2") -> BumpAnalysis:
|
|
378
|
+
"""เรียก Ollama Local REST API"""
|
|
379
|
+
url = f"{host.rstrip('/')}/api/chat"
|
|
380
|
+
user_content = f"Commits since last release:\n" + "\n".join(commits) + f"\n\nCode Diff:\n{diff}"
|
|
381
|
+
payload = {
|
|
382
|
+
"model": model,
|
|
383
|
+
"messages": [
|
|
384
|
+
{"role": "system", "content": SYSTEM_PROMPT},
|
|
385
|
+
{"role": "user", "content": user_content}
|
|
386
|
+
],
|
|
387
|
+
"format": "json",
|
|
388
|
+
"stream": False,
|
|
389
|
+
"options": {"temperature": 0.1}
|
|
390
|
+
}
|
|
391
|
+
with httpx.Client(timeout=90.0) as client:
|
|
392
|
+
resp = client.post(url, json=payload)
|
|
393
|
+
resp.raise_for_status()
|
|
394
|
+
data = resp.json()
|
|
395
|
+
raw_text = data["message"]["content"]
|
|
396
|
+
parsed = extract_json_from_text(raw_text)
|
|
397
|
+
return BumpAnalysis(**parsed)
|
|
398
|
+
|
|
399
|
+
def analyze(
|
|
400
|
+
diff: str,
|
|
401
|
+
commits: List[str],
|
|
402
|
+
provider: Optional[str] = None,
|
|
403
|
+
model: Optional[str] = None,
|
|
404
|
+
api_key: Optional[str] = None,
|
|
405
|
+
host: Optional[str] = None
|
|
406
|
+
) -> BumpAnalysis:
|
|
407
|
+
"""
|
|
408
|
+
ฟังก์ชันหลักสำหรับส่ง Diff ไปให้ AI วิเคราะห์
|
|
409
|
+
รองรับ: gemini, anthropic, openrouter, deepseek, groq, openai, ollama, custom
|
|
410
|
+
"""
|
|
411
|
+
if not provider or provider == "auto":
|
|
412
|
+
detected_prov, detected_key = detect_default_provider()
|
|
413
|
+
if not detected_prov:
|
|
414
|
+
raise ValueError(
|
|
415
|
+
"No configured AI provider or active API key found. "
|
|
416
|
+
"Please run 'shift-this-version config' to select an AI provider and enter your API key."
|
|
417
|
+
)
|
|
418
|
+
provider = detected_prov
|
|
419
|
+
if not api_key:
|
|
420
|
+
api_key = detected_key
|
|
421
|
+
|
|
422
|
+
provider = provider.lower()
|
|
423
|
+
# ดึงค่า model และ host จาก config หากไม่ได้ระบุผ่าน CLI
|
|
424
|
+
chosen_model = model or config.get_configured_model(provider)
|
|
425
|
+
chosen_host = host or config.get_configured_host(provider)
|
|
426
|
+
|
|
427
|
+
# 1. กลุ่ม Direct Cloud Giants
|
|
428
|
+
if provider == "gemini":
|
|
429
|
+
key = get_key_for_provider("gemini", api_key)
|
|
430
|
+
if not key:
|
|
431
|
+
raise ValueError("Gemini API key is not found. Run 'shift-this-version config' or set GEMINI_API_KEY.")
|
|
432
|
+
target_model = chosen_model or "gemini-2.5-flash"
|
|
433
|
+
return call_gemini(diff, commits, api_key=key, model=target_model)
|
|
434
|
+
|
|
435
|
+
elif provider == "anthropic":
|
|
436
|
+
key = get_key_for_provider("anthropic", api_key)
|
|
437
|
+
if not key:
|
|
438
|
+
raise ValueError("Anthropic API key is not found. Run 'shift-this-version config' or set ANTHROPIC_API_KEY.")
|
|
439
|
+
target_model = chosen_model or "claude-3-5-haiku-20241022"
|
|
440
|
+
return call_anthropic(diff, commits, api_key=key, model=target_model)
|
|
441
|
+
|
|
442
|
+
elif provider == "openai":
|
|
443
|
+
key = get_key_for_provider("openai", api_key)
|
|
444
|
+
if not key:
|
|
445
|
+
raise ValueError("OpenAI API key is not found. Run 'shift-this-version config' or set OPENAI_API_KEY.")
|
|
446
|
+
target_model = chosen_model or "gpt-4o-mini"
|
|
447
|
+
return call_openai_compatible(
|
|
448
|
+
diff, commits, api_key=key,
|
|
449
|
+
base_url="https://api.openai.com/v1",
|
|
450
|
+
model=target_model
|
|
451
|
+
)
|
|
452
|
+
|
|
453
|
+
# 2. กลุ่ม High-Speed & Value Powerhouses
|
|
454
|
+
elif provider == "deepseek":
|
|
455
|
+
key = get_key_for_provider("deepseek", api_key)
|
|
456
|
+
if not key:
|
|
457
|
+
raise ValueError("DeepSeek API key is not found. Run 'shift-this-version config' or set DEEPSEEK_API_KEY.")
|
|
458
|
+
target_model = chosen_model or "deepseek-chat"
|
|
459
|
+
return call_openai_compatible(
|
|
460
|
+
diff, commits, api_key=key,
|
|
461
|
+
base_url="https://api.deepseek.com/v1",
|
|
462
|
+
model=target_model
|
|
463
|
+
)
|
|
464
|
+
|
|
465
|
+
elif provider == "groq":
|
|
466
|
+
key = get_key_for_provider("groq", api_key)
|
|
467
|
+
if not key:
|
|
468
|
+
raise ValueError("Groq API key is not found. Run 'shift-this-version config' or set GROQ_API_KEY.")
|
|
469
|
+
target_model = chosen_model or "llama-3.3-70b-versatile"
|
|
470
|
+
return call_openai_compatible(
|
|
471
|
+
diff, commits, api_key=key,
|
|
472
|
+
base_url="https://api.groq.com/openai/v1",
|
|
473
|
+
model=target_model
|
|
474
|
+
)
|
|
475
|
+
|
|
476
|
+
# 3. กลุ่ม Universal Hub (OpenRouter)
|
|
477
|
+
elif provider == "openrouter":
|
|
478
|
+
key = get_key_for_provider("openrouter", api_key)
|
|
479
|
+
if not key:
|
|
480
|
+
raise ValueError("OpenRouter API key is not found. Run 'shift-this-version config' or set OPENROUTER_API_KEY.")
|
|
481
|
+
target_model = chosen_model or "google/gemini-2.0-flash-001"
|
|
482
|
+
return call_openai_compatible(
|
|
483
|
+
diff, commits, api_key=key,
|
|
484
|
+
base_url="https://openrouter.ai/api/v1",
|
|
485
|
+
model=target_model,
|
|
486
|
+
extra_headers={"HTTP-Referer": "https://github.com/shift-this-version", "X-Title": "shift-this-version"}
|
|
487
|
+
)
|
|
488
|
+
|
|
489
|
+
# 4. กลุ่ม Local & Self-Hosted
|
|
490
|
+
elif provider == "ollama":
|
|
491
|
+
target_host = chosen_host or os.getenv("OLLAMA_HOST", "http://localhost:11434")
|
|
492
|
+
if not is_ollama_running(target_host):
|
|
493
|
+
raise ConnectionError(
|
|
494
|
+
f"Could not connect to Ollama at '{target_host}'. "
|
|
495
|
+
"Please verify that the Ollama service is running, or run 'shift-this-version config' to switch providers."
|
|
496
|
+
)
|
|
497
|
+
target_model = chosen_model or "llama3.2"
|
|
498
|
+
return call_ollama(diff, commits, host=target_host, model=target_model)
|
|
499
|
+
|
|
500
|
+
elif provider in ("custom", "localai"):
|
|
501
|
+
base_url = chosen_host or os.getenv("CUSTOM_API_BASE", "http://localhost:1234/v1")
|
|
502
|
+
target_model = chosen_model or "local-model"
|
|
503
|
+
key = get_key_for_provider("custom", api_key) or "not-needed"
|
|
504
|
+
return call_openai_compatible(
|
|
505
|
+
diff, commits, api_key=key,
|
|
506
|
+
base_url=base_url,
|
|
507
|
+
model=target_model
|
|
508
|
+
)
|
|
509
|
+
|
|
510
|
+
else:
|
|
511
|
+
raise ValueError(
|
|
512
|
+
f"Unsupported provider: '{provider}'. Choose from: gemini, anthropic, openai, deepseek, groq, openrouter, ollama, custom."
|
|
513
|
+
)
|
|
514
|
+
|