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 String getBody() {
259         var body = json.get("description").asString();
260         if (body == null) {
261             body = "";
262         }
263         return body;
264     }
265 
266     @Override
267     public void setBody(String body) {
268         request.put("")
269                .body("description", body)
270                .execute();
271     }
272 
273     private Comment parseComment(JSONValue comment) {
274         var ret = new Comment(comment.get("id").toString(),
275                               comment.get("body").asString(),
276                               new HostUserDetails(comment.get("author").get("id").asInt(),
277                                                   comment.get("author").get("username").asString(),
278                                                   comment.get("author").get("name").asString()),
279                               ZonedDateTime.parse(comment.get("created_at").asString()),
280                               ZonedDateTime.parse(comment.get("updated_at").asString()));
281         return ret;
282     }
283 
284     @Override
285     public List<Comment> getComments() {
286         return request.get("notes").param("sort", "asc").execute().stream()
287                       .filter(entry -> !entry.contains("position")) // Ignore comments with a position - they are review comments
288                       .filter(entry -> !entry.get("system").asBoolean()) // Ignore system generated comments
289                 .map(this::parseComment)
290                 .collect(Collectors.toList());
291     }
292 
293     @Override
294     public Comment addComment(String body) {
295         log.fine("Posting a new comment");
296         var comment = request.post("notes")
297                              .body("body", body)
298                              .execute();
299         var parsedComment = parseComment(comment);
300         log.fine("Id of new comment: " + parsedComment.id());
301         return parsedComment;
302     }
303 
304     @Override
305     public Comment updateComment(String id, String body) {
306         log.fine("Updating existing comment " + id);
307         var comment = request.put("notes/" + id)
308                              .body("body", body)
309                              .execute();
310         var parsedComment = parseComment(comment);
311         log.fine("Id of updated comment: " + parsedComment.id());
312         return parsedComment;
313     }
314 
315     @Override
316     public ZonedDateTime getCreated() {
317         return ZonedDateTime.parse(json.get("created_at").asString());
318     }
319 
320     @Override
321     public ZonedDateTime getUpdated() {
322         return ZonedDateTime.parse(json.get("updated_at").asString());
323     }
324 
325     private final String checkMarker = "<!-- Merge request status check message (%s) -->";
326     private final String checkResultMarker = "<!-- Merge request status check result (%s) (%s) (%s) (%s) -->";
327     private final String checkResultPattern = "<!-- Merge request status check result \\(([-\\w]+)\\) \\((\\w+)\\) \\(%s\\) \\((\\S+)\\) -->";
328 
329     private Optional<Comment> getStatusCheckComment(String name) {
330         var marker = String.format(checkMarker, name);
331 
332         return getComments().stream()
333                 .filter(c -> c.body().contains(marker))
334                 .findFirst();
335     }
336 
337     private String encodeMarkdown(String message) {
338         return message.replaceAll("\n", "  \n");
339     }
340 
341     private final Pattern checkBodyPattern = Pattern.compile("^##### ([^\\n\\r]*)\\R(.*)",
342                                                              Pattern.DOTALL | Pattern.MULTILINE);
343 
344     @Override
345     public Map<String, Check> getChecks(Hash hash) {
346         var pattern = Pattern.compile(String.format(checkResultPattern, hash.hex()));
347         var matchers = getComments().stream()
348                 .collect(Collectors.toMap(comment -> comment,
349                         comment -> pattern.matcher(comment.body())));
350 
351         return matchers.entrySet().stream()
352                 .filter(entry -> entry.getValue().find())
353                 .collect(Collectors.toMap(entry -> entry.getValue().group(1),
354                         entry -> {
355                             var checkBuilder = CheckBuilder.create(entry.getValue().group(1), hash);
356                             checkBuilder.startedAt(entry.getKey().createdAt());
357                             if (!entry.getValue().group(2).equals("RUNNING")) {
358                                 checkBuilder.complete(entry.getValue().group(2).equals("SUCCESS"), entry.getKey().updatedAt());
359                             }
360                             if (!entry.getValue().group(3).equals("NONE")) {
361                                 checkBuilder.metadata(new String(Base64.getDecoder().decode(entry.getValue().group(3)), StandardCharsets.UTF_8));
362                             }
363                             var checkBodyMatcher = checkBodyPattern.matcher(entry.getKey().body());
364                             if (checkBodyMatcher.find()) {
365                                 checkBuilder.title(checkBodyMatcher.group(1));
366                                 checkBuilder.summary(checkBodyMatcher.group(2));
367                             }
368                             return checkBuilder.build();
369                         }));
370     }
371 
372     @Override
373     public void createCheck(Check check) {
374         log.info("Looking for previous status check comment");
375 
376         var previous = getStatusCheckComment(check.name());
377         var body = ":hourglass_flowing_sand: The merge request check **" + check.name() + "** is currently running...";
378         var metadata = "NONE";
379         if (check.metadata().isPresent()) {
380             metadata = Base64.getEncoder().encodeToString(check.metadata().get().getBytes(StandardCharsets.UTF_8));
381         }
382         var message = String.format(checkMarker, check.name()) + "\n" +
383                 String.format(checkResultMarker,
384                         check.name(),
385                         "RUNNING",
386                         check.hash(),
387                         metadata
388                         ) + "\n" + encodeMarkdown(body);
389 
390         previous.ifPresentOrElse(p -> updateComment(p.id(), message),
391                 () -> addComment(message));
392     }
393 
394     private String linkToDiff(String path, Hash hash, int line) {
395         return "[" + path + " line " + line + "](" + URIBuilder.base(repository.getUrl())
396                          .setPath("/" + repository.getName()+ "/blob/" + hash.hex() + "/" + path)
397                          .setAuthentication(null)
398                          .build() + "#L" + Integer.toString(line) + ")";
399     }
400 
401     @Override
402     public void updateCheck(Check check) {
403         log.info("Looking for previous status check comment");
404 
405         var previous = getStatusCheckComment(check.name())
406                 .orElseGet(() -> addComment("Progress deleted?"));
407 
408         String status;
409         switch (check.status()) {
410             case IN_PROGRESS:
411                 status = "RUNNING";
412                 break;
413             case SUCCESS:
414                 status = "SUCCESS";
415                 break;
416             case FAILURE:
417                 status = "FAILURE";
418                 break;
419             default:
420                 throw new RuntimeException("Unknown check status");
421         }
422 
423         var metadata = "NONE";
424         if (check.metadata().isPresent()) {
425             metadata = Base64.getEncoder().encodeToString(check.metadata().get().getBytes(StandardCharsets.UTF_8));
426         }
427         var markers = String.format(checkMarker, check.name()) + "\n" + String.format(checkResultMarker, check.name(),
428                 status, check.hash(), metadata);
429 
430         String body;
431         if (check.status() == CheckStatus.SUCCESS) {
432             body = ":tada: The merge request check **" + check.name() + "** completed successfully!";
433         } else {
434             if (check.status() == CheckStatus.IN_PROGRESS) {
435                 body = ":hourglass_flowing_sand: The merge request check **" + check.name() + "** is currently running...";
436             } else {
437                 body = ":warning: The merge request check **" + check.name() + "** identified the following issues:";
438             }
439             if (check.title().isPresent() && check.summary().isPresent()) {
440                 body += encodeMarkdown("\n" + "##### " + check.title().get() + "\n" + check.summary().get());
441 
442                 for (var annotation : check.annotations()) {
443                     var annotationString = "  - ";
444                     switch (annotation.level()) {
445                         case NOTICE:
446                             annotationString += "Notice: ";
447                             break;
448                         case WARNING:
449                             annotationString += "Warning: ";
450                             break;
451                         case FAILURE:
452                             annotationString += "Failure: ";
453                             break;
454                     }
455                     annotationString += linkToDiff(annotation.path(), check.hash(), annotation.startLine());
456                     annotationString += "\n    - " + annotation.message().lines().collect(Collectors.joining("\n    - "));
457 
458                     body += "\n" + annotationString;
459                 }
460             }
461         }
462 
463         updateComment(previous.id(), markers + "\n" + body);
464     }
465 
466     @Override
467     public void setState(State state) {
468         request.put("")
469                .body("state_event", state == State.CLOSED ? "close" : "reopen")
470                .execute();
471     }
472 
473     @Override
474     public void addLabel(String label) {
475         // GitLab does not allow adding/removing single labels, only setting the full list
476         // We retrieve the list again here to try to minimize the race condition window
477         var currentJson = request.get("").execute().asObject();
478         var labels = Stream.concat(currentJson.get("labels").stream()
479                 .map(JSONValue::asString),
480                 List.of(label).stream())
481                 .collect(Collectors.toSet());
482         request.put("")
483                .body("labels", String.join(",", labels))
484                .execute();
485     }
486 
487     @Override
488     public void removeLabel(String label) {
489         var currentJson = request.get("").execute().asObject();
490         var labels = currentJson.get("labels").stream()
491                 .map(JSONValue::asString)
492                 .filter(l -> !l.equals(label))
493                 .collect(Collectors.toSet());
494         request.put("")
495                .body("labels", String.join(",", labels))
496                .execute();
497     }
498 
499     @Override
500     public List<String> getLabels() {
501         var currentJson = request.get("").execute().asObject();
502         return currentJson.get("labels").stream()
503                 .map(JSONValue::asString)
504                 .sorted()
505                 .collect(Collectors.toList());
506     }
507 
508     @Override
509     public URI getWebUrl() {
510         return URIBuilder.base(repository.getWebUrl())
511                          .setPath("/" + repository.getName() + "/merge_requests/" + getId())
512                          .build();
513     }
514 
515     @Override
516     public String toString() {
517         return "GitLabMergeRequest #" + getId() + " by " + getAuthor();
518     }
519 
520     @Override
521     public List<HostUserDetails> getAssignees() {
522         var assignee = json.get("assignee").asObject();
523         if (assignee != null) {
524             var user = repository.host().getUserDetails(assignee.get("username").asString());
525             return List.of(user);
526         }
527         return Collections.emptyList();
528     }
529 
530     @Override
531     public void setAssignees(List<HostUserDetails> assignees) {
532         var id = assignees.size() == 0 ? 0 : Integer.valueOf(assignees.get(0).id());
533         var param = JSON.object().put("assignee_id", id);
534         request.put().body(param).execute();
535         if (assignees.size() > 1) {
536             var rest = assignees.subList(1, assignees.size());
537             var usernames = rest.stream()
538                                 .map(HostUserDetails::userName)
539                                 .map(username -> "@" + username)
540                                 .collect(Collectors.joining(" "));
541             var comment = usernames + " can you have a look at this merge request?";
542             addComment(comment);
543         }
544     }
545 }