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(), pr.targetRef());
 51         targetHash = localRepo.fetch(repository.url(), pr.targetRef());
 52         headHash = localRepo.fetch(repository.url(), pr.headHash().hex());
 53         baseHash = localRepo.mergeBase(targetHash, headHash);
 54     }
 55 
 56     /**
 57      * The Review list is in chronological order, the latest one from a particular reviewer is the
 58      * one that is "active".
 59      * @param allReviews
 60      * @return
 61      */
 62     static List<Review> filterActiveReviews(List<Review> allReviews) {
 63         var reviewPerUser = new LinkedHashMap<HostUser, Review>();
 64         for (var review : allReviews) {
 65             reviewPerUser.put(review.reviewer(), review);
 66         }
 67         return new ArrayList<>(reviewPerUser.values());
 68     }
 69 
 70     private String commitMessage(List<Review> activeReviews, Namespace namespace, boolean isMerge) throws IOException {
 71         var reviewers = activeReviews.stream()
 72                           .filter(review -> review.verdict() == Review.Verdict.APPROVED)
 73                           .map(review -> review.reviewer().id())
 74                           .map(namespace::get)
 75                           .filter(Objects::nonNull)
 76                           .map(Contributor::username)
 77                           .collect(Collectors.toList());
 78 
 79         var comments = pr.comments();
 80         var additionalContributors = Contributors.contributors(pr.repository().forge().currentUser(),
 81                                                                comments).stream()
 82                                                  .map(email -> Author.fromString(email.toString()))
 83                                                  .collect(Collectors.toList());
 84 
 85         var additionalIssues = SolvesTracker.currentSolved(pr.repository().forge().currentUser(), comments);
 86         var summary = Summary.summary(pr.repository().forge().currentUser(), comments);
 87         var issue = Issue.fromString(pr.title());
 88         var commitMessageBuilder = issue.map(CommitMessage::title).orElseGet(() -> CommitMessage.title(isMerge ? "Merge" : pr.title()));
 89         if (issue.isPresent()) {
 90             commitMessageBuilder.issues(additionalIssues);
 91         }
 92         commitMessageBuilder.contributors(additionalContributors)
 93                             .reviewers(reviewers);
 94         summary.ifPresent(commitMessageBuilder::summary);
 95 
 96         return String.join("\n", commitMessageBuilder.format(CommitMessageFormatters.v1));
 97     }
 98 
 99     private Hash commitSquashed(List<Review> activeReviews, Namespace namespace, String censusDomain, String sponsorId) throws IOException {
100         localRepo.checkout(baseHash, true);
101         localRepo.squash(headHash);
102         if (localRepo.isClean()) {
103             // There are no changes remaining after squashing
104             return baseHash;
105         }
106 
107         Author committer;
108         Author author;
109         var contributor = namespace.get(pr.author().id());
110 
111         if (contributor == null) {
112             // Use the information contained in the head commit - jcheck has verified that it contains sane values
113             var headCommit = localRepo.commits(headHash.hex() + "^.." + headHash.hex()).asList().get(0);
114             author = headCommit.author();
115         } else {
116             author = new Author(contributor.fullName().orElseThrow(), contributor.username() + "@" + censusDomain);
117         }
118 
119         if (sponsorId != null) {
120             var sponsorContributor = namespace.get(sponsorId);
121             committer = new Author(sponsorContributor.fullName().orElseThrow(), sponsorContributor.username() + "@" + censusDomain);
122         } else {
123             committer = author;
124         }
125 
126         var commitMessage = commitMessage(activeReviews, namespace, false);
127         return localRepo.commit(commitMessage, author.name(), author.email(), committer.name(), committer.email());
128     }
129 
130     private Hash commitMerge(List<Review> activeReviews, Namespace namespace, String censusDomain) throws IOException {
131         localRepo.checkout(headHash, true);
132 
133         var contributor = namespace.get(pr.author().id());
134         if (contributor == null) {
135             throw new RuntimeException("Merges can only be performed by Committers");
136         }
137 
138         var author = new Author(contributor.fullName().orElseThrow(), contributor.username() + "@" + censusDomain);
139 
140         var commitMessage = commitMessage(activeReviews, namespace, true);
141         return localRepo.amend(commitMessage, author.name(), author.email(), author.name(), author.email());
142     }
143 
144     Hash commit(Namespace namespace, String censusDomain, String sponsorId) throws IOException {
145         var activeReviews = filterActiveReviews(pr.reviews());
146         if (!pr.title().startsWith("Merge")) {
147             return commitSquashed(activeReviews, namespace, censusDomain, sponsorId);
148         } else {
149             return commitMerge(activeReviews, namespace, censusDomain);
150         }
151     }
152 
153     List<Commit> divergingCommits() {
154         try {
155             return localRepo.commits(baseHash + ".." + targetHash).asList();
156         } catch (IOException e) {
157             throw new RuntimeException(e);
158         }
159     }
160 
161     Optional<Hash> rebase(Hash commitHash, PrintWriter reply) {
162         var divergingCommits = divergingCommits();
163         if (divergingCommits.size() > 0) {
164             reply.print("The following commits have been pushed to ");
165             reply.print(pr.targetRef());
166             reply.println(" since your change was applied:");
167             divergingCommits.forEach(c -> reply.println(" * " + c.hash().hex() + ": " + c.message().get(0)));
168 
169             try {
170                 var commit = localRepo.lookup(commitHash).orElseThrow();
171                 localRepo.rebase(targetHash, commit.committer().name(), commit.committer().email());
172                 reply.println();
173                 reply.println("Your commit was automatically rebased without conflicts.");
174                 var hash = localRepo.head();
175                 return Optional.of(hash);
176             } catch (IOException e) {
177                 reply.println();
178                 reply.print("It was not possible to rebase your changes automatically. Please merge `");
179                 reply.print(pr.targetRef());
180                 reply.println("` into your branch and try again.");
181                 try {
182                     localRepo.checkout(commitHash, true);
183                 } catch (IOException e2) {
184                     throw new UncheckedIOException(e2);
185                 }
186                 return Optional.empty();
187             }
188         } else {
189             // No rebase needed
190             return Optional.of(commitHash);
191         }
192     }
193 
194     Repository localRepo() {
195         return this.localRepo;
196     }
197 
198     Hash baseHash() {
199         return this.baseHash;
200     }
201 
202     Set<Path> changedFiles() throws IOException {
203         var ret = new HashSet<Path>();
204         var changes = localRepo.diff(baseHash, headHash);
205         for (var patch : changes.patches()) {
206             patch.target().path().ifPresent(ret::add);
207             patch.source().path().ifPresent(ret::add);
208         }
209         return ret;
210     }
211 
212     PullRequestCheckIssueVisitor createVisitor(Hash localHash, CensusInstance censusInstance) throws IOException {
213         var checks = JCheck.checks(localRepo(), censusInstance.census(), localHash);
214         return new PullRequestCheckIssueVisitor(checks);
215     }
216 
217     void executeChecks(Hash localHash, CensusInstance censusInstance, PullRequestCheckIssueVisitor visitor) throws Exception {
218         try (var issues = JCheck.check(localRepo(), censusInstance.census(), CommitMessageParsers.v1, "HEAD~1..HEAD",
219                                        localHash, new HashMap<>(), new HashSet<>())) {
220             for (var issue : issues) {
221                 issue.accept(visitor);
222             }
223         }
224     }
225 }