1 /* 2 * Copyright (c) 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.cli; 24 25 import org.openjdk.skara.args.*; 26 import org.openjdk.skara.host.*; 27 import org.openjdk.skara.vcs.*; 28 import org.openjdk.skara.vcs.openjdk.*; 29 import org.openjdk.skara.proxy.HttpProxy; 30 import org.openjdk.skara.ssh.SSHConfig; 31 32 import java.io.IOException; 33 import java.net.URI; 34 import java.nio.file.*; 35 import java.util.*; 36 import java.util.concurrent.TimeUnit; 37 import java.util.function.Supplier; 38 import java.util.logging.Level; 39 import java.util.stream.Collectors; 40 import java.nio.charset.StandardCharsets; 41 42 public class GitPr { 43 private static void exit(String fmt, Object...args) { 44 System.err.println(String.format(fmt, args)); 45 System.exit(1); 46 } 47 48 private static <T> Supplier<T> die(String fmt, Object... args) { 49 return () -> { 50 exit(fmt, args); 51 return null; 52 }; 53 } 54 55 private static void await(Process p) throws IOException { 56 try { 57 var res = p.waitFor(); 58 if (res != 0) { 59 throw new IOException("Unexpected exit code " + res); 60 } 61 } catch (InterruptedException e) { 62 throw new IOException(e); 63 } 64 } 65 66 private static boolean spawnEditor(ReadOnlyRepository repo, Path file) throws IOException { 67 String editor = null; 68 var lines = repo.config("core.editor"); 69 if (lines.size() == 1) { 70 editor = lines.get(0); 71 } 72 if (editor == null) { 73 editor = System.getenv("GIT_EDITOR"); 74 } 75 if (editor == null) { 76 editor = System.getenv("EDITOR"); 77 } 78 if (editor == null) { 79 editor = System.getenv("VISUAL"); 80 } 81 if (editor == null) { 82 editor = "vi"; 83 } 84 85 var pb = new ProcessBuilder(editor, file.toString()); 86 pb.inheritIO(); 87 var p = pb.start(); 88 try { 89 return p.waitFor() == 0; 90 } catch (InterruptedException e) { 91 throw new IOException(e); 92 } 93 } 94 95 private static String projectName(URI uri) { 96 var name = uri.getPath().toString().substring(1); 97 if (name.endsWith(".git")) { 98 name = name.substring(0, name.length() - ".git".length()); 99 } 100 return name; 101 } 102 103 private static HostedRepository getHostedRepositoryFor(URI uri, GitCredentials credentials) throws IOException { 104 var host = Host.from(uri, new PersonalAccessToken(credentials.username(), credentials.password())); 105 if (System.getenv("GIT_TOKEN") == null) { 106 GitCredentials.approve(credentials); 107 } 108 var remoteRepo = host.getRepository(projectName(uri)); 109 var parentRepo = remoteRepo.getParent(); 110 var targetRepo = parentRepo.isPresent() ? parentRepo.get() : remoteRepo; 111 return targetRepo; 112 } 113 114 private static PullRequest getPullRequest(URI uri, GitCredentials credentials, Argument prId) throws IOException { 115 if (!prId.isPresent()) { 116 exit("error: missing pull request identifier"); 117 } 118 119 var pr = getHostedRepositoryFor(uri, credentials).getPullRequest(prId.asString()); 120 if (pr == null) { 121 exit("error: could not fetch PR information"); 122 } 123 124 return pr; 125 } 126 127 private static void show(String ref, Hash hash) throws IOException { 128 show(ref, hash, null); 129 } 130 private static void show(String ref, Hash hash, Path dir) throws IOException { 131 var pb = new ProcessBuilder("git", "diff", "--binary", 132 "--patch", 133 "--find-renames=50%", 134 "--find-copies=50%", 135 "--find-copies-harder", 136 "--abbrev", 137 ref + "..." + hash.hex()); 138 if (dir != null) { 139 pb.directory(dir.toFile()); 140 } 141 pb.inheritIO(); 142 await(pb.start()); 143 } 144 145 private static void gimport() throws IOException { 146 var pb = new ProcessBuilder("hg", "gimport"); 147 pb.inheritIO(); 148 await(pb.start()); 149 } 150 151 private static void hgImport(Path patch) throws IOException { 152 var pb = new ProcessBuilder("hg", "import", "--no-commit", patch.toAbsolutePath().toString()); 153 pb.inheritIO(); 154 await(pb.start()); 155 } 156 157 private static List<String> hgTags() throws IOException, InterruptedException { 158 var pb = new ProcessBuilder("hg", "tags", "--quiet"); 159 pb.redirectOutput(ProcessBuilder.Redirect.PIPE); 160 pb.redirectError(ProcessBuilder.Redirect.INHERIT); 161 var p = pb.start(); 162 var bytes = p.getInputStream().readAllBytes(); 163 var exited = p.waitFor(1, TimeUnit.MINUTES); 164 var exitValue = p.exitValue(); 165 if (!exited || exitValue != 0) { 166 throw new IOException("'hg tags' exited with value: " + exitValue); 167 } 168 169 return Arrays.asList(new String(bytes, StandardCharsets.UTF_8).split("\n")); 170 } 171 172 private static String hgResolve(String ref) throws IOException, InterruptedException { 173 var pb = new ProcessBuilder("hg", "log", "-r", ref, "--template", "{node}"); 174 pb.redirectOutput(ProcessBuilder.Redirect.PIPE); 175 pb.redirectError(ProcessBuilder.Redirect.INHERIT); 176 var p = pb.start(); 177 var bytes = p.getInputStream().readAllBytes(); 178 var exited = p.waitFor(1, TimeUnit.MINUTES); 179 var exitValue = p.exitValue(); 180 if (!exited || exitValue != 0) { 181 throw new IOException("'hg log' exited with value: " + exitValue); 182 } 183 184 return new String(bytes, StandardCharsets.UTF_8); 185 } 186 187 private static Path diff(String ref, Hash hash) throws IOException { 188 return diff(ref, hash, null); 189 } 190 191 private static Path diff(String ref, Hash hash, Path dir) throws IOException { 192 var patch = Files.createTempFile(hash.hex(), ".patch"); 193 var pb = new ProcessBuilder("git", "diff", "--binary", 194 "--patch", 195 "--find-renames=50%", 196 "--find-copies=50%", 197 "--find-copies-harder", 198 "--abbrev", 199 ref + "..." + hash.hex()); 200 if (dir != null) { 201 pb.directory(dir.toFile()); 202 } 203 pb.redirectOutput(patch.toFile()); 204 pb.redirectError(ProcessBuilder.Redirect.INHERIT); 205 await(pb.start()); 206 return patch; 207 } 208 209 private static void apply(Path patch) throws IOException { 210 var pb = new ProcessBuilder("git", "apply", "--no-commit", patch.toString()); 211 pb.inheritIO(); 212 await(pb.start()); 213 } 214 215 private static int longest(List<String> strings) { 216 return strings.stream().mapToInt(String::length).max().orElse(0); 217 } 218 219 public static void main(String[] args) throws IOException, InterruptedException { 220 var flags = List.of( 221 Option.shortcut("u") 222 .fullname("username") 223 .describe("NAME") 224 .helptext("Username on host") 225 .optional(), 226 Option.shortcut("r") 227 .fullname("remote") 228 .describe("NAME") 229 .helptext("Name of remote, defaults to 'origin'") 230 .optional(), 231 Option.shortcut("b") 232 .fullname("branch") 233 .describe("NAME") 234 .helptext("Name of target branch, defaults to 'master'") 235 .optional(), 236 Option.shortcut("") 237 .fullname("authors") 238 .describe("LIST") 239 .helptext("Comma separated list of authors") 240 .optional(), 241 Option.shortcut("") 242 .fullname("assignees") 243 .describe("LIST") 244 .helptext("Comma separated list of assignees") 245 .optional(), 246 Option.shortcut("") 247 .fullname("labels") 248 .describe("LIST") 249 .helptext("Comma separated list of labels") 250 .optional(), 251 Option.shortcut("") 252 .fullname("columns") 253 .describe("id,title,author,assignees,labels") 254 .helptext("Comma separated list of columns to show") 255 .optional(), 256 Switch.shortcut("") 257 .fullname("no-decoration") 258 .helptext("Hide any decorations when listing PRs") 259 .optional(), 260 Switch.shortcut("") 261 .fullname("mercurial") 262 .helptext("Force use of Mercurial (hg)") 263 .optional(), 264 Switch.shortcut("") 265 .fullname("verbose") 266 .helptext("Turn on verbose output") 267 .optional(), 268 Switch.shortcut("") 269 .fullname("debug") 270 .helptext("Turn on debugging output") 271 .optional(), 272 Switch.shortcut("") 273 .fullname("version") 274 .helptext("Print the version of this tool") 275 .optional()); 276 277 var inputs = List.of( 278 Input.position(0) 279 .describe("list|fetch|show|checkout|apply|integrate|approve|create|close|update") 280 .singular() 281 .required(), 282 Input.position(1) 283 .describe("ID") 284 .singular() 285 .optional() 286 ); 287 288 var parser = new ArgumentParser("git-pr", flags, inputs); 289 var arguments = parser.parse(args); 290 291 if (arguments.contains("version")) { 292 System.out.println("git-pr version: " + Version.fromManifest().orElse("unknown")); 293 System.exit(0); 294 } 295 296 if (arguments.contains("verbose") || arguments.contains("debug")) { 297 var level = arguments.contains("debug") ? Level.FINER : Level.FINE; 298 Logging.setup(level); 299 } 300 301 HttpProxy.setup(); 302 303 var isMercurial = arguments.contains("mercurial"); 304 var cwd = Path.of("").toAbsolutePath(); 305 var repo = Repository.get(cwd).orElseThrow(() -> new IOException("no git repository found at " + cwd.toString())); 306 var remote = arguments.get("remote").orString(isMercurial ? "default" : "origin"); 307 var remotePullPath = repo.pullPath(remote); 308 var username = arguments.contains("username") ? arguments.get("username").asString() : null; 309 var token = isMercurial ? System.getenv("HG_TOKEN") : System.getenv("GIT_TOKEN"); 310 var uri = Remote.toWebURI(remotePullPath); 311 var credentials = GitCredentials.fill(uri.getHost(), uri.getPath(), username, token, uri.getScheme()); 312 var host = Host.from(uri, new PersonalAccessToken(credentials.username(), credentials.password())); 313 314 var action = arguments.at(0).asString(); 315 if (action.equals("create")) { 316 if (isMercurial) { 317 var currentBookmark = repo.currentBookmark(); 318 if (!currentBookmark.isPresent()) { 319 System.err.println("error: no bookmark is active, you must be on an active bookmark"); 320 System.err.println(""); 321 System.err.println("To create a bookmark and activate it, run:"); 322 System.err.println(""); 323 System.err.println(" hg bookmark NAME-FOR-YOUR-BOOKMARK"); 324 System.err.println(""); 325 System.exit(1); 326 } 327 328 var bookmark = currentBookmark.get(); 329 if (bookmark.equals(new Bookmark("master"))) { 330 System.err.println("error: you should not create pull requests from the 'master' bookmark"); 331 System.err.println("To create a bookmark and activate it, run:"); 332 System.err.println(""); 333 System.err.println(" hg bookmark NAME-FOR-YOUR-BOOKMARK"); 334 System.err.println(""); 335 System.exit(1); 336 } 337 338 var tags = hgTags(); 339 var upstreams = tags.stream() 340 .filter(t -> t.endsWith(bookmark.name())) 341 .collect(Collectors.toList()); 342 if (upstreams.isEmpty()) { 343 System.err.println("error: there is no remote branch for the local bookmark '" + bookmark.name() + "'"); 344 System.err.println(""); 345 System.err.println("To create a remote branch and push the commits for your local branch, run:"); 346 System.err.println(""); 347 System.err.println(" hg push --bookmark " + bookmark.name()); 348 System.err.println(""); 349 System.exit(1); 350 } 351 352 var tagsAndHashes = new HashMap<String, String>(); 353 for (var tag : tags) { 354 tagsAndHashes.put(tag, hgResolve(tag)); 355 } 356 var bookmarkHash = hgResolve(bookmark.name()); 357 if (!tagsAndHashes.containsValue(bookmarkHash)) { 358 System.err.println("error: there are local commits on bookmark '" + bookmark.name() + "' not present in a remote repository"); 359 System.err.println(""); 360 361 if (upstreams.size() == 1) { 362 System.err.println("To push the local commits to the remote repository, run:"); 363 System.err.println(""); 364 System.err.println(" hg push --bookmark " + bookmark.name() + " " + upstreams.get(0)); 365 System.err.println(""); 366 } else { 367 System.err.println("The following paths contains the " + bookmark.name() + " bookmark:"); 368 System.err.println(""); 369 for (var upstream : upstreams) { 370 System.err.println("- " + upstream.replace("/" + bookmark.name(), "")); 371 } 372 System.err.println(""); 373 System.err.println("To push the local commits to a remote repository, run:"); 374 System.err.println(""); 375 System.err.println(" hg push --bookmark " + bookmark.name() + " <PATH>"); 376 System.err.println(""); 377 } 378 System.exit(1); 379 } 380 381 var targetBranch = arguments.get("branch").orString("master"); 382 var targetHash = hgResolve(targetBranch); 383 var commits = repo.commits(targetHash + ".." + bookmarkHash + "-" + targetHash).asList(); 384 if (commits.isEmpty()) { 385 System.err.println("error: no difference between bookmarks " + targetBranch + " and " + bookmark.name()); 386 System.err.println(" Cannot create an empty pull request, have you committed?"); 387 System.exit(1); 388 } 389 390 var diff = repo.diff(repo.head()); 391 if (!diff.patches().isEmpty()) { 392 System.err.println("error: there are uncommitted changes in your working tree:"); 393 System.err.println(""); 394 for (var patch : diff.patches()) { 395 var path = patch.target().path().isPresent() ? 396 patch.target().path().get() : patch.source().path().get(); 397 System.err.println(" " + patch.status().toString() + " " + path.toString()); 398 } 399 System.err.println(""); 400 System.err.println("If these changes are meant to be part of the pull request, run:"); 401 System.err.println(""); 402 System.err.println(" hg commit --amend"); 403 System.err.println(" hg git-cleanup"); 404 System.err.println(" hg push --bookmark " + bookmark.name() + " <PATH>"); 405 System.err.println(" hg gimport"); 406 System.err.println(""); 407 System.err.println("If these changes are *not* meant to be part of the pull request, run:"); 408 System.err.println(""); 409 System.err.println(" hg shelve"); 410 System.err.println(""); 411 System.err.println("(You can later restore the changes by running: hg unshelve)"); 412 System.exit(1); 413 } 414 415 var remoteRepo = host.getRepository(projectName(uri)); 416 if (token == null) { 417 GitCredentials.approve(credentials); 418 } 419 var parentRepo = remoteRepo.getParent().orElseThrow(() -> 420 new IOException("error: remote repository " + remotePullPath + " is not a fork of any repository")); 421 422 var file = Files.createTempFile("PULL_REQUEST_", ".txt"); 423 if (commits.size() == 1) { 424 var commit = commits.get(0); 425 var message = CommitMessageParsers.v1.parse(commit.message()); 426 Files.writeString(file, message.title() + "\n"); 427 if (!message.summaries().isEmpty()) { 428 Files.write(file, message.summaries(), StandardOpenOption.APPEND); 429 } 430 if (!message.additional().isEmpty()) { 431 Files.write(file, message.additional(), StandardOpenOption.APPEND); 432 } 433 } else { 434 Files.write(file, List.of("")); 435 } 436 Files.write(file, List.of( 437 "# Please enter the pull request message for your changes. Lines starting", 438 "# with '#' will be ignored, and an empty message aborts the pull request.", 439 "# The first line will be considered the subject, use a blank line to separate", 440 "# the subject from the body.", 441 "#", 442 "# Commits to be included from branch '" + bookmark.name() + "'" 443 ), 444 StandardOpenOption.APPEND 445 ); 446 for (var commit : commits) { 447 var desc = commit.hash().abbreviate() + ": " + commit.message().get(0); 448 Files.writeString(file, "# - " + desc + "\n", StandardOpenOption.APPEND); 449 } 450 Files.writeString(file, "#\n", StandardOpenOption.APPEND); 451 Files.writeString(file, "# Target repository: " + remotePullPath + "\n", StandardOpenOption.APPEND); 452 Files.writeString(file, "# Target branch: " + targetBranch + "\n", StandardOpenOption.APPEND); 453 var success = spawnEditor(repo, file); 454 if (!success) { 455 System.err.println("error: editor exited with non-zero status code, aborting"); 456 System.exit(1); 457 } 458 var lines = Files.readAllLines(file) 459 .stream() 460 .filter(l -> !l.startsWith("#")) 461 .collect(Collectors.toList()); 462 var isEmpty = lines.stream().allMatch(String::isEmpty); 463 if (isEmpty) { 464 System.err.println("error: no message present, aborting"); 465 System.exit(1); 466 } 467 468 var title = lines.get(0); 469 List<String> body = null; 470 if (lines.size() > 1) { 471 body = lines.subList(1, lines.size()) 472 .stream() 473 .dropWhile(String::isEmpty) 474 .collect(Collectors.toList()); 475 } else { 476 body = Collections.emptyList(); 477 } 478 479 var pr = remoteRepo.createPullRequest(parentRepo, targetBranch, bookmark.name(), title, body); 480 if (arguments.contains("assignees")) { 481 var usernames = Arrays.asList(arguments.get("assignees").asString().split(",")); 482 var assignees = usernames.stream() 483 .map(host::getUserDetails) 484 .collect(Collectors.toList()); 485 pr.setAssignees(assignees); 486 } 487 System.out.println(pr.getWebUrl().toString()); 488 Files.deleteIfExists(file); 489 490 System.exit(0); 491 } 492 var currentBranch = repo.currentBranch(); 493 if (currentBranch.equals(repo.defaultBranch())) { 494 System.err.println("error: you should not create pull requests from the 'master' branch"); 495 System.err.println(""); 496 System.err.println("To create a local branch for your changes and restore the 'master' branch, run:"); 497 System.err.println(""); 498 System.err.println(" git checkout -b NAME-FOR-YOUR-LOCAL-BRANCH"); 499 System.err.println(" git branch --force master origin/master"); 500 System.err.println(""); 501 System.exit(1); 502 } 503 504 var upstream = repo.upstreamFor(currentBranch); 505 if (upstream.isEmpty()) { 506 System.err.println("error: there is no remote branch for the local branch '" + currentBranch.name() + "'"); 507 System.err.println(""); 508 System.err.println("A remote branch must be present at " + remotePullPath + " to create a pull request"); 509 System.err.println("To create a remote branch and push the commits for your local branch, run:"); 510 System.err.println(""); 511 System.err.println(" git push --set-upstream " + remote + " " + currentBranch.name()); 512 System.err.println(""); 513 System.err.println("If you created the remote branch from another client, you must update this repository."); 514 System.err.println("To update remote information for this repository, run:"); 515 System.err.println(""); 516 System.err.println(" git fetch " + remote); 517 System.err.println(" git branch --set-upstream " + currentBranch + " " + remote + "/" + currentBranch); 518 System.err.println(""); 519 System.exit(1); 520 } 521 522 var upstreamRefName = upstream.get().substring(remote.length() + 1); 523 repo.fetch(uri, upstreamRefName); 524 var branchCommits = repo.commits(upstream.get() + ".." + currentBranch.name()).asList(); 525 if (!branchCommits.isEmpty()) { 526 System.err.println("error: there are local commits on branch '" + currentBranch.name() + "' not present in the remote repository " + remotePullPath); 527 System.err.println(""); 528 System.err.println("All commits must be present in the remote repository to be part of the pull request"); 529 System.err.println("The following commits are not present in the remote repository:"); 530 System.err.println(""); 531 for (var commit : branchCommits) { 532 System.err.println("- " + commit.hash().abbreviate() + ": " + commit.message().get(0)); 533 } 534 System.err.println(""); 535 System.err.println("To push the above local commits to the remote repository, run:"); 536 System.err.println(""); 537 System.err.println(" git push " + remote + " " + currentBranch.name()); 538 System.err.println(""); 539 System.exit(1); 540 } 541 542 var targetBranch = arguments.get("branch").orString("master"); 543 var commits = repo.commits(targetBranch + ".." + currentBranch.name()).asList(); 544 if (commits.isEmpty()) { 545 System.err.println("error: no difference between branches " + targetBranch + " and " + currentBranch.name()); 546 System.err.println(" Cannot create an empty pull request, have you committed?"); 547 System.exit(1); 548 } 549 550 var diff = repo.diff(repo.head()); 551 if (!diff.patches().isEmpty()) { 552 System.err.println("error: there are uncommitted changes in your working tree:"); 553 System.err.println(""); 554 for (var patch : diff.patches()) { 555 var path = patch.target().path().isPresent() ? 556 patch.target().path().get() : patch.source().path().get(); 557 System.err.println(" " + patch.status().toString() + " " + path.toString()); 558 } 559 System.err.println(""); 560 System.err.println("If these changes are meant to be part of the pull request, run:"); 561 System.err.println(""); 562 System.err.println(" git commit -am 'Forgot to add some changes'"); 563 System.err.println(""); 564 System.err.println("If these changes are *not* meant to be part of the pull request, run:"); 565 System.err.println(""); 566 System.err.println(" git stash"); 567 System.err.println(""); 568 System.err.println("(You can later restore the changes by running: git stash pop)"); 569 System.exit(1); 570 } 571 572 var remoteRepo = host.getRepository(projectName(uri)); 573 if (token == null) { 574 GitCredentials.approve(credentials); 575 } 576 var parentRepo = remoteRepo.getParent().orElseThrow(() -> 577 new IOException("error: remote repository " + remotePullPath + " is not a fork of any repository")); 578 579 var file = Files.createTempFile("PULL_REQUEST_", ".txt"); 580 if (commits.size() == 1) { 581 var commit = commits.get(0); 582 var message = CommitMessageParsers.v1.parse(commit.message()); 583 Files.writeString(file, message.title() + "\n"); 584 if (!message.summaries().isEmpty()) { 585 Files.write(file, message.summaries(), StandardOpenOption.APPEND); 586 } 587 if (!message.additional().isEmpty()) { 588 Files.write(file, message.additional(), StandardOpenOption.APPEND); 589 } 590 } else { 591 Files.write(file, List.of("")); 592 } 593 Files.write(file, List.of( 594 "# Please enter the pull request message for your changes. Lines starting", 595 "# with '#' will be ignored, and an empty message aborts the pull request.", 596 "# The first line will be considered the subject, use a blank line to separate", 597 "# the subject from the body.", 598 "#", 599 "# Commits to be included from branch '" + currentBranch.name() + "'" 600 ), 601 StandardOpenOption.APPEND 602 ); 603 for (var commit : commits) { 604 var desc = commit.hash().abbreviate() + ": " + commit.message().get(0); 605 Files.writeString(file, "# - " + desc + "\n", StandardOpenOption.APPEND); 606 } 607 Files.writeString(file, "#\n", StandardOpenOption.APPEND); 608 Files.writeString(file, "# Target repository: " + remotePullPath + "\n", StandardOpenOption.APPEND); 609 Files.writeString(file, "# Target branch: " + targetBranch + "\n", StandardOpenOption.APPEND); 610 var success = spawnEditor(repo, file); 611 if (!success) { 612 System.err.println("error: editor exited with non-zero status code, aborting"); 613 System.exit(1); 614 } 615 var lines = Files.readAllLines(file) 616 .stream() 617 .filter(l -> !l.startsWith("#")) 618 .collect(Collectors.toList()); 619 var isEmpty = lines.stream().allMatch(String::isEmpty); 620 if (isEmpty) { 621 System.err.println("error: no message present, aborting"); 622 System.exit(1); 623 } 624 625 var title = lines.get(0); 626 List<String> body = null; 627 if (lines.size() > 1) { 628 body = lines.subList(1, lines.size()) 629 .stream() 630 .dropWhile(String::isEmpty) 631 .collect(Collectors.toList()); 632 } else { 633 body = Collections.emptyList(); 634 } 635 636 var pr = remoteRepo.createPullRequest(parentRepo, targetBranch, currentBranch.name(), title, body); 637 if (arguments.contains("assignees")) { 638 var usernames = Arrays.asList(arguments.get("assignees").asString().split(",")); 639 var assignees = usernames.stream() 640 .map(host::getUserDetails) 641 .collect(Collectors.toList()); 642 pr.setAssignees(assignees); 643 } 644 System.out.println(pr.getWebUrl().toString()); 645 Files.deleteIfExists(file); 646 } else if (action.equals("integrate") || action.equals("approve")) { 647 var pr = getPullRequest(uri, credentials, arguments.at(1)); 648 649 if (action.equals("integrate")) { 650 pr.addComment("/integrate"); 651 } else if (action.equals("approve")) { 652 pr.addReview(Review.Verdict.APPROVED, "Looks good!"); 653 } else { 654 throw new IllegalStateException("unexpected action: " + action); 655 } 656 } else if (action.equals("list")) { 657 var remoteRepo = getHostedRepositoryFor(uri, credentials); 658 var prs = remoteRepo.getPullRequests(); 659 660 var ids = new ArrayList<String>(); 661 var titles = new ArrayList<String>(); 662 var authors = new ArrayList<String>(); 663 var assignees = new ArrayList<String>(); 664 var labels = new ArrayList<String>(); 665 666 var filterAuthors = arguments.contains("authors") ? 667 new HashSet<>(Arrays.asList(arguments.get("authors").asString().split(","))) : 668 Set.of(); 669 var filterAssignees = arguments.contains("assignees") ? 670 Arrays.asList(arguments.get("assignees").asString().split(",")) : 671 Set.of(); 672 var filterLabels = arguments.contains("labels") ? 673 Arrays.asList(arguments.get("labels").asString().split(",")) : 674 Set.of(); 675 676 var defaultColumns = List.of("id", "title", "authors", "assignees", "labels"); 677 var columnValues = Map.of(defaultColumns.get(0), ids, 678 defaultColumns.get(1), titles, 679 defaultColumns.get(2), authors, 680 defaultColumns.get(3), assignees, 681 defaultColumns.get(4), labels); 682 var columns = arguments.contains("columns") ? 683 Arrays.asList(arguments.get("columns").asString().split(",")) : 684 defaultColumns; 685 if (columns != defaultColumns) { 686 for (var column : columns) { 687 if (!defaultColumns.contains(column)) { 688 System.err.println("error: unknown column: " + column); 689 System.err.println(" available columns are: " + String.join(",", defaultColumns)); 690 System.exit(1); 691 } 692 } 693 } 694 695 for (var pr : remoteRepo.getPullRequests()) { 696 var prAuthor = pr.getAuthor().userName(); 697 if (!filterAuthors.isEmpty() && !filterAuthors.contains(prAuthor)) { 698 continue; 699 } 700 701 var prAssignees = pr.getAssignees().stream() 702 .map(HostUserDetails::userName) 703 .collect(Collectors.toSet()); 704 if (!filterAssignees.isEmpty() && !filterAssignees.stream().anyMatch(prAssignees::contains)) { 705 continue; 706 } 707 708 var prLabels = new HashSet<>(pr.getLabels()); 709 if (!filterLabels.isEmpty() && !filterLabels.stream().anyMatch(prLabels::contains)) { 710 continue; 711 } 712 713 ids.add(pr.getId()); 714 titles.add(pr.getTitle()); 715 authors.add(prAuthor); 716 assignees.add(String.join(",", prAssignees)); 717 labels.add(String.join(",", prLabels)); 718 } 719 720 721 String fmt = ""; 722 for (var column : columns.subList(0, columns.size() - 1)) { 723 var values = columnValues.get(column); 724 var n = Math.max(column.length(), longest(values)); 725 fmt += "%-" + n + "s\t"; 726 } 727 fmt += "%s\n"; 728 729 if (!ids.isEmpty() && !arguments.contains("no-decoration")) { 730 var upperCase = columns.stream() 731 .map(String::toUpperCase) 732 .collect(Collectors.toList()); 733 System.out.format(fmt, (Object[]) upperCase.toArray(new String[0])); 734 } 735 for (var i = 0; i < ids.size(); i++) { 736 final int n = i; 737 var row = columns.stream() 738 .map(columnValues::get) 739 .map(values -> values.get(n)) 740 .collect(Collectors.toList()); 741 System.out.format(fmt, (Object[]) row.toArray(new String[0])); 742 } 743 } else if (action.equals("fetch") || action.equals("checkout") || action.equals("show") || action.equals("apply")) { 744 var prId = arguments.at(1); 745 if (!prId.isPresent()) { 746 exit("error: missing pull request identifier"); 747 } 748 749 var remoteRepo = getHostedRepositoryFor(uri, credentials); 750 var pr = remoteRepo.getPullRequest(prId.asString()); 751 var repoUrl = remoteRepo.getWebUrl(); 752 var prHeadRef = pr.getSourceRef(); 753 var isHgGit = isMercurial && Repository.exists(repo.root().resolve(".hg").resolve("git")); 754 if (isHgGit) { 755 var hgGitRepo = Repository.get(repo.root().resolve(".hg").resolve("git")).get(); 756 var hgGitFetchHead = hgGitRepo.fetch(repoUrl, prHeadRef); 757 758 if (action.equals("show") || action.equals("apply")) { 759 var target = hgGitRepo.fetch(repoUrl, pr.getTargetRef()); 760 var hgGitMergeBase = hgGitRepo.mergeBase(target, hgGitFetchHead); 761 762 if (action.equals("show")) { 763 show(hgGitMergeBase.hex(), hgGitFetchHead, hgGitRepo.root()); 764 } else { 765 var patch = diff(hgGitMergeBase.hex(), hgGitFetchHead, hgGitRepo.root()); 766 hgImport(patch); 767 Files.delete(patch); 768 } 769 } else if (action.equals("fetch") || action.equals("checkout")) { 770 var hgGitRef = prHeadRef.endsWith("/head") ? prHeadRef.replace("/head", "") : prHeadRef; 771 var hgGitBranches = hgGitRepo.branches(); 772 if (hgGitBranches.contains(new Branch(hgGitRef))) { 773 hgGitRepo.delete(new Branch(hgGitRef)); 774 } 775 hgGitRepo.branch(hgGitFetchHead, hgGitRef); 776 gimport(); 777 var hgFetchHead = repo.resolve(hgGitRef).get(); 778 779 if (action.equals("fetch") && arguments.contains("branch")) { 780 repo.branch(hgFetchHead, arguments.get("branch").asString()); 781 } else if (action.equals("checkout")) { 782 repo.checkout(hgFetchHead); 783 if (arguments.contains("branch")) { 784 repo.branch(hgFetchHead, arguments.get("branch").asString()); 785 } 786 } 787 } else { 788 exit("Unexpected action: " + action); 789 } 790 791 return; 792 } 793 794 var fetchHead = repo.fetch(repoUrl, pr.getSourceRef()); 795 if (action.equals("fetch")) { 796 if (arguments.contains("branch")) { 797 var branchName = arguments.get("branch").asString(); 798 repo.branch(fetchHead, branchName); 799 } else { 800 System.out.println(fetchHead.hex()); 801 } 802 } else if (action.equals("checkout")) { 803 if (arguments.contains("branch")) { 804 var branchName = arguments.get("branch").asString(); 805 var branch = repo.branch(fetchHead, branchName); 806 repo.checkout(branch, false); 807 } else { 808 repo.checkout(fetchHead, false); 809 } 810 } else if (action.equals("show")) { 811 show(pr.getTargetRef(), fetchHead); 812 } else if (action.equals("apply")) { 813 var patch = diff(pr.getTargetRef(), fetchHead); 814 apply(patch); 815 Files.deleteIfExists(patch); 816 } 817 } else if (action.equals("close")) { 818 var prId = arguments.at(1); 819 if (!prId.isPresent()) { 820 exit("error: missing pull request identifier"); 821 } 822 823 var remoteRepo = getHostedRepositoryFor(uri, credentials); 824 var pr = remoteRepo.getPullRequest(prId.asString()); 825 pr.setState(PullRequest.State.CLOSED); 826 } else if (action.equals("update")) { 827 var prId = arguments.at(1); 828 if (!prId.isPresent()) { 829 exit("error: missing pull request identifier"); 830 } 831 832 var remoteRepo = getHostedRepositoryFor(uri, credentials); 833 var pr = remoteRepo.getPullRequest(prId.asString()); 834 if (arguments.contains("assignees")) { 835 var usernames = Arrays.asList(arguments.get("assignees").asString().split(",")); 836 var assignees = usernames.stream() 837 .map(host::getUserDetails) 838 .collect(Collectors.toList()); 839 pr.setAssignees(assignees); 840 } 841 } else { 842 exit("error: unexpected action: " + action); 843 } 844 } 845 }