diff --git a/cli/src/main/java/org/openjdk/skara/cli/GitWebrev.java b/cli/src/main/java/org/openjdk/skara/cli/GitWebrev.java index b40d61f15..6969ad8f6 100644 --- a/cli/src/main/java/org/openjdk/skara/cli/GitWebrev.java +++ b/cli/src/main/java/org/openjdk/skara/cli/GitWebrev.java @@ -150,6 +150,10 @@ private static void generate(String[] args) throws IOException { .fullname("mercurial") .helptext("Deprecated: force use of mercurial") .optional(), + Switch.shortcut("") + .fullname("json") + .helptext("Generate a JSON description instead of HTML") + .optional(), Switch.shortcut("C") .fullname("no-comments") .helptext("Don't show comments") @@ -200,30 +204,44 @@ private static void generate(String[] args) throws IOException { var repo = repository.get(); var isMercurial = arguments.contains("mercurial"); + + URI upstreamPullPath = null; + URI originPullPath = null; + var remotes = repo.remotes(); + if (remotes.contains("upstream")) { + upstreamPullPath = Remote.toWebURI(repo.pullPath("upstream")); + } + if (remotes.contains("origin") || remotes.contains("default")) { + var remote = isMercurial ? "default" : "origin"; + originPullPath = Remote.toWebURI(repo.pullPath(remote)); + } + + if (arguments.contains("json") && + (upstreamPullPath == null || originPullPath == null)) { + System.err.println("error: --json requires remotes 'upstream' and 'origin' to be present"); + System.exit(1); + } + var upstream = arg("upstream", arguments, repo); if (upstream == null) { - var remotes = repo.remotes(); - if (remotes.contains("upstream")) { - var pullPath = Remote.toWebURI(repo.pullPath("upstream")); - var host = pullPath.getHost(); + if (upstreamPullPath != null) { + var host = upstreamPullPath.getHost(); if (host != null && host.endsWith("openjdk.java.net")) { - upstream = pullPath.toString(); + upstream = upstreamPullPath.toString(); } else if (host != null && host.equals("github.com")) { - var path = pullPath.getPath(); + var path = upstreamPullPath.getPath(); if (path != null && path.startsWith("/openjdk/")) { - upstream = pullPath.toString(); + upstream = upstreamPullPath.toString(); } } - } else if (remotes.contains("origin") || remotes.contains("default")) { - var remote = isMercurial ? "default" : "origin"; - var pullPath = Remote.toWebURI(repo.pullPath(remote)); - var host = pullPath.getHost(); + } else if (originPullPath != null) { + var host = originPullPath.getHost(); if (host != null && host.endsWith("openjdk.java.net")) { - upstream = pullPath.toString(); + upstream = originPullPath.toString(); } else if (host != null && host.equals("github.com")) { - var path = pullPath.getPath(); + var path = originPullPath.getPath(); if (path != null && path.startsWith("/openjdk/")) { - upstream = pullPath.toString(); + upstream = originPullPath.toString(); } } } @@ -272,7 +290,6 @@ private static void generate(String[] args) throws IOException { } else { String remote = arg("remote", arguments, repo); if (remote == null) { - var remotes = repo.remotes(); if (remotes.size() == 0) { System.err.println("error: no remotes present, cannot figure out outgoing changes"); System.err.println(" Use --rev to specify revision to compare against"); @@ -403,18 +420,32 @@ private static void generate(String[] args) throws IOException { var issueParts = issue != null ? issue.split("-") : new String[0]; var jbsProject = issueParts.length == 2 && KNOWN_JBS_PROJECTS.contains(issueParts[0])? issueParts[0] : "JDK"; - Webrev.repository(repo) - .output(output) - .title(title) - .upstream(upstream) - .username(author.name()) - .commitLinker(hash -> upstreamURL == null ? null : upstreamURL + "/commit/" + hash) - .issueLinker(id -> jbs + (isDigit(id.charAt(0)) ? jbsProject + "-" : "") + id) - .issue(issue) - .version(version) - .files(files) - .similarity(similarity) - .generate(base, head); + if (arguments.contains("json")) { + if (head == null) { + head = repo.head(); + } + var upstreamName = upstreamPullPath.getPath().substring(1); + var originName = originPullPath.getPath().substring(1); + Webrev.repository(repo) + .output(output) + .upstream(upstreamPullPath, upstreamName) + .fork(originPullPath, originName) + .similarity(similarity) + .generateJSON(base, head); + } else { + Webrev.repository(repo) + .output(output) + .title(title) + .upstream(upstream) + .username(author.name()) + .commitLinker(hash -> upstreamURL == null ? null : upstreamURL + "/commit/" + hash) + .issueLinker(id -> jbs + (isDigit(id.charAt(0)) ? jbsProject + "-" : "") + id) + .issue(issue) + .version(version) + .files(files) + .similarity(similarity) + .generate(base, head); + } } private static void apply(String[] args) throws Exception { diff --git a/vcs/src/main/java/org/openjdk/skara/vcs/Hunk.java b/vcs/src/main/java/org/openjdk/skara/vcs/Hunk.java index 959aa494c..fee01b4f1 100644 --- a/vcs/src/main/java/org/openjdk/skara/vcs/Hunk.java +++ b/vcs/src/main/java/org/openjdk/skara/vcs/Hunk.java @@ -23,7 +23,9 @@ package org.openjdk.skara.vcs; import java.io.BufferedWriter; +import java.io.StringWriter; import java.io.IOException; +import java.io.UncheckedIOException; import java.util.List; public class Hunk { @@ -80,7 +82,17 @@ public WebrevStats stats() { return new WebrevStats(added, removed, modified); } + public int changes() { + return source.lines().size() + target.lines().size(); + } + + public int additions() { + return target.lines().size(); + } + public int deletions() { + return source.lines().size(); + } public void write(BufferedWriter w) throws IOException { w.append("@@ -"); @@ -110,4 +122,36 @@ public void write(BufferedWriter w) throws IOException { w.write("\n"); } } + + @Override + public String toString() { + var sb = new StringBuilder(); + sb.append("@@ -"); + sb.append(source.range().toString()); + sb.append(" +"); + sb.append(target.range().toString()); + sb.append(" @@"); + sb.append("\n"); + + for (var line : source.lines()) { + sb.append("-"); + sb.append(line); + sb.append("\n"); + } + if (!source.hasNewlineAtEndOfFile()) { + sb.append("\\ No newline at end of file"); + sb.append("\n"); + } + + for (var line : target.lines()) { + sb.append("+"); + sb.append(line); + sb.append("\n"); + } + if (!target.hasNewlineAtEndOfFile()) { + sb.append("\\ No newline at end of file"); + sb.append("\n"); + } + return sb.toString(); + } } diff --git a/vcs/src/main/java/org/openjdk/skara/vcs/TextualPatch.java b/vcs/src/main/java/org/openjdk/skara/vcs/TextualPatch.java index 9acb17dc5..c01a362af 100644 --- a/vcs/src/main/java/org/openjdk/skara/vcs/TextualPatch.java +++ b/vcs/src/main/java/org/openjdk/skara/vcs/TextualPatch.java @@ -63,4 +63,17 @@ public WebrevStats stats() { return new WebrevStats(added, removed, modified); } + + public int additions() { + return hunks.stream().mapToInt(Hunk::additions).sum(); + } + + public int deletions() { + return hunks.stream().mapToInt(Hunk::deletions).sum(); + } + + public int changes() { + return additions() + deletions(); + } + } diff --git a/webrev/build.gradle b/webrev/build.gradle index 82ca4ae46..93dde6e6b 100644 --- a/webrev/build.gradle +++ b/webrev/build.gradle @@ -33,6 +33,7 @@ module { dependencies { implementation project(':vcs') + implementation project(':json') testImplementation project(':test') } diff --git a/webrev/src/main/java/module-info.java b/webrev/src/main/java/module-info.java index d840b567a..b837092ec 100644 --- a/webrev/src/main/java/module-info.java +++ b/webrev/src/main/java/module-info.java @@ -22,6 +22,7 @@ */ module org.openjdk.skara.webrev { requires org.openjdk.skara.vcs; + requires org.openjdk.skara.json; requires java.net.http; requires java.logging; diff --git a/webrev/src/main/java/org/openjdk/skara/webrev/Webrev.java b/webrev/src/main/java/org/openjdk/skara/webrev/Webrev.java index 8f83bd501..f8e89ce90 100644 --- a/webrev/src/main/java/org/openjdk/skara/webrev/Webrev.java +++ b/webrev/src/main/java/org/openjdk/skara/webrev/Webrev.java @@ -23,12 +23,17 @@ package org.openjdk.skara.webrev; import org.openjdk.skara.vcs.*; +import org.openjdk.skara.json.JSON; import java.io.*; +import java.net.URI; import java.net.URISyntaxException; import java.nio.channels.FileChannel; import java.nio.file.*; +import java.nio.charset.StandardCharsets; import java.util.*; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; import java.util.logging.Logger; import java.util.stream.Collectors; import java.util.function.Function; @@ -66,7 +71,11 @@ public static class Builder { private final Path output; private String title = "webrev"; private String username; - private String upstream; + private URI upstreamURI; + private String upstreamName; + private URI forkURI; + private String forkName; + private String fork; private String pullRequest; private String branch; private String issue; @@ -91,8 +100,25 @@ public Builder title(String title) { return this; } - public Builder upstream(String upstream) { - this.upstream = upstream; + public Builder upstream(String name) { + this.upstreamName = name; + return this; + } + + public Builder upstream(URI uri, String name) { + this.upstreamURI = uri; + this.upstreamName = name; + return this; + } + + public Builder fork(String name) { + this.forkName = name; + return this; + } + + public Builder fork(URI uri, String name) { + this.forkURI = uri; + this.forkName = name; return this; } @@ -147,10 +173,141 @@ public void generate(Hash tailEnd, Hash head) throws IOException { generate(diff, tailEnd, head); } + public void generateJSON(Hash tailEnd, Hash head) throws IOException { + if (head == null) { + head = repository.head(); + } + var diff = repository.diff(tailEnd, head, files); + generateJSON(diff, tailEnd, head); + } + public void generate(Diff diff) throws IOException { generate(diff, diff.from(), diff.to()); } + public void generateJSON(Diff diff) throws IOException { + generateJSON(diff, diff.from(), diff.to()); + } + + private void generateJSON(Diff diff, Hash tailEnd, Hash head) throws IOException { + if (head == null) { + throw new IllegalArgumentException("Must supply a head hash"); + } + if (upstreamURI == null) { + throw new IllegalStateException("Must supply an URI to upstream repository"); + } + if (upstreamName == null) { + throw new IllegalStateException("Must supply a name for the upstream repository"); + } + if (forkURI == null) { + throw new IllegalStateException("Must supply an URI to fork repository"); + } + if (forkName == null) { + throw new IllegalStateException("Must supply a name for the fork repository"); + } + + Files.createDirectories(output); + var metadata = JSON.object(); + var now = ZonedDateTime.now(); + metadata.put("created_at", now.format(DateTimeFormatter.ISO_INSTANT)); + + var base = JSON.object(); + base.put("sha", tailEnd.hex()); + base.put("repo", + JSON.object().put("html_url", upstreamURI.toString()) + .put("full_name", upstreamName) + ); + metadata.put("base", base); + + var headObj = JSON.object(); + headObj.put("sha", head.hex()); + headObj.put("repo", + JSON.object().put("html_url", forkURI.toString()) + .put("full_name", forkName) + ); + metadata.put("head", headObj); + + var pathsPerCommit = new HashMap>(); + var comparison = JSON.object(); + var files = JSON.array(); + for (var patch : diff.patches()) { + var file = JSON.object(); + Path filename = null; + Path previousFilename = null; + String status = null; + if (patch.status().isModified()) { + status = "modified"; + filename = patch.target().path().get(); + } else if (patch.status().isAdded()) { + status = "added"; + filename = patch.target().path().get(); + } else if (patch.status().isDeleted()) { + status = "deleted"; + filename = patch.source().path().get(); + } else if (patch.status().isCopied()) { + status = "copied"; + filename = patch.target().path().get(); + previousFilename = patch.source().path().get(); + } else if (patch.status().isRenamed()) { + status = "renamed"; + filename = patch.target().path().get(); + previousFilename = patch.source().path().get(); + } else { + throw new IllegalStateException("Unexpected status: " + patch.status()); + } + + file.put("filename", filename.toString()); + file.put("status", status); + if (previousFilename != null) { + file.put("previous_filename", previousFilename.toString()); + } + if (patch.isBinary()) { + file.put("binary", true); + } else { + file.put("binary", false); + var textualPatch = patch.asTextualPatch(); + + file.put("additions", textualPatch.additions()); + file.put("deletions", textualPatch.deletions()); + file.put("changes", textualPatch.changes()); + + var sb = new StringBuilder(); + for (var hunk : textualPatch.hunks()) { + sb.append(hunk.toString()); + } + file.put("patch", sb.toString()); + } + files.add(file); + var commits = repository.commitMetadata(tailEnd, head, List.of(filename)); + for (var commit : commits) { + if (!pathsPerCommit.containsKey(commit.hash())) { + pathsPerCommit.put(commit.hash(), new ArrayList()); + } + pathsPerCommit.get(commit.hash()).add(filename); + } + } + comparison.put("files", files); + + var commits = JSON.array(); + for (var commit : repository.commitMetadata(tailEnd, head)) { + var c = JSON.object(); + c.put("sha", commit.hash().hex()); + c.put("commit", + JSON.object().put("message", String.join("\n", commit.message())) + ); + var filesArray = JSON.array(); + for (var path : pathsPerCommit.get(commit.hash())) { + filesArray.add(JSON.object().put("filename", path.toString())); + } + c.put("files", filesArray); + commits.add(c); + } + + Files.writeString(output.resolve("metadata.json"), metadata.toString(), StandardCharsets.UTF_8); + Files.writeString(output.resolve("comparison.json"), comparison.toString(), StandardCharsets.UTF_8); + Files.writeString(output.resolve("commits.json"), commits.toString(), StandardCharsets.UTF_8); + } + private void generate(Diff diff, Hash tailEnd, Hash head) throws IOException { Files.createDirectories(output); @@ -240,7 +397,7 @@ private void generate(Diff diff, Hash tailEnd, Hash head) throws IOException { var index = new IndexView(fileViews, title, username, - upstream, + upstreamName, branch, pullRequest, issueForWebrev,