Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 42 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,11 @@ with Java 1.8+ using Git for version control.

### Usage

This plugin works together with the [Maven Release Plugin] to create
conventional commit compliant releases for your Maven projects
This plugin works together with the [Maven Release Plugin] to create
a conventional commit compliant releases for your Maven projects

#### Install the Plugin

In your main `pom.xml` file add the plugin:

<plugins>
Expand All @@ -22,11 +22,50 @@ In your main `pom.xml` file add the plugin:
</plugin>
</plugins>

You can provide the link to you tracking system as parameter in configuration. In generated change log there will be
the link to the ticket.

<trackingSystemUrlFormat>http://example.com/%s</trackingSystemUrlFormat>

`%s` - will be replaced by ticket id provided at the begging of message in square brackets.
For example:

`fix: [ticket-id] message`

Also, you can provide the pattern for repository URL. In the generated change log
there will be a commit hash with URL to the commit in the remote repository.

<repoUrlFormat>http://example.com/%s</repoUrlFormat>

#### Release a Version

mvn conventional-commits:version release:prepare
mvn release:perform

#### With generated change logs

mvn conventional-commits:version conventional-commits:changelog release:prepare
mvn release:perform

#### Changelog example

##### Commit messages:
breaking change: [ticket-23] change public API

ci: add build step

##### Generated change log (CHANGELOG.MD):
## 1.0.0 (2020-11-14)
### Breaking changes
* change public API [(ticket-23)](http://example.com/ticket-23) [(23b1e004c4)](http://example.com/23b1e004c45b56b633f09656a05875a5a5ff7e86)
### CI
* add build step

**Note**: changelog goal performs a commit that includes updated CHANGELOG.MD
this commit will not be rolled back on release:clean - this is because of well-known
maven limitation - release plugin does not allow committing additional files on release:prepare
stage

## Gradle Plugin

A [Gradle] plugin is planned for future release.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@

import java.util.Objects;
import java.util.Optional;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

public class Commit
{
private static final Pattern BREAKING_REGEX = Pattern.compile("^(fix|feat)!.+", Pattern.CASE_INSENSITIVE);
private static final String TRACKING_SYSTEM_REGEX_STRING = "^\\s*\\[\\s*(.*)\\s*\\]\\s*";

private final CommitAdapter commit;

Expand Down Expand Up @@ -67,6 +69,36 @@ public Optional<ConventionalCommitType> getCommitType()
return Optional.ofNullable(type);
}

public Optional<String> getCommitMessageDescription() {
return getCommitMessageFullDescription()
.map(fullCommitMessage -> fullCommitMessage.replaceFirst(TRACKING_SYSTEM_REGEX_STRING, ""));
}

public Optional<String> getTrackingSystemId() {
return getCommitMessageFullDescription().map(commitMessage -> {
if("".equals(commitMessage.trim())) {
return null;
}

Matcher matcher = Pattern.compile(TRACKING_SYSTEM_REGEX_STRING + ".*", Pattern.CASE_INSENSITIVE).matcher(commitMessage);
return matcher.matches() ? matcher.group(1).trim() : null;
});
}

public String getCommitHash() {
return this.commit.getCommitHash();
}

private Optional<String> getCommitMessageFullDescription() {
String message = getMessageForComparison();
String[] split = message.split(":");
if(split.length <= 1) {
return Optional.empty();
}

return Optional.of(split[1].trim());
}

private String getMessageForComparison()
{
String msg = commit.getShortMessage();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,6 @@ public interface CommitAdapter<T>
String getShortMessage();

T getCommit();

String getCommitHash();
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ public class GitCommitAdapter implements CommitAdapter<RevCommit>
{
private final RevCommit commit;

GitCommitAdapter(RevCommit commit)
public GitCommitAdapter(RevCommit commit)
{
Objects.requireNonNull(commit, "commit cannot be null");
this.commit = commit;
Expand All @@ -25,4 +25,10 @@ public RevCommit getCommit()
{
return commit;
}

@Override
public String getCommitHash()
{
return commit.getName();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@
import org.eclipse.jgit.lib.ObjectId;
import org.eclipse.jgit.lib.Ref;
import org.eclipse.jgit.lib.Repository;
import org.eclipse.jgit.lib.RepositoryCache;
import org.eclipse.jgit.revplot.PlotWalk;
import org.eclipse.jgit.revwalk.RevCommit;
import org.eclipse.jgit.util.FS;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

Expand All @@ -15,34 +17,33 @@
import java.util.Objects;
import java.util.stream.Collectors;

public class LogHandler
{
public class LogHandler {
public static final String HEAD_COMMIT_ALIAS = "HEAD";
private final Logger logger = LoggerFactory.getLogger(LogHandler.class);

private final Repository repository;
private final Git git;

public LogHandler(Repository repository)
{
public LogHandler(Repository repository) {
Objects.requireNonNull(repository, "repository cannot be null");
if (!RepositoryCache.FileKey.isGitRepository(repository.getDirectory(), FS.DETECTED)) {
throw new IllegalArgumentException("Current working directory is not a git repository or " + HEAD_COMMIT_ALIAS + " is missing");
}
this.repository = repository;
this.git = Git.wrap(repository);
}

RevCommit getLastTaggedCommit() throws IOException, GitAPIException
{
RevCommit getLastTaggedCommit() throws IOException, GitAPIException {
List<Ref> tags = git.tagList().call();
List<ObjectId> peeledTags = tags.stream().map(t -> repository.peel(t).getPeeledObjectId()).collect(Collectors.toList());
PlotWalk walk = new PlotWalk(repository);
RevCommit start = walk.parseCommit(repository.resolve("HEAD"));
RevCommit start = walk.parseCommit(repository.resolve(HEAD_COMMIT_ALIAS));

walk.markStart(start);

RevCommit revCommit;
while ((revCommit = walk.next()) != null)
{
if (peeledTags.contains(revCommit.getId()))
{
while ((revCommit = walk.next()) != null) {
if (peeledTags.contains(revCommit.getId())) {
logger.debug("Found commit matching last tag: {}", revCommit);
break;
}
Expand All @@ -53,13 +54,11 @@ RevCommit getLastTaggedCommit() throws IOException, GitAPIException
return revCommit;
}

public Iterable<RevCommit> getCommitsSinceLastTag() throws IOException, GitAPIException
{
ObjectId start = repository.resolve("HEAD");
public Iterable<RevCommit> getCommitsSinceLastTag() throws IOException, GitAPIException {
ObjectId start = repository.resolve(HEAD_COMMIT_ALIAS);
RevCommit lastCommit = this.getLastTaggedCommit();

if (lastCommit == null)
{
if (lastCommit == null) {
logger.warn("No annotated tags found matching any commits on branch");
return git.log().call();
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package com.smartling.cc4j.semantic.release.common.changelog;

import com.smartling.cc4j.semantic.release.common.Commit;
import com.smartling.cc4j.semantic.release.common.ConventionalCommitType;
import com.smartling.cc4j.semantic.release.common.scm.ScmApiException;

import java.util.Map;
import java.util.Set;

public interface ChangelogExtractor {
/**
* Extracts and groups commits by their commit types
* @return - commits grouped by commit type
*/
Map<ConventionalCommitType, Set<Commit>> getGroupedCommitsByCommitTypes() throws ScmApiException;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
package com.smartling.cc4j.semantic.release.common.changelog;

import com.smartling.cc4j.semantic.release.common.Commit;
import com.smartling.cc4j.semantic.release.common.ConventionalCommitType;
import com.smartling.cc4j.semantic.release.common.LogHandler;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.time.LocalDate;
import java.util.*;
import java.util.stream.Collectors;

public class ChangelogGenerator {
public static final int COMMIT_HASH_DISPLAYED_LENGTH = 10;
private final Logger logger = LoggerFactory.getLogger(LogHandler.class);

private static final String CHANGELOG_FORMAT = "## %s (%s)" +
"%s";
private static final String MD_LINK_FORMAT = "[%s](%s)";
private static final String BUG_FIXES_HEADER = "Bug fixes";
private static final String FEATURE_HEADER = "Feature";
private static final String BREAKING_HEADER = "Breaking changes";
private static final String DOCS_HEADER = "Docs";
private static final String CI_HEADER = "CI";
private static final String BUILD_HEADER = "Build";

private final String repoUrlFormat;
private final String trackingSystemUrlFormat;

public ChangelogGenerator(String repoUrlFormat, String trackingSystemUrlFormat) {
this.repoUrlFormat = repoUrlFormat;
this.trackingSystemUrlFormat = trackingSystemUrlFormat;
}

public String generate(String nextVersion, Map<ConventionalCommitType, Set<Commit>> commitsByCommitType) {
Objects.requireNonNull(nextVersion, "next version can not be null");
Objects.requireNonNull(commitsByCommitType, "commits by type can not be null");

List<String> sections = new ArrayList<>();

if (commitsByCommitType.get(ConventionalCommitType.BREAKING_CHANGE) != null) {
getSection(BREAKING_HEADER, commitsByCommitType.get(ConventionalCommitType.BREAKING_CHANGE)).ifPresent(sections::add);
}

if (commitsByCommitType.get(ConventionalCommitType.FIX) != null) {
getSection(BUG_FIXES_HEADER, commitsByCommitType.get(ConventionalCommitType.FIX)).ifPresent(sections::add);
}

if (commitsByCommitType.get(ConventionalCommitType.FEAT) != null) {
getSection(FEATURE_HEADER, commitsByCommitType.get(ConventionalCommitType.FEAT)).ifPresent(sections::add);
}

if (commitsByCommitType.get(ConventionalCommitType.DOCS) != null) {
getSection(DOCS_HEADER, commitsByCommitType.get(ConventionalCommitType.DOCS)).ifPresent(sections::add);
}

if (commitsByCommitType.get(ConventionalCommitType.CI) != null) {
getSection(CI_HEADER, commitsByCommitType.get(ConventionalCommitType.CI)).ifPresent(sections::add);
}

if (commitsByCommitType.get(ConventionalCommitType.BUILD) != null) {
getSection(BUILD_HEADER, commitsByCommitType.get(ConventionalCommitType.BUILD)).ifPresent(sections::add);
}

sections = sections.stream().filter(Objects::nonNull).collect(Collectors.toList());

return String.format(CHANGELOG_FORMAT, nextVersion, LocalDate.now(), "\n" + String.join("\n", sections));

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I detect that this code is problematic. According to the Bad practice (BAD_PRACTICE), FS: Format string should use %n rather than n (VA_FORMAT_STRING_USES_NEWLINE).
This format string includes a newline character (\n). In format strings, it is generally preferable to use %n, which will produce the platform-specific line separator.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @lillieMaiBauer, i added a spot bugs plugin to the project.
%n - looks the right thing to do. But i realized this case:

  1. CI server uses Unix based OS with \n line separator - changelog contains \n as a line separator
  2. For some reason we decided to perform release from Windows host - which will replace \n to \r\n in the changelog file
    I am not sure if this is the correct behavior? I can see in many sources that %n should be used, however - is the case described above ok - so we really want to use platform specific separator in generated changelog file?

}

private Optional<String> getSection(String header, Set<Commit> commits) {
String sectionEntries = getSectionEntries(commits);
if (sectionEntries != null && !sectionEntries.trim().equals("")) {
return Optional.of("###" + header + "\n" + sectionEntries);
}

return Optional.empty();
}

private String getSectionEntries(Set<Commit> commits) {
Set<String> uniqueMessages = new HashSet<>();
return commits.stream()
.filter(commit -> commit.getCommitMessageDescription().isPresent() && uniqueMessages.add(commit.getCommitMessageDescription().get()))
.map(this::getChangeLogEntry)
.filter(Optional::isPresent)
.map(Optional::get)
.sorted()
.collect(Collectors.joining("\n"));
}

private Optional<String> getChangeLogEntry(Commit commit) {
if (!commit.getCommitMessageDescription().isPresent()) {
logger.warn("Skipping message for commit: {}", commit.getCommitHash());
}
return commit.getCommitMessageDescription().map(message -> {
if (commit.getCommitMessageDescription().get().trim().equals("")) {
logger.warn("Skipping message for commit: {}", commit.getCommitHash());
return null;
}
return "* " + commit.getCommitMessageDescription().get() + getTrackingSystemLink(commit) + getCommitHashLink(commit);
});
}

private String getCommitHashLink(Commit commit) {
if (this.repoUrlFormat == null) {
return " (" + commit.getCommitHash().substring(0, COMMIT_HASH_DISPLAYED_LENGTH) + ")";
} else {
return " " + String.format(MD_LINK_FORMAT, "(" + commit.getCommitHash().substring(0, COMMIT_HASH_DISPLAYED_LENGTH) + ")", String.format(this.repoUrlFormat, commit.getCommitHash()));
}
}

private String getTrackingSystemLink(Commit commit) {
if (this.trackingSystemUrlFormat == null || !commit.getTrackingSystemId().isPresent()) {
return commit.getTrackingSystemId().map(s -> " (" + s + ")").orElse("");
} else {
return " " + String.format(MD_LINK_FORMAT, "(" + commit.getTrackingSystemId().get() + ")", String.format(this.trackingSystemUrlFormat, commit.getTrackingSystemId().get()));
}
}
}
Loading