1 /* 2 * Copyright (c) 2019, Oracle and/or its affiliates. All rights reserved. 3 * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. 4 * 5 * This code is free software; you can redistribute it and/or modify it 6 * under the terms of the GNU General Public License version 2 only, as 7 * published by the Free Software Foundation. 8 * 9 * This code is distributed in the hope that it will be useful, but WITHOUT 10 * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or 11 * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License 12 * version 2 for more details (a copy is included in the LICENSE file that 13 * accompanied this code). 14 * 15 * You should have received a copy of the GNU General Public License version 16 * 2 along with this work; if not, write to the Free Software Foundation, 17 * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. 18 * 19 * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA 20 * or visit www.oracle.com if you need additional information or have any 21 * questions. 22 */ 23 package org.openjdk.skara.bots.pr; 24 25 import org.openjdk.skara.census.*; 26 import org.openjdk.skara.forge.*; 27 import org.openjdk.skara.host.*; 28 import org.openjdk.skara.jcheck.JCheck; 29 import org.openjdk.skara.vcs.*; 30 import org.openjdk.skara.vcs.openjdk.Issue; 31 import org.openjdk.skara.vcs.openjdk.*; 32 33 import java.io.*; 34 import java.nio.file.Path; 35 import java.util.*; 36 import java.util.stream.Collectors; 37 38 class PullRequestInstance { 39 private final PullRequest pr; 40 private final Repository localRepo; 41 private final Hash targetHash; 42 private final Hash headHash; 43 private final Hash baseHash; 44 45 PullRequestInstance(Path localRepoPath, PullRequest pr) throws IOException { 46 this.pr = pr; 47 var repository = pr.repository(); 48 49 // Materialize the PR's target ref 50 localRepo = Repository.materialize(localRepoPath, repository.url(), 51 "+" + pr.targetRef() + ":pr_prinstance_" + repository.name()); 52 targetHash = localRepo.fetch(repository.url(), pr.targetRef()); 53 headHash = localRepo.fetch(repository.url(), pr.headHash().hex()); 54 baseHash = localRepo.mergeBase(targetHash, headHash); 55 } 56 57 /** 58 * The Review list is in chronological order, the latest one from a particular reviewer is the 59 * one that is "active". 60 * @param allReviews 61 * @return 62 */ 63 static List<Review> filterActiveReviews(List<Review> allReviews) { 64 var reviewPerUser = new LinkedHashMap<HostUser, Review>(); 65 for (var review : allReviews) { 66 reviewPerUser.put(review.reviewer(), review); 67 } 68 return new ArrayList<>(reviewPerUser.values()); 69 } 70 71 private String commitMessage(List<Review> activeReviews, Namespace namespace, boolean isMerge) throws IOException { 72 var reviewers = activeReviews.stream() 73 .filter(review -> review.verdict() == Review.Verdict.APPROVED) 74 .map(review -> review.reviewer().id()) 75 .map(namespace::get) 76 .filter(Objects::nonNull) 77 .map(Contributor::username) 78 .collect(Collectors.toList()); 79 80 var comments = pr.comments(); 81 var additionalContributors = Contributors.contributors(pr.repository().forge().currentUser(), 82 comments).stream() 83 .map(email -> Author.fromString(email.toString())) 84 .collect(Collectors.toList()); 85 86 var additionalIssues = SolvesTracker.currentSolved(pr.repository().forge().currentUser(), comments); 87 var summary = Summary.summary(pr.repository().forge().currentUser(), comments); 88 var issue = Issue.fromString(pr.title()); 89 var commitMessageBuilder = issue.map(CommitMessage::title).orElseGet(() -> CommitMessage.title(isMerge ? "Merge" : pr.title())); 90 if (issue.isPresent()) { 91 commitMessageBuilder.issues(additionalIssues); 92 } 93 commitMessageBuilder.contributors(additionalContributors) 94 .reviewers(reviewers); 95 summary.ifPresent(commitMessageBuilder::summary); 96 97 return String.join("\n", commitMessageBuilder.format(CommitMessageFormatters.v1)); 98 } 99 100 private Hash commitSquashed(List<Review> activeReviews, Namespace namespace, String censusDomain, String sponsorId) throws IOException { 101 localRepo.checkout(baseHash, true); 102 localRepo.squash(headHash); 103 if (localRepo.isClean()) { 104 // There are no changes remaining after squashing 105 return baseHash; 106 } 107 108 Author committer; 109 Author author; 110 var contributor = namespace.get(pr.author().id()); 111 112 if (contributor == null) { 113 // Use the information contained in the head commit - jcheck has verified that it contains sane values 114 var headCommit = localRepo.commits(headHash.hex() + "^.." + headHash.hex()).asList().get(0); 115 author = headCommit.author(); 116 } else { 117 author = new Author(contributor.fullName().orElseThrow(), contributor.username() + "@" + censusDomain); 118 } 119 120 if (sponsorId != null) { 121 var sponsorContributor = namespace.get(sponsorId); 122 committer = new Author(sponsorContributor.fullName().orElseThrow(), sponsorContributor.username() + "@" + censusDomain); 123 } else { 124 committer = author; 125 } 126 127 var commitMessage = commitMessage(activeReviews, namespace, false); 128 return localRepo.commit(commitMessage, author.name(), author.email(), committer.name(), committer.email()); 129 } 130 131 private Hash commitMerge(List<Review> activeReviews, Namespace namespace, String censusDomain) throws IOException { 132 localRepo.checkout(headHash, true); 133 134 var contributor = namespace.get(pr.author().id()); 135 if (contributor == null) { 136 throw new RuntimeException("Merges can only be performed by Committers"); 137 } 138 139 var author = new Author(contributor.fullName().orElseThrow(), contributor.username() + "@" + censusDomain); 140 141 var commitMessage = commitMessage(activeReviews, namespace, true); 142 return localRepo.amend(commitMessage, author.name(), author.email(), author.name(), author.email()); 143 } 144 145 Hash commit(Namespace namespace, String censusDomain, String sponsorId) throws IOException { 146 var activeReviews = filterActiveReviews(pr.reviews()); 147 if (!pr.title().startsWith("Merge")) { 148 return commitSquashed(activeReviews, namespace, censusDomain, sponsorId); 149 } else { 150 return commitMerge(activeReviews, namespace, censusDomain); 151 } 152 } 153 154 List<Commit> divergingCommits() { 155 try { 156 return localRepo.commits(baseHash + ".." + targetHash).asList(); 157 } catch (IOException e) { 158 throw new RuntimeException(e); 159 } 160 } 161 162 Optional<Hash> rebase(Hash commitHash, PrintWriter reply) { 163 var divergingCommits = divergingCommits(); 164 if (divergingCommits.size() > 0) { 165 reply.print("The following commits have been pushed to "); 166 reply.print(pr.targetRef()); 167 reply.println(" since your change was applied:"); 168 divergingCommits.forEach(c -> reply.println(" * " + c.hash().hex() + ": " + c.message().get(0))); 169 170 try { 171 var commit = localRepo.lookup(commitHash).orElseThrow(); 172 localRepo.rebase(targetHash, commit.committer().name(), commit.committer().email()); 173 reply.println(); 174 reply.println("Your commit was automatically rebased without conflicts."); 175 var hash = localRepo.head(); 176 return Optional.of(hash); 177 } catch (IOException e) { 178 reply.println(); 179 reply.print("It was not possible to rebase your changes automatically. Please merge `"); 180 reply.print(pr.targetRef()); 181 reply.println("` into your branch and try again."); 182 try { 183 localRepo.checkout(commitHash, true); 184 } catch (IOException e2) { 185 throw new UncheckedIOException(e2); 186 } 187 return Optional.empty(); 188 } 189 } else { 190 // No rebase needed 191 return Optional.of(commitHash); 192 } 193 } 194 195 Repository localRepo() { 196 return this.localRepo; 197 } 198 199 Hash baseHash() { 200 return this.baseHash; 201 } 202 203 Set<Path> changedFiles() throws IOException { 204 var ret = new HashSet<Path>(); 205 var changes = localRepo.diff(baseHash, headHash); 206 for (var patch : changes.patches()) { 207 patch.target().path().ifPresent(ret::add); 208 patch.source().path().ifPresent(ret::add); 209 } 210 return ret; 211 } 212 213 PullRequestCheckIssueVisitor createVisitor(Hash localHash, CensusInstance censusInstance) throws IOException { 214 var checks = JCheck.checks(localRepo(), censusInstance.census(), localHash); 215 return new PullRequestCheckIssueVisitor(checks); 216 } 217 218 void executeChecks(Hash localHash, CensusInstance censusInstance, PullRequestCheckIssueVisitor visitor) throws Exception { 219 try (var issues = JCheck.check(localRepo(), censusInstance.census(), CommitMessageParsers.v1, "HEAD~1..HEAD", 220 localHash, new HashMap<>(), new HashSet<>())) { 221 for (var issue : issues) { 222 issue.accept(visitor); 223 } 224 } 225 } 226 }