diff --git a/pom.xml b/pom.xml index bd00bca40c..714567f3e6 100644 --- a/pom.xml +++ b/pom.xml @@ -3,11 +3,11 @@ org.kohsuke pom - 14 + 17 github-api - 1.82 + 1.83 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/ - github-api-1.82 + github-api-1.83 diff --git a/src/main/java/org/kohsuke/github/GHBranch.java b/src/main/java/org/kohsuke/github/GHBranch.java index 4c80db9ef4..f54bea9f47 100644 --- a/src/main/java/org/kohsuke/github/GHBranch.java +++ b/src/main/java/org/kohsuke/github/GHBranch.java @@ -1,13 +1,17 @@ package org.kohsuke.github; -import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; -import org.kohsuke.github.BranchProtection.RequiredStatusChecks; +import static org.kohsuke.github.Previews.LOKI; import java.io.IOException; +import java.net.URL; import java.util.Arrays; import java.util.Collection; -import static org.kohsuke.github.Previews.LOKI; +import org.kohsuke.github.BranchProtection.RequiredStatusChecks; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; /** * A branch in a repository. @@ -22,6 +26,10 @@ public class GHBranch { private String name; private Commit commit; + @JsonProperty("protected") + private boolean protection; + private String protection_url; + public static class Commit { String sha; @@ -45,6 +53,23 @@ public String getName() { return name; } + /** + * Returns true if the push to this branch is restricted via branch protection. + */ + @Preview @Deprecated + public boolean isProtected() { + return protection; + } + + /** + * Returns API URL that deals with the protection of this branch. + */ + @Preview @Deprecated + public URL getProtectionUrl() { + return GitHub.parseURL(protection_url); + } + + /** * The commit that this branch currently points to. */ diff --git a/src/main/java/org/kohsuke/github/GHPermission.java b/src/main/java/org/kohsuke/github/GHPermission.java new file mode 100644 index 0000000000..51a808dfc0 --- /dev/null +++ b/src/main/java/org/kohsuke/github/GHPermission.java @@ -0,0 +1,59 @@ +/* + * The MIT License + * + * Copyright 2016 CloudBees, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +package org.kohsuke.github; + +import java.util.Locale; + +/** + * Permission for a user in a repository. + * @see API + */ +/*package*/ class GHPermission { + + private String permission; + private GHUser user; + + /** + * @return one of {@code admin}, {@code write}, {@code read}, or {@code none} + */ + public String getPermission() { + return permission; + } + + public GHPermissionType getPermissionType() { + return Enum.valueOf(GHPermissionType.class, permission.toUpperCase(Locale.ENGLISH)); + } + + public GHUser getUser() { + return user; + } + + void wrapUp(GitHub root) { + if (user != null) { + user.root = root; + } + } + +} diff --git a/src/main/java/org/kohsuke/github/GHPermissionType.java b/src/main/java/org/kohsuke/github/GHPermissionType.java new file mode 100644 index 0000000000..d3e2bd0909 --- /dev/null +++ b/src/main/java/org/kohsuke/github/GHPermissionType.java @@ -0,0 +1,11 @@ +package org.kohsuke.github; + +/** + * @author Kohsuke Kawaguchi + */ +public enum GHPermissionType { + ADMIN, + WRITE, + READ, + NONE +} diff --git a/src/main/java/org/kohsuke/github/GHRelease.java b/src/main/java/org/kohsuke/github/GHRelease.java index b61dc62b38..96421da415 100644 --- a/src/main/java/org/kohsuke/github/GHRelease.java +++ b/src/main/java/org/kohsuke/github/GHRelease.java @@ -121,9 +121,7 @@ static GHRelease[] wrap(GHRelease[] releases, GHRepository owner) { * Java 7 or greater. Options for fixing this for earlier JVMs can be found here * http://stackoverflow.com/questions/12361090/server-name-indication-sni-on-java but involve more complicated * handling of the HTTP requests to github's API. - * - * @throws IOException - */ + */ public GHAsset uploadAsset(File file, String contentType) throws IOException { Requester builder = new Requester(owner.root); diff --git a/src/main/java/org/kohsuke/github/GHReleaseBuilder.java b/src/main/java/org/kohsuke/github/GHReleaseBuilder.java index 04e368f2b2..b1daac956e 100644 --- a/src/main/java/org/kohsuke/github/GHReleaseBuilder.java +++ b/src/main/java/org/kohsuke/github/GHReleaseBuilder.java @@ -33,7 +33,6 @@ public GHReleaseBuilder body(String body) { * * @param commitish Defaults to the repository’s default branch (usually "master"). Unused if the Git tag * already exists. - * @return */ public GHReleaseBuilder commitish(String commitish) { if (commitish != null) { diff --git a/src/main/java/org/kohsuke/github/GHRepository.java b/src/main/java/org/kohsuke/github/GHRepository.java index 4a0d410621..74fbb4d665 100644 --- a/src/main/java/org/kohsuke/github/GHRepository.java +++ b/src/main/java/org/kohsuke/github/GHRepository.java @@ -449,7 +449,6 @@ public GHPersonSet getCollaborators() throws IOException { * Lists up the collaborators on this repository. * * @return Users - * @throws IOException */ public PagedIterable listCollaborators() throws IOException { return listUsers("collaborators"); @@ -481,6 +480,29 @@ public Set getCollaboratorNames() throws IOException { return r; } + /** + * Obtain permission for a given user in this repository. + * @param user a {@link GHUser#getLogin} + * @throws FileNotFoundException under some conditions (e.g., private repo you can see but are not an admin of); treat as unknown + * @throws HttpException with a 403 under other conditions (e.g., public repo you have no special rights to); treat as unknown + */ + @Deprecated @Preview + public GHPermissionType getPermission(String user) throws IOException { + GHPermission perm = root.retrieve().withPreview(KORRA).to(getApiTailUrl("collaborators/" + user + "/permission"), GHPermission.class); + perm.wrapUp(root); + return perm.getPermissionType(); + } + + /** + * Obtain permission for a given user in this repository. + * @throws FileNotFoundException under some conditions (e.g., private repo you can see but are not an admin of); treat as unknown + * @throws HttpException with a 403 under other conditions (e.g., public repo you have no special rights to); treat as unknown + */ + @Deprecated @Preview + public GHPermissionType getPermission(GHUser u) throws IOException { + return getPermission(u.getLogin()); + } + /** * If this repository belongs to an organization, return a set of teams. */ @@ -818,7 +840,7 @@ public GHTree getTreeRecursive(String sha, int recursive) throws IOException { } /** - * Obtains the metadata & the content of a blob. + * Obtains the metadata & the content of a blob. * *

* This method retrieves the whole content in memory, so beware when you are dealing with large BLOB. diff --git a/src/main/java/org/kohsuke/github/GHTree.java b/src/main/java/org/kohsuke/github/GHTree.java index 59c7cd287e..d52e02a793 100644 --- a/src/main/java/org/kohsuke/github/GHTree.java +++ b/src/main/java/org/kohsuke/github/GHTree.java @@ -30,7 +30,6 @@ public String getSha() { /** * Return an array of entries of the trees - * @return */ public List getTree() { return Collections.unmodifiableList(Arrays.asList(tree)); diff --git a/src/main/java/org/kohsuke/github/GitHub.java b/src/main/java/org/kohsuke/github/GitHub.java index b50b2234ea..df9353e032 100644 --- a/src/main/java/org/kohsuke/github/GitHub.java +++ b/src/main/java/org/kohsuke/github/GitHub.java @@ -23,12 +23,10 @@ */ package org.kohsuke.github; -import static com.fasterxml.jackson.annotation.JsonAutoDetect.Visibility.ANY; -import static com.fasterxml.jackson.annotation.JsonAutoDetect.Visibility.NONE; -import static java.util.logging.Level.FINE; -import static java.net.HttpURLConnection.HTTP_UNAUTHORIZED; -import static org.kohsuke.github.Previews.DRAX; - +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.introspect.VisibilityChecker.Std; +import com.infradna.tool.bridge_method_injector.WithBridgeMethods; import java.io.ByteArrayInputStream; import java.io.FileNotFoundException; import java.io.IOException; @@ -49,17 +47,19 @@ import java.util.Map; import java.util.Set; import java.util.TimeZone; - +import java.util.concurrent.TimeUnit; +import java.util.logging.Level; +import java.util.logging.Logger; +import javax.annotation.CheckForNull; +import javax.annotation.Nonnull; import org.apache.commons.codec.Charsets; import org.apache.commons.codec.binary.Base64; -import com.fasterxml.jackson.databind.DeserializationFeature; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.introspect.VisibilityChecker.Std; -import com.infradna.tool.bridge_method_injector.WithBridgeMethods; - -import javax.annotation.Nonnull; -import java.util.logging.Logger; +import static com.fasterxml.jackson.annotation.JsonAutoDetect.Visibility.ANY; +import static com.fasterxml.jackson.annotation.JsonAutoDetect.Visibility.NONE; +import static java.net.HttpURLConnection.HTTP_UNAUTHORIZED; +import static java.util.logging.Level.FINE; +import static org.kohsuke.github.Previews.DRAX; /** * Root of the GitHub API. @@ -90,6 +90,10 @@ public class GitHub { private HttpConnector connector = HttpConnector.DEFAULT; + private final Object headerRateLimitLock = new Object(); + private GHRateLimit headerRateLimit = null; + private volatile GHRateLimit rateLimit = null; + /** * Creates a client API root object. * @@ -254,6 +258,10 @@ public HttpConnector getConnector() { return connector; } + public String getApiUrl() { + return apiUrl; + } + /** * Sets the custom connector used to make requests to GitHub. */ @@ -287,17 +295,61 @@ public void setConnector(HttpConnector connector) { */ public GHRateLimit getRateLimit() throws IOException { try { - return retrieve().to("/rate_limit", JsonRateLimit.class).rate; + return rateLimit = retrieve().to("/rate_limit", JsonRateLimit.class).rate; } catch (FileNotFoundException e) { // GitHub Enterprise doesn't have the rate limit, so in that case // return some big number that's not too big. // see issue #78 GHRateLimit r = new GHRateLimit(); r.limit = r.remaining = 1000000; - long hours = 1000L * 60 * 60; - r.reset = new Date(System.currentTimeMillis() + 1 * hours ); - return r; + long hour = 60L * 60L; // this is madness, storing the date as seconds in a Date object + r.reset = new Date((System.currentTimeMillis() + hour) / 1000L ); + return rateLimit = r; + } + } + + /*package*/ void updateRateLimit(@Nonnull GHRateLimit observed) { + synchronized (headerRateLimitLock) { + if (headerRateLimit == null + || headerRateLimit.getResetDate().getTime() < observed.getResetDate().getTime() + || headerRateLimit.remaining > observed.remaining) { + headerRateLimit = observed; + LOGGER.log(Level.INFO, "Rate limit now: {0}", headerRateLimit); + } + } + } + + /** + * Returns the most recently observed rate limit data or {@code null} if either there is no rate limit + * (for example GitHub Enterprise) or if no requests have been made. + * + * @return the most recently observed rate limit data or {@code null}. + */ + @CheckForNull + public GHRateLimit lastRateLimit() { + synchronized (headerRateLimitLock) { + return headerRateLimit; + } + } + + /** + * Gets the current rate limit while trying not to actually make any remote requests unless absolutely necessary. + * + * @return the current rate limit data. + * @throws IOException if we couldn't get the current rate limit data. + */ + @Nonnull + public GHRateLimit rateLimit() throws IOException { + synchronized (headerRateLimitLock) { + if (headerRateLimit != null) { + return headerRateLimit; + } + } + GHRateLimit rateLimit = this.rateLimit; + if (rateLimit == null || rateLimit.getResetDate().getTime() < System.currentTimeMillis()) { + rateLimit = getRateLimit(); } + return rateLimit; } /** @@ -416,7 +468,6 @@ protected void wrapUp(GHUser[] page) { * * @param key The license key provided from the API * @return The license details - * @throws IOException * @see GHLicense#getKey() */ @Preview @Deprecated diff --git a/src/main/java/org/kohsuke/github/Previews.java b/src/main/java/org/kohsuke/github/Previews.java index f95a28b42b..238b062b8b 100644 --- a/src/main/java/org/kohsuke/github/Previews.java +++ b/src/main/java/org/kohsuke/github/Previews.java @@ -7,4 +7,5 @@ static final String LOKI = "application/vnd.github.loki-preview+json"; static final String DRAX = "application/vnd.github.drax-preview+json"; static final String SQUIRREL_GIRL = "application/vnd.github.squirrel-girl-preview"; + static final String KORRA = "application/vnd.github.korra-preview"; } diff --git a/src/main/java/org/kohsuke/github/Requester.java b/src/main/java/org/kohsuke/github/Requester.java index 70c48940c0..633e236dec 100644 --- a/src/main/java/org/kohsuke/github/Requester.java +++ b/src/main/java/org/kohsuke/github/Requester.java @@ -25,8 +25,6 @@ import com.fasterxml.jackson.databind.JsonMappingException; import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; -import org.apache.commons.io.IOUtils; - import java.io.FileNotFoundException; import java.io.IOException; import java.io.InputStream; @@ -42,6 +40,7 @@ import java.net.URLEncoder; import java.util.ArrayList; import java.util.Collection; +import java.util.Date; import java.util.HashMap; import java.util.Iterator; import java.util.LinkedHashMap; @@ -49,16 +48,18 @@ import java.util.Locale; import java.util.Map; import java.util.NoSuchElementException; +import java.util.logging.Level; import java.util.logging.Logger; import java.util.regex.Matcher; import java.util.regex.Pattern; import java.util.zip.GZIPInputStream; - import javax.annotation.WillClose; +import org.apache.commons.io.IOUtils; +import org.apache.commons.lang.StringUtils; import static java.util.Arrays.asList; import static java.util.logging.Level.FINE; -import static org.kohsuke.github.GitHub.*; +import static org.kohsuke.github.GitHub.MAPPER; /** * A builder pattern for making HTTP call and parsing its output. @@ -281,6 +282,8 @@ private T _to(String tailApiUrl, Class type, T instance) throws IOExcepti return result; } catch (IOException e) { handleApiError(e); + } finally { + noteRateLimit(tailApiUrl); } } } @@ -299,6 +302,8 @@ public int asHttpStatusCode(String tailApiUrl) throws IOException { return uc.getResponseCode(); } catch (IOException e) { handleApiError(e); + } finally { + noteRateLimit(tailApiUrl); } } } @@ -313,6 +318,59 @@ public InputStream asStream(String tailApiUrl) throws IOException { return wrapStream(uc.getInputStream()); } catch (IOException e) { handleApiError(e); + } finally { + noteRateLimit(tailApiUrl); + } + } + } + + private void noteRateLimit(String tailApiUrl) { + if ("/rate_limit".equals(tailApiUrl)) { + // the rate_limit API is "free" + return; + } + if (tailApiUrl.startsWith("/search")) { + // the search API uses a different rate limit + return; + } + String limit = uc.getHeaderField("X-RateLimit-Limit"); + if (StringUtils.isBlank(limit)) { + // if we are missing a header, return fast + return; + } + String remaining = uc.getHeaderField("X-RateLimit-Remaining"); + if (StringUtils.isBlank(remaining)) { + // if we are missing a header, return fast + return; + } + String reset = uc.getHeaderField("X-RateLimit-Reset"); + if (StringUtils.isBlank(reset)) { + // if we are missing a header, return fast + return; + } + GHRateLimit observed = new GHRateLimit(); + try { + observed.limit = Integer.parseInt(limit); + } catch (NumberFormatException e) { + if (LOGGER.isLoggable(Level.FINEST)) { + LOGGER.log(Level.FINEST, "Malformed X-RateLimit-Limit header value " + limit, e); + } + return; + } + try { + observed.remaining = Integer.parseInt(remaining); + } catch (NumberFormatException e) { + if (LOGGER.isLoggable(Level.FINEST)) { + LOGGER.log(Level.FINEST, "Malformed X-RateLimit-Remaining header value " + remaining, e); + } + return; + } + try { + observed.reset = new Date(Long.parseLong(reset)); // this is madness, storing the date as seconds + root.updateRateLimit(observed); + } catch (NumberFormatException e) { + if (LOGGER.isLoggable(Level.FINEST)) { + LOGGER.log(Level.FINEST, "Malformed X-RateLimit-Reset header value " + reset, e); } } } @@ -382,7 +440,7 @@ private boolean isMethodWithBody() { } try { - return new PagingIterator(type, root.getApiURL(s.toString())); + return new PagingIterator(type, tailApiUrl, root.getApiURL(s.toString())); } catch (IOException e) { throw new Error(e); } @@ -391,6 +449,7 @@ private boolean isMethodWithBody() { class PagingIterator implements Iterator { private final Class type; + private final String tailApiUrl; /** * The next batch to be returned from {@link #next()}. @@ -402,9 +461,10 @@ class PagingIterator implements Iterator { */ private URL url; - PagingIterator(Class type, URL url) { - this.url = url; + PagingIterator(Class type, String tailApiUrl, URL url) { this.type = type; + this.tailApiUrl = tailApiUrl; + this.url = url; } public boolean hasNext() { @@ -438,6 +498,8 @@ private void fetch() { return; } catch (IOException e) { handleApiError(e); + } finally { + noteRateLimit(tailApiUrl); } } } catch (IOException e) { @@ -598,11 +660,16 @@ private InputStream wrapStream(InputStream in) throws IOException { InputStream es = wrapStream(uc.getErrorStream()); try { if (es!=null) { + String error = IOUtils.toString(es, "UTF-8"); 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); + throw (IOException) new FileNotFoundException(error).initCause(e); + } else if (e instanceof HttpException) { + HttpException http = (HttpException) e; + throw (IOException) new HttpException(error, http.getResponseCode(), http.getResponseMessage(), http.getUrl(), e); + } else { + throw (IOException) new IOException(error).initCause(e); + } } else throw e; } finally { diff --git a/src/test/java/org/kohsuke/github/AbstractGitHubApiTestBase.java b/src/test/java/org/kohsuke/github/AbstractGitHubApiTestBase.java index f1753481e7..f32087c0d1 100644 --- a/src/test/java/org/kohsuke/github/AbstractGitHubApiTestBase.java +++ b/src/test/java/org/kohsuke/github/AbstractGitHubApiTestBase.java @@ -1,10 +1,12 @@ package org.kohsuke.github; import org.junit.Assert; +import org.junit.Assume; import org.junit.Before; import org.kohsuke.randname.RandomNameGenerator; import java.io.File; +import java.io.IOException; /** * @author Kohsuke Kawaguchi @@ -25,5 +27,18 @@ public void setUp() throws Exception { } } + protected GHUser getUser() { + try { + return gitHub.getMyself(); + } catch (IOException e) { + throw new RuntimeException(e.getMessage(), e); + } + } + + protected void kohsuke() { + String login = getUser().getLogin(); + Assume.assumeTrue(login.equals("kohsuke") || login.equals("kohsuke2")); + } + protected static final RandomNameGenerator rnd = new RandomNameGenerator(); } diff --git a/src/test/java/org/kohsuke/github/AppTest.java b/src/test/java/org/kohsuke/github/AppTest.java index e1d513d739..a20b2eccf3 100755 --- a/src/test/java/org/kohsuke/github/AppTest.java +++ b/src/test/java/org/kohsuke/github/AppTest.java @@ -173,14 +173,6 @@ private GHRepository getTestRepository() throws IOException { return repository; } - private GHUser getUser() { - try { - return gitHub.getMyself(); - } catch (IOException e) { - throw new RuntimeException(e.getMessage(), e); - } - } - @Test public void testListIssues() throws IOException { GHUser u = getUser(); @@ -928,9 +920,4 @@ private void assertBlobContent(InputStream is) throws Exception { assertThat(content,containsString("FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR")); assertThat(content.length(),is(1104)); } - - private void kohsuke() { - String login = getUser().getLogin(); - Assume.assumeTrue(login.equals("kohsuke") || login.equals("kohsuke2")); - } } diff --git a/src/test/java/org/kohsuke/github/RepositoryTest.java b/src/test/java/org/kohsuke/github/RepositoryTest.java index dae2892a45..9955c34956 100644 --- a/src/test/java/org/kohsuke/github/RepositoryTest.java +++ b/src/test/java/org/kohsuke/github/RepositoryTest.java @@ -3,6 +3,7 @@ import org.junit.Test; import org.kohsuke.github.GHRepository.Contributor; +import java.io.FileNotFoundException; import java.io.IOException; /** @@ -40,6 +41,33 @@ public void listContributors() throws IOException { assertTrue(kohsuke); } + @Test + public void getPermission() throws Exception { + kohsuke(); + GHRepository r = gitHub.getRepository("github-api-test-org/test-permission"); + assertEquals(GHPermissionType.ADMIN, r.getPermission("kohsuke")); + assertEquals(GHPermissionType.READ, r.getPermission("dude")); + r = gitHub.getOrganization("apache").getRepository("groovy"); + try { + r.getPermission("jglick"); + fail(); + } catch (HttpException x) { + x.printStackTrace(); // good + assertEquals(403, x.getResponseCode()); + } + + if (false) { + // can't easily test this; there's no private repository visible to the test user + r = gitHub.getOrganization("cloudbees").getRepository("private-repo-not-writable-by-me"); + try { + r.getPermission("jglick"); + fail(); + } catch (FileNotFoundException x) { + x.printStackTrace(); // good + } + } + } + private GHRepository getRepository() throws IOException { return gitHub.getOrganization("github-api-test-org").getRepository("jenkins"); }