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.host.*;
 27 import org.openjdk.skara.jcheck.JCheck;
 28 import org.openjdk.skara.vcs.*;
 29 import org.openjdk.skara.vcs.openjdk.*;
 30 
 31 import java.io.*;
 32 import java.nio.file.Path;
 33 import java.util.*;
 34 import java.util.stream.Collectors;
 35 
 36 class PullRequestInstance {
 37     private final PullRequest pr;
 38     private final Repository localRepo;
 39     private final Hash targetHash;
 40     private final Hash headHash;
 41     private final Hash baseHash;
 42 
 43     PullRequestInstance(Path localRepoPath, PullRequest pr) throws IOException  {
 44         this.pr = pr;
 45         var repository = pr.repository();
 46 
 47         // Materialize the PR's target ref
 48         localRepo = Repository.materialize(localRepoPath, repository.getUrl(), pr.getTargetRef());
 49         targetHash = localRepo.fetch(repository.getUrl(), pr.getTargetRef());
 50         headHash = localRepo.fetch(repository.getUrl(), pr.getHeadHash().hex());
 51         baseHash = localRepo.mergeBase(targetHash, headHash);
 52     }
 53 
 54     /**
 55      * The Review list is in chronological order, the latest one from a particular reviewer is the
 56      * one that is "active".
 57      * @param allReviews
 58      * @return
 59      */
 60     static List<Review> filterActiveReviews(List<Review> allReviews) {
 61         var reviewPerUser = new LinkedHashMap<HostUserDetails, Review>();
 62         for (var review : allReviews) {
 63             reviewPerUser.put(review.reviewer(), review);
 64         }
 65         return new ArrayList<>(reviewPerUser.values());
 66     }
 67 
 68     private String commitMessage(List<Review> activeReviews, Namespace namespace, boolean isMerge) throws IOException {
 69         var reviewers = activeReviews.stream()
 70                           .filter(review -> review.verdict() == Review.Verdict.APPROVED)
 71                           .map(review -> review.reviewer().id())
 72                           .map(namespace::get)
 73                           .filter(Objects::nonNull)
 74                           .map(Contributor::username)
 75                           .collect(Collectors.toList());
 76 
 77         var comments = pr.getComments();
 78         var additionalContributors = Contributors.contributors(pr.repository().host().getCurrentUserDetails(),
 79                                                                comments).stream()
 80                                                  .map(email -> Author.fromString(email.toString()))
 81                                                  .collect(Collectors.toList());
 82 
 83         var summary = Summary.summary(pr.repository().host().getCurrentUserDetails(), comments);
 84 
 85         var commitMessage = CommitMessage.title(isMerge ? "Merge" : pr.getTitle())
 86                                          .contributors(additionalContributors)
 87                                          .reviewers(reviewers);
 88         summary.ifPresent(commitMessage::summary);
 89 
 90         return String.join("\n", commitMessage.format(CommitMessageFormatters.v1));
 91     }
 92 
 93     private Hash commitSquashed(List<Review> activeReviews, Namespace namespace, String censusDomain, String sponsorId) throws IOException {
 94         localRepo.checkout(baseHash, true);
 95         localRepo.squash(headHash);
 96 
 97         Author committer;
 98         Author author;
 99         var contributor = namespace.get(pr.getAuthor().id());
100 
101         if (contributor == null) {
102             // Use the information contained in the head commit - jcheck has verified that it contains sane values
103             var headCommit = localRepo.commits(headHash.hex() + "^.." + headHash.hex()).asList().get(0);
104             author = headCommit.author();
105         } else {
106             author = new Author(contributor.fullName().orElseThrow(), contributor.username() + "@" + censusDomain);
107         }
108 
109         if (sponsorId != null) {
110             var sponsorContributor = namespace.get(sponsorId);
111             committer = new Author(sponsorContributor.fullName().orElseThrow(), sponsorContributor.username() + "@" + censusDomain);
112         } else {
113             committer = author;
114         }
115 
116         var commitMessage = commitMessage(activeReviews, namespace, false);
117         return localRepo.commit(commitMessage, author.name(), author.email(), committer.name(), committer.email());
118     }
119 
120     private Hash commitMerge(List<Review> activeReviews, Namespace namespace, String censusDomain) throws IOException {
121         localRepo.checkout(headHash, true);
122 
123         var contributor = namespace.get(pr.getAuthor().id());
124         if (contributor == null) {
125             throw new RuntimeException("Merges can only be performed by Committers");
126         }
127 
128         var author = new Author(contributor.fullName().orElseThrow(), contributor.username() + "@" + censusDomain);
129 
130         var commitMessage = commitMessage(activeReviews, namespace, true);
131         return localRepo.amend(commitMessage, author.name(), author.email(), author.name(), author.email());
132     }
133 
134     Hash commit(Namespace namespace, String censusDomain, String sponsorId) throws IOException {
135         var activeReviews = filterActiveReviews(pr.getReviews());
136         if (!pr.getTitle().startsWith("Merge")) {
137             return commitSquashed(activeReviews, namespace, censusDomain, sponsorId);
138         } else {
139             return commitMerge(activeReviews, namespace, censusDomain);
140         }
141     }
142 
143     List<Commit> divergingCommits() {
144         try {
145             return localRepo.commits(baseHash + ".." + targetHash).asList();
146         } catch (IOException e) {
147             throw new RuntimeException(e);
148         }
149     }
150 
151     boolean rebasePossible(Hash commitHash) {
152         try {
153             var commit = localRepo.lookup(commitHash);
154             if (commit.isEmpty()) {
155                 return false;
156             }
157             localRepo.rebase(targetHash, commit.get().committer().name(), commit.get().committer().email());
158             var hash = localRepo.head();
159             return !hash.hex().equals(targetHash.hex());
160         } catch (IOException e) {
161             return false;
162         }
163     }
164 
165     Optional<Hash> rebase(Hash commitHash, PrintWriter reply) {
166         var divergingCommits = divergingCommits();
167         if (divergingCommits.size() > 0) {
168             reply.print("The following commits have been pushed to ");
169             reply.print(pr.getTargetRef());
170             reply.println(" since your change was applied:");
171             divergingCommits.forEach(c -> reply.println(" * " + c.hash()));
172 
173             try {
174                 var commit = localRepo.lookup(commitHash).orElseThrow();
175                 localRepo.rebase(targetHash, commit.committer().name(), commit.committer().email());
176                 reply.println();
177                 reply.println("Your commit was automatically rebased without conflicts.");
178                 var hash = localRepo.head();
179                 if (hash.hex().equals(targetHash.hex())) {
180                     reply.print("Warning! Your commit did not result in any changes! ");
181                     reply.println("No push attempt will be made.");
182                     return Optional.empty();
183                 } else {
184                     return Optional.of(hash);
185                 }
186             } catch (IOException e) {
187                 reply.println();
188                 reply.print("It was not possible to rebase your changes automatically. ");
189                 reply.println("Please rebase your branch manually and try again.");
190                 return Optional.empty();
191             }
192         } else {
193             // No rebase needed
194             return Optional.of(commitHash);
195         }
196     }
197 
198     Repository localRepo() {
199         return this.localRepo;
200     }
201 
202     Hash baseHash() {
203         return this.baseHash;
204     }
205 
206     Set<Path> changedFiles() throws IOException {
207         var ret = new HashSet<Path>();
208         var changes = localRepo.diff(baseHash, headHash);
209         for (var patch : changes.patches()) {
210             patch.target().path().ifPresent(ret::add);
211             patch.source().path().ifPresent(ret::add);
212         }
213         return ret;
214     }
215 
216     PullRequestCheckIssueVisitor executeChecks(Hash localHash, CensusInstance censusInstance) throws Exception {
217         var checks = JCheck.checks(localRepo(), censusInstance.census(), localHash);
218         var visitor = new PullRequestCheckIssueVisitor(checks);
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         return visitor;
227     }
228 }