Shipping software is easy; explaining what shipped is the hard part. Every release, someone has to manually scan Git commits, cross-reference Jira tickets,
Shipping software is easy; explaining what shipped is the hard part. Every release, someone has to manually scan Git commits, cross-reference Jira tickets, filter out noise like "fix typo" or "merge branch," and cobble together a Markdown changelog that product managers, support engineers, and customers can actually read. It's tedious, error-prone, and always happens at the worst possible time — right when everyone is focused on getting the release out the door.
This script automates the whole ritual. It walks the Git history between two refs, extracts Jira ticket keys from commit messages, fetches ticket metadata from the Jira REST API (summary, type, status), and emits a clean, grouped Markdown changelog. It works with plain Git repos or GitHub-hosted ones, respects environment-based configuration so it can run in CI without secrets in the command line, and degrades gracefully when the Jira API is unreachable.
The architecture is a straightforward three-stage pipeline: collect → enrich → render. In the collect stage, we shell out to git log with a range (--from..--to) and a custom --pretty format that gives us one line per commit, including the full subject and body. We then apply a compiled regex to pull Jira ticket keys (e.g., DEV-1234) out of each message, deduplicating across commits so a ticket mentioned five times only appears once.
The enrich stage hits the Jira REST API (/rest/api/2/issue/{key}) using requests with basic auth or a bearer token, with a short timeout and per-ticket error isolation — a single 404 or 401 won't kill the whole run. We cache results in memory to avoid hammering the API when many commits reference the same ticket. The render stage groups tickets by Jira issue type (Bug, Story, Task, etc.), sorts categories in a sensible priority order, and writes Markdown to stdout or a file. Design choices worth noting: we use subprocess.run with check=False and inspect the return code ourselves so we can distinguish "no commits in range" from "bad ref"; we use dataclasses for typed ticket records; and we keep the renderer pure so it's trivially unit-testable.
#!/usr/bin/env python3
"""
Release Notes Generator from Jira/GitHub
Walks a Git commit range, extracts Jira ticket keys from commit messages,
fetches ticket metadata from Jira, and renders a grouped Markdown changelog.
Usage:
python3 release_notes.py --from v1.2.0 --to HEAD --jira-project DEV
"""
from __future__ import annotations
import argparse
import os
import re
import subprocess
import sys
from dataclasses import dataclass, field
from typing import Iterable
import requests
# --------------------------------------------------------------------------- #
# Configuration (environment variables with sensible defaults)
# --------------------------------------------------------------------------- #
JIRA_BASE_URL = os.environ.get("JIRA_BASE_URL", "").rstrip("/")
JIRA_EMAIL = os.environ.get("JIRA_EMAIL", "")
JIRA_API_TOKEN = os.environ.get("JIRA_API_TOKEN", "")
JIRA_TIMEOUT = float(os.environ.get("JIRA_TIMEOUT", "10"))
GITHUB_REPO_URL = os.environ.get("GITHUB_REPO_URL", "").rstrip("/")
# Order in which issue types appear in the output. Anything not listed is
# appended at the end under "Other".
TYPE_ORDER = ["Epic", "Story", "New Feature", "Improvement", "Task", "Bug", "Sub-task"]
# Regex: match Jira keys like DEV-123 or ABC-42 in commit messages.
# Project prefix is injected at runtime so we only pick up relevant tickets.
def build_ticket_regex(project: str) -> re.Pattern[str]:
return re.compile(rf"\b({re.escape(project)}-\d+)\b", re.IGNORECASE)
# --------------------------------------------------------------------------- #
# Data model
# --------------------------------------------------------------------------- #
@dataclass
class Ticket:
key: str
summary: str = ""
issue_type: str = "Other"
status: str = ""
url: str = ""
def as_markdown(self) -> str:
label = f"[{self.key}]({self.url})" if self.url else self.key
suffix = f" — _{self.status}_" if self.status else ""
return f"- {label}: {self.summary}{suffix}"
@dataclass
class Commit:
sha: str
subject: str
body: str = ""
tickets: list[str] = field(default_factory=list)
# --------------------------------------------------------------------------- #
# Git collection
# --------------------------------------------------------------------------- #
def collect_commits(from_ref: str, to_ref: str, project: str) -> list[Commit]:
"""Return commits in `from_ref..to_ref` with Jira keys extracted."""
# Use a rare field separator so we can safely split subject from body.
fmt = "%H%x1f%s%x1f%b%x1e"
cmd = ["git", "log", f"{from_ref}..{to_ref}", f"--pretty=format:{fmt}"]
try:
result = subprocess.run(
cmd, capture_output=True, text=True, check=False, timeout=60
)
except FileNotFoundError as exc:
raise RuntimeError("git executable not found on PATH") from exc
except subprocess.TimeoutExpired as exc:
raise RuntimeError("git log timed out after 60s") from exc
if result.returncode != 0:
raise RuntimeError(
f"git log failed (exit {result.returncode}): {result.stderr.strip()}"
)
ticket_re = build_ticket_regex(project)
commits: list[Commit] = []
# Records are separated by \x1e; each record has fields split by \x1f.
for record in result.stdout.split("\x1e"):
record = record.strip()
if not record:
continue
parts = record.split("\x1f")
if len(parts) < 3:
continue
sha, subject, body = parts[0], parts[1], parts[2]
# Deduplicate keys within a single commit while preserving order.
seen: dict[str, None] = {}
for match in ticket_re.findall(f"{subject}\n{body}"):
seen[match.upper()] = None
commits.append(Commit(sha=sha, subject=subject, body=body, tickets=list(seen)))
return commits
# --------------------------------------------------------------------------- #
# Jira enrichment
# --------------------------------------------------------------------------- #
def jira_session() -> requests.Session:
"""Build an authenticated requests session for the Jira REST API."""
session = requests.Session()
if JIRA_EMAIL and JIRA_API_TOKEN:
session.auth = (JIRA_EMAIL, JIRA_API_TOKEN)
elif JIRA_API_TOKEN:
session.headers["Authorization"] = f"Bearer {JIRA_API_TOKEN}"
session.headers["Accept"] = "application/json"
return session
def fetch_ticket(session: requests.Session, key: str) -> Ticket:
"""Fetch a single ticket; on failure return a minimal stub Ticket."""
url = f"{JIRA_BASE_URL}/rest/api/2/issue/{key}"
ticket = Ticket(key=key, url=f"{JIRA_BASE_URL}/browse/{key}")
if not JIRA_BASE_URL:
ticket.summary = "(Jira not configured)"
return ticket
try:
resp = session.get(url, timeout=JIRA_TIMEOUT)
if resp.status_code == 404:
ticket.summary = "(ticket not found)"
return ticket
resp.raise_for_status()
data = resp.json()
fields = data.get("fields", {})
ticket.summary = fields.get("summary", "")
ticket.issue_type = (fields.get("issuetype") or {}).get("name", "Other")
ticket.status = (fields.get("status") or {}).get("name", "")
except requests.RequestException as exc:
# Network hiccups shouldn't abort the release notes run.
ticket.summary = f"(Jira lookup failed: {exc.__class__.__name__})"
return ticket
def enrich_tickets(keys: Iterable[str]) -> dict[str, Ticket]:
"""Fetch metadata for all keys, caching to avoid duplicate API calls."""
session = jira_session()
cache: dict[str, Ticket] = {}
for key in keys:
if key not in cache:
cache[key] = fetch_ticket(session, key)
return cache
# --------------------------------------------------------------------------- #
# Rendering
# --------------------------------------------------------------------------- #
def _type_sort_key(name: str) -> tuple[int, str]:
try:
return (TYPE_ORDER.index(name), name)
except ValueError:
return (len(TYPE_ORDER), name)
def render_markdown(
from_ref: str,
to_ref: str,
commits: list[Commit],
tickets: dict[str, Ticket],
) -> str:
"""Produce the final Markdown changelog."""
lines: list[str] = []
lines.append(f"# Release Notes: {from_ref} → {to_ref}")
lines.append("")
if not commits:
lines.append("_No commits found in this range._")
return "\n".join(lines) + "\n"
# Group tickets by issue type.
grouped: dict[str, list[Ticket]] = {}
for ticket in tickets.values():
grouped.setdefault(ticket.issue_type, []).append(ticket)
for issue_type in sorted(grouped, key=_type_sort_key):
lines.append(f"## {issue_type}")
lines.append("")
for ticket in sorted(grouped[issue_type], key=lambda t: t.key):
lines.append(ticket.as_markdown())
lines.append("")
# Append the raw commit list as a fallback / audit trail.
lines.append("## Commits")
lines.append("")
for commit in commits:
short_sha = commit.sha[:8]
if GITHUB_REPO_URL:
link = f"[`{short_sha}`]({GITHUB_REPO_URL}/commit/{commit.sha})"
else:
link = f"`{short_sha}`"
lines.append(f"- {link} {commit.subject}")
lines.append("")
return "\n".join(lines)
# --------------------------------------------------------------------------- #
# Entry point
# --------------------------------------------------------------------------- #
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Generate Markdown release notes from Git + Jira."
)
parser.add_argument("--from", dest="from_ref", required=True,
help="Starting ref (exclusive), e.g. v1.2.0")
parser.add_argument("--to", dest="to_ref", default="HEAD",
help="Ending ref (inclusive). Default: HEAD")
parser.add_argument("--jira-project", required=True,
help="Jira project key prefix, e.g. DEV")
parser.add_argument("--output", "-o", default="-",
help="Output file path, or '-' for stdout (default).")
return parser.parse_args(argv)
def main(argv: list[str] | None = None) -> int:
args = parse_args(argv)
try:
commits = collect_commits(args.from_ref, args.to_ref, args.jira_project)
except RuntimeError as exc:
print(f"error: {exc}", file=sys.stderr)
return 1
# Collect unique ticket keys across all commits.
all_keys: dict[str, None] = {}
for commit in commits:
for key in commit.tickets:
all_keys[key] = None
tickets = enrich_tickets(all_keys.keys()) if all_keys else {}
markdown = render_markdown(args.from_ref, args.to_ref, commits, tickets)
if args.output == "-":
sys.stdout.write(markdown)
else:
try:
with open(args.output, "w", encoding="utf-8") as fh:
fh.write(markdown)
except OSError as exc:
print(f"error: cannot write {args.output}: {exc}", file=sys.stderr)
return 1
print(f"Wrote {args.output} ({len(commits)} commits, {len(tickets)} tickets)")
return 0
if __name__ == "__main__":
raise SystemExit(main())
The script reads all secrets and endpoints from environment variables so it can be wired into CI without leaking credentials into shell history or process listings.
| Variable | Required | Purpose |
|---|---|---|
JIRA_BASE_URL |
Yes (for enrichment) | Base URL of your Jira instance, e.g. https://yourco.atlassian.net. Trailing slash is stripped automatically. |
JIRA_EMAIL |
For Cloud | Email associated with the Jira API token. Used with basic auth. |
JIRA_API_TOKEN |
Yes (for enrichment) | API token (Jira Cloud) or personal access token (Server/DC). If JIRA_EMAIL is unset, the token is sent as a Bearer token instead. |
JIRA_TIMEOUT |
No | Per-request timeout in seconds. Defaults to 10. |
GITHUB_REPO_URL |
No | If set (e.g. https://github.com/acme/app), commit SHAs render as clickable links. |
CLI flags (--from, --to, --jira-project, --output) are always required for the range and project; the environment only supplies credentials and endpoints. If JIRA_BASE_URL is empty, the script still runs and produces a changelog — tickets just get a (Jira not configured) placeholder summary.
1. Basic run against a local repo, print to stdout:
export JIRA_BASE_URL="https://acme.atlassian.net"
export JIRA_EMAIL="ci-bot@acme.com"
export JIRA_API_TOKEN="xxxxxxxxxxxxxxxxxxxx"
export GITHUB_REPO_URL="https://github.com/acme/app"
python3 release_notes.py --from v1.2.0 --to HEAD --jira-project DEV
Expected output (abridged):
# Release Notes: v1.2.0 → HEAD
## Story
- [DEV-1042](https://acme.atlassian.net/browse/DEV-1042): Add SSO login — _Done_
## Bug
- [DEV-1051](https://acme.atlassian.net/browse/DEV-1051): Fix race in cache warmup — _Closed_
## Commits
- [`a1b2c3d4`](https://github.com/acme/app/commit/a1b2c3d4...) DEV-1042 wire up SSO provider
- [`e5f6a7b8`](https://github.com/acme/app/commit/e5f6a7b8...) DEV-1051 guard cache init
2. Write directly to a file for CI artifact upload:
python3 release_notes.py --from v1.2.0 --to v1.3.0 \
--jira-project DEV -o RELEASE_NOTES.md
# Wrote RELEASE_NOTES.md (37 commits, 12 tickets)
3. Run against a specific tag range without Jira (offline mode):
python3 release_notes.py --from v1.2.0 --to v1.2.1 --jira-project DEV
The output will still list tickets and commits, with summaries replaced by (Jira not configured).
git not installed or not on PATH → FileNotFoundError is caught and re-raised as a RuntimeError with a clear message; main() returns exit code 1.--from v9.9.9) → git log exits non-zero; the stderr from Git is surfaced verbatim so the user knows exactly which ref was bad.collect_commits returns an empty list; the renderer emits _No commits found in this range._ instead of a blank document.(ticket not found) rather than crashing; useful when a commit references a ticket from a different project.requests.RequestException is caught per-ticket; the summary becomes (Jira lookup failed: ConnectionError) and the rest of the run continues. This keeps CI green even when Jira is having a bad day.OSError is caught and reported to stderr; exit code 1.JIRA_BASE_URL → the script still produces useful output; it just skips network enrichment entirely./repos/{owner}/{repo}/commits/{sha}/pulls API to collect merged PR titles, labels, and authors. This gives you a richer changelog for teams that don't use Jira.--exclude-labels chore,docs and --exclude-authors dependabot[bot] flags so that noise commits don't pollute the notes. Jira labels from fields.labels make good hooks for this.updated timestamp, and honor Jira's Retry-After header on 429 responses. This makes repeated runs during a release cycle much faster.--publish slack or --publish github-release mode that posts the rendered Markdown to Slack or creates a GitHub Release via the REST API, closing the loop from "generate" to "announce."