1 /* 2 * Copyright (c) 2018, 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.host.gitlab; 24 25 import org.openjdk.skara.host.*; 26 import org.openjdk.skara.host.network.*; 27 import org.openjdk.skara.json.*; 28 import org.openjdk.skara.vcs.Hash; 29 30 import java.net.URI; 31 import java.nio.charset.StandardCharsets; 32 import java.time.ZonedDateTime; 33 import java.util.*; 34 import java.util.logging.Logger; 35 import java.util.regex.Pattern; 36 import java.util.stream.*; 37 38 public class GitLabMergeRequest implements PullRequest { 39 40 private final JSONValue json; 41 private final RestRequest request; 42 private final Logger log = Logger.getLogger("org.openjdk.skara.host");; 43 private final GitLabRepository repository; 44 45 GitLabMergeRequest(GitLabRepository repository, JSONValue jsonValue, RestRequest request) { 46 this.repository = repository; 47 this.json = jsonValue; 48 this.request = request.restrict("merge_requests/" + json.get("iid").toString() + "/"); 49 } 50 51 @Override 52 public HostedRepository repository() { 53 return repository; 54 } 55 56 @Override 57 public String getId() { 58 return json.get("iid").toString(); 59 } 60 61 @Override 62 public HostUserDetails getAuthor() { 63 return repository.host().getUserDetails(json.get("author").get("username").asString()); 64 } 65 66 @Override 67 public List<Review> getReviews() { 68 69 class CommitDate { 70 private Hash hash; 71 private ZonedDateTime date; 72 } 73 74 var commits = request.get("commits").execute().stream() 75 .map(JSONValue::asObject) 76 .map(obj -> { 77 var ret = new CommitDate(); 78 ret.hash = new Hash(obj.get("id").asString()); 79 ret.date = ZonedDateTime.parse(obj.get("created_at").asString()); 80 return ret; 81 }) 82 .sorted(Comparator.comparing(cd -> cd.date)) 83 .collect(Collectors.toList()); 84 85 if (commits.size() == 0) { 86 throw new RuntimeException("Reviews on a PR without any commits?"); 87 } 88 89 return request.get("award_emoji").execute().stream() 90 .map(JSONValue::asObject) 91 .filter(obj -> obj.get("name").asString().equals("thumbsup") || 92 obj.get("name").asString().equals("thumbsdown") || 93 obj.get("name").asString().equals("question")) 94 .map(obj -> { 95 var reviewer = repository.host().getUserDetails(obj.get("user").get("username").asString()); 96 Review.Verdict verdict; 97 switch (obj.get("name").asString()) { 98 case "thumbsup": 99 verdict = Review.Verdict.APPROVED; 100 break; 101 case "thumbsdown": 102 verdict = Review.Verdict.DISAPPROVED; 103 break; 104 default: 105 verdict = Review.Verdict.NONE; 106 break; 107 } 108 109 var createdAt = ZonedDateTime.parse(obj.get("updated_at").asString()); 110 111 // Find the latest commit that isn't created after our review 112 var hash = commits.get(0).hash; 113 for (var cd : commits) { 114 if (createdAt.isAfter(cd.date)) { 115 hash = cd.hash; 116 } 117 } 118 var id = obj.get("id").asInt(); 119 return new Review(reviewer, verdict, hash, id, null); 120 }) 121 .collect(Collectors.toList()); 122 } 123 124 @Override 125 public void addReview(Review.Verdict verdict, String body) { 126 // Remove any previous awards 127 var awards = request.get("award_emoji").execute().stream() 128 .map(JSONValue::asObject) 129 .filter(obj -> obj.get("name").asString().equals("thumbsup") || 130 obj.get("name").asString().equals("thumbsdown") || 131 obj.get("name").asString().equals("question")) 132 .filter(obj -> obj.get("user").get("username").asString().equals(repository.host().getCurrentUserDetails().userName())) 133 .map(obj -> obj.get("id").toString()) 134 .collect(Collectors.toList()); 135 for (var award : awards) { 136 request.delete("award_emoji/" + award).execute(); 137 } 138 139 String award; 140 switch (verdict) { 141 case APPROVED: 142 award = "thumbsup"; 143 break; 144 case DISAPPROVED: 145 award = "thumbsdown"; 146 break; 147 default: 148 award = "question"; 149 break; 150 } 151 request.post("award_emoji") 152 .body("name", award) 153 .execute(); 154 } 155 156 private ReviewComment parseReviewComment(String discussionId, ReviewComment parent, JSONObject note) { 157 var comment = new ReviewComment(parent, 158 discussionId, 159 new Hash(note.get("position").get("head_sha").asString()), 160 note.get("position").get("new_path").asString(), 161 note.get("position").get("new_line").asInt(), 162 note.get("id").toString(), 163 note.get("body").asString(), 164 new HostUserDetails(note.get("author").get("id").asInt(), 165 note.get("author").get("username").asString(), 166 note.get("author").get("name").asString()), 167 ZonedDateTime.parse(note.get("created_at").asString()), 168 ZonedDateTime.parse(note.get("updated_at").asString())); 169 return comment; 170 } 171 172 @Override 173 public ReviewComment addReviewComment(Hash base, Hash hash, String path, int line, String body) { 174 log.fine("Posting a new review comment"); 175 var query = JSON.object() 176 .put("body", body) 177 .put("position", JSON.object() 178 .put("base_sha", base.hex()) 179 .put("start_sha", base.hex()) 180 .put("head_sha", hash.hex()) 181 .put("position_type", "text") 182 .put("new_path", path) 183 .put("new_line", line)); 184 var comments = request.post("discussions").body(query).execute(); 185 if (comments.get("notes").asArray().size() != 1) { 186 throw new RuntimeException("Failed to create review comment"); 187 } 188 var parsedComment = parseReviewComment(comments.get("id").asString(), null, 189 comments.get("notes").asArray().get(0).asObject()); 190 log.fine("Id of new review comment: " + parsedComment.id()); 191 return parsedComment; 192 } 193 194 @Override 195 public ReviewComment addReviewCommentReply(ReviewComment parent, String body) { 196 var discussionId = parent.threadId(); 197 var comment = request.post("discussions/" + discussionId + "/notes") 198 .body("body", body) 199 .execute(); 200 return parseReviewComment(discussionId, parent, comment.asObject()); 201 } 202 203 private List<ReviewComment> parseDiscussion(JSONObject discussion) { 204 var ret = new ArrayList<ReviewComment>(); 205 ReviewComment parent = null; 206 for (var note : discussion.get("notes").asArray()) { 207 // Ignore system generated comments 208 if (note.get("system").asBoolean()) { 209 continue; 210 } 211 // Ignore plain comments 212 if (!note.contains("position")) { 213 continue; 214 } 215 216 var comment = parseReviewComment(discussion.get("id").asString(), parent, note.asObject()); 217 parent = comment; 218 ret.add(comment); 219 } 220 221 return ret; 222 } 223 224 @Override 225 public List<ReviewComment> getReviewComments() { 226 return request.get("discussions").execute().stream() 227 .filter(entry -> !entry.get("individual_note").asBoolean()) 228 .flatMap(entry -> parseDiscussion(entry.asObject()).stream()) 229 .collect(Collectors.toList()); 230 } 231 232 @Override 233 public Hash getHeadHash() { 234 return new Hash(json.get("sha").asString()); 235 } 236 237 @Override 238 public String getSourceRef() { 239 return "merge-requests/" + getId() + "/head"; 240 } 241 242 @Override 243 public String getTargetRef() { 244 return json.get("target_branch").asString(); 245 } 246 247 @Override 248 public Hash getTargetHash() { 249 return repository.getBranchHash(getTargetRef()); 250 } 251 252 @Override 253 public String getTitle() { 254 return json.get("title").asString(); 255 } 256 257 @Override 258 public void setTitle(String title) { 259 throw new RuntimeException("not implemented yet"); 260 } 261 262 @Override 263 public String getBody() { 264 var body = json.get("description").asString(); 265 if (body == null) { 266 body = ""; 267 } 268 return body; 269 } 270 271 @Override 272 public void setBody(String body) { 273 request.put("") 274 .body("description", body) 275 .execute(); 276 } 277 278 private Comment parseComment(JSONValue comment) { 279 var ret = new Comment(comment.get("id").toString(), 280 comment.get("body").asString(), 281 new HostUserDetails(comment.get("author").get("id").asInt(), 282 comment.get("author").get("username").asString(), 283 comment.get("author").get("name").asString()), 284 ZonedDateTime.parse(comment.get("created_at").asString()), 285 ZonedDateTime.parse(comment.get("updated_at").asString())); 286 return ret; 287 } 288 289 @Override 290 public List<Comment> getComments() { 291 return request.get("notes").param("sort", "asc").execute().stream() 292 .filter(entry -> !entry.contains("position")) // Ignore comments with a position - they are review comments 293 .filter(entry -> !entry.get("system").asBoolean()) // Ignore system generated comments 294 .map(this::parseComment) 295 .collect(Collectors.toList()); 296 } 297 298 @Override 299 public Comment addComment(String body) { 300 log.fine("Posting a new comment"); 301 var comment = request.post("notes") 302 .body("body", body) 303 .execute(); 304 var parsedComment = parseComment(comment); 305 log.fine("Id of new comment: " + parsedComment.id()); 306 return parsedComment; 307 } 308 309 @Override 310 public Comment updateComment(String id, String body) { 311 log.fine("Updating existing comment " + id); 312 var comment = request.put("notes/" + id) 313 .body("body", body) 314 .execute(); 315 var parsedComment = parseComment(comment); 316 log.fine("Id of updated comment: " + parsedComment.id()); 317 return parsedComment; 318 } 319 320 @Override 321 public ZonedDateTime getCreated() { 322 return ZonedDateTime.parse(json.get("created_at").asString()); 323 } 324 325 @Override 326 public ZonedDateTime getUpdated() { 327 return ZonedDateTime.parse(json.get("updated_at").asString()); 328 } 329 330 private final String checkMarker = "<!-- Merge request status check message (%s) -->"; 331 private final String checkResultMarker = "<!-- Merge request status check result (%s) (%s) (%s) (%s) -->"; 332 private final String checkResultPattern = "<!-- Merge request status check result \\(([-\\w]+)\\) \\((\\w+)\\) \\(%s\\) \\((\\S+)\\) -->"; 333 334 private Optional<Comment> getStatusCheckComment(String name) { 335 var marker = String.format(checkMarker, name); 336 337 return getComments().stream() 338 .filter(c -> c.body().contains(marker)) 339 .findFirst(); 340 } 341 342 private String encodeMarkdown(String message) { 343 return message.replaceAll("\n", " \n"); 344 } 345 346 private final Pattern checkBodyPattern = Pattern.compile("^##### ([^\\n\\r]*)\\R(.*)", 347 Pattern.DOTALL | Pattern.MULTILINE); 348 349 @Override 350 public Map<String, Check> getChecks(Hash hash) { 351 var pattern = Pattern.compile(String.format(checkResultPattern, hash.hex())); 352 var matchers = getComments().stream() 353 .collect(Collectors.toMap(comment -> comment, 354 comment -> pattern.matcher(comment.body()))); 355 356 return matchers.entrySet().stream() 357 .filter(entry -> entry.getValue().find()) 358 .collect(Collectors.toMap(entry -> entry.getValue().group(1), 359 entry -> { 360 var checkBuilder = CheckBuilder.create(entry.getValue().group(1), hash); 361 checkBuilder.startedAt(entry.getKey().createdAt()); 362 if (!entry.getValue().group(2).equals("RUNNING")) { 363 checkBuilder.complete(entry.getValue().group(2).equals("SUCCESS"), entry.getKey().updatedAt()); 364 } 365 if (!entry.getValue().group(3).equals("NONE")) { 366 checkBuilder.metadata(new String(Base64.getDecoder().decode(entry.getValue().group(3)), StandardCharsets.UTF_8)); 367 } 368 var checkBodyMatcher = checkBodyPattern.matcher(entry.getKey().body()); 369 if (checkBodyMatcher.find()) { 370 checkBuilder.title(checkBodyMatcher.group(1)); 371 checkBuilder.summary(checkBodyMatcher.group(2)); 372 } 373 return checkBuilder.build(); 374 })); 375 } 376 377 @Override 378 public void createCheck(Check check) { 379 log.info("Looking for previous status check comment"); 380 381 var previous = getStatusCheckComment(check.name()); 382 var body = ":hourglass_flowing_sand: The merge request check **" + check.name() + "** is currently running..."; 383 var metadata = "NONE"; 384 if (check.metadata().isPresent()) { 385 metadata = Base64.getEncoder().encodeToString(check.metadata().get().getBytes(StandardCharsets.UTF_8)); 386 } 387 var message = String.format(checkMarker, check.name()) + "\n" + 388 String.format(checkResultMarker, 389 check.name(), 390 "RUNNING", 391 check.hash(), 392 metadata 393 ) + "\n" + encodeMarkdown(body); 394 395 previous.ifPresentOrElse(p -> updateComment(p.id(), message), 396 () -> addComment(message)); 397 } 398 399 private String linkToDiff(String path, Hash hash, int line) { 400 return "[" + path + " line " + line + "](" + URIBuilder.base(repository.getUrl()) 401 .setPath("/" + repository.getName()+ "/blob/" + hash.hex() + "/" + path) 402 .setAuthentication(null) 403 .build() + "#L" + Integer.toString(line) + ")"; 404 } 405 406 @Override 407 public void updateCheck(Check check) { 408 log.info("Looking for previous status check comment"); 409 410 var previous = getStatusCheckComment(check.name()) 411 .orElseGet(() -> addComment("Progress deleted?")); 412 413 String status; 414 switch (check.status()) { 415 case IN_PROGRESS: 416 status = "RUNNING"; 417 break; 418 case SUCCESS: 419 status = "SUCCESS"; 420 break; 421 case FAILURE: 422 status = "FAILURE"; 423 break; 424 default: 425 throw new RuntimeException("Unknown check status"); 426 } 427 428 var metadata = "NONE"; 429 if (check.metadata().isPresent()) { 430 metadata = Base64.getEncoder().encodeToString(check.metadata().get().getBytes(StandardCharsets.UTF_8)); 431 } 432 var markers = String.format(checkMarker, check.name()) + "\n" + String.format(checkResultMarker, check.name(), 433 status, check.hash(), metadata); 434 435 String body; 436 if (check.status() == CheckStatus.SUCCESS) { 437 body = ":tada: The merge request check **" + check.name() + "** completed successfully!"; 438 } else { 439 if (check.status() == CheckStatus.IN_PROGRESS) { 440 body = ":hourglass_flowing_sand: The merge request check **" + check.name() + "** is currently running..."; 441 } else { 442 body = ":warning: The merge request check **" + check.name() + "** identified the following issues:"; 443 } 444 if (check.title().isPresent() && check.summary().isPresent()) { 445 body += encodeMarkdown("\n" + "##### " + check.title().get() + "\n" + check.summary().get()); 446 447 for (var annotation : check.annotations()) { 448 var annotationString = " - "; 449 switch (annotation.level()) { 450 case NOTICE: 451 annotationString += "Notice: "; 452 break; 453 case WARNING: 454 annotationString += "Warning: "; 455 break; 456 case FAILURE: 457 annotationString += "Failure: "; 458 break; 459 } 460 annotationString += linkToDiff(annotation.path(), check.hash(), annotation.startLine()); 461 annotationString += "\n - " + annotation.message().lines().collect(Collectors.joining("\n - ")); 462 463 body += "\n" + annotationString; 464 } 465 } 466 } 467 468 updateComment(previous.id(), markers + "\n" + body); 469 } 470 471 @Override 472 public void setState(State state) { 473 request.put("") 474 .body("state_event", state == State.CLOSED ? "close" : "reopen") 475 .execute(); 476 } 477 478 @Override 479 public void addLabel(String label) { 480 // GitLab does not allow adding/removing single labels, only setting the full list 481 // We retrieve the list again here to try to minimize the race condition window 482 var currentJson = request.get("").execute().asObject(); 483 var labels = Stream.concat(currentJson.get("labels").stream() 484 .map(JSONValue::asString), 485 List.of(label).stream()) 486 .collect(Collectors.toSet()); 487 request.put("") 488 .body("labels", String.join(",", labels)) 489 .execute(); 490 } 491 492 @Override 493 public void removeLabel(String label) { 494 var currentJson = request.get("").execute().asObject(); 495 var labels = currentJson.get("labels").stream() 496 .map(JSONValue::asString) 497 .filter(l -> !l.equals(label)) 498 .collect(Collectors.toSet()); 499 request.put("") 500 .body("labels", String.join(",", labels)) 501 .execute(); 502 } 503 504 @Override 505 public List<String> getLabels() { 506 var currentJson = request.get("").execute().asObject(); 507 return currentJson.get("labels").stream() 508 .map(JSONValue::asString) 509 .sorted() 510 .collect(Collectors.toList()); 511 } 512 513 @Override 514 public URI getWebUrl() { 515 return URIBuilder.base(repository.getWebUrl()) 516 .setPath("/" + repository.getName() + "/merge_requests/" + getId()) 517 .build(); 518 } 519 520 @Override 521 public String toString() { 522 return "GitLabMergeRequest #" + getId() + " by " + getAuthor(); 523 } 524 525 @Override 526 public List<HostUserDetails> getAssignees() { 527 var assignee = json.get("assignee").asObject(); 528 if (assignee != null) { 529 var user = repository.host().getUserDetails(assignee.get("username").asString()); 530 return List.of(user); 531 } 532 return Collections.emptyList(); 533 } 534 535 @Override 536 public void setAssignees(List<HostUserDetails> assignees) { 537 var id = assignees.size() == 0 ? 0 : Integer.valueOf(assignees.get(0).id()); 538 var param = JSON.object().put("assignee_id", id); 539 request.put().body(param).execute(); 540 if (assignees.size() > 1) { 541 var rest = assignees.subList(1, assignees.size()); 542 var usernames = rest.stream() 543 .map(HostUserDetails::userName) 544 .map(username -> "@" + username) 545 .collect(Collectors.joining(" ")); 546 var comment = usernames + " can you have a look at this merge request?"; 547 addComment(comment); 548 } 549 } 550 }