R/files.R

Defines functions upload_tree upload_blob

# FUNCTION: upload_blob --------------------------------------------------------
#
# Read a file and upload it to GitHub
#
# @param path (string) The path to the file to upload. It must be readable.
# @param repo (string) The repository specified in the format: `owner/repo`.
# @param ... Parameters passed to [gh_request()].
#
# @return `upload_blob()` returns a list of the blob's properties.
#
upload_blob <- function(
  path,
  repo,
  ...
) {
  assert_file(path) && assert_readable(path)
  assert_repo(repo)

  info(
    "Uploading file '", fs::path_file(path), "' to repository '", repo, "'",
    level = 2
  )
  content <- jsonlite::base64_enc(readBin(path, "raw", file.info(path)$size))

  gh_url("repos", repo, "git/blobs") %>%
    gh_request(
      "POST",
      payload = list(content = content, encoding = "base64"),
      ...
    )
}


# FUNCTION: upload_tree --------------------------------------------------------
#
# Upload a directory of files as a tree
#
# @param path (string) The path to the directory to upload. It must be readable.
# @param repo (string) The repository specified in the format: `owner/repo`.
# @param base_commit (string, optional) Either a SHA, branch or tag used to
#   identify the commit to base the specified file change to. If not supplied
#   the tree will just contain the files specified.
# @param placeholder (boolean, optional) Whether the files are placeholders,
#   containing the SHA of the blob, of the actual contents. Default: `FALSE`.
# @param ignore (character, optional) The files to ignore in the directory.
#   Default: `".git"`, `".Rproj.user"`, `".Rhistory"`, `".RData"` and
#   `".Ruserdata"`.
# @param ... Parameters passed to [gh_request()].
#
# @return `upload_tree()` returns a list containing the tree SHA and the base
#   commit SHA.
#
upload_tree <- function(
  path,
  repo,
  base_commit = NULL,
  placeholder = FALSE,
  ignore      = c(".git", ".Rproj.user", ".Rhistory", ".RData", ".Ruserdata"),
  ...
) {
  assert_dir(path) && assert_readable(path)
  assert_repo(repo)
  assert_character(ignore)

  ignore <- unique(c(".", "..", ignore))

  info("Uploading files in '", path, "' to repository '", repo, "'", level = 2)
  tree <- fs::dir_ls(path = path, all = TRUE, type = "any") %>%
    discard(~ fs::path_file(.) %in% ignore)

  if (length(tree) == 0) return(list(tree_sha = NA))

  tree <- file.info(tree) %>%
    rownames_to_column("path") %>%
    mutate(sha = map2_chr(.data$path, .data$isdir, function(path, isdir) {
      if (isdir) {
        upload_tree(
          path = path,
          repo = repo,
          placeholder = placeholder,
          ignore = ignore,
          ...
        ) %>%
          pluck("tree_sha")
      } else {
        if (placeholder) {
          # readr >= 2.0.0 locks a file until all its contents are used
          if (unlist(utils::packageVersion("readr"))[[1]] < 2) {
            readr::read_lines(file = path)
          } else {
            readr::read_lines(file = path, lazy = FALSE)
          }
        } else {
          upload_blob(path = path, repo = repo, ...)$sha
        }
      }
    })) %>%
    mutate(
      type = ifelse(.data$isdir, "tree", "blob"),
      mode = ifelse(.data$isdir, "040000", "100644"),
      path = fs::path_file(path)
    ) %>%
    select("path", "mode", "type", "sha") %>%
    filter(!is.na(.data$sha))

  payload <- tibble(
    path = tree$path,
    mode = tree$mode,
    type = tree$type,
    sha  = tree$sha
  ) %>%
    pmap(list) %>%
    list(tree = .)

  if (!is_null(base_commit)) {
    base_commit <- try_catch(
      view_commit(ref = base_commit, repo = repo, ...),
      on_error = function(e) NULL
    )
    payload$base_tree <- base_commit$tree_sha
  }

  info("Creating tree in repository '", repo, "'", level = 2)
  tree <- gh_url("repos", repo, "git/trees") %>%
    gh_request("POST", payload = payload, ...)

  list(commit_sha = base_commit$sha, tree_sha = tree$sha)
}


#  FUNCTION: upload_files ------------------------------------------------------
#
#' Upload files and create a commit
#'
#' This function uploads the specified files to a repository in GitHub and
#' creates a commit on the specified branch.
#'
#' This function uploads the specified files to a repository and creates a new
#' commit on the specified branch. Note: the files are created, or updated if
#' they already exist, and any other files in the parent are left unchanged.
#'
#' The `author` and `committer` arguments are optional and if not supplied the
#' current authenticated user is used. However, if you want to set them
#' explicitly you must specify a named list with `name` and `email` as the
#' elements (see examples).
#'
#' If a parent has been specified the files are created or updated. If a parent
#' has not been specified, but the branch exists the current head commit is used
#' as a parent. If the branch does not exist then an orphan commit is created.
#'
#' Note: The GitHub API imposes a file size limit of 100MB for this request.
#'
#' For more details see the GitHub API documentation:
#'
#' - <https://docs.github.com/en/rest/reference/git#create-a-blob>
#' - <https://docs.github.com/en/rest/reference/git#create-a-tree>
#' - <https://docs.github.com/en/rest/reference/git#create-a-commit>
#'
#' @param from_path (string) The paths to the files to upload. They must be
#'   readable.
#' @param to_path (string) The paths to write the files to, within the
#'   repository.
#' @param branch (string) The name of the branch to make the new commit on.
#' @param message (string) The commit message.
#' @param repo (string) The repository specified in the format: `owner/repo`.
#' @param author (list, optional) A the name and email address of the user who
#'   wrote the changes in the commit.
#' @param committer (list, optional) A the name and email address of the user
#'   who created the commit.
#' @param parent (string, optional) Reference for the commit to use as a parent,
#'   can be either a SHA, branch or tag. If it is a branch then the head commit
#'   is used. See the details section for more information.
#' @param force (boolean, optional) Whether to force the update if it is not a
#'   simple fast-forward. Default: `FALSE`.
#' @param ... Parameters passed to [gh_request()].
#'
#' @return `upload_files()` returns a list of the commit properties.
#'
#' **Commit Properties:**
#'
#' - **sha**: The commit SHA.
#' - **message**: The commit message.
#' - **author_name**: The author's name.
#' - **author_email**: The author's email address.
#' - **committer_name**: The committer's name.
#' - **committer_email**: The committer's email address.
#' - **tree_sha**: The SHA of the file tree.
#' - **parent_sha**: The commit SHA of the parent(s).
#' - **date**: The date the commit was made.
#'
#' @examples
#' \dontrun{
#'
#'   # Upload files to the main branch
#'   upload_files(
#'     from_path = c("c:/test/file1.txt", "c:/test/file2.txt"),
#'     to_path   = c("file1.txt", "file2.txt"),
#'     branch    = "main",
#'     message   = "Commit to test upload_files()",
#'     repo      = "ChadGoymer/githapi"
#'   )
#'
#'   # Upload files into directories within the main branch
#'   upload_files(
#'     from_path = c(
#'       "c:/test/file1.txt",
#'       "c:/test/file2.txt",
#'       "c:/test/file3.txt"
#'     ),
#'     to_path   = c(
#'       "dir-1/file-1.txt",
#'       "dir-1/dir-1-1/file-2.txt",
#'       "dir-2/file-3.txt"
#'     ),
#'     branch    = "main",
#'     message   = "Commit to test upload_files()",
#'     repo      = str_c("ChadGoymer/test-files-", now)
#'   )
#'
#'   # Upload files to the main branch specifying an author and committer
#'   upload_files(
#'     from_path = c("c:/test/file1.txt", "c:/test/file2.txt"),
#'     to_path   = c("file1.txt", "file2.txt"),
#'     branch    = "main",
#'     message   = "Commit to test upload_files()",
#'     repo      = "ChadGoymer/githapi",
#'     author    = list(name = "Bob",  email = "bob@acme.com"),
#'     committer = list(name = "Jane", email = "jane@acme.com")
#'   )
#'
#'   # Create a new branch from the main branch
#'   upload_files(
#'     from_path = c("c:/test/file1.txt", "c:/test/file2.txt"),
#'     to_path   = c("file1.txt", "file2.txt"),
#'     branch    = "new-branch",
#'     message   = "Commit to test upload_files()",
#'     repo      = "ChadGoymer/githapi",
#'     parent    = "main"
#'   )
#'
#' }
#'
#' @export
#'
upload_files <- function(
  from_path,
  to_path,
  branch,
  message,
  repo,
  author,
  committer,
  parent,
  force = FALSE,
  ...
) {
  assert_file(from_path) && assert_readable(from_path)
  assert_character(to_path)
  assert(
    identical(length(from_path), length(to_path)),
    "'from_path' and 'to_path' must be the same length"
  )
  assert_ref(branch)
  assert_character(message, n = 1)
  assert_repo(repo)
  assert_logical(force, n = 1)

  payload <- list(message = message)

  if (!is_missing_or_null(author)) {
    assert_list(author) && assert_names(author, c("name", "email"))
    assert_character(author$name, n = 1)
    assert_character(author$email, n = 1)
    payload$author <- author
  }

  if (!is_missing_or_null(committer)) {
    assert_list(committer) && assert_names(committer, c("name", "email"))
    assert_character(committer$name, n = 1)
    assert_character(committer$email, n = 1)
    payload$committer <- committer
  }

  if (is_missing_or_null(parent)) {
    parent <- branch
  }
  assert_character(parent, n = 1)

  info("Uploading files to repository '", repo, "'")
  blob_shas <- map_chr(from_path, ~ upload_blob(path = ., repo = repo, ...)$sha)

  temp_path <- fs::file_temp("tree-")
  fs::dir_create(temp_path)
  on.exit(try_dir_delete(temp_path))

  walk2(fs::path(temp_path, to_path), blob_shas, function(path, sha) {
    if (!fs::dir_exists(fs::path_dir(path))) {
      fs::dir_create(fs::path_dir(path), recurse = TRUE)
    }
    readr::write_lines(sha, path)
  })

  info("Uploading tree to repository '", repo, "'", level = 3)
  result <- upload_tree(
    path        = temp_path,
    repo        = repo,
    base_commit = parent,
    placeholder = TRUE,
    ...
  )

  payload$tree    <- result$tree_sha
  payload$parents <- as.list(result$commit_sha)

  info("Creating commit in repo '", repo, "'")
  commit <- gh_url("repos", repo, "git/commits") %>%
    gh_request("POST", payload = payload, ...)

  if (identical(branch, parent) && is_null(result$commit_sha)) {
    create_branch(name = branch, ref = commit$sha, repo = repo, ...)
  } else {
    branch_sha <- try_catch(
      view_commit(ref = branch, repo = repo, ...),
      on_error = function(e) NULL
    )

    if (is_null(branch_sha)) {
      create_branch(name = branch, ref = commit$sha, repo = repo, ...)
    } else {
      update_branch(
        branch = branch,
        ref    = commit$sha,
        repo   = repo,
        force  = force,
        ...
      )
    }
  }

  view_commit(commit$sha, repo = repo, ...)
}


#  FUNCTION: download_file -----------------------------------------------------
#
#' Download a file from GitHub
#'
#' This function downloads a file in the specified commit to the path provided.
#'
#' Note: The GitHub API imposes a file size limit of 100MB for this request.
#'
#' For more details see the GitHub API documentation:
#'
#' - <https://docs.github.com/en/rest/reference/repos#get-repository-content>
#' - <https://docs.github.com/en/rest/reference/git#get-a-blob>
#'
#' @param from_path (string) The path to the file to download, within the
#'   repository.
#' @param to_path (string) The path to download the file to.
#' @param ref (string) Either a SHA, branch or tag used to identify the commit.
#' @param repo (string) The repository specified in the format: `owner/repo`.
#' @param ... Parameters passed to [gh_request()].
#'
#' @return `download_file()` returns the path where the file is downloaded to.
#'
#' @examples
#' \dontrun{
#'
#'   # Download the README file from the main branch
#'   download_file(
#'     from_path = "README.md",
#'     to_path   = "~/README.md",
#'     ref       = "main",
#'     repo      = "ChadGoymer/githapi"
#'   )
#'
#' }
#'
#' @export
#'
download_file <- function(
  from_path,
  to_path,
  ref,
  repo,
  ...
) {
  assert_character(from_path, n = 1)
  assert_ref(ref)
  assert_repo(repo)

  info(
    "Checking file '", fs::path_file(from_path),
    "' exists in repository '", repo, "'",
    level = 3
  )

  file <- gh_url("repos", repo, "contents", fs::path_dir(from_path), ref = ref) %>%
    gh_find(property = "name", value = fs::path_file(from_path), ...)

  info(
    "Downloading file '", fs::path_file(from_path),
    "' in repository '", repo, "'"
  )
  path_gh <- gh_url("repos", repo, "git/blobs", file$sha) %>%
    gh_download(to_path, accept = "application/vnd.github.v3.raw", ...)

  info("Done", level = 3)
  path_gh
}


#  FUNCTION: create_file -------------------------------------------------------
#
#' Create a file in a new commit
#'
#' This function adds a file in a repository in GitHub by creating a new commit
#' on the specified branch. If the branch does not already exist a `parent`
#' commit must be specified and a new branch is created from it. If the file
#' already exists `create_file()` throws an error.
#'
#' The `author` and `committer` arguments are optional and if not supplied the
#' current authenticated user is used. However, if you want to set them
#' explicitly you must specify a named list with `name` and `email` as the
#' elements (see examples).
#'
#' Note: The GitHub API imposes a file size limit of 1MB for this request. For
#' larger files use the [upload_files()] function.
#'
#' For more details see the GitHub API documentation:
#'
#' - <https://docs.github.com/en/rest/reference/repos#create-or-update-file-contents>
#'
#' @param content (string) The content of the file specified as a single string.
#' @param path (string) The path to create the file at, within the repository.
#' @param branch (string) The name of the branch to make the new commit on.
#' @param message (string) The commit message.
#' @param repo (string) The repository specified in the format: `owner/repo`.
#' @param parent (string, optional) If creating a new branch the the parent
#'   commit must be specified as either a SHA, branch or tag.
#' @param author (list, optional) A the name and email address of the user who
#'   wrote the changes in the commit.
#' @param committer (list, optional) A the name and email address of the user
#'   who created the commit.
#' @param ... Parameters passed to [gh_request()].
#'
#' @return `create_file()`returns a list of the commit properties.
#'
#' **Commit Properties:**
#'
#' - **sha**: The commit SHA.
#' - **message**: The commit message.
#' - **author_name**: The author's name.
#' - **author_email**: The author's email address.
#' - **committer_name**: The committer's name.
#' - **committer_email**: The committer's email address.
#' - **tree_sha**: The SHA of the file tree.
#' - **parent_sha**: The commit SHA of the parent(s).
#' - **date**: The date the commit was made.
#'
#' @examples
#' \dontrun{
#'
#'   # Create a new file on the main branch
#'   create_file(
#'     content = "# This is a new file\\n\\n Created by `create_file()`",
#'     path    = "new-file.md",
#'     branch  = "main",
#'     message = "Created a new file with create_file()",
#'     repo    = "ChadGoymer/githapi"
#'   )
#'
#'   # Create a new file on a new branch
#'   create_file(
#'     content = "# This is a new file\\n\\n Created by `create_file()`",
#'     path    = "new-file.md",
#'     branch  = "new-branch",
#'     message = "Created a new file with create_file()",
#'     repo    = "ChadGoymer/githapi",
#'     parent  = "main"
#'   )
#'
#'   # Create a new file on the main branch specifying an author and committer
#'   create_file(
#'     content   = "# This is a new file\\n\\n Created by `create_file()`",
#'     path      = "new-file.md",
#'     branch    = "main",
#'     message   = "Created a new file with create_file()",
#'     repo      = "ChadGoymer/githapi",
#'     author    = list(name = "Bob",  email = "bob@acme.com"),
#'     committer = list(name = "Jane", email = "jane@acme.com")
#'   )
#'
#' }
#'
#' @export
#'
create_file <- function(
  content,
  path,
  branch,
  message,
  repo,
  parent,
  author,
  committer,
  ...
) {
  assert_character(content, n = 1)
  assert_character(path, n = 1)
  assert_ref(branch)
  assert_character(message, n = 1)
  assert_repo(repo)

  payload <- list(
    content = jsonlite::base64_enc(content),
    branch  = branch,
    message = message
  )

  if (!is_missing_or_null(parent) && !identical(parent, branch)) {
    assert_ref(parent)
    create_branch(name = branch, ref = parent, repo = repo, ...)
  }

  if (!is_missing_or_null(author)) {
    assert_list(author) && assert_names(author, c("name", "email"))
    assert_character(author$name, n = 1)
    assert_character(author$email, n = 1)
    payload$author <- author
  }

  if (!is_missing_or_null(committer)) {
    assert_list(committer) && assert_names(committer, c("name", "email"))
    assert_character(committer$name, n = 1)
    assert_character(committer$email, n = 1)
    payload$committer <- committer
  }

  info(
    "Checking if a file with path '", path,
    "' already exists in repository '", repo, "'",
    level = 3
  )
  file_exists <- tryCatch({
    gh_url("repos", repo, "contents", path, ref = branch) %>%
      gh_request("GET", ...)
    TRUE
  },
  error = function(e) {
    FALSE
  })

  assert(
    !file_exists,
    "A file with path '", path,
    "' already exists. To update it use update_file()"
  )

  info("Creating file '", fs::path_file(path), "' in repository '", repo, "'")
  commit <- gh_url("repos", repo, "contents", path) %>%
    gh_request("PUT", payload = payload, ...) %>%
    pluck("commit")

  view_commit(commit$sha, repo = repo, ...)
}


#  FUNCTION: update_file -------------------------------------------------------
#
#' Update a file in a new commit
#'
#' This function updates a file in a repository in GitHub by creating a new
#' commit on the specified branch. If the branch does not already exist a
#' `parent` commit must be specified and a new branch is created from it.
#'
#' The `author` and `committer` arguments are optional and if not supplied the
#' current authenticated user is used. However, if you want to set them
#' explicitly you must specify a named list with `name` and `email` as the
#' elements (see examples).
#'
#' Note: The GitHub API imposes a file size limit of 1MB for this request. For
#' larger files use the [upload_files()] function.
#'
#' For more details see the GitHub API documentation:
#'
#' - <https://docs.github.com/en/rest/reference/repos#create-or-update-file-contents>
#'
#' @param content (string) The content of the file specified as a single string.
#' @param path (string) The path of the file to update, within the repository.
#' @param branch (string) The name of the branch to make the new commit on.
#' @param message (string) The commit message.
#' @param repo (string) The repository specified in the format: `owner/repo`.
#' @param parent (string, optional) If creating a new branch the the parent
#'   commit must be specified as either a SHA, branch or tag.
#' @param author (list, optional) A the name and email address of the user who
#'   wrote the changes in the commit.
#' @param committer (list, optional) A the name and email address of the user
#'   who created the commit.
#' @param ... Parameters passed to [gh_request()].
#'
#' @return `update_file()`returns a list of the commit properties.
#'
#' **Commit Properties:**
#'
#' - **sha**: The commit SHA.
#' - **message**: The commit message.
#' - **author_name**: The author's name.
#' - **author_email**: The author's email address.
#' - **committer_name**: The committer's name.
#' - **committer_email**: The committer's email address.
#' - **tree_sha**: The SHA of the file tree.
#' - **parent_sha**: The commit SHA of the parent(s).
#' - **date**: The date the commit was made.
#'
#' @examples
#' \dontrun{
#'
#'   # Update a file on the main branch
#'   update_file(
#'     content = "# This is a file\\n\\n Updated by `update_file()`",
#'     path    = "updated-file.md",
#'     branch  = "main",
#'     message = "Updated a file with update_file()",
#'     repo    = "ChadGoymer/githapi"
#'   )
#'
#'   # Update a file on a new branch
#'   update_file(
#'     content = "# This is a file\\n\\n Updated by `update_file()`",
#'     path    = "updated-file.md",
#'     branch  = "new-branch",
#'     message = "Updated a file with update_file()",
#'     repo    = "ChadGoymer/githapi",
#'     parent  = "main"
#'   )
#'
#'   # Create a new file on the main branch specifying an author and committer
#'   update_file(
#'     content   = "# This is a file\\n\\n Updated by `update_file()`",
#'     path      = "updated-file.md",
#'     branch    = "main",
#'     message   = "Updated a file with update_file()",
#'     repo      = "ChadGoymer/githapi",
#'     author    = list(name = "Bob",  email = "bob@acme.com"),
#'     committer = list(name = "Jane", email = "jane@acme.com")
#'   )
#'
#' }
#'
#' @export
#'
update_file <- function(
  content,
  path,
  branch,
  message,
  repo,
  parent,
  author,
  committer,
  ...
) {
  assert_character(content, n = 1)
  assert_character(path, n = 1)
  assert_ref(branch)
  assert_character(message, n = 1)
  assert_repo(repo)

  payload <- list(
    content = jsonlite::base64_enc(content),
    branch  = branch,
    message = message
  )

  if (!is_missing_or_null(parent) && !identical(parent, branch)) {
    assert_ref(parent)
    create_branch(name = branch, ref = parent, repo = repo, ...)
  }

  if (!is_missing_or_null(author)) {
    assert_list(author) && assert_names(author, c("name", "email"))
    assert_character(author$name, n = 1)
    assert_character(author$email, n = 1)
    payload$author <- author
  }

  if (!is_missing_or_null(committer)) {
    assert_list(committer) && assert_names(committer, c("name", "email"))
    assert_character(committer$name, n = 1)
    assert_character(committer$email, n = 1)
    payload$committer <- committer
  }

  info(
    "Checking if a file with path '", path,
    "' already exists in repository '", repo, "'",
    level = 3
  )
  file <- tryCatch({
    gh_url("repos", repo, "contents", path, ref = branch) %>%
      gh_request("GET", ...)
  },
  error = function(e) {
    NULL
  })

  assert(
    !is_null(file),
    "A file with path '", path,
    "' does not exist. To create it use create_file()"
  )
  payload$sha <- file$sha

  info("Updating file '", fs::path_file(path), "' in repository '", repo, "'")
  commit <- gh_url("repos", repo, "contents", path) %>%
    gh_request("PUT", payload = payload, ...) %>%
    pluck("commit")

  view_commit(commit$sha, repo = repo, ...)
}


#  FUNCTION: delete_file -------------------------------------------------------
#
#' Delete a file in a new commit
#'
#' This function deletes a file in a repository in GitHub by creating a new
#' commit on the specified branch. If the branch does not already exist a
#' `parent` commit must be specified and a new branch is created from it. If the
#' file does not exist `delete_file()` throws as error.
#'
#' The `author` and `committer` arguments are optional and if not supplied the
#' current authenticated user is used. However, if you want to set them
#' explicitly you must specify a named list with `name` and `email` as the
#' elements (see examples).
#'
#' Note: The GitHub API imposes a file size limit of 1MB for this request. For
#' larger files use the [upload_files()] function.
#'
#' For more details see the GitHub API documentation:
#'
#' - <https://docs.github.com/en/rest/reference/repos#delete-a-file>
#'
#' @param path (string) The path of the file to delete, within the repository.
#' @param branch (string) The name of the branch to make the new commit on.
#' @param message (string) The commit message.
#' @param repo (string) The repository specified in the format: `owner/repo`.
#' @param parent (string, optional) If creating a new branch the the parent
#'   commit must be specified as either a SHA, branch or tag.
#' @param author (list, optional) A the name and email address of the user who
#'   wrote the changes in the commit.
#' @param committer (list, optional) A the name and email address of the user
#'   who created the commit.
#' @param ... Parameters passed to [gh_request()].
#'
#' @return `delete_file()`returns a list of the commit properties.
#'
#' **Commit Properties:**
#'
#' - **sha**: The commit SHA.
#' - **message**: The commit message.
#' - **author_name**: The author's name.
#' - **author_email**: The author's email address.
#' - **committer_name**: The committer's name.
#' - **committer_email**: The committer's email address.
#' - **tree_sha**: The SHA of the file tree.
#' - **parent_sha**: The commit SHA of the parent(s).
#' - **date**: The date the commit was made.
#'
#' @examples
#' \dontrun{
#'
#'   # Delete a file on the main branch
#'   delete_file(
#'     path    = "file-to-delete.md",
#'     branch  = "main",
#'     message = "Deleted a file with delete_file()",
#'     repo    = "ChadGoymer/githapi"
#'   )
#'
#'   # Delete a file on a new branch
#'   create_file(
#'     path    = "file-to-delete.md",
#'     branch  = "new-branch",
#'     message = "Deleted a file with delete_file()",
#'     repo    = "ChadGoymer/githapi",
#'     parent  = "main"
#'   )
#'
#'   # Delete a file on the main branch specifying an author and committer
#'   delete_file(
#'     path      = "file-to-delete.md",
#'     branch    = "main",
#'     message   = "Deleted a file with delete_file()",
#'     repo      = "ChadGoymer/githapi",
#'     author    = list(name = "Bob",  email = "bob@acme.com"),
#'     committer = list(name = "Jane", email = "jane@acme.com")
#'   )
#'
#' }
#'
#' @export
#'
delete_file <- function(
  path,
  branch,
  message,
  repo,
  parent,
  author,
  committer,
  ...
) {
  assert_character(path, n = 1)
  assert_ref(branch)
  assert_character(message, n = 1)
  assert_repo(repo)

  payload <- list(branch = branch, message = message)

  if (!is_missing_or_null(parent) && !identical(parent, branch)) {
    assert_ref(parent)
    create_branch(name = branch, ref = parent, repo = repo, ...)
  }

  if (!is_missing_or_null(author)) {
    assert_list(author) && assert_names(author, c("name", "email"))
    assert_character(author$name, n = 1)
    assert_character(author$email, n = 1)
    payload$author <- author
  }

  if (!is_missing_or_null(committer)) {
    assert_list(committer) && assert_names(committer, c("name", "email"))
    assert_character(committer$name, n = 1)
    assert_character(committer$email, n = 1)
    payload$committer <- committer
  }

  info(
    "Checking if a file with path '", path,
    "' already exists in repository '", repo, "'",
    level = 3
  )
  file <- gh_url("repos", repo, "contents", path, ref = branch) %>%
    gh_request("GET", ...)
  payload$sha <- file$sha

  info("Deleting file '", fs::path_file(path), "' in repository '", repo, "'")
  commit <- gh_url("repos", repo, "contents", path) %>%
    gh_request("DELETE", payload = payload, ...) %>%
    pluck("commit")

  view_commit(commit$sha, repo = repo, ...)
}


#  FUNCTION: view_files --------------------------------------------------------
#
#' View files within a repository
#'
#' `view_files()` summarises files in a table with the properties as columns and
#' a row for each file in the repository. `view_file()` returns a list of all
#' properties for a single file. `browse_files()` and `browse_file()` open the
#' web page for the commit tree and blob respectively in the default browser.
#'
#' You can summarise all the milestones of a repository in a specified `state`
#' and change the order they are returned using `sort` and `direction`.
#'
#' For more details see the GitHub API documentation:
#'
#' - <https://docs.github.com/en/rest/reference/git#get-a-tree>
#' - <https://docs.github.com/en/rest/reference/repos#get-repository-content>
#'
#' @param path (string) The path to the file, within the repository.
#' @param ref (string) Either a SHA, branch or tag used to identify the commit.
#' @param repo (string) The repository specified in the format: `owner/repo`.
#' @param recursive (boolean, optional) Whether to list files in subfolders as
#'   well. Default: `TRUE`.
#' @param ... Parameters passed to [gh_page()] or [gh_request()].
#'
#' @return `view_files()` returns a tibble of file properties. `view_file()`
#'   returns a list of properties for a single file. `browse_files()` and
#'   `browse_file()` opens the default browser on the tree or blob page and
#'   returns the URL.
#'
#' **File Properties:**
#'
#' - **path**: The path to the file within the repository.
#' - **sha**: The SHA of the file blob.
#' - **size**: The size of the file in bytes.
#' - **html_url**: The URL of the blob's web page in GitHub.
#'
#' @examples
#' \dontrun{
#'
#'   # View files on the main branch in a repository
#'   view_files("main", "ChadGoymer/githapi")
#'
#'   # View properties of a single file in a repository
#'   view_file(
#'     path = "README.md",
#'     ref  = "main",
#'     repo = "ChadGoymer/githapi"
#'   )
#'
#'   # Open the commit's tree page in the default browser
#'   browse_files("main", "ChadGoymer/githapi")
#'
#'   # Open the file's blob page in the default browser
#'   browse_file(
#'     path = "README.md",
#'     ref  = "main",
#'     repo = "ChadGoymer/githapi"
#'   )
#'
#' }
#'
#' @export
#'
view_files <- function(
  ref,
  repo,
  recursive = TRUE,
  ...
) {
  assert_ref(ref)
  assert_repo(repo)
  assert_logical(recursive, n = 1)

  if (!recursive) {
    recursive <- NULL
  }

  commit <- view_commit(ref = ref, repo = repo, ...)
  blob_base_url <- commit$html_url %>%
    str_replace(str_c(repo, "/", "commit"), str_c(repo, "/", "blob"))

  info(
    "Viewing files for commit with reference '", ref,
    "' in repository '", repo, "'"
  )
  files_lst <- gh_url(
    "repos", repo, "git/trees", commit$tree_sha, recursive = recursive
  ) %>%
    gh_request("GET", ...)

  info("Transforming results", level = 4)
  files_gh <- bind_properties(files_lst$tree, properties$file) %>%
    filter(.data$type == "blob") %>%
    select(-"type") %>%
    mutate(html_url = str_c(blob_base_url, "/", .data$path))

  info("Done", level = 7)
  structure(
    files_gh,
    class   = class(files_gh),
    url     = attr(files_lst, "url"),
    request = attr(files_lst, "request"),
    status  = attr(files_lst, "status"),
    header  = attr(files_lst, "header")
  )
}


#  FUNCTION: view_file ---------------------------------------------------------
#
#' @rdname view_files
#' @export
#'
view_file <- function(
  path,
  ref,
  repo,
  ...
) {
  assert_character(path, n = 1)
  assert_ref(ref)
  assert_repo(repo)

  info("Viewing file '", fs::path_file(path), "' in repository '", repo, "'")
  file_lst <- gh_url("repos", repo, "contents", path, ref = ref) %>%
    gh_request("GET", ...)

  info("Transforming results", level = 4)
  file_gh <- file_lst %>%
    select_properties(properties$file) %>%
    discard(names(.) == "type")

  info("Done", level = 7)
  structure(
    file_gh,
    class   = class(file_lst),
    url     = attr(file_lst, "url"),
    request = attr(file_lst, "request"),
    status  = attr(file_lst, "status"),
    header  = attr(file_lst, "header")
  )
}


#  FUNCTION: browse_files ------------------------------------------------------
#
#' @rdname view_files
#' @export
#'
browse_files <- function(
  ref,
  repo,
  ...
) {
  assert_ref(ref)
  assert_repo(repo)

  info("Browsing commit '", ref, "' in repository '", repo, "'")
  commit <- gh_url("repos", repo, "commits", ref) %>%
    gh_request("GET", ...)

  tree_url <- commit$html_url %>%
    str_replace(str_c(repo, "/", "commit"), str_c(repo, "/", "tree"))

  httr::BROWSE(tree_url)

  info("Done", level = 7)
  structure(
    tree_url,
    class   = c("github", "character"),
    url     = attr(commit, "url"),
    request = attr(commit, "request"),
    status  = attr(commit, "status"),
    header  = attr(commit, "header")
  )
}


#  FUNCTION: browse_file -------------------------------------------------------
#
#' @rdname view_files
#' @export
#'
browse_file <- function(
  path,
  ref,
  repo,
  ...
) {
  assert_character(path, n = 1)
  assert_ref(ref)
  assert_repo(repo)

  file <- view_file(path = path, ref = ref, repo = repo, ...)
  httr::BROWSE(file$html_url)

  info("Done", level = 7)
  structure(
    file$html_url,
    class   = c("github", "character"),
    url     = attr(file, "url"),
    request = attr(file, "request"),
    status  = attr(file, "status"),
    header  = attr(file, "header")
  )
}


#  FUNCTION: write_github_file -------------------------------------------------
#
#' Write a file to a branch
#'
#' These functions writes files to a repository by creating a new commit on the
#' specified branch. `write_github_file()` writes the `content` to a text file,
#' using [readr::write_file()]; `write_github_lines()` writes to a text file,
#' using [readr::write_lines()] and `write_github_csv()` writes a CSV file,
#' using [readr::write_csv()].
#'
#' @param content (character or data.frame) The content of the file.
#' @param path (string) The path to create the file at, within the repository.
#' @param branch (string) The name of the branch to make the new commit on.
#' @param message (string) The commit message.
#' @param repo (string) The repository specified in the format: `owner/repo`.
#' @param author (list, optional) A the name and email address of the user who
#'   wrote the changes in the commit.
#' @param committer (list, optional) A the name and email address of the user
#'   who created the commit.
#' @param ... Parameters passed to [readr::write_file()], [readr::write_lines()]
#'   or [readr::write_csv()].
#'
#' @return `write_github_file()`, `write_github_lines()` and
#'   `write_github_csv()` return a list of the commit properties.
#'
#' **Commit Properties:**
#'
#' - **sha**: The commit SHA.
#' - **message**: The commit message.
#' - **author_name**: The author's name.
#' - **author_email**: The author's email address.
#' - **committer_name**: The committer's name.
#' - **committer_email**: The committer's email address.
#' - **tree_sha**: The SHA of the file tree.
#' - **parent_sha**: The commit SHA of the parent(s).
#' - **date**: The date the commit was made.
#'
#' @examples
#' \dontrun{
#'
#'   write_github_file(
#'     content = "# This is a new file\\n\\n Created by githapi",
#'     path    = "new-file.md",
#'     branch  = "main",
#'     message = "Created a new file with write_github_file()",
#'     repo    = "ChadGoymer/githapi"
#'   )
#'
#'   write_github_lines(
#'     content   = c("# This is a new file", "", "Created by githapi"),
#'     path      = "new-file.md",
#'     branch    = "main",
#'     message   = "Created a new file with write_github_lines()",
#'     repo      = "ChadGoymer/githapi"
#'   )
#'
#'   write_github_csv(
#'     content = tibble(letters = LETTERS, numbers = 1:26),
#'     path    = "new-file.md",
#'     branch  = "main",
#'     message = "Updated an existing file with write_github_csv()",
#'     repo    = "ChadGoymer/githapi"
#'   )
#'
#' }
#'
#' @export
#'
write_github_file <- function(
  content,
  path,
  branch,
  message,
  repo,
  author,
  committer,
  ...
) {
  assert_character(path, n = 1)
  assert_ref(branch)
  assert_character(message, n = 1)
  assert_repo(repo)

  temp_path <- fs::file_temp("read-file-")
  fs::dir_create(temp_path, recurse = TRUE)
  on.exit(try_dir_delete(temp_path))

  temp_file <- fs::path(temp_path, fs::path_file(path))

  info("Writing file '", fs::path_file(path), "'")
  readr::write_file(content, temp_file, ...)

  upload_files(
    from_path = temp_file,
    to_path   = path,
    branch    = branch,
    message   = message,
    repo      = repo,
    author    = author,
    committer = committer,
    ...
  )
}


#  FUNCTION: write_github_lines ------------------------------------------------
#
#' @rdname write_github_file
#' @export
#'
write_github_lines <- function(
  content,
  path,
  branch,
  message,
  repo,
  author,
  committer,
  ...
) {
  assert_character(path, n = 1)
  assert_ref(branch)
  assert_character(message, n = 1)
  assert_repo(repo)

  temp_path <- fs::file_temp("read-file-")
  fs::dir_create(temp_path, recurse = TRUE)
  on.exit(try_dir_delete(temp_path))

  temp_file <- fs::path(temp_path, fs::path_file(path))

  info("Writing file '", fs::path_file(path), "'")
  readr::write_lines(content, temp_file, ...)

  upload_files(
    from_path = temp_file,
    to_path   = path,
    branch    = branch,
    message   = message,
    repo      = repo,
    author    = author,
    committer = committer,
    ...
  )
}


#  FUNCTION: write_github_csv --------------------------------------------------
#
#' @rdname write_github_file
#' @export
#'
write_github_csv <- function(
  content,
  path,
  branch,
  message,
  repo,
  author,
  committer,
  ...
) {
  assert_character(path, n = 1)
  assert_ref(branch)
  assert_character(message, n = 1)
  assert_repo(repo)

  temp_path <- fs::file_temp("read-file-")
  fs::dir_create(temp_path, recurse = TRUE)
  on.exit(try_dir_delete(temp_path))

  temp_file <- fs::path(temp_path, fs::path_file(path))

  info("Writing file '", fs::path_file(path), "'")
  readr::write_csv(content, temp_file, ...)

  upload_files(
    from_path = temp_file,
    to_path   = path,
    branch    = branch,
    message   = message,
    repo      = repo,
    author    = author,
    committer = committer,
    ...
  )
}


#  FUNCTION: read_github_file --------------------------------------------------
#
#' Read files from a commit
#'
#' These functions read a file from a commit in a repository.
#' `read_github_file()` reads a text file, using [readr::read_file()], and
#' returns the result as a string. `read_github_lines()` reads a text file,
#' using [readr::read_lines()], and returns the result as a character vector,
#' one element per line. `read_github_csv()` reads a CSV file, using
#' [readr::read_csv()], and returns the result as a tibble.
#'
#' @param path (string) The path to the file, within the repository.
#' @param ref (string) Either a SHA, branch or tag used to identify the commit.
#' @param repo (string) The repository specified in the format: `owner/repo`.
#' @param ... Parameters passed to [readr::read_file()], [readr::read_lines()]
#'   or [readr::read_csv()].
#'
#' @return `read_github_file()` returns a string containing the file contents,
#'   `read_github_lines()` returns a character vector, and `read_github_csv()`
#'   returns a tibble.
#'
#' @examples
#' \dontrun{
#'
#'   read_github_file(
#'     path = "README.md",
#'     ref  = "main",
#'     repo = "ChadGoymer/githapi"
#'   )
#'
#'   read_github_lines(
#'     path = "README.md",
#'     ref  = "main",
#'     repo = "ChadGoymer/githapi"
#'   )
#'
#'   read_github_csv(
#'     path = "inst/test-data/test.csv",
#'     ref  = "main",
#'     repo = "ChadGoymer/githapi"
#'   )
#'
#' }
#'
#' @export
#'
read_github_file <- function(
  path,
  ref,
  repo,
  ...
) {
  assert_character(path, n = 1)
  assert_ref(ref)
  assert_repo(repo)

  temp_path <- fs::file_temp("read-file-")
  fs::dir_create(temp_path, recurse = TRUE)
  on.exit(try_dir_delete(temp_path))

  file_path <- download_file(
    from_path = path,
    to_path   = fs::path(temp_path, fs::path_file(path)),
    ref       = ref,
    repo      = repo,
    ...
  )

  info("Reading file '", fs::path_file(path), "'")
  file_contents <- readr::read_file(file_path, ...)

  info("Done", level = 7)
  structure(
    file_contents,
    class   = c("github", class(file_contents)),
    url     = attr(file_path, "url"),
    request = attr(file_path, "request"),
    status  = attr(file_path, "status"),
    header  = attr(file_path, "header")
  )
}


#  FUNCTION: read_github_lines -------------------------------------------------
#
#' @rdname read_github_file
#' @export
#'
read_github_lines <- function(
  path,
  ref,
  repo,
  ...
) {
  assert_character(path, n = 1)
  assert_ref(ref)
  assert_repo(repo)

  temp_path <- fs::file_temp("read-file-")
  fs::dir_create(temp_path, recurse = TRUE)
  on.exit(try_dir_delete(temp_path))

  file_path <- download_file(
    from_path = path,
    to_path   = fs::path(temp_path, fs::path_file(path)),
    ref       = ref,
    repo      = repo,
    ...
  )

  info("Reading file '", fs::path_file(path), "'")
  # readr >= 2.0.0 locks a file until all its contents are used
  if (unlist(utils::packageVersion("readr"))[[1]] < 2) {
    file_contents <- readr::read_lines(file_path, ...)
  } else {
    file_contents <- readr::read_lines(file_path, lazy = FALSE, ...)
  }

  info("Done", level = 7)
  structure(
    file_contents,
    class   = c("github", class(file_contents)),
    url     = attr(file_path, "url"),
    request = attr(file_path, "request"),
    status  = attr(file_path, "status"),
    header  = attr(file_path, "header")
  )
}


#  FUNCTION: read_github_csv ---------------------------------------------------
#
#' @rdname read_github_file
#' @export
#'
read_github_csv <- function(
  path,
  ref,
  repo,
  ...
) {
  assert_character(path, n = 1)
  assert_ref(ref)
  assert_repo(repo)

  temp_path <- fs::file_temp("read-file-")
  fs::dir_create(temp_path, recurse = TRUE)
  on.exit(try_dir_delete(temp_path))

  file_path <- download_file(
    from_path = path,
    to_path   = fs::path(temp_path, fs::path_file(path)),
    ref       = ref,
    repo      = repo,
    ...
  )

  info("Reading file '", fs::path_file(path), "'")
  # readr >= 2.0.0 locks a file until all its contents are used
  if (unlist(utils::packageVersion("readr"))[[1]] < 2) {
    file_contents <- readr::read_csv(file_path, ...)
  } else {
    file_contents <- readr::read_csv(file_path, lazy = FALSE, ...)
  }

  info("Done", level = 7)
  structure(
    file_contents,
    class   = c("github", class(file_contents)),
    url     = attr(file_path, "url"),
    request = attr(file_path, "request"),
    status  = attr(file_path, "status"),
    header  = attr(file_path, "header")
  )
}


#  FUNCTION: github_source -----------------------------------------------------
#
#' Source a R script from a commit
#'
#' This function sources an R script from a commit in a repository using
#' [source()].
#'
#' @param path (string) The path to the file, within the repository.
#' @param ref (string) Either a SHA, branch or tag used to identify the commit.
#' @param repo (string) The repository specified in the format: `owner/repo`.
#' @param ... Parameters passed to [source()].
#'
#' @return The result of the sourced script.
#'
#' @examples
#' \dontrun{
#'
#'   github_source(
#'     path = "inst/test-data/test-script.R",
#'     ref  = "main",
#'     repo = "ChadGoymer/githapi"
#'   )
#'
#' }
#'
#' @export
#'
github_source <- function(
  path,
  ref,
  repo,
  ...
) {
  assert_character(path, n = 1)
  assert_ref(ref)
  assert_repo(repo)

  temp_path <- fs::file_temp("read-file-")
  fs::dir_create(temp_path, recurse = TRUE)
  on.exit(try_dir_delete(temp_path))

  file_path <- download_file(
    from_path = path,
    to_path   = fs::path(temp_path, fs::path_file(path)),
    ref       = ref,
    repo      = repo,
    ...
  )

  info("Sourcing file '", fs::path_file(path), "'")
  result <- source(file_path, ...)

  info("Done", level = 7)
  structure(
    result,
    class   = c("github", class(result)),
    url     = attr(file_path, "url"),
    request = attr(file_path, "request"),
    status  = attr(file_path, "status"),
    header  = attr(file_path, "header")
  )
}


#  FUNCTION: compare_files -----------------------------------------------------
#
#' View file changes made between two commits
#'
#' `compare_files()` summarises the file changes made between two commits in a
#' table with the properties as columns and a row for each file. The `base`
#' commit must be in the history of the `head` commit.
#'
#' For more details see the GitHub API documentation:
#'
#' - <https://docs.github.com/en/rest/reference/repos#compare-two-commits>
#'
#' @param head (string) Either a SHA, branch or tag used to identify the head
#'   commit.
#' @param base (string) Either a SHA, branch or tag used to identify the base
#'   commit.
#' @param repo (string) The repository specified in the format: `owner/repo`.
#' @param ... Parameters passed to [gh_request()].
#'
#' @return `compare_files()` returns a tibble of file properties.
#'
#' **File Properties:**
#'
#' - **path**: The path to the file within the repository.
#' - **sha**: The SHA of the file blob.
#' - **status**: Whether the file was "added", "modified" or "deleted".
#' - **additions**: The number of lines added in the file.
#' - **deletions**: The number of lines deleted in the file.
#' - **changes**: The number of lines changed in the file.
#' - **patch**: The git patch for the file.
#' - **html_url**: The URL of the blob's web page in GitHub.
#'
#' @examples
#' \dontrun{
#'
#'   # View the files changes made between the current main branch and a release
#'   compare_files("main", "0.8.7", "ChadGoymer/githapi")
#'
#' }
#'
#' @export
#'
compare_files <- function(
  base,
  head,
  repo,
  ...
) {
  assert_ref(base)
  assert_ref(head)
  assert_repo(repo)

  info(
    "Comparing commit '", head, "' with '", base,
    "' in repository '", repo, "'"
  )
  comparison_lst <- gh_url(
    "repos", repo, "compare", str_c(base, "...", head)
  ) %>%
    gh_request("GET", ...)

  info("Transforming results", level = 4)
  comparison_gh <- bind_properties(
    comparison_lst$files,
    properties$compare_files
  )

  info("Done", level = 7)
  structure(
    comparison_gh,
    class   = c("github", class(comparison_gh)),
    url     = attr(comparison_lst, "url"),
    request = attr(comparison_lst, "request"),
    status  = attr(comparison_lst, "status"),
    header  = attr(comparison_lst, "header")
  )
}
ChadGoymer/githapi documentation built on Oct. 22, 2021, 10:56 a.m.