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 }