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 }