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 "no project role"; 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 OpenJDK username"; 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 }