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.forge.*; 26 import org.openjdk.skara.host.*; 27 import org.openjdk.skara.issuetracker.*; 28 import org.openjdk.skara.vcs.*; 29 import org.openjdk.skara.vcs.openjdk.Issue; 30 31 import java.io.*; 32 import java.util.*; 33 import java.util.logging.Logger; 34 import java.util.regex.Pattern; 35 import java.util.stream.*; 36 37 class CheckRun { 38 private final CheckWorkItem workItem; 39 private final PullRequest pr; 40 private final PullRequestInstance prInstance; 41 private final List<Comment> comments; 42 private final List<Review> allReviews; 43 private final List<Review> activeReviews; 44 private final Set<String> labels; 45 private final CensusInstance censusInstance; 46 private final Map<String, String> blockingLabels; 47 private final IssueProject issueProject; 48 49 private final Logger log = Logger.getLogger("org.openjdk.skara.bots.pr"); 50 private final String progressMarker = "<!-- Anything below this marker will be automatically updated, please do not edit manually! -->"; 51 private final String mergeReadyMarker = "<!-- PullRequestBot merge is ready comment -->"; 52 private final Pattern mergeSourcePattern = Pattern.compile("^Merge ([-/\\w]+):([-\\w]+$)"); 53 private final Set<String> newLabels; 54 55 private CheckRun(CheckWorkItem workItem, PullRequest pr, PullRequestInstance prInstance, List<Comment> comments, 56 List<Review> allReviews, List<Review> activeReviews, Set<String> labels, 57 CensusInstance censusInstance, Map<String, String> blockingLabels, IssueProject issueProject) { 58 this.workItem = workItem; 59 this.pr = pr; 60 this.prInstance = prInstance; 61 this.comments = comments; 62 this.allReviews = allReviews; 63 this.activeReviews = activeReviews; 64 this.labels = new HashSet<>(labels); 65 this.newLabels = new HashSet<>(labels); 66 this.censusInstance = censusInstance; 67 this.blockingLabels = blockingLabels; 68 this.issueProject = issueProject; 69 } 70 71 static void execute(CheckWorkItem workItem, PullRequest pr, PullRequestInstance prInstance, List<Comment> comments, 72 List<Review> allReviews, List<Review> activeReviews, Set<String> labels, CensusInstance censusInstance, Map<String, String> blockingLabels, 73 IssueProject issueProject) { 74 var run = new CheckRun(workItem, pr, prInstance, comments, allReviews, activeReviews, labels, censusInstance, blockingLabels, issueProject); 75 run.checkStatus(); 76 } 77 78 // For unknown contributors, check that all commits have the same name and email 79 private boolean checkCommitAuthor(List<Commit> commits) throws IOException { 80 var author = censusInstance.namespace().get(pr.author().id()); 81 if (author != null) { 82 return true; 83 } 84 85 var names = new HashSet<String>(); 86 var emails = new HashSet<String>(); 87 88 for (var commit : commits) { 89 names.add(commit.author().name()); 90 emails.add(commit.author().email()); 91 } 92 93 return ((names.size() == 1) && emails.size() == 1); 94 } 95 96 private Optional<String> mergeSourceRepository() { 97 var repoMatcher = mergeSourcePattern.matcher(pr.title()); 98 if (!repoMatcher.matches()) { 99 return Optional.empty(); 100 } 101 return Optional.of(repoMatcher.group(1)); 102 } 103 104 private Optional<String> mergeSourceBranch() { 105 var branchMatcher = mergeSourcePattern.matcher(pr.title()); 106 if (!branchMatcher.matches()) { 107 return Optional.empty(); 108 } 109 var mergeSourceBranch = branchMatcher.group(2); 110 return Optional.of(mergeSourceBranch); 111 } 112 113 // Additional bot-specific checks that are not handled by JCheck 114 private List<String> botSpecificChecks() throws IOException { 115 var ret = new ArrayList<String>(); 116 117 var baseHash = prInstance.baseHash(); 118 var headHash = pr.headHash(); 119 var commits = prInstance.localRepo().commits(baseHash + ".." + headHash).asList(); 120 121 if (!checkCommitAuthor(commits)) { 122 var error = "For contributors who are not existing OpenJDK Authors, commit attribution will be taken from " + 123 "the commits in the PR. However, the commits in this PR have inconsistent user names and/or " + 124 "email addresses. Please amend the commits."; 125 ret.add(error); 126 } 127 128 if (pr.title().startsWith("Merge")) { 129 if (commits.size() < 2) { 130 ret.add("A Merge PR must contain at least two commits that are not already present in the target."); 131 } else { 132 if (!commits.get(0).isMerge()) { 133 ret.add("The top commit must be a merge commit."); 134 } 135 136 var sourceRepo = mergeSourceRepository(); 137 var sourceBranch = mergeSourceBranch(); 138 if (sourceBranch.isPresent() && sourceRepo.isPresent()) { 139 try { 140 var mergeSourceRepo = pr.repository().forge().repository(sourceRepo.get()).orElseThrow(() -> 141 new RuntimeException("Could not find repository " + sourceRepo.get()) 142 ); 143 try { 144 var sourceHash = prInstance.localRepo().fetch(mergeSourceRepo.url(), sourceBranch.get()); 145 if (!prInstance.localRepo().isAncestor(commits.get(1).hash(), sourceHash)) { 146 ret.add("The merge contains commits that are not ancestors of the source"); 147 } 148 } catch (IOException e) { 149 ret.add("Could not fetch branch `" + sourceBranch.get() + "` from project `" + 150 sourceRepo.get() + "` - check that they are correct."); 151 } 152 } catch (RuntimeException e) { 153 ret.add("Could not find project `" + 154 sourceRepo.get() + "` - check that it is correct."); 155 } 156 } else { 157 ret.add("Could not determine the source for this merge. A Merge PR title must be specified on the format: " + 158 "Merge `project`:`branch` to allow verification of the merge contents."); 159 } 160 } 161 } 162 163 for (var blocker : blockingLabels.entrySet()) { 164 if (labels.contains(blocker.getKey())) { 165 ret.add(blocker.getValue()); 166 } 167 } 168 169 return ret; 170 } 171 172 private void updateCheckBuilder(CheckBuilder checkBuilder, PullRequestCheckIssueVisitor visitor, List<String> additionalErrors) { 173 if (visitor.isReadyForReview() && additionalErrors.isEmpty()) { 174 checkBuilder.complete(true); 175 } else { 176 checkBuilder.title("Required"); 177 var summary = Stream.concat(visitor.getMessages().stream(), additionalErrors.stream()) 178 .sorted() 179 .map(m -> "- " + m) 180 .collect(Collectors.joining("\n")); 181 checkBuilder.summary(summary); 182 for (var annotation : visitor.getAnnotations()) { 183 checkBuilder.annotation(annotation); 184 } 185 checkBuilder.complete(false); 186 } 187 } 188 189 private void updateReadyForReview(PullRequestCheckIssueVisitor visitor, List<String> additionalErrors) { 190 // If there are no issues at all, the PR is already reviewed 191 if (visitor.getMessages().isEmpty() && additionalErrors.isEmpty()) { 192 pr.removeLabel("rfr"); 193 return; 194 } 195 196 // Additional errors are not allowed 197 if (!additionalErrors.isEmpty()) { 198 newLabels.remove("rfr"); 199 return; 200 } 201 202 // Draft requests are not for review 203 if (pr.isDraft()) { 204 newLabels.remove("rfr"); 205 return; 206 } 207 208 // Check if the visitor found any issues that should be resolved before reviewing 209 if (visitor.isReadyForReview()) { 210 newLabels.add("rfr"); 211 } else { 212 newLabels.remove("rfr"); 213 } 214 } 215 216 private String getRole(String username) { 217 var project = censusInstance.project(); 218 var version = censusInstance.census().version().format(); 219 if (project.isReviewer(username, version)) { 220 return "**Reviewer**"; 221 } else if (project.isCommitter(username, version)) { 222 return "Committer"; 223 } else if (project.isAuthor(username, version)) { 224 return "Author"; 225 } else { 226 return "no project role"; 227 } 228 } 229 230 private String formatReviewer(HostUser reviewer) { 231 var namespace = censusInstance.namespace(); 232 var contributor = namespace.get(reviewer.id()); 233 if (contributor == null) { 234 return reviewer.userName() + " (no known " + namespace.name() + " user name / role)"; 235 } else { 236 var userNameLink = "[" + contributor.username() + "](@" + reviewer.userName() + ")"; 237 return contributor.fullName().orElse(contributor.username()) + " (" + userNameLink + " - " + 238 getRole(contributor.username()) + ")"; 239 } 240 } 241 242 private String getChecksList(PullRequestCheckIssueVisitor visitor) { 243 return visitor.getChecks().entrySet().stream() 244 .map(entry -> "- [" + (entry.getValue() ? "x" : " ") + "] " + entry.getKey()) 245 .collect(Collectors.joining("\n")); 246 } 247 248 private Optional<String> getReviewersList(List<Review> reviews) { 249 var reviewers = reviews.stream() 250 .filter(review -> review.verdict() == Review.Verdict.APPROVED) 251 .map(review -> { 252 var entry = " * " + formatReviewer(review.reviewer()); 253 if (!review.hash().equals(pr.headHash())) { 254 entry += " **Note!** Review applies to " + review.hash(); 255 } 256 return entry; 257 }) 258 .collect(Collectors.joining("\n")); 259 if (reviewers.length() > 0) { 260 return Optional.of(reviewers); 261 } else { 262 return Optional.empty(); 263 } 264 } 265 266 private String getStatusMessage(List<Comment> comments, List<Review> reviews, PullRequestCheckIssueVisitor visitor) { 267 var progressBody = new StringBuilder(); 268 progressBody.append("## Progress\n"); 269 progressBody.append(getChecksList(visitor)); 270 271 var issue = Issue.fromString(pr.title()); 272 if (issueProject != null && issue.isPresent()) { 273 var allIssues = new ArrayList<Issue>(); 274 allIssues.add(issue.get()); 275 allIssues.addAll(SolvesTracker.currentSolved(pr.repository().forge().currentUser(), comments)); 276 progressBody.append("\n\n## Issue"); 277 if (allIssues.size() > 1) { 278 progressBody.append("s"); 279 } 280 progressBody.append("\n"); 281 for (var currentIssue : allIssues) { 282 var iss = issueProject.issue(currentIssue.id()); 283 if (iss.isPresent()) { 284 progressBody.append("["); 285 progressBody.append(iss.get().id()); 286 progressBody.append("]("); 287 progressBody.append(iss.get().webUrl()); 288 progressBody.append("): "); 289 progressBody.append(iss.get().title()); 290 progressBody.append("\n"); 291 } else { 292 progressBody.append("⚠️ Failed to retrieve information on issue `"); 293 progressBody.append(currentIssue.id()); 294 progressBody.append("`.\n"); 295 } 296 } 297 } 298 299 getReviewersList(reviews).ifPresent(reviewers -> { 300 progressBody.append("\n\n## Approvers\n"); 301 progressBody.append(reviewers); 302 }); 303 304 return progressBody.toString(); 305 } 306 307 private String updateStatusMessage(String message) { 308 var description = pr.body(); 309 var markerIndex = description.lastIndexOf(progressMarker); 310 311 if (markerIndex >= 0 && description.substring(markerIndex).equals(message)) { 312 log.info("Progress already up to date"); 313 return description; 314 } 315 var newBody = (markerIndex < 0 ? 316 description : 317 description.substring(0, markerIndex)).trim() + "\n" + progressMarker + "\n" + message; 318 319 // TODO? Retrieve the body again here to lower the chance of concurrent updates 320 pr.setBody(newBody); 321 return newBody; 322 } 323 324 private String verdictToString(Review.Verdict verdict) { 325 switch (verdict) { 326 case APPROVED: 327 return "changes are approved"; 328 case DISAPPROVED: 329 return "more changes needed"; 330 case NONE: 331 return "comment added"; 332 default: 333 throw new RuntimeException("Unknown verdict: " + verdict); 334 } 335 } 336 337 private void updateReviewedMessages(List<Comment> comments, List<Review> reviews) { 338 var reviewTracker = new ReviewTracker(comments, reviews); 339 340 for (var added : reviewTracker.newReviews().entrySet()) { 341 var body = added.getValue() + "\n" + 342 "This PR has been reviewed by " + 343 formatReviewer(added.getKey().reviewer()) + " - " + 344 verdictToString(added.getKey().verdict()) + "."; 345 pr.addComment(body); 346 } 347 } 348 349 private Optional<Comment> findComment(List<Comment> comments, String marker) { 350 var self = pr.repository().forge().currentUser(); 351 return comments.stream() 352 .filter(comment -> comment.author().equals(self)) 353 .filter(comment -> comment.body().contains(marker)) 354 .findAny(); 355 } 356 357 private String getMergeReadyComment(String commitMessage, List<Review> reviews, boolean rebasePossible) { 358 var message = new StringBuilder(); 359 message.append("@"); 360 message.append(pr.author().userName()); 361 message.append(" This change can now be integrated. The commit message will be:\n"); 362 message.append("```\n"); 363 message.append(commitMessage); 364 message.append("\n```\n"); 365 366 message.append("- If you would like to add a summary, use the `/summary` command.\n"); 367 message.append("- To list additional contributors, use the `/contributor` command.\n"); 368 369 var divergingCommits = prInstance.divergingCommits(); 370 if (divergingCommits.size() > 0) { 371 message.append("\n"); 372 message.append("Since the source branch of this PR was last updated there "); 373 if (divergingCommits.size() == 1) { 374 message.append("has been 1 commit "); 375 } else { 376 message.append("have been "); 377 message.append(divergingCommits.size()); 378 message.append(" commits "); 379 } 380 message.append("pushed to the `"); 381 message.append(pr.targetRef()); 382 message.append("` branch:\n"); 383 var commitList = divergingCommits.stream() 384 .map(commit -> " * " + commit.hash().hex() + ": " + commit.message().get(0)) 385 .collect(Collectors.joining("\n")); 386 message.append(commitList); 387 message.append("\n\n"); 388 if (rebasePossible) { 389 message.append("Since there are no conflicts, your changes will automatically be rebased on top of the "); 390 message.append("above commits when integrating. If you prefer to do this manually, please merge `"); 391 message.append(pr.targetRef()); 392 message.append("` into your branch first.\n"); 393 } else { 394 message.append("Your changes cannot be rebased automatically without conflicts, so you will need to "); 395 message.append("merge `"); 396 message.append(pr.targetRef()); 397 message.append("` into your branch before integrating.\n"); 398 } 399 } 400 401 if (!ProjectPermissions.mayCommit(censusInstance, pr.author())) { 402 message.append("\n"); 403 var contributor = censusInstance.namespace().get(pr.author().id()); 404 if (contributor == null) { 405 message.append("As you are not a known OpenJDK [Author](http://openjdk.java.net/bylaws#author), "); 406 } else { 407 message.append("As you do not have Committer status in this project, "); 408 } 409 410 message.append("an existing [Committer](http://openjdk.java.net/bylaws#committer) must agree to "); 411 message.append("[sponsor](http://openjdk.java.net/sponsor/) your change. "); 412 var candidates = reviews.stream() 413 .filter(review -> ProjectPermissions.mayCommit(censusInstance, review.reviewer())) 414 .map(review -> "@" + review.reviewer().userName()) 415 .collect(Collectors.joining(", ")); 416 if (candidates.length() > 0) { 417 message.append("Possible candidates are the reviewers of this PR ("); 418 message.append(candidates); 419 message.append(") but any other Committer may sponsor as well. "); 420 } 421 if (rebasePossible) { 422 message.append("\n\n"); 423 message.append("- To flag this PR as ready for integration with the above commit message, type "); 424 message.append("`/integrate` in a new comment. (Afterwards, your sponsor types "); 425 message.append("`/sponsor` in a new comment to perform the integration).\n"); 426 } 427 } else if (rebasePossible) { 428 if (divergingCommits.size() > 0) { 429 message.append("\n"); 430 } 431 message.append("- To integrate this PR with the above commit message, type "); 432 message.append("`/integrate` in a new comment.\n"); 433 } 434 message.append(mergeReadyMarker); 435 return message.toString(); 436 } 437 438 private String getMergeNoLongerReadyComment() { 439 var message = new StringBuilder(); 440 message.append("@"); 441 message.append(pr.author().userName()); 442 message.append(" This change is no longer ready for integration - check the PR body for details.\n"); 443 message.append(mergeReadyMarker); 444 return message.toString(); 445 } 446 447 private void updateMergeReadyComment(boolean isReady, String commitMessage, List<Comment> comments, List<Review> reviews, boolean rebasePossible) { 448 var existing = findComment(comments, mergeReadyMarker); 449 if (isReady) { 450 var message = getMergeReadyComment(commitMessage, reviews, rebasePossible); 451 if (existing.isEmpty()) { 452 pr.addComment(message); 453 } else { 454 pr.updateComment(existing.get().id(), message); 455 } 456 } else { 457 existing.ifPresent(comment -> pr.updateComment(comment.id(), getMergeNoLongerReadyComment())); 458 } 459 } 460 461 private void checkStatus() { 462 var checkBuilder = CheckBuilder.create("jcheck", pr.headHash()); 463 var censusDomain = censusInstance.configuration().census().domain(); 464 Exception checkException = null; 465 466 try { 467 // Post check in-progress 468 log.info("Starting to run jcheck on PR head"); 469 pr.createCheck(checkBuilder.build()); 470 var localHash = prInstance.commit(censusInstance.namespace(), censusDomain, null); 471 472 // Try to rebase 473 boolean rebasePossible = true; 474 var ignored = new PrintWriter(new StringWriter()); 475 var rebasedHash = prInstance.rebase(localHash, ignored); 476 if (rebasedHash.isEmpty()) { 477 rebasePossible = false; 478 } else { 479 localHash = rebasedHash.get(); 480 } 481 482 // Determine current status 483 var visitor = prInstance.executeChecks(localHash, censusInstance); 484 var additionalErrors = botSpecificChecks(); 485 updateCheckBuilder(checkBuilder, visitor, additionalErrors); 486 updateReadyForReview(visitor, additionalErrors); 487 488 // Calculate and update the status message if needed 489 var statusMessage = getStatusMessage(comments, activeReviews, visitor); 490 var updatedBody = updateStatusMessage(statusMessage); 491 492 // Post / update approval messages (only needed if the review itself can't contain a body) 493 if (!pr.repository().forge().supportsReviewBody()) { 494 updateReviewedMessages(comments, allReviews); 495 } 496 497 var commit = prInstance.localRepo().lookup(localHash).orElseThrow(); 498 var commitMessage = String.join("\n", commit.message()); 499 var readyForIntegration = visitor.getMessages().isEmpty() && additionalErrors.isEmpty(); 500 updateMergeReadyComment(readyForIntegration, commitMessage, comments, activeReviews, rebasePossible); 501 if (readyForIntegration) { 502 newLabels.add("ready"); 503 } else { 504 newLabels.remove("ready"); 505 } 506 if (!rebasePossible) { 507 newLabels.add("outdated"); 508 } else { 509 newLabels.remove("outdated"); 510 } 511 512 // Ensure that the ready for sponsor label is up to date 513 newLabels.remove("sponsor"); 514 var readyHash = ReadyForSponsorTracker.latestReadyForSponsor(pr.repository().forge().currentUser(), comments); 515 if (readyHash.isPresent() && readyForIntegration) { 516 var acceptedHash = readyHash.get(); 517 if (pr.headHash().equals(acceptedHash)) { 518 newLabels.add("sponsor"); 519 } 520 } 521 522 // Calculate current metadata to avoid unnecessary future checks 523 var metadata = workItem.getMetadata(pr.title(), updatedBody, pr.comments(), activeReviews, newLabels, censusInstance, pr.targetHash()); 524 checkBuilder.metadata(metadata); 525 } catch (Exception e) { 526 log.throwing("CommitChecker", "checkStatus", e); 527 newLabels.remove("ready"); 528 checkBuilder.metadata("invalid"); 529 checkBuilder.title("Exception occurred during jcheck - the operation will be retried"); 530 checkBuilder.summary(e.getMessage()); 531 checkBuilder.complete(false); 532 checkException = e; 533 } 534 var check = checkBuilder.build(); 535 pr.updateCheck(check); 536 537 // Synchronize the wanted set of labels 538 for (var newLabel : newLabels) { 539 if (!labels.contains(newLabel)) { 540 pr.addLabel(newLabel); 541 } 542 } 543 for (var oldLabel : labels) { 544 if (!newLabels.contains(oldLabel)) { 545 pr.removeLabel(oldLabel); 546 } 547 } 548 549 // After updating the PR, rethrow any exception to automatically retry on transient errors 550 if (checkException != null) { 551 throw new RuntimeException("Exception during jcheck", checkException); 552 } 553 } 554 }