Friday afternoon. You tag v0.4.0, open CHANGELOG.md, and stare at 48 commits. Three are real features. The rest is noise.
That weekly ritual is a perfect first agent project. The input is bounded, the output is low-stakes, and the cost of being wrong is a confusing changelog — not a broken production system.
This is a case study of one small project: a cron job that reads git history, groups commits, asks a language model to draft release notes, and writes a decision ledger you can audit. It runs on a free server. Each run costs nothing.
Background: why release notes are a good agent boundary
Most release-note automation stops at grouping. Tools like git-cliff sort commits by conventional-commit type and concatenate them. That works, but the result reads like a log, not a changelog.
A language model can do the second half: turn grouped commits into sentences a user actually understands. The blockers are the usual two — API cost and hosting cost. For a hobby project, both are hard to justify.
MonkeyCode's open-source project addresses exactly those two blockers with free model access and a free server option. Disclosure: This article was prepared as part of MonkeyCode's product outreach. The setup below uses both, but the pattern is provider-agnostic; you can swap in any chat API.
Goal
The agent has four jobs, in order:
- Find the last tag and collect commits since it.
- Group commits by type (feat, fix, perf, docs, refactor, chore, other).
- Draft a short changelog with a language model.
- Write a JSON ledger so you can see what the model received and produced.
Implementation
Step 1: collect commits
# collect.py
import subprocess
def last_tag(repo: str = ".") -> str:
out = subprocess.check_output(
["git", "-C", repo, "tag", "--sort=-creatordate"], text=True
)
return out.splitlines()[0]
def commits_since(tag: str, repo: str = ".") -> list[dict]:
cmd = ["git", "-C", repo, "log", "--no-merges", "--oneline", f"{tag}..HEAD"]
out = subprocess.check_output(cmd, text=True)
commits = []
for line in out.splitlines():
hash_, message = line.split(" ", 1)
commits.append({"hash": hash_, "message": message})
return commits
--no-merges matters. Merge commits duplicate the changes they merge, and the model will happily list the same feature twice.
Step 2: group commits
# group.py
import re
from collections import defaultdict
TYPE_PATTERN = re.compile(r"^(feat|fix|docs|refactor|chore|perf|test|build|ci)(\(.+\))?!?: (.+)")
def group_commits(commits: list[dict]) -> dict[str, list[str]]:
groups = defaultdict(list)
for c in commits:
m = TYPE_PATTERN.match(c["message"])
key = m.group(1) if m else "other"
groups[key].append(c["message"])
return dict(groups)
The regex is the whole reliability story. If the commit message does not follow the convention, it lands in other — and the prompt below tells the model to summarize that group conservatively.
Step 3: build the prompt
def build_prompt(groups: dict[str, list[str]]) -> str:
lines = []
for kind in ("feat", "fix", "perf", "docs", "refactor", "chore", "other"):
items = groups.get(kind, [])[:20] # hard cap per group
if not items:
continue
lines.append(f"## {kind}")
lines.extend(f"- {m}" for m in items)
return (
"You are drafting release notes for a small open-source project.\n"
"Rewrite the grouped commits below as a short changelog.\n"
"Rules:\n"
"- Put user-visible changes first.\n"
"- Do not invent changes. If a group is empty, skip it.\n"
"- Keep it under 150 words.\n\n"
+ "\n".join(lines)
)
The cap at 20 items per group is a token budget in disguise. A 2,000-commit range will not fit a free-tier prompt; truncation keeps the run cheap and the draft readable.
Step 4: call the model
# adapter.py — transport omitted on purpose.
# Plug in your provider's client here. The contract is simple:
# send the prompt, receive markdown text, retry once on timeout.
def draft_changelog(prompt: str) -> str:
raise NotImplementedError("add your provider client")
The exact HTTP call depends on the provider you choose. What matters is the contract: one prompt in, markdown out, one retry on timeout. Do not retry on empty output — an empty draft is a signal the prompt or the grouping broke, and retrying hides that.
Step 5: write the decision ledger
# ledger.py
import datetime
import json
def write_ledger(groups: dict[str, list[str]], draft: str, path: str = "ledger.jsonl") -> None:
entry = {
"date": datetime.date.today().isoformat(),
"commit_count": sum(len(v) for v in groups.values()),
"groups": {k: len(v) for k, v in groups.items()},
"draft": draft,
}
with open(path, "a") as f:
f.write(json.dumps(entry) + "\n")
A recent DEV discussion argued that agents should remember decisions, not just data. This ledger is that idea in its smallest form: every run records what the model saw and what it produced. When a draft is wrong, you can see whether the model or the input was at fault.
Step 6: schedule it
0 9 * * 1 cd /srv/release-notes && /usr/bin/python3 run.py >> run.log 2>&1
Monday at 09:00, before you open the changelog. Set CRON_TZ=UTC if your server clock drifts.
Test plan
Test the parts that do not need a model before you trust the parts that do.
# test_group.py
def test_group_commits():
fake = [
{"hash": "a1b2c3", "message": "feat: add retry with backoff"},
{"hash": "d4e5f6", "message": "fix: handle empty queue"},
{"hash": "g7h8i9", "message": "update README"},
]
groups = group_commits(fake)
assert groups["feat"] == ["feat: add retry with backoff"]
assert groups["fix"] == ["fix: handle empty queue"]
assert groups["other"] == ["update README"]
Add one test for the truncation cap and one for the empty-repo case. That is enough coverage for a script this size.
Failure modes
| Symptom | Likely cause | Fix |
|---|---|---|
Everything lands in other
|
Repo does not use conventional commits | Accept it, or add a fallback prompt for raw messages |
| Draft invents features | Prompt too loose, or cap too high | Tighten the rules; lower the cap to 10 |
| Empty draft | Grouping broke, not the model | Check the ledger; do not retry blindly |
| Cron never fires | Server timezone | Set CRON_TZ=UTC and log to a file |
Results
A representative draft looks like this:
## Features
- Added retry with exponential backoff to the queue worker.
- New `--dry-run` flag for batch jobs.
## Fixes
- Fixed a race condition when two jobs claimed the same row.
## Other
- Documentation updates and dependency bumps.
And the ledger line that produced it:
{"date": "2026-08-24", "commit_count": 47, "groups": {"feat": 2, "fix": 1, "other": 44}, "draft": "## Features\n- Added retry..."}
In testing, two patterns showed up repeatedly. First, the draft was rarely publishable as-is, but it cut writing time from minutes of scrolling to a quick edit. Second, the ledger caught a real bug: the model silently dropped the perf group because the parser had mislabeled those commits as other. Without the ledger, that loss would have been invisible.
Limitations
Do not use this approach for:
- Repos without conventional commits. The fallback works, but the draft gets vague and the model starts guessing.
- Large monorepos. A huge commit range blows past any token budget. Truncate hard and accept the loss.
- Teams that publish verbatim. The draft is a starting point, not a legal document. A human signs off before release.
Free-tier quotas change. Check MonkeyCode's current terms before you build a pipeline on any free allowance — this article deliberately avoids quoting numbers that may be stale.
Lessons learned
Three lessons carried over to bigger agent projects.
- The prompt contract matters more than the model. The grouping code does the reliable work; the model only rewrites.
- Auditability turns a black box into a tool. One JSONL file made every draft explainable.
- Free infrastructure changes which problems are worth automating. A ten-minute weekly task becomes worth automating at zero marginal cost.
The whole script is about 120 lines. Point it at your repo, let it run once, and read the ledger. If you want to try it, MonkeyCode's free tier (10 million tokens) and free server option are enough to run this exact project — and since it is open source, you can inspect exactly what the agent sends before you trust it.
Top comments (0)