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 }