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.github;
 24 
 25 import org.openjdk.skara.host.*;
 26 import org.openjdk.skara.host.network.RestRequest;
 27 import org.openjdk.skara.json.*;
 28 import org.openjdk.skara.vcs.Hash;
 29 
 30 import java.net.URI;
 31 import java.time.*;
 32 import java.time.format.DateTimeFormatter;
 33 import java.util.*;
 34 import java.util.logging.Logger;
 35 import java.util.stream.Collectors;
 36 
 37 public class GitHubPullRequest implements PullRequest {
 38     private final JSONValue json;
 39     private final RestRequest request;
 40     private final GitHubHost host;
 41     private final GitHubRepository repository;
 42     private final Logger log = Logger.getLogger("org.openjdk.skara.host");
 43 
 44     GitHubPullRequest(GitHubRepository repository, JSONValue jsonValue, RestRequest request) {
 45         this.host = (GitHubHost)repository.host();
 46         this.repository = repository;
 47         this.request = request;
 48         this.json = jsonValue;
 49     }
 50 
 51     @Override
 52     public HostedRepository repository() {
 53         return repository;
 54     }
 55 
 56     @Override
 57     public String getId() {
 58         return json.get("number").toString();
 59     }
 60 
 61     @Override
 62     public HostUserDetails getAuthor() {
 63         return host.parseUserField(json);
 64     }
 65 
 66     @Override
 67     public List<Review> getReviews() {
 68         var reviews = request.get("pulls/" + json.get("number").toString() + "/reviews").execute().stream()
 69                              .map(JSONValue::asObject)
 70                              .filter(obj -> !(obj.get("state").asString().equals("COMMENTED") && obj.get("body").asString().isEmpty()))
 71                              .map(obj -> {
 72                                  var reviewer = host.parseUserField(obj);
 73                                  var hash = new Hash(obj.get("commit_id").asString());
 74                                  Review.Verdict verdict;
 75                                  switch (obj.get("state").asString()) {
 76                                      case "APPROVED":
 77                                          verdict = Review.Verdict.APPROVED;
 78                                          break;
 79                                      case "CHANGES_REQUESTED":
 80                                          verdict = Review.Verdict.DISAPPROVED;
 81                                          break;
 82                                      default:
 83                                          verdict = Review.Verdict.NONE;
 84                                          break;
 85                                  }
 86                                  var id = obj.get("id").asInt();
 87                                  var body = obj.get("body").asString();
 88                                  return new Review(reviewer, verdict, hash, id, body);
 89                              })
 90                              .collect(Collectors.toList());
 91         return reviews;
 92     }
 93 
 94     @Override
 95     public void addReview(Review.Verdict verdict, String body) {
 96         var query = JSON.object();
 97         switch (verdict) {
 98             case APPROVED:
 99                 query.put("event", "APPROVE");
100                 break;
101             case DISAPPROVED:
102                 query.put("event", "REQUEST_CHANGES");
103                 break;
104             case NONE:
105                 query.put("event", "COMMENT");
106                 break;
107         }
108         query.put("body", body);
109         request.post("pulls/" + json.get("number").toString() + "/reviews")
110                .body(query)
111                .execute();
112     }
113 
114     private ReviewComment parseReviewComment(ReviewComment parent, JSONObject json, PositionMapper diff) {
115         var author = host.parseUserField(json);
116         var threadId = parent == null ? json.get("id").toString() : parent.threadId();
117         var comment = new ReviewComment(parent,
118                                         threadId,
119                                         new Hash(json.get("commit_id").asString()),
120                                         json.get("path").asString(),
121                                         diff.positionToLine(json.get("path").asString(), json.get("original_position").asInt()),
122                                         json.get("id").toString(),
123                                         json.get("body").asString(),
124                                         author,
125                                         ZonedDateTime.parse(json.get("created_at").asString()),
126                                         ZonedDateTime.parse(json.get("updated_at").asString()));
127         return comment;
128     }
129 
130     @Override
131     public ReviewComment addReviewComment(Hash base, Hash hash, String path, int line, String body) {
132         var rawDiff = request.get("pulls/" + json.get("number").toString())
133                              .header("Accept", "application/vnd.github.v3.diff")
134                              .executeUnparsed();
135         var diff = PositionMapper.parse(rawDiff);
136 
137         var query = JSON.object()
138                 .put("body", body)
139                 .put("commit_id", hash.hex())
140                 .put("path", path)
141                 .put("position", diff.lineToPosition(path, line));
142         var response = request.post("pulls/" + json.get("number").toString() + "/comments")
143                 .body(query)
144                 .execute();
145         return parseReviewComment(null, response.asObject(), diff);
146     }
147 
148     @Override
149     public ReviewComment addReviewCommentReply(ReviewComment parent, String body) {
150         var rawDiff = request.get("pulls/" + json.get("number").toString())
151                              .header("Accept", "application/vnd.github.v3.diff")
152                              .executeUnparsed();
153         var diff = PositionMapper.parse(rawDiff);
154 
155         var query = JSON.object()
156                         .put("body", body)
157                 .put("in_reply_to", Integer.parseInt(parent.threadId()));
158         var response = request.post("pulls/" + json.get("number").toString() + "/comments")
159                 .body(query)
160                 .execute();
161         return parseReviewComment(parent, response.asObject(), diff);
162     }
163 
164     @Override
165     public List<ReviewComment> getReviewComments() {
166         var rawDiff = request.get("pulls/" + json.get("number").toString())
167                           .header("Accept", "application/vnd.github.v3.diff")
168                           .executeUnparsed();
169         var diff = PositionMapper.parse(rawDiff);
170 
171         var ret = new ArrayList<ReviewComment>();
172         var reviewComments = request.get("pulls/" + json.get("number").toString() + "/comments").execute().stream()
173                                     .map(JSONValue::asObject)
174                                     .collect(Collectors.toList());
175         var idToComment = new HashMap<String, ReviewComment>();
176 
177         for (var reviewComment : reviewComments) {
178             ReviewComment parent = null;
179             if (reviewComment.contains("in_reply_to_id")) {
180                 parent = idToComment.get(reviewComment.get("in_reply_to_id").toString());
181             }
182             var comment = parseReviewComment(parent, reviewComment, diff);
183             idToComment.put(comment.id(), comment);
184             ret.add(comment);
185         }
186 
187         return ret;
188     }
189 
190     @Override
191     public Hash getHeadHash() {
192         return new Hash(json.get("head").get("sha").asString());
193     }
194 
195     @Override
196     public String getSourceRef() {
197         return "pull/" + getId() + "/head";
198     }
199 
200     @Override
201     public String getTargetRef() {
202         return json.get("base").get("ref").asString();
203     }
204 
205     @Override
206     public Hash getTargetHash() {
207         return repository.getBranchHash(getTargetRef());
208     }
209 
210     @Override
211     public String getTitle() {
212         return json.get("title").asString();
213     }
214 
215     @Override
216     public String getBody() {
217         var body = json.get("body").asString();
218         if (body == null) {
219             body = "";
220         }
221         return body;
222     }
223 
224     @Override
225     public void setBody(String body) {
226         request.patch("pulls/" + json.get("number").toString())
227                .body("body", body)
228                .execute();
229     }
230 
231     private Comment parseComment(JSONValue comment) {
232         var ret = new Comment(Integer.toString(comment.get("id").asInt()),
233                               comment.get("body").asString(),
234                               host.parseUserField(comment),
235                               ZonedDateTime.parse(comment.get("created_at").asString()),
236                               ZonedDateTime.parse(comment.get("updated_at").asString()));
237         return ret;
238     }
239 
240     @Override
241     public List<Comment> getComments() {
242         return request.get("issues/" + json.get("number").toString() + "/comments").execute().stream()
243                 .map(this::parseComment)
244                 .collect(Collectors.toList());
245     }
246 
247     @Override
248     public Comment addComment(String body) {
249         var comment = request.post("issues/" + json.get("number").toString() + "/comments")
250                 .body("body", body)
251                 .execute();
252         return parseComment(comment);
253     }
254 
255     @Override
256     public Comment updateComment(String id, String body) {
257         var comment = request.patch("issues/comments/" + id)
258                 .body("body", body)
259                 .execute();
260         return parseComment(comment);
261     }
262 
263     @Override
264     public ZonedDateTime getCreated() {
265         return ZonedDateTime.parse(json.get("created_at").asString());
266     }
267 
268     @Override
269     public ZonedDateTime getUpdated() {
270         return ZonedDateTime.parse(json.get("updated_at").asString());
271     }
272 
273     @Override
274     public Map<String, Check> getChecks(Hash hash) {
275         var checks = request.get("commits/" + hash.hex() + "/check-runs").execute();
276 
277         return checks.get("check_runs").stream()
278                 .collect(Collectors.toMap(c -> c.get("name").asString(),
279                         c -> {
280                             var checkBuilder = CheckBuilder.create(c.get("name").asString(), new Hash(c.get("head_sha").asString()));
281                             checkBuilder.startedAt(ZonedDateTime.parse(c.get("started_at").asString()));
282 
283                             var completed = c.get("status").asString().equals("completed");
284                             if (completed) {
285                                 checkBuilder.complete(c.get("conclusion").asString().equals("success"),
286                                         ZonedDateTime.parse(c.get("completed_at").asString()));
287                             }
288                             if (c.contains("external_id")) {
289                                 checkBuilder.metadata(c.get("external_id").asString());
290                             }
291                             if (c.contains("output")) {
292                                 var output = c.get("output").asObject();
293                                 if (output.contains("title")) {
294                                     checkBuilder.title(output.get("title").asString());
295                                 }
296                                 if (output.contains("summary")) {
297                                     checkBuilder.summary(output.get("summary").asString());
298                                 }
299                             }
300 
301                             return checkBuilder.build();
302                         }));
303     }
304 
305     @Override
306     public void createCheck(Check check) {
307         var checkQuery = JSON.object();
308         checkQuery.put("name", check.name());
309         checkQuery.put("head_branch", json.get("head").get("ref").asString());
310         checkQuery.put("head_sha", check.hash().hex());
311         checkQuery.put("started_at", check.startedAt().format(DateTimeFormatter.ISO_INSTANT));
312         checkQuery.put("status", "in_progress");
313         check.metadata().ifPresent(metadata -> checkQuery.put("external_id", metadata));
314 
315         request.post("check-runs").body(checkQuery).execute();
316     }
317 
318     @Override
319     public void updateCheck(Check check) {
320         JSONObject outputQuery = null;
321         if (check.title().isPresent() && check.summary().isPresent()) {
322             outputQuery = JSON.object();
323             outputQuery.put("title", check.title().get());
324             outputQuery.put("summary", check.summary().get());
325 
326             var annotations = JSON.array();
327             for (var annotation : check.annotations()) {
328                 var annotationQuery = JSON.object();
329                 annotationQuery.put("path", annotation.path());
330                 annotationQuery.put("start_line", annotation.startLine());
331                 annotationQuery.put("end_line", annotation.endLine());
332                 annotation.startColumn().ifPresent(startColumn -> annotationQuery.put("start_column", startColumn));
333                 annotation.endColumn().ifPresent(endColumn -> annotationQuery.put("end_column", endColumn));
334                 switch (annotation.level()) {
335                     case NOTICE:
336                         annotationQuery.put("annotation_level", "notice");
337                         break;
338                     case WARNING:
339                         annotationQuery.put("annotation_level", "warning");
340                         break;
341                     case FAILURE:
342                         annotationQuery.put("annotation_level", "failure");
343                         break;
344                 }
345 
346                 annotationQuery.put("message", annotation.message());
347                 annotation.title().ifPresent(title -> annotationQuery.put("title", title));
348                 annotations.add(annotationQuery);
349             }
350 
351             outputQuery.put("annotations", annotations);
352         }
353 
354         var completedQuery = JSON.object();
355         completedQuery.put("name", check.name());
356         completedQuery.put("head_branch", json.get("head").get("ref"));
357         completedQuery.put("head_sha", check.hash().hex());
358         completedQuery.put("status", "completed");
359         completedQuery.put("started_at", check.startedAt().format(DateTimeFormatter.ISO_INSTANT));
360         check.metadata().ifPresent(metadata -> completedQuery.put("external_id", metadata));
361 
362         if (check.status() != CheckStatus.IN_PROGRESS) {
363             completedQuery.put("conclusion", check.status() == CheckStatus.SUCCESS ? "success" : "failure");
364             completedQuery.put("completed_at", check.completedAt().orElse(ZonedDateTime.now(ZoneOffset.UTC))
365                     .format(DateTimeFormatter.ISO_INSTANT));
366         }
367 
368         if (outputQuery != null) {
369             completedQuery.put("output", outputQuery);
370         }
371 
372         request.post("check-runs").body(completedQuery).execute();
373     }
374 
375     @Override
376     public void setState(State state) {
377         request.patch("pulls/" + json.get("number").toString())
378                .body("state", state == State.CLOSED ? "closed" : "open")
379                .execute();
380     }
381 
382     @Override
383     public void addLabel(String label) {
384         var query = JSON.object().put("labels", JSON.array().add(label));
385         request.post("issues/" + json.get("number").toString() + "/labels")
386                .body(query)
387                .execute();
388     }
389 
390     @Override
391     public void removeLabel(String label) {
392         request.delete("issues/" + json.get("number").toString() + "/labels/" + label)
393                .onError(r -> {
394                    // The GitHub API explicitly states that 404 is the response for deleting labels currently not set
395                    if (r.statusCode() == 404) {
396                        return JSONValue.fromNull();
397                    }
398                    throw new RuntimeException("Invalid response");
399                })
400                .execute();
401     }
402 
403     @Override
404     public List<String> getLabels() {
405         return request.get("issues/" + json.get("number").toString() + "/labels").execute().stream()
406                       .map(JSONValue::asObject)
407                       .map(obj -> obj.get("name").asString())
408                       .sorted()
409                       .collect(Collectors.toList());
410     }
411 
412     @Override
413     public URI getWebUrl() {
414         var host = (GitHubHost)repository.host();
415         var endpoint = "/" + repository.getName() + "/pull/" + getId();
416         return host.getWebURI(endpoint);
417     }
418 
419     @Override
420     public String toString() {
421         return "GitHubPullRequest #" + getId() + " by " + getAuthor();
422     }
423 
424     @Override
425     public List<HostUserDetails> getAssignees() {
426         return json.get("assignees").asArray()
427                                     .stream()
428                                     .map(host::parseUserObject)
429                                     .collect(Collectors.toList());
430     }
431 
432     @Override
433     public void setAssignees(List<HostUserDetails> assignees) {
434         var assignee_ids = JSON.array();
435         for (var assignee : assignees) {
436             assignee_ids.add(assignee.userName());
437         }
438         var param = JSON.object().put("assignees", assignee_ids);
439         request.patch("issues/" + json.get("number").toString()).body(param).execute();
440     }
441 }