DevLab / deployment / release-notes-generator-from-jiragithub
DEPLOYMENT PYTHON INTERMEDIATE

Release Notes Generator from Jira/GitHub

Shipping software is easy; explaining what shipped is the hard part. Every release, someone has to manually scan Git commits, cross-reference Jira tickets,

Python3.10+
OSLinux / macOS
Lines~217
Packages1 deps
Install Dependencies
$ pip install requests
Usage
$ python3 release_notes.py --from v1.2.0 --to HEAD --jira-project DEV
#deployment#python#intermediate#requests

Release Notes Generator from Jira/GitHub

Problem Statement

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.

How It Works

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.

Full Script

#!/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())

Configuration

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.

Usage Examples

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).

Error Handling

  • 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.
  • Invalid ref range (e.g. --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.
  • No commits in range → collect_commits returns an empty list; the renderer emits _No commits found in this range._ instead of a blank document.
  • Jira 404 → the ticket is rendered with (ticket not found) rather than crashing; useful when a commit references a ticket from a different project.
  • Jira 401/500/timeout → 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.
  • Output file not writable → OSError is caught and reported to stderr; exit code 1.
  • Missing JIRA_BASE_URL → the script still produces useful output; it just skips network enrichment entirely.

Extending Ideas

  • GitHub PR enrichment: Instead of (or in addition to) Jira, call the GitHub /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.
  • Release-notes templates: Move the Markdown rendering into a Jinja2 template loaded from a file, so teams can customize headings, add a "Breaking Changes" section, or emit HTML for a docs site.
  • Filtering and labeling: Add --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.
  • Caching and rate-limit handling: Persist fetched tickets to a small SQLite cache keyed by ticket key and updated timestamp, and honor Jira's Retry-After header on 429 responses. This makes repeated runs during a release cycle much faster.
  • Publishing integrations: Add a --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."
// RELATED_SCRIPTS
← Back to DevLab valtersit.com