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