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 }