Skip to content

Commit

Permalink
feat(changelog): handle BREAKING CHANGE from footer
Browse files Browse the repository at this point in the history
Introduces a new ConventionalCommit subtype of Commit, which parses the commit body according to conventional commit specification.
The conventional-commit preset uses a custom changelog.format instead of replacers to achieve formatting.
The changelog presets can now have a custom format.

Closes: jreleaser#809
  • Loading branch information
gotson committed Dec 5, 2022
1 parent 956f468 commit 61a440a
Show file tree
Hide file tree
Showing 4 changed files with 456 additions and 29 deletions.
Expand Up @@ -294,10 +294,6 @@ private static void validateChangelog(JReleaserContext context, BaseReleaser ser
changelog.setSort(org.jreleaser.model.Changelog.Sort.DESC);
}

if (isBlank(changelog.getFormat())) {
changelog.setFormat("- {{commitShortHash}} {{commitTitle}} ({{commitAuthor}})");
}

if (isBlank(changelog.getCategoryTitleFormat())) {
changelog.setCategoryTitleFormat("## {{categoryTitle}}");
}
Expand Down Expand Up @@ -325,6 +321,11 @@ private static void validateChangelog(JReleaserContext context, BaseReleaser ser
loadPreset(context, changelog, errors);
}

// set the default format after the preset, as preset can contain a default format too
if (isBlank(changelog.getFormat())) {
changelog.setFormat("- {{commitShortHash}} {{commitTitle}} ({{commitAuthor}})");
}

if (changelog.getCategories().isEmpty()) {
changelog.getCategories().add(Changelog.Category.of("feature", RB.$("default.category.feature"), "", "feature", "enhancement"));
changelog.getCategories().add(Changelog.Category.of("fix", RB.$("default.category.bug.fix"), "", "bug", "fix"));
Expand Down Expand Up @@ -424,6 +425,10 @@ private static void loadPreset(JReleaserContext context, Changelog changelog, Er
if (null != inputStream) {
Changelog loaded = JReleaserConfigLoader.load(Changelog.class, presetFileName, inputStream);

if(isBlank(changelog.getFormat())) {
changelog.setFormat(loaded.getFormat());
}

Set<Changelog.Labeler> labelersCopy = new TreeSet<>(Changelog.Labeler.ORDER);
labelersCopy.addAll(changelog.getLabelers());
labelersCopy.addAll(loaded.getLabelers());
Expand Down
Expand Up @@ -81,10 +81,4 @@ categories:
labels:
- 'docs'

replacers:
- search: '((?:build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)(?:\(.*\))?)!(:\s.*)'
replace: '🚨 $1$2'
- search: '(?:build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)\((.*)\):\s(.*)'
replace: '\*\*$1\*\*: $2'
- search: '(?:build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test):\s(.*)'
replace: '$1'
format: '- {{commitShortHash}} {{#commitIsConventional}}{{#commitIsBreakingChange}}🚨 {{/commitIsBreakingChange}}{{#commitScope}}**{{commitScope}}**: {{/commitScope}}{{commitDescription}}{{#commitBreakingChangeContent}} - *{{commitBreakingChangeContent}}*{{/commitBreakingChangeContent}}{{/commitIsConventional}}{{^commitIsConventional}}{{commitTitle}}{{/commitIsConventional}}'
Expand Up @@ -36,17 +36,20 @@

import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.OptionalInt;
import java.util.Set;
import java.util.TreeSet;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.IntStream;
import java.util.stream.StreamSupport;

import static java.lang.System.lineSeparator;
Expand Down Expand Up @@ -571,10 +574,27 @@ protected static class Commit {
private String fullHash;
private String shortHash;
private String title;
private String body;
protected String body;
private Author author;
private int time;

protected Commit(RevCommit rc) {
fullHash = rc.getId().name();
shortHash = rc.getId().abbreviate(7).name();
body = rc.getFullMessage();
String[] lines = split(body);
title = lines[0];
author = new Author(rc.getAuthorIdent().getName(), rc.getAuthorIdent().getEmailAddress());
addContributor(rc.getCommitterIdent().getName(), rc.getCommitterIdent().getEmailAddress());
time = rc.getCommitTime();
for (String line : lines) {
Matcher m = CO_AUTHORED_BY_PATTERN.matcher(line);
if (m.matches()) {
addContributor(m.group(1), m.group(2));
}
}
}

Map<String, Object> asContext(boolean links, String commitsUrl) {
Map<String, Object> context = new LinkedHashMap<>();
if (links) {
Expand All @@ -597,30 +617,135 @@ private void addContributor(String name, String email) {
}

static Commit of(RevCommit rc) {
Commit c = new Commit();
c.fullHash = rc.getId().name();
c.shortHash = rc.getId().abbreviate(7).name();
c.body = rc.getFullMessage();
String[] lines = split(c.body);
c.title = lines[0];
c.author = new Author(rc.getAuthorIdent().getName(), rc.getAuthorIdent().getEmailAddress());
c.addContributor(rc.getCommitterIdent().getName(), rc.getCommitterIdent().getEmailAddress());
c.time = rc.getCommitTime();
for (String line : lines) {
Matcher m = CO_AUTHORED_BY_PATTERN.matcher(line);
if (m.matches()) {
c.addContributor(m.group(1), m.group(2));
}
}
return c;
return new Commit(rc);
}

private static String[] split(String str) {
protected static String[] split(String str) {
// Any Unicode linebreak sequence
return str.split("\\R");
}
}

static class ConventionalCommit extends Commit {
private static final Pattern FIRST_LINE_PATTERN =
Pattern.compile("^(?<type>build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)(?:\\((?<scope>\\w+)\\))?(?<bang>!)?: (?<description>.*$)");
private static final Pattern BREAKING_CHANGE_PATTERN = Pattern.compile("^BREAKING[ \\-]CHANGE:\\s+(?<content>[\\w\\W]+)", Pattern.MULTILINE);
private static final Pattern TRAILER_PATTERN = Pattern.compile("(?<token>^\\w+(?:-\\w+)*)(?:: | #)(?<value>.*$)");

private boolean isConventional = true;
private boolean isBreakingChange;
private String type = "";
private String scope = "";
private String description = "";
private final List<Trailer> trailers = new ArrayList<>();
private String breakingChangeContent = "";

private ConventionalCommit(RevCommit rc) {
super(rc);
List<String> lines = new ArrayList<>(Arrays.asList(split(body)));
Matcher matcherFirstLine = FIRST_LINE_PATTERN.matcher(lines.get(0));
if (matcherFirstLine.matches()) {
lines.remove(0); // consumed first line
if (matcherFirstLine.group("bang") != null && !matcherFirstLine.group("bang").isEmpty()) {
isBreakingChange = true;
}
type = matcherFirstLine.group("type");
scope = matcherFirstLine.group("scope") == null ? "" : matcherFirstLine.group("scope");
description = matcherFirstLine.group("description");
} else {
isConventional = false;
return;
}

// drop any empty lines at the beginning
while (!lines.isEmpty() && lines.get(0).equals("")) {
lines.remove(0);
}

// try to match trailers from the end
while (!lines.isEmpty()) {
Matcher matcherTrailer = TRAILER_PATTERN.matcher(lines.get(lines.size() - 1));
if (matcherTrailer.matches()) {
String token = matcherTrailer.group("token");
if(token.equals("BREAKING-CHANGE")) break;
trailers.add(new Trailer(token, matcherTrailer.group("value")));
lines.remove(lines.size() - 1); // consume last line
} else {
break;
}
}

// drop any empty lines at the end
while (!lines.isEmpty() && lines.get(lines.size() - 1).equals("")) {
lines.remove(lines.size() - 1);
}

Matcher matcherBC = BREAKING_CHANGE_PATTERN.matcher(String.join("\n", lines));
if (matcherBC.find()) {
isBreakingChange = true;
breakingChangeContent = matcherBC.group("content");
// consume the breaking change
OptionalInt match = IntStream.range(0, lines.size())
.filter(i -> BREAKING_CHANGE_PATTERN.matcher(lines.get(i)).find())
.findFirst();
if (match.isPresent()) {
if (lines.size() > match.getAsInt()) {
lines.subList(match.getAsInt(), lines.size()).clear();
}
}
}

// the rest is the body
body = String.join("\n", lines);
}

public static Commit of(RevCommit rc) {
ConventionalCommit c = new ConventionalCommit(rc);
if(c.isConventional) return c;
// not ideal to reparse the commit, but that way we return a Commit instead of a ConventionalCommit
else return Commit.of(rc);
}

@Override
Map<String, Object> asContext(boolean links, String commitsUrl) {
Map<String, Object> context = super.asContext(links, commitsUrl);
context.put("commitIsConventional", true);
context.put("commitBreakingChangeContent", passThrough(breakingChangeContent));
context.put("commitIsBreakingChange", isBreakingChange);
context.put("commitType", type);
context.put("commitScope", scope);
context.put("commitDescription", description);
return context;
}

public List<Trailer> getTrailers() {
return trailers;
}

static class Trailer {
private final String token;
private final String value;

@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Trailer)) return false;
Trailer trailer = (Trailer) o;
return token.equals(trailer.token) && value.equals(trailer.value);
}

@Override
public int hashCode() {
return Objects.hash(token, value);
}

public Trailer(String token, String value) {
this.token = token;
this.value = value;
}
}
}

private static class Author implements Comparable<Author> {
protected final String name;
protected final String email;
Expand Down

0 comments on commit 61a440a

Please sign in to comment.