diff --git a/pom.xml b/pom.xml index 6f2201f303..e0b19ded3d 100644 --- a/pom.xml +++ b/pom.xml @@ -7,7 +7,7 @@ github-api - 1.22 + 1.23 GitHub API for Java http://github-api.kohsuke.org/ GitHub API for Java diff --git a/src/main/java/org/kohsuke/github/GHCommit.java b/src/main/java/org/kohsuke/github/GHCommit.java new file mode 100644 index 0000000000..9ba4ca2bd8 --- /dev/null +++ b/src/main/java/org/kohsuke/github/GHCommit.java @@ -0,0 +1,243 @@ +package org.kohsuke.github; + +import java.io.IOException; +import java.net.URL; +import java.util.AbstractList; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import static org.kohsuke.github.ApiVersion.V3; + +/** + * A commit in a repository. + * + * @author Kohsuke Kawaguchi + * @see GHRepository#getCommit(String) + * @see GHCommitComment#getCommit() + */ +public class GHCommit { + private GHRepository owner; + + public static class Stats { + int total,additions,deletions; + } + + /** + * A file that was modified. + */ + public static class File { + String status; + int changes,additions,deletions; + String raw_url, blob_url, filename, sha, patch; + + /** + * Number of lines added + removed. + */ + public int getLinesChanged() { + return changes; + } + + /** + * Number of lines added. + */ + public int getLinesAdded() { + return additions; + } + + /** + * Number of lines removed. + */ + public int getLinesDeleted() { + return deletions; + } + + /** + * "modified", "added", or "deleted" + */ + public String getStatus() { + return status; + } + + /** + * Just the base name and the extension without any directory name. + */ + public String getFileName() { + return filename; + } + + /** + * The actual change. + */ + public String getPatch() { + return patch; + } + + /** + * URL like 'https://raw.github.com/jenkinsci/jenkins/4eb17c197dfdcf8ef7ff87eb160f24f6a20b7f0e/core/pom.xml' + * that resolves to the actual content of the file. + */ + public URL getRawUrl() { + return GitHub.parseURL(raw_url); + } + + /** + * URL like 'https://github.com/jenkinsci/jenkins/blob/1182e2ebb1734d0653142bd422ad33c21437f7cf/core/pom.xml' + * that resolves to the HTML page that describes this file. + */ + public URL getBlobUrl() { + return GitHub.parseURL(blob_url); + } + + /** + * [0-9a-f]{40} SHA1 checksum. + */ + public String getSha() { + return sha; + } + } + + public static class Parent { + String url,sha; + } + + static class User { + // TODO: what if someone who doesn't have an account on GitHub makes a commit? + String url,avatar_url,login,gravatar_id; + int id; + } + + String url,sha; + List files; + Stats stats; + List parents; + User author,committer; + + /** + * The repository that contains the commit. + */ + public GHRepository getOwner() { + return owner; + } + + /** + * Number of lines added + removed. + */ + public int getLinesChanged() { + return stats.total; + } + + /** + * Number of lines added. + */ + public int getLinesAdded() { + return stats.additions; + } + + /** + * Number of lines removed. + */ + public int getLinesDeleted() { + return stats.deletions; + } + + /** + * [0-9a-f]{40} SHA1 checksum. + */ + public String getSHA1() { + return sha; + } + + /** + * List of files changed/added/removed in this commit. + * + * @return + * Can be empty but never null. + */ + public List getFiles() { + return files!=null ? Collections.unmodifiableList(files) : Collections.emptyList(); + } + + /** + * Returns the SHA1 of parent commit objects. + */ + public List getParentSHA1s() { + if (parents==null) return Collections.emptyList(); + return new AbstractList() { + @Override + public String get(int index) { + return parents.get(index).sha; + } + + @Override + public int size() { + return parents.size(); + } + }; + } + + /** + * Resolves the parent commit objects and return them. + */ + public List getParents() throws IOException { + List r = new ArrayList(); + for (String sha1 : getParentSHA1s()) + r.add(owner.getCommit(sha1)); + return r; + } + + public GHUser getAuthor() throws IOException { + return resolveUser(author); + } + + public GHUser getCommitter() throws IOException { + return resolveUser(committer); + } + + private GHUser resolveUser(User author) throws IOException { + if (author==null || author.login==null) return null; + return owner.root.getUser(author.login); + } + + /** + * Lists up all the commit comments in this repository. + */ + public PagedIterable listComments() { + return new PagedIterable() { + public PagedIterator iterator() { + return new PagedIterator(owner.root.retrievePaged(String.format("/repos/%s/%s/commits/%s/comments",owner.getOwnerName(),owner.getName(),sha),GHCommitComment[].class,false,V3)) { + @Override + protected void wrapUp(GHCommitComment[] page) { + for (GHCommitComment c : page) + c.wrap(owner); + } + }; + } + }; + } + + /** + * Creates a commit comment. + * + * I'm not sure how path/line/position parameters interact with each other. + */ + public GHCommitComment createComment(String body, String path, Integer line, Integer position) throws IOException { + GHCommitComment r = new Poster(owner.root,V3) + .with("body",body) + .with("path",path) + .with("line",line) + .with("position",position) + .withCredential() + .to(String.format("/repos/%s/%s/commits/%s/comments",owner.getOwnerName(),owner.getName(),sha),GHCommitComment.class); + return r.wrap(owner); + } + + public GHCommitComment createComment(String body) throws IOException { + return createComment(body,null,null,null); + } + + GHCommit wrapUp(GHRepository owner) { + this.owner = owner; + return this; + } +} diff --git a/src/main/java/org/kohsuke/github/GHCommitComment.java b/src/main/java/org/kohsuke/github/GHCommitComment.java new file mode 100644 index 0000000000..c2364a23d5 --- /dev/null +++ b/src/main/java/org/kohsuke/github/GHCommitComment.java @@ -0,0 +1,125 @@ +package org.kohsuke.github; + +import java.io.IOException; +import java.net.URL; +import java.util.Date; + +import static org.kohsuke.github.ApiVersion.V3; + +/** + * A comment attached to a commit (or a specific line in a specific file of a commit.) + * + * @author Kohsuke Kawaguchi + * @see GHRepository#listCommitComments() + * @see GHCommit#listComments() + * @see GHCommit#createComment(String, String, Integer, Integer) + */ +public class GHCommitComment { + private GHRepository owner; + + String updated_at, created_at; + String body, url, html_url, commit_id; + Integer line; + int id; + String path; + User user; + + static class User { + // TODO: what if someone who doesn't have an account on GitHub makes a commit? + String url,avatar_url,login,gravatar_id; + int id; + } + + public GHRepository getOwner() { + return owner; + } + + public Date getCreatedAt() { + return GitHub.parseDate(created_at); + } + + public Date getUpdatedAt() { + return GitHub.parseDate(updated_at); + } + + /** + * URL like 'https://github.com/kohsuke/sandbox-ant/commit/8ae38db0ea5837313ab5f39d43a6f73de3bd9000#commitcomment-1252827' to + * show this commit comment in a browser. + */ + public URL getHtmlUrl() { + return GitHub.parseURL(html_url); + } + + public String getSHA1() { + return commit_id; + } + + /** + * Commit comment in the GitHub flavored markdown format. + */ + public String getBody() { + return body; + } + + /** + * A commit comment can be on a specific line of a specific file, if so, this field points to a file. + * Otherwise null. + */ + public String getPath() { + return path; + } + + /** + * A commit comment can be on a specific line of a specific file, if so, this field points to the line number in the file. + * Otherwise -1. + */ + public int getLine() { + return line!=null ? line : -1; + } + + public int getId() { + return id; + } + + /** + * Gets the user who put this comment. + */ + public GHUser getUser() throws IOException { + return owner.root.getUser(user.login); + } + + /** + * Gets the commit to which this comment is associated with. + */ + public GHCommit getCommit() throws IOException { + return getOwner().getCommit(getSHA1()); + } + + /** + * Updates the body of the commit message. + */ + public void update(String body) throws IOException { + GHCommitComment r = new Poster(owner.root,V3) + .with("body",body) + .withCredential() + .to(getApiTail(),GHCommitComment.class,"PATCH"); + this.body = body; + } + + /** + * Deletes this comment. + */ + public void delete() throws IOException { + new Poster(owner.root,V3).withCredential().to(getApiTail(),null,"DELETE"); + } + + private String getApiTail() { + return String.format("/repos/%s/%s/comments/%s",owner.getOwnerName(),owner.getName(),id); + } + + + GHCommitComment wrap(GHRepository owner) { + this.owner = owner; + return this; + } +} diff --git a/src/main/java/org/kohsuke/github/GHPerson.java b/src/main/java/org/kohsuke/github/GHPerson.java index 5a03ed9451..70ba8f27d0 100644 --- a/src/main/java/org/kohsuke/github/GHPerson.java +++ b/src/main/java/org/kohsuke/github/GHPerson.java @@ -1,5 +1,7 @@ package org.kohsuke.github; +import com.infradna.tool.bridge_method_injector.WithBridgeMethods; + import java.io.FileNotFoundException; import java.io.IOException; import java.util.Arrays; diff --git a/src/main/java/org/kohsuke/github/GHRepository.java b/src/main/java/org/kohsuke/github/GHRepository.java index 2a2c4f8327..909554a6b0 100644 --- a/src/main/java/org/kohsuke/github/GHRepository.java +++ b/src/main/java/org/kohsuke/github/GHRepository.java @@ -73,6 +73,7 @@ public class GHRepository { private Map milestones = new HashMap(); private String master_branch; + private Map commits = new HashMap(); public String getDescription() { return description; @@ -271,7 +272,7 @@ public void delete() throws IOException { String url = "/repos/delete/" + owner.login +"/"+name; DeleteToken token = poster.to(url, DeleteToken.class); - poster.with("delete_token",token.delete_token).to(url); + poster.with("delete_token", token.delete_token).to(url); } /** @@ -360,6 +361,51 @@ public GHHook getHook(int id) throws IOException { return root.retrieveWithAuth3(String.format("/repos/%s/%s/hooks/%d",owner.login,name,id),GHHook.class).wrap(this); } + /** + * Gets a commit object in this repository. + */ + public GHCommit getCommit(String sha1) throws IOException { + GHCommit c = commits.get(sha1); + if (c==null) { + c = root.retrieve3(String.format("/repos/%s/%s/commits/%s",owner.login,name,sha1),GHCommit.class).wrapUp(this); + commits.put(sha1,c); + } + return c; + } + + /** + * Lists all the commits. + */ + public PagedIterable listCommits() { + return new PagedIterable() { + public PagedIterator iterator() { + return new PagedIterator(root.retrievePaged(String.format("/repos/%s/%s/commits",owner.login,name),GHCommit[].class,false,V3)) { + protected void wrapUp(GHCommit[] page) { + for (GHCommit c : page) + c.wrapUp(GHRepository.this); + } + }; + } + }; + } + + /** + * Lists up all the commit comments in this repository. + */ + public PagedIterable listCommitComments() { + return new PagedIterable() { + public PagedIterator iterator() { + return new PagedIterator(root.retrievePaged(String.format("/repos/%s/%s/comments",owner.login,name),GHCommitComment[].class,false,V3)) { + @Override + protected void wrapUp(GHCommitComment[] page) { + for (GHCommitComment c : page) + c.wrap(GHRepository.this); + } + }; + } + }; + } + /** * * See https://api.github.com/hooks for possible names and their configuration scheme. diff --git a/src/main/java/org/kohsuke/github/GitHub.java b/src/main/java/org/kohsuke/github/GitHub.java index 91b34b1d7a..faffd27d79 100644 --- a/src/main/java/org/kohsuke/github/GitHub.java +++ b/src/main/java/org/kohsuke/github/GitHub.java @@ -320,7 +320,7 @@ private T parse(HttpURLConnection uc, Class type) throws IOException { */ private InputStream wrapStream(HttpURLConnection uc, InputStream in) throws IOException { String encoding = uc.getContentEncoding(); - if (encoding==null) return in; + if (encoding==null || in==null) return in; if (encoding.equals("gzip")) return new GZIPInputStream(in); throw new UnsupportedOperationException("Unexpected Content-Encoding: "+encoding); diff --git a/src/main/java/org/kohsuke/github/PagedIterable.java b/src/main/java/org/kohsuke/github/PagedIterable.java new file mode 100644 index 0000000000..2470ca7297 --- /dev/null +++ b/src/main/java/org/kohsuke/github/PagedIterable.java @@ -0,0 +1,8 @@ +package org.kohsuke.github; + +/** + * @author Kohsuke Kawaguchi + */ +public interface PagedIterable extends Iterable { + PagedIterator iterator(); +} diff --git a/src/main/java/org/kohsuke/github/PagedIterator.java b/src/main/java/org/kohsuke/github/PagedIterator.java new file mode 100644 index 0000000000..74c197aa2e --- /dev/null +++ b/src/main/java/org/kohsuke/github/PagedIterator.java @@ -0,0 +1,64 @@ +package org.kohsuke.github; + +import java.util.Arrays; +import java.util.Iterator; +import java.util.List; + +/** + * Iterator over a pagenated data source. + * + * Aside from the normal iterator operation, this method exposes {@link #nextPage()} + * that allows the caller to retrieve items per page. + * + * @author Kohsuke Kawaguchi + */ +public abstract class PagedIterator implements Iterator { + private final Iterator base; + + /** + * Current batch that we retrieved but haven't returned to the caller. + */ + private T[] current; + private int pos; + + /*package*/ PagedIterator(Iterator base) { + this.base = base; + } + + protected abstract void wrapUp(T[] page); + + public boolean hasNext() { + return (current!=null && pos nextPage() { + fetch(); + List r = Arrays.asList(current); + r = r.subList(pos,r.size()); + current = null; + pos = 0; + return r; + } +} diff --git a/src/main/java/org/kohsuke/github/Poster.java b/src/main/java/org/kohsuke/github/Poster.java index 556044a986..c07b5ddad3 100644 --- a/src/main/java/org/kohsuke/github/Poster.java +++ b/src/main/java/org/kohsuke/github/Poster.java @@ -33,7 +33,9 @@ import java.io.OutputStreamWriter; import java.io.Reader; import java.io.UnsupportedEncodingException; +import java.lang.reflect.Field; import java.net.HttpURLConnection; +import java.net.ProtocolException; import java.net.URLEncoder; import java.util.ArrayList; import java.util.HashMap; @@ -86,6 +88,12 @@ public Poster with(String key, int value) { return _with(key, value); } + public Poster with(String key, Integer value) { + if (value!=null) + _with(key, value.intValue()); + return this; + } + public Poster with(String key, boolean value) { return _with(key, value); } @@ -136,7 +144,18 @@ public T to(String tailApiUrl, Class type, String method) throws IOExcept uc.setRequestProperty("Authorization", "Basic " + root.encodedAuthorization); } } - uc.setRequestMethod(method); + try { + uc.setRequestMethod(method); + } catch (ProtocolException e) { + // JDK only allows one of the fixed set of verbs. Try to override that + try { + Field $method = HttpURLConnection.class.getDeclaredField("method"); + $method.setAccessible(true); + $method.set(uc,method); + } catch (Exception x) { + throw (IOException)new IOException("Failed to set the custom verb").initCause(x); + } + } if (v==ApiVersion.V2) { diff --git a/src/test/java/org/kohsuke/AppTest.java b/src/test/java/org/kohsuke/AppTest.java index aabee36d41..1e016e771c 100644 --- a/src/test/java/org/kohsuke/AppTest.java +++ b/src/test/java/org/kohsuke/AppTest.java @@ -1,6 +1,9 @@ package org.kohsuke; import junit.framework.TestCase; +import org.kohsuke.github.GHCommit; +import org.kohsuke.github.GHCommit.File; +import org.kohsuke.github.GHCommitComment; import org.kohsuke.github.GHEvent; import org.kohsuke.github.GHEventInfo; import org.kohsuke.github.GHEventPayload; @@ -14,9 +17,11 @@ import org.kohsuke.github.GHTeam; import org.kohsuke.github.GHUser; import org.kohsuke.github.GitHub; +import org.kohsuke.github.PagedIterable; import java.io.IOException; import java.net.URL; +import java.util.ArrayList; import java.util.Map; import java.util.Set; import java.util.List; @@ -74,13 +79,58 @@ public void testMemberOrgs() throws Exception { System.out.println(o); } + public void testCommit() throws Exception { + GitHub gitHub = GitHub.connect(); + GHCommit commit = gitHub.getUser("jenkinsci").getRepository("jenkins").getCommit("08c1c9970af4d609ae754fbe803e06186e3206f7"); + System.out.println(commit); + assertEquals(1, commit.getParents().size()); + assertEquals(1,commit.getFiles().size()); + + File f = commit.getFiles().get(0); + assertEquals(48,f.getLinesChanged()); + assertEquals("modified",f.getStatus()); + assertEquals("changelog.html",f.getFileName()); + } + + public void testListCommits() throws Exception { + GitHub gitHub = GitHub.connect(); + List sha1 = new ArrayList(); + for (GHCommit c : gitHub.getUser("kohsuke").getRepository("empty-commit").listCommits()) { + System.out.println(c.getSHA1()); + sha1.add(c.getSHA1()); + } + assertEquals("fdfad6be4db6f96faea1f153fb447b479a7a9cb7",sha1.get(0)); + assertEquals(1,sha1.size()); + } + public void testBranches() throws Exception { GitHub gitHub = GitHub.connect(); Map b = gitHub.getUser("jenkinsci").getRepository("jenkins").getBranches(); System.out.println(b); } - + + public void testCommitComment() throws Exception { + GitHub gitHub = GitHub.connect(); + GHRepository r = gitHub.getUser("jenkinsci").getRepository("jenkins"); + PagedIterable comments = r.listCommitComments(); + List batch = comments.iterator().nextPage(); + for (GHCommitComment comment : batch) { + System.out.println(comment.getBody()); + assertSame(comment.getOwner(), r); + } + } + + public void tryCreateCommitComment() throws Exception { + GitHub gitHub = GitHub.connect(); + GHCommit commit = gitHub.getUser("kohsuke").getRepository("sandbox-ant").getCommit("8ae38db0ea5837313ab5f39d43a6f73de3bd9000"); + GHCommitComment c = commit.createComment("[testing](http://kohsuse.org/)"); + System.out.println(c); + c.update("updated text"); + System.out.println(c); + c.delete(); + } + public void tryHook() throws Exception { GitHub gitHub = GitHub.connect(); GHRepository r = gitHub.getMyself().getRepository("test2");