diff --git a/pom.xml b/pom.xml index ec7557ffb0..c7ef908b17 100644 --- a/pom.xml +++ b/pom.xml @@ -7,7 +7,7 @@ github-api - 1.64-SNAPSHOT + 1.66 GitHub API for Java http://github-api.kohsuke.org/ GitHub API for Java @@ -16,7 +16,7 @@ scm:git:git@github.com/kohsuke/${project.artifactId}.git scm:git:ssh://git@github.com/kohsuke/${project.artifactId}.git http://${project.artifactId}.kohsuke.org/ - HEAD + github-api-1.66 diff --git a/src/main/java/org/kohsuke/github/GHContent.java b/src/main/java/org/kohsuke/github/GHContent.java index cd04e436f1..8ab48d2fdd 100644 --- a/src/main/java/org/kohsuke/github/GHContent.java +++ b/src/main/java/org/kohsuke/github/GHContent.java @@ -16,7 +16,13 @@ */ @SuppressWarnings({"UnusedDeclaration"}) public class GHContent { - private GHRepository owner; + /* + In normal use of this class, repository field is set via wrap(), + but in the code search API, there's a nested 'repository' field that gets populated from JSON. + */ + private GHRepository repository; + + private GitHub root; private String type; private String encoding; @@ -31,7 +37,7 @@ public class GHContent { private String download_url; public GHRepository getOwner() { - return owner; + return repository; } public String getType() { @@ -107,13 +113,16 @@ public String getHtmlUrl() { * Retrieves the actual content stored here. */ public InputStream read() throws IOException { - return new Requester(owner.root).read(getDownloadUrl()); + return new Requester(root).asStream(getDownloadUrl()); } /** * URL to retrieve the raw content of the file. Null if this is a directory. */ - public String getDownloadUrl() { return download_url; } + public String getDownloadUrl() throws IOException { + populate(); + return download_url; + } public boolean isFile() { return "file".equals(type); @@ -123,6 +132,16 @@ public boolean isDirectory() { return "dir".equals(type); } + /** + * Fully populate the data by retrieving missing data. + * + * Depending on the original API call where this object is created, it may not contain everything. + */ + protected synchronized void populate() throws IOException { + if (download_url!=null) return; // already populated + root.retrieve().to(url, this); + } + /** * List immediate children of this directory. */ @@ -132,10 +151,10 @@ public PagedIterable listDirectoryContent() throws IOException { return new PagedIterable() { public PagedIterator iterator() { - return new PagedIterator(owner.root.retrieve().asIterator(url, GHContent[].class)) { + return new PagedIterator(root.retrieve().asIterator(url, GHContent[].class)) { @Override protected void wrapUp(GHContent[] page) { - GHContent.wrap(page,owner); + GHContent.wrap(page, repository); } }; } @@ -157,7 +176,7 @@ public GHContentUpdateResponse update(byte[] newContentBytes, String commitMessa public GHContentUpdateResponse update(byte[] newContentBytes, String commitMessage, String branch) throws IOException { String encodedContent = DatatypeConverter.printBase64Binary(newContentBytes); - Requester requester = new Requester(owner.root) + Requester requester = new Requester(root) .with("path", path) .with("message", commitMessage) .with("sha", sha) @@ -170,8 +189,8 @@ public GHContentUpdateResponse update(byte[] newContentBytes, String commitMessa GHContentUpdateResponse response = requester.to(getApiRoute(), GHContentUpdateResponse.class); - response.getContent().wrap(owner); - response.getCommit().wrapUp(owner); + response.getContent().wrap(repository); + response.getCommit().wrapUp(repository); this.content = encodedContent; return response; @@ -182,7 +201,7 @@ public GHContentUpdateResponse delete(String message) throws IOException { } public GHContentUpdateResponse delete(String commitMessage, String branch) throws IOException { - Requester requester = new Requester(owner.root) + Requester requester = new Requester(root) .with("path", path) .with("message", commitMessage) .with("sha", sha) @@ -194,18 +213,26 @@ public GHContentUpdateResponse delete(String commitMessage, String branch) throw GHContentUpdateResponse response = requester.to(getApiRoute(), GHContentUpdateResponse.class); - response.getCommit().wrapUp(owner); + response.getCommit().wrapUp(repository); return response; } private String getApiRoute() { - return "/repos/" + owner.getOwnerName() + "/" + owner.getName() + "/contents/" + path; + return "/repos/" + repository.getOwnerName() + "/" + repository.getName() + "/contents/" + path; } GHContent wrap(GHRepository owner) { - this.owner = owner; + this.repository = owner; + this.root = owner.root; return this; } + GHContent wrap(GitHub root) { + this.root = root; + if (repository!=null) + repository.wrap(root); + return this; + } + public static GHContent[] wrap(GHContent[] contents, GHRepository repository) { for (GHContent unwrappedContent : contents) { diff --git a/src/main/java/org/kohsuke/github/GHContentSearchBuilder.java b/src/main/java/org/kohsuke/github/GHContentSearchBuilder.java new file mode 100644 index 0000000000..47e5a46f52 --- /dev/null +++ b/src/main/java/org/kohsuke/github/GHContentSearchBuilder.java @@ -0,0 +1,74 @@ +package org.kohsuke.github; + +/** + * Search code for {@link GHContent}. + * + * @author Kohsuke Kawaguchi + * @see GitHub#searchContent() + */ +public class GHContentSearchBuilder extends GHSearchBuilder { + /*package*/ GHContentSearchBuilder(GitHub root) { + super(root,ContentSearchResult.class); + } + + /** + * Search terms. + */ + public GHContentSearchBuilder q(String term) { + super.q(term); + return this; + } + + public GHContentSearchBuilder in(String v) { + return q("in:"+v); + } + + public GHContentSearchBuilder language(String v) { + return q("language:"+v); + } + + public GHContentSearchBuilder fork(String v) { + return q("fork:"+v); + } + + public GHContentSearchBuilder size(String v) { + return q("size:"+v); + } + + public GHContentSearchBuilder path(String v) { + return q("path:"+v); + } + + public GHContentSearchBuilder filename(String v) { + return q("filename:"+v); + } + + public GHContentSearchBuilder extension(String v) { + return q("extension:"+v); + } + + public GHContentSearchBuilder user(String v) { + return q("user:"+v); + } + + + public GHContentSearchBuilder repo(String v) { + return q("repo:"+v); + } + + private static class ContentSearchResult extends SearchResult { + private GHContent[] items; + + @Override + /*package*/ GHContent[] getItems(GitHub root) { + for (GHContent item : items) + item.wrap(root); + return items; + } + } + + @Override + protected String getApiUrl() { + return "/search/code"; + } +} diff --git a/src/main/java/org/kohsuke/github/GHIssueSearchBuilder.java b/src/main/java/org/kohsuke/github/GHIssueSearchBuilder.java index 66ed87053b..c3054b1067 100644 --- a/src/main/java/org/kohsuke/github/GHIssueSearchBuilder.java +++ b/src/main/java/org/kohsuke/github/GHIssueSearchBuilder.java @@ -1,9 +1,5 @@ package org.kohsuke.github; -import org.apache.commons.lang.StringUtils; - -import java.util.ArrayList; -import java.util.List; import java.util.Locale; /** @@ -12,21 +8,16 @@ * @author Kohsuke Kawaguchi * @see GitHub#searchIssues() */ -public class GHIssueSearchBuilder { - private final GitHub root; - private final Requester req; - private final List terms = new ArrayList(); - +public class GHIssueSearchBuilder extends GHSearchBuilder { /*package*/ GHIssueSearchBuilder(GitHub root) { - this.root = root; - req = root.retrieve(); + super(root,IssueSearchResult.class); } /** * Search terms. */ public GHIssueSearchBuilder q(String term) { - terms.add(term); + super.q(term); return this; } @@ -61,25 +52,15 @@ private static class IssueSearchResult extends SearchResult { private GHIssue[] items; @Override - public GHIssue[] getItems() { + /*package*/ GHIssue[] getItems(GitHub root) { + for (GHIssue i : items) + i.wrap(root); return items; } } - /** - * Lists up the issues with the criteria built so far. - */ - public PagedSearchIterable list() { - return new PagedSearchIterable() { - public PagedIterator iterator() { - req.set("q", StringUtils.join(terms," ")); - return new PagedIterator(adapt(req.asIterator("/search/issues", IssueSearchResult.class))) { - protected void wrapUp(GHIssue[] page) { - for (GHIssue c : page) - c.wrap(root); - } - }; - } - }; + @Override + protected String getApiUrl() { + return "/search/issues"; } } diff --git a/src/main/java/org/kohsuke/github/GHNotificationStream.java b/src/main/java/org/kohsuke/github/GHNotificationStream.java new file mode 100644 index 0000000000..5ea6fb69cb --- /dev/null +++ b/src/main/java/org/kohsuke/github/GHNotificationStream.java @@ -0,0 +1,207 @@ +package org.kohsuke.github; + +import java.io.IOException; +import java.util.Date; +import java.util.Iterator; +import java.util.NoSuchElementException; + +/** + * Listens to GitHub notification stream. + * + *

+ * This class supports two modes of retrieving notifications that can + * be controlled via {@link #nonBlocking(boolean)}. + * + *

+ * In the blocking mode, which is the default, iterator will be infinite. + * The call to {@link Iterator#next()} will block until a new notification + * arrives. This is useful for application that runs perpetually and reacts + * to notifications. + * + *

+ * In the non-blocking mode, the iterator will only report the set of + * notifications initially retrieved from GitHub, then quit. This is useful + * for a batch application to process the current set of notifications. + * + * @author Kohsuke Kawaguchi + * @see GitHub#listNotifications() + * @see GHRepository#listNotifications() + */ +public class GHNotificationStream implements Iterable { + private final GitHub root; + + private Boolean all, participating; + private String since; + private String apiUrl; + private boolean nonBlocking = false; + + /*package*/ GHNotificationStream(GitHub root, String apiUrl) { + this.root = root; + this.apiUrl = apiUrl; + } + + /** + * Should the stream include notifications that are already read? + */ + public GHNotificationStream read(boolean v) { + all = v; + return this; + } + + /** + * Should the stream be restricted to notifications in which the user + * is directly participating or mentioned? + */ + public GHNotificationStream participating(boolean v) { + participating = v; + return this; + } + + public GHNotificationStream since(long timestamp) { + return since(new Date(timestamp)); + } + + public GHNotificationStream since(Date dt) { + since = GitHub.printDate(dt); + return this; + } + + /** + * If set to true, {@link #iterator()} will stop iterating instead of blocking and + * waiting for the updates to arrive. + */ + public GHNotificationStream nonBlocking(boolean v) { + this.nonBlocking = v; + return this; + } + + /** + * Returns an infinite blocking {@link Iterator} that returns + * {@link GHThread} as notifications arrive. + */ + public Iterator iterator() { + // capture the configuration setting here + final Requester req = new Requester(root).method("GET") + .with("all", all).with("participating", participating).with("since", since); + + return new Iterator() { + /** + * Stuff we've fetched but haven't returned to the caller. + * Newer ones first. + */ + private GHThread[] threads = EMPTY_ARRAY; + + /** + * Next element in {@link #threads} to return. This counts down. + */ + private int idx=-1; + + /** + * threads whose updated_at is older than this should be ignored. + */ + private long lastUpdated = -1; + + /** + * Next request should have "If-Modified-Since" header with this value. + */ + private String lastModified; + + /** + * When is the next polling allowed? + */ + private long nextCheckTime = -1; + + private GHThread next; + + public GHThread next() { + if (next==null) { + next = fetch(); + if (next==null) + throw new NoSuchElementException(); + } + + GHThread r = next; + next = null; + return r; + } + + public boolean hasNext() { + if (next==null) + next = fetch(); + return next!=null; + } + + GHThread fetch() { + try { + while (true) {// loop until we get new threads to return + + // if we have fetched un-returned threads, use them first + while (idx>=0) { + GHThread n = threads[idx--]; + long nt = n.getUpdatedAt().getTime(); + if (nt >= lastUpdated) { + lastUpdated = nt; + return n.wrap(root); + } + } + + if (nonBlocking && nextCheckTime>=0) + return null; // nothing more to report, and we aren't blocking + + // observe the polling interval before making the call + while (true) { + long now = System.currentTimeMillis(); + if (nextCheckTime < now) break; + long waitTime = Math.max(Math.min(nextCheckTime - now, 1000), 60 * 1000); + Thread.sleep(waitTime); + } + + req.setHeader("If-Modified-Since", lastModified); + + threads = req.to(apiUrl, GHThread[].class); + if (threads==null) { + threads = EMPTY_ARRAY; // if unmodified, we get empty array + } else { + // we get a new batch, but we want to ignore the ones that we've seen + lastUpdated++; + } + idx = threads.length-1; + + nextCheckTime = calcNextCheckTime(); + lastModified = req.getResponseHeader("Last-Modified"); + } + } catch (IOException e) { + throw new RuntimeException(e); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + } + + private long calcNextCheckTime() { + String v = req.getResponseHeader("X-Poll-Interval"); + if (v==null) v="60"; + return System.currentTimeMillis()+Integer.parseInt(v)*1000; + } + + public void remove() { + throw new UnsupportedOperationException(); + } + }; + } + + public void markAsRead() throws IOException { + markAsRead(-1); + } + + /** + * Marks all the notifications as read. + */ + public void markAsRead(long timestamp) throws IOException { + final Requester req = new Requester(root).method("PUT"); + if (timestamp>=0) + req.with("last_read_at", GitHub.printDate(new Date(timestamp))); + req.asHttpStatusCode(apiUrl); + } + + private static final GHThread[] EMPTY_ARRAY = new GHThread[0]; +} diff --git a/src/main/java/org/kohsuke/github/GHRepository.java b/src/main/java/org/kohsuke/github/GHRepository.java index 1c9fcc7b34..ff1539892d 100644 --- a/src/main/java/org/kohsuke/github/GHRepository.java +++ b/src/main/java/org/kohsuke/github/GHRepository.java @@ -1166,11 +1166,17 @@ public Reader renderMarkdown(String text, MarkdownMode mode) throws IOException .with("text", text) .with("mode",mode==null?null:mode.toString()) .with("context", getFullName()) - .read("/markdown"), + .asStream("/markdown"), "UTF-8"); } - + /** + * List all the notifications in a repository for the current user. + */ + public GHNotificationStream listNotifications() { + return new GHNotificationStream(root,getApiTailUrl("/notifications")); + } + @Override public String toString() { diff --git a/src/main/java/org/kohsuke/github/GHRepositorySearchBuilder.java b/src/main/java/org/kohsuke/github/GHRepositorySearchBuilder.java new file mode 100644 index 0000000000..cbcbb9e19d --- /dev/null +++ b/src/main/java/org/kohsuke/github/GHRepositorySearchBuilder.java @@ -0,0 +1,82 @@ +package org.kohsuke.github; + +import java.util.Locale; + +/** + * Search repositories. + * + * @author Kohsuke Kawaguchi + * @see GitHub#searchRepositories() + */ +public class GHRepositorySearchBuilder extends GHSearchBuilder { + /*package*/ GHRepositorySearchBuilder(GitHub root) { + super(root,RepositorySearchResult.class); + } + + /** + * Search terms. + */ + public GHRepositorySearchBuilder q(String term) { + super.q(term); + return this; + } + + public GHRepositorySearchBuilder in(String v) { + return q("in:"+v); + } + + public GHRepositorySearchBuilder size(String v) { + return q("size:"+v); + } + + public GHRepositorySearchBuilder forks(String v) { + return q("forks:"+v); + } + + public GHRepositorySearchBuilder created(String v) { + return q("created:"+v); + } + + public GHRepositorySearchBuilder pushed(String v) { + return q("pushed:"+v); + } + + public GHRepositorySearchBuilder user(String v) { + return q("user:"+v); + } + + public GHRepositorySearchBuilder repo(String v) { + return q("repo:"+v); + } + + public GHRepositorySearchBuilder language(String v) { + return q("language:"+v); + } + + public GHRepositorySearchBuilder stars(String v) { + return q("stars:"+v); + } + + public GHRepositorySearchBuilder sort(Sort sort) { + req.with("sort",sort.toString().toLowerCase(Locale.ENGLISH)); + return this; + } + + public enum Sort { STARS, FORKS, UPDATED } + + private static class RepositorySearchResult extends SearchResult { + private GHRepository[] items; + + @Override + /*package*/ GHRepository[] getItems(GitHub root) { + for (GHRepository item : items) + item.wrap(root); + return items; + } + } + + @Override + protected String getApiUrl() { + return "/search/repositories"; + } +} diff --git a/src/main/java/org/kohsuke/github/GHSearchBuilder.java b/src/main/java/org/kohsuke/github/GHSearchBuilder.java new file mode 100644 index 0000000000..766c6a319f --- /dev/null +++ b/src/main/java/org/kohsuke/github/GHSearchBuilder.java @@ -0,0 +1,54 @@ +package org.kohsuke.github; + +import org.apache.commons.lang.StringUtils; + +import java.util.ArrayList; +import java.util.List; + +/** + * Base class for various search builders. + * + * @author Kohsuke Kawaguchi + */ +public abstract class GHSearchBuilder { + protected final GitHub root; + protected final Requester req; + protected final List terms = new ArrayList(); + + /** + * Data transfer object that receives the result of search. + */ + private final Class> receiverType; + + /*package*/ GHSearchBuilder(GitHub root, Class> receiverType) { + this.root = root; + this.req = root.retrieve(); + this.receiverType = receiverType; + } + + /** + * Search terms. + */ + public GHSearchBuilder q(String term) { + terms.add(term); + return this; + } + + /** + * Performs the search. + */ + public PagedSearchIterable list() { + return new PagedSearchIterable(root) { + public PagedIterator iterator() { + req.set("q", StringUtils.join(terms, " ")); + return new PagedIterator(adapt(req.asIterator(getApiUrl(), receiverType))) { + protected void wrapUp(T[] page) { + // SearchResult.getItems() should do it + } + }; + } + }; + } + + protected abstract String getApiUrl(); +} diff --git a/src/main/java/org/kohsuke/github/GHSubscription.java b/src/main/java/org/kohsuke/github/GHSubscription.java index bc5dcb92e4..16acbed45e 100644 --- a/src/main/java/org/kohsuke/github/GHSubscription.java +++ b/src/main/java/org/kohsuke/github/GHSubscription.java @@ -4,9 +4,11 @@ import java.util.Date; /** - * Represents your subscribing to a repository. + * Represents your subscribing to a repository / conversation thread.. * * @author Kohsuke Kawaguchi + * @see GHRepository#getSubscription() + * @see GHThread#getSubscription() */ public class GHSubscription { private String created_at, url, repository_url, reason; diff --git a/src/main/java/org/kohsuke/github/GHThread.java b/src/main/java/org/kohsuke/github/GHThread.java new file mode 100644 index 0000000000..e1f749a9f1 --- /dev/null +++ b/src/main/java/org/kohsuke/github/GHThread.java @@ -0,0 +1,98 @@ +package org.kohsuke.github; + +import java.io.FileNotFoundException; +import java.io.IOException; +import java.util.Date; + +/** + * A conversation in the notification API. + * + * @see documentation + * @see GHNotificationStream + * @author Kohsuke Kawaguchi + */ +public class GHThread extends GHObject { + private GitHub root; + private GHRepository repository; + private Subject subject; + private String reason; + private boolean unread; + private String last_read_at; + private String url,subscription_url; + + static class Subject { + String title; + String url; + String latest_comment_url; + String type; + } + + private GHThread() {// no external construction allowed + } + + /** + * Returns null if the entire thread has never been read. + */ + public Date getLastReadAt() { + return GitHub.parseDate(last_read_at); + } + + public String getReason() { + return reason; + } + + public GHRepository getRepository() { + return repository; + } + + // TODO: how to expose the subject? + + public boolean isRead() { + return !unread; + } + + public String getTitle() { + return subject.title; + } + + public String getType() { + return subject.type; + } + + /*package*/ GHThread wrap(GitHub root) { + this.root = root; + if (this.repository!=null) + this.repository.wrap(root); + return this; + } + + /** + * Marks this thread as read. + */ + public void markAsRead() throws IOException { + new Requester(root).method("PATCH").to(url); + } + + /** + * Subscribes to this conversation to get notifications. + */ + public GHSubscription subscribe(boolean subscribed, boolean ignored) throws IOException { + return new Requester(root) + .with("subscribed", subscribed) + .with("ignored", ignored) + .method("PUT").to(subscription_url, GHSubscription.class).wrapUp(root); + } + + /** + * Returns the current subscription for this thread. + * + * @return null if no subscription exists. + */ + public GHSubscription getSubscription() throws IOException { + try { + return new Requester(root).to(subscription_url, GHSubscription.class).wrapUp(root); + } catch (FileNotFoundException e) { + return null; + } + } +} diff --git a/src/main/java/org/kohsuke/github/GHUserSearchBuilder.java b/src/main/java/org/kohsuke/github/GHUserSearchBuilder.java new file mode 100644 index 0000000000..ee0c5f62a0 --- /dev/null +++ b/src/main/java/org/kohsuke/github/GHUserSearchBuilder.java @@ -0,0 +1,72 @@ +package org.kohsuke.github; + +import java.util.Locale; + +/** + * Search users. + * + * @author Kohsuke Kawaguchi + * @see GitHub#searchUsers() + */ +public class GHUserSearchBuilder extends GHSearchBuilder { + /*package*/ GHUserSearchBuilder(GitHub root) { + super(root,UserSearchResult.class); + } + + /** + * Search terms. + */ + public GHUserSearchBuilder q(String term) { + super.q(term); + return this; + } + + public GHUserSearchBuilder type(String v) { + return q("type:"+v); + } + + public GHUserSearchBuilder in(String v) { + return q("in:"+v); + } + + public GHUserSearchBuilder repos(String v) { + return q("repos:"+v); + } + + public GHUserSearchBuilder location(String v) { + return q("location:"+v); + } + + public GHUserSearchBuilder language(String v) { + return q("language:"+v); + } + + public GHUserSearchBuilder created(String v) { + return q("created:"+v); + } + + public GHUserSearchBuilder followers(String v) { + return q("followers:"+v); + } + + public GHUserSearchBuilder sort(Sort sort) { + req.with("sort",sort.toString().toLowerCase(Locale.ENGLISH)); + return this; + } + + public enum Sort { FOLLOWERS, REPOSITORIES, JOINED } + + private static class UserSearchResult extends SearchResult { + private GHUser[] items; + + @Override + /*package*/ GHUser[] getItems(GitHub root) { + return GHUser.wrap(items,root); + } + } + + @Override + protected String getApiUrl() { + return "/search/users"; + } +} diff --git a/src/main/java/org/kohsuke/github/GitHub.java b/src/main/java/org/kohsuke/github/GitHub.java index ec1a2a46d5..33601668ff 100644 --- a/src/main/java/org/kohsuke/github/GitHub.java +++ b/src/main/java/org/kohsuke/github/GitHub.java @@ -443,6 +443,34 @@ public GHIssueSearchBuilder searchIssues() { return new GHIssueSearchBuilder(this); } + /** + * Search users. + */ + public GHUserSearchBuilder searchUsers() { + return new GHUserSearchBuilder(this); + } + + /** + * Search repositories. + */ + public GHRepositorySearchBuilder searchRepositories() { + return new GHRepositorySearchBuilder(this); + } + + /** + * Search content. + */ + public GHContentSearchBuilder searchContent() { + return new GHContentSearchBuilder(this); + } + + /** + * List all the notifications. + */ + public GHNotificationStream listNotifications() { + return new GHNotificationStream(this,"/notifications"); + } + /** * This provides a dump of every public repository, in the order that they were created. * @see documentation @@ -487,7 +515,7 @@ public Reader renderMarkdown(String text) throws IOException { new Requester(this) .with(new ByteArrayInputStream(text.getBytes("UTF-8"))) .contentType("text/plain;charset=UTF-8") - .read("/markdown/raw"), + .asStream("/markdown/raw"), "UTF-8"); } diff --git a/src/main/java/org/kohsuke/github/PagedSearchIterable.java b/src/main/java/org/kohsuke/github/PagedSearchIterable.java index 2adb23381f..65b6c7490d 100644 --- a/src/main/java/org/kohsuke/github/PagedSearchIterable.java +++ b/src/main/java/org/kohsuke/github/PagedSearchIterable.java @@ -8,11 +8,17 @@ * @author Kohsuke Kawaguchi */ public abstract class PagedSearchIterable extends PagedIterable { + private final GitHub root; + /** * As soon as we have any result fetched, it's set here so that we can report the total count. */ private SearchResult result; + /*package*/ PagedSearchIterable(GitHub root) { + this.root = root; + } + /** * Returns the total number of hit, including the results that's not yet fetched. */ @@ -43,7 +49,7 @@ public boolean hasNext() { public T[] next() { SearchResult v = base.next(); if (result==null) result = v; - return v.getItems(); + return v.getItems(root); } public void remove() { diff --git a/src/main/java/org/kohsuke/github/Requester.java b/src/main/java/org/kohsuke/github/Requester.java index 7f34e72144..cbab7b82fb 100644 --- a/src/main/java/org/kohsuke/github/Requester.java +++ b/src/main/java/org/kohsuke/github/Requester.java @@ -23,7 +23,8 @@ */ package org.kohsuke.github; -import static org.kohsuke.github.GitHub.MAPPER; +import com.fasterxml.jackson.databind.JsonMappingException; +import org.apache.commons.io.IOUtils; import java.io.FileNotFoundException; import java.io.IOException; @@ -43,6 +44,7 @@ import java.util.HashMap; import java.util.HashSet; import java.util.Iterator; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.NoSuchElementException; @@ -51,8 +53,7 @@ import java.util.regex.Pattern; import java.util.zip.GZIPInputStream; -import com.fasterxml.jackson.databind.JsonMappingException; -import org.apache.commons.io.IOUtils; +import static org.kohsuke.github.GitHub.*; /** * A builder pattern for making HTTP call and parsing its output. @@ -62,6 +63,7 @@ class Requester { private final GitHub root; private final List args = new ArrayList(); + private final Map headers = new LinkedHashMap(); /** * Request method. @@ -70,6 +72,11 @@ class Requester { private String contentType = "application/x-www-form-urlencoded"; private InputStream body; + /** + * Current connection. + */ + private HttpURLConnection uc; + private static class Entry { String key; Object value; @@ -84,6 +91,15 @@ private Entry(String key, Object value) { this.root = root; } + /** + * Sets the request HTTP header. + * + * If a header of the same name is already set, this method overrides it. + */ + public void setHeader(String name, String value) { + headers.put(name,value); + } + /** * Makes a request with authentication credential. */ @@ -107,6 +123,10 @@ public Requester with(String key, Integer value) { public Requester with(String key, boolean value) { return _with(key, value); } + public Requester with(String key, Boolean value) { + return _with(key, value); + } + public Requester with(String key, String value) { return _with(key, value); @@ -196,12 +216,12 @@ private T _to(String tailApiUrl, Class type, T instance) throws IOExcepti } tailApiUrl += qs.toString(); } - HttpURLConnection uc = setupConnection(root.getApiURL(tailApiUrl)); + setupConnection(root.getApiURL(tailApiUrl)); - buildRequest(uc); + buildRequest(); try { - T result = parse(uc, type, instance); + T result = parse(type, instance); if (type != null && type.isArray()) { // we might have to loop for pagination - done through recursion final String links = uc.getHeaderField("link"); if (links != null && links.contains("rel=\"next\"")) { @@ -222,7 +242,7 @@ private T _to(String tailApiUrl, Class type, T instance) throws IOExcepti } return result; } catch (IOException e) { - handleApiError(e,uc); + handleApiError(e); } } } @@ -232,33 +252,41 @@ private T _to(String tailApiUrl, Class type, T instance) throws IOExcepti */ public int asHttpStatusCode(String tailApiUrl) throws IOException { while (true) {// loop while API rate limit is hit - HttpURLConnection uc = setupConnection(root.getApiURL(tailApiUrl)); + setupConnection(root.getApiURL(tailApiUrl)); - buildRequest(uc); + buildRequest(); try { return uc.getResponseCode(); } catch (IOException e) { - handleApiError(e,uc); + handleApiError(e); } } } - public InputStream read(String tailApiUrl) throws IOException { + public InputStream asStream(String tailApiUrl) throws IOException { while (true) {// loop while API rate limit is hit - HttpURLConnection uc = setupConnection(root.getApiURL(tailApiUrl)); + setupConnection(root.getApiURL(tailApiUrl)); - buildRequest(uc); + buildRequest(); try { - return wrapStream(uc,uc.getInputStream()); + return wrapStream(uc.getInputStream()); } catch (IOException e) { - handleApiError(e,uc); + handleApiError(e); } } } - private void buildRequest(HttpURLConnection uc) throws IOException { + public String getResponseHeader(String header) { + return uc.getHeaderField(header); + } + + + /** + * Set up the request parameters or POST payload. + */ + private void buildRequest() throws IOException { if (!method.equals("GET")) { uc.setDoOutput(true); uc.setRequestProperty("Content-type", contentType); @@ -347,14 +375,14 @@ private void fetch() { try { while (true) {// loop while API rate limit is hit - HttpURLConnection uc = setupConnection(url); + setupConnection(url); try { - next = parse(uc,type,null); + next = parse(type,null); assert next!=null; - findNextURL(uc); + findNextURL(); return; } catch (IOException e) { - handleApiError(e,uc); + handleApiError(e); } } } catch (IOException e) { @@ -365,7 +393,7 @@ private void fetch() { /** * Locate the next page from the pagination "Link" tag. */ - private void findNextURL(HttpURLConnection uc) throws MalformedURLException { + private void findNextURL() throws MalformedURLException { url = null; // start defensively String link = uc.getHeaderField("Link"); if (link==null) return; @@ -386,14 +414,20 @@ private void findNextURL(HttpURLConnection uc) throws MalformedURLException { } - private HttpURLConnection setupConnection(URL url) throws IOException { - HttpURLConnection uc = root.getConnector().connect(url); + private void setupConnection(URL url) throws IOException { + uc = root.getConnector().connect(url); // if the authentication is needed but no credential is given, try it anyway (so that some calls // that do work with anonymous access in the reduced form should still work.) if (root.encodedAuthorization!=null) uc.setRequestProperty("Authorization", root.encodedAuthorization); + for (Map.Entry e : headers.entrySet()) { + String v = e.getValue(); + if (v!=null) + uc.setRequestProperty(e.getKey(), v); + } + try { uc.setRequestMethod(method); } catch (ProtocolException e) { @@ -407,13 +441,14 @@ private HttpURLConnection setupConnection(URL url) throws IOException { } } uc.setRequestProperty("Accept-Encoding", "gzip"); - return uc; } - private T parse(HttpURLConnection uc, Class type, T instance) throws IOException { + private T parse(Class type, T instance) throws IOException { + if (uc.getResponseCode()==304) + return null; // special case handling for 304 unmodified, as the content will be "" InputStreamReader r = null; try { - r = new InputStreamReader(wrapStream(uc, uc.getInputStream()), "UTF-8"); + r = new InputStreamReader(wrapStream(uc.getInputStream()), "UTF-8"); String data = IOUtils.toString(r); if (type!=null) try { @@ -432,7 +467,7 @@ private T parse(HttpURLConnection uc, Class type, T instance) throws IOEx /** * Handles the "Content-Encoding" header. */ - private InputStream wrapStream(HttpURLConnection uc, InputStream in) throws IOException { + private InputStream wrapStream(InputStream in) throws IOException { String encoding = uc.getContentEncoding(); if (encoding==null || in==null) return in; if (encoding.equals("gzip")) return new GZIPInputStream(in); @@ -443,19 +478,20 @@ private InputStream wrapStream(HttpURLConnection uc, InputStream in) throws IOEx /** * Handle API error by either throwing it or by returning normally to retry. */ - /*package*/ void handleApiError(IOException e, HttpURLConnection uc) throws IOException { + /*package*/ void handleApiError(IOException e) throws IOException { if ("0".equals(uc.getHeaderField("X-RateLimit-Remaining"))) { root.rateLimitHandler.onError(e,uc); } - if (e instanceof FileNotFoundException) - throw e; // pass through 404 Not Found to allow the caller to handle it intelligently - - InputStream es = wrapStream(uc, uc.getErrorStream()); + InputStream es = wrapStream(uc.getErrorStream()); try { - if (es!=null) - throw (IOException)new IOException(IOUtils.toString(es,"UTF-8")).initCause(e); - else + if (es!=null) { + if (e instanceof FileNotFoundException) { + // pass through 404 Not Found to allow the caller to handle it intelligently + throw (IOException) new FileNotFoundException(IOUtils.toString(es, "UTF-8")).initCause(e); + } else + throw (IOException) new IOException(IOUtils.toString(es, "UTF-8")).initCause(e); + } else throw e; } finally { IOUtils.closeQuietly(es); diff --git a/src/main/java/org/kohsuke/github/SearchResult.java b/src/main/java/org/kohsuke/github/SearchResult.java index 26651510c6..a8a7583a4c 100644 --- a/src/main/java/org/kohsuke/github/SearchResult.java +++ b/src/main/java/org/kohsuke/github/SearchResult.java @@ -9,5 +9,8 @@ abstract class SearchResult { int total_count; boolean incomplete_results; - public abstract T[] getItems(); + /** + * Wraps up the retrieved object and return them. Only called once. + */ + /*package*/ abstract T[] getItems(GitHub root); } diff --git a/src/site/markdown/index.md b/src/site/markdown/index.md index 3c2af01970..ccd0c9cf38 100644 --- a/src/site/markdown/index.md +++ b/src/site/markdown/index.md @@ -41,4 +41,11 @@ This library comes with a pluggable connector to use different HTTP client imple through `HttpConnector`. In particular, this means you can use [OkHttp](http://square.github.io/okhttp/), so we can make use of it's HTTP response cache. Making a conditional request against the GitHub API and receiving a 304 response -[does not count against the rate limit](http://developer.github.com/v3/#conditional-requests). \ No newline at end of file +[does not count against the rate limit](http://developer.github.com/v3/#conditional-requests). + +The following code shows an example of how to set up persistent cache on the disk: + + Cache cache = new Cache(cacheDirectory, 10 * 1024 * 1024); // 10MB cache + GitHub gitHub = GitHubBuilder.fromCredentials() + .withConnector(new OkHttpConnector(new OkUrlFactory(new OkHttpClient().setCache(cache)))) + .build(); diff --git a/src/test/java/org/kohsuke/github/AppTest.java b/src/test/java/org/kohsuke/github/AppTest.java index 6e56bbff89..b5d7c517a7 100755 --- a/src/test/java/org/kohsuke/github/AppTest.java +++ b/src/test/java/org/kohsuke/github/AppTest.java @@ -777,6 +777,56 @@ public void markDown() throws Exception { assertTrue(actual.contains("to fix issue")); } + @Test + public void searchUsers() throws Exception { + PagedSearchIterable r = gitHub.searchUsers().q("tom").repos(">42").followers(">1000").list(); + GHUser u = r.iterator().next(); + System.out.println(u.getName()); + assertNotNull(u.getId()); + assertTrue(r.getTotalCount() > 0); + } + + @Test + public void searchRepositories() throws Exception { + PagedSearchIterable r = gitHub.searchRepositories().q("tetris").language("assembly").sort(GHRepositorySearchBuilder.Sort.STARS).list(); + GHRepository u = r.iterator().next(); + System.out.println(u.getName()); + assertNotNull(u.getId()); + assertEquals("Assembly", u.getLanguage()); + assertTrue(r.getTotalCount() > 0); + } + + @Test + public void searchContent() throws Exception { + PagedSearchIterable r = gitHub.searchContent().q("addClass").in("file").language("js").repo("jquery/jquery").list(); + GHContent c = r.iterator().next(); + System.out.println(c.getName()); + assertNotNull(c.getDownloadUrl()); + assertNotNull(c.getOwner()); + assertEquals("jquery/jquery",c.getOwner().getFullName()); + assertTrue(r.getTotalCount() > 0); + } + + @Test + public void notifications() throws Exception { + boolean found=false; + for (GHThread t : gitHub.listNotifications().nonBlocking(true).read(true)) { + if (!found) { + found = true; + t.markAsRead(); // test this by calling it once on old nofication + } + assertNotNull(t.getTitle()); + assertNotNull(t.getReason()); + + System.out.println(t.getTitle()); + System.out.println(t.getLastReadAt()); + System.out.println(t.getType()); + System.out.println(); + } + assertTrue(found); + gitHub.listNotifications().markAsRead(); + } + private void kohsuke() { String login = getUser().getLogin(); Assume.assumeTrue(login.equals("kohsuke") || login.equals("kohsuke2"));