diff --git a/.Rbuildignore b/.Rbuildignore index 3ec5687..012eb7c 100644 --- a/.Rbuildignore +++ b/.Rbuildignore @@ -1,6 +1,8 @@ -^LICENSE\.md$ +^.*~$ +^.*\.json$ ^.*\.Rproj$ ^\.Rproj\.user$ +^LICENSE\.md$ ^main$ ^pull_request$ ^packages\.json$ diff --git a/.gitignore b/.gitignore index 234f028..37c1341 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ +*~ +*.json .Rproj.user .Rhistory .RData diff --git a/DESCRIPTION b/DESCRIPTION index b52ce12..81b7c79 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -1,7 +1,7 @@ Package: r.releases.utils Title: Utilities for An R Universe of Package Releases Description: Utilities for an R universe of package releases. -Version: 0.0.7.9000 +Version: 0.0.8 License: MIT + file LICENSE URL: https://r-releases.github.io/r.releases.utils/, @@ -33,6 +33,7 @@ Imports: jsonlite, nanonext, pkgsearch, + utils, vctrs Encoding: UTF-8 Language: en-US diff --git a/NAMESPACE b/NAMESPACE index d11de82..6aab61f 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -4,14 +4,19 @@ export(assert_cran_url) export(assert_package) export(assert_release_exists) export(build_universe) +export(get_current_versions) +export(record_versions) export(review_pull_request) export(review_pull_requests) export(try_message) importFrom(gh,gh) importFrom(jsonlite,parse_json) importFrom(jsonlite,read_json) +importFrom(jsonlite,write_json) importFrom(nanonext,ncurl) importFrom(nanonext,parse_url) importFrom(nanonext,status_code) importFrom(pkgsearch,cran_package) +importFrom(utils,available.packages) +importFrom(utils,compareVersion) importFrom(vctrs,vec_rbind) diff --git a/NEWS.md b/NEWS.md index 0ff4ff0..8b9f519 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,7 +1,8 @@ -# r.releases.utils 0.0.7.9000 (development) +# r.releases.utils 0.0.8 * Use R-releases and not `r-releases` to refer to the project. * Edit bot messages. +* Add `record_versions()`. # r.releases.utils 0.0.7 diff --git a/R/package.R b/R/package.R index f73f627..5f884aa 100644 --- a/R/package.R +++ b/R/package.R @@ -3,8 +3,9 @@ #' @name r.releases.utils-package #' @family help #' @importFrom gh gh -#' @importFrom jsonlite parse_json read_json +#' @importFrom jsonlite parse_json read_json write_json #' @importFrom nanonext ncurl parse_url status_code #' @importFrom pkgsearch cran_package +#' @importFrom utils available.packages compareVersion #' @importFrom vctrs vec_rbind NULL diff --git a/R/record_versions.R b/R/record_versions.R new file mode 100644 index 0000000..89b660d --- /dev/null +++ b/R/record_versions.R @@ -0,0 +1,100 @@ +#' @title Record the manifest of package versions. +#' @export +#' @description Record the manifest of versions of packages +#' and their hashes. +#' @details This function tracks a manifest containing the current version, +#' the current hash, the highest version ever released, and +#' the hash of the highest version ever released. It uses this information +#' to determine whether the package complies with best +#' practices for version numbers. +#' @return `NULL` (invisibly). Writes a package version manifest +#' and a manifest of version issues as JSON files. +#' @param manifest Character of length 1, file path to the JSON manifest. +#' @param issues Character of length 1, file path to a JSON file +#' which records packages with version issues. +#' @param repos Character string of package repositories to track. +#' @param current A data frame of current versions and hashes of packages +#' in `repos`. This argument is exposed for testing only. +#' @param check_hash Logical of length 1, check hashes when judging package +#' version compliance. This allows [record_versions()] to flag packages +#' that create new releases but keep the same version number. +record_versions <- function( + manifest = "versions.json", + issues = "version_issues.json", + repos = "https://r-releases.r-universe.dev", + current = r.releases.utils::get_current_versions(repos = repos), + check_hash = TRUE +) { + if (!file.exists(manifest)) { + jsonlite::write_json(x = current, path = manifest, pretty = TRUE) + return(invisible()) + } + previous <- read_versions_previous(manifest = manifest) + new <- update_version_manifest(current = current, previous = previous) + jsonlite::write_json(x = new, path = manifest, pretty = TRUE) + aligned <- versions_aligned(manifest = new, check_hash = check_hash) + new_issues <- new[!aligned,, drop = FALSE] # nolint + jsonlite::write_json(x = new_issues, path = issues, pretty = TRUE) + invisible() +} + +#' @title Get the current versions of packages +#' @export +#' @keywords internal +#' @description Get the current versions of packages in the repos. +#' @return A data frame of packages with their current versions and hashes. +#' @inheritParams record_versions +get_current_versions <- function( + repos = "https://r-releases.r-universe.dev" +) { + out <- utils::available.packages(repos = repos) + out <- out[, c("Package", "Version", "MD5sum")] + out <- as.data.frame(out) + colnames(out) <- c("package", "version_current", "hash_current") + rownames(out) <- NULL + out +} + +read_versions_previous <- function(manifest) { + out <- jsonlite::read_json(path = manifest) + out <- do.call(what = vctrs::vec_rbind, args = out) + out <- lapply(out, as.character) + if (is.null(out$version_highest)) { + out$version_highest <- out$version_current + } + if (is.null(out$hash_highest)) { + out$hash_highest <- out$hash_current + } + out$version_current <- NULL + out$hash_current <- NULL + out +} + +update_version_manifest <- function(current, previous) { + new <- merge(x = current, y = previous, all = TRUE) + incremented <- manifest_compare_versions(manifest = new) == 1L + new$version_highest[incremented] <- new$version_current[incremented] + new$hash_highest[incremented] <- new$hash_current[incremented] + new +} + +manifest_compare_versions <- function(manifest) { + apply( + X = manifest, + MARGIN = 1L, + FUN = function(row) { + utils::compareVersion( + a = .subset2(row, "version_current"), + b = .subset2(row, "version_highest") + ) + } + ) +} + +versions_aligned <- function(manifest, check_hash) { + aligned <- manifest$version_current == manifest$version_highest + if (check_hash) { + aligned <- aligned & (manifest$hash_current == manifest$hash_highest) + } + aligned +} diff --git a/man/get_current_versions.Rd b/man/get_current_versions.Rd new file mode 100644 index 0000000..2f2247c --- /dev/null +++ b/man/get_current_versions.Rd @@ -0,0 +1,18 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/record_versions.R +\name{get_current_versions} +\alias{get_current_versions} +\title{Get the current versions of packages} +\usage{ +get_current_versions(repos = "https://r-releases.r-universe.dev") +} +\arguments{ +\item{repos}{Character string of package repositories to track.} +} +\value{ +A data frame of packages with their current versions and hashes. +} +\description{ +Get the current versions of packages in the repos. +} +\keyword{internal} diff --git a/man/record_versions.Rd b/man/record_versions.Rd new file mode 100644 index 0000000..8eed436 --- /dev/null +++ b/man/record_versions.Rd @@ -0,0 +1,44 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/record_versions.R +\name{record_versions} +\alias{record_versions} +\title{Record the manifest of package versions.} +\usage{ +record_versions( + manifest = "versions.json", + issues = "version_issues.json", + repos = "https://r-releases.r-universe.dev", + current = r.releases.utils::get_current_versions(repos = repos), + check_hash = TRUE +) +} +\arguments{ +\item{manifest}{Character of length 1, file path to the JSON manifest.} + +\item{issues}{Character of length 1, file path to a JSON file +which records packages with version issues.} + +\item{repos}{Character string of package repositories to track.} + +\item{current}{A data frame of current versions and hashes of packages +in \code{repos}. This argument is exposed for testing only.} + +\item{check_hash}{Logical of length 1, check hashes when judging package +version compliance. This allows \code{\link[=record_versions]{record_versions()}} to flag packages +that create new releases but keep the same version number.} +} +\value{ +\code{NULL} (invisibly). Writes a package version manifest +and a manifest of version issues as JSON files. +} +\description{ +Record the manifest of versions of packages +and their hashes. +} +\details{ +This function tracks a manifest containing the current version, +the current hash, the highest version ever released, and +the hash of the highest version ever released. It uses this information +to determine whether the package complies with best +practices for version numbers. +} diff --git a/tests/test-record_versions.R b/tests/test-record_versions.R new file mode 100644 index 0000000..c845392 --- /dev/null +++ b/tests/test-record_versions.R @@ -0,0 +1,223 @@ +# Temporary files used in the mock test. +manifest <- tempfile() +issues <- tempfile() + +# First update to the manifest. +contents <- data.frame( + package = c( + "package_unmodified", + "version_decremented", + "version_incremented", + "version_unmodified" + ), + version_current = rep("1.0.0", 4L), + hash_current = rep("hash_1.0.0", 4L) +) +r.releases.utils::record_versions( + manifest = manifest, + issues = issues, + current = contents +) +written <- jsonlite::read_json(manifest) +expected <- list( + list( + package = "package_unmodified", + version_current = "1.0.0", + hash_current = "hash_1.0.0" + ), + list( + package = "version_decremented", + version_current = "1.0.0", + hash_current = "hash_1.0.0" + ), + list( + package = "version_incremented", + version_current = "1.0.0", + hash_current = "hash_1.0.0" + ), + list( + package = "version_unmodified", + version_current = "1.0.0", + hash_current = "hash_1.0.0" + ) +) +stopifnot(identical(written, expected)) +stopifnot(!file.exists(issues)) + +# Update the manifest after no changes to packages or versions. +r.releases.utils::record_versions( + manifest = manifest, + issues = issues, + current = contents +) +written <- jsonlite::read_json(manifest) +expected <- list( + list( + package = "package_unmodified", + version_current = "1.0.0", + hash_current = "hash_1.0.0", + version_highest = "1.0.0", + hash_highest = "hash_1.0.0" + ), + list( + package = "version_decremented", + version_current = "1.0.0", + hash_current = "hash_1.0.0", + version_highest = "1.0.0", + hash_highest = "hash_1.0.0" + ), + list( + package = "version_incremented", + version_current = "1.0.0", + hash_current = "hash_1.0.0", + version_highest = "1.0.0", + hash_highest = "hash_1.0.0" + ), + list( + package = "version_unmodified", + version_current = "1.0.0", + hash_current = "hash_1.0.0", + version_highest = "1.0.0", + hash_highest = "hash_1.0.0" + ) +) +stopifnot(identical(written, expected)) +stopifnot(file.exists(issues)) +stopifnot(identical(jsonlite::read_json(issues), list())) + +# Update the packages in all the ways indicated above. +index <- contents$package == "version_decremented" +contents$version_current[index] <- "0.0.1" +contents$hash_current[index] <- "hash_0.0.1" +index <- contents$package == "version_incremented" +contents$version_current[index] <- "2.0.0" +contents$hash_current[index] <- "hash_2.0.0" +index <- contents$package == "version_unmodified" +contents$version_current[index] <- "1.0.0" +contents$hash_current[index] <- "hash_1.0.0-modified" +for (index in seq_len(2L)) { + r.releases.utils::record_versions( + manifest = manifest, + issues = issues, + current = contents + ) + written <- jsonlite::read_json(manifest) + expected <- list( + list( + package = "package_unmodified", + version_current = "1.0.0", + hash_current = "hash_1.0.0", + version_highest = "1.0.0", + hash_highest = "hash_1.0.0" + ), + list( + package = "version_decremented", + version_current = "0.0.1", + hash_current = "hash_0.0.1", + version_highest = "1.0.0", + hash_highest = "hash_1.0.0" + ), + list( + package = "version_incremented", + version_current = "2.0.0", + hash_current = "hash_2.0.0", + version_highest = "2.0.0", + hash_highest = "hash_2.0.0" + ), + list( + package = "version_unmodified", + version_current = "1.0.0", + hash_current = "hash_1.0.0-modified", + version_highest = "1.0.0", + hash_highest = "hash_1.0.0" + ) + ) + stopifnot(identical(written, expected)) + stopifnot(file.exists(issues)) + written_issues <- jsonlite::read_json(issues) + expected_issues <- list( + list( + package = "version_decremented", + version_current = "0.0.1", + hash_current = "hash_0.0.1", + version_highest = "1.0.0", + hash_highest = "hash_1.0.0" + ), + list( + package = "version_unmodified", + version_current = "1.0.0", + hash_current = "hash_1.0.0-modified", + version_highest = "1.0.0", + hash_highest = "hash_1.0.0" + ) + ) + stopifnot(identical(written_issues, expected_issues)) +} + +# Same, but do not check the hash. +r.releases.utils::record_versions( + manifest = manifest, + issues = issues, + current = contents, + check_hash = FALSE +) +written <- jsonlite::read_json(manifest) +expected <- list( + list( + package = "package_unmodified", + version_current = "1.0.0", + hash_current = "hash_1.0.0", + version_highest = "1.0.0", + hash_highest = "hash_1.0.0" + ), + list( + package = "version_decremented", + version_current = "0.0.1", + hash_current = "hash_0.0.1", + version_highest = "1.0.0", + hash_highest = "hash_1.0.0" + ), + list( + package = "version_incremented", + version_current = "2.0.0", + hash_current = "hash_2.0.0", + version_highest = "2.0.0", + hash_highest = "hash_2.0.0" + ), + list( + package = "version_unmodified", + version_current = "1.0.0", + hash_current = "hash_1.0.0-modified", + version_highest = "1.0.0", + hash_highest = "hash_1.0.0" + ) +) +stopifnot(identical(written, expected)) +stopifnot(file.exists(issues)) +written_issues <- jsonlite::read_json(issues) +expected_issues <- list( + list( + package = "version_decremented", + version_current = "0.0.1", + hash_current = "hash_0.0.1", + version_highest = "1.0.0", + hash_highest = "hash_1.0.0" + ) +) +stopifnot(identical(written_issues, expected_issues)) + +# Remove temporary files +unlink(c(manifest, issues)) + +# The manifest can be created and updated from the actual repo. +manifest <- tempfile() +issues <- tempfile() +r.releases.utils::record_versions(manifest = manifest, issues = issues) +stopifnot(file.exists(manifest)) +r.releases.utils::record_versions(manifest = manifest, issues = issues) +contents <- jsonlite::read_json(manifest) +stopifnot(is.character(contents[[1L]]$package)) +stopifnot(length(contents[[1L]]$package) == 1L) +stopifnot(file.exists(manifest)) +stopifnot(file.exists(issues)) +unlink(c(manifest, issues))