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 void setTitle(String title) {
217         throw new RuntimeException("not implemented yet");
218     }
219 
220     @Override
221     public String getBody() {
222         var body = json.get("body").asString();
223         if (body == null) {
224             body = "";
225         }
226         return body;
227     }
228 
229     @Override
230     public void setBody(String body) {
231         request.patch("pulls/" + json.get("number").toString())
232                .body("body", body)
233                .execute();
234     }
235 
236     private Comment parseComment(JSONValue comment) {
237         var ret = new Comment(Integer.toString(comment.get("id").asInt()),
238                               comment.get("body").asString(),
239                               host.parseUserField(comment),
240                               ZonedDateTime.parse(comment.get("created_at").asString()),
241                               ZonedDateTime.parse(comment.get("updated_at").asString()));
242         return ret;
243     }
244 
245     @Override
246     public List<Comment> getComments() {
247         return request.get("issues/" + json.get("number").toString() + "/comments").execute().stream()
248                 .map(this::parseComment)
249                 .collect(Collectors.toList());
250     }
251 
252     @Override
253     public Comment addComment(String body) {
254         var comment = request.post("issues/" + json.get("number").toString() + "/comments")
255                 .body("body", body)
256                 .execute();
257         return parseComment(comment);
258     }
259 
260     @Override
261     public Comment updateComment(String id, String body) {
262         var comment = request.patch("issues/comments/" + id)
263                 .body("body", body)
264                 .execute();
265         return parseComment(comment);
266     }
267 
268     @Override
269     public ZonedDateTime getCreated() {
270         return ZonedDateTime.parse(json.get("created_at").asString());
271     }
272 
273     @Override
274     public ZonedDateTime getUpdated() {
275         return ZonedDateTime.parse(json.get("updated_at").asString());
276     }
277 
278     @Override
279     public Map<String, Check> getChecks(Hash hash) {
280         var checks = request.get("commits/" + hash.hex() + "/check-runs").execute();
281 
282         return checks.get("check_runs").stream()
283                 .collect(Collectors.toMap(c -> c.get("name").asString(),
284                         c -> {
285                             var checkBuilder = CheckBuilder.create(c.get("name").asString(), new Hash(c.get("head_sha").asString()));
286                             checkBuilder.startedAt(ZonedDateTime.parse(c.get("started_at").asString()));
287 
288                             var completed = c.get("status").asString().equals("completed");
289                             if (completed) {
290                                 checkBuilder.complete(c.get("conclusion").asString().equals("success"),
291                                         ZonedDateTime.parse(c.get("completed_at").asString()));
292                             }
293                             if (c.contains("external_id")) {
294                                 checkBuilder.metadata(c.get("external_id").asString());
295                             }
296                             if (c.contains("output")) {
297                                 var output = c.get("output").asObject();
298                                 if (output.contains("title")) {
299                                     checkBuilder.title(output.get("title").asString());
300                                 }
301                                 if (output.contains("summary")) {
302                                     checkBuilder.summary(output.get("summary").asString());
303                                 }
304                             }
305 
306                             return checkBuilder.build();
307                         }));
308     }
309 
310     @Override
311     public void createCheck(Check check) {
312         var checkQuery = JSON.object();
313         checkQuery.put("name", check.name());
314         checkQuery.put("head_branch", json.get("head").get("ref").asString());
315         checkQuery.put("head_sha", check.hash().hex());
316         checkQuery.put("started_at", check.startedAt().format(DateTimeFormatter.ISO_INSTANT));
317         checkQuery.put("status", "in_progress");
318         check.metadata().ifPresent(metadata -> checkQuery.put("external_id", metadata));
319 
320         request.post("check-runs").body(checkQuery).execute();
321     }
322 
323     @Override
324     public void updateCheck(Check check) {
325         JSONObject outputQuery = null;
326         if (check.title().isPresent() && check.summary().isPresent()) {
327             outputQuery = JSON.object();
328             outputQuery.put("title", check.title().get());
329             outputQuery.put("summary", check.summary().get());
330 
331             var annotations = JSON.array();
332             for (var annotation : check.annotations()) {
333                 var annotationQuery = JSON.object();
334                 annotationQuery.put("path", annotation.path());
335                 annotationQuery.put("start_line", annotation.startLine());
336                 annotationQuery.put("end_line", annotation.endLine());
337                 annotation.startColumn().ifPresent(startColumn -> annotationQuery.put("start_column", startColumn));
338                 annotation.endColumn().ifPresent(endColumn -> annotationQuery.put("end_column", endColumn));
339                 switch (annotation.level()) {
340                     case NOTICE:
341                         annotationQuery.put("annotation_level", "notice");
342                         break;
343                     case WARNING:
344                         annotationQuery.put("annotation_level", "warning");
345                         break;
346                     case FAILURE:
347                         annotationQuery.put("annotation_level", "failure");
348                         break;
349                 }
350 
351                 annotationQuery.put("message", annotation.message());
352                 annotation.title().ifPresent(title -> annotationQuery.put("title", title));
353                 annotations.add(annotationQuery);
354             }
355 
356             outputQuery.put("annotations", annotations);
357         }
358 
359         var completedQuery = JSON.object();
360         completedQuery.put("name", check.name());
361         completedQuery.put("head_branch", json.get("head").get("ref"));
362         completedQuery.put("head_sha", check.hash().hex());
363         completedQuery.put("status", "completed");
364         completedQuery.put("started_at", check.startedAt().format(DateTimeFormatter.ISO_INSTANT));
365         check.metadata().ifPresent(metadata -> completedQuery.put("external_id", metadata));
366 
367         if (check.status() != CheckStatus.IN_PROGRESS) {
368             completedQuery.put("conclusion", check.status() == CheckStatus.SUCCESS ? "success" : "failure");
369             completedQuery.put("completed_at", check.completedAt().orElse(ZonedDateTime.now(ZoneOffset.UTC))
370                     .format(DateTimeFormatter.ISO_INSTANT));
371         }
372 
373         if (outputQuery != null) {
374             completedQuery.put("output", outputQuery);
375         }
376 
377         request.post("check-runs").body(completedQuery).execute();
378     }
379 
380     @Override
381     public void setState(State state) {
382         request.patch("pulls/" + json.get("number").toString())
383                .body("state", state == State.CLOSED ? "closed" : "open")
384                .execute();
385     }
386 
387     @Override
388     public void addLabel(String label) {
389         var query = JSON.object().put("labels", JSON.array().add(label));
390         request.post("issues/" + json.get("number").toString() + "/labels")
391                .body(query)
392                .execute();
393     }
394 
395     @Override
396     public void removeLabel(String label) {
397         request.delete("issues/" + json.get("number").toString() + "/labels/" + label)
398                .onError(r -> {
399                    // The GitHub API explicitly states that 404 is the response for deleting labels currently not set
400                    if (r.statusCode() == 404) {
401                        return JSONValue.fromNull();
402                    }
403                    throw new RuntimeException("Invalid response");
404                })
405                .execute();
406     }
407 
408     @Override
409     public List<String> getLabels() {
410         return request.get("issues/" + json.get("number").toString() + "/labels").execute().stream()
411                       .map(JSONValue::asObject)
412                       .map(obj -> obj.get("name").asString())
413                       .sorted()
414                       .collect(Collectors.toList());
415     }
416 
417     @Override
418     public URI getWebUrl() {
419         var host = (GitHubHost)repository.host();
420         var endpoint = "/" + repository.getName() + "/pull/" + getId();
421         return host.getWebURI(endpoint);
422     }
423 
424     @Override
425     public String toString() {
426         return "GitHubPullRequest #" + getId() + " by " + getAuthor();
427     }
428 
429     @Override
430     public List<HostUserDetails> getAssignees() {
431         return json.get("assignees").asArray()
432                                     .stream()
433                                     .map(host::parseUserObject)
434                                     .collect(Collectors.toList());
435     }
436 
437     @Override
438     public void setAssignees(List<HostUserDetails> assignees) {
439         var assignee_ids = JSON.array();
440         for (var assignee : assignees) {
441             assignee_ids.add(assignee.userName());
442         }
443         var param = JSON.object().put("assignees", assignee_ids);
444         request.patch("issues/" + json.get("number").toString()).body(param).execute();
445     }
446 }