1#' Query the GitHub API 2#' 3#' This is an extremely minimal client. You need to know the API 4#' to be able to use this client. All this function does is: 5#' * Try to substitute each listed parameter into `endpoint`, using the 6#' `{parameter}` notation. 7#' * If a GET request (the default), then add all other listed parameters 8#' as query parameters. 9#' * If not a GET request, then send the other parameters in the request 10#' body, as JSON. 11#' * Convert the response to an R list using [jsonlite::fromJSON()]. 12#' 13#' @param endpoint GitHub API endpoint. Must be one of the following forms: 14#' * `METHOD path`, e.g. `GET /rate_limit`, 15#' * `path`, e.g. `/rate_limit`, 16#' * `METHOD url`, e.g. `GET https://api.github.com/rate_limit`, 17#' * `url`, e.g. `https://api.github.com/rate_limit`. 18#' 19#' If the method is not supplied, will use `.method`, which defaults 20#' to `"GET"`. 21#' @param ... Name-value pairs giving API parameters. Will be matched into 22#' `endpoint` placeholders, sent as query parameters in GET requests, and as a 23#' JSON body of POST requests. If there is only one unnamed parameter, and it 24#' is a raw vector, then it will not be JSON encoded, but sent as raw data, as 25#' is. This can be used for example to add assets to releases. Named `NULL` 26#' values are silently dropped. For GET requests, named `NA` values trigger an 27#' error. For other methods, named `NA` values are included in the body of the 28#' request, as JSON `null`. 29#' @param per_page Number of items to return per page. If omitted, 30#' will be substituted by `max(.limit, 100)` if `.limit` is set, 31#' otherwise determined by the API (never greater than 100). 32#' @param .destfile Path to write response to disk. If `NULL` (default), 33#' response will be processed and returned as an object. If path is given, 34#' response will be written to disk in the form sent. 35#' @param .overwrite If `.destfile` is provided, whether to overwrite an 36#' existing file. Defaults to `FALSE`. 37#' @param .token Authentication token. Defaults to `GITHUB_PAT` or 38#' `GITHUB_TOKEN` environment variables, in this order if any is set. 39#' See [gh_token()] if you need more flexibility, e.g. different tokens 40#' for different GitHub Enterprise deployments. 41#' @param .api_url Github API url (default: <https://api.github.com>). Used 42#' if `endpoint` just contains a path. Defaults to `GITHUB_API_URL` 43#' environment variable if set. 44#' @param .method HTTP method to use if not explicitly supplied in the 45#' `endpoint`. 46#' @param .limit Number of records to return. This can be used 47#' instead of manual pagination. By default it is `NULL`, 48#' which means that the defaults of the GitHub API are used. 49#' You can set it to a number to request more (or less) 50#' records, and also to `Inf` to request all records. 51#' Note, that if you request many records, then multiple GitHub 52#' API calls are used to get them, and this can take a potentially 53#' long time. 54#' @param .accept The value of the `Accept` HTTP header. Defaults to 55#' `"application/vnd.github.v3+json"` . If `Accept` is given in 56#' `.send_headers`, then that will be used. This parameter can be used to 57#' provide a custom media type, in order to access a preview feature of 58#' the API. 59#' @param .send_headers Named character vector of header field values 60#' (except `Authorization`, which is handled via `.token`). This can be 61#' used to override or augment the default `User-Agent` header: 62#' `"https://github.com/r-lib/gh"`. 63#' @param .progress Whether to show a progress indicator for calls that 64#' need more than one HTTP request. 65#' @param .params Additional list of parameters to append to `...`. 66#' It is easier to use this than `...` if you have your parameters in 67#' a list already. 68#' 69#' @return Answer from the API as a `gh_response` object, which is also a 70#' `list`. Failed requests will generate an R error. Requests that 71#' generate a raw response will return a raw vector. 72#' 73#' @export 74#' @seealso [gh_gql()] if you want to use the GitHub GraphQL API, 75#' [gh_whoami()] for details on GitHub API token management. 76#' @examplesIf identical(Sys.getenv("IN_PKGDOWN"), "true") 77#' ## Repositories of a user, these are equivalent 78#' gh("/users/hadley/repos") 79#' gh("/users/{username}/repos", username = "hadley") 80#' 81#' ## Starred repositories of a user 82#' gh("/users/hadley/starred") 83#' gh("/users/{username}/starred", username = "hadley") 84#' 85#' @examplesIf FALSE 86#' ## Create a repository, needs a token in GITHUB_PAT (or GITHUB_TOKEN) 87#' ## environment variable 88#' gh("POST /user/repos", name = "foobar") 89#' 90#' @examplesIf identical(Sys.getenv("IN_PKGDOWN"), "true") 91#' ## Issues of a repository 92#' gh("/repos/hadley/dplyr/issues") 93#' gh("/repos/{owner}/{repo}/issues", owner = "hadley", repo = "dplyr") 94#' 95#' ## Automatic pagination 96#' users <- gh("/users", .limit = 50) 97#' length(users) 98#' 99#' @examplesIf FALSE 100#' ## Access developer preview of Licenses API (in preview as of 2015-09-24) 101#' gh("/licenses") # used to error code 415 102#' gh("/licenses", .accept = "application/vnd.github.drax-preview+json") 103#' 104#' @examplesIf FALSE 105#' ## Access Github Enterprise API 106#' ## Use GITHUB_API_URL environment variable to change the default. 107#' gh("/user/repos", type = "public", .api_url = "https://github.foobar.edu/api/v3") 108#' 109#' @examplesIf FALSE 110#' ## Use I() to force body part to be sent as an array, even if length 1 111#' ## This works whether assignees has length 1 or > 1 112#' assignees <- "gh_user" 113#' assignees <- c("gh_user1", "gh_user2") 114#' gh("PATCH /repos/OWNER/REPO/issues/1", assignees = I(assignees)) 115#' 116#' @examplesIf FALSE 117#' ## There are two ways to send JSON data. One is that you supply one or 118#' ## more objects that will be converted to JSON automatically via 119#' ## jsonlite::toJSON(). In this case sometimes you need to use 120#' ## jsonlite::unbox() because fromJSON() creates lists from scalar vectors 121#' ## by default. The Content-Type header is automatically added in this 122#' ## case. For example this request turns on GitHub Pages, using this 123#' ## API: https://docs.github.com/v3/repos/pages/#enable-a-pages-site 124#' 125#' gh::gh( 126#' "POST /repos/{owner}/{repo}/pages", 127#' owner = "gaborcsardi", 128#' repo = "playground", 129#' source = list( 130#' branch = jsonlite::unbox("master"), 131#' path = jsonlite::unbox("/docs") 132#' ), 133#' .send_headers = c(Accept = "application/vnd.github.switcheroo-preview+json") 134#' ) 135#' 136#' ## The second way is to handle the JSON encoding manually, and supply it 137#' ## as a raw vector in an unnamed argument, and also a Content-Type header: 138#' 139#' body <- '{ "source": { "branch": "master", "path": "/docs" } }' 140#' gh::gh( 141#' "POST /repos/{owner}/{repo}/pages", 142#' owner = "gaborcsardi", 143#' repo = "playground", 144#' charToRaw(body), 145#' .send_headers = c( 146#' Accept = "application/vnd.github.switcheroo-preview+json", 147#' "Content-Type" = "application/json" 148#' ) 149#' ) 150gh <- function(endpoint, ..., per_page = NULL, .token = NULL, .destfile = NULL, 151 .overwrite = FALSE, .api_url = NULL, .method = "GET", 152 .limit = NULL, .accept = "application/vnd.github.v3+json", 153 .send_headers = NULL, .progress = TRUE, .params = list()) { 154 155 params <- c(list(...), .params) 156 params <- drop_named_nulls(params) 157 158 if (is.null(per_page)) { 159 if (!is.null(.limit)) { 160 per_page <- max(min(.limit, 100), 1) 161 } 162 } 163 164 if (!is.null(per_page)) { 165 params <- c(params, list(per_page = per_page)) 166 } 167 168 req <- gh_build_request(endpoint = endpoint, params = params, 169 token = .token, destfile = .destfile, 170 overwrite = .overwrite, accept = .accept, 171 send_headers = .send_headers, 172 api_url = .api_url, method = .method) 173 174 175 if (req$method == "GET") check_named_nas(params) 176 177 if (.progress) prbr <- make_progress_bar(req) 178 179 raw <- gh_make_request(req) 180 181 res <- gh_process_response(raw) 182 len <- gh_response_length(res) 183 184 while (!is.null(.limit) && len < .limit && gh_has_next(res)) { 185 if (.progress) update_progress_bar(prbr, res) 186 res2 <- gh_next(res) 187 188 if (!is.null(names(res2)) && identical(names(res), names(res2))) { 189 res3 <- mapply( # Handle named array case 190 function(x, y, n) { # e.g. GET /search/repositories 191 z <- c(x, y) 192 atm <- is.atomic(z) 193 if (atm && n %in% c("total_count", "incomplete_results")) { 194 y 195 } else if (atm) { 196 unique(z) 197 } else { 198 z 199 } 200 }, 201 res, res2, names(res), 202 SIMPLIFY = FALSE 203 ) 204 } else { # Handle unnamed array case 205 res3 <- c(res, res2) # e.g. GET /orgs/:org/invitations 206 } 207 208 len <- len + gh_response_length(res2) 209 210 attributes(res3) <- attributes(res2) 211 res <- res3 212 } 213 214 # We only subset for a non-named response. 215 if (! is.null(.limit) && len > .limit && 216 ! "total_count" %in% names(res) && length(res) == len) { 217 res_attr <- attributes(res) 218 res <- res[seq_len(.limit)] 219 attributes(res) <- res_attr 220 } 221 222 res 223} 224 225gh_response_length <- function(res) { 226 if (!is.null(names(res)) && length(res) > 1 && 227 names(res)[1] == "total_count") { 228 # Ignore total_count, incomplete_results, repository_selection 229 # and take the first list element to get the length 230 lst <- vapply(res, is.list, logical(1)) 231 nm <- setdiff( 232 names(res), 233 c("total_count", "incomplete_results", "repository_selection") 234 ) 235 tgt <- which(lst[nm])[1] 236 if (is.na(tgt)) length(res) else length(res[[ nm[tgt] ]]) 237 } else { 238 length(res) 239 } 240} 241 242gh_make_request <- function(x) { 243 244 method_fun <- list("GET" = GET, "POST" = POST, "PATCH" = PATCH, 245 "PUT" = PUT, "DELETE" = DELETE)[[x$method]] 246 if (is.null(method_fun)) throw(new_error("Unknown HTTP verb")) 247 248 raw <- do.call(method_fun, 249 compact(list(url = x$url, query = x$query, body = x$body, 250 add_headers(x$headers), x$dest))) 251 raw 252} 253