diff --git a/projects/reactor/src/main/java/releaser/reactor/CfConfiguration.java b/projects/reactor/src/main/java/releaser/reactor/CfConfiguration.java new file mode 100644 index 00000000..2c8335ac --- /dev/null +++ b/projects/reactor/src/main/java/releaser/reactor/CfConfiguration.java @@ -0,0 +1,86 @@ +/* + * Copyright 2013-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 + * + * https://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 releaser.reactor; + +import org.cloudfoundry.client.CloudFoundryClient; +import org.cloudfoundry.doppler.DopplerClient; +import org.cloudfoundry.operations.DefaultCloudFoundryOperations; +import org.cloudfoundry.reactor.ConnectionContext; +import org.cloudfoundry.reactor.DefaultConnectionContext; +import org.cloudfoundry.reactor.TokenProvider; +import org.cloudfoundry.reactor.client.ReactorCloudFoundryClient; +import org.cloudfoundry.reactor.doppler.ReactorDopplerClient; +import org.cloudfoundry.reactor.tokenprovider.PasswordGrantTokenProvider; +import org.cloudfoundry.reactor.uaa.ReactorUaaClient; +import org.cloudfoundry.uaa.UaaClient; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; + +/** + * @author Simon Baslé + */ +@Configuration +@Profile("production") +class CfConfiguration { + + @Bean + DefaultConnectionContext connectionContext(@Value("${cf.apiHost}") String apiHost) { + return DefaultConnectionContext.builder().apiHost(apiHost).build(); + } + + @Bean + PasswordGrantTokenProvider tokenProvider(@Value("${cf.username}") String username, + @Value("${cf.password}") String password) { + return PasswordGrantTokenProvider.builder().password(password).username(username) + .build(); + } + + @Bean + ReactorCloudFoundryClient cloudFoundryClient(ConnectionContext connectionContext, + TokenProvider tokenProvider) { + return ReactorCloudFoundryClient.builder().connectionContext(connectionContext) + .tokenProvider(tokenProvider).build(); + } + + @Bean + ReactorDopplerClient dopplerClient(ConnectionContext connectionContext, + TokenProvider tokenProvider) { + return ReactorDopplerClient.builder().connectionContext(connectionContext) + .tokenProvider(tokenProvider).build(); + } + + @Bean + ReactorUaaClient uaaClient(ConnectionContext connectionContext, + TokenProvider tokenProvider) { + return ReactorUaaClient.builder().connectionContext(connectionContext) + .tokenProvider(tokenProvider).build(); + } + + @Bean + DefaultCloudFoundryOperations defaultCloudFoundryOperations( + CloudFoundryClient cloudFoundryClient, DopplerClient dopplerClient, + UaaClient uaaClient, @Value("${cf.organization}") String organizationId, + @Value("${cf.space}") String spaceId) { + return DefaultCloudFoundryOperations.builder() + .cloudFoundryClient(cloudFoundryClient).dopplerClient(dopplerClient) + .uaaClient(uaaClient).organization(organizationId).space(spaceId).build(); + } + +} diff --git a/projects/reactor/src/main/java/releaser/reactor/GenerateReleaseNotesTask.java b/projects/reactor/src/main/java/releaser/reactor/GenerateReleaseNotesTask.java new file mode 100644 index 00000000..0e19e0d6 --- /dev/null +++ b/projects/reactor/src/main/java/releaser/reactor/GenerateReleaseNotesTask.java @@ -0,0 +1,570 @@ +/* + * Copyright 2013-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 + * + * https://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 releaser.reactor; + +import java.io.IOException; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZonedDateTime; +import java.util.ArrayList; +import java.util.Collections; +import java.util.EnumMap; +import java.util.EnumSet; +import java.util.HashSet; +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.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +import javax.json.JsonObject; + +import com.jcabi.github.Coordinates; +import com.jcabi.github.Github; +import com.jcabi.github.Issue; +import com.jcabi.github.Issues; +import com.jcabi.github.Release; +import com.jcabi.github.Repo; +import com.jcabi.github.RepoCommit; +import com.jcabi.github.RepoCommits; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import releaser.internal.git.ProjectGitHandler; +import releaser.internal.git.SimpleCommit; +import releaser.internal.spring.Arguments; +import releaser.internal.tasks.DryRunReleaseReleaserTask; +import releaser.internal.tasks.ProjectPostReleaseReleaserTask; +import releaser.internal.tasks.release.PushChangesReleaseTask; +import releaser.internal.tech.BuildUnstableException; +import releaser.internal.tech.ExecutionResult; + +/** + * @author Simon Baslé + */ +public class GenerateReleaseNotesTask + implements ProjectPostReleaseReleaserTask, DryRunReleaseReleaserTask { + + private static final Logger log = LoggerFactory + .getLogger(GenerateReleaseNotesTask.class); + + private static final String NAME = "releaseNotes"; + + private static final String SHORTNAME = "rn"; + + private final Github github; + + private final ProjectGitHandler gitHandler; + + public GenerateReleaseNotesTask(Github github, ProjectGitHandler gitHandler) { + this.github = github; + this.gitHandler = gitHandler; + } + + @Override + public int getOrder() { + return PushChangesReleaseTask.ORDER + 5; + } + + @Override + public String name() { + return NAME; + } + + @Override + public String shortName() { + return SHORTNAME; + } + + @Override + public String header() { + return "Generating release notes"; + } + + @Override + public String description() { + return "Generates the release notes as a Github release draft, from issues and commits"; + } + + @Override + public ExecutionResult runTask(Arguments args) + throws BuildUnstableException, RuntimeException { + if (args.versionFromBom.isSnapshot()) { + log.info("\nWon't generate notes for a snapshot version"); + return ExecutionResult.success(); + } + + Repo repo = github.repos().get(new Coordinates.Simple( + args.properties.getGit().getOrgName(), args.projectToRun.name())); + + String releaseTag = "v" + args.versionFromBom.version; + if (args.options.dryRun != Boolean.TRUE) { + Optional maybeTagSha1 = gitHandler.findTagSha1(args.project, + releaseTag); + if (maybeTagSha1.isPresent()) { + String tagSha1 = maybeTagSha1.get(); + try { + // call json() to trigger fetch of gh tag by SHA1 + repo.git().references().get("tags/" + tagSha1).json(); + } + catch (IOException e) { + return ExecutionResult.failure(new IllegalStateException( + "Shouldn't create a release if tag " + releaseTag + " (sha1=" + + tagSha1 + ") not visible in Github", + e)); + } + } + else { + return ExecutionResult.failure(new IllegalStateException( + "Attempting to draft release note but tag not found in repository: " + + releaseTag)); + } + } + + Issues issuesClient = repo.issues(); + EnumMap> entries = new EnumMap<>(Type.class); + + String fromVersionTag = "v" + args.projectToRun.originalVersion.version; + String toVersionTag = "v" + args.versionFromBom.version; + + if (Boolean.TRUE == args.options.interactive) { + log.info("\nForce the from in log range if needed [{}]: ", fromVersionTag); + String modifiedFrom = System.console().readLine(); + if (!modifiedFrom.trim().isEmpty()) { + fromVersionTag = modifiedFrom; + } + } + log.info("Fetching the log for range {}..{}", fromVersionTag, toVersionTag); + + // gather commits. we use tags in the format `vVERSION` + final List revCommits = gitHandler.commitsBetween(args.project, + fromVersionTag, toVersionTag); + + // parse and link to issues if possible, determining type + for (SimpleCommit revCommit : revCommits) { + ChangelogEntry entry = parseChangeLogEntry(issuesClient, revCommit); + + for (Type type : entry.types) { + entries.computeIfAbsent(type, t -> new ArrayList<>()).add(entry); + } + } + + // generate the notes + String notes = generateNotes(args, entries, + extractContributorMentions(repo, revCommits)); + + if (args.options.dryRun != null && args.options.dryRun) { + // print out + log.info("[Dry-Run] Generated release notes:"); + log.info("\n\n" + notes + "\n\n"); + return ExecutionResult.success(); + } + else { + // WARNING: double check the tag actually exists, otherwise this will create a + // tag :( The verification is done early in the task to avoid fetching + // commits/issues + + Release.Smart draftRelease = null; + JsonObject draftReleaseJson = null; + try { + // attempt to find gh release with this tag. stop at 2 month old releases + Instant oldestReleaseToConsider = ZonedDateTime.now().minusMonths(2) + .toInstant(); + for (Release release : repo.releases().iterate()) { + JsonObject releaseJson = release.json(); + // seems the created release has a wrong tag "untagged-xxxxx", we also + // look at name + if (releaseJson.getString("tag_name").equals(releaseTag) + || releaseJson.getString("name").equals(releaseTag)) { + if (!releaseJson.getBoolean("draft")) { + return ExecutionResult.failure(new IllegalStateException( + "Release already exists for tag " + releaseTag)); + } + else { + draftRelease = new Release.Smart(release); + draftReleaseJson = releaseJson; + break; + } + } + // we didn't find a matching release. don't go too far back in time! + String publishedAtJson = releaseJson.getString("published_at"); + Instant publishedAt = oldestReleaseToConsider.minusSeconds(1); + if (publishedAtJson != null) { + publishedAt = ZonedDateTime.parse(publishedAtJson).toInstant(); + } + if (publishedAt.isBefore(oldestReleaseToConsider)) { + log.info("OK, didn't find a release matching tag " + releaseTag + + " within the last 2 month, stopping there"); + break; + } + } + } + catch (Throwable e) { + return ExecutionResult.failure(new IllegalStateException( + "Unable to try to match github releases with tag " + releaseTag, + e)); + } + + if (draftReleaseJson == null) { + // create a draft release for the tag + try { + Release.Smart release = new Release.Smart( + repo.releases().create(releaseTag)); + release.draft(true); + release.tag(releaseTag); + release.name(releaseTag); + release.body(notes); + if (args.versionFromBom.isMilestone() || args.versionFromBom.isRc()) { + release.prerelease(true); + } + return ExecutionResult.success(); + } + catch (IOException e) { + return ExecutionResult.failure(e); + } + } + else { + // update the existing draft + try { + // edit the release + String oldAndNewNotes = draftReleaseJson.getString("body") + + "\n\n----\nNew draft added " + + LocalDateTime.now().toString() + "\n----\n" + notes; + draftRelease.body(oldAndNewNotes); + return ExecutionResult.success(); + } + catch (IOException e) { + return ExecutionResult.failure(e); + } + } + } + } + + /** + * Generate the release notes. + */ + protected String generateNotes(Arguments args, + EnumMap> entries, + List contributorGithubMentions) { + // TODO use a template? handlebars ! + StringBuilder notes = new StringBuilder().append(args.projectToRun.name()) + .append(" `").append(args.versionFromBom.version) + .append("` is part of **`").append(args.releaseTrain().version) + .append("` Release Train**."); + + notes.append("\n\n## :warning: Update considerations and deprecations"); + for (ChangelogEntry noteworthy : entries.getOrDefault(Type.NOTEWORTHY, + Collections.emptyList())) { + notes.append("\n - ").append(noteworthy.description); + for (String issueTitle : noteworthy.associatedIssueLinksAndTitles.values()) { + notes.append("\n\t - ").append(cleanupShortMessage(issueTitle)); + } + } + + notes.append("\n\n## :sparkles: New features and improvements"); + for (ChangelogEntry feature : entries.getOrDefault(Type.FEATURE, + Collections.emptyList())) { + notes.append("\n - ").append(feature.description); + for (String issueTitle : feature.associatedIssueLinksAndTitles.values()) { + notes.append("\n\t - ").append(cleanupShortMessage(issueTitle)); + } + } + + notes.append("\n\n## :beetle: Bug fixes"); + for (ChangelogEntry bug : entries.getOrDefault(Type.BUG, + Collections.emptyList())) { + notes.append("\n - ").append(bug.description); + for (String issueTitle : bug.associatedIssueLinksAndTitles.values()) { + notes.append("\n\t - ").append(cleanupShortMessage(issueTitle)); + } + } + + notes.append("\n\n## :book: Documentation, Tests and Build"); + for (ChangelogEntry misc : entries.getOrDefault(Type.DOC_MISC, + Collections.emptyList())) { + notes.append("\n - ").append(misc.description); + for (String issueTitle : misc.associatedIssueLinksAndTitles.values()) { + notes.append("\n\t - ").append(cleanupShortMessage(issueTitle)); + } + } + + notes.append("\n\n## **TODO DISPATCH THESE**"); + for (ChangelogEntry unclassified : entries.getOrDefault(Type.UNCLASSIFIED, + Collections.emptyList())) { + notes.append("\n - ").append(unclassified.description); + for (String issueTitle : unclassified.associatedIssueLinksAndTitles + .values()) { + notes.append("\n\t - ").append(cleanupShortMessage(issueTitle)); + } + } + + // contributors + notes.append( + "\n\n## :+1: Thanks to the following contributors that also participated to this release\n"); + notes.append(String.join(", ", contributorGithubMentions)); + + return notes.toString(); + } + + /** + * Categorize into a {@link Type} from a set of labels and the commit's short message + * (eg. in case of specific message prefix). + * @param labels the set of labels found for issues referenced in the commit + * @param shortMessage the commit's title/short message + * @return a {@link Type} categorizing the commit, or {@link Type#UNCLASSIFIED} if not + * clear + */ + protected EnumSet extractTypes(Set labels, String shortMessage) { + List types = new ArrayList<>(); + for (String label : labels) { + switch (label) { + case "type/bug": + types.add(Type.BUG); + break; + case "type/enhancement": + types.add(Type.FEATURE); + break; + case "type/documentation": + case "type/dependency-upgrade": + case "type/chores": + types.add(Type.DOC_MISC); + break; + default: + if (label.startsWith("warn/")) { + types.add(Type.NOTEWORTHY); + } + break; + } + } + + if (shortMessage.startsWith("[build]") || shortMessage.startsWith("[polish]") + || shortMessage.startsWith("[doc]")) { + types.add(Type.DOC_MISC); + } + + if (types.isEmpty()) { + return EnumSet.of(Type.UNCLASSIFIED); + } + return EnumSet.copyOf(types); + } + + protected String commitToGithubMention(RepoCommits commitsClient, + SimpleCommit revCommit) { + RepoCommit dumbCommit = commitsClient.get(revCommit.fullSha1); + RepoCommit.Smart smartCommit = new RepoCommit.Smart(dumbCommit); + try { + JsonObject commitJson = smartCommit.json(); + return "@" + commitJson.getJsonObject("author").getString("login"); + } + catch (IOException e) { + return null; + } + } + + /** + * Extract at-mentions of contributors, given a list of commits (will fetch + * contributor login from github). The list is deduplicated and sorted in + * case-insensitive alphabetical order. + */ + List extractContributorMentions(Repo repo, List revCommits) { + RepoCommits commitsClient = repo.commits(); + return revCommits.stream().map(c -> commitToGithubMention(commitsClient, c)) + .filter(Objects::nonNull).distinct().sorted(String.CASE_INSENSITIVE_ORDER) + .collect(Collectors.toList()); + } + + /** + * Clean up a short message, removing issue links in suffix and pr prefix forms. + */ + protected String cleanupShortMessage(String shortMessage) { + final Matcher shortMessageMatcher = SHORT_MESSAGE.matcher(shortMessage); + if (shortMessageMatcher.matches()) { + return shortMessageMatcher.group(1).trim(); + } + return shortMessage; + } + + /** + * Clean up the short message of a {@link SimpleCommit}, ie detect message prefix like + * "fix #123 Something something (#124)". The prefix up to the issue link is removed, + * and so is the PR reference at the end. + */ + protected String cleanupShortMessage(SimpleCommit commit) { + if (!commit.isMergeCommit) { + return cleanupShortMessage(commit.title); + } + return commit.title; + } + + /** + * Extract issue numbers from links like #123 in the whole commit message, in order. + */ + protected Set extractIssueNumbers(SimpleCommit commit) { + Set issueNumbers = new LinkedHashSet<>(); + Matcher titleIssueMatcher = ISSUE_REF.matcher(commit.title); + while (titleIssueMatcher.find()) { + issueNumbers.add(Integer.valueOf(titleIssueMatcher.group(1))); + } + Matcher bodyIssueMatcher = ISSUE_REF.matcher(commit.fullMessage); + while (bodyIssueMatcher.find()) { + issueNumbers.add(Integer.valueOf(bodyIssueMatcher.group(1))); + } + return issueNumbers; + } + + /** + * From the set of issue numbers (see {@link #extractIssueNumbers(SimpleCommit)}), + * extract github client Issue objects and fetch labels and titles from these issues. + * The labels and titles are injected in a {@link Set} of labels and {@link Map} of + * hashtag issue links to issue titles. + */ + protected void fetchIssueLabelsAndTitles(Issues issueClient, + Set issueNumbers, Set labelsTarget, + Map referencedIssuesTarget) { + issueNumbers.forEach(i -> { + try { + // avoid "Smart" issue here as it makes further requests to the server :( + // we have everything handy in the JSON + Issue issue = issueClient.get(i); + JsonObject issueJson = issue.json(); + issueJson.getJsonArray("labels").forEach(label -> labelsTarget + .add(((JsonObject) label).getString("name"))); + referencedIssuesTarget.put("#" + issueJson.getInt("number"), + issueJson.getString("title")); + } + catch (Exception e) { + log.warn("Could not fetch issue information for #" + i, e); + } + }); + } + + /** + * Extract issue information from the commit and generate the {@link ChangelogEntry}. + */ + protected ChangelogEntry parseChangeLogEntry(Issues issueClient, + SimpleCommit commit) { + String cleanShortMessage = cleanupShortMessage(commit); + Set issueNumbers = extractIssueNumbers(commit); + + Set labelsTarget = new HashSet<>(); + Map referencedIssuesTarget = new LinkedHashMap<>(); + fetchIssueLabelsAndTitles(issueClient, issueNumbers, labelsTarget, + referencedIssuesTarget); + + EnumSet types = extractTypes(labelsTarget, commit.title); + + return new ChangelogEntry(types, commit.abbreviatedSha1, cleanShortMessage, + referencedIssuesTarget); + } + + /* + * Pattern specific to reactor commit message convention: `fix #123 Some short + * description (#4567)`. We want to capture the middle part and use that in the + * release note. The `fix` part is optional, but occurs most of the time unless there + * is no issue associated. The `(#xxx)` part is also optional and much more rare, + * reflecting the PR number in case there has been extensive review/discussion in that + * PR. Both references would be caught by ISSUE_REF pattern below. + */ + static Pattern SHORT_MESSAGE = Pattern + .compile("(?:[a-zA-Z]+ #[0-9]+)?([^\\(]+)(?:\\(#[0-9]+\\))?"); + + /** + * A {@link Pattern} to find issue/pr numbers in a commit message, by detecting + * {@code #}. + */ + protected static final Pattern ISSUE_REF = Pattern.compile("#([0-9]+)"); + + /** + * An enum of the 3 types of changes recognized by the changelog template, plus one + * type for noteworthy items (eg. breaking changes) and one additional type for the + * commits that couldn't be classified (eg. no relevant prefix and no associated + * issue). + */ + protected enum Type { + + NOTEWORTHY, BUG, FEATURE, DOC_MISC, UNCLASSIFIED; + + } + + /** + * Representation of an entry in the release notes changelog. + */ + protected static final class ChangelogEntry { + + /** + * The set of types of the change, or singleton {@link Type#UNCLASSIFIED} if + * unknown. + */ + public final EnumSet types; + + /** + * The human-friendly description to put in the release note for that commit. + */ + public final String description; + + /** + * The human-friendly SHA1 (typically abbreviated to 8 chars) to reference the + * commit in the release note if there is no associated issue. + */ + public final String commitSha1; + + /** + * An ordered map of github-compatible issue links (including the pound sign) and + * their titles, to be added to the draft as possible alternative descriptions. + * The links are automatically added to the end of the {@link #description} by the + * {@link #ChangelogEntry(EnumSet, String, String, Map)} constructor}. + */ + public final Map associatedIssueLinksAndTitles; + + /** + * @param types the set of {@link Type} of the commit + * @param commitSha1 the commit's human-friendly sha1 (can and should be the + * abbreviated form) + * @param cleanShortMessage the commit's cleaned up title, as should be displayed + * in the release note entry + * @param associatedIssueLinksAndTitles the commit's associated + * issue(s)/pull-request(s), in order of importance. If non-empty, each issue will + * be mentioned at the end of the entry, and the issue titles will be added to the + * {@link #description} on their own lines as potential alternative descriptions + */ + ChangelogEntry(EnumSet types, String commitSha1, String cleanShortMessage, + Map associatedIssueLinksAndTitles) { + this.types = types; + this.commitSha1 = commitSha1; + this.associatedIssueLinksAndTitles = associatedIssueLinksAndTitles; + + if (this.associatedIssueLinksAndTitles.isEmpty()) { + description = cleanShortMessage + " (" + commitSha1 + ")"; + } + else { + this.description = cleanShortMessage + " (" + + String.join(", ", this.associatedIssueLinksAndTitles.keySet()) + + ")"; + } + } + + public String toString() { + return this.description; + } + + } + +} diff --git a/projects/reactor/src/main/java/releaser/reactor/ReactorConfiguration.java b/projects/reactor/src/main/java/releaser/reactor/ReactorConfiguration.java index 434360a1..43b86b74 100644 --- a/projects/reactor/src/main/java/releaser/reactor/ReactorConfiguration.java +++ b/projects/reactor/src/main/java/releaser/reactor/ReactorConfiguration.java @@ -16,30 +16,26 @@ package releaser.reactor; -import org.cloudfoundry.client.CloudFoundryClient; -import org.cloudfoundry.doppler.DopplerClient; +import com.jcabi.github.Github; +import com.jcabi.github.RtGithub; +import com.jcabi.http.wire.RetryWire; import org.cloudfoundry.operations.CloudFoundryOperations; -import org.cloudfoundry.operations.DefaultCloudFoundryOperations; -import org.cloudfoundry.reactor.ConnectionContext; -import org.cloudfoundry.reactor.DefaultConnectionContext; -import org.cloudfoundry.reactor.TokenProvider; -import org.cloudfoundry.reactor.client.ReactorCloudFoundryClient; -import org.cloudfoundry.reactor.doppler.ReactorDopplerClient; -import org.cloudfoundry.reactor.tokenprovider.PasswordGrantTokenProvider; -import org.cloudfoundry.reactor.uaa.ReactorUaaClient; -import org.cloudfoundry.uaa.UaaClient; import releaser.internal.Releaser; +import releaser.internal.ReleaserProperties; +import releaser.internal.git.ProjectGitHandler; +import org.springframework.beans.factory.BeanInitializationException; import org.springframework.beans.factory.annotation.Value; -import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; +import org.springframework.util.StringUtils; @Configuration +@Profile("production") class ReactorConfiguration { @Bean - @ConditionalOnMissingBean CfClient cfClient(CloudFoundryOperations cloudFoundryOperations) { return new CfClient(cloudFoundryOperations); } @@ -50,52 +46,23 @@ class ReactorConfiguration { return new RestartSiteProjectPostReleaseTask(releaser, cfClient, reactorAppName); } -} - -@Configuration -class CfConfiguration { - @Bean - DefaultConnectionContext connectionContext(@Value("${cf.apiHost}") String apiHost) { - return DefaultConnectionContext.builder().apiHost(apiHost).build(); + Github githubClient(ReleaserProperties properties) { + if (!StringUtils.hasText(properties.getGit().getOauthToken())) { + throw new BeanInitializationException( + "You must set the value of the OAuth token. You can do it " + + "either via the command line [--releaser.git.oauth-token=...] " + + "or put it as an env variable in [~/.bashrc] or " + + "[~/.zshrc] e.g. [export RELEASER_GIT_OAUTH_TOKEN=...]"); + } + return new RtGithub(new RtGithub(properties.getGit().getOauthToken()).entry() + .through(RetryWire.class)); } @Bean - PasswordGrantTokenProvider tokenProvider(@Value("${cf.username}") String username, - @Value("${cf.password}") String password) { - return PasswordGrantTokenProvider.builder().password(password).username(username) - .build(); - } - - @Bean - ReactorCloudFoundryClient cloudFoundryClient(ConnectionContext connectionContext, - TokenProvider tokenProvider) { - return ReactorCloudFoundryClient.builder().connectionContext(connectionContext) - .tokenProvider(tokenProvider).build(); - } - - @Bean - ReactorDopplerClient dopplerClient(ConnectionContext connectionContext, - TokenProvider tokenProvider) { - return ReactorDopplerClient.builder().connectionContext(connectionContext) - .tokenProvider(tokenProvider).build(); - } - - @Bean - ReactorUaaClient uaaClient(ConnectionContext connectionContext, - TokenProvider tokenProvider) { - return ReactorUaaClient.builder().connectionContext(connectionContext) - .tokenProvider(tokenProvider).build(); - } - - @Bean - DefaultCloudFoundryOperations defaultCloudFoundryOperations( - CloudFoundryClient cloudFoundryClient, DopplerClient dopplerClient, - UaaClient uaaClient, @Value("${cf.organization}") String organizationId, - @Value("${cf.space}") String spaceId) { - return DefaultCloudFoundryOperations.builder() - .cloudFoundryClient(cloudFoundryClient).dopplerClient(dopplerClient) - .uaaClient(uaaClient).organization(organizationId).space(spaceId).build(); + GenerateReleaseNotesTask releaseNotesTask(Github github, + ProjectGitHandler gitHandler) { + return new GenerateReleaseNotesTask(github, gitHandler); } } diff --git a/projects/reactor/src/main/java/releaser/reactor/RestartSiteProjectPostReleaseTask.java b/projects/reactor/src/main/java/releaser/reactor/RestartSiteProjectPostReleaseTask.java index dc3f6199..f5e51b95 100644 --- a/projects/reactor/src/main/java/releaser/reactor/RestartSiteProjectPostReleaseTask.java +++ b/projects/reactor/src/main/java/releaser/reactor/RestartSiteProjectPostReleaseTask.java @@ -20,12 +20,10 @@ import org.cloudfoundry.operations.CloudFoundryOperations; import org.cloudfoundry.operations.applications.RestartApplicationRequest; import releaser.internal.Releaser; import releaser.internal.spring.Arguments; -import releaser.internal.tasks.DryRunReleaseReleaserTask; import releaser.internal.tasks.release.PublishDocsReleaseTask; import releaser.internal.tech.ExecutionResult; -public class RestartSiteProjectPostReleaseTask extends PublishDocsReleaseTask - implements DryRunReleaseReleaserTask { +public class RestartSiteProjectPostReleaseTask extends PublishDocsReleaseTask { private static final String REACTOR_CORE_PROJECT_NAME = "reactor-core"; diff --git a/projects/reactor/src/main/resources/application.yml b/projects/reactor/src/main/resources/application.yml index 4eab6673..ee597ebb 100644 --- a/projects/reactor/src/main/resources/application.yml +++ b/projects/reactor/src/main/resources/application.yml @@ -6,11 +6,16 @@ spring: jackson: deserialization: FAIL_ON_UNKNOWN_PROPERTIES: true + profiles: + active: production releaser: git: - org-name: spring-cloud + org-name: reactor release-train-bom-url: https://github.com/reactor/reactor fetch-versions-from-git: true + gradle: + build-command: "./gradlew clean bumpVersionsInReadme build publishToMavenLocal --console=plain -PnextVersion={{nextVersion}} -PoldVersion={{oldVersion}} -PcurrentVersion={{version}} {{systemProps}}" + meta-release: release-train-project-name: reactor release-train-dependency-names: @@ -20,4 +25,14 @@ cf: organization: FrameworksAndRuntimes space: Reactor reactorAppName: projectreactor - apiHost: api.run.pivotal.io \ No newline at end of file + apiHost: api.run.pivotal.io + +# Boot values to be passed via env/command line: +# cf.username +# cf.password + +# Gradle project properties to be passed to deploy task somehow +# artifactory_publish_contextUrl +# artifactory_publish_repoKey +# artifactory_publish_username +# artifactory_publish_password \ No newline at end of file diff --git a/projects/reactor/src/test/java/releaser/ReleaserApplicationTests.java b/projects/reactor/src/test/java/releaser/ReleaserApplicationTests.java index df77e517..9f880058 100644 --- a/projects/reactor/src/test/java/releaser/ReleaserApplicationTests.java +++ b/projects/reactor/src/test/java/releaser/ReleaserApplicationTests.java @@ -21,24 +21,21 @@ import java.util.List; import java.util.Map; import org.junit.jupiter.api.Test; -import org.mockito.Mockito; -import releaser.internal.options.Parser; -import releaser.internal.spring.ExecutionResultHandler; -import releaser.internal.spring.SpringReleaser; import releaser.internal.tasks.DryRunReleaseReleaserTask; import releaser.internal.tasks.ReleaserTask; +import releaser.internal.tasks.release.PublishDocsReleaseTask; +import releaser.reactor.GenerateReleaseNotesTask; +import releaser.reactor.RestartSiteProjectPostReleaseTask; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.context.ApplicationContext; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; import org.springframework.core.annotation.AnnotationAwareOrderComparator; import org.springframework.test.context.ActiveProfiles; -@SpringBootTest( - classes = { ReleaserApplicationTests.Config.class, ReleaserApplication.class }, - properties = { "releaser.sagan.update-sagan=false" }) +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest(classes = { ReleaserApplication.class }) @ActiveProfiles("test") class ReleaserApplicationTests { @@ -51,31 +48,27 @@ class ReleaserApplicationTests { } @Test - void should_load_reactor_version_of_publish_docs() { + void should_load_generate_release_notes_in_dry_run() { Map beans = context .getBeansOfType(DryRunReleaseReleaserTask.class); List inOrder = new LinkedList<>(beans.values()); inOrder.sort(AnnotationAwareOrderComparator.INSTANCE); - System.out.println(inOrder); + + assertThat(inOrder).anySatisfy( + task -> assertThat(task).isInstanceOf(GenerateReleaseNotesTask.class)); } - @Configuration - static class Config { + @Test + void should_load_restart_site() { + Map beans = context.getBeansOfType(ReleaserTask.class); + List inOrder = new LinkedList<>(beans.values()); + inOrder.sort(AnnotationAwareOrderComparator.INSTANCE); - @Bean - SpringReleaser mockReleaser() { - return Mockito.mock(SpringReleaser.class); - } - - @Bean - ExecutionResultHandler mockExecutionResultHandler() { - return Mockito.mock(ExecutionResultHandler.class); - } - - @Bean - Parser mockParser() { - return Mockito.mock(Parser.class); - } + assertThat(inOrder).anySatisfy(task -> assertThat(task) + .isInstanceOf(RestartSiteProjectPostReleaseTask.class)); + assertThat(inOrder).noneSatisfy( + task -> assertThat(task).isInstanceOf(PublishDocsReleaseTask.class) + .isNotInstanceOf(RestartSiteProjectPostReleaseTask.class)); } diff --git a/projects/reactor/src/test/java/releaser/reactor/CfTestConfiguration.java b/projects/reactor/src/test/java/releaser/reactor/CfTestConfiguration.java new file mode 100644 index 00000000..35633bcb --- /dev/null +++ b/projects/reactor/src/test/java/releaser/reactor/CfTestConfiguration.java @@ -0,0 +1,38 @@ +/* + * Copyright 2013-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 + * + * https://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 releaser.reactor; + +import org.cloudfoundry.operations.CloudFoundryOperations; +import org.mockito.BDDMockito; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; + +/** + * @author Simon Baslé + */ +@Configuration +@Profile("test") +class CfTestConfiguration { + + @Bean + CloudFoundryOperations mockCloudFoundryOperations() { + return BDDMockito.mock(CloudFoundryOperations.class); + } + +} diff --git a/projects/reactor/src/test/java/releaser/reactor/GenerateReleaseNotesTaskTest.java b/projects/reactor/src/test/java/releaser/reactor/GenerateReleaseNotesTaskTest.java new file mode 100644 index 00000000..fd2f7063 --- /dev/null +++ b/projects/reactor/src/test/java/releaser/reactor/GenerateReleaseNotesTaskTest.java @@ -0,0 +1,299 @@ +/* + * Copyright 2013-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 + * + * https://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 releaser.reactor; + +import java.io.IOException; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Set; + +import com.jcabi.github.Issue; +import com.jcabi.github.Issues; +import com.jcabi.github.mock.MkGithub; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.mockito.stubbing.VoidAnswer4; +import releaser.internal.git.SimpleCommit; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.mockito.AdditionalAnswers.answerVoid; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doNothing; +import static releaser.reactor.GenerateReleaseNotesTask.Type; + +/** + * @author Simon Baslé + */ +@SpringBootTest +@ActiveProfiles("test") +class GenerateReleaseNotesTaskTest { + + @Autowired + GenerateReleaseNotesTask task; + + @Autowired + MkGithub githubClient; + + @Test + void extractTypeFromLabel() { + assertThat(task.extractTypes(Collections.singleton("type/bug"), "")) + .as("type/bug").containsOnly(Type.BUG); + + assertThat(task.extractTypes(Collections.singleton("type/enhancement"), "")) + .as("type/enhancement").containsOnly(Type.FEATURE); + + assertThat(task.extractTypes(Collections.singleton("type/documentation"), "")) + .as("type/documentation").containsOnly(Type.DOC_MISC); + + assertThat(task.extractTypes(Collections.singleton("type/chores"), "")) + .as("type/chores").containsOnly(Type.DOC_MISC); + + assertThat(task.extractTypes(Collections.singleton("warn/something"), "")) + .as("warn/*").containsOnly(Type.NOTEWORTHY); + + assertThat(task.extractTypes(Collections.singleton("type/whatever"), "")) + .as("type/whatever").containsOnly(Type.UNCLASSIFIED); + } + + @Test + void extractTypeFromMultipleLabels() { + Set labels = new LinkedHashSet<>(); + labels.add("type/bug"); + labels.add("type/enhancement"); + labels.add("type/documentation"); + labels.add("whatever"); + + assertThat(task.extractTypes(labels, "")).as("multiple labels") + .containsOnly(Type.BUG, Type.FEATURE, Type.DOC_MISC); + } + + @Test + void extractMiscTypeFromBuildMessagePrefix() { + String message = "[build] Foo"; + + assertThat(task.extractTypes(Collections.emptySet(), message)) + .containsOnly(Type.DOC_MISC); + } + + @Test + void extractMiscTypeFromPolishMessagePrefix() { + String message = "[polish] Foo"; + + assertThat(task.extractTypes(Collections.emptySet(), message)) + .containsOnly(Type.DOC_MISC); + } + + @Test + void extractMiscTypeFromDocMessagePrefix() { + String message = "[doc] Foo"; + + assertThat(task.extractTypes(Collections.emptySet(), message)) + .containsOnly(Type.DOC_MISC); + } + + @Test + void extractUnclassifiedTypeFromRandomMessagePrefix() { + String message = "fix #123 There was a [bug], needed to [polish] the [doc]"; + + assertThat(task.extractTypes(Collections.emptySet(), message)) + .containsOnly(Type.UNCLASSIFIED); + } + + @Test + void noTitleCleanupFromMergeCommit() { + SimpleCommit mergeCommit = new SimpleCommit("sha1", "fullsha1", + "merge #123 into 3.3 (#123)", "merge #123 into 3.3", "Simon Baslé", + "sbasle@pivotal.io", "Simon Baslé", "sbasle@pivotal.io", true); + + assertThat(task.cleanupShortMessage(mergeCommit)) + .isEqualTo("merge #123 into 3.3 (#123)"); + } + + @Test + void titleCleanupFixPrefix() { + SimpleCommit commit = new SimpleCommit("sha1", "fullsha1", + "fix #123 Text from title", "fix #123 Some more text", "Simon Baslé", + "sbasle@pivotal.io", "Simon Baslé", "sbasle@pivotal.io", false); + + assertThat(task.cleanupShortMessage(commit)).isEqualTo("Text from title"); + } + + @Test + void titleCleanupSeePrefix() { + SimpleCommit commit = new SimpleCommit("sha1", "fullsha1", + "see #123 Text from title", "see #123 Some more text", "Simon Baslé", + "sbasle@pivotal.io", "Simon Baslé", "sbasle@pivotal.io", false); + + assertThat(task.cleanupShortMessage(commit)).isEqualTo("Text from title"); + } + + @Test + void titleCleanupPrStyleSuffix() { + SimpleCommit commit = new SimpleCommit("sha1", "fullsha1", + "Commit without issue (#123)", "fullMessage", "Simon Baslé", + "sbasle@pivotal.io", "Simon Baslé", "sbasle@pivotal.io", false); + + assertThat(task.cleanupShortMessage(commit)).isEqualTo("Commit without issue"); + } + + @Test + void titleCleanupPrStyleSuffixNoSpace() { + SimpleCommit commit = new SimpleCommit("sha1", "fullsha1", + "Commit without issue(#123)", "fullMessage", "Simon Baslé", + "sbasle@pivotal.io", "Simon Baslé", "sbasle@pivotal.io", false); + + assertThat(task.cleanupShortMessage(commit)).isEqualTo("Commit without issue"); + } + + @Test + void titleCleanupBothPrefixAndSuffix() { + SimpleCommit commit = new SimpleCommit("sha1", "fullsha1", + "prefix #123 Commit title (#123)", "fullMessage", "Simon Baslé", + "sbasle@pivotal.io", "Simon Baslé", "sbasle@pivotal.io", false); + + assertThat(task.cleanupShortMessage(commit)).isEqualTo("Commit title"); + } + + @Test + void issueNumberTitlePrefix() { + SimpleCommit commit = new SimpleCommit("sha1", "fullsha1", + "prefix #123 Commit title", "fullMessage", "Simon Baslé", + "sbasle@pivotal.io", "Simon Baslé", "sbasle@pivotal.io", false); + + assertThat(task.extractIssueNumbers(commit)).containsOnly(123); + } + + @Test + void issueNumberTitlePrStyleSuffix() { + SimpleCommit commit = new SimpleCommit("sha1", "fullsha1", "Commit title (#123)", + "fullMessage", "Simon Baslé", "sbasle@pivotal.io", "Simon Baslé", + "sbasle@pivotal.io", false); + + assertThat(task.extractIssueNumbers(commit)).containsOnly(123); + } + + @Test + void issueNumberTitlePrefixAndSuffix() { + SimpleCommit commit = new SimpleCommit("sha1", "fullsha1", + "prefix #123 Commit title (#456)", "fullMessage", "Simon Baslé", + "sbasle@pivotal.io", "Simon Baslé", "sbasle@pivotal.io", false); + + assertThat(task.extractIssueNumbers(commit)).containsOnly(123, 456); + } + + @Test + void issueNumberTitleNotSeparatedBySpace() { + SimpleCommit commit = new SimpleCommit("sha1", "fullsha1", + "prefix#123Commit title(#456)", "fullMessage", "Simon Baslé", + "sbasle@pivotal.io", "Simon Baslé", "sbasle@pivotal.io", false); + + assertThat(task.extractIssueNumbers(commit)).containsOnly(123, 456); + } + + static final VoidAnswer4, Set, Map> MOCK_FETCH_ISSUES = ( + ignore1, issues, ignore2, resolved) -> issues + .forEach(i -> resolved.put("#" + i, "alternative title for " + i)); + + static final Issues MOCK_ISSUES = Mockito.mock(Issues.class); + + @Test + void generateChangelogDescriptionSingleIssueTwice() throws IOException { + final GenerateReleaseNotesTask spy = Mockito.spy(task); + doAnswer(answerVoid(MOCK_FETCH_ISSUES)).when(spy).fetchIssueLabelsAndTitles(any(), + any(), any(), any()); + + SimpleCommit commit = new SimpleCommit("sha1", "fullsha1", + "prefix #123 Commit title (#123)", "fullMessage", "Simon Baslé", + "sbasle@pivotal.io", "Simon Baslé", "sbasle@pivotal.io", false); + + assertThat(spy.parseChangeLogEntry(githubClient.randomRepo().issues(), + commit).description).isEqualTo("Commit title (#123)"); + } + + @Test + void generateChangelogDescriptionTwoIssues() throws IOException { + final GenerateReleaseNotesTask spy = Mockito.spy(task); + doAnswer(answerVoid(MOCK_FETCH_ISSUES)).when(spy).fetchIssueLabelsAndTitles(any(), + any(), any(), any()); + + SimpleCommit commit = new SimpleCommit("sha1", "fullsha1", + "prefix #123 Commit title (#456)", "fullMessage", "Simon Baslé", + "sbasle@pivotal.io", "Simon Baslé", "sbasle@pivotal.io", false); + + assertThat(spy.parseChangeLogEntry(MOCK_ISSUES, commit).description) + .isEqualTo("Commit title (#123, #456)"); + } + + @Test + void generateChangelogDescriptionTwoIssuesNoSpace() throws IOException { + final GenerateReleaseNotesTask spy = Mockito.spy(task); + doAnswer(answerVoid(MOCK_FETCH_ISSUES)).when(spy).fetchIssueLabelsAndTitles(any(), + any(), any(), any()); + + SimpleCommit commit = new SimpleCommit("sha1", "fullsha1", + "prefix #123Commit title no space(#456)", "fullMessage", "Simon Baslé", + "sbasle@pivotal.io", "Simon Baslé", "sbasle@pivotal.io", false); + + assertThat(spy.parseChangeLogEntry(githubClient.randomRepo().issues(), + commit).description).isEqualTo("Commit title no space (#123, #456)"); + } + + @Test + void generateChangelogDescriptionNoMatchingIssueUsesShortSha1() throws IOException { + final GenerateReleaseNotesTask spy = Mockito.spy(task); + doNothing().when(spy).fetchIssueLabelsAndTitles(any(), any(), any(), any()); + + SimpleCommit commit = new SimpleCommit("sha1", "fullsha1", + "prefix #123 Commit title no space(#456)", "fullMessage", "Simon Baslé", + "sbasle@pivotal.io", "Simon Baslé", "sbasle@pivotal.io", false); + + assertThat(spy.parseChangeLogEntry(githubClient.randomRepo().issues(), + commit).description).isEqualTo("Commit title no space (sha1)"); + } + + @Test + void issueInfoFetchProtectedWhenIssueNotFound() throws IOException { + Issues issuesClient = githubClient.randomRepo().issues(); + Set labels = new HashSet<>(); + Map associatedIssues = new HashMap<>(); + + assertThatExceptionOfType(Exception.class).as("github client fails") + .isThrownBy(() -> new Issue.Smart(issuesClient.get(123)).title()); + + assertThatCode(() -> task.fetchIssueLabelsAndTitles(issuesClient, + Collections.singleton(123), labels, associatedIssues)) + .as("fetching just does nothing").doesNotThrowAnyException(); + + assertThat(labels).as("labels").isEmpty(); + assertThat(associatedIssues).as("associated issues").isEmpty(); + } + + // TODO test login extraction by mocking the task's commitToGithubMention method + + // TODO find a way to mock commits and thus test extractContributors + +} diff --git a/projects/reactor/src/test/java/releaser/reactor/ReactorTestConfiguration.java b/projects/reactor/src/test/java/releaser/reactor/ReactorTestConfiguration.java new file mode 100644 index 00000000..53d3d88b --- /dev/null +++ b/projects/reactor/src/test/java/releaser/reactor/ReactorTestConfiguration.java @@ -0,0 +1,98 @@ +/* + * Copyright 2013-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 + * + * https://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 releaser.reactor; + +import java.io.IOException; + +import com.jcabi.github.Github; +import com.jcabi.github.mock.MkGithub; +import org.mockito.BDDMockito; +import org.mockito.Mockito; +import releaser.internal.Releaser; +import releaser.internal.git.ProjectGitHandler; +import releaser.internal.options.Parser; +import releaser.internal.spring.ExecutionResultHandler; +import releaser.internal.spring.SpringReleaser; + +import org.springframework.beans.factory.BeanCreationException; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; +import org.springframework.context.annotation.Profile; + +/** + * A configuration for tests, that mocks external clients but reuses the base bean + * declaration for tasks. + * + * @author Simon Baslé + */ +@Configuration +@Profile("test") +public class ReactorTestConfiguration { + + @Bean + RestartSiteProjectPostReleaseTask restartSiteProjectPostReleaseTask(Releaser releaser, + CfClient cfClient, @Value("${cf.reactorAppName}") String reactorAppName) { + return new RestartSiteProjectPostReleaseTask(releaser, cfClient, reactorAppName); + } + + @Bean + GenerateReleaseNotesTask releaseNotesTask(Github github, + ProjectGitHandler gitHandler) { + return new GenerateReleaseNotesTask(github, gitHandler); + } + + @Bean + ProjectGitHandler mockGitHandler() { + return Mockito.mock(ProjectGitHandler.class); + } + + @Bean + MkGithub mockGithub() { + try { + return new MkGithub(); + } + catch (IOException e) { + throw new BeanCreationException("Unable to create mock Github bean", e); + } + } + + @Bean + CfClient mockCfClient() { + return BDDMockito.mock(CfClient.class); + } + + @Bean + @Primary + SpringReleaser mockReleaser() { + return Mockito.mock(SpringReleaser.class); + } + + @Bean + @Primary + ExecutionResultHandler mockExecutionResultHandler() { + return Mockito.mock(ExecutionResultHandler.class); + } + + @Bean + @Primary + Parser mockParser() { + return Mockito.mock(Parser.class); + } + +} diff --git a/projects/reactor/src/test/java/releaser/reactor/RestartSiteProjectPostReleaseTaskTests.java b/projects/reactor/src/test/java/releaser/reactor/RestartSiteProjectPostReleaseTaskTests.java index e71af351..2e98e85e 100644 --- a/projects/reactor/src/test/java/releaser/reactor/RestartSiteProjectPostReleaseTaskTests.java +++ b/projects/reactor/src/test/java/releaser/reactor/RestartSiteProjectPostReleaseTaskTests.java @@ -17,10 +17,8 @@ package releaser.reactor; import org.assertj.core.api.BDDAssertions; -import org.cloudfoundry.operations.CloudFoundryOperations; import org.junit.jupiter.api.Test; import org.mockito.BDDMockito; -import releaser.internal.Releaser; import releaser.internal.ReleaserProperties; import releaser.internal.options.Options; import releaser.internal.project.ProjectVersion; @@ -31,13 +29,10 @@ import releaser.internal.spring.ProjectsFromBom; import releaser.internal.tech.ExecutionResult; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.autoconfigure.EnableAutoConfiguration; import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; import org.springframework.test.context.ActiveProfiles; -@SpringBootTest(classes = RestartSiteProjectPostReleaseTaskTests.Config.class) +@SpringBootTest @ActiveProfiles("test") class RestartSiteProjectPostReleaseTaskTests { @@ -92,25 +87,4 @@ class RestartSiteProjectPostReleaseTaskTests { }; } - @Configuration - @EnableAutoConfiguration - static class Config extends ReactorConfiguration { - - @Bean - Releaser mockReleaser() { - return BDDMockito.mock(Releaser.class); - } - - @Bean - CloudFoundryOperations mockCloudFoundryOperations() { - return BDDMockito.mock(CloudFoundryOperations.class); - } - - @Override - CfClient cfClient(CloudFoundryOperations cloudFoundryOperations) { - return BDDMockito.mock(CfClient.class); - } - - } - } diff --git a/projects/reactor/src/test/resources/application-test.yml b/projects/reactor/src/test/resources/application-test.yml index 79678c85..f6d7d3f2 100644 --- a/projects/reactor/src/test/resources/application-test.yml +++ b/projects/reactor/src/test/resources/application-test.yml @@ -1,3 +1,6 @@ cf: username: foo - password: bar \ No newline at end of file + password: bar +spring: + profiles: + active: test \ No newline at end of file diff --git a/releaser-core/src/main/java/releaser/internal/ReleaserProperties.java b/releaser-core/src/main/java/releaser/internal/ReleaserProperties.java index 635adb2e..b6945c53 100644 --- a/releaser-core/src/main/java/releaser/internal/ReleaserProperties.java +++ b/releaser-core/src/main/java/releaser/internal/ReleaserProperties.java @@ -446,7 +446,7 @@ public class ReleaserProperties implements Serializable { private String documentationUrl; /** - * URL to the release train project page repository. + * The organization name on Github. */ @NotBlank private String orgName; diff --git a/releaser-core/src/main/java/releaser/internal/git/GitRepo.java b/releaser-core/src/main/java/releaser/internal/git/GitRepo.java index 211470ab..06813923 100644 --- a/releaser-core/src/main/java/releaser/internal/git/GitRepo.java +++ b/releaser-core/src/main/java/releaser/internal/git/GitRepo.java @@ -19,7 +19,9 @@ package releaser.internal.git; import java.io.File; import java.io.FileNotFoundException; import java.io.IOException; +import java.util.LinkedList; import java.util.List; +import java.util.Optional; import com.jcraft.jsch.IdentityRepository; import com.jcraft.jsch.JSch; @@ -42,6 +44,7 @@ import org.eclipse.jgit.api.ResetCommand; import org.eclipse.jgit.api.TransportConfigCallback; import org.eclipse.jgit.api.errors.EmptyCommitException; import org.eclipse.jgit.api.errors.GitAPIException; +import org.eclipse.jgit.lib.ObjectId; import org.eclipse.jgit.lib.Ref; import org.eclipse.jgit.revwalk.RevCommit; import org.eclipse.jgit.transport.CredentialsProvider; @@ -175,6 +178,92 @@ class GitRepo { } } + /** + * Attempt to retrieve a tag id from a name, prepending the name with /refs/tags/. + */ + Optional findTagIdByName(String tagName, boolean unpeel) { + try (Git git = this.gitFactory.open(file(this.basedir))) { + return git.tagList().call().stream() + .filter(ref -> ref.getName().equals("refs/tags/" + tagName)) + .findFirst().map(ref -> { + if (ref.isPeeled() && unpeel) { + return ref.getPeeledObjectId(); + } + return ref.getObjectId(); + }); + } + catch (Exception e) { + throw new IllegalStateException( + "Unable to fetch git tag id for refs/tags/" + tagName, e); + } + } + + /** + * Logs {@link RevCommit} between two tags / branches / hashes. + * @param from oldest revision + * @param to newest revision + */ + List log(String from, String to) { + try (Git git = this.gitFactory.open(file(this.basedir))) { + final Optional fromRevisionOptional = findTagOrBranchHeadRevision(git, + from); + final Optional toRevisionOptional = findTagOrBranchHeadRevision(git, to); + + ObjectId fromRevision; + if (fromRevisionOptional.isPresent()) { + Ref ref = fromRevisionOptional.get(); + if (ref.isPeeled()) { + fromRevision = ref.getPeeledObjectId(); + } + else { + fromRevision = ref.getObjectId(); + } + } + else { + fromRevision = ObjectId.fromString(from); + } + + ObjectId toRevision; + if (toRevisionOptional.isPresent()) { + Ref ref = toRevisionOptional.get(); + if (ref.isPeeled()) { + toRevision = ref.getPeeledObjectId(); + } + else { + toRevision = ref.getObjectId(); + } + } + else { + toRevision = ObjectId.fromString(to); + } + + LinkedList commits = new LinkedList<>(); + git.log().addRange(fromRevision, toRevision).call().forEach(commits::add); + return commits; + } + catch (Exception e) { + throw new IllegalStateException( + "Unable to fetch git log for " + from + ".." + to, e); + } + } + + /** + * Look for a tag with the given name, and if not found looks for a branch. + */ + private Optional findTagOrBranchHeadRevision(Git git, String tagOrBranch) + throws GitAPIException { + final Optional tag = git.tagList().call().stream() + .filter(ref -> ref.getName().equals("refs/tags/" + tagOrBranch)) + .findFirst(); + if (tag.isPresent()) { + return tag; + } + + return git.branchList().setListMode(ListBranchCommand.ListMode.ALL).call() + .stream().filter(ref -> ref.getName().equals("refs/heads/" + tagOrBranch)) + .findFirst(); + } + boolean hasBranch(String branch) { try (Git git = this.gitFactory.open(file(this.basedir))) { List refs = git.branchList().setListMode(ListBranchCommand.ListMode.ALL) @@ -192,6 +281,8 @@ class GitRepo { } private String nameOfBranch(String branch) { + // TODO careful: this doesn't take into account branches that follow a pattern + // like `experiments/foo` return branch.substring(branch.lastIndexOf("/") + 1); } diff --git a/releaser-core/src/main/java/releaser/internal/git/ProjectGitHandler.java b/releaser-core/src/main/java/releaser/internal/git/ProjectGitHandler.java index ce8e3fe1..51540fbf 100644 --- a/releaser-core/src/main/java/releaser/internal/git/ProjectGitHandler.java +++ b/releaser-core/src/main/java/releaser/internal/git/ProjectGitHandler.java @@ -19,9 +19,13 @@ package releaser.internal.git; import java.io.Closeable; import java.io.File; import java.util.Arrays; +import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Collectors; +import org.eclipse.jgit.lib.AnyObjectId; import org.eclipse.jgit.transport.URIish; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -191,6 +195,31 @@ public class ProjectGitHandler implements ReleaserPropertiesAware, Closeable { return clonedProject; } + /** + * Find the commits between two versions. The second version is actually turned into + * the head of the relevant branch. + * @param clonedProject location of the cloned project + * @param fromRef the ref to start from (tag, branch or sha1) + * @param toRef the ref to go to (tag, branch or sha1) + * @return the list of revisions between these two references + */ + public List commitsBetween(File clonedProject, String fromRef, + String toRef) { + return gitRepo(clonedProject).log(fromRef, toRef).stream().map(SimpleCommit::new) + .collect(Collectors.toList()); + } + + /** + * Attempt to find the SHA1 of a named tag (without the refs/tags/ prefix). + * @param clonedProject location of the cloned project + * @param tagName the name of the tag to find + * @return an {@link Optional} that is valued with the sha1, if found + */ + public Optional findTagSha1(File clonedProject, String tagName) { + return gitRepo(clonedProject).findTagIdByName(tagName, false) + .map(AnyObjectId::getName); + } + private String suffixNonHttpRepo(String orgUrl) { return orgUrl.startsWith("http") || orgUrl.startsWith("git") ? "" : "/"; } diff --git a/releaser-core/src/main/java/releaser/internal/git/SimpleCommit.java b/releaser-core/src/main/java/releaser/internal/git/SimpleCommit.java new file mode 100644 index 00000000..8accf3ca --- /dev/null +++ b/releaser-core/src/main/java/releaser/internal/git/SimpleCommit.java @@ -0,0 +1,109 @@ +/* + * Copyright 2013-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 + * + * https://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 releaser.internal.git; + +import org.eclipse.jgit.revwalk.RevCommit; + +/** + * A simple representation of a git commit. + * + * @author Simon Baslé + */ +public class SimpleCommit { + + /** + * The abbreviated SHA1 of the commit, useful for human-readable output. + */ + public final String abbreviatedSha1; + + /** + * The full SHA1 of the commit, useful to identify it. + */ + public final String fullSha1; + + /** + * The title line of the commit (first line of the fullMessage). + */ + public final String title; + + /** + * The full commit message, including the title, body and separating newlines. + */ + public final String fullMessage; + + /** + * The name of the author of the commit, who contributed the code. + */ + public final String authorName; + + /** + * The email of the author of the commit, who contributed the code. + */ + public final String authorEmail; + + /** + * The name of the committer of the commit, who pushed the code to the repository. + */ + public final String committerName; + + /** + * The email of the committer of the commit, who pushed the code to the repository. + */ + public final String committerEmail; + + /** + * Is the commit a merge commit, ie. it has two parents. + */ + public final boolean isMergeCommit; + + SimpleCommit(RevCommit revCommit) { + this(revCommit.abbreviate(8).name(), revCommit.name(), + revCommit.getShortMessage(), revCommit.getFullMessage(), + revCommit.getAuthorIdent().getName(), + revCommit.getAuthorIdent().getEmailAddress(), + revCommit.getCommitterIdent().getName(), + revCommit.getCommitterIdent().getEmailAddress(), + revCommit.getParentCount() > 1); + } + + /** + * Create a {@link SimpleCommit}. + * @param abbreviatedSha1 the short version of the SHA-1, for human-readable output + * @param fullSha1 the full SHA-1 + * @param title the first line of the commit message + * @param fullMessage the full commit message, including title + * @param authorName the name of the commit's author + * @param authorEmail the email of the commit's author + * @param committerName the name of the commit's committer + * @param committerEmail the email of the commit's committer + * @param isMergeCommit is the commit a merge commit (with 2 parents) + */ + public SimpleCommit(String abbreviatedSha1, String fullSha1, String title, + String fullMessage, String authorName, String authorEmail, + String committerName, String committerEmail, boolean isMergeCommit) { + this.abbreviatedSha1 = abbreviatedSha1; + this.fullSha1 = fullSha1; + this.title = title; + this.fullMessage = fullMessage; + this.authorName = authorName; + this.authorEmail = authorEmail; + this.committerName = committerName; + this.committerEmail = committerEmail; + this.isMergeCommit = isMergeCommit; + } + +} diff --git a/releaser-core/src/main/java/releaser/internal/project/ProjectCommandExecutor.java b/releaser-core/src/main/java/releaser/internal/project/ProjectCommandExecutor.java index c21fee98..70fc0b81 100644 --- a/releaser-core/src/main/java/releaser/internal/project/ProjectCommandExecutor.java +++ b/releaser-core/src/main/java/releaser/internal/project/ProjectCommandExecutor.java @@ -88,7 +88,7 @@ public class ProjectCommandExecutor implements ReleaserPropertiesAware { try { String projectRoot = this.properties.getWorkingDir(); String[] commands = command.split(" "); - return runCommand(projectRoot, commands); + return runCommand(projectRoot, commands).trim(); } catch (IllegalStateException e) { throw e; @@ -319,8 +319,7 @@ class ProcessExecutor implements ReleaserPropertiesAware { commandsToRun = commandToExecute(lastArg); } log.info("Will run the command [{}]", Arrays.toString(commandsToRun)); - return new ProcessBuilder(commandsToRun).directory(new File(workingDir)) - .inheritIO(); + return new ProcessBuilder(commandsToRun).directory(new File(workingDir)); } String[] commandToExecute(String lastArg) { diff --git a/releaser-spring/src/main/java/releaser/internal/github/GithubConfiguration.java b/releaser-spring/src/main/java/releaser/internal/github/GithubConfiguration.java index 9977d127..4160366e 100644 --- a/releaser-spring/src/main/java/releaser/internal/github/GithubConfiguration.java +++ b/releaser-spring/src/main/java/releaser/internal/github/GithubConfiguration.java @@ -17,6 +17,7 @@ package releaser.internal.github; import com.jcabi.github.Github; +import com.jcabi.github.RtGithub; import org.springframework.beans.BeansException; import org.springframework.beans.factory.config.BeanPostProcessor; @@ -32,7 +33,7 @@ class GithubConfiguration { @Override public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException { - if (bean instanceof Github) { + if (bean instanceof RtGithub) { return new CachingGithub((Github) bean); } return bean; diff --git a/releaser-spring/src/main/java/releaser/internal/sagan/SaganConfiguration.java b/releaser-spring/src/main/java/releaser/internal/sagan/SaganConfiguration.java index 42bc408b..d7ed76c9 100644 --- a/releaser-spring/src/main/java/releaser/internal/sagan/SaganConfiguration.java +++ b/releaser-spring/src/main/java/releaser/internal/sagan/SaganConfiguration.java @@ -36,7 +36,7 @@ class SaganConfiguration { @Bean @ConditionalOnMissingBean - @ConditionalOnProperty(value = "releaser.sagan.update-sagan", matchIfMissing = true) + @ConditionalOnProperty(value = "releaser.sagan.update-sagan", havingValue = "true") SaganClient saganClient(ReleaserProperties properties) { RestTemplate restTemplate = restTemplate(properties); return new RestTemplateSaganClient(restTemplate, properties); @@ -44,7 +44,8 @@ class SaganConfiguration { @Bean @ConditionalOnMissingBean - @ConditionalOnProperty(value = "releaser.sagan.update-sagan", havingValue = "false") + @ConditionalOnProperty(value = "releaser.sagan.update-sagan", havingValue = "false", + matchIfMissing = true) SaganClient noOpSaganClient() { return new SaganClient() { @Override diff --git a/releaser-spring/src/main/java/releaser/internal/spring/BatchConfiguration.java b/releaser-spring/src/main/java/releaser/internal/spring/BatchConfiguration.java index 4184f8f0..79c5e6a7 100644 --- a/releaser-spring/src/main/java/releaser/internal/spring/BatchConfiguration.java +++ b/releaser-spring/src/main/java/releaser/internal/spring/BatchConfiguration.java @@ -56,8 +56,8 @@ class BatchConfiguration { @Bean @ConditionalOnMissingBean - ExecutionResultHandler springBatchExecutionResultHandler(JobExplorer jobExplorer, - ConfigurableApplicationContext context) { + SpringBatchExecutionResultHandler springBatchExecutionResultHandler( + JobExplorer jobExplorer, ConfigurableApplicationContext context) { return new SpringBatchExecutionResultHandler(jobExplorer, context); } @@ -74,11 +74,11 @@ class BatchConfiguration { JobBuilderFactory jobBuilderFactory, ProjectsToRunFactory projectsToRunFactory, JobLauncher jobLauncher, FlowRunnerTaskExecutorSupplier flowRunnerTaskExecutorSupplier, - ConfigurableApplicationContext context, - ReleaserProperties releaserProperties) { + ConfigurableApplicationContext context, ReleaserProperties releaserProperties, + BuildReportHandler reportHandler) { return new SpringBatchFlowRunner(stepBuilderFactory, jobBuilderFactory, projectsToRunFactory, jobLauncher, flowRunnerTaskExecutorSupplier, - context, releaserProperties); + context, releaserProperties, reportHandler); } @Bean diff --git a/releaser-spring/src/main/java/releaser/internal/spring/BuildReportHandler.java b/releaser-spring/src/main/java/releaser/internal/spring/BuildReportHandler.java new file mode 100644 index 00000000..7280664f --- /dev/null +++ b/releaser-spring/src/main/java/releaser/internal/spring/BuildReportHandler.java @@ -0,0 +1,32 @@ +/* + * Copyright 2013-2019 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 + * + * https://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 releaser.internal.spring; + +/** + * Handles reporting the results of the build. Similar to {@link ExecutionResultHandler} + * with the difference that it could be invoked before the final result is determined, and + * shouldn't handle exiting the application. + */ +public interface BuildReportHandler { + + /** + * Display a report summarizing the state of the build so far. Can be invoked during + * normal execution, so it should filter out eg. tasks that are running. + */ + void reportBuildSummary(); + +} diff --git a/releaser-spring/src/main/java/releaser/internal/spring/SpringBatchExecutionResultHandler.java b/releaser-spring/src/main/java/releaser/internal/spring/SpringBatchExecutionResultHandler.java index aeab0345..060cfa2b 100644 --- a/releaser-spring/src/main/java/releaser/internal/spring/SpringBatchExecutionResultHandler.java +++ b/releaser-spring/src/main/java/releaser/internal/spring/SpringBatchExecutionResultHandler.java @@ -43,7 +43,8 @@ import org.springframework.context.ConfigurableApplicationContext; import org.springframework.core.NestedExceptionUtils; import org.springframework.util.StringUtils; -class SpringBatchExecutionResultHandler implements ExecutionResultHandler { +class SpringBatchExecutionResultHandler + implements ExecutionResultHandler, BuildReportHandler { private static final Logger log = LoggerFactory .getLogger(SpringBatchExecutionResultHandler.class); @@ -60,7 +61,7 @@ class SpringBatchExecutionResultHandler implements ExecutionResultHandler { @Override public void accept(ExecutionResult executionResult) { - buildSummaryTable(); + reportBuildSummary(); if (executionResult.isFailure()) { log.error("At least one failure occurred while running the release process", executionResult.foundExceptions()); @@ -79,21 +80,21 @@ class SpringBatchExecutionResultHandler implements ExecutionResultHandler { } void exitSuccessfully() { - SpringApplication.exit(this.context, () -> 0); - System.exit(0); + System.exit(SpringApplication.exit(this.context, () -> 0)); } void exitWithException() { - SpringApplication.exit(this.context, () -> 1); - System.exit(1); + System.exit(SpringApplication.exit(this.context, () -> 1)); } - private void buildSummaryTable() { + @Override + public void reportBuildSummary() { List jobNames = this.jobExplorer.getJobNames(); List sortedJobExecutions = jobNames.stream() .flatMap(name -> this.jobExplorer.findJobInstancesByJobName(name, 0, 100) .stream()) .flatMap(instance -> this.jobExplorer.getJobExecutions(instance).stream()) + .filter(j -> !j.isRunning()) .sorted(Comparator.comparing(JobExecution::getCreateTime)) .collect(Collectors.toList()); List stepContexts = sortedJobExecutions.stream() diff --git a/releaser-spring/src/main/java/releaser/internal/spring/SpringBatchFlowRunner.java b/releaser-spring/src/main/java/releaser/internal/spring/SpringBatchFlowRunner.java index 23fd3bc9..ef1add8c 100644 --- a/releaser-spring/src/main/java/releaser/internal/spring/SpringBatchFlowRunner.java +++ b/releaser-spring/src/main/java/releaser/internal/spring/SpringBatchFlowRunner.java @@ -93,14 +93,14 @@ class SpringBatchFlowRunner implements FlowRunner, Closeable { JobBuilderFactory jobBuilderFactory, ProjectsToRunFactory projectsToRunFactory, JobLauncher jobLauncher, FlowRunnerTaskExecutorSupplier flowRunnerTaskExecutorSupplier, - ConfigurableApplicationContext context, - ReleaserProperties releaserProperties) { + ConfigurableApplicationContext context, ReleaserProperties releaserProperties, + BuildReportHandler reportHandler) { this.stepBuilderFactory = stepBuilderFactory; this.jobBuilderFactory = jobBuilderFactory; this.projectsToRunFactory = projectsToRunFactory; this.jobLauncher = jobLauncher; this.flowRunnerTaskExecutorSupplier = flowRunnerTaskExecutorSupplier; - this.stepSkipper = new ConsoleInputStepSkipper(context); + this.stepSkipper = new ConsoleInputStepSkipper(context, reportHandler); this.releaserProperties = releaserProperties; this.executorService = Executors.newFixedThreadPool( this.releaserProperties.getMetaRelease().getReleaseGroupThreadCount()); @@ -534,8 +534,12 @@ class ConsoleInputStepSkipper { private final ConfigurableApplicationContext context; - ConsoleInputStepSkipper(ConfigurableApplicationContext context) { + private final BuildReportHandler reportHandler; + + ConsoleInputStepSkipper(ConfigurableApplicationContext context, + BuildReportHandler reportHandler) { this.context = context; + this.reportHandler = reportHandler; } public boolean skipStep() { @@ -544,8 +548,8 @@ class ConsoleInputStepSkipper { case "s": return true; case "q": - SpringApplication.exit(this.context, () -> 0); - System.exit(0); + reportHandler.reportBuildSummary(); + System.exit(SpringApplication.exit(this.context, () -> 0)); return true; default: return false;