Improvements to common build and new Reactor task (merge with rebase) (#183)

* Reactor: For reference, mention additional inputs in app properties

* Polish: fix javadoc copypasta and mark potential problem in GitRepo

* Common: Expose method for git log (list revisions between 2 refs)

* Common: Only post-process RtGithub beans into CachingGithub

* Common: Make Sagan noOp if property is not explicitly set

* Common: Fix Process command executor outputting to app's stdout

This prevents the command executor from capturing the command's output.

* Common: Fix findTagOrBranchHeadRevision and log

In findTag... we need to compare name using refs/tags/
and refs/heads/ prefixes.

In log we need to peel symbolic tags to get the right
ObjectId. Optional.map did seem to cause issues.

* Common: Add isMergeCommit to SimpleCommit

* Common: Add method to find tag SHA by name

* Common: Polish how exit codes are generated and used

* Common: Add BuildReportHandler to show report earlier than last step

This commit also filters out tasks that haven't run yet, avoiding
NPE due to endTime being null.

* Reactor: restart task should not be part of dry-runs

* Reactor: Alter Gradle build command to include bumpVersionsInReadme task

* Reactor: Add GenerateReleaseNotesTask

Also added partial tests for the task.

Avoids generating notes if snapshot, mark as pre-release if milestone
or rc.

* Reactor: Split out configurations and use profiles for test

* Reactor: Fix org in application.yml

* Reactor: Fix some formatting

* Reactor: Force github OAuth token at Github client creation

* Reactor: Split parseChangelog into several more testable methods

* Reactor: Let interactive GenerateReleaseNotesTask force a log range

* Reactor: Allow multiple dispatch of note entries

Switching from a single TYPE to an EnumSet

* Reactor: Extract issue numbers in title too just in case

* Reactor: Fix alternative titles markdown and description

Also better protect agains Github client failures when
fetching more info like title and labels.

* Reactor: Polish format (newlines) in tag input, notes output

* Reactor: Check tag exists but not release. Check on SHA1

* Reactor: Make checks we can save notes draft BEFORE querying commits

* Reactor: Attempt to find existing release draft (max 2 month old), avoid unnecessary calls

If an existing draft is found, append notes to it.

* Reactor: Ask only for "from" change for interactive log/release notes
This commit is contained in:
Simon Baslé
2020-01-22 10:44:59 +01:00
committed by Marcin Grzejszczak
parent 7632b99031
commit 188c079bd6
22 changed files with 1448 additions and 140 deletions

View File

@@ -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();
}
}

View File

@@ -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<String> 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<Type, List<ChangelogEntry>> 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<SimpleCommit> 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<Type, List<ChangelogEntry>> entries,
List<String> 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<Type> extractTypes(Set<String> labels, String shortMessage) {
List<Type> 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<String> extractContributorMentions(Repo repo, List<SimpleCommit> 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<Integer> extractIssueNumbers(SimpleCommit commit) {
Set<Integer> 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<Integer> issueNumbers, Set<String> labelsTarget,
Map<String, String> 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<Integer> issueNumbers = extractIssueNumbers(commit);
Set<String> labelsTarget = new HashSet<>();
Map<String, String> referencedIssuesTarget = new LinkedHashMap<>();
fetchIssueLabelsAndTitles(issueClient, issueNumbers, labelsTarget,
referencedIssuesTarget);
EnumSet<Type> 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<Type> 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<String, String> 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<Type> types, String commitSha1, String cleanShortMessage,
Map<String, String> 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;
}
}
}

View File

@@ -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);
}
}

View File

@@ -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";

View File

@@ -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
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

View File

@@ -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<String, DryRunReleaseReleaserTask> beans = context
.getBeansOfType(DryRunReleaseReleaserTask.class);
List<ReleaserTask> 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<String, ReleaserTask> beans = context.getBeansOfType(ReleaserTask.class);
List<ReleaserTask> 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));
}

View File

@@ -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);
}
}

View File

@@ -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<String> 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<Issues, Set<Integer>, Set<String>, Map<String, String>> 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<String> labels = new HashSet<>();
Map<String, String> 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
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}
}

View File

@@ -1,3 +1,6 @@
cf:
username: foo
password: bar
password: bar
spring:
profiles:
active: test

View File

@@ -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;

View File

@@ -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<ObjectId> 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<RevCommit> log(String from, String to) {
try (Git git = this.gitFactory.open(file(this.basedir))) {
final Optional<Ref> fromRevisionOptional = findTagOrBranchHeadRevision(git,
from);
final Optional<Ref> 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<RevCommit> 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<Ref> findTagOrBranchHeadRevision(Git git, String tagOrBranch)
throws GitAPIException {
final Optional<Ref> 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<Ref> 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);
}

View File

@@ -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<SimpleCommit> 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<String> 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") ? "" : "/";
}

View File

@@ -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;
}
}

View File

@@ -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) {

View File

@@ -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;

View File

@@ -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

View File

@@ -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

View File

@@ -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();
}

View File

@@ -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<String> jobNames = this.jobExplorer.getJobNames();
List<JobExecution> 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<StepExecution> stepContexts = sortedJobExecutions.stream()

View File

@@ -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;