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/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
@@ -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