From c0b10e547db8baca42dd728fa590d7ee104a8346 Mon Sep 17 00:00:00 2001 From: Mark Paluch Date: Mon, 9 Nov 2020 16:07:09 +0100 Subject: [PATCH] #99 - Extract ticket references from Git commits. --- .../data/release/build/Repository.java | 2 +- .../data/release/git/GitCommands.java | 34 +++ .../data/release/git/GitOperations.java | 165 ++++++++++++++- .../data/release/git/ParsedCommitMessage.java | 196 ++++++++++++++++++ .../springframework/data/release/git/Tag.java | 6 +- .../data/release/git/VersionTags.java | 158 +++++++++++++- .../data/release/issues/Changelog.java | 5 +- .../data/release/issues/TicketReference.java | 58 ++++++ .../data/release/model/ArtifactVersion.java | 74 ++++++- .../data/release/model/ReleaseTrains.java | 4 + .../data/release/model/Train.java | 36 +++- .../git/GitOperationsIntegrationTests.java | 52 +++++ .../git/ParsedCommitMessageUnitTests.java | 102 +++++++++ .../release/git/VersionTagsUnitTests.java | 57 +++++ .../data/release/model/TrainsUnitTest.java | 19 ++ 15 files changed, 944 insertions(+), 24 deletions(-) create mode 100644 src/main/java/org/springframework/data/release/git/ParsedCommitMessage.java create mode 100644 src/main/java/org/springframework/data/release/issues/TicketReference.java create mode 100644 src/test/java/org/springframework/data/release/git/ParsedCommitMessageUnitTests.java create mode 100644 src/test/java/org/springframework/data/release/git/VersionTagsUnitTests.java diff --git a/src/main/java/org/springframework/data/release/build/Repository.java b/src/main/java/org/springframework/data/release/build/Repository.java index 4603a7e..eb26c17 100644 --- a/src/main/java/org/springframework/data/release/build/Repository.java +++ b/src/main/java/org/springframework/data/release/build/Repository.java @@ -63,7 +63,7 @@ public class Repository { return "snapshot"; } - if (version.isMilestoneVersion()) { + if (version.isMilestoneVersion() || version.isReleaseCandidateVersion()) { return "milestone"; } diff --git a/src/main/java/org/springframework/data/release/git/GitCommands.java b/src/main/java/org/springframework/data/release/git/GitCommands.java index 444e631..a420990 100644 --- a/src/main/java/org/springframework/data/release/git/GitCommands.java +++ b/src/main/java/org/springframework/data/release/git/GitCommands.java @@ -21,16 +21,21 @@ import lombok.RequiredArgsConstructor; import java.util.Collections; import java.util.List; import java.util.Optional; +import java.util.concurrent.Executor; import java.util.stream.Collectors; import java.util.stream.Stream; import org.springframework.data.release.CliComponent; import org.springframework.data.release.TimedCommand; +import org.springframework.data.release.issues.Changelog; import org.springframework.data.release.issues.Ticket; +import org.springframework.data.release.issues.TicketReference; +import org.springframework.data.release.issues.Tickets; import org.springframework.data.release.model.Project; import org.springframework.data.release.model.ReleaseTrains; import org.springframework.data.release.model.Train; import org.springframework.data.release.model.TrainIteration; +import org.springframework.data.release.utils.ExecutionUtils; import org.springframework.shell.core.annotation.CliCommand; import org.springframework.shell.core.annotation.CliOption; import org.springframework.shell.support.table.Table; @@ -46,6 +51,7 @@ import org.springframework.util.StringUtils; class GitCommands extends TimedCommand { private final @NonNull GitOperations git; + private final @NonNull Executor executor; @CliCommand("git co-train") public void checkout(@CliOption(key = "", mandatory = true) Train train) throws Exception { @@ -71,6 +77,34 @@ class GitCommands extends TimedCommand { return StringUtils.collectionToDelimitedString(git.getTags(project).asList(), "\n"); } + @CliCommand("git previous") + public String previous(@CliOption(key = "", mandatory = true) TrainIteration iteration) throws Exception { + return git.getPreviousIteration(iteration).toString(); + } + + @CliCommand("git changelog") + public String changelog(@CliOption(key = "", mandatory = true) TrainIteration iteration) throws Exception { + + TrainIteration previousIteration = git.getPreviousIteration(iteration); + + return ExecutionUtils.runAndReturn(executor, iteration, moduleIteration -> { + + List ticketRefs = git.getTicketReferencesBetween(moduleIteration.getProject(), previousIteration, + iteration); + + return Changelog.of(moduleIteration, toTickets(ticketRefs)); + + }).stream() // + .map(Changelog::toString) // + .collect(Collectors.joining("\n")); + } + + private Tickets toTickets(List ticketRefs) { + + return new Tickets( + ticketRefs.stream().map(it -> new Ticket(it.getId(), it.getMessage(), "", null)).collect(Collectors.toList())); + } + /** * Resets all projects contained in the given {@link Train}. * diff --git a/src/main/java/org/springframework/data/release/git/GitOperations.java b/src/main/java/org/springframework/data/release/git/GitOperations.java index c34de4e..547c384 100644 --- a/src/main/java/org/springframework/data/release/git/GitOperations.java +++ b/src/main/java/org/springframework/data/release/git/GitOperations.java @@ -22,18 +22,12 @@ import lombok.experimental.FieldDefaults; import java.io.File; import java.io.IOException; import java.time.LocalDateTime; -import java.util.Arrays; -import java.util.Collection; -import java.util.Date; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.Optional; -import java.util.TimeZone; +import java.util.*; import java.util.concurrent.Executor; import java.util.concurrent.atomic.AtomicBoolean; import java.util.stream.Collectors; import java.util.stream.Stream; +import java.util.stream.StreamSupport; import org.eclipse.jgit.api.CheckoutCommand; import org.eclipse.jgit.api.CherryPickResult; @@ -63,15 +57,19 @@ import org.eclipse.jgit.transport.URIish; import org.springframework.data.release.io.Workspace; import org.springframework.data.release.issues.IssueTracker; import org.springframework.data.release.issues.Ticket; +import org.springframework.data.release.issues.TicketReference; import org.springframework.data.release.model.ArtifactVersion; import org.springframework.data.release.model.Gpg; import org.springframework.data.release.model.Iteration; import org.springframework.data.release.model.ModuleIteration; import org.springframework.data.release.model.Project; +import org.springframework.data.release.model.Projects; +import org.springframework.data.release.model.ReleaseTrains; import org.springframework.data.release.model.Train; import org.springframework.data.release.model.TrainIteration; import org.springframework.data.release.utils.ExecutionUtils; import org.springframework.data.release.utils.Logger; +import org.springframework.data.util.Pair; import org.springframework.data.util.Streamable; import org.springframework.plugin.core.PluginRegistry; import org.springframework.stereotype.Component; @@ -311,10 +309,40 @@ public class GitOperations { logger.log(project, "Project update done!"); } + /** + * Updates the given {@link Project} by fetching all tags. + * + * @param project must not be {@literal null}. + */ + public void fetchTags(Project project) { + + Assert.notNull(project, "Project must not be null!"); + + logger.log(project, "Updating project tags…"); + + GitProject gitProject = new GitProject(project, server); + String repositoryName = gitProject.getRepositoryName(); + + doWithGit(project, git -> { + + if (workspace.hasProjectDirectory(project)) { + + logger.log(project, "Found existing repository %s. Obtaining tags…", repositoryName); + logger.log(project, "git fetch --tags"); + git.fetch().setTagOpt(TagOpt.FETCH_TAGS).call(); + + } else { + clone(project); + } + }); + + logger.log(project, "Project tags update done!"); + } + public VersionTags getTags(Project project) { return doWithGit(project, git -> { - return new VersionTags(git.tagList().call().stream()// + return new VersionTags(project, git.tagList().call().stream()// .map(ref -> { RevCommit commit = getCommit(git.getRepository(), ref); @@ -375,6 +403,120 @@ public class GitOperations { }); } + /** + * Lookup the previous {@link TrainIteration} from existing tags. + * + * @param trainIteration must not be {@literal null}. + * @return + * @throws IllegalStateException if no previous iteration could be found. + */ + public TrainIteration getPreviousIteration(TrainIteration trainIteration) { + + Assert.notNull(trainIteration, "TrainIteration must not be null!"); + + if (trainIteration.getIteration().isMilestone() && trainIteration.getIteration().getIterationValue() == 1) { + + Train trainToUse = getPreviousTrain(trainIteration); + return trainToUse.getIteration(Iteration.GA); + } + + Optional mostRecentBefore = getTags(Projects.BUILD) // + .filter((tag, ti) -> ti.getTrain().equals(trainIteration.getTrain())) // + .find((tag, iteration) -> iteration.getIteration().compareTo(trainIteration.getIteration()) < 0, + Pair::getSecond); + + return mostRecentBefore.orElseThrow(() -> new IllegalStateException( + "Cannot determine previous iteration for " + trainIteration.getReleaseTrainNameAndVersion())); + } + + public List getTicketReferencesBetween(Project project, TrainIteration from, TrainIteration to) { + + VersionTags tags = getTags(project); + + List ticketReferences = doWithGit(project, git -> { + + Repository repo = git.getRepository(); + + ModuleIteration toModuleIteration = to.getModule(project); + ObjectId fromTag = resolveLowerBoundary(project, from, tags, repo); + ObjectId toTag = resolveUpperBoundary(toModuleIteration, tags, repo); + + Iterable commits = git.log().addRange(fromTag, toTag).call(); + + return StreamSupport.stream(commits.spliterator(), false).flatMap(it -> { + + ParsedCommitMessage message = ParsedCommitMessage.parse(it.getFullMessage()); + + if (message.getTicketReference() == null) { + logger.warn(toModuleIteration, "Commit %s does not refer to a ticket (%s)", it.getName(), + it.getShortMessage()); + return Stream.empty(); + } + + return Stream.of(message.getTicketReference()); + + }).collect(Collectors.toList()); + }); + + // make TicketReference unique + Set uniqueIds = new HashSet<>(); + List uniqueTicketReferences = new ArrayList<>(); + + for (TicketReference reference : ticketReferences) { + if (uniqueIds.add(reference.getId())) { + uniqueTicketReferences.add(reference); + } + } + + uniqueTicketReferences.sort(Comparator. naturalOrder().reversed()); + + return uniqueTicketReferences; + } + + protected ObjectId resolveLowerBoundary(Project project, TrainIteration iteration, VersionTags tags, Repository repo) + throws IOException { + + if (iteration.contains(project)) { + + Optional fromTag = tags.filter(iteration.getTrain()).findTag(iteration.getIteration()); + + Tag tag = fromTag.get(); + + return repo.parseCommit(repo.resolve(tag.getName())); + } + + return repo.resolve(getFirstCommit(repo)); + } + + protected ObjectId resolveUpperBoundary(ModuleIteration iteration, VersionTags tags, Repository repo) + throws IOException { + + Optional tag = tags.filter(iteration.getTrain()).findTag(iteration.getIteration()); + String rangeEnd = tag.map(Tag::getName).orElse(Branch.from(iteration).toString()); + return repo.parseCommit(repo.resolve(rangeEnd)); + } + + private static String getFirstCommit(Repository repo) throws IOException { + + try (RevWalk revWalk = new RevWalk(repo)) { + return revWalk.parseCommit(repo.resolve("master")).getName(); + } + } + + private static Train getPreviousTrain(TrainIteration trainIteration) { + + Train trainToUse = ReleaseTrains.CODD; + + for (Train train : ReleaseTrains.trains()) { + if (train.isBefore(trainIteration.getTrain())) { + trainToUse = train; + } else { + break; + } + } + return trainToUse; + } + private Stream getRemoteBranches(Project project) { return doWithGit(project, git -> { @@ -901,4 +1043,9 @@ public class GitOperations { return false; } } + + private static class VersionedIterations { + + + } } diff --git a/src/main/java/org/springframework/data/release/git/ParsedCommitMessage.java b/src/main/java/org/springframework/data/release/git/ParsedCommitMessage.java new file mode 100644 index 0000000..a9676ee --- /dev/null +++ b/src/main/java/org/springframework/data/release/git/ParsedCommitMessage.java @@ -0,0 +1,196 @@ +/* + * Copyright 2020 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.release.git; + +import lombok.Getter; +import lombok.ToString; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Optional; +import java.util.regex.MatchResult; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.springframework.data.release.issues.TicketReference; + +/** + * @author Mark Paluch + */ +@Getter +@ToString +class ParsedCommitMessage { + + private static final Pattern JIRA_TICKET = Pattern.compile("(?>\\[)?([A-Z]+[ ]?-[ ]?\\d+)(?>\\])?"); + private static final Pattern GITHUB_TICKET = Pattern.compile("((?>#|gh-)\\d+)"); + + private static final Pattern GITHUB_CLOSE_SYNTAX = Pattern.compile( + "(?>closes|closed|close|fixes|fixed|fix|resolves|resolved|resolve|see)[\\s:]*((?>#|gh-)\\d+)", + Pattern.CASE_INSENSITIVE | Pattern.MULTILINE); + + private static final Pattern GITHUB_PREFIX_SYNTAX = Pattern.compile("^(#\\d+)"); + + private static final Pattern A_TICKET = Pattern + .compile(String.format("(%s|%s)", JIRA_TICKET.pattern(), GITHUB_TICKET.pattern())); + + private static final Pattern ORIGINAL_PULL_REQUEST = Pattern + .compile("Original (?>pull request|PR|pullrequest):(?>\\s+)?" + A_TICKET.pattern(), Pattern.CASE_INSENSITIVE); + + private static final Pattern RELATED_TICKET = Pattern.compile( + "Related (?>tickets|ticket):(?>\\s+)?((" + A_TICKET.pattern() + "(?>[\\s,]*))+)", Pattern.CASE_INSENSITIVE); + + private final String summary; + private final String body; + + private final TicketReference ticketReference; + private final TicketReference pullRequestReference; + private final List relatedTickets; + + private ParsedCommitMessage(String summary, String body, TicketReference ticketReference, + TicketReference pullRequestReference, List relatedTickets) { + this.summary = summary; + this.body = body; + this.ticketReference = ticketReference; + this.pullRequestReference = pullRequestReference; + this.relatedTickets = relatedTickets; + } + + public static ParsedCommitMessage parse(String message) { + + int lineBreak = message.indexOf('\n'); + + String summary = null; + String body = null; + TicketReference ticketReference = null; + TicketReference pullRequestReference = null; + + if (lineBreak > -1) { + summary = message.substring(0, lineBreak).trim(); + body = message.substring(lineBreak + 1).trim(); + } else { + summary = message.trim(); + } + + // DATACASS-nnn - syntax + Matcher jiraMatcher = JIRA_TICKET.matcher(summary); + + if (jiraMatcher.find()) { + + MatchResult mr = jiraMatcher.toMatchResult(); + + // allow […] syntax and start of message syntax + if (mr.start(1) < 2) { + int summaryStart = findSummaryIndex(summary, mr.end(1)); + + ticketReference = new TicketReference(jiraMatcher.group(1).toUpperCase(Locale.ROOT), + summaryStart > -1 ? summary.substring(summaryStart) : summary, TicketReference.Style.Jira); + } + } + + // Closes (gh-nnn|#nnn) syntax + Matcher gitHubMatcher = GITHUB_CLOSE_SYNTAX.matcher(message); + + // #nnn syntax + Matcher gitHubPrefixMatcher = GITHUB_PREFIX_SYNTAX.matcher(summary); + + if (gitHubPrefixMatcher.find()) { + + MatchResult mr = gitHubPrefixMatcher.toMatchResult(); + if (mr.start(1) == 0) { + int summaryStart = findSummaryIndex(summary, mr.end(1)); + + ticketReference = new TicketReference(gitHubPrefixMatcher.group(1).toUpperCase(Locale.ROOT), + summaryStart > -1 ? summary.substring(summaryStart) : summary, TicketReference.Style.GitHub); + } + + } else { + if (gitHubMatcher.find()) { + ticketReference = new TicketReference(gitHubMatcher.group(1), summary, TicketReference.Style.GitHub); + } + } + + List relatedTickets = new ArrayList<>(); + if (body != null) { + Matcher relatedTicketsMatcher = RELATED_TICKET.matcher(body); + + if (relatedTicketsMatcher.find()) { + + String ticketIds[] = relatedTicketsMatcher.group(1).split(","); + + for (String ticketId : ticketIds) { + extractTicket(ticketId).ifPresent(relatedTickets::add); + } + } + + while (gitHubMatcher.find()) { + extractTicket(gitHubMatcher.group(1)).ifPresent(relatedTickets::add); + } + } + + if (body != null) { + + Matcher prMatcher = ORIGINAL_PULL_REQUEST.matcher(body); + + if (prMatcher.find()) { + + Optional pullRequest = extractTicket(prMatcher.group(1)); + + if (pullRequest.isPresent()) { + pullRequestReference = pullRequest.get(); + } + + if (ticketReference == null && pullRequestReference != null) { + ticketReference = pullRequestReference; + pullRequestReference = null; + } + } + } + + return new ParsedCommitMessage(summary, body, ticketReference, pullRequestReference, relatedTickets); + } + + protected static Optional extractTicket(String ticketId) { + + if (JIRA_TICKET.matcher(ticketId.trim()).matches()) { + return Optional.of(new TicketReference(ticketId.trim(), null, TicketReference.Style.Jira)); + } + + if (GITHUB_TICKET.matcher(ticketId.trim()).matches()) { + return Optional.of(new TicketReference(ticketId.trim(), null, TicketReference.Style.GitHub)); + } + + return Optional.empty(); + } + + private static int findSummaryIndex(String summary, int startAt) { + + int dash = summary.indexOf("- ", startAt); + + if (dash > -1) { + return dash + 2; + } + + int space = summary.indexOf(" ", startAt); + + if (space > -1) { + return space + 1; + } + + return -1; + } + +} diff --git a/src/main/java/org/springframework/data/release/git/Tag.java b/src/main/java/org/springframework/data/release/git/Tag.java index 0975385..2db4315 100644 --- a/src/main/java/org/springframework/data/release/git/Tag.java +++ b/src/main/java/org/springframework/data/release/git/Tag.java @@ -67,12 +67,16 @@ public class Tag implements Comparable { public Optional toArtifactVersion() { try { - return Optional.of(ArtifactVersion.of(getVersionSource())); + return Optional.of(getRequiredArtifactVersion()); } catch (IllegalArgumentException o_O) { return Optional.empty(); } } + public ArtifactVersion getRequiredArtifactVersion() { + return ArtifactVersion.of(getVersionSource()); + } + /** * Creates a new {@link Tag} for the given {@link ArtifactVersion} based on the format of the current one. * diff --git a/src/main/java/org/springframework/data/release/git/VersionTags.java b/src/main/java/org/springframework/data/release/git/VersionTags.java index a3de907..4a9d1d6 100644 --- a/src/main/java/org/springframework/data/release/git/VersionTags.java +++ b/src/main/java/org/springframework/data/release/git/VersionTags.java @@ -17,13 +17,26 @@ package org.springframework.data.release.git; import lombok.EqualsAndHashCode; +import java.util.Collection; +import java.util.Comparator; import java.util.Iterator; import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.TreeMap; +import java.util.function.BiPredicate; +import java.util.function.Function; import java.util.stream.Collectors; import org.springframework.data.release.model.ArtifactVersion; +import org.springframework.data.release.model.Iteration; import org.springframework.data.release.model.ModuleIteration; +import org.springframework.data.release.model.Project; import org.springframework.data.release.model.Projects; +import org.springframework.data.release.model.ReleaseTrains; +import org.springframework.data.release.model.Train; +import org.springframework.data.release.model.TrainIteration; +import org.springframework.data.util.Pair; import org.springframework.data.util.Streamable; import org.springframework.util.Assert; @@ -31,21 +44,26 @@ import org.springframework.util.Assert; * Value object to represent a collection of {@link Tag}s. * * @author Oliver Gierke + * @author Mark Paluch */ @EqualsAndHashCode public class VersionTags implements Streamable { + private final Project project; private final List tags; /** - * Creates a new {@link VersionTags} instance for the given {@link List} of {@link Tag}s. + * Creates a new {@link VersionTags} instance for the given {@link Project} and {@link List} of {@link Tag}s. * + * @param project must not be {@literal null}. * @param source must not be {@literal null}. */ - VersionTags(List source) { + VersionTags(Project project, Collection source) { + Assert.notNull(project, "Project must not be null!"); Assert.notNull(source, "Tags must not be null!"); + this.project = project; this.tags = source.stream().// filter(Tag::isVersionTag).// sorted().collect(Collectors.toList()); @@ -93,4 +111,140 @@ public class VersionTags implements Streamable { public Iterator iterator() { return tags.iterator(); } + + /** + * Create {@link VersionTags} that contains only tags from the given {@link Train}. + * + * @param train + * @return + */ + public VersionTags filter(Train train) { + return filter((tag, ti) -> ti.getTrain().equals(train)); + } + + /** + * Create {@link VersionTags} from a filtered subset of this {@link VersionTags}. + * + * @param predicate + * @return + */ + public VersionTags filter(BiPredicate predicate) { + + Map iterations = newMap(); + + withIterations().forEach((tag, trainIteration) -> { + + if (predicate.test(tag, trainIteration)) { + iterations.put(tag, trainIteration); + } + }); + + return new VersionTags(project, iterations.keySet()); + } + + Map withIterations() { + + Map iterations = newMap(); + + for (Tag tag : tags) { + + Optional artifactVersion = tag.toArtifactVersion(); + if (!artifactVersion.isPresent()) { + continue; + } + + ArtifactVersion version = artifactVersion.get(); + Optional iteration = toIteration(version); + + if (!iteration.isPresent()) { + continue; + } + + Optional ti = getTrainIteration(version, project, iteration.get()); + + ti.ifPresent(it -> iterations.put(tag, it)); + } + + return iterations; + } + + public Optional findMostRecentTrainIterationBefore(Iteration iterationToFind) { + return find((tag, iteration) -> iteration.getIteration().compareTo(iterationToFind) < 0, Pair::getSecond); + } + + public Optional findTag(Iteration iterationToFind) { + return find((tag, iteration) -> iteration.getIteration().compareTo(iterationToFind) == 0, Pair::getFirst); + } + + public Optional find(BiPredicate filter, + Function, T> resultExtractor) { + + for (Map.Entry entry : withIterations().entrySet()) { + + if (filter.test(entry.getKey(), entry.getValue())) { + return Optional.of(resultExtractor.apply(Pair.of(entry.getKey(), entry.getValue()))); + } + } + + return Optional.empty(); + } + + private static TreeMap newMap() { + return new TreeMap(Comparator.reverseOrder()); + } + + private static Optional getTrainIteration(ArtifactVersion version, Project project, + Iteration iteration) { + + // consider major version bump during ReleaseTrain + boolean minorIncrementedButTargetsNextTrain = false; + + for (Train train : ReleaseTrains.trains()) { + + TrainIteration ti = new TrainIteration(train, iteration); + + if (!ti.contains(project)) { + continue; + } + + if (minorIncrementedButTargetsNextTrain) { + return Optional.of(ti); + } + + ModuleIteration mi = ti.getModule(project); + + ArtifactVersion artifactVersion = ArtifactVersion.of(mi); + + if (artifactVersion.equals(version)) { + return Optional.of(ti); + } + + if (artifactVersion.getNextMinorVersion().equals(version)) { + minorIncrementedButTargetsNextTrain = true; + } + } + + return Optional.empty(); + } + + private static Optional toIteration(ArtifactVersion version) { + + if (version.isMilestoneVersion()) { + return Optional.of(Iteration.valueOf("M" + version.getLevel())); + } + + if (version.isReleaseCandidateVersion()) { + return Optional.of(Iteration.valueOf("RC" + version.getLevel())); + } + + if (version.isBugFixVersion()) { + return Optional.of(Iteration.valueOf("SR" + version.getLevel())); + } + + if (version.isReleaseVersion()) { + return Optional.of(Iteration.GA); + } + + return Optional.empty(); + } } diff --git a/src/main/java/org/springframework/data/release/issues/Changelog.java b/src/main/java/org/springframework/data/release/issues/Changelog.java index 419f870..33f50d3 100644 --- a/src/main/java/org/springframework/data/release/issues/Changelog.java +++ b/src/main/java/org/springframework/data/release/issues/Changelog.java @@ -61,8 +61,11 @@ public class Changelog { String summary = ticket.getSummary(); - builder.append("* ").append(ticket.getId()).append(" - ").append(summary.trim()); + builder.append("* ").append(ticket.getId()).append(" - ").append(summary != null ? summary.trim() : ""); + if (summary == null) { + System.out.println(); + } if (!summary.endsWith(".")) { builder.append("."); } diff --git a/src/main/java/org/springframework/data/release/issues/TicketReference.java b/src/main/java/org/springframework/data/release/issues/TicketReference.java new file mode 100644 index 0000000..c2c0665 --- /dev/null +++ b/src/main/java/org/springframework/data/release/issues/TicketReference.java @@ -0,0 +1,58 @@ +/* + * Copyright 2020 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.release.issues; + +import lombok.Value; + +/** + * @author Mark Paluch + */ +@Value +public class TicketReference implements Comparable { + + String id; + String message; + Style style; + + public TicketReference(String id, String message, Style style) { + this.id = normalize(id); + this.message = message; + this.style = style; + } + + private static String normalize(String id) { + + if (id.toLowerCase().startsWith("gh-")) { + return "#" + id.substring(3); + } + + return id.toUpperCase().replaceAll(" ", ""); + } + + @Override + public int compareTo(TicketReference o) { + + if (id.startsWith("#") && o.id.startsWith("#")) { + return Integer.compare(Integer.parseInt(id.substring(1)), Integer.parseInt(o.id.substring(1))); + } + + return id.compareToIgnoreCase(o.id); + } + + public enum Style { + GitHub, Jira; + } +} diff --git a/src/main/java/org/springframework/data/release/model/ArtifactVersion.java b/src/main/java/org/springframework/data/release/model/ArtifactVersion.java index 6baed9a..1919e02 100644 --- a/src/main/java/org/springframework/data/release/model/ArtifactVersion.java +++ b/src/main/java/org/springframework/data/release/model/ArtifactVersion.java @@ -15,10 +15,10 @@ */ package org.springframework.data.release.model; -import lombok.EqualsAndHashCode; import lombok.Getter; import lombok.With; +import java.util.Objects; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -30,7 +30,6 @@ import org.springframework.util.Assert; * @author Oliver Gierke * @author Mark Paluch */ -@EqualsAndHashCode public class ArtifactVersion implements Comparable { private static final Pattern PATTERN = Pattern @@ -40,12 +39,13 @@ public class ArtifactVersion implements Comparable { .compile("((\\d+)\\.(\\d+)(\\.\\d+)?)(-((RC\\d+)|(M\\d+)|(SNAPSHOT)))?"); private static final String RELEASE_SUFFIX = "RELEASE"; - private static final String MILESTONE_SUFFIX = "M\\d|RC\\d"; + private static final String MILESTONE_SUFFIX = "M\\d"; + private static final String RC_SUFFIX = "RC\\d"; private static final String SNAPSHOT_SUFFIX = "BUILD-SNAPSHOT"; private static final String SNAPSHOT_MODIFIER = "SNAPSHOT"; - private static final String VALID_SUFFIX = String.format("%s|%s|%s|-%s|-%s|-%s", RELEASE_SUFFIX, MILESTONE_SUFFIX, - SNAPSHOT_SUFFIX, RELEASE_SUFFIX, MILESTONE_SUFFIX, SNAPSHOT_MODIFIER); + private static final String VALID_SUFFIX = String.format("%s|%s|%s|%s|-%s|-%s|-%s", RELEASE_SUFFIX, MILESTONE_SUFFIX, + RC_SUFFIX, SNAPSHOT_SUFFIX, RELEASE_SUFFIX, MILESTONE_SUFFIX, SNAPSHOT_MODIFIER); private final @Getter Version version; private final @With boolean modifierFormat; @@ -175,6 +175,39 @@ public class ArtifactVersion implements Comparable { return suffix.matches(MILESTONE_SUFFIX); } + /** + * Returns whether the version is a RC version. + * + * @return + */ + public boolean isReleaseCandidateVersion() { + return suffix.matches(RC_SUFFIX); + } + + public int getLevel() { + + if (isMilestoneVersion()) { + Pattern pattern = Pattern.compile("M(\\d+)"); + Matcher matcher = pattern.matcher(suffix); + matcher.find(); + return Integer.parseInt(matcher.group(1)); + } + + if (isReleaseCandidateVersion()) { + Pattern pattern = Pattern.compile("RC(\\d+)"); + Matcher matcher = pattern.matcher(suffix); + matcher.find(); + return Integer.parseInt(matcher.group(1)); + } + + if (isBugFixVersion()) { + return version.getBugfix(); + } + + throw new IllegalStateException("Not a M/RC/SR release"); + + } + public boolean isSnapshotVersion() { return suffix.matches(SNAPSHOT_SUFFIX) || suffix.matches(SNAPSHOT_MODIFIER); } @@ -217,9 +250,16 @@ public class ArtifactVersion implements Comparable { return isSnapshotVersion() ? this : new ArtifactVersion(version, modifierFormat, getSnapshotSuffix()); } + /** + * @return the next minor version retaining the modifier and snapshot suffix. + */ + public ArtifactVersion getNextMinorVersion() { + return new ArtifactVersion(version.nextMinor(), modifierFormat, suffix); + } + public String getReleaseTrainSuffix() { - if (isSnapshotVersion() || isMilestoneVersion()) { + if (isSnapshotVersion() || isMilestoneVersion() || isReleaseCandidateVersion()) { return suffix; } @@ -250,7 +290,7 @@ public class ArtifactVersion implements Comparable { if (modifierFormat) { - if (isSnapshotVersion() || isMilestoneVersion()) { + if (isSnapshotVersion() || isMilestoneVersion() || isReleaseCandidateVersion()) { return String.format("%s-%s", version.toMajorMinorBugfix(), suffix); } @@ -276,4 +316,24 @@ public class ArtifactVersion implements Comparable { public String getGeneration() { return String.format("%s.%s.x", version.getMajor(), version.getMinor()); } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof ArtifactVersion)) { + return false; + } + ArtifactVersion other = (ArtifactVersion) o; + return version.equals(other.version) && isReleaseVersion() == other.isReleaseVersion() + && isSnapshotVersion() == other.isSnapshotVersion() && isMilestoneVersion() == other.isMilestoneVersion() + && isReleaseCandidateVersion() == other.isReleaseCandidateVersion(); + } + + @Override + public int hashCode() { + return Objects.hash(version, isReleaseVersion(), isSnapshotVersion(), isMilestoneVersion(), + isReleaseCandidateVersion()); + } } diff --git a/src/main/java/org/springframework/data/release/model/ReleaseTrains.java b/src/main/java/org/springframework/data/release/model/ReleaseTrains.java index 113dd53..52fb958 100644 --- a/src/main/java/org/springframework/data/release/model/ReleaseTrains.java +++ b/src/main/java/org/springframework/data/release/model/ReleaseTrains.java @@ -139,4 +139,8 @@ public class ReleaseTrains { .findFirst() // .orElse(null); } + + public static List trains() { + return TRAINS; + } } diff --git a/src/main/java/org/springframework/data/release/model/Train.java b/src/main/java/org/springframework/data/release/model/Train.java index e3c5709..b5e7b9e 100644 --- a/src/main/java/org/springframework/data/release/model/Train.java +++ b/src/main/java/org/springframework/data/release/model/Train.java @@ -46,9 +46,10 @@ import org.springframework.util.Assert; */ @Value @RequiredArgsConstructor(access = AccessLevel.PRIVATE) +@EqualsAndHashCode(of = "name") public class Train implements Streamable { - private final String name;; + private final String name; private final Modules modules; private @Nullable Version calver; private @With Iterations iterations; @@ -136,7 +137,7 @@ public class Train implements Streamable { Module module = getModule(project); - return new ModuleIteration(module, getIteration(iteration)); + return new ModuleIteration(module, doGetTrainIteration(iteration)); } public Iterable getModuleIterations(Iteration iteration) { @@ -154,7 +155,7 @@ public class Train implements Streamable { } public TrainIteration getIteration(String name) { - return new TrainIteration(this, iterations.getIterationByName(name)); + return doGetTrainIteration(iterations.getIterationByName(name)); } @@ -184,6 +185,31 @@ public class Train implements Streamable { return new Train(name, Modules.of(modules), calver, iterations, alwaysUseBranch); } + /** + * Check whether this train comes before {@link Train other}. + * + * @param other + * @return + */ + public boolean isBefore(Train other) { + + Assert.notNull(other, "Train must not be null"); + + if (usesCalver() && other.usesCalver()) { + return getCalver().compareTo(other.getCalver()) < 0; + } + + if (usesCalver()) { + return false; + } + + if (other.usesCalver()) { + return true; + } + + return getName().compareToIgnoreCase(other.getName()) < 0; + } + /* * (non-Javadoc) * @see java.lang.Object#toString() @@ -216,6 +242,10 @@ public class Train implements Streamable { Assert.isTrue(iterations.contains(iteration), String.format("Iteration %s is not a valid one for the configured iterations %s!", iteration, iterations)); + return doGetTrainIteration(iteration); + } + + protected TrainIteration doGetTrainIteration(Iteration iteration) { return new TrainIteration(this, iteration); } diff --git a/src/test/java/org/springframework/data/release/git/GitOperationsIntegrationTests.java b/src/test/java/org/springframework/data/release/git/GitOperationsIntegrationTests.java index 162de4b..56d8dbe 100644 --- a/src/test/java/org/springframework/data/release/git/GitOperationsIntegrationTests.java +++ b/src/test/java/org/springframework/data/release/git/GitOperationsIntegrationTests.java @@ -23,17 +23,24 @@ import java.io.IOException; import java.net.URL; import java.net.URLConnection; +import org.eclipse.jgit.api.Git; +import org.eclipse.jgit.internal.storage.file.FileRepository; +import org.eclipse.jgit.lib.ObjectId; +import org.eclipse.jgit.revwalk.RevCommit; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.release.AbstractIntegrationTests; +import org.springframework.data.release.model.Iteration; import org.springframework.data.release.model.ReleaseTrains; import org.springframework.data.release.model.TestReleaseTrains; +import org.springframework.data.release.model.TrainIteration; /** * @author Oliver Gierke + * @author Mark Paluch */ @Disabled class GitOperationsIntegrationTests extends AbstractIntegrationTests { @@ -75,4 +82,49 @@ class GitOperationsIntegrationTests extends AbstractIntegrationTests { void obtainsVersionTagsForRepoThatAlsoHasOtherTags() { gitOperations.getTags(MONGO_DB); } + + @Test + void shouldDeterminePreviousIterationFromGA() throws Exception { + + TrainIteration hopperRc1 = gitOperations.getPreviousIteration(ReleaseTrains.HOPPER.getIteration(Iteration.GA)); + + assertThat(hopperRc1.getTrain()).isEqualTo(ReleaseTrains.HOPPER); + assertThat(hopperRc1.getIteration()).isEqualTo(Iteration.RC1); + + TrainIteration hopperM1 = gitOperations.getPreviousIteration(ReleaseTrains.HOPPER.getIteration(Iteration.RC1)); + + assertThat(hopperM1.getTrain()).isEqualTo(ReleaseTrains.HOPPER); + assertThat(hopperM1.getIteration()).isEqualTo(Iteration.M1); + + TrainIteration hopperSR9 = gitOperations.getPreviousIteration(ReleaseTrains.HOPPER.getIteration(Iteration.SR10)); + + assertThat(hopperSR9.getTrain()).isEqualTo(ReleaseTrains.HOPPER); + assertThat(hopperSR9.getIteration()).isEqualTo(Iteration.SR9); + } + + @Test + void verify() throws Exception { + + // gitOperations.update(ReleaseTrains.HOPPER); + + try (Git git = new Git(new FileRepository("/Users/mpaluch/git/data/spring-data-elasticsearch/.git"))) { + + ObjectId resolve = git.getRepository().parseCommit(git.getRepository().resolve("3.0.0.RELEASE")); + Iterable master = git.log().addRange(resolve, git.getRepository().resolve("master")).call(); + + for (RevCommit revCommit : master) { + + ParsedCommitMessage message = ParsedCommitMessage.parse(revCommit.getFullMessage()); + + if (message.getTicketReference() == null || message.getSummary() == null) { + System.out.println(revCommit.getFullMessage()); + System.out.println(); + } + } + + } catch (Exception o_O) { + throw new RuntimeException(o_O); + } + } + } diff --git a/src/test/java/org/springframework/data/release/git/ParsedCommitMessageUnitTests.java b/src/test/java/org/springframework/data/release/git/ParsedCommitMessageUnitTests.java new file mode 100644 index 0000000..c95e3c9 --- /dev/null +++ b/src/test/java/org/springframework/data/release/git/ParsedCommitMessageUnitTests.java @@ -0,0 +1,102 @@ +/* + * Copyright 2020 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.release.git; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +/** + * Unit tests for {@link ParsedCommitMessage}. + * + * @author Mark Paluch + */ +class ParsedCommitMessageUnitTests { + + @Test + void shouldParsePlainCommit() { + + ParsedCommitMessage commit = ParsedCommitMessage.parse("hello world"); + assertThat(commit.getSummary()).isEqualTo("hello world"); + assertThat(commit.getBody()).isNull(); + } + + @ParameterizedTest + @ValueSource(strings = { "DATAFOO-123 - Hello World.", "DATAFOO-123 Hello World.", "[DATAFOO-123] - Hello World.", + "DATAFOO - 123 - Hello World." }) + void shouldParseOneLinerCommitWithJiraTicket(String commitMessage) { + + ParsedCommitMessage commit = ParsedCommitMessage.parse(commitMessage); + + assertThat(commit.getSummary()).endsWith("Hello World."); + assertThat(commit.getTicketReference().getId()).isEqualTo("DATAFOO-123"); + } + + @Test + void considersMultipleTicketsAsRelatedTickets() { + + ParsedCommitMessage commit = ParsedCommitMessage + .parse("Polishing\n\nAdd tests for write and delete.\n\nCloses gh-503\nCloses gh-511"); + + assertThat(commit.getTicketReference().getId()).isEqualTo("#503"); + assertThat(commit.getRelatedTickets()).hasSize(1); + assertThat(commit.getRelatedTickets().get(0).getId()).isEqualTo("#511"); + } + + @ParameterizedTest + @ValueSource(strings = { "close", "closes", "closed", "fix", "fixes", "fixed", "resolve", "resolves", "resolved" }) + void considersResolvesSyntax(String prefix) { + + ParsedCommitMessage commit = ParsedCommitMessage.parse("Polishing\n\n" + prefix + " gh-586."); + + assertThat(commit.getTicketReference().getId()).isEqualTo("#586"); + } + + @ParameterizedTest + @ValueSource(strings = { "Incorporate review feedback\n\nSee gh-574." }) + void shouldParseCommitWithSeeTicket(String commitMessage) { + + ParsedCommitMessage commit = ParsedCommitMessage.parse(commitMessage); + + assertThat(commit.getSummary()).isEqualTo("Incorporate review feedback"); + assertThat(commit.getTicketReference().getId()).isEqualTo("#574"); + } + + @Test + void shouldParseCommitWithRelatedTickets() { + + ParsedCommitMessage commit = ParsedCommitMessage + .parse("DATAFOO-123 - Hello World.\n Related tickets: DATACMNS-1438, DATACMNS-1461, DATACMNS-1609."); + + assertThat(commit.getTicketReference().getId()).isEqualTo("DATAFOO-123"); + assertThat(commit.getRelatedTickets()).hasSize(3); + } + + @ParameterizedTest + @ValueSource(strings = { "DATAFOO-456 - Hello World.\n Original pull request: #415.", + "DATAFOO-456 - Hello World.\n Original pr: #415." }) + void shouldParseCommitWithPullRequest(String commitMessage) { + + ParsedCommitMessage commit = ParsedCommitMessage.parse(commitMessage); + + assertThat(commit.getTicketReference().getId()).isEqualTo("DATAFOO-456"); + assertThat(commit.getPullRequestReference()).isNotNull(); + assertThat(commit.getPullRequestReference().getId()).isEqualTo("#415"); + } + +} diff --git a/src/test/java/org/springframework/data/release/git/VersionTagsUnitTests.java b/src/test/java/org/springframework/data/release/git/VersionTagsUnitTests.java new file mode 100644 index 0000000..605f5dd --- /dev/null +++ b/src/test/java/org/springframework/data/release/git/VersionTagsUnitTests.java @@ -0,0 +1,57 @@ +/* + * Copyright 2020 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.release.git; + +import static org.assertj.core.api.Assertions.*; + +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import org.junit.jupiter.api.Test; + +import org.springframework.data.release.model.Iteration; +import org.springframework.data.release.model.Projects; +import org.springframework.data.release.model.ReleaseTrains; + +/** + * Unit tests for {@link VersionTags}. + * + * @author Mark Paluch + */ +class VersionTagsUnitTests { + + @Test + void shouldDetermineTagForIteration() { + + VersionTags tags = new VersionTags(Projects.NEO4J, + Stream.of("5.3.0.RELEASE", "5.4.0-M1", "6.0.0-M2").map(Tag::of).collect(Collectors.toList())); + + assertThat(tags.filter(ReleaseTrains.NEUMANN).findTag(Iteration.GA)) + .hasValueSatisfying(actual -> assertThat(actual.getName()).isEqualTo("5.3.0.RELEASE")); + assertThat(tags.filter(ReleaseTrains.OCKHAM).findTag(Iteration.M2)) + .hasValueSatisfying(actual -> assertThat(actual.getName()).isEqualTo("6.0.0-M2")); + } + + @Test + void shouldDetermineTagForMajorVersionBumpDuringDevelopment() { + + VersionTags tags = new VersionTags(Projects.NEO4J, + Stream.of("5.3.0.RELEASE", "5.4.0-M1", "6.0.0-M2").map(Tag::of).collect(Collectors.toList())); + + assertThat(tags.filter(ReleaseTrains.OCKHAM).findTag(Iteration.M1)) + .hasValueSatisfying(actual -> assertThat(actual.getName()).isEqualTo("5.4.0-M1")); + } +} diff --git a/src/test/java/org/springframework/data/release/model/TrainsUnitTest.java b/src/test/java/org/springframework/data/release/model/TrainsUnitTest.java index 4e65778..53a6783 100644 --- a/src/test/java/org/springframework/data/release/model/TrainsUnitTest.java +++ b/src/test/java/org/springframework/data/release/model/TrainsUnitTest.java @@ -47,4 +47,23 @@ class TrainsUnitTest { assertThat(ReleaseTrains.PASCAL.getModule(Projects.BOM).getVersion().toMajorMinorBugfix()).isEqualTo("2021.0.0"); } + + @Test + void beforeShouldConsiderNonCalver() { + assertThat(ReleaseTrains.HOPPER.isBefore(ReleaseTrains.GOSLING)).isFalse(); + assertThat(ReleaseTrains.HOPPER.isBefore(ReleaseTrains.INGALLS)).isTrue(); + } + + @Test + void beforeShouldConsiderCalver() { + assertThat(ReleaseTrains.PASCAL.isBefore(ReleaseTrains.OCKHAM)).isFalse(); + assertThat(ReleaseTrains.OCKHAM.isBefore(ReleaseTrains.PASCAL)).isTrue(); + } + + @Test + void beforeShouldConsiderMixedCalver() { + assertThat(ReleaseTrains.PASCAL.isBefore(ReleaseTrains.INGALLS)).isFalse(); + assertThat(ReleaseTrains.INGALLS.isBefore(ReleaseTrains.PASCAL)).isTrue(); + } + }