diff --git a/bots/pr/src/main/java/org/openjdk/skara/bots/pr/BackportCommand.java b/bots/pr/src/main/java/org/openjdk/skara/bots/pr/BackportCommand.java new file mode 100644 index 000000000..fe1339504 --- /dev/null +++ b/bots/pr/src/main/java/org/openjdk/skara/bots/pr/BackportCommand.java @@ -0,0 +1,203 @@ +/* + * Copyright (c) 2020, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ +package org.openjdk.skara.bots.pr; + +import org.openjdk.skara.forge.HostedCommit; +import org.openjdk.skara.forge.PullRequest; +import org.openjdk.skara.issuetracker.Comment; +import org.openjdk.skara.vcs.*; +import org.openjdk.skara.vcs.openjdk.CommitMessageParsers; + +import java.io.PrintWriter; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; +import java.time.format.DateTimeFormatter; + +public class BackportCommand implements CommandHandler { + private void showHelp(PrintWriter reply) { + reply.println("Usage: `/backport []`"); + } + + @Override + public String description() { + return "Create a backport"; + } + + @Override + public boolean allowedInCommit() { + return true; + } + + @Override + public boolean allowedInPullRequest() { + return false; + } + + @Override + public void handle(PullRequestBot bot, HostedCommit commit, CensusInstance censusInstance, Path scratchPath, CommandInvocation command, List allComments, PrintWriter reply) { + var username = command.user().username(); + if (censusInstance.contributor(command.user()).isEmpty()) { + reply.println("@" + username + " only OpenJDK [contributors](https://openjdk.java.net/bylaws#contributor) can use the `/backport` command"); + return; + } + + var args = command.args(); + if (args.isBlank()) { + showHelp(reply); + return; + } + + var parts = args.split(" "); + if (parts.length > 2) { + showHelp(reply); + return; + } + + var forge = bot.repo().forge(); + var repoName = parts[0].replace("http://", "") + .replace("https://", "") + .replace(forge.hostname() + "/", ""); + var currentRepoName = bot.repo().name(); + if (!currentRepoName.equals(repoName) && !repoName.contains("/")) { + var group = bot.repo().name().split("/")[0]; + repoName = group + "/" + repoName; + } + + var targetRepo = forge.repository(repoName); + if (targetRepo.isEmpty()) { + reply.println("@" + username + " the target repository `" + repoName + "` does not exist"); + return; + } + + var branchName = parts.length == 2 ? parts[1] : "master"; + var targetBranches = targetRepo.get().branches(); + if (targetBranches.stream().noneMatch(b -> b.name().equals(branchName))) { + reply.println("@" + username + " the target branch `" + branchName + "` does not exist"); + return; + } + + try { + var hash = commit.hash(); + var fork = bot.writeableForkOf(targetRepo.get()); + var localRepoDir = scratchPath.resolve("backport-command") + .resolve(repoName) + .resolve("fork"); + var localRepo = bot.hostedRepositoryPool() + .orElseThrow(() -> new IllegalStateException("Missing repository pool for PR bot")) + .materialize(fork, localRepoDir); + var fetchHead = localRepo.fetch(bot.repo().url(), hash.hex()); + localRepo.checkout(new Branch(branchName)); + var head = localRepo.head(); + var backportBranch = localRepo.branch(head, "backport-" + hash.abbreviate()); + localRepo.checkout(backportBranch); + var didApply = localRepo.cherryPick(fetchHead); + if (!didApply) { + var lines = new ArrayList(); + lines.add("@" + username + " :warning: could not backport `" + hash.abbreviate() + "` to " + + "[" + repoName + "](" + targetRepo.get().webUrl() + "] due to conflicts in the following files:"); + lines.add(""); + var unmerged = localRepo.status() + .stream() + .filter(e -> e.status().isUnmerged()) + .map(e -> e.target().path().orElseGet(() -> e.source().path().orElseThrow())) + .collect(Collectors.toList()); + for (var path : unmerged) { + lines.add("- " + path.toString()); + } + lines.add(""); + lines.add("To manually resolve these conflicts run the following commands in your personal fork of [" + repoName + "](" + targetRepo.get().webUrl() + "):"); + lines.add(""); + lines.add("```"); + lines.add("$ git checkout -b " + backportBranch.name()); + lines.add("$ git fetch " + bot.repo().webUrl() + " " + hash.hex()); + lines.add("$ git cherry-pick --no-commit " + hash.hex()); + lines.add("$ # Resolve conflicts"); + lines.add("$ git add files/with/resolved/conflicts"); + lines.add("$ git commit -m 'Backport " + hash.hex() + "'"); + lines.add("```"); + lines.add(""); + lines.add("Once you have resolved the conflicts as explained above continue with creating a pull request towards the [" + repoName + "](" + targetRepo.get().webUrl() + ") with the title \"Backport " + hash.hex() + "\"."); + + reply.println(String.join("\n", lines)); + localRepo.reset(head, true); + return; + } + + var backportHash = localRepo.commit("Backport " + hash.hex(), "duke", "duke@openjdk.org"); + localRepo.push(backportHash, fork.url(), backportBranch.name(), true); + var message = CommitMessageParsers.v1.parse(commit); + var formatter = DateTimeFormatter.ofPattern("d MMM uuuu"); + var lines = new ArrayList(); + lines.add("Hi all,"); + lines.add(""); + lines.add("this is an _automatically_ generated pull request containing a backport of " + + "[" + hash.abbreviate() + "](" + commit.url() + ") as requested by " + + "@" + username); + lines.add(""); + var info = "The commit being backported was authored by " + commit.author().name() + " on " + + commit.committed().format(formatter); + if (message.reviewers().isEmpty()) { + info += " and had no reviewers"; + } else { + var reviewers = message.reviewers() + .stream() + .map(r -> censusInstance.census().contributor(r)) + .map(c -> { + var link = "[" + c.username() + "](https://openjdk.java.net/census#" + + c.username() + ")"; + return c.fullName().isPresent() ? + c.fullName() + " (" + link + ")" : + link; + }) + .collect(Collectors.toList()); + var numReviewers = reviewers.size(); + var listing = numReviewers == 1 ? + reviewers.get(0) : + String.join(", ", reviewers.subList(0, numReviewers - 1)); + if (numReviewers > 1) { + listing += " and " + reviewers.get(numReviewers - 1); + } + info += " and was reviewed by " + listing; + } + info += "."; + lines.add(info); + lines.add(""); + lines.add("Thanks,"); + lines.add("J. Duke"); + + var prFromFork = fork.createPullRequest(targetRepo.get(), + "master", + backportBranch.name(), + "Backport " + hash.hex(), + lines); + var prFromTarget = targetRepo.get().pullRequest(prFromFork.id()); + reply.println("@" + command.user().username() + " backport pull request [#" + prFromTarget.id() + "](" + prFromFork.webUrl() + ") targeting repository [" + targetRepo.get().name() + "](" + targetRepo.get().webUrl() + ") created successfully."); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } +} diff --git a/bots/pr/src/main/java/org/openjdk/skara/bots/pr/CommitCommandWorkItem.java b/bots/pr/src/main/java/org/openjdk/skara/bots/pr/CommitCommandWorkItem.java index 614a24add..8b58f864c 100644 --- a/bots/pr/src/main/java/org/openjdk/skara/bots/pr/CommitCommandWorkItem.java +++ b/bots/pr/src/main/java/org/openjdk/skara/bots/pr/CommitCommandWorkItem.java @@ -43,7 +43,8 @@ public class CommitCommandWorkItem implements WorkItem { private static final Logger log = Logger.getLogger("org.openjdk.skara.bots.pr"); private static final Map commandHandlers = Map.ofEntries( - Map.entry("help", new HelpCommand()) + Map.entry("help", new HelpCommand()), + Map.entry("backport", new BackportCommand()) ); static class HelpCommand implements CommandHandler { diff --git a/bots/pr/src/main/java/org/openjdk/skara/bots/pr/PullRequestBot.java b/bots/pr/src/main/java/org/openjdk/skara/bots/pr/PullRequestBot.java index 6f98aa20e..00f1f05d7 100644 --- a/bots/pr/src/main/java/org/openjdk/skara/bots/pr/PullRequestBot.java +++ b/bots/pr/src/main/java/org/openjdk/skara/bots/pr/PullRequestBot.java @@ -62,6 +62,7 @@ class PullRequestBot implements Bot { private final ConcurrentMap currentLabels; private final ConcurrentHashMap scheduledRechecks; private final PullRequestUpdateCache updateCache; + private final Map forks; private final Logger log = Logger.getLogger("org.openjdk.skara.bots.pr"); private Instant lastFullUpdate; @@ -74,7 +75,8 @@ class PullRequestBot implements Bot { Map readyComments, IssueProject issueProject, boolean ignoreStaleReviews, Pattern allowedTargetBranches, Path seedStorage, HostedRepository confOverrideRepo, String confOverrideName, - String confOverrideRef, String censusLink, List commitCommandUsers) { + String confOverrideRef, String censusLink, List commitCommandUsers, + Map forks) { remoteRepo = repo; this.censusRepo = censusRepo; this.censusRef = censusRef; @@ -97,6 +99,7 @@ class PullRequestBot implements Bot { this.commitCommandUsers = commitCommandUsers.stream() .map(HostUser::id) .collect(Collectors.toSet()); + this.forks = forks; currentLabels = new ConcurrentHashMap<>(); scheduledRechecks = new ConcurrentHashMap<>(); @@ -271,6 +274,10 @@ Optional seedStorage() { return Optional.ofNullable(seedStorage); } + Optional hostedRepositoryPool() { + return seedStorage().map(path -> new HostedRepositoryPool(path)); + } + Optional confOverrideRepository() { return Optional.ofNullable(confOverrideRepo); } @@ -289,4 +296,12 @@ Optional censusLink(Contributor contributor) { } return Optional.of(URI.create(censusLink.replace("{{contributor}}", contributor.username()))); } + + HostedRepository writeableForkOf(HostedRepository upstream) { + var fork = forks.get(upstream.name()); + if (fork == null) { + throw new IllegalArgumentException("No writeable fork for " + upstream.name()); + } + return fork; + } } diff --git a/bots/pr/src/main/java/org/openjdk/skara/bots/pr/PullRequestBotBuilder.java b/bots/pr/src/main/java/org/openjdk/skara/bots/pr/PullRequestBotBuilder.java index cc8dda1ea..6840cdfad 100644 --- a/bots/pr/src/main/java/org/openjdk/skara/bots/pr/PullRequestBotBuilder.java +++ b/bots/pr/src/main/java/org/openjdk/skara/bots/pr/PullRequestBotBuilder.java @@ -52,6 +52,7 @@ public class PullRequestBotBuilder { private String confOverrideRef = Branch.defaultFor(VCS.GIT).name(); private String censusLink = null; private List commitCommandUsers = List.of(); + private Map forks = Map.of(); PullRequestBotBuilder() { } @@ -151,11 +152,16 @@ public PullRequestBotBuilder commitCommandUsers(List commitCommandUser return this; } + public PullRequestBotBuilder forks(Map forks) { + this.forks = forks; + return this; + } + public PullRequestBot build() { return new PullRequestBot(repo, censusRepo, censusRef, labelConfiguration, externalCommands, blockingCheckLabels, readyLabels, twoReviewersLabels, twentyFourHoursLabels, readyComments, issueProject, ignoreStaleReviews, allowedTargetBranches, seedStorage, confOverrideRepo, confOverrideName, - confOverrideRef, censusLink, commitCommandUsers); + confOverrideRef, censusLink, commitCommandUsers, forks); } } diff --git a/bots/pr/src/main/java/org/openjdk/skara/bots/pr/PullRequestBotFactory.java b/bots/pr/src/main/java/org/openjdk/skara/bots/pr/PullRequestBotFactory.java index 64bd3dc3f..132d4d3b8 100644 --- a/bots/pr/src/main/java/org/openjdk/skara/bots/pr/PullRequestBotFactory.java +++ b/bots/pr/src/main/java/org/openjdk/skara/bots/pr/PullRequestBotFactory.java @@ -56,6 +56,14 @@ public List create(BotConfiguration configuration) { } } + var forks = new HashMap(); + if (specific.contains("forks")) { + for (var fork : specific.get("forks").asArray()) { + var repo = configuration.repository(fork.asString()); + forks.put(repo.name(), repo); + } + } + var readyLabels = specific.get("ready").get("labels").stream() .map(JSONValue::asString) .collect(Collectors.toSet()); @@ -89,7 +97,8 @@ public List create(BotConfiguration configuration) { .readyLabels(readyLabels) .readyComments(readyComments) .externalCommands(external) - .seedStorage(configuration.storageFolder().resolve("seeds")); + .seedStorage(configuration.storageFolder().resolve("seeds")) + .forks(forks); if (repo.value().contains("labels")) { var labelGroup = repo.value().get("labels").asString(); diff --git a/bots/pr/src/test/java/org/openjdk/skara/bots/pr/BackportCommitCommandTests.java b/bots/pr/src/test/java/org/openjdk/skara/bots/pr/BackportCommitCommandTests.java new file mode 100644 index 000000000..1f5c5c079 --- /dev/null +++ b/bots/pr/src/test/java/org/openjdk/skara/bots/pr/BackportCommitCommandTests.java @@ -0,0 +1,229 @@ +/* + * Copyright (c) 2020, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ +package org.openjdk.skara.bots.pr; + +import org.junit.jupiter.api.*; +import org.openjdk.skara.issuetracker.Issue; +import org.openjdk.skara.test.*; + +import java.io.IOException; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; + +public class BackportCommitCommandTests { + @Test + void simple(TestInfo testInfo) throws IOException { + try (var credentials = new HostCredentials(testInfo); + var tempFolder = new TemporaryDirectory()) { + var author = credentials.getHostedRepository(); + var reviewer = credentials.getHostedRepository(); + + var censusBuilder = credentials.getCensusBuilder() + .addAuthor(author.forge().currentUser().id()) + .addReviewer(reviewer.forge().currentUser().id()); + var seedFolder = tempFolder.path().resolve("seed"); + var bot = PullRequestBot.newBuilder() + .repo(author) + .censusRepo(censusBuilder.build()) + .censusLink("https://census.com/{{contributor}}-profile") + .seedStorage(seedFolder) + .commitCommandUsers(List.of(author.forge().currentUser())) + .forks(Map.of(author.name(), author)) + .build(); + + // Populate the projects repository + var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType()); + var masterHash = CheckableRepository.appendAndCommit(localRepo); + localRepo.push(masterHash, author.url(), "master", true); + + // Make a change in another branch + var editHash = CheckableRepository.appendAndCommit(localRepo); + localRepo.push(editHash, author.url(), "edit"); + + // Add a backport command + author.addCommitComment(editHash, "/backport " + author.name()); + TestBotRunner.runPeriodicItems(bot); + + var recentCommitComments = author.recentCommitComments(); + assertEquals(2, recentCommitComments.size()); + var botReply = recentCommitComments.get(0); + assertTrue(botReply.body().contains("backport")); + assertTrue(botReply.body().contains("created successfully.")); + + var pulls = author.pullRequests(); + assertEquals(1, pulls.size()); + var pr = pulls.get(0); + assertEquals("Backport " + editHash.hex(), pr.title()); + assertEquals("master", pr.targetRef()); + + var prDiff = pr.diff(); + var commitDiff = localRepo.diff(masterHash, editHash); + assertEquals(1, commitDiff.patches().size()); + assertEquals(1, prDiff.patches().size()); + + var commitPatch = commitDiff.patches().get(0); + var prPatch = commitDiff.patches().get(0); + assertEquals(commitPatch.status(), prPatch.status()); + assertEquals(commitPatch.target().path(), prPatch.target().path()); + assertEquals(commitPatch.source().path(), prPatch.source().path()); + + var commitHunks = commitPatch.asTextualPatch().hunks(); + var prHunks = prPatch.asTextualPatch().hunks(); + assertEquals(commitHunks.size(), prHunks.size()); + for (var i = 0; i < commitHunks.size(); i++) { + var commitHunk = commitHunks.get(i); + var prHunk = prHunks.get(i); + assertEquals(commitHunk.target().lines(), prHunk.target().lines()); + assertEquals(commitHunk.source().lines(), prHunk.source().lines()); + } + } + } + + @Test + void unknownTargetRepo(TestInfo testInfo) throws IOException { + try (var credentials = new HostCredentials(testInfo); + var tempFolder = new TemporaryDirectory()) { + var author = credentials.getHostedRepository(); + var reviewer = credentials.getHostedRepository(); + + var censusBuilder = credentials.getCensusBuilder() + .addAuthor(author.forge().currentUser().id()) + .addReviewer(reviewer.forge().currentUser().id()); + var seedFolder = tempFolder.path().resolve("seed"); + var bot = PullRequestBot.newBuilder() + .repo(author) + .censusRepo(censusBuilder.build()) + .censusLink("https://census.com/{{contributor}}-profile") + .seedStorage(seedFolder) + .commitCommandUsers(List.of(author.forge().currentUser())) + .forks(Map.of(author.name(), author)) + .build(); + + // Populate the projects repository + var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType()); + var masterHash = CheckableRepository.appendAndCommit(localRepo); + localRepo.push(masterHash, author.url(), "master", true); + + // Make a change in another branch + var editHash = CheckableRepository.appendAndCommit(localRepo); + localRepo.push(editHash, author.url(), "edit"); + + // Add a backport command + author.addCommitComment(editHash, "/backport non-existing-repo"); + TestBotRunner.runPeriodicItems(bot); + + var recentCommitComments = author.recentCommitComments(); + assertEquals(2, recentCommitComments.size()); + var botReply = recentCommitComments.get(0); + assertTrue(botReply.body().contains("target repository")); + assertTrue(botReply.body().contains("does not exist")); + assertEquals(List.of(), author.pullRequests()); + } + } + + @Test + void unknownTargetBranch(TestInfo testInfo) throws IOException { + try (var credentials = new HostCredentials(testInfo); + var tempFolder = new TemporaryDirectory()) { + var author = credentials.getHostedRepository(); + var reviewer = credentials.getHostedRepository(); + + var censusBuilder = credentials.getCensusBuilder() + .addAuthor(author.forge().currentUser().id()) + .addReviewer(reviewer.forge().currentUser().id()); + var seedFolder = tempFolder.path().resolve("seed"); + var bot = PullRequestBot.newBuilder() + .repo(author) + .censusRepo(censusBuilder.build()) + .censusLink("https://census.com/{{contributor}}-profile") + .seedStorage(seedFolder) + .commitCommandUsers(List.of(author.forge().currentUser())) + .forks(Map.of(author.name(), author)) + .build(); + + // Populate the projects repository + var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType()); + var masterHash = CheckableRepository.appendAndCommit(localRepo); + localRepo.push(masterHash, author.url(), "master", true); + + // Make a change in another branch + var editHash = CheckableRepository.appendAndCommit(localRepo); + localRepo.push(editHash, author.url(), "edit"); + + // Add a backport command + author.addCommitComment(editHash, "/backport " + author.name() + " non-existing-branch"); + TestBotRunner.runPeriodicItems(bot); + + var recentCommitComments = author.recentCommitComments(); + assertEquals(2, recentCommitComments.size()); + var botReply = recentCommitComments.get(0); + assertTrue(botReply.body().contains("target branch")); + assertTrue(botReply.body().contains("does not exist")); + assertEquals(List.of(), author.pullRequests()); + } + } + + @Test + void backportDoesNotApply(TestInfo testInfo) throws IOException { + try (var credentials = new HostCredentials(testInfo); + var tempFolder = new TemporaryDirectory()) { + var author = credentials.getHostedRepository(); + var reviewer = credentials.getHostedRepository(); + + var censusBuilder = credentials.getCensusBuilder() + .addAuthor(author.forge().currentUser().id()) + .addReviewer(reviewer.forge().currentUser().id()); + var seedFolder = tempFolder.path().resolve("seed"); + var bot = PullRequestBot.newBuilder() + .repo(author) + .censusRepo(censusBuilder.build()) + .censusLink("https://census.com/{{contributor}}-profile") + .seedStorage(seedFolder) + .commitCommandUsers(List.of(author.forge().currentUser())) + .forks(Map.of(author.name(), author)) + .build(); + + // Populate the projects repository + var localRepo = CheckableRepository.init(tempFolder.path(), author.repositoryType()); + var masterHash = CheckableRepository.appendAndCommit(localRepo); + localRepo.push(masterHash, author.url(), "master", true); + + // Make a change push it to edit branch + var editHash = CheckableRepository.appendAndCommit(localRepo); + localRepo.push(editHash, author.url(), "edit", true); + + var masterHash2 = CheckableRepository.appendAndCommit(localRepo); + localRepo.push(masterHash2, author.url(), "master", true); + + // Add a backport command + author.addCommitComment(editHash, "/backport " + author.name() + " master"); + TestBotRunner.runPeriodicItems(bot); + + var recentCommitComments = author.recentCommitComments(); + assertEquals(2, recentCommitComments.size()); + var botReply = recentCommitComments.get(0); + assertTrue(botReply.body().contains(":warning: could not backport")); + assertEquals(List.of(), author.pullRequests()); + } + } +} diff --git a/test/src/main/java/org/openjdk/skara/test/TestHostedRepository.java b/test/src/main/java/org/openjdk/skara/test/TestHostedRepository.java index 60295988c..5645e9ccc 100644 --- a/test/src/main/java/org/openjdk/skara/test/TestHostedRepository.java +++ b/test/src/main/java/org/openjdk/skara/test/TestHostedRepository.java @@ -216,6 +216,7 @@ public List recentCommitComments() { return commitComments.values() .stream() .flatMap(e -> e.stream()) + .sorted((c1, c2) -> c2.updatedAt().compareTo(c1.updatedAt())) .collect(Collectors.toList()); }