Skip to content

Commit

Permalink
✨ feat(changelog): implement basic changelog features
Browse files Browse the repository at this point in the history
  • Loading branch information
Jannchie committed Jun 17, 2024
1 parent 1b31cbf commit cf623c6
Show file tree
Hide file tree
Showing 4 changed files with 240 additions and 9 deletions.
13 changes: 12 additions & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,16 @@
"black-formatter.args": [
"--line-length",
"160"
]
],
"[python]": {
"editor.defaultFormatter": "ms-python.black-formatter",
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.organizeImports": "explicit"
},
},
"isort.args": [
"--profile",
"black"
],
}
232 changes: 225 additions & 7 deletions tgit/changelog.py
Original file line number Diff line number Diff line change
@@ -1,25 +1,243 @@
import re
import warnings
from collections import defaultdict
from dataclasses import dataclass

from tgit.utils import console
import git


def get_latest_git_tag(repo: git.Repo):
return get_tag_by_idx(repo, -1)


def get_tag_by_idx(repo: git.Repo, idx: int):
try:
if tags := sorted(repo.tags, key=lambda t: t.commit.committed_datetime):
return tags[idx].name
else:
return None
except Exception as e:
print(f"Error: {e}")
return None


def get_first_commit_hash(repo: git.Repo) -> str:
return next(
(commit.hexsha for commit in repo.iter_commits() if not commit.parents),
None,
)


def get_commit_hash_from_tag(repo: git.Repo, tag):
try:
return repo.tags[tag].commit.hexsha
except Exception as e:
print(f"Error: {e}")
return None


def define_changelog_parser(subparsers):
parser_changelog = subparsers.add_parser("changelog", help="generate changelogs")
parser_changelog.add_argument("-f", "--from", help="From hash/tag", type=str, dest="from_raw")
parser_changelog.add_argument("-t", "--to", help="To hash/tag", type=str, dest="to_raw")
parser_changelog.add_argument("-v", "--verbose", action="count", default=0, help="increase output verbosity")
parser_changelog.add_argument("-v", "--verbose", action="count", default=0, help="increase output verbosity", dest="verbose")
parser_changelog.add_argument("path", help="repository path", type=str, nargs="?", default=".")
parser_changelog.set_defaults(func=handle_changelog)


@dataclass
class ChangelogArgs:
from_raw: str
to_raw: str
verbose: int
path: str


def get_simple_hash(repo: git.Repo, hash, length=7):
try:
return repo.git.rev_parse(hash, short=length)
except Exception as e:
print(f"Error: {e}")
return None


def ref_to_hash(repo: git.Repo, ref: str, length=7):
try:
return repo.git.rev_parse(ref, short=length)
except Exception as e:
print(f"Error: {e}")
return None


commit_pattern = re.compile(
r"(?P<emoji>:.+:|(\uD83C[\uDF00-\uDFFF])|(\uD83D[\uDC00-\uDE4F\uDE80-\uDEFF])|[\u2600-\u2B55])?( *)?(?P<type>[a-z]+)(\((?P<scope>.+?)\))?(?P<breaking>!)?: (?P<description>.+)",
re.IGNORECASE,
)


def resolve_from_ref(repo, from_raw):
if from_raw is not None:
return from_raw
last_tag = get_latest_git_tag(repo)
return get_first_commit_hash(repo) if last_tag is None else last_tag


@dataclass
class Author:
name: str
email: str

def __str__(self):
return f"{self.name} <{self.email}>"


class TGITCommit:
def __init__(self, repo: git.Repo, commit: git.Commit, message_dict: dict):
commit_date = commit.committed_datetime

co_author_raws = [line for line in commit.message.split("\n") if line.lower().startswith("co-authored-by:")]
co_author_pattern = re.compile(r"Co-authored-by: (?P<name>.+?) <(?P<email>.+?)>", re.IGNORECASE)
co_authors = [co_author_pattern.match(co_author).groupdict() for co_author in co_author_raws]
authors = [{"name": commit.author.name, "email": commit.author.email}] + co_authors
self.authors: list[Author] = [Author(**kwargs) for kwargs in authors]
self.date = commit_date
self.emoji = message_dict.get("emoji")
self.type = message_dict.get("type")
self.scope = message_dict.get("scope")
self.description = message_dict.get("description")
self.breaking = bool(message_dict.get("breaking"))
self.hash = repo.git.rev_parse(commit.hexsha, short=7)

def __str__(self) -> str:
authors_str = ", ".join(str(author) for author in self.authors)
date_str = self.date.strftime("%Y-%m-%d %H:%M:%S")
return (
f"Hash: {self.hash}\n"
f"Breaking: {self.breaking}\n"
f"Commit: {self.emoji or ''} {self.type or ''}{f'({self.scope})' if self.scope else ''}: {self.description}\n"
f"Date: {date_str}\n"
f"Authors: {authors_str}\n"
)


def format_names(names):
if not names:
return ""

if len(names) == 1:
return f"By {names[0]}"

if len(names) == 2:
return f"By {names[0]} and {names[1]}"

formatted_names = ", ".join(names[:-1])
formatted_names += f" and {names[-1]}"

return f"By {formatted_names}"


def get_remote_uri(url: str):
# SSH URL regex, with groups for domain, namespace and repo name
ssh_pattern = re.compile(r"git@([\w\.]+):(.+)/(.+)\.git")
# HTTPS URL regex, with groups for domain, namespace and repo name
https_pattern = re.compile(r"https://([\w\.]+)/(.+)/(.+)\.git")

if ssh_match := ssh_pattern.match(url):
domain, namespace, repo_name = ssh_match[1], ssh_match[2], ssh_match[3]
return f"{domain}/{namespace}/{repo_name}" # "domain/namespace/repo_name"

if https_match := https_pattern.match(url):
domain, namespace, repo_name = https_match[1], https_match[2], https_match[3]
return f"{domain}/{namespace}/{repo_name}" # "domain/namespace/repo_name"

return None


def get_commits(repo: git.Repo, from_hash: str, to_hash: str) -> list[TGITCommit]:
raw_commits = list(repo.iter_commits(f"{from_hash}...{to_hash}"))
tgit_commits = []
for commit in raw_commits:
if m := commit_pattern.match(commit.message):
message_dict = m.groupdict()
tgit_commits.append(TGITCommit(repo, commit, message_dict))
return tgit_commits


def group_commits_by_type(commits: list[TGITCommit]) -> dict[str, list[TGITCommit]]:
commits_by_type = defaultdict(list)
for commit in commits:
if commit.breaking:
commits_by_type["breaking"].append(commit)
else:
commits_by_type[commit.type].append(commit)
return commits_by_type


def generate_changelog(commits_by_type: dict[str, list[TGITCommit]], from_ref: str, to_ref: str, remote_uri: str = None) -> str:
order = ["breaking", "feat", "fix", "refactor", "perf", "style", "docs", "chore"]
names = [
":rocket: Breaking Changes",
":sparkles: Features",
":bug: Fixes",
":art: Refactors",
":zap: Performance Improvements",
":lipstick: Styles",
":memo: Documentation",
":wrench: Chores",
]
out_str = ""
out_str = f"## {to_ref}\n\n"
if remote_uri:
out_str += f"[{from_ref}...{to_ref}](https://{remote_uri}/compare/{from_ref}...{to_ref})\n\n"
else:
out_str += f"{from_ref}...{to_ref}\n\n"

def get_hash_link(commit: TGITCommit):
if remote_uri:
return f"[{commit.hash}](https://{remote_uri}/commit/{commit.hash})"
return commit.hash

for i, o in enumerate(order):
if commits := commits_by_type.get(o):
title = f"### {names[i]}\n\n"
out_str += title
# Sort commits by scope, if scope is None, put it to last
commits.sort(key=lambda c: "zzzzz" if c.scope is None else c.scope)
for commit in commits:

authors_str = format_names([f"[{a.name}](mailto:{a.email})" for a in commit.authors])
if commit.scope:
line = f"- **{commit.scope}**: {commit.description} - {authors_str} in {get_hash_link(commit)}\n"
else:
line = f"- {commit.description} - {authors_str} in {get_hash_link(commit)}\n"
out_str += line
out_str += "\n"
return out_str


def handle_changelog(args: ChangelogArgs):
from_raw = args.from_raw
to_raw = args.to_raw
console.log(f"{from_raw} -> {to_raw}")
console.log(args)
console.log("WIP")
repo = git.Repo(args.path)
from_ref = resolve_from_ref(repo, args.from_raw)
to_ref = "HEAD" if args.to_raw is None else args.to_raw
is_from_tag = from_ref in repo.tags
if is_from_tag and to_ref == "HEAD":
to_ref = from_ref
from_ref = get_tag_by_idx(repo, -2)
if from_ref is None:
from_ref = get_first_commit_hash(repo)

from_hash = ref_to_hash(repo, from_ref)
to_hash = ref_to_hash(repo, to_ref)

try:
origin_url = repo.remote().url
remote_uri = get_remote_uri(origin_url)
except ValueError:
warnings.warn("Origin not found, some of the link generation functions could not be enabled.")
remote_uri = None

tgit_commits = get_commits(repo, from_hash, to_hash)
commits_by_type = group_commits_by_type(tgit_commits)
changelog = generate_changelog(commits_by_type, from_ref, to_ref, remote_uri)
print()
print(changelog)
3 changes: 2 additions & 1 deletion tgit/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
import sys
from typing import Optional

import inquirer
import rich
from rich.panel import Panel
from rich.syntax import Syntax
Expand All @@ -11,6 +10,7 @@

console = rich.get_console()

import inquirer

type_emojis = {
"feat": ":sparkles:",
Expand Down Expand Up @@ -57,6 +57,7 @@ def run_command(command: str):
console.print(panel)

if not settings.get("skip_confirm", False):

ok = inquirer.prompt([inquirer.Confirm("continue", message="Do you want to continue?", default=True)])
if not ok or not ok["continue"]:
return
Expand Down
1 change: 1 addition & 0 deletions tgit/version.py
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ def get_prev_version():


def handle_version(args: VersionArgs):

verbose = args.verbose

# check if there is uncommitted changes
Expand Down

0 comments on commit cf623c6

Please sign in to comment.