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 URI toURI(String remotePath) throws IOException { 216 if (remotePath.startsWith("git+")) { 217 remotePath = remotePath.substring("git+".length()); 218 } 219 if (remotePath.startsWith("http")) { 220 return URI.create(remotePath); 221 } else { 222 if (remotePath.startsWith("ssh://")) { 223 remotePath = remotePath.substring("ssh://".length()).replaceFirst("/", ":"); 224 } 225 var indexOfColon = remotePath.indexOf(':'); 226 var indexOfSlash = remotePath.indexOf('/'); 227 if (indexOfColon != -1) { 228 if (indexOfSlash == -1 || indexOfColon < indexOfSlash) { 229 var path = remotePath.contains("@") ? remotePath.split("@")[1] : remotePath; 230 var name = path.split(":")[0]; 231 232 // Could be a Host in the ~/.ssh/config file 233 var sshConfig = Path.of(System.getProperty("user.home"), ".ssh", "config"); 234 if (Files.exists(sshConfig)) { 235 for (var host : SSHConfig.parse(sshConfig).hosts()) { 236 if (host.name().equals(name)) { 237 var hostName = host.hostName(); 238 if (hostName != null) { 239 return URI.create("https://" + hostName + "/" + path.split(":")[1]); 240 } 241 } 242 } 243 } 244 245 // Otherwise is must be a domain 246 return URI.create("https://" + path.replace(":", "/")); 247 } 248 } 249 } 250 251 exit("error: cannot find remote repository for " + remotePath); 252 return null; // will never reach here 253 } 254 255 private static int longest(List<String> strings) { 256 return strings.stream().mapToInt(String::length).max().orElse(0); 257 } 258 259 public static void main(String[] args) throws IOException, InterruptedException { 260 var flags = List.of( 261 Option.shortcut("u") 262 .fullname("username") 263 .describe("NAME") 264 .helptext("Username on host") 265 .optional(), 266 Option.shortcut("r") 267 .fullname("remote") 268 .describe("NAME") 269 .helptext("Name of remote, defaults to 'origin'") 270 .optional(), 271 Option.shortcut("b") 272 .fullname("branch") 273 .describe("NAME") 274 .helptext("Name of target branch, defaults to 'master'") 275 .optional(), 276 Option.shortcut("") 277 .fullname("authors") 278 .describe("LIST") 279 .helptext("Comma separated list of authors") 280 .optional(), 281 Option.shortcut("") 282 .fullname("assignees") 283 .describe("LIST") 284 .helptext("Comma separated list of assignees") 285 .optional(), 286 Option.shortcut("") 287 .fullname("labels") 288 .describe("LIST") 289 .helptext("Comma separated list of labels") 290 .optional(), 291 Option.shortcut("") 292 .fullname("columns") 293 .describe("id,title,author,assignees,labels") 294 .helptext("Comma separated list of columns to show") 295 .optional(), 296 Switch.shortcut("") 297 .fullname("no-decoration") 298 .helptext("Hide any decorations when listing PRs") 299 .optional(), 300 Switch.shortcut("") 301 .fullname("mercurial") 302 .helptext("Force use of Mercurial (hg)") 303 .optional(), 304 Switch.shortcut("") 305 .fullname("verbose") 306 .helptext("Turn on verbose output") 307 .optional(), 308 Switch.shortcut("") 309 .fullname("debug") 310 .helptext("Turn on debugging output") 311 .optional(), 312 Switch.shortcut("") 313 .fullname("version") 314 .helptext("Print the version of this tool") 315 .optional()); 316 317 var inputs = List.of( 318 Input.position(0) 319 .describe("list|fetch|show|checkout|apply|integrate|approve|create|close|update") 320 .singular() 321 .required(), 322 Input.position(1) 323 .describe("ID") 324 .singular() 325 .optional() 326 ); 327 328 var parser = new ArgumentParser("git-pr", flags, inputs); 329 var arguments = parser.parse(args); 330 331 if (arguments.contains("version")) { 332 System.out.println("git-pr version: " + Version.fromManifest().orElse("unknown")); 333 System.exit(0); 334 } 335 336 if (arguments.contains("verbose") || arguments.contains("debug")) { 337 var level = arguments.contains("debug") ? Level.FINER : Level.FINE; 338 Logging.setup(level); 339 } 340 341 HttpProxy.setup(); 342 343 var isMercurial = arguments.contains("mercurial"); 344 var cwd = Path.of("").toAbsolutePath(); 345 var repo = Repository.get(cwd).orElseThrow(() -> new IOException("no git repository found at " + cwd.toString())); 346 var remote = arguments.get("remote").orString(isMercurial ? "default" : "origin"); 347 var remotePullPath = repo.pullPath(remote); 348 var username = arguments.contains("username") ? arguments.get("username").asString() : null; 349 var token = isMercurial ? System.getenv("HG_TOKEN") : System.getenv("GIT_TOKEN"); 350 var uri = toURI(remotePullPath); 351 var credentials = GitCredentials.fill(uri.getHost(), uri.getPath(), username, token, uri.getScheme()); 352 var host = Host.from(uri, new PersonalAccessToken(credentials.username(), credentials.password())); 353 354 var action = arguments.at(0).asString(); 355 if (action.equals("create")) { 356 if (isMercurial) { 357 var currentBookmark = repo.currentBookmark(); 358 if (!currentBookmark.isPresent()) { 359 System.err.println("error: no bookmark is active, you must be on an active bookmark"); 360 System.err.println(""); 361 System.err.println("To create a bookmark and activate it, run:"); 362 System.err.println(""); 363 System.err.println(" hg bookmark NAME-FOR-YOUR-BOOKMARK"); 364 System.err.println(""); 365 System.exit(1); 366 } 367 368 var bookmark = currentBookmark.get(); 369 if (bookmark.equals(new Bookmark("master"))) { 370 System.err.println("error: you should not create pull requests from the 'master' bookmark"); 371 System.err.println("To create a bookmark and activate it, run:"); 372 System.err.println(""); 373 System.err.println(" hg bookmark NAME-FOR-YOUR-BOOKMARK"); 374 System.err.println(""); 375 System.exit(1); 376 } 377 378 var tags = hgTags(); 379 var upstreams = tags.stream() 380 .filter(t -> t.endsWith(bookmark.name())) 381 .collect(Collectors.toList()); 382 if (upstreams.isEmpty()) { 383 System.err.println("error: there is no remote branch for the local bookmark '" + bookmark.name() + "'"); 384 System.err.println(""); 385 System.err.println("To create a remote branch and push the commits for your local branch, run:"); 386 System.err.println(""); 387 System.err.println(" hg push --bookmark " + bookmark.name()); 388 System.err.println(""); 389 System.exit(1); 390 } 391 392 var tagsAndHashes = new HashMap<String, String>(); 393 for (var tag : tags) { 394 tagsAndHashes.put(tag, hgResolve(tag)); 395 } 396 var bookmarkHash = hgResolve(bookmark.name()); 397 if (!tagsAndHashes.containsValue(bookmarkHash)) { 398 System.err.println("error: there are local commits on bookmark '" + bookmark.name() + "' not present in a remote repository"); 399 System.err.println(""); 400 401 if (upstreams.size() == 1) { 402 System.err.println("To push the local commits to the remote repository, run:"); 403 System.err.println(""); 404 System.err.println(" hg push --bookmark " + bookmark.name() + " " + upstreams.get(0)); 405 System.err.println(""); 406 } else { 407 System.err.println("The following paths contains the " + bookmark.name() + " bookmark:"); 408 System.err.println(""); 409 for (var upstream : upstreams) { 410 System.err.println("- " + upstream.replace("/" + bookmark.name(), "")); 411 } 412 System.err.println(""); 413 System.err.println("To push the local commits to a remote repository, run:"); 414 System.err.println(""); 415 System.err.println(" hg push --bookmark " + bookmark.name() + " <PATH>"); 416 System.err.println(""); 417 } 418 System.exit(1); 419 } 420 421 var targetBranch = arguments.get("branch").orString("master"); 422 var targetHash = hgResolve(targetBranch); 423 var commits = repo.commits(targetHash + ".." + bookmarkHash + "-" + targetHash).asList(); 424 if (commits.isEmpty()) { 425 System.err.println("error: no difference between bookmarks " + targetBranch + " and " + bookmark.name()); 426 System.err.println(" Cannot create an empty pull request, have you committed?"); 427 System.exit(1); 428 } 429 430 var diff = repo.diff(repo.head()); 431 if (!diff.patches().isEmpty()) { 432 System.err.println("error: there are uncommitted changes in your working tree:"); 433 System.err.println(""); 434 for (var patch : diff.patches()) { 435 var path = patch.target().path().isPresent() ? 436 patch.target().path().get() : patch.source().path().get(); 437 System.err.println(" " + patch.status().toString() + " " + path.toString()); 438 } 439 System.err.println(""); 440 System.err.println("If these changes are meant to be part of the pull request, run:"); 441 System.err.println(""); 442 System.err.println(" hg commit --amend"); 443 System.err.println(" hg git-cleanup"); 444 System.err.println(" hg push --bookmark " + bookmark.name() + " <PATH>"); 445 System.err.println(" hg gimport"); 446 System.err.println(""); 447 System.err.println("If these changes are *not* meant to be part of the pull request, run:"); 448 System.err.println(""); 449 System.err.println(" hg shelve"); 450 System.err.println(""); 451 System.err.println("(You can later restore the changes by running: hg unshelve)"); 452 System.exit(1); 453 } 454 455 var remoteRepo = host.getRepository(projectName(uri)); 456 if (token == null) { 457 GitCredentials.approve(credentials); 458 } 459 var parentRepo = remoteRepo.getParent().orElseThrow(() -> 460 new IOException("error: remote repository " + remotePullPath + " is not a fork of any repository")); 461 462 var file = Files.createTempFile("PULL_REQUEST_", ".txt"); 463 if (commits.size() == 1) { 464 var commit = commits.get(0); 465 var message = CommitMessageParsers.v1.parse(commit.message()); 466 Files.writeString(file, message.title() + "\n"); 467 if (!message.summaries().isEmpty()) { 468 Files.write(file, message.summaries(), StandardOpenOption.APPEND); 469 } 470 if (!message.additional().isEmpty()) { 471 Files.write(file, message.additional(), StandardOpenOption.APPEND); 472 } 473 } else { 474 Files.write(file, List.of("")); 475 } 476 Files.write(file, List.of( 477 "# Please enter the pull request message for your changes. Lines starting", 478 "# with '#' will be ignored, and an empty message aborts the pull request.", 479 "# The first line will be considered the subject, use a blank line to separate", 480 "# the subject from the body.", 481 "#", 482 "# Commits to be included from branch '" + bookmark.name() + "'" 483 ), 484 StandardOpenOption.APPEND 485 ); 486 for (var commit : commits) { 487 var desc = commit.hash().abbreviate() + ": " + commit.message().get(0); 488 Files.writeString(file, "# - " + desc + "\n", StandardOpenOption.APPEND); 489 } 490 Files.writeString(file, "#\n", StandardOpenOption.APPEND); 491 Files.writeString(file, "# Target repository: " + remotePullPath + "\n", StandardOpenOption.APPEND); 492 Files.writeString(file, "# Target branch: " + targetBranch + "\n", StandardOpenOption.APPEND); 493 var success = spawnEditor(repo, file); 494 if (!success) { 495 System.err.println("error: editor exited with non-zero status code, aborting"); 496 System.exit(1); 497 } 498 var lines = Files.readAllLines(file) 499 .stream() 500 .filter(l -> !l.startsWith("#")) 501 .collect(Collectors.toList()); 502 var isEmpty = lines.stream().allMatch(String::isEmpty); 503 if (isEmpty) { 504 System.err.println("error: no message present, aborting"); 505 System.exit(1); 506 } 507 508 var title = lines.get(0); 509 List<String> body = null; 510 if (lines.size() > 1) { 511 body = lines.subList(1, lines.size()) 512 .stream() 513 .dropWhile(String::isEmpty) 514 .collect(Collectors.toList()); 515 } else { 516 body = Collections.emptyList(); 517 } 518 519 var pr = remoteRepo.createPullRequest(parentRepo, targetBranch, bookmark.name(), title, body); 520 if (arguments.contains("assignees")) { 521 var usernames = Arrays.asList(arguments.get("assignees").asString().split(",")); 522 var assignees = usernames.stream() 523 .map(host::getUserDetails) 524 .collect(Collectors.toList()); 525 pr.setAssignees(assignees); 526 } 527 System.out.println(pr.getWebUrl().toString()); 528 Files.deleteIfExists(file); 529 530 System.exit(0); 531 } 532 var currentBranch = repo.currentBranch(); 533 if (currentBranch.equals(repo.defaultBranch())) { 534 System.err.println("error: you should not create pull requests from the 'master' branch"); 535 System.err.println(""); 536 System.err.println("To create a local branch for your changes and restore the 'master' branch, run:"); 537 System.err.println(""); 538 System.err.println(" git checkout -b NAME-FOR-YOUR-LOCAL-BRANCH"); 539 System.err.println(" git branch --force master origin/master"); 540 System.err.println(""); 541 System.exit(1); 542 } 543 544 var upstream = repo.upstreamFor(currentBranch); 545 if (upstream.isEmpty()) { 546 System.err.println("error: there is no remote branch for the local branch '" + currentBranch.name() + "'"); 547 System.err.println(""); 548 System.err.println("A remote branch must be present at " + remotePullPath + " to create a pull request"); 549 System.err.println("To create a remote branch and push the commits for your local branch, run:"); 550 System.err.println(""); 551 System.err.println(" git push --set-upstream " + remote + " " + currentBranch.name()); 552 System.err.println(""); 553 System.err.println("If you created the remote branch from another client, you must update this repository."); 554 System.err.println("To update remote information for this repository, run:"); 555 System.err.println(""); 556 System.err.println(" git fetch " + remote); 557 System.err.println(" git branch --set-upstream " + currentBranch + " " + remote + "/" + currentBranch); 558 System.err.println(""); 559 System.exit(1); 560 } 561 562 var upstreamRefName = upstream.get().substring(remote.length() + 1); 563 repo.fetch(uri, upstreamRefName); 564 var branchCommits = repo.commits(upstream.get() + ".." + currentBranch.name()).asList(); 565 if (!branchCommits.isEmpty()) { 566 System.err.println("error: there are local commits on branch '" + currentBranch.name() + "' not present in the remote repository " + remotePullPath); 567 System.err.println(""); 568 System.err.println("All commits must be present in the remote repository to be part of the pull request"); 569 System.err.println("The following commits are not present in the remote repository:"); 570 System.err.println(""); 571 for (var commit : branchCommits) { 572 System.err.println("- " + commit.hash().abbreviate() + ": " + commit.message().get(0)); 573 } 574 System.err.println(""); 575 System.err.println("To push the above local commits to the remote repository, run:"); 576 System.err.println(""); 577 System.err.println(" git push " + remote + " " + currentBranch.name()); 578 System.err.println(""); 579 System.exit(1); 580 } 581 582 var targetBranch = arguments.get("branch").orString("master"); 583 var commits = repo.commits(targetBranch + ".." + currentBranch.name()).asList(); 584 if (commits.isEmpty()) { 585 System.err.println("error: no difference between branches " + targetBranch + " and " + currentBranch.name()); 586 System.err.println(" Cannot create an empty pull request, have you committed?"); 587 System.exit(1); 588 } 589 590 var diff = repo.diff(repo.head()); 591 if (!diff.patches().isEmpty()) { 592 System.err.println("error: there are uncommitted changes in your working tree:"); 593 System.err.println(""); 594 for (var patch : diff.patches()) { 595 var path = patch.target().path().isPresent() ? 596 patch.target().path().get() : patch.source().path().get(); 597 System.err.println(" " + patch.status().toString() + " " + path.toString()); 598 } 599 System.err.println(""); 600 System.err.println("If these changes are meant to be part of the pull request, run:"); 601 System.err.println(""); 602 System.err.println(" git commit -am 'Forgot to add some changes'"); 603 System.err.println(""); 604 System.err.println("If these changes are *not* meant to be part of the pull request, run:"); 605 System.err.println(""); 606 System.err.println(" git stash"); 607 System.err.println(""); 608 System.err.println("(You can later restore the changes by running: git stash pop)"); 609 System.exit(1); 610 } 611 612 var remoteRepo = host.getRepository(projectName(uri)); 613 if (token == null) { 614 GitCredentials.approve(credentials); 615 } 616 var parentRepo = remoteRepo.getParent().orElseThrow(() -> 617 new IOException("error: remote repository " + remotePullPath + " is not a fork of any repository")); 618 619 var file = Files.createTempFile("PULL_REQUEST_", ".txt"); 620 if (commits.size() == 1) { 621 var commit = commits.get(0); 622 var message = CommitMessageParsers.v1.parse(commit.message()); 623 Files.writeString(file, message.title() + "\n"); 624 if (!message.summaries().isEmpty()) { 625 Files.write(file, message.summaries(), StandardOpenOption.APPEND); 626 } 627 if (!message.additional().isEmpty()) { 628 Files.write(file, message.additional(), StandardOpenOption.APPEND); 629 } 630 } else { 631 Files.write(file, List.of("")); 632 } 633 Files.write(file, List.of( 634 "# Please enter the pull request message for your changes. Lines starting", 635 "# with '#' will be ignored, and an empty message aborts the pull request.", 636 "# The first line will be considered the subject, use a blank line to separate", 637 "# the subject from the body.", 638 "#", 639 "# Commits to be included from branch '" + currentBranch.name() + "'" 640 ), 641 StandardOpenOption.APPEND 642 ); 643 for (var commit : commits) { 644 var desc = commit.hash().abbreviate() + ": " + commit.message().get(0); 645 Files.writeString(file, "# - " + desc + "\n", StandardOpenOption.APPEND); 646 } 647 Files.writeString(file, "#\n", StandardOpenOption.APPEND); 648 Files.writeString(file, "# Target repository: " + remotePullPath + "\n", StandardOpenOption.APPEND); 649 Files.writeString(file, "# Target branch: " + targetBranch + "\n", StandardOpenOption.APPEND); 650 var success = spawnEditor(repo, file); 651 if (!success) { 652 System.err.println("error: editor exited with non-zero status code, aborting"); 653 System.exit(1); 654 } 655 var lines = Files.readAllLines(file) 656 .stream() 657 .filter(l -> !l.startsWith("#")) 658 .collect(Collectors.toList()); 659 var isEmpty = lines.stream().allMatch(String::isEmpty); 660 if (isEmpty) { 661 System.err.println("error: no message present, aborting"); 662 System.exit(1); 663 } 664 665 var title = lines.get(0); 666 List<String> body = null; 667 if (lines.size() > 1) { 668 body = lines.subList(1, lines.size()) 669 .stream() 670 .dropWhile(String::isEmpty) 671 .collect(Collectors.toList()); 672 } else { 673 body = Collections.emptyList(); 674 } 675 676 var pr = remoteRepo.createPullRequest(parentRepo, targetBranch, currentBranch.name(), title, body); 677 if (arguments.contains("assignees")) { 678 var usernames = Arrays.asList(arguments.get("assignees").asString().split(",")); 679 var assignees = usernames.stream() 680 .map(host::getUserDetails) 681 .collect(Collectors.toList()); 682 pr.setAssignees(assignees); 683 } 684 System.out.println(pr.getWebUrl().toString()); 685 Files.deleteIfExists(file); 686 } else if (action.equals("integrate") || action.equals("approve")) { 687 var pr = getPullRequest(uri, credentials, arguments.at(1)); 688 689 if (action.equals("integrate")) { 690 pr.addComment("/integrate"); 691 } else if (action.equals("approve")) { 692 pr.addReview(Review.Verdict.APPROVED, "Looks good!"); 693 } else { 694 throw new IllegalStateException("unexpected action: " + action); 695 } 696 } else if (action.equals("list")) { 697 var remoteRepo = getHostedRepositoryFor(uri, credentials); 698 var prs = remoteRepo.getPullRequests(); 699 700 var ids = new ArrayList<String>(); 701 var titles = new ArrayList<String>(); 702 var authors = new ArrayList<String>(); 703 var assignees = new ArrayList<String>(); 704 var labels = new ArrayList<String>(); 705 706 var filterAuthors = arguments.contains("authors") ? 707 new HashSet<>(Arrays.asList(arguments.get("authors").asString().split(","))) : 708 Set.of(); 709 var filterAssignees = arguments.contains("assignees") ? 710 Arrays.asList(arguments.get("assignees").asString().split(",")) : 711 Set.of(); 712 var filterLabels = arguments.contains("labels") ? 713 Arrays.asList(arguments.get("labels").asString().split(",")) : 714 Set.of(); 715 716 var defaultColumns = List.of("id", "title", "authors", "assignees", "labels"); 717 var columnValues = Map.of(defaultColumns.get(0), ids, 718 defaultColumns.get(1), titles, 719 defaultColumns.get(2), authors, 720 defaultColumns.get(3), assignees, 721 defaultColumns.get(4), labels); 722 var columns = arguments.contains("columns") ? 723 Arrays.asList(arguments.get("columns").asString().split(",")) : 724 defaultColumns; 725 if (columns != defaultColumns) { 726 for (var column : columns) { 727 if (!defaultColumns.contains(column)) { 728 System.err.println("error: unknown column: " + column); 729 System.err.println(" available columns are: " + String.join(",", defaultColumns)); 730 System.exit(1); 731 } 732 } 733 } 734 735 for (var pr : remoteRepo.getPullRequests()) { 736 var prAuthor = pr.getAuthor().userName(); 737 if (!filterAuthors.isEmpty() && !filterAuthors.contains(prAuthor)) { 738 continue; 739 } 740 741 var prAssignees = pr.getAssignees().stream() 742 .map(HostUserDetails::userName) 743 .collect(Collectors.toSet()); 744 if (!filterAssignees.isEmpty() && !filterAssignees.stream().anyMatch(prAssignees::contains)) { 745 continue; 746 } 747 748 var prLabels = new HashSet<>(pr.getLabels()); 749 if (!filterLabels.isEmpty() && !filterLabels.stream().anyMatch(prLabels::contains)) { 750 continue; 751 } 752 753 ids.add(pr.getId()); 754 titles.add(pr.getTitle()); 755 authors.add(prAuthor); 756 assignees.add(String.join(",", prAssignees)); 757 labels.add(String.join(",", prLabels)); 758 } 759 760 761 String fmt = ""; 762 for (var column : columns.subList(0, columns.size() - 1)) { 763 var values = columnValues.get(column); 764 var n = Math.max(column.length(), longest(values)); 765 fmt += "%-" + n + "s\t"; 766 } 767 fmt += "%s\n"; 768 769 if (!ids.isEmpty() && !arguments.contains("no-decoration")) { 770 var upperCase = columns.stream() 771 .map(String::toUpperCase) 772 .collect(Collectors.toList()); 773 System.out.format(fmt, (Object[]) upperCase.toArray(new String[0])); 774 } 775 for (var i = 0; i < ids.size(); i++) { 776 final int n = i; 777 var row = columns.stream() 778 .map(columnValues::get) 779 .map(values -> values.get(n)) 780 .collect(Collectors.toList()); 781 System.out.format(fmt, (Object[]) row.toArray(new String[0])); 782 } 783 } else if (action.equals("fetch") || action.equals("checkout") || action.equals("show") || action.equals("apply")) { 784 var prId = arguments.at(1); 785 if (!prId.isPresent()) { 786 exit("error: missing pull request identifier"); 787 } 788 789 var remoteRepo = getHostedRepositoryFor(uri, credentials); 790 var pr = remoteRepo.getPullRequest(prId.asString()); 791 var repoUrl = remoteRepo.getWebUrl(); 792 var prHeadRef = pr.getSourceRef(); 793 var isHgGit = isMercurial && Repository.exists(repo.root().resolve(".hg").resolve("git")); 794 if (isHgGit) { 795 var hgGitRepo = Repository.get(repo.root().resolve(".hg").resolve("git")).get(); 796 var hgGitFetchHead = hgGitRepo.fetch(repoUrl, prHeadRef); 797 798 if (action.equals("show") || action.equals("apply")) { 799 var target = hgGitRepo.fetch(repoUrl, pr.getTargetRef()); 800 var hgGitMergeBase = hgGitRepo.mergeBase(target, hgGitFetchHead); 801 802 if (action.equals("show")) { 803 show(hgGitMergeBase.hex(), hgGitFetchHead, hgGitRepo.root()); 804 } else { 805 var patch = diff(hgGitMergeBase.hex(), hgGitFetchHead, hgGitRepo.root()); 806 hgImport(patch); 807 Files.delete(patch); 808 } 809 } else if (action.equals("fetch") || action.equals("checkout")) { 810 var hgGitRef = prHeadRef.endsWith("/head") ? prHeadRef.replace("/head", "") : prHeadRef; 811 var hgGitBranches = hgGitRepo.branches(); 812 if (hgGitBranches.contains(new Branch(hgGitRef))) { 813 hgGitRepo.delete(new Branch(hgGitRef)); 814 } 815 hgGitRepo.branch(hgGitFetchHead, hgGitRef); 816 gimport(); 817 var hgFetchHead = repo.resolve(hgGitRef).get(); 818 819 if (action.equals("fetch") && arguments.contains("branch")) { 820 repo.branch(hgFetchHead, arguments.get("branch").asString()); 821 } else if (action.equals("checkout")) { 822 repo.checkout(hgFetchHead); 823 if (arguments.contains("branch")) { 824 repo.branch(hgFetchHead, arguments.get("branch").asString()); 825 } 826 } 827 } else { 828 exit("Unexpected action: " + action); 829 } 830 831 return; 832 } 833 834 var fetchHead = repo.fetch(repoUrl, pr.getSourceRef()); 835 if (action.equals("fetch")) { 836 if (arguments.contains("branch")) { 837 var branchName = arguments.get("branch").asString(); 838 repo.branch(fetchHead, branchName); 839 } else { 840 System.out.println(fetchHead.hex()); 841 } 842 } else if (action.equals("checkout")) { 843 if (arguments.contains("branch")) { 844 var branchName = arguments.get("branch").asString(); 845 var branch = repo.branch(fetchHead, branchName); 846 repo.checkout(branch, false); 847 } else { 848 repo.checkout(fetchHead, false); 849 } 850 } else if (action.equals("show")) { 851 show(pr.getTargetRef(), fetchHead); 852 } else if (action.equals("apply")) { 853 var patch = diff(pr.getTargetRef(), fetchHead); 854 apply(patch); 855 Files.deleteIfExists(patch); 856 } 857 } else if (action.equals("close")) { 858 var prId = arguments.at(1); 859 if (!prId.isPresent()) { 860 exit("error: missing pull request identifier"); 861 } 862 863 var remoteRepo = getHostedRepositoryFor(uri, credentials); 864 var pr = remoteRepo.getPullRequest(prId.asString()); 865 pr.setState(PullRequest.State.CLOSED); 866 } else if (action.equals("update")) { 867 var prId = arguments.at(1); 868 if (!prId.isPresent()) { 869 exit("error: missing pull request identifier"); 870 } 871 872 var remoteRepo = getHostedRepositoryFor(uri, credentials); 873 var pr = remoteRepo.getPullRequest(prId.asString()); 874 if (arguments.contains("assignees")) { 875 var usernames = Arrays.asList(arguments.get("assignees").asString().split(",")); 876 var assignees = usernames.stream() 877 .map(host::getUserDetails) 878 .collect(Collectors.toList()); 879 pr.setAssignees(assignees); 880 } 881 } else { 882 exit("error: unexpected action: " + action); 883 } 884 } 885 }