1 package org.openjdk.skara.bots.mlbridge;
  2 
  3 import org.openjdk.skara.census.Contributor;
  4 import org.openjdk.skara.email.*;
  5 import org.openjdk.skara.forge.*;
  6 import org.openjdk.skara.host.*;
  7 import org.openjdk.skara.issuetracker.Comment;
  8 import org.openjdk.skara.vcs.Hash;
  9 
 10 import java.net.URI;
 11 import java.nio.charset.StandardCharsets;
 12 import java.security.*;
 13 import java.util.*;
 14 import java.util.stream.*;
 15 
 16 class ReviewArchive {
 17     private final PullRequestInstance prInstance;
 18     private final CensusInstance censusInstance;
 19     private final EmailAddress sender;
 20     private final List<Email> existing;
 21     private final Map<String, Email> existingIds = new HashMap<>();
 22     private final List<Email> generated = new ArrayList<>();
 23     private final Map<String, Email> generatedIds = new HashMap<>();
 24     private final Set<EmailAddress> approvalIds = new HashSet<>();
 25     private final List<Hash> reportedHeads;
 26     private final List<Hash> reportedBases;
 27 
 28     private EmailAddress getAuthorAddress(HostUser originalAuthor) {
 29         var contributor = censusInstance.namespace().get(originalAuthor.id());
 30         if (contributor == null) {
 31             return EmailAddress.from(originalAuthor.fullName(),
 32                                      censusInstance.namespace().name() + "+" +
 33                                              originalAuthor.id() + "+" + originalAuthor.userName() + "@" +
 34                                              censusInstance.configuration().census().domain());
 35         } else {
 36             return EmailAddress.from(contributor.fullName().orElse(originalAuthor.fullName()),
 37                                      contributor.username() + "@" + censusInstance.configuration().census().domain());
 38         }
 39     }
 40 
 41     private EmailAddress getUniqueMessageId(String identifier) {
 42         try {
 43             var prSpecific = prInstance.pr().repository().name().replace("/", ".") + "." + prInstance.id();
 44             var digest = MessageDigest.getInstance("SHA-256");
 45             digest.update(prSpecific.getBytes(StandardCharsets.UTF_8));
 46             digest.update(identifier.getBytes(StandardCharsets.UTF_8));
 47             var encodedCommon = Base64.getUrlEncoder().encodeToString(digest.digest());
 48 
 49             return EmailAddress.from(encodedCommon + "." + UUID.randomUUID() + "@" + prInstance.pr().repository().url().getHost());
 50         } catch (NoSuchAlgorithmException e) {
 51             throw new RuntimeException("Cannot find SHA-256");
 52         }
 53     }
 54 
 55     private EmailAddress getMessageId() {
 56         return getUniqueMessageId("fc");
 57     }
 58 
 59     private EmailAddress getMessageId(Comment comment) {
 60         return getUniqueMessageId("pc" + comment.id());
 61     }
 62 
 63     private EmailAddress getMessageId(ReviewComment comment) {
 64         return getUniqueMessageId("rc" + comment.id());
 65     }
 66 
 67     private EmailAddress getMessageId(Hash hash) {
 68         return getUniqueMessageId("ha" + hash.hex());
 69     }
 70 
 71     private EmailAddress getMessageId(Review review) {
 72         return getUniqueMessageId("rv" + review.id());
 73     }
 74 
 75     private String getStableMessageId(EmailAddress uniqueMessageId) {
 76         return uniqueMessageId.localPart().split("\\.")[0];
 77     }
 78 
 79     private Set<String> getStableMessageIds(Email email) {
 80         var ret = new HashSet<String>();
 81         ret.add(getStableMessageId(email.id()));
 82         if (email.hasHeader("PR-Collapsed-IDs")) {
 83             var additional = email.headerValue("PR-Collapsed-IDs").split(" ");
 84             ret.addAll(Arrays.asList(additional));
 85         }
 86         return ret;
 87     }
 88 
 89     private Email topEmail() {
 90         if (!existing.isEmpty()) {
 91             return existing.get(0);
 92         }
 93         return generated.get(0);
 94     }
 95 
 96     // Returns a suitable parent to use for a general comment
 97     private Email latestGeneralComment() {
 98         return Stream.concat(existing.stream(), generated.stream())
 99                      .filter(email -> !email.hasHeader("PR-Head-Hash"))
100                      .filter(email -> email.subject().startsWith("Re: RFR"))
101                      .max(Comparator.comparingInt(email -> Integer.parseInt(email.headerValue("PR-Sequence"))))
102                      .orElse(topEmail());
103     }
104 
105     // Returns the top-level comment for a certain head hash
106     private Email topCommentForHash(Hash hash) {
107         return Stream.concat(existing.stream(), generated.stream())
108                      .filter(email -> email.hasHeader("PR-Head-Hash"))
109                      .filter(email -> email.headerValue("PR-Head-Hash").equals(hash.hex()))
110                      .findFirst()
111                      .orElse(topEmail());
112     }
113 
114     private Email parentForReviewComment(ReviewComment reviewComment) {
115         var parent = topCommentForHash(reviewComment.hash());
116         if (reviewComment.parent().isPresent()) {
117             var parentId = getStableMessageId(getMessageId(reviewComment.parent().get()));
118             var last = Stream.concat(existing.stream(), generated.stream())
119                              .filter(email -> (email.hasHeader("References") && email.headerValue("References").contains(parentId)) ||
120                                      (getStableMessageId(email.id()).equals(parentId)) ||
121                                      (email.hasHeader("PR-Collapsed-IDs") && email.headerValue("PR-Collapsed-IDs").contains(parentId)))
122                              .max(Comparator.comparingInt(email -> Integer.parseInt(email.headerValue("PR-Sequence"))));
123 
124             if (last.isEmpty()) {
125                 throw new RuntimeException("Failed to find parent");
126             } else {
127                 return last.get();
128             }
129         }
130         return parent;
131     }
132 
133     ReviewArchive(EmailAddress sender, PullRequestInstance prInstance, CensusInstance censusInstance, List<Email> sentMails) {
134         this.sender = sender;
135         this.prInstance = prInstance;
136         this.censusInstance = censusInstance;
137 
138         existing = sentMails;
139         for (var email : existing) {
140             var stableIds = getStableMessageIds(email);
141             for (var stableId : stableIds) {
142                 existingIds.put(stableId, email);
143             }
144         }
145 
146         // Determine the latest hashes reported
147         reportedHeads = existing.stream()
148                                 .filter(email -> email.hasHeader("PR-Head-Hash"))
149                                 .map(email -> email.headerValue("PR-Head-Hash"))
150                                 .map(Hash::new)
151                                 .collect(Collectors.toList());
152         reportedBases = existing.stream()
153                                 .filter(email -> email.hasHeader("PR-Base-Hash"))
154                                 .map(email -> email.headerValue("PR-Base-Hash"))
155                                 .map(Hash::new)
156                                 .collect(Collectors.toList());
157     }
158 
159     Hash latestHead() {
160         if (reportedHeads.isEmpty()) {
161             throw new IllegalArgumentException("No head reported yet");
162         }
163         return reportedHeads.get(reportedHeads.size() - 1);
164     }
165 
166     Hash latestBase() {
167         if (reportedBases.isEmpty()) {
168             throw new IllegalArgumentException("No base reported yet");
169         }
170         return reportedBases.get(reportedBases.size() - 1);
171     }
172 
173     int revisionCount() {
174         return reportedHeads.size();
175     }
176 
177     void create(URI webrev) {
178         var body = ArchiveMessages.composeConversation(prInstance, webrev);
179         var id = getMessageId();
180         var email = Email.create("RFR: " + prInstance.pr().title(), body)
181                          .sender(sender)
182                          .author(getAuthorAddress(prInstance.pr().author()))
183                          .id(id)
184                          .header("PR-Head-Hash", prInstance.headHash().hex())
185                          .header("PR-Base-Hash", prInstance.baseHash().hex())
186                          .build();
187         generated.add(email);
188         generatedIds.put(getStableMessageId(id), email);
189     }
190 
191     private String latestHeadPrefix() {
192         return String.format("[Rev %02d]", revisionCount());
193     }
194 
195     void addFull(URI webrev) {
196         var body = ArchiveMessages.composeRebaseComment(prInstance, webrev);
197         var id = getMessageId(prInstance.headHash());
198         var parent = topEmail();
199         var email = Email.reply(parent, "Re: " + latestHeadPrefix() + " RFR: " + prInstance.pr().title(), body)
200                          .sender(sender)
201                          .author(getAuthorAddress(prInstance.pr().author()))
202                          .recipient(parent.author())
203                          .id(id)
204                          .header("PR-Head-Hash", prInstance.headHash().hex())
205                          .header("PR-Base-Hash", prInstance.baseHash().hex())
206                          .header("PR-Sequence", Integer.toString(existing.size() + generated.size()))
207                          .build();
208         generated.add(email);
209         generatedIds.put(getStableMessageId(id), email);
210     }
211 
212     void addIncremental(URI fullWebrev, URI incrementalWebrev) {
213         var body = ArchiveMessages.composeIncrementalComment(latestHead(), prInstance, fullWebrev, incrementalWebrev);
214         var id = getMessageId(prInstance.headHash());
215         var parent = topEmail();
216         var email = Email.reply(parent, "Re: " + latestHeadPrefix() + " RFR: " + prInstance.pr().title(), body)
217                          .sender(sender)
218                          .author(getAuthorAddress(prInstance.pr().author()))
219                          .recipient(parent.author())
220                          .id(id)
221                          .header("PR-Head-Hash", prInstance.headHash().hex())
222                          .header("PR-Base-Hash", prInstance.baseHash().hex())
223                          .header("PR-Sequence", Integer.toString(existing.size() + generated.size()))
224                          .build();
225         generated.add(email);
226         generatedIds.put(getStableMessageId(id), email);
227     }
228 
229     private Optional<Email> findCollapsable(Email parent, HostUser author, String subject) {
230         var parentId = getStableMessageId(parent.id());
231 
232         // Is it a self-reply?
233         if (parent.author().equals(getAuthorAddress(author)) && generatedIds.containsKey(parentId)) {
234             // But avoid extending top-level parents
235             if (!parent.hasHeader("PR-Head-Hash")) {
236                 // And only collapse identical subjects
237                 if (parent.subject().equals(subject)) {
238                     return Optional.of(parent);
239                 }
240             }
241         }
242 
243         // Have we already replied to the same parent?
244         for (var candidate : generated) {
245             if (!candidate.hasHeader("In-Reply-To")) {
246                 continue;
247             }
248             var inReplyTo = EmailAddress.parse(candidate.headerValue("In-Reply-To"));
249             var candidateParentId = getStableMessageId(inReplyTo);
250             if (candidateParentId.equals(parentId) && candidate.author().equals(getAuthorAddress(author))) {
251                 // Only collapse identical subjects
252                 if (candidate.subject().equals(subject)) {
253                     return Optional.of(candidate);
254                 }
255             }
256         }
257 
258         return Optional.empty();
259     }
260 
261     private void addReplyCommon(Email parent, HostUser author, String subject, String body, EmailAddress id) {
262         if (!subject.startsWith("Re: ")) {
263             subject = "Re: " + subject;
264         }
265 
266         // Collapse self-replies and replies-to-same that have been created in this run
267         var collapsable = findCollapsable(parent, author, subject);
268         if (collapsable.isPresent()) {
269             // Drop the parent
270             var parentEmail = collapsable.get();
271             generated.remove(parentEmail);
272             generatedIds.remove(getStableMessageId(parentEmail.id()));
273 
274             var collapsed = parentEmail.hasHeader("PR-Collapsed-IDs") ? parentEmail.headerValue("PR-Collapsed-IDs") + " " : "";
275             collapsed += getStableMessageId(parentEmail.id());
276 
277             var reply = ArchiveMessages.composeCombinedReply(parentEmail, body, prInstance);
278             var email = Email.from(parentEmail)
279                              .body(reply)
280                              .subject(subject)
281                              .id(id)
282                              .header("PR-Collapsed-IDs", collapsed)
283                              .header("PR-Sequence", Integer.toString(existing.size() + generated.size()))
284                              .build();
285             generated.add(email);
286             generatedIds.put(getStableMessageId(id), email);
287         } else {
288             var reply = ArchiveMessages.composeReply(parent, body, prInstance);
289             var email = Email.reply(parent, subject, reply)
290                              .sender(sender)
291                              .author(getAuthorAddress(author))
292                              .recipient(parent.author())
293                              .id(id)
294                              .header("PR-Sequence", Integer.toString(existing.size() + generated.size()))
295                              .build();
296             generated.add(email);
297             generatedIds.put(getStableMessageId(id), email);
298         }
299     }
300 
301     void addComment(Comment comment) {
302         var id = getMessageId(comment);
303         if (existingIds.containsKey(getStableMessageId(id))) {
304             return;
305         }
306 
307         var parent = latestGeneralComment();
308         addReplyCommon(parent, comment.author(), "Re: RFR: " + prInstance.pr().title(), comment.body(), id);
309     }
310 
311     private String projectRole(Contributor contributor) {
312         var version = censusInstance.configuration().census().version();
313         if (censusInstance.project().isLead(contributor.username(), version)) {
314             return "Lead";
315         } else if (censusInstance.project().isReviewer(contributor.username(), version)) {
316             return "Reviewer";
317         } else if (censusInstance.project().isCommitter(contributor.username(), version)) {
318             return "Committer";
319         } else if (censusInstance.project().isAuthor(contributor.username(), version)) {
320             return "Author";
321         }
322         return "none";
323     }
324 
325     void addReview(Review review) {
326         var id = getMessageId(review);
327         if (existingIds.containsKey(getStableMessageId(id))) {
328             return;
329         }
330 
331         // Default parent and subject
332         var parent = topCommentForHash(review.hash());
333         var subject = parent.subject();
334 
335         var replyBody = ArchiveMessages.reviewCommentBody(review.body().orElse(""));
336 
337         addReplyCommon(parent, review.reviewer(), subject, replyBody, id);
338     }
339 
340     void addReviewVerdict(Review review) {
341         var id = getMessageId(review);
342         if (existingIds.containsKey(getStableMessageId(id))) {
343             return;
344         }
345 
346         var contributor = censusInstance.namespace().get(review.reviewer().id());
347         var isReviewer = contributor != null && censusInstance.project().isReviewer(contributor.username(), censusInstance.configuration().census().version());
348 
349         // Default parent and subject
350         var parent = topCommentForHash(review.hash());
351         var subject = parent.subject();
352 
353         // Approvals by Reviewers get special treatment - post these as top-level comments
354         if (review.verdict() == Review.Verdict.APPROVED && isReviewer) {
355             approvalIds.add(id);
356         }
357 
358         var userName = contributor != null ? contributor.username() : review.reviewer().userName() + "@" + censusInstance.namespace().name();
359         var userRole = contributor != null ? projectRole(contributor) : "no project role";
360         var replyBody = ArchiveMessages.reviewVerdictBody(review.body().orElse(""), review.verdict(), userName, userRole);
361 
362         addReplyCommon(parent, review.reviewer(), subject, replyBody, id);
363     }
364 
365     void addReviewComment(ReviewComment reviewComment) {
366         var id = getMessageId(reviewComment);
367         if (existingIds.containsKey(getStableMessageId(id))) {
368             return;
369         }
370 
371         var parent = parentForReviewComment(reviewComment);
372         var body = new StringBuilder();
373 
374         // Add some context to the first post
375         if (reviewComment.parent().isEmpty()) {
376             var contents = prInstance.pr().repository().fileContents(reviewComment.path(), reviewComment.hash().hex()).lines().collect(Collectors.toList());
377 
378             body.append(reviewComment.path()).append(" line ").append(reviewComment.line()).append(":\n\n");
379             for (int i = Math.max(0, reviewComment.line() - 2); i < Math.min(contents.size(), reviewComment.line() + 1); ++i) {
380                 body.append("> ").append(i + 1).append(": ").append(contents.get(i)).append("\n");
381             }
382             body.append("\n");
383         }
384         body.append(reviewComment.body());
385 
386         addReplyCommon(parent, reviewComment.author(), parent.subject(), body.toString(), id);
387     }
388 
389     List<Email> generatedEmails() {
390         var finalEmails = new ArrayList<Email>();
391         for (var email : generated) {
392             for (var approvalId : approvalIds) {
393                 var collapsed = email.hasHeader("PR-Collapsed-IDs") ? email.headerValue("PR-Collapsed-IDs") + " " : "";
394                 if (email.id().equals(approvalId) || collapsed.contains(getStableMessageId(approvalId))) {
395                     email = Email.reparent(topEmail(), email)
396                                  .subject("Re: [Approved] " + "RFR: " + prInstance.pr().title())
397                                  .build();
398                     break;
399                 }
400             }
401             finalEmails.add(email);
402         }
403 
404         return finalEmails;
405     }
406 }