git-to-doc 0.1.0__tar.gz

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.
@@ -0,0 +1,104 @@
1
+ Metadata-Version: 2.4
2
+ Name: git-to-doc
3
+ Version: 0.1.0
4
+ Summary: Generate Conventional Commit messages & changelogs from git diffs using local Gemma
5
+ Requires-Python: >=3.9
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: ollama>=0.4
8
+ Requires-Dist: pydantic>=2.0
9
+ Requires-Dist: requests>=2.31
10
+
11
+ # git-to-doc ⚡
12
+
13
+ > Turn any git diff into a Conventional Commit message, changelog, and plain-English PR summary — powered by Gemma running locally via Ollama.
14
+
15
+ ## Install
16
+
17
+ ```bash
18
+ pip install git-to-doc
19
+ ```
20
+
21
+ **Requires:** [Ollama](https://ollama.com) running locally with a Gemma model pulled:
22
+ ```bash
23
+ ollama pull gemma4
24
+ ```
25
+
26
+ ## Usage
27
+
28
+ ```bash
29
+ # From a GitHub PR URL
30
+ git-to-doc https://github.com/pallets/flask/pull/5000
31
+
32
+ # From a local .diff file
33
+ git-to-doc sample.diff
34
+
35
+ # Save output as markdown
36
+ git-to-doc https://github.com/pallets/flask/pull/5000 --output md
37
+
38
+ # Save both markdown and JSON
39
+ git-to-doc sample.diff --output both
40
+
41
+ # Use a different model
42
+ git-to-doc sample.diff --model gemma2:2b
43
+
44
+ # Benchmark multiple diffs
45
+ git-to-doc-compare sample.diff https://github.com/pallets/flask/pull/5000
46
+ ```
47
+
48
+ ## Output
49
+
50
+ For each diff, `git-to-doc` produces:
51
+
52
+ 1. **Conventional Commit message** — ready to paste into `git commit`
53
+ 2. **Changelog entry** — ready to paste into `CHANGELOG.md`
54
+ 3. **Plain-English summary** — for non-technical stakeholders
55
+
56
+ ```
57
+ ──────────────────────────────────────────────────────────
58
+ ✨ CONVENTIONAL COMMIT MESSAGE
59
+ ──────────────────────────────────────────────────────────
60
+
61
+ feat(parser): add null safety and safe_parse wrapper
62
+
63
+ ──────────────────────────────────────────────────────────
64
+ 📋 CHANGELOG ENTRY
65
+ ──────────────────────────────────────────────────────────
66
+
67
+ ## [Unreleased] — 2026-06-26
68
+ ### Added
69
+ - feat(parser): add null safety checks and safe_parse fallback
70
+
71
+ ──────────────────────────────────────────────────────────
72
+ 🗣️ PLAIN ENGLISH SUMMARY
73
+ ──────────────────────────────────────────────────────────
74
+
75
+ The parser now safely handles missing or null input instead of crashing.
76
+ ```
77
+
78
+ ## Supported Inputs
79
+
80
+ | Input | Example |
81
+ |---|---|
82
+ | GitHub PR URL | `git-to-doc https://github.com/org/repo/pull/123` |
83
+ | Raw diff URL | `git-to-doc https://patch-diff.githubusercontent.com/raw/org/repo/pull/123.diff` |
84
+ | Local `.diff` file | `git-to-doc my_changes.diff` |
85
+ | Folder of diffs | `git-to-doc ./diffs/` |
86
+
87
+ ## Flags
88
+
89
+ | Flag | Description |
90
+ |---|---|
91
+ | `--output md` | Save markdown file (auto-named `PR-123-changelog-DATE.md`) |
92
+ | `--output json` | Save JSON file |
93
+ | `--output both` | Save both |
94
+ | `--model NAME` | Ollama model to use (default: `gemma4`) |
95
+
96
+ ## Built With
97
+
98
+ - [Ollama](https://ollama.com) — local LLM runtime
99
+ - [Gemma](https://ai.google.dev/gemma) — Google's open model
100
+ - [Pydantic](https://docs.pydantic.dev) — structured output validation
101
+
102
+ ---
103
+
104
+ Built at AI-First Developer Efficiencies Hackathon · Track 1: The Automagic Documenter
@@ -0,0 +1,94 @@
1
+ # git-to-doc ⚡
2
+
3
+ > Turn any git diff into a Conventional Commit message, changelog, and plain-English PR summary — powered by Gemma running locally via Ollama.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install git-to-doc
9
+ ```
10
+
11
+ **Requires:** [Ollama](https://ollama.com) running locally with a Gemma model pulled:
12
+ ```bash
13
+ ollama pull gemma4
14
+ ```
15
+
16
+ ## Usage
17
+
18
+ ```bash
19
+ # From a GitHub PR URL
20
+ git-to-doc https://github.com/pallets/flask/pull/5000
21
+
22
+ # From a local .diff file
23
+ git-to-doc sample.diff
24
+
25
+ # Save output as markdown
26
+ git-to-doc https://github.com/pallets/flask/pull/5000 --output md
27
+
28
+ # Save both markdown and JSON
29
+ git-to-doc sample.diff --output both
30
+
31
+ # Use a different model
32
+ git-to-doc sample.diff --model gemma2:2b
33
+
34
+ # Benchmark multiple diffs
35
+ git-to-doc-compare sample.diff https://github.com/pallets/flask/pull/5000
36
+ ```
37
+
38
+ ## Output
39
+
40
+ For each diff, `git-to-doc` produces:
41
+
42
+ 1. **Conventional Commit message** — ready to paste into `git commit`
43
+ 2. **Changelog entry** — ready to paste into `CHANGELOG.md`
44
+ 3. **Plain-English summary** — for non-technical stakeholders
45
+
46
+ ```
47
+ ──────────────────────────────────────────────────────────
48
+ ✨ CONVENTIONAL COMMIT MESSAGE
49
+ ──────────────────────────────────────────────────────────
50
+
51
+ feat(parser): add null safety and safe_parse wrapper
52
+
53
+ ──────────────────────────────────────────────────────────
54
+ 📋 CHANGELOG ENTRY
55
+ ──────────────────────────────────────────────────────────
56
+
57
+ ## [Unreleased] — 2026-06-26
58
+ ### Added
59
+ - feat(parser): add null safety checks and safe_parse fallback
60
+
61
+ ──────────────────────────────────────────────────────────
62
+ 🗣️ PLAIN ENGLISH SUMMARY
63
+ ──────────────────────────────────────────────────────────
64
+
65
+ The parser now safely handles missing or null input instead of crashing.
66
+ ```
67
+
68
+ ## Supported Inputs
69
+
70
+ | Input | Example |
71
+ |---|---|
72
+ | GitHub PR URL | `git-to-doc https://github.com/org/repo/pull/123` |
73
+ | Raw diff URL | `git-to-doc https://patch-diff.githubusercontent.com/raw/org/repo/pull/123.diff` |
74
+ | Local `.diff` file | `git-to-doc my_changes.diff` |
75
+ | Folder of diffs | `git-to-doc ./diffs/` |
76
+
77
+ ## Flags
78
+
79
+ | Flag | Description |
80
+ |---|---|
81
+ | `--output md` | Save markdown file (auto-named `PR-123-changelog-DATE.md`) |
82
+ | `--output json` | Save JSON file |
83
+ | `--output both` | Save both |
84
+ | `--model NAME` | Ollama model to use (default: `gemma4`) |
85
+
86
+ ## Built With
87
+
88
+ - [Ollama](https://ollama.com) — local LLM runtime
89
+ - [Gemma](https://ai.google.dev/gemma) — Google's open model
90
+ - [Pydantic](https://docs.pydantic.dev) — structured output validation
91
+
92
+ ---
93
+
94
+ Built at AI-First Developer Efficiencies Hackathon · Track 1: The Automagic Documenter
@@ -0,0 +1,6 @@
1
+ # git-to-doc
2
+ from git_to_doc.model import analyze_diff, CommitDoc
3
+ from git_to_doc.renderer import render_full_output, render_markdown_file
4
+
5
+ __all__ = ["analyze_diff", "CommitDoc", "render_full_output", "render_markdown_file"]
6
+ __version__ = "0.1.0"
@@ -0,0 +1,199 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ git-to-doc — Generate Conventional Commit messages & changelogs from git diffs.
4
+
5
+ Accepts:
6
+ - A GitHub PR URL (github.com or patch-diff.githubusercontent.com)
7
+ - A local .diff or .txt file
8
+ - A folder of .diff files (processes all of them)
9
+
10
+ Flags:
11
+ --output [md|json|both] Save output file(s). Defaults to 'md' if omitted.
12
+ --model MODEL Ollama model to use (default: gemma4).
13
+
14
+ Examples:
15
+ python doc.py https://github.com/pallets/flask/pull/5000
16
+ python doc.py https://github.com/pallets/flask/pull/5000 --output
17
+ python doc.py https://github.com/pallets/flask/pull/5000 --output json
18
+ python doc.py https://github.com/pallets/flask/pull/5000 --output both
19
+ python doc.py sample.diff --output md
20
+ python doc.py ./diffs/ --output both
21
+ """
22
+
23
+ import sys, re, json, argparse, time
24
+ from pathlib import Path
25
+ from datetime import date
26
+ from typing import Optional
27
+
28
+ import requests
29
+
30
+ from git_to_doc.model import analyze_diff, CommitDoc
31
+ from git_to_doc.renderer import render_full_output, render_markdown_file
32
+
33
+ # ── ANSI helpers ─────────────────────────────────────────────────────────────
34
+ RESET = "\033[0m"; BOLD = "\033[1m"; GREEN = "\033[32m"
35
+ RED = "\033[31m"; CYAN = "\033[36m"; DIM = "\033[2m"; YELLOW = "\033[33m"
36
+
37
+ def _c(t, *c): return "".join(c) + t + RESET
38
+
39
+
40
+ # ── URL normalisation ─────────────────────────────────────────────────────────
41
+ _GH_PR = re.compile(r'https?://github\.com/([^/]+/[^/]+)/pull/(\d+)', re.I)
42
+ _GH_RAW = re.compile(r'https?://patch-diff\.githubusercontent\.com/raw/([^/]+/[^/]+)/pull/(\d+)', re.I)
43
+
44
+ def normalise_url(url: str) -> tuple:
45
+ """Return (fetch_url, slug) for any GitHub PR URL format."""
46
+ url = url.rstrip("/")
47
+
48
+ m = _GH_PR.match(url)
49
+ if m:
50
+ repo, pr = m.group(1), m.group(2)
51
+ fetch = f"https://patch-diff.githubusercontent.com/raw/{repo}/pull/{pr}.diff"
52
+ return fetch, f"PR-{pr}"
53
+
54
+ m = _GH_RAW.match(url)
55
+ if m:
56
+ pr = m.group(2)
57
+ fetch = url if url.endswith(".diff") else url + ".diff"
58
+ return fetch, f"PR-{pr}"
59
+
60
+ # Generic URL — ensure .diff suffix
61
+ fetch = url if url.endswith(".diff") else url + ".diff"
62
+ slug = re.sub(r'[^a-zA-Z0-9_-]', '-', Path(url).stem)[:40]
63
+ return fetch, slug
64
+
65
+ def is_url(s: str) -> bool:
66
+ return s.startswith("http://") or s.startswith("https://")
67
+
68
+ def fetch_diff(url: str) -> str:
69
+ fetch_url, _ = normalise_url(url)
70
+ print(_c(f" ↓ {fetch_url}", DIM))
71
+ resp = requests.get(fetch_url, timeout=30, headers={"User-Agent": "git-to-doc/1.0"})
72
+ resp.raise_for_status()
73
+ return resp.text
74
+
75
+
76
+ # ── Diff stats ────────────────────────────────────────────────────────────────
77
+ def diff_stats(text: str) -> dict:
78
+ lines = text.splitlines()
79
+ files = [l[len("diff --git "):].split(" b/")[-1]
80
+ for l in lines if l.startswith("diff --git ")]
81
+ adds = sum(1 for l in lines if l.startswith("+") and not l.startswith("+++"))
82
+ dels = sum(1 for l in lines if l.startswith("-") and not l.startswith("---"))
83
+ return {"files": files, "additions": adds, "deletions": dels}
84
+
85
+
86
+ # ── Auto filename ─────────────────────────────────────────────────────────────
87
+ def auto_stem(input_str: str) -> str:
88
+ today = date.today().isoformat()
89
+ if is_url(input_str):
90
+ _, slug = normalise_url(input_str)
91
+ return f"{slug}-changelog-{today}"
92
+ p = Path(input_str)
93
+ if p.is_dir():
94
+ return f"{p.name}-changelog-{today}"
95
+ return f"{p.stem}-changelog-{today}"
96
+
97
+
98
+ # ── Single diff processing ────────────────────────────────────────────────────
99
+ def process_diff(diff_text: str, stem: str, model: str,
100
+ fmt: Optional[str], source: str = "") -> CommitDoc:
101
+ """
102
+ fmt: None → terminal only | 'md' → save .md | 'json' → save .json | 'both' → both
103
+ source: original input (URL or file path) embedded in the markdown file
104
+ """
105
+ stats = diff_stats(diff_text)
106
+ print(_c(f" Files changed : {len(stats['files'])}", DIM))
107
+ for f in stats["files"]:
108
+ print(_c(f" • {f}", DIM))
109
+ print(_c(f" +{stats['additions']} additions -{stats['deletions']} deletions", DIM))
110
+ print()
111
+ print(_c(f" ⏳ Sending to {model}…", DIM))
112
+
113
+ t0 = time.time()
114
+ result = analyze_diff(diff_text, model=model)
115
+ elapsed = time.time() - t0
116
+ print(_c(f" ⚡ Inference done in {elapsed:.1f}s", DIM))
117
+ print(render_full_output(result))
118
+
119
+ if fmt in ("md", "both"):
120
+ md_path = Path(f"{stem}.md")
121
+ md_path.write_text(
122
+ render_markdown_file(result, model=model, source=source, stats=stats),
123
+ encoding="utf-8"
124
+ )
125
+ print(_c(f" ✓ Markdown saved → {md_path}", GREEN))
126
+
127
+ if fmt in ("json", "both"):
128
+ json_path = Path(f"{stem}.json")
129
+ json_path.write_text(result.model_dump_json(indent=2), encoding="utf-8")
130
+ print(_c(f" ✓ JSON saved → {json_path}", GREEN))
131
+
132
+ return result
133
+
134
+
135
+ # ── Main ──────────────────────────────────────────────────────────────────────
136
+ def main():
137
+ parser = argparse.ArgumentParser(
138
+ description="Generate Conventional Commit messages & changelogs from git diffs.",
139
+ formatter_class=argparse.RawDescriptionHelpFormatter,
140
+ epilog=__doc__,
141
+ )
142
+ parser.add_argument("input",
143
+ help="GitHub PR URL, local .diff file, or folder of .diff files")
144
+ parser.add_argument("--output", nargs="?", const="md", default=None,
145
+ metavar="{md,json,both}",
146
+ help="Save output: 'md' (default when flag used), 'json', or 'both'. Auto-names file.")
147
+ parser.add_argument("--model", default="gemma4",
148
+ help="Ollama model name (default: gemma4)")
149
+
150
+ args = parser.parse_args()
151
+
152
+ # Validate --output value
153
+ valid_fmts = {"md", "json", "both", None}
154
+ if args.output not in valid_fmts:
155
+ print(_c(f" ✗ --output must be md, json, or both (got '{args.output}')", RED))
156
+ sys.exit(1)
157
+
158
+ fmt = args.output # None | 'md' | 'json' | 'both'
159
+
160
+ # ── Banner ────────────────────────────────────────────────────────────────
161
+ print(_c("\n 🔍 git-to-doc", BOLD, CYAN) +
162
+ _c(f" powered by {args.model}", DIM))
163
+ print(_c(" " + "─" * 52, DIM))
164
+
165
+ # ── Dispatch: URL / folder / file ─────────────────────────────────────────
166
+ try:
167
+ if is_url(args.input):
168
+ print(_c(" ⚡ Source: URL", BOLD))
169
+ diff_text = fetch_diff(args.input)
170
+ process_diff(diff_text, auto_stem(args.input), args.model, fmt, source=args.input)
171
+
172
+ elif Path(args.input).is_dir():
173
+ diffs = sorted(Path(args.input).glob("**/*.diff"))
174
+ if not diffs:
175
+ print(_c(f" ✗ No .diff files found in {args.input}", RED))
176
+ sys.exit(1)
177
+ print(_c(f" ⚡ Source: folder ({len(diffs)} diff files)", BOLD))
178
+ for diff_file in diffs:
179
+ print(_c(f"\n ── {diff_file.name} ──", BOLD, CYAN))
180
+ stem = f"{diff_file.stem}-changelog-{date.today().isoformat()}"
181
+ process_diff(diff_file.read_text(encoding="utf-8"), stem, args.model, fmt, source=str(diff_file))
182
+
183
+ else:
184
+ path = Path(args.input)
185
+ if not path.exists():
186
+ print(_c(f" ✗ File not found: {args.input}", RED))
187
+ sys.exit(1)
188
+ print(_c(f" ⚡ Source: {path.name}", BOLD))
189
+ process_diff(path.read_text(encoding="utf-8"), auto_stem(args.input), args.model, fmt, source=str(path))
190
+
191
+ except requests.HTTPError as e:
192
+ print(_c(f"\n ✗ HTTP error: {e}", RED))
193
+ sys.exit(1)
194
+
195
+ print()
196
+
197
+
198
+ if __name__ == "__main__":
199
+ main()
@@ -0,0 +1,164 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ compare.py — Benchmark git-to-doc across multiple diffs or models.
4
+
5
+ Usage:
6
+ python compare.py <diff1> [diff2 ...] [--models m1 m2]
7
+ python compare.py ./diffs/ --models gemma4 llama3
8
+
9
+ Examples:
10
+ python compare.py sample.diff
11
+ python compare.py sample.diff https://github.com/pallets/flask/pull/5000
12
+ python compare.py ./diffs/ --models gemma4
13
+ """
14
+
15
+ import sys, argparse, time, json
16
+ from pathlib import Path
17
+ from typing import List
18
+
19
+ import requests
20
+
21
+ from git_to_doc.model import analyze_diff, CommitDoc
22
+
23
+ # ── ANSI helpers ─────────────────────────────────────────────────────────────
24
+ RESET = "\033[0m"; BOLD = "\033[1m"; GREEN = "\033[32m"
25
+ RED = "\033[31m"; CYAN = "\033[36m"; DIM = "\033[2m"
26
+ YELLOW = "\033[33m"; WHITE = "\033[97m"
27
+
28
+ def _c(t, *c): return "".join(c) + t + RESET
29
+
30
+
31
+ # ── Fetch / read diff ─────────────────────────────────────────────────────────
32
+ def load_diff(source: str) -> str:
33
+ if source.startswith("http://") or source.startswith("https://"):
34
+ url = source.rstrip("/")
35
+ if not url.endswith(".diff"):
36
+ url += ".diff"
37
+ resp = requests.get(url, timeout=30, headers={"User-Agent": "git-to-doc/1.0"})
38
+ resp.raise_for_status()
39
+ return resp.text
40
+ return Path(source).read_text(encoding="utf-8")
41
+
42
+
43
+ # ── Benchmark single diff against one model ───────────────────────────────────
44
+ def benchmark(diff_text: str, label: str, model: str) -> dict:
45
+ t0 = time.time()
46
+ result = analyze_diff(diff_text, model=model)
47
+ elapsed = time.time() - t0
48
+ return {
49
+ "label": label,
50
+ "model": model,
51
+ "elapsed": round(elapsed, 2),
52
+ "type": result.type,
53
+ "scope": result.scope,
54
+ "subject": result.subject,
55
+ "breaking": result.breaking,
56
+ "doc": result,
57
+ }
58
+
59
+
60
+ # ── Pretty table ──────────────────────────────────────────────────────────────
61
+ def print_table(results: list):
62
+ print()
63
+ print(_c(" " + "─" * 78, DIM))
64
+ print(_c(f" {'SOURCE':<28} {'MODEL':<12} {'TIME':>6} {'TYPE':<10} SUBJECT", BOLD, CYAN))
65
+ print(_c(" " + "─" * 78, DIM))
66
+ for r in results:
67
+ label = r["label"][:27]
68
+ model = r["model"][:11]
69
+ elapsed = f"{r['elapsed']}s"
70
+ typ = r["type"]
71
+ subject = r["subject"][:38]
72
+ color = GREEN if r["elapsed"] < 30 else YELLOW
73
+ print(f" {label:<28} {_c(model, DIM):<12} {_c(elapsed, color):>6} {_c(typ, BOLD):<10} {subject}")
74
+ print(_c(" " + "─" * 78, DIM))
75
+ print()
76
+
77
+ if len(results) > 1:
78
+ fastest = min(results, key=lambda r: r["elapsed"])
79
+ print(_c(f" ⚡ Fastest: {fastest['model']} on '{fastest['label']}' "
80
+ f"({fastest['elapsed']}s)", BOLD, GREEN))
81
+ print()
82
+
83
+
84
+ # ── Main ──────────────────────────────────────────────────────────────────────
85
+ def main():
86
+ parser = argparse.ArgumentParser(
87
+ description="Benchmark git-to-doc across multiple diffs and/or models.",
88
+ formatter_class=argparse.RawDescriptionHelpFormatter,
89
+ epilog=__doc__,
90
+ )
91
+ parser.add_argument("inputs", nargs="+",
92
+ help="One or more diff files, URLs, or a folder")
93
+ parser.add_argument("--models", nargs="+", default=["gemma4"],
94
+ help="One or more Ollama model names to compare (default: gemma4)")
95
+ parser.add_argument("--output", action="store_true",
96
+ help="Save a compare_results.json summary file")
97
+ args = parser.parse_args()
98
+
99
+ # Expand folders to .diff files
100
+ sources = []
101
+ for inp in args.inputs:
102
+ p = Path(inp)
103
+ if p.is_dir():
104
+ found = sorted(p.glob("**/*.diff"))
105
+ if not found:
106
+ print(_c(f" ✗ No .diff files in {inp}", RED))
107
+ sys.exit(1)
108
+ sources.extend(str(f) for f in found)
109
+ else:
110
+ sources.append(inp)
111
+
112
+ # ── Banner ────────────────────────────────────────────────────────────────
113
+ print(_c("\n 🔬 git-to-doc compare", BOLD, CYAN))
114
+ print(_c(f" {len(sources)} source(s) × {len(args.models)} model(s)"
115
+ f" = {len(sources) * len(args.models)} run(s)", DIM))
116
+ print(_c(" " + "─" * 52, DIM))
117
+
118
+ # ── Run benchmarks ────────────────────────────────────────────────────────
119
+ results = []
120
+ for source in sources:
121
+ label = source.split("/")[-1][:27]
122
+ try:
123
+ print(_c(f"\n Loading: {source}", DIM))
124
+ diff_text = load_diff(source)
125
+ except Exception as e:
126
+ print(_c(f" ✗ Failed to load {source}: {e}", RED))
127
+ continue
128
+
129
+ for model in args.models:
130
+ print(_c(f" ⏳ [{model}] {label}…", DIM), end="", flush=True)
131
+ try:
132
+ r = benchmark(diff_text, label, model)
133
+ results.append(r)
134
+ print(_c(f" {r['elapsed']}s", GREEN))
135
+ except Exception as e:
136
+ print(_c(f" ✗ Error: {e}", RED))
137
+
138
+ if not results:
139
+ print(_c("\n No results to display.", RED))
140
+ sys.exit(1)
141
+
142
+ # ── Results table ─────────────────────────────────────────────────────────
143
+ print_table(results)
144
+
145
+ # ── Per-result detail ─────────────────────────────────────────────────────
146
+ for r in results:
147
+ doc = r["doc"]
148
+ print(_c(f" [{r['model']}] {r['label']}", BOLD))
149
+ print(f" Commit : {_c(doc.type + (f'({doc.scope})' if doc.scope else '') + ': ' + doc.subject, WHITE)}")
150
+ print(f" Summary: {doc.plain_english[:120]}")
151
+ print()
152
+
153
+ # ── Save JSON summary ─────────────────────────────────────────────────────
154
+ if args.output:
155
+ out = [
156
+ {k: v for k, v in r.items() if k != "doc"}
157
+ for r in results
158
+ ]
159
+ Path("compare_results.json").write_text(json.dumps(out, indent=2))
160
+ print(_c(" ✓ Saved compare_results.json", GREEN))
161
+
162
+
163
+ if __name__ == "__main__":
164
+ main()
@@ -0,0 +1,62 @@
1
+ import ollama
2
+ import json
3
+ from pydantic import BaseModel, ValidationError
4
+ from typing import Literal, Optional
5
+
6
+ class CommitDoc(BaseModel):
7
+ type: Literal["feat","fix","docs","refactor","perf","test","chore","ci","build","revert"]
8
+ scope: Optional[str]
9
+ subject: str
10
+ body: Optional[str]
11
+ breaking: bool
12
+ changelog_entry: str
13
+ plain_english: str
14
+
15
+ SYSTEM_PROMPT = """
16
+
17
+ You are an expert Git commit message generator and technical writer.
18
+ Given a raw git diff, output ONLY a valid JSON object with no explanation, no markdown fences, no preamble.
19
+
20
+ Rules for each field:
21
+ - type: one of feat|fix|docs|refactor|perf|test|chore|ci|build|revert
22
+ - scope: lowercase name of the module or folder most affected (null if unclear)
23
+ - subject: imperative mood, lowercase, no trailing period, max 72 chars — be specific
24
+ - body: 1-3 sentences of technical detail for non-trivial changes (null if simple)
25
+ - breaking: true ONLY if existing public API contracts are removed or changed incompatibly
26
+ - changelog_entry: a single markdown bullet like "- feat(scope): description" ready to paste into CHANGELOG.md
27
+ - plain_english: 1-2 sentences a non-technical person could understand explaining WHAT changed and WHY
28
+
29
+ Output format: raw JSON only. No ```json fences. No commentary.
30
+
31
+ """
32
+
33
+ def analyze_diff(diff_text: str, model: str = "gemma4", max_retries: int = 3) -> CommitDoc:
34
+ if len(diff_text) > 6000:
35
+ diff_text = diff_text[:6000] + "\n... [truncated]"
36
+
37
+ for attempt in range(max_retries):
38
+ try:
39
+ response = ollama.chat(
40
+ model=model if ":" in model else f"{model}:latest",
41
+ messages=[
42
+ {"role": "system", "content": SYSTEM_PROMPT},
43
+ {"role": "user", "content": f"Analyze this git diff:\n\n{diff_text}"}
44
+ ],
45
+ format=CommitDoc.model_json_schema(),
46
+ options={"temperature": 0}
47
+ )
48
+ raw = response["message"]["content"]
49
+ return CommitDoc.model_validate_json(raw)
50
+ except ValidationError as e:
51
+ print(f"[retry {attempt+1}] validation failed: {e}")
52
+
53
+ # Fallback — never crash during demo
54
+ return CommitDoc(
55
+ type="chore",
56
+ scope=None,
57
+ subject="update codebase",
58
+ body=None,
59
+ breaking=False,
60
+ changelog_entry="- chore: miscellaneous updates",
61
+ plain_english="General codebase updates were made."
62
+ )
@@ -0,0 +1,171 @@
1
+ from datetime import date
2
+ from git_to_doc.model import CommitDoc
3
+
4
+ RESET = "\033[0m"
5
+ BOLD = "\033[1m"
6
+ GREEN = "\033[32m"
7
+ CYAN = "\033[36m"
8
+ YELLOW = "\033[33m"
9
+ DIM = "\033[2m"
10
+ WHITE = "\033[97m"
11
+
12
+ def _c(text: str, *codes: str) -> str:
13
+ return "".join(codes) + text + RESET
14
+
15
+
16
+ _SECTION = {
17
+ "feat": "Added",
18
+ "fix": "Fixed",
19
+ "docs": "Documentation",
20
+ "refactor": "Changed",
21
+ "perf": "Performance",
22
+ "test": "Tests",
23
+ "chore": "Maintenance",
24
+ "ci": "CI/CD",
25
+ "build": "Build",
26
+ "revert": "Reverted",
27
+ }
28
+
29
+ _TYPE_EMOJI = {
30
+ "feat": "✨",
31
+ "fix": "🐛",
32
+ "docs": "📝",
33
+ "refactor": "♻️",
34
+ "perf": "⚡",
35
+ "test": "🧪",
36
+ "chore": "🔧",
37
+ "ci": "🤖",
38
+ "build": "📦",
39
+ "revert": "⏪",
40
+ }
41
+
42
+
43
+ def render_commit_message(doc: CommitDoc) -> str:
44
+ """Returns a Conventional Commit string, e.g. feat(scope): subject"""
45
+ scope = f"({doc.scope})" if doc.scope else ""
46
+ breaking = "!" if doc.breaking else ""
47
+ header = f"{doc.type}{scope}{breaking}: {doc.subject}"
48
+
49
+ parts = [header]
50
+ if doc.body:
51
+ parts.append(f"\n{doc.body}")
52
+ if doc.breaking:
53
+ parts.append(f"\nBREAKING CHANGE: {doc.body or doc.subject}")
54
+ return "\n".join(parts)
55
+
56
+
57
+ def render_changelog(doc: CommitDoc) -> str:
58
+ """Returns a markdown changelog snippet ready to paste into CHANGELOG.md"""
59
+ today = date.today().isoformat()
60
+ section = "⚠️ Breaking Changes" if doc.breaking else _SECTION.get(doc.type, "Changed")
61
+
62
+ lines = [
63
+ f"## [Unreleased] — {today}",
64
+ "",
65
+ f"### {section}",
66
+ "",
67
+ doc.changelog_entry,
68
+ ]
69
+ if doc.body:
70
+ lines += ["", f" {doc.body}"]
71
+ return "\n".join(lines)
72
+
73
+
74
+ def render_full_output(doc: CommitDoc) -> str:
75
+ """Pretty-prints the full terminal output with colour and markdown blocks."""
76
+ emoji = _TYPE_EMOJI.get(doc.type, "📌")
77
+ bar = _c("─" * 58, DIM)
78
+
79
+ commit_msg = render_commit_message(doc)
80
+ changelog = render_changelog(doc)
81
+
82
+ output = f"""
83
+ {bar}
84
+ {_c(f" {emoji} CONVENTIONAL COMMIT MESSAGE", BOLD, CYAN)}
85
+ {bar}
86
+
87
+ {_c(commit_msg, BOLD, WHITE)}
88
+
89
+ {bar}
90
+ {_c(" 📋 CHANGELOG ENTRY (paste into CHANGELOG.md)", BOLD, CYAN)}
91
+ {bar}
92
+
93
+ {changelog}
94
+
95
+ {bar}
96
+ {_c(" 🗣️ PLAIN ENGLISH SUMMARY", BOLD, CYAN)}
97
+ {bar}
98
+
99
+ {_c(doc.plain_english, YELLOW)}
100
+
101
+ {bar}
102
+ """
103
+ return output
104
+
105
+
106
+ def render_markdown_file(doc: CommitDoc, model: str = "gemma4",
107
+ source: str = "", stats: dict = None) -> str:
108
+ """Returns a rich markdown file with metadata, no heading collision."""
109
+ emoji = _TYPE_EMOJI.get(doc.type, "📌")
110
+ commit_msg = render_commit_message(doc)
111
+ today = date.today().isoformat()
112
+ section = "⚠️ Breaking Changes" if doc.breaking else _SECTION.get(doc.type, "Changed")
113
+ scope_str = f"({doc.scope})" if doc.scope else ""
114
+
115
+ # Files changed block
116
+ files_block = ""
117
+ if stats and stats.get("files"):
118
+ files_list = "\n".join(f"- `{f}`" for f in stats["files"])
119
+ files_block = f"""## 📂 Files Changed
120
+
121
+ {files_list}
122
+
123
+ **+{stats.get('additions', 0)} additions / -{stats.get('deletions', 0)} deletions**
124
+
125
+ ---
126
+
127
+ """
128
+
129
+ source_block = f"> **Source:** {source}\n\n" if source else ""
130
+
131
+ body_block = f"\n> {doc.body}\n" if doc.body else ""
132
+
133
+ return f"""# {emoji} `{doc.type}{scope_str}`: {doc.subject}
134
+
135
+ > Generated by **git-to-doc** using `{model}` on {today}
136
+ {source_block}
137
+ ---
138
+
139
+ ## 🗣️ Plain English Summary
140
+
141
+ {doc.plain_english}
142
+
143
+ ---
144
+
145
+ ## ✅ Conventional Commit Message
146
+
147
+ ```
148
+ {commit_msg}
149
+ ```
150
+
151
+ ---
152
+
153
+ ## 📋 Changelog Entry
154
+
155
+ ### {section}
156
+
157
+ {doc.changelog_entry}
158
+ {body_block}
159
+ ---
160
+
161
+ {files_block}## ℹ️ Metadata
162
+
163
+ | Field | Value |
164
+ |-------|-------|
165
+ | Type | `{doc.type}` |
166
+ | Scope | `{doc.scope or "—"}` |
167
+ | Breaking Change | {"⚠️ Yes" if doc.breaking else "No"} |
168
+ | Model | `{model}` |
169
+ | Generated | {today} |
170
+ """
171
+
@@ -0,0 +1,104 @@
1
+ Metadata-Version: 2.4
2
+ Name: git-to-doc
3
+ Version: 0.1.0
4
+ Summary: Generate Conventional Commit messages & changelogs from git diffs using local Gemma
5
+ Requires-Python: >=3.9
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: ollama>=0.4
8
+ Requires-Dist: pydantic>=2.0
9
+ Requires-Dist: requests>=2.31
10
+
11
+ # git-to-doc ⚡
12
+
13
+ > Turn any git diff into a Conventional Commit message, changelog, and plain-English PR summary — powered by Gemma running locally via Ollama.
14
+
15
+ ## Install
16
+
17
+ ```bash
18
+ pip install git-to-doc
19
+ ```
20
+
21
+ **Requires:** [Ollama](https://ollama.com) running locally with a Gemma model pulled:
22
+ ```bash
23
+ ollama pull gemma4
24
+ ```
25
+
26
+ ## Usage
27
+
28
+ ```bash
29
+ # From a GitHub PR URL
30
+ git-to-doc https://github.com/pallets/flask/pull/5000
31
+
32
+ # From a local .diff file
33
+ git-to-doc sample.diff
34
+
35
+ # Save output as markdown
36
+ git-to-doc https://github.com/pallets/flask/pull/5000 --output md
37
+
38
+ # Save both markdown and JSON
39
+ git-to-doc sample.diff --output both
40
+
41
+ # Use a different model
42
+ git-to-doc sample.diff --model gemma2:2b
43
+
44
+ # Benchmark multiple diffs
45
+ git-to-doc-compare sample.diff https://github.com/pallets/flask/pull/5000
46
+ ```
47
+
48
+ ## Output
49
+
50
+ For each diff, `git-to-doc` produces:
51
+
52
+ 1. **Conventional Commit message** — ready to paste into `git commit`
53
+ 2. **Changelog entry** — ready to paste into `CHANGELOG.md`
54
+ 3. **Plain-English summary** — for non-technical stakeholders
55
+
56
+ ```
57
+ ──────────────────────────────────────────────────────────
58
+ ✨ CONVENTIONAL COMMIT MESSAGE
59
+ ──────────────────────────────────────────────────────────
60
+
61
+ feat(parser): add null safety and safe_parse wrapper
62
+
63
+ ──────────────────────────────────────────────────────────
64
+ 📋 CHANGELOG ENTRY
65
+ ──────────────────────────────────────────────────────────
66
+
67
+ ## [Unreleased] — 2026-06-26
68
+ ### Added
69
+ - feat(parser): add null safety checks and safe_parse fallback
70
+
71
+ ──────────────────────────────────────────────────────────
72
+ 🗣️ PLAIN ENGLISH SUMMARY
73
+ ──────────────────────────────────────────────────────────
74
+
75
+ The parser now safely handles missing or null input instead of crashing.
76
+ ```
77
+
78
+ ## Supported Inputs
79
+
80
+ | Input | Example |
81
+ |---|---|
82
+ | GitHub PR URL | `git-to-doc https://github.com/org/repo/pull/123` |
83
+ | Raw diff URL | `git-to-doc https://patch-diff.githubusercontent.com/raw/org/repo/pull/123.diff` |
84
+ | Local `.diff` file | `git-to-doc my_changes.diff` |
85
+ | Folder of diffs | `git-to-doc ./diffs/` |
86
+
87
+ ## Flags
88
+
89
+ | Flag | Description |
90
+ |---|---|
91
+ | `--output md` | Save markdown file (auto-named `PR-123-changelog-DATE.md`) |
92
+ | `--output json` | Save JSON file |
93
+ | `--output both` | Save both |
94
+ | `--model NAME` | Ollama model to use (default: `gemma4`) |
95
+
96
+ ## Built With
97
+
98
+ - [Ollama](https://ollama.com) — local LLM runtime
99
+ - [Gemma](https://ai.google.dev/gemma) — Google's open model
100
+ - [Pydantic](https://docs.pydantic.dev) — structured output validation
101
+
102
+ ---
103
+
104
+ Built at AI-First Developer Efficiencies Hackathon · Track 1: The Automagic Documenter
@@ -0,0 +1,13 @@
1
+ README.md
2
+ pyproject.toml
3
+ git_to_doc/__init__.py
4
+ git_to_doc/cli.py
5
+ git_to_doc/compare.py
6
+ git_to_doc/model.py
7
+ git_to_doc/renderer.py
8
+ git_to_doc.egg-info/PKG-INFO
9
+ git_to_doc.egg-info/SOURCES.txt
10
+ git_to_doc.egg-info/dependency_links.txt
11
+ git_to_doc.egg-info/entry_points.txt
12
+ git_to_doc.egg-info/requires.txt
13
+ git_to_doc.egg-info/top_level.txt
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ git-to-doc = git_to_doc.cli:main
3
+ git-to-doc-compare = git_to_doc.compare:main
@@ -0,0 +1,3 @@
1
+ ollama>=0.4
2
+ pydantic>=2.0
3
+ requests>=2.31
@@ -0,0 +1 @@
1
+ git_to_doc
@@ -0,0 +1,23 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "git-to-doc"
7
+ version = "0.1.0"
8
+ description = "Generate Conventional Commit messages & changelogs from git diffs using local Gemma"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ dependencies = [
12
+ "ollama>=0.4",
13
+ "pydantic>=2.0",
14
+ "requests>=2.31",
15
+ ]
16
+
17
+ [project.scripts]
18
+ git-to-doc = "git_to_doc.cli:main"
19
+ git-to-doc-compare = "git_to_doc.compare:main"
20
+
21
+ [tool.setuptools.packages.find]
22
+ where = ["."]
23
+ include = ["git_to_doc*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+