git2report 0.1.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.
- git2report/__init__.py +2 -0
- git2report/__main__.py +4 -0
- git2report/branches.py +34 -0
- git2report/cache.py +78 -0
- git2report/charts.py +155 -0
- git2report/chunker.py +47 -0
- git2report/cli.py +173 -0
- git2report/clustering.py +56 -0
- git2report/config.py +28 -0
- git2report/eval_tools.py +123 -0
- git2report/extractor.py +37 -0
- git2report/filtering.py +34 -0
- git2report/metrics.py +111 -0
- git2report/models.py +45 -0
- git2report/pipeline.py +188 -0
- git2report/report.py +884 -0
- git2report/rules.py +71 -0
- git2report/summarizer.py +359 -0
- git2report/timeutil.py +19 -0
- git2report/web.py +1207 -0
- git2report-0.1.0.dist-info/METADATA +142 -0
- git2report-0.1.0.dist-info/RECORD +26 -0
- git2report-0.1.0.dist-info/WHEEL +5 -0
- git2report-0.1.0.dist-info/entry_points.txt +4 -0
- git2report-0.1.0.dist-info/licenses/LICENSE +21 -0
- git2report-0.1.0.dist-info/top_level.txt +1 -0
git2report/rules.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Small helpers that decide what a path or a message *is*. No Git, no AI here."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
|
|
6
|
+
CONVENTIONAL = re.compile(r"^(\w+)(?:\(([^)]+)\))?!?:\s*(.+)") # type(scope): description
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def parse_conventional(subject: str) -> tuple[str | None, str | None, str]:
|
|
10
|
+
"""'feat(auth): add login' -> ('feat', 'auth', 'add login'); plain text -> (None, None, text)."""
|
|
11
|
+
m = CONVENTIONAL.match(subject.strip())
|
|
12
|
+
if not m:
|
|
13
|
+
return None, None, subject.strip()
|
|
14
|
+
return m.group(1).lower(), (m.group(2) or None), m.group(3).strip()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def is_junk(path: str, patterns: tuple[str, ...]) -> bool:
|
|
18
|
+
p = path.replace("\\", "/")
|
|
19
|
+
parts = p.split("/")
|
|
20
|
+
for pat in patterns:
|
|
21
|
+
if pat.endswith("/"): # a folder name, matched as a whole segment
|
|
22
|
+
if pat[:-1] in parts[:-1]:
|
|
23
|
+
return True
|
|
24
|
+
elif pat.startswith("."): # an extension-like suffix
|
|
25
|
+
if p.endswith(pat):
|
|
26
|
+
return True
|
|
27
|
+
elif parts[-1] == pat: # an exact file name
|
|
28
|
+
return True
|
|
29
|
+
return False
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def is_test(path: str) -> bool:
|
|
33
|
+
p = path.lower().replace("\\", "/")
|
|
34
|
+
parts = p.split("/")
|
|
35
|
+
name = parts[-1]
|
|
36
|
+
return (
|
|
37
|
+
any(d in ("test", "tests", "__tests__", "spec", "specs") for d in parts[:-1])
|
|
38
|
+
or ".test." in name or ".spec." in name
|
|
39
|
+
or name.startswith("test_") or name.endswith("_test.py")
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def tokens(text: str) -> int:
|
|
44
|
+
"""Rough token estimate: about 4 characters per token."""
|
|
45
|
+
return len(text) // 4
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# When a message has no 'type(scope):' prefix, guess the type from its first word. Simple and transparent.
|
|
49
|
+
_INFERRED = (
|
|
50
|
+
("feat", ("add", "adds", "added", "implement", "implements", "implemented", "introduce", "create", "created", "support", "build")),
|
|
51
|
+
("fix", ("fix", "fixes", "fixed", "resolve", "resolved", "correct", "handle", "patch", "prevent")),
|
|
52
|
+
("perf", ("optimize", "optimise", "speed", "cache", "faster")),
|
|
53
|
+
("refactor", ("refactor", "rename", "move", "remove", "removed", "clean", "cleanup", "split", "simplify", "deprecate",
|
|
54
|
+
"extract", "reorganize", "drop")),
|
|
55
|
+
("docs", ("document", "documents", "docs", "readme")),
|
|
56
|
+
("test", ("test", "tests")),
|
|
57
|
+
("chore", ("bump", "upgrade", "release", "start", "update", "merge")),
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def commit_type(subject: str) -> str | None:
|
|
62
|
+
"""The Conventional Commit type if present; otherwise a guess from the first word; otherwise None."""
|
|
63
|
+
ctype, _, desc = parse_conventional(subject)
|
|
64
|
+
if ctype:
|
|
65
|
+
return ctype
|
|
66
|
+
words = desc.lower().split()
|
|
67
|
+
first = words[0].strip("`.,:;") if words else ""
|
|
68
|
+
for kind, starts in _INFERRED:
|
|
69
|
+
if first in starts:
|
|
70
|
+
return kind
|
|
71
|
+
return None
|
git2report/summarizer.py
ADDED
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
"""STEP 6: turn one module's commits + short code into plain sentences.
|
|
2
|
+
|
|
3
|
+
The AI only ever writes words. It is never asked for numbers or commit IDs, and its answer is
|
|
4
|
+
checked before use. If the model is missing, answers badly, or breaks the rules, we fall back to
|
|
5
|
+
an offline sentence built from the commit messages themselves, and the report says so."""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import re
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import Callable
|
|
12
|
+
|
|
13
|
+
from git2report.models import CommitRecord
|
|
14
|
+
from git2report.rules import commit_type, parse_conventional
|
|
15
|
+
|
|
16
|
+
STYLE = {
|
|
17
|
+
"guide": "formal, academic tone; focus on milestones and design",
|
|
18
|
+
"client": "simple business language; focus on what users can now do",
|
|
19
|
+
"team": "release-notes style; concise and technical",
|
|
20
|
+
}
|
|
21
|
+
FILE_NAME = re.compile(r"\b[\w\-/]+\.(?:js|jsx|ts|tsx|py|java|css|html|json|md|go|rs|c|cpp|sh|yml|yaml)\b", re.I)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class SummaryRequest:
|
|
26
|
+
module: str
|
|
27
|
+
commits: list[CommitRecord]
|
|
28
|
+
code_text: str
|
|
29
|
+
audience: str = "guide"
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def prompt(self) -> str:
|
|
33
|
+
msgs = "\n".join(f"- {c.subject}" for c in self.commits)
|
|
34
|
+
return (
|
|
35
|
+
f"Audience: {self.audience} ({STYLE[self.audience]}). The reader is not technical.\n"
|
|
36
|
+
"Task: write 2 simple sentences about these changes.\n"
|
|
37
|
+
"Rules: do not write numbers or file names. Use only the information below.\n"
|
|
38
|
+
f"Module: {self.module}\nCommits:\n{msgs}\nCode changes:\n{self.code_text[:1500]}\n"
|
|
39
|
+
'Return only JSON: {"summary": "..."}'
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class Summary:
|
|
45
|
+
text: str
|
|
46
|
+
mode: str # "ai" or "offline"
|
|
47
|
+
flagged: bool = False # True when an AI answer was rejected by our checks
|
|
48
|
+
note: str = ""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
TEAM_LABEL = {"feat": "Added", "fix": "Fixed", "perf": "Improved", "refactor": "Refactored", "docs": "Docs",
|
|
52
|
+
"test": "Tests", "style": "Style", "chore": "Chores", "ci": "CI", "build": "Build"}
|
|
53
|
+
USER_FACING = {"feat", "fix", "perf", None}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _clean(desc: str) -> str:
|
|
57
|
+
desc = re.sub(r"\s*\(#\d+\)\s*$", "", desc).rstrip(" .;:") # drop '(#123)' and trailing dots
|
|
58
|
+
return (desc[0].upper() + desc[1:]) if desc else ""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _items(req: SummaryRequest) -> list[tuple[str | None, str]]:
|
|
62
|
+
out = []
|
|
63
|
+
for c in req.commits:
|
|
64
|
+
_, _, desc = parse_conventional(c.subject)
|
|
65
|
+
if _clean(desc):
|
|
66
|
+
out.append((commit_type(c.subject), _clean(desc)))
|
|
67
|
+
return out
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _joined(descs: list[str], sep: str, limit: int = 3) -> str:
|
|
71
|
+
extra = f" (and {len(descs) - limit} more)" if len(descs) > limit else ""
|
|
72
|
+
return sep.join(descs[:limit]) + extra
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def offline_sentence(req: SummaryRequest) -> str:
|
|
76
|
+
"""Wording built from the commit messages only. The audience changes WHAT is shown and HOW it is laid out:
|
|
77
|
+
guide = full list, client = user-facing work only, team = release-notes grouped by type."""
|
|
78
|
+
items = _items(req)
|
|
79
|
+
if not items:
|
|
80
|
+
return "Changes were made in this module."
|
|
81
|
+
if req.audience == "client":
|
|
82
|
+
shown = [d for t, d in items if t in USER_FACING]
|
|
83
|
+
if not shown:
|
|
84
|
+
return "No user-facing changes; internal work only."
|
|
85
|
+
return f"What is new or improved: {_joined(shown, '; ')}."
|
|
86
|
+
if req.audience == "team":
|
|
87
|
+
groups: dict[str, list[str]] = {}
|
|
88
|
+
for t, d in items:
|
|
89
|
+
label = TEAM_LABEL.get(t, "Changed")
|
|
90
|
+
if label in ("Added", "Fixed"): # 'Added: Add login' reads badly; drop the repeated verb
|
|
91
|
+
stripped = re.sub(r"^(add|adds|added|fix|fixes|fixed)\s+", "", d, flags=re.I)
|
|
92
|
+
d = (stripped[0].upper() + stripped[1:]) if stripped else d
|
|
93
|
+
groups.setdefault(label, []).append(d)
|
|
94
|
+
return " ".join(f"{label}: {_joined(ds, ', ', 2)}." for label, ds in groups.items())
|
|
95
|
+
return f"Work completed: {_joined([d for _, d in items], '; ')}."
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def find_issues(text: str) -> list[str]:
|
|
99
|
+
issues = []
|
|
100
|
+
if not text.strip():
|
|
101
|
+
issues.append("empty answer")
|
|
102
|
+
if re.search(r"\d", text):
|
|
103
|
+
issues.append("contains a number")
|
|
104
|
+
if FILE_NAME.search(text):
|
|
105
|
+
issues.append("mentions a file name")
|
|
106
|
+
return issues
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def parse_model_json(raw: str) -> str | None:
|
|
110
|
+
"""Pull the 'summary' string out of the model's answer; tolerate extra text around the JSON."""
|
|
111
|
+
for candidate in (raw, *re.findall(r"\{.*\}", raw, flags=re.S)):
|
|
112
|
+
try:
|
|
113
|
+
data = json.loads(candidate)
|
|
114
|
+
except (ValueError, TypeError):
|
|
115
|
+
continue
|
|
116
|
+
if isinstance(data, dict) and isinstance(data.get("summary"), str):
|
|
117
|
+
return data["summary"].strip()
|
|
118
|
+
return None
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class OfflineSummarizer:
|
|
122
|
+
mode = "offline"
|
|
123
|
+
label = "offline mode (no AI; wording taken from the commit messages)"
|
|
124
|
+
|
|
125
|
+
def summarize(self, req: SummaryRequest) -> Summary:
|
|
126
|
+
return Summary(offline_sentence(req), "offline")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class OllamaSummarizer:
|
|
130
|
+
mode = "ai"
|
|
131
|
+
|
|
132
|
+
def __init__(self, model: str = "llama3:8b", host: str | None = None,
|
|
133
|
+
chat: Callable[[str], str] | None = None, retries: int = 1):
|
|
134
|
+
import os
|
|
135
|
+
self.model = model or "llama3:8b"
|
|
136
|
+
self.host = host or os.environ.get("OLLAMA_HOST", "http://localhost:11434")
|
|
137
|
+
self.retries = retries
|
|
138
|
+
self._custom_chat = chat is not None
|
|
139
|
+
self._chat = chat or (lambda p: self._ollama_call(p, True))
|
|
140
|
+
self._ask = chat or (lambda p: self._ollama_call(p, False))
|
|
141
|
+
self.label = f"local AI model Ollama ({self.model}) (answers checked by code)"
|
|
142
|
+
|
|
143
|
+
def _ollama_call(self, prompt: str, json_mode: bool) -> str:
|
|
144
|
+
# 1. Try python package if installed
|
|
145
|
+
try:
|
|
146
|
+
import ollama
|
|
147
|
+
client = ollama.Client(host=self.host)
|
|
148
|
+
kwargs = {"format": "json"} if json_mode else {}
|
|
149
|
+
resp = client.chat(model=self.model, options={"temperature": 0.1},
|
|
150
|
+
messages=[{"role": "user", "content": prompt}], **kwargs)
|
|
151
|
+
return resp["message"]["content"]
|
|
152
|
+
except Exception:
|
|
153
|
+
pass
|
|
154
|
+
|
|
155
|
+
# 2. Zero-dependency fallback to local Ollama HTTP REST API
|
|
156
|
+
import json
|
|
157
|
+
import urllib.request
|
|
158
|
+
import urllib.error
|
|
159
|
+
|
|
160
|
+
payload = {
|
|
161
|
+
"model": self.model,
|
|
162
|
+
"messages": [{"role": "user", "content": prompt}],
|
|
163
|
+
"stream": False,
|
|
164
|
+
"options": {"temperature": 0.1}
|
|
165
|
+
}
|
|
166
|
+
if json_mode:
|
|
167
|
+
payload["format"] = "json"
|
|
168
|
+
|
|
169
|
+
endpoint = f"{self.host.rstrip('/')}/api/chat"
|
|
170
|
+
req = urllib.request.Request(
|
|
171
|
+
endpoint,
|
|
172
|
+
data=json.dumps(payload).encode("utf-8"),
|
|
173
|
+
headers={"Content-Type": "application/json"}
|
|
174
|
+
)
|
|
175
|
+
try:
|
|
176
|
+
with urllib.request.urlopen(req, timeout=45) as res:
|
|
177
|
+
data = json.loads(res.read().decode("utf-8"))
|
|
178
|
+
return data["message"]["content"]
|
|
179
|
+
except urllib.error.URLError as e:
|
|
180
|
+
raise RuntimeError(f"Could not connect to Ollama at {self.host}: {e}") from None
|
|
181
|
+
|
|
182
|
+
def ask(self, prompt: str) -> str:
|
|
183
|
+
"""Plain-text question to the model (used for the evaluation baseline)."""
|
|
184
|
+
return self._ask(prompt)
|
|
185
|
+
|
|
186
|
+
def summarize(self, req: SummaryRequest) -> Summary:
|
|
187
|
+
from git2report.cache import compute_cache_key, default_cache
|
|
188
|
+
cache_key = None
|
|
189
|
+
if not self._custom_chat:
|
|
190
|
+
hashes = [c.hash for c in req.commits]
|
|
191
|
+
cache_key = compute_cache_key("module", req.module, hashes, req.audience, self.model)
|
|
192
|
+
cached = default_cache.get(cache_key)
|
|
193
|
+
if cached:
|
|
194
|
+
return Summary(cached["summary"], "ai", note="(cached)")
|
|
195
|
+
|
|
196
|
+
fallback = offline_sentence(req)
|
|
197
|
+
last_note = ""
|
|
198
|
+
for _ in range(self.retries + 1):
|
|
199
|
+
try:
|
|
200
|
+
raw = self._chat(req.prompt)
|
|
201
|
+
except Exception as exc: # not installed, not running, timeout...
|
|
202
|
+
return Summary(fallback, "offline", True, f"AI unavailable ({type(exc).__name__}); used offline wording")
|
|
203
|
+
text = parse_model_json(raw)
|
|
204
|
+
if text is None:
|
|
205
|
+
last_note = "model did not return valid JSON"
|
|
206
|
+
continue
|
|
207
|
+
issues = find_issues(text)
|
|
208
|
+
if issues:
|
|
209
|
+
last_note = "AI answer rejected: " + ", ".join(issues)
|
|
210
|
+
continue
|
|
211
|
+
if cache_key:
|
|
212
|
+
default_cache.set(cache_key, text, "ai")
|
|
213
|
+
return Summary(text, "ai")
|
|
214
|
+
return Summary(fallback, "offline", True, f"{last_note}; used offline wording")
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _load_env_file() -> None:
|
|
218
|
+
import os
|
|
219
|
+
from pathlib import Path
|
|
220
|
+
for p in [Path(".env"), Path(__file__).resolve().parent.parent / ".env", Path.home() / ".git2report_env"]:
|
|
221
|
+
if p.exists():
|
|
222
|
+
try:
|
|
223
|
+
for line in p.read_text(encoding="utf-8").splitlines():
|
|
224
|
+
line = line.strip()
|
|
225
|
+
if line and not line.startswith("#") and "=" in line:
|
|
226
|
+
k, v = line.split("=", 1)
|
|
227
|
+
k, v = k.strip(), v.strip().strip('"\'')
|
|
228
|
+
if k and k not in os.environ:
|
|
229
|
+
os.environ[k] = v
|
|
230
|
+
except Exception:
|
|
231
|
+
pass
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
class GeminiSummarizer:
|
|
235
|
+
mode = "ai"
|
|
236
|
+
|
|
237
|
+
def __init__(self, model: str = "gemini-2.5-flash", api_key: str | None = None,
|
|
238
|
+
chat: Callable[[str], str] | None = None, retries: int = 1):
|
|
239
|
+
import os
|
|
240
|
+
_load_env_file()
|
|
241
|
+
self.model = model or "gemini-2.5-flash"
|
|
242
|
+
if api_key is None:
|
|
243
|
+
self.api_key = os.environ.get("GEMINI_API_KEY") or os.environ.get("GOOGLE_API_KEY") or ""
|
|
244
|
+
else:
|
|
245
|
+
self.api_key = api_key
|
|
246
|
+
self.retries = retries
|
|
247
|
+
self._custom_chat = chat is not None
|
|
248
|
+
self._chat = chat or self._call_gemini
|
|
249
|
+
self.label = f"Google Gemini ({self.model}) simple plain-language AI"
|
|
250
|
+
|
|
251
|
+
def _call_gemini(self, prompt: str) -> str:
|
|
252
|
+
if not self.api_key:
|
|
253
|
+
raise ValueError("GEMINI_API_KEY not found. Pass --api-key <key> or set $env:GEMINI_API_KEY.")
|
|
254
|
+
|
|
255
|
+
import json
|
|
256
|
+
import time
|
|
257
|
+
import urllib.request
|
|
258
|
+
import urllib.error
|
|
259
|
+
|
|
260
|
+
models_to_try = [self.model]
|
|
261
|
+
if self.model != "gemini-3.5-flash-lite":
|
|
262
|
+
models_to_try.append("gemini-3.5-flash-lite")
|
|
263
|
+
|
|
264
|
+
last_err = None
|
|
265
|
+
for current_model in models_to_try:
|
|
266
|
+
url = f"https://generativelanguage.googleapis.com/v1beta/models/{current_model}:generateContent?key={self.api_key}"
|
|
267
|
+
payload = {
|
|
268
|
+
"contents": [{"parts": [{"text": prompt}]}],
|
|
269
|
+
"generationConfig": {
|
|
270
|
+
"temperature": 0.2,
|
|
271
|
+
"responseMimeType": "application/json"
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
req = urllib.request.Request(
|
|
275
|
+
url,
|
|
276
|
+
data=json.dumps(payload).encode("utf-8"),
|
|
277
|
+
headers={"Content-Type": "application/json"}
|
|
278
|
+
)
|
|
279
|
+
for attempt in range(2):
|
|
280
|
+
try:
|
|
281
|
+
with urllib.request.urlopen(req, timeout=15) as res:
|
|
282
|
+
data = json.loads(res.read().decode("utf-8"))
|
|
283
|
+
return data["candidates"][0]["content"]["parts"][0]["text"]
|
|
284
|
+
except urllib.error.HTTPError as e:
|
|
285
|
+
if e.code == 429 and attempt == 0:
|
|
286
|
+
time.sleep(1.0)
|
|
287
|
+
continue
|
|
288
|
+
err_msg = e.read().decode("utf-8", errors="replace")
|
|
289
|
+
last_err = RuntimeError(f"Gemini API error ({e.code}): {err_msg}")
|
|
290
|
+
break # Break inner loop and try next model candidate
|
|
291
|
+
except Exception as e:
|
|
292
|
+
last_err = e
|
|
293
|
+
break
|
|
294
|
+
|
|
295
|
+
if last_err:
|
|
296
|
+
raise last_err
|
|
297
|
+
raise RuntimeError("Gemini call failed with unknown error.")
|
|
298
|
+
|
|
299
|
+
def ask(self, prompt: str) -> str:
|
|
300
|
+
return self._chat(prompt)
|
|
301
|
+
|
|
302
|
+
def summarize(self, req: SummaryRequest) -> Summary:
|
|
303
|
+
fallback = offline_sentence(req)
|
|
304
|
+
if not self.api_key and not self._custom_chat:
|
|
305
|
+
return Summary(fallback, "offline", True, "GEMINI_API_KEY missing; used offline wording")
|
|
306
|
+
|
|
307
|
+
from git2report.cache import compute_cache_key, default_cache
|
|
308
|
+
cache_key = None
|
|
309
|
+
if not self._custom_chat:
|
|
310
|
+
hashes = [c.hash for c in req.commits]
|
|
311
|
+
cache_key = compute_cache_key("module", req.module, hashes, req.audience, self.model)
|
|
312
|
+
cached = default_cache.get(cache_key)
|
|
313
|
+
if cached:
|
|
314
|
+
return Summary(cached["summary"], "ai", note="(cached)")
|
|
315
|
+
|
|
316
|
+
last_note = ""
|
|
317
|
+
for _ in range(self.retries + 1):
|
|
318
|
+
try:
|
|
319
|
+
raw = self._chat(req.prompt)
|
|
320
|
+
except Exception as exc:
|
|
321
|
+
return Summary(fallback, "offline", True, f"Gemini error ({type(exc).__name__}); used offline wording")
|
|
322
|
+
text = parse_model_json(raw)
|
|
323
|
+
if text is None:
|
|
324
|
+
last_note = "model did not return valid JSON"
|
|
325
|
+
continue
|
|
326
|
+
issues = find_issues(text)
|
|
327
|
+
if issues:
|
|
328
|
+
last_note = "AI answer rejected: " + ", ".join(issues)
|
|
329
|
+
continue
|
|
330
|
+
if cache_key:
|
|
331
|
+
default_cache.set(cache_key, text, "ai")
|
|
332
|
+
return Summary(text, "ai")
|
|
333
|
+
return Summary(fallback, "offline", True, f"{last_note}; used offline wording")
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def list_local_ollama_models(host: str | None = None) -> list[str]:
|
|
337
|
+
"""Check if Ollama server is running and return list of pulled model names."""
|
|
338
|
+
import json
|
|
339
|
+
import os
|
|
340
|
+
import urllib.request
|
|
341
|
+
target_host = host or os.environ.get("OLLAMA_HOST", "http://localhost:11434")
|
|
342
|
+
try:
|
|
343
|
+
url = f"{target_host.rstrip('/')}/api/tags"
|
|
344
|
+
with urllib.request.urlopen(url, timeout=1.5) as r:
|
|
345
|
+
data = json.loads(r.read().decode("utf-8"))
|
|
346
|
+
return [m["name"] for m in data.get("models", [])]
|
|
347
|
+
except Exception:
|
|
348
|
+
return []
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def get_summarizer(kind: str, model: str = "", api_key: str | None = None, host: str | None = None):
|
|
352
|
+
if kind == "gemini":
|
|
353
|
+
gemini_model = model if model and ":" not in model else "gemini-2.5-flash"
|
|
354
|
+
return GeminiSummarizer(model=gemini_model, api_key=api_key)
|
|
355
|
+
if kind == "ollama":
|
|
356
|
+
ollama_model = model or "llama3:8b"
|
|
357
|
+
return OllamaSummarizer(model=ollama_model, host=host)
|
|
358
|
+
return OfflineSummarizer()
|
|
359
|
+
|
git2report/timeutil.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from datetime import datetime, timedelta
|
|
5
|
+
|
|
6
|
+
from git2report.models import GitReportError
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def parse_since(text: str, now: datetime | None = None) -> datetime:
|
|
10
|
+
"""'7d', '2w', '12h' (relative to now) or an ISO date such as '2026-10-01'."""
|
|
11
|
+
now = now or datetime.now()
|
|
12
|
+
m = re.fullmatch(r"\s*(\d+)\s*([hdw])\s*", text.lower())
|
|
13
|
+
if m:
|
|
14
|
+
n, unit = int(m.group(1)), m.group(2)
|
|
15
|
+
return now - timedelta(**{{"h": "hours", "d": "days", "w": "weeks"}[unit]: n})
|
|
16
|
+
try:
|
|
17
|
+
return datetime.fromisoformat(text.strip())
|
|
18
|
+
except ValueError:
|
|
19
|
+
raise GitReportError(f"Cannot understand --since '{text}'. Use e.g. 7d, 2w, 12h or 2026-10-01.") from None
|