Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/projects/changelog-from-git/_category_.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"label": "Changelog from Git",
"position": 22
}
352 changes: 352 additions & 0 deletions docs/projects/changelog-from-git/index.md

Large diffs are not rendered by default.

6 changes: 6 additions & 0 deletions docs/projects/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,12 @@ They're optional and ungraded. Browse them any time — each project's intro say

<ProjectChooser
projects={mergeProjectMeta([
{
id: 'changelog-from-git',
title: 'Build a Changelog Generator from Git History',
summary:
"Turn a noisy stream of commit messages into a clean, categorized changelog — with every entry traceable back to the commit hash it came from.",
},
{
id: '2027-dependency-freshness-checker',
title: 'Build a Dependency-Freshness Checker',
Expand Down
1 change: 1 addition & 0 deletions examples/changelog-from-git/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
GITHUB_TOKEN=your-key-here
5 changes: 5 additions & 0 deletions examples/changelog-from-git/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
.env
__pycache__/
sample_repo/
build/
node_modules/
1 change: 1 addition & 0 deletions examples/changelog-from-git/.python-version
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
3.12
76 changes: 76 additions & 0 deletions examples/changelog-from-git/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# Changelog from Git Example

[![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/abderrahim-lectures/python-data-analysis-course)

The local companion to the course's [Build a Changelog Generator from Git History](../../docs/projects/changelog-from-git/index.md) project — fetch a repo's commit history with `git log`, synthesize a clean, categorized changelog (Added / Changed / Fixed) with a free-tier LLM, and verify every entry against the real commits.

## What's here

- `fetch_commits.py` — `load_commits(max_commits)`: runs `git log --format=...` (NUL-separated fields, so messy subjects can't break parsing) and returns one record per commit: hash, author, date, subject — [Step 1](../../docs/projects/changelog-from-git/index.md#step-1-fetch-the-commit-stream).
- `generate.py` — builds a prompt that demands categorization, noise-filtering, and a **commit-hash citation per entry**, then asks a free-tier LLM for the changelog — [Step 2](../../docs/projects/changelog-from-git/index.md#step-2-let-the-llm-synthesize-with-citations-required).
- `make_sample_repo.py` — creates `sample_repo/`, a small git repo with realistically messy history (features, fixes, "wip", a merge commit, "tweak") so the tool has something to chew on with no network access. The `sample_repo/` folder itself is gitignored and recreated by running this script.
- `notebook.ipynb` — a Colab/Kaggle/Binder-ready notebook that shallow-clones the course repo and generates a changelog from its recent commits. Launch it from the badges on the [lesson page](../../docs/projects/changelog-from-git/index.md#where-to-run-this), or open it directly: [![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/abderrahim-lectures/python-data-analysis-course/blob/main/examples/changelog-from-git/notebook.ipynb) [![Open In Kaggle](https://kaggle.com/static/images/open-in-kaggle.svg)](https://kaggle.com/kernels/welcome?src=https://github.com/abderrahim-lectures/python-data-analysis-course/blob/main/examples/changelog-from-git/notebook.ipynb) [![Binder](https://mybinder.org/badge_logo.svg)](https://mybinder.org/v2/gh/abderrahim-lectures/python-data-analysis-course/main?filepath=examples%2Fchangelog-from-git%2Fnotebook.ipynb)

## Running it

First, create the bundled sample repo (no network, no API key needed for this step):

```bash
uv sync
uv run python make_sample_repo.py # creates sample_repo/ with 14 messy commits
cd sample_repo
uv run python ../fetch_commits.py 50 # see the commit stream yourself
```

The generation step needs a free-tier API key:

1. **Get a free-tier API key** from your chosen provider — see the table in the [lesson's Setup](../../docs/projects/changelog-from-git/index.md#get-a-free-llm-api-key).
2. **Copy `.env.example` to `.env`** and fill in the key:
```bash
cp .env.example .env
# then edit .env
```
`.env` is already gitignored — never commit a real key.
3. **Generate the changelog** from inside the sample repo:
```bash
cd sample_repo
uv run python ../generate.py 50
```

Every changelog entry ends with a short commit hash — verify a few with `git show <hash>` inside `sample_repo/`.

### Using a real repo

`cd` into any git repo you have locally (or shallow-clone the course repo) and run the same two commands from inside it:

```bash
git clone --depth 20 https://github.com/abderrahim-lectures/python-data-analysis-course
cd python-data-analysis-course
# copy the example scripts here (or run them with the --project flag)
uv run --project ../examples/changelog-from-git python ../examples/changelog-from-git/generate.py 20
```

`--depth 20` gives you enough history to make a changelog meaningful (a `--depth 1` clone has exactly one commit).

`uv` reads `pyproject.toml`/`uv.lock` and creates an isolated environment for this project automatically on first run.

## Running it in GitHub Codespaces

Click the badge above, or go to the [repo's Codespaces page](https://github.com/abderrahim-lectures/python-data-analysis-course), for a ready-to-go cloud dev environment (Node + Python + `uv` preinstalled via [`.devcontainer/devcontainer.json`](../../.devcontainer/devcontainer.json)). Once it's open:

```bash
cd examples/changelog-from-git
uv run python make_sample_repo.py
cd sample_repo
uv run python ../generate.py 50
```

(add your API key as a [Codespaces secret](https://docs.github.com/en/codespaces/managing-your-codespaces/managing-encrypted-secrets-for-your-repository-and-organization#adding-secrets-for-a-repository) or `export` it for a one-off session).

## A note on staying current

Model names and APIs in this space change fast. The GitHub Models endpoint and `gpt-4o-mini` free tier used here were both verified working while writing this example, but may have drifted by the time you read it — see the callout in the [lesson](../../docs/projects/changelog-from-git/index.md#step-2-let-the-llm-synthesize-with-citations-required) for what to check before relying on this code.

## Built your own changelog tool?

See [`examples/student-projects/`](../student-projects/) for how to share it with the class via a pull request — no git experience required, it walks through every step.
60 changes: 60 additions & 0 deletions examples/changelog-from-git/fetch_commits.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
"""Fetches a repo's recent commit history as structured records.

Run with: uv run python fetch_commits.py [max_commits]
This prints a summary -- generate.py (Step 2) imports load_commits().
Run from inside a git repo (or a folder that is one).
"""

import subprocess
import sys


def _run_git(args: list[str]) -> str:
"""Runs `git <args>` in the current directory and returns its stdout."""
result = subprocess.run(
["git", *args],
capture_output=True,
text=True,
check=False,
)
if result.returncode != 0:
raise RuntimeError(f"git {' '.join(args)} failed:\n{result.stderr}")
return result.stdout


def load_commits(max_commits: int = 50) -> list[dict]:
"""Returns the last `max_commits` commits, newest first."""
raw = _run_git(
[
"log",
f"-{max_commits}",
# One record per commit, fields separated by a NUL byte so that
# neither subject text nor hashes can accidentally blur together.
"--format=%H%x00%an%x00%ad%x00%s%x00%x00",
"--date=short",
]
)
commits = []
for block in raw.split("\x00\x00"):
block = block.strip("\n")
if not block:
continue
parts = block.split("\x00")
if len(parts) < 4:
continue
commit = {
"hash": parts[0],
"author": parts[1],
"date": parts[2],
"subject": parts[3],
}
commits.append(commit)
return commits


if __name__ == "__main__":
limit = int(sys.argv[1]) if len(sys.argv) > 1 else 50
commits = load_commits(limit)
print(f"Fetched {len(commits)} commits")
for c in commits[:5]:
print(f" {c['hash'][:8]} {c['date']} {c['author']}: {c['subject']}")
55 changes: 55 additions & 0 deletions examples/changelog-from-git/generate.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
"""Turns fetched commits into a categorized, cited changelog.

Run with: uv run python generate.py 50
(Set your API key in .env first.)
"""

import os
import sys

from dotenv import load_dotenv
from openai import OpenAI

from fetch_commits import load_commits

load_dotenv()

PROMPT_TEMPLATE = """You are writing a release changelog from commit messages.
Below is a list of recent commits, each tagged with a short hash. Write a
clean changelog with these rules:

- Group entries into sections: **Added**, **Changed**, **Fixed**.
- Merge commits that clearly belong to the same change; drop pure noise
(merge commits, "wip", formatting-only messages) unless they hint at a real
change, in which case include the change.
- Every entry must end with the commit hash(es) it came from, like "(abc1234)".
- Do NOT invent commits, features, or fixes. If a message is too vague to
classify, put it in a final "Other / unclear" section rather than guessing.

Commits (hash: subject):
{commits}

Changelog:
"""


def build_prompt(commits: list[dict]) -> str:
lines = [f"{c['hash'][:8]}: {c['subject']}" for c in commits]
return PROMPT_TEMPLATE.format(commits="\n".join(lines))


def generate_changelog(commits: list[dict]) -> str:
client = OpenAI(
api_key=os.environ["GITHUB_TOKEN"],
base_url="https://models.github.ai/inference",
)
response = client.chat.completions.create(
model="gpt-4o-mini", # confirm this still has a free tier before running
messages=[{"role": "user", "content": build_prompt(commits)}],
)
return response.choices[0].message.content


if __name__ == "__main__":
limit = int(sys.argv[1]) if len(sys.argv) > 1 else 50
print(generate_changelog(load_commits(limit)))
66 changes: 66 additions & 0 deletions examples/changelog-from-git/make_sample_repo.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
"""Creates a small sample git repo with realistically messy commit history,
so the changelog tool has something to work with out of the box.

Run with: uv run python make_sample_repo.py
Creates ./sample_repo with ~15 commits (mix of features, fixes, docs, and
noise like merge commits and "wip"), then prints the fetched commit list.
"""

from __future__ import annotations

import os
import subprocess
import sys
from pathlib import Path

REPO = Path("sample_repo")

STEPS = [
("feat: add user login with email verification", "app.py", "def login():\n pass\n"),
("wip", "app.py", "def login():\n return email\n"),
("fix: handle empty email in login", "app.py", "def login(email):\n return email.strip() or None\n"),
("add README", "README.md", "# Sample App\n"),
("feat: password reset flow", "auth.py", "def reset_password():\n pass\n"),
("Merge branch 'feature/reset'", "auth.py", "def reset_password():\n return True\n"),
("docs: clarify setup steps in README", "README.md", "# Sample App\n\n## Setup\n1. Install\n"),
("fix: reset token expiry", "auth.py", "from datetime import timedelta\nTOKEN_TTL = timedelta(hours=1)\n"),
("refactor utils", "utils.py", "def normalize(s):\n return s\n"),
("feat: dark mode toggle", "theme.py", "def toggle_theme():\n pass\n"),
("tweak", "theme.py", "def toggle_theme():\n return 'dark'\n"),
("fix: flash on toggle", "theme.py", "def toggle_theme():\n from time import sleep\n sleep(0)\n return 'dark'\n"),
("feat: export CSV report", "reports.py", "def export_csv():\n return []\n"),
("bump version to 1.2.0", "pyproject.toml", "version = \"1.2.0\"\n"),
]


def make_repo() -> None:
if REPO.exists():
import shutil

shutil.rmtree(REPO)
REPO.mkdir()
os.chdir(REPO)

def git(*args: str) -> None:
subprocess.run(["git", *args], check=True, capture_output=True)

git("init", "-q")
git("config", "user.email", "student@example.com")
git("config", "user.name", "Student")

for subject, filename, content in STEPS:
Path(filename).write_text(content)
git("add", "-A")
git("commit", "-q", "-m", subject)
os.chdir("..")
print(f"Created {REPO}/ with {len(STEPS)} commits")


if __name__ == "__main__":
make_repo()
sys.path.insert(0, ".")
from fetch_commits import load_commits

os.chdir(REPO)
for c in load_commits(50):
print(f" {c['hash'][:8]} {c['date']} {c['author']}: {c['subject']}")
Loading
Loading