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 }