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 }