blob: ab0496d8c538dc670eb337cfb2a66644d469923f [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.ignite.githubservice;
import com.google.common.base.Preconditions;
import com.google.common.base.Strings;
import com.google.gson.Gson;
import com.google.gson.reflect.TypeToken;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.UncheckedIOException;
import java.net.ConnectException;
import java.net.MalformedURLException;
import java.net.URLEncoder;
import java.net.SocketException;
import java.net.SocketTimeoutException;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.StringTokenizer;
import java.util.concurrent.ThreadLocalRandom;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.AtomicReference;
import java.util.concurrent.locks.LockSupport;
import org.apache.ignite.ci.github.GitHubBranchShort;
import org.apache.ignite.ci.github.GitHubIssueComment;
import org.apache.ignite.ci.github.GitHubUser;
import org.apache.ignite.ci.github.PullRequest;
import org.apache.ignite.tcbot.common.conf.IDataSourcesConfigSupplier;
import org.apache.ignite.tcbot.common.conf.IGitHubConfig;
import org.apache.ignite.tcbot.common.interceptor.AutoProfiling;
import org.apache.ignite.tcbot.common.util.HttpUtil;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import static com.google.common.base.Strings.isNullOrEmpty;
class GitHubConnectionImpl implements IGitHubConnection {
/** Logger. */
private static final Logger logger = LoggerFactory.getLogger(GitHubConnectionImpl.class);
/** */
private static final String INTEGRATION_TEST_PROFILE = "integration-test";
/** */
private static final String PROFILE_PROPERTY = "tcbot.profile";
/** Config. */
private final IDataSourcesConfigSupplier cfg;
/** Service (server) code. */
private String srvCode;
/** GitHub read attempts. */
private static final int READ_ATTEMPTS = 3;
/** Initial retry backoff. */
private static final long INITIAL_RETRY_BACKOFF_MS = 500;
/** Retry jitter. */
private static final long RETRY_JITTER_MS = 250;
/** Max retry backoff. */
private static final long MAX_RETRY_BACKOFF_MS = TimeUnit.SECONDS.toMillis(30);
private static AtomicLong lastRq = new AtomicLong();
GitHubConnectionImpl(IDataSourcesConfigSupplier cfg) {
this.cfg = cfg;
}
/**
* @param linkRspHdrVal Value of Link response HTTP header.
*/
@Nullable public static String parseNextLinkFromLinkRspHeader(String linkRspHdrVal) {
String nextLink = null;
StringTokenizer tokenizer = new StringTokenizer(linkRspHdrVal, ",");
for (; tokenizer.hasMoreTokens(); ) {
String tok = tokenizer.nextToken();
List<String> linkAndRel = new ArrayList<>();
StringTokenizer tokenizerForLink = new StringTokenizer(tok, ";");
for (; tokenizerForLink.hasMoreTokens(); ) {
String nextTok = tokenizerForLink.nextToken();
linkAndRel.add(nextTok);
}
if (linkAndRel.size() >= 2) {
String linkType = linkAndRel.get(1);
if ("rel=\"next\"".equals(linkType.trim()))
nextLink = linkAndRel.get(0).trim();
}
}
if (!isNullOrEmpty(nextLink)) {
if (nextLink.startsWith("<"))
nextLink = nextLink.substring(1);
if (nextLink.endsWith(">"))
nextLink = nextLink.substring(0, nextLink.length() - 1);
}
return nextLink;
}
/** {@inheritDoc} */
@Override public void init(String srvCode) {
Preconditions.checkState(this.srvCode == null, "Server re-init is not supported");
this.srvCode = srvCode;
}
/** {@inheritDoc} */
@AutoProfiling
@Override public PullRequest getPullRequest(Integer id) {
String gitApiUrl = getApiUrlMandatory();
String pr = gitApiUrl + "pulls/" + id;
for (int attempt = 1; attempt <= READ_ATTEMPTS; attempt++) {
try (InputStream is = sendGetToGit(pr, null)) {
InputStreamReader reader = new InputStreamReader(is);
return new Gson().fromJson(reader, PullRequest.class);
}
catch (IOException e) {
if (shouldRetry(e, attempt)) {
long backoffMs = retryBackoffMs(attempt);
logger.warn("Failed to read GitHub pull request, will retry " +
"[srv={}, pr={}, url={}, attempt={}/{}, backoffMs={}]",
srvCode, id, pr, attempt, READ_ATTEMPTS, backoffMs, e);
LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(backoffMs));
continue;
}
throw new UncheckedIOException("Failed to read GitHub pull request [srv=" + srvCode +
", pr=" + id + ", url=" + pr + ", attempt=" + attempt + '/' + READ_ATTEMPTS + ']', e);
}
}
throw new IllegalStateException("Unreachable");
}
/** {@inheritDoc} */
@AutoProfiling
@Override public GitHubUser getUser(String login) {
String url = userApiUrl(getApiUrlMandatory(), login);
for (int attempt = 1; attempt <= READ_ATTEMPTS; attempt++) {
try (InputStream is = sendGetToGit(url, null)) {
InputStreamReader reader = new InputStreamReader(is);
return new Gson().fromJson(reader, GitHubUser.class);
}
catch (IOException e) {
if (shouldRetry(e, attempt)) {
long backoffMs = retryBackoffMs(attempt);
logger.warn("Failed to read GitHub user, will retry " +
"[srv={}, login={}, url={}, attempt={}/{}, backoffMs={}]",
srvCode, login, url, attempt, READ_ATTEMPTS, backoffMs, e);
LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(backoffMs));
continue;
}
throw new UncheckedIOException("Failed to read GitHub user [srv=" + srvCode +
", login=" + login + ", url=" + url + ", attempt=" + attempt + '/' + READ_ATTEMPTS + ']', e);
}
}
throw new IllegalStateException("Unreachable");
}
/** */
@Nullable private String notifyGitError(String url, String body) {
try {
HttpUtil.sendPostAsStringToGit(config().gitAuthTok(), url, body);
return null;
}
catch (IOException e) {
String err = e.getClass().getSimpleName() + ": " + e.getMessage();
logger.error("Failed to notify Git [errMsg={}]", err, e);
return err;
}
catch (RuntimeException e) {
String err = e.getClass().getSimpleName() + ": " + e.getMessage();
logger.error("Failed to notify Git [errMsg={}]", err, e);
return err;
}
}
/** {@inheritDoc} */
@AutoProfiling
@Override public List<GitHubIssueComment> getIssueComments(int prNum) {
List<GitHubIssueComment> res = new ArrayList<>();
AtomicReference<String> outLinkNext = new AtomicReference<>();
String url = getApiUrlMandatory() + "issues/" + prNum + "/comments?per_page=100";
do {
HashMap<String, String> rspHeaders = new HashMap<>();
outLinkNext.set(null);
rspHeaders.put("Link", null);
TypeToken<ArrayList<GitHubIssueComment>> tok = new TypeToken<ArrayList<GitHubIssueComment>>() {
};
res.addAll(readOnePage(outLinkNext, url, rspHeaders, tok));
url = outLinkNext.get();
}
while (url != null);
return res;
}
/** {@inheritDoc} */
@AutoProfiling
@Override public String postIssueCommentError(int prNum, String body) {
String url = getApiUrlMandatory() + "issues/" + prNum + "/comments";
HashMap<String, String> req = new HashMap<>();
req.put("body", body);
String json = new Gson().toJson(req);
return notifyGitError(url, json);
}
/** {@inheritDoc} */
@AutoProfiling
@Override public List<PullRequest> getPullRequestsPage(@Nullable String fullUrl,
@Nullable AtomicReference<String> outLinkNext) {
String gitApiUrl = getApiUrlMandatory();
String url = fullUrl != null ? fullUrl : gitApiUrl + "pulls?sort=updated&direction=desc";
HashMap<String, String> rspHeaders = new HashMap<>();
if (outLinkNext != null) {
outLinkNext.set(null);
rspHeaders.put("Link", null); // requesting header
}
TypeToken<ArrayList<PullRequest>> tok = new TypeToken<ArrayList<PullRequest>>() {
};
return readOnePage(outLinkNext, url, rspHeaders, tok);
}
@Nonnull public String getApiUrlMandatory() {
String gitApiUrl = config().gitApiUrl();
Preconditions.checkState(!isNullOrEmpty(gitApiUrl), "Git API URL is not configured for this server.");
return gitApiUrl;
}
/**
* @param gitApiUrl Repository API URL.
* @param login GitHub login.
*/
static String userApiUrl(String gitApiUrl, String login) {
Preconditions.checkState(!isNullOrEmpty(gitApiUrl), "Git API URL is not configured.");
Preconditions.checkState(!isNullOrEmpty(login), "GitHub login is empty.");
int reposIdx = gitApiUrl.indexOf("/repos/");
Preconditions.checkState(reposIdx >= 0, "Unsupported Git API URL: " + gitApiUrl);
String apiRoot = gitApiUrl.substring(0, reposIdx + 1);
String encodedLogin = URLEncoder.encode(login, StandardCharsets.UTF_8);
return apiRoot + "users/" + encodedLogin;
}
/** {@inheritDoc} */
@AutoProfiling
@Override public List<GitHubBranchShort> getBranchesPage(@Nullable String fullUrl,
@Nonnull AtomicReference<String> outLinkNext) {
String url = fullUrl != null ? fullUrl : getApiUrlMandatory() + "branches";
HashMap<String, String> rspHeaders = new HashMap<>();
outLinkNext.set(null);
rspHeaders.put("Link", null); // requesting header
TypeToken<ArrayList<GitHubBranchShort>> tok = new TypeToken<ArrayList<GitHubBranchShort>>() {
};
return this.readOnePage(outLinkNext, url, rspHeaders, tok);
}
public <T> List<T> readOnePage(@Nullable AtomicReference<String> outLinkNext,
String url, HashMap<String, String> rspHeaders, TypeToken<ArrayList<T>> typeTok) {
for (int attempt = 1; attempt <= READ_ATTEMPTS; attempt++) {
if (rspHeaders.containsKey("Link"))
rspHeaders.put("Link", null);
try (InputStream stream = sendGetToGit(url, rspHeaders)) {
InputStreamReader reader = new InputStreamReader(stream);
List<T> list = new Gson().fromJson(reader, typeTok.getType());
String link = rspHeaders.get("Link");
if (link != null) {
String nextLink = parseNextLinkFromLinkRspHeader(link);
if (nextLink != null && outLinkNext != null)
outLinkNext.set(nextLink);
}
logger.info("Processing Github link: " + link);
return list;
}
catch (IOException e) {
if (shouldRetry(e, attempt)) {
long backoffMs = retryBackoffMs(attempt);
logger.warn("Failed to read GitHub page, will retry [srv={}, url={}, attempt={}/{}, backoffMs={}]",
srvCode, url, attempt, READ_ATTEMPTS, backoffMs, e);
LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(backoffMs));
continue;
}
throw new UncheckedIOException("Failed to read GitHub page [srv=" + srvCode +
", url=" + url + ", link=" + rspHeaders.get("Link") +
", attempt=" + attempt + '/' + READ_ATTEMPTS + ']', e);
}
}
throw new IllegalStateException("Unreachable");
}
/**
* @param e Exception.
* @param attempt Attempt.
*/
private boolean shouldRetry(IOException e, int attempt) {
return attempt < READ_ATTEMPTS && isTemporaryTransportFailure(e);
}
/**
* @param e Exception.
*/
private boolean isTemporaryTransportFailure(Throwable e) {
for (Throwable th = e; th != null; th = th.getCause()) {
if (th instanceof ConnectException || th instanceof SocketException || th instanceof SocketTimeoutException)
return true;
}
return false;
}
/**
* @param attempt Attempt.
*/
private long retryBackoffMs(int attempt) {
long base = INITIAL_RETRY_BACKOFF_MS << (attempt - 1);
long backoff = base + ThreadLocalRandom.current().nextLong(RETRY_JITTER_MS + 1);
return Math.min(backoff, MAX_RETRY_BACKOFF_MS);
}
protected InputStream sendGetToGit(String url, HashMap<String, String> rspHeaders) throws IOException {
final String tok = config().gitAuthTok();
velocityControl(tok);
return HttpUtil.sendGetToGit(tok, url, rspHeaders);
}
//https://developer.github.com/v3/#rate-limiting
@AutoProfiling
protected void velocityControl(String tok) {
if (isIntegrationTestLoopbackGitHub())
return;
final int reqPerHour = Strings.isNullOrEmpty(tok) ? 60 : 5000;
final long nanosInHour = Duration.ofHours(1).toNanos();
final long waitBeforeNextReq = nanosInHour / reqPerHour;
boolean win;
do {
final long lastRq = this.lastRq.get();
final long curNs = System.nanoTime();
if (lastRq != 0) {
final long nanosPassed = curNs - lastRq;
final long nsWait = waitBeforeNextReq - nanosPassed;
if (nsWait > 0)
LockSupport.parkNanos(nsWait);
}
win = this.lastRq.compareAndSet(lastRq, curNs);
} while (!win);
}
/** */
private boolean isIntegrationTestLoopbackGitHub() {
if (!INTEGRATION_TEST_PROFILE.equals(System.getProperty(PROFILE_PROPERTY)))
return false;
try {
return isLoopbackHost(new URL(getApiUrlMandatory()).getHost());
}
catch (MalformedURLException e) {
return false;
}
}
/**
* @param host URL host.
*/
private static boolean isLoopbackHost(String host) {
if (host == null)
return false;
String normalized = host.toLowerCase();
return "localhost".equals(normalized)
|| "127.0.0.1".equals(normalized)
|| normalized.startsWith("127.")
|| "::1".equals(normalized)
|| "0:0:0:0:0:0:0:1".equals(normalized)
|| "[::1]".equals(normalized)
|| "[0:0:0:0:0:0:0:1]".equals(normalized);
}
/** {@inheritDoc} */
@Override public IGitHubConfig config() {
Preconditions.checkNotNull(srvCode);
return cfg.getGitConfig(srvCode);
}
}