diff --git a/DESCRIPTION b/DESCRIPTION index 1f7e472f..d0864743 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -57,6 +57,7 @@ Suggests: callr, chromote, covr, + curl, DBI, desc, devtools, diff --git a/NAMESPACE b/NAMESPACE index 4b6ad38e..5bc2dfe1 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -37,6 +37,7 @@ export(btw_this) export(btw_tool_agent_subagent) export(btw_tool_cran_package) export(btw_tool_cran_search) +export(btw_tool_cran_versions) export(btw_tool_docs_available_vignettes) export(btw_tool_docs_help_page) export(btw_tool_docs_package_help_topics) diff --git a/R/btw_this.R b/R/btw_this.R index 90d39f24..e0665d67 100644 --- a/R/btw_this.R +++ b/R/btw_this.R @@ -80,9 +80,14 @@ as_btw_capture <- function(x) { #' * `btw_this("@help dplyr across")` - space-separated format #' * `btw_this("@help across")` - searches all packages #' -#' * `"@news {{package_name}} {{search_term}}"` \cr -#' Include the release notes (NEWS) from the latest package release, e.g. -#' `"@news dplyr"`, or that match a search term, e.g. `"@news dplyr join_by"`. +#' * `"@news {{package_name}} [{{version}}] [{{search_term}}]"` \cr +#' Include the release notes (NEWS) from the latest package release, a +#' specific version, or entries that match a search term, e.g. `"@news dplyr"`, +#' `"@news dplyr v1.1.4"`, or `"@news dplyr join_by"`. +#' +#' * `"@cran versions {{package_name}}"` \cr +#' Include CRAN release versions and dates for a package, e.g. +#' `"@cran versions dplyr"`. #' #' * `"@url {{url}}"` \cr #' Include the contents of a web page at the specified URL as markdown, e.g. @@ -257,6 +262,7 @@ dispatch_at_command <- function(cmd, caller_env) { btw_this_cmd <- switch( cmd$command, news = btw_this_news, + cran = btw_this_cran, url = btw_this_url, pkg = btw_this_pkg, help = btw_this_help, @@ -276,8 +282,8 @@ btw_this_news <- function(args) { if (!nzchar(args)) { cli::cli_abort( c( - "{.code @news} must be followed by a package name and an optional search term.", - "i" = 'e.g. {.code "@news dplyr"} or {.code "@news dplyr join_by"}' + "{.code @news} must be followed by a package name and optional version or search term.", + "i" = 'e.g. {.code "@news dplyr"}, {.code "@news dplyr v1.1.4"}, or {.code "@news dplyr join_by"}' ), call = caller_env(n = 2) ) @@ -285,13 +291,41 @@ btw_this_news <- function(args) { parts <- strsplit(args, " ", fixed = TRUE)[[1]] package_name <- parts[1] - search_term <- if (length(parts) > 1) { - paste(parts[-1], collapse = " ") + has_version <- length(parts) > 1 && + grepl("^v\\d+(?:[.-]\\d+)*$", parts[2], perl = TRUE) + version <- if (has_version) sub("^v", "", parts[2]) else NULL + search_start <- if (has_version) 3 else 2 + search_term <- if (length(parts) >= search_start) { + paste(parts[search_start:length(parts)], collapse = " ") } else { "" } - I(btw_tool_docs_package_news_impl(package_name, search_term)@value) + I( + btw_tool_docs_package_news_impl( + package_name, + search_term = search_term, + version = version + )@value + ) +} + +btw_this_cran <- function(args) { + parts <- strsplit(args, " ", fixed = TRUE)[[1]] + command <- parts[1] + package_name <- if (length(parts) > 1) parts[2] else "" + + if (!identical(command, "versions") || !nzchar(package_name) || length(parts) > 2) { + cli::cli_abort( + c( + "{.code @cran} must be followed by {.code versions} and a package name.", + "i" = 'e.g. {.code "@cran versions dplyr"}' + ), + call = caller_env(n = 2) + ) + } + + I(btw_tool_cran_versions_impl(package_name)@value) } btw_this_url <- function(args) { diff --git a/R/tool-cran.R b/R/tool-cran.R index d1e48a0c..5f7e02a4 100644 --- a/R/tool-cran.R +++ b/R/tool-cran.R @@ -300,6 +300,245 @@ btw_this.cran_package <- function(x, ...) { return(md_text) } +#' Tool: List CRAN package versions +#' +#' @description +#' Lists the current CRAN version and archived package versions with their +#' release dates. Archive dates are taken from CRAN's package archive index. +#' +#' @param package_name The name of a package on CRAN. +#' @param after Only return releases on or after this ISO date (`YYYY-MM-DD`). +#' @param before Only return releases on or before this ISO date +#' (`YYYY-MM-DD`). +#' @inheritParams btw_tool_docs_package_news +#' +#' @returns A data frame with the version, release date and timestamp, current +#' release status, and source tarball URL for each package release. +#' @seealso [btw_tools()] +#' @family cran tools +#' @export +btw_tool_cran_versions <- function(package_name, after, before, `_intent`) {} + +btw_tool_cran_versions_impl <- function( + package_name, + after = NULL, + before = NULL +) { + versions <- cran_versions(package_name, after = after, before = before) + value <- paste( + sprintf("### CRAN releases for %s", package_name), + md_table(versions[c("version", "released")]), + sep = "\n\n" + ) + + btw_tool_result( + value = value, + data = versions, + display = list( + title = sprintf("{%s} CRAN Releases", package_name), + markdown = value, + show_request = FALSE + ) + ) +} + +cran_versions <- function(package_name, after = NULL, before = NULL) { + check_string(package_name) + after <- as_cran_release_date(after, "after") + before <- as_cran_release_date(before, "before") + if (!is.null(after) && !is.null(before) && after > before) { + cli::cli_abort("{.arg after} must be on or before {.arg before}.") + } + + current <- cran_current_version(package_name) + archived <- cran_archive_versions(package_name) + versions <- rbind(current, archived) + + if (!nrow(versions)) { + cli::cli_abort("Package {.pkg {package_name}} was not found on CRAN.") + } + + versions <- versions[!duplicated(versions$version), ] + if (!is.null(after)) { + versions <- versions[versions$released >= after, ] + } + if (!is.null(before)) { + versions <- versions[versions$released <= before, ] + } + versions[order(base::package_version(versions$version), decreasing = TRUE), ] +} + +as_cran_release_date <- function(x, arg) { + check_string(x, allow_null = TRUE) + if (is.null(x)) { + return(NULL) + } + if (!grepl("^\\d{4}-\\d{2}-\\d{2}$", x)) { + cli::cli_abort("{.arg {arg}} must be an ISO date like {.val 2023-01-01}.") + } + + date <- as.Date(x) + if (is.na(date)) { + cli::cli_abort("{.arg {arg}} must be a valid ISO date.") + } + date +} + +cran_archive_versions <- function(package_name) { + archive <- tryCatch( + cran_archive_page(package_name), + error = function(e) NULL + ) + if (is.null(archive)) { + return(cran_versions_data()) + } + + rows <- xml2::xml_find_all( + archive, + "//tr[td/a[contains(@href, '.tar.gz')]]" + ) + if (!length(rows)) { + return(cran_versions_data()) + } + + hrefs <- xml2::xml_attr( + xml2::xml_find_first(rows, ".//a[contains(@href, '.tar.gz')]"), + "href" + ) + pattern <- paste0( + "^", + gsub(".", "\\.", package_name, fixed = TRUE), + "_(.+)\\.tar\\.gz$" + ) + matches <- regexec(pattern, hrefs) + versions <- vapply( + regmatches(hrefs, matches), + function(x) if (length(x) == 2) x[2] else NA_character_, + character(1) + ) + + dates <- vapply(rows, function(row) { + cells <- xml2::xml_find_all(row, "./td") + trimws(xml2::xml_text(cells[[3]])) + }, character(1)) + + keep <- !is.na(versions) + released_at <- format_cran_timestamp(dates[keep]) + cran_versions_data( + version = versions[keep], + released = as.Date(released_at), + released_at = released_at, + current = FALSE, + tarball_url = paste0( + "https://cran.r-project.org/src/contrib/Archive/", + package_name, + "/", + hrefs[keep] + ) + ) +} + +cran_archive_page <- function(package_name) { + xml2::read_html( + sprintf( + "https://cran.r-project.org/src/contrib/Archive/%s/", + utils::URLencode(package_name, reserved = TRUE) + ) + ) +} + +cran_current_version <- function(package_name) { + packages <- utils::available.packages(repos = "https://cran.r-project.org") + if (!package_name %in% rownames(packages)) { + return(cran_versions_data()) + } + + released_at <- format_cran_timestamp(packages[package_name, "Published"]) + cran_versions_data( + version = packages[package_name, "Version"], + released = as.Date(released_at), + released_at = released_at, + current = TRUE, + tarball_url = sprintf( + "https://cran.r-project.org/src/contrib/%s_%s.tar.gz", + package_name, + packages[package_name, "Version"] + ) + ) +} + +format_cran_timestamp <- function(x) { + format( + as.POSIXct(x, tz = "UTC"), + "%Y-%m-%dT%H:%M:%SZ", + tz = "UTC" + ) +} + +cran_versions_data <- function( + version = character(), + released = as.Date(character()), + released_at = format_cran_timestamp(released), + current = FALSE, + tarball_url = NA_character_ +) { + n <- length(version) + data.frame( + version = as.character(version), + released = rep_len(as.Date(released), n), + released_at = rep_len(as.character(released_at), n), + current = rep_len(as.logical(current), n), + tarball_url = rep_len(as.character(tarball_url), n), + stringsAsFactors = FALSE + ) +} + +btw_has_internet <- function() { + rlang::is_installed("curl") && isTRUE(curl::has_internet()) +} + +btw_can_register_cran_versions <- function() { + btw_has_internet() +} + +.btw_add_to_tools( + name = "btw_tool_cran_versions", + group = "cran", + alias_group = "search", + can_register = function() btw_can_register_cran_versions(), + tool = function() { + ellmer::tool( + btw_tool_cran_versions_impl, + name = "btw_tool_cran_versions", + description = paste( + "List a CRAN package's release versions and dates.", + "Includes the current CRAN release and versions in the CRAN archive." + ), + annotations = ellmer::tool_annotations( + title = "CRAN Package Releases", + read_only_hint = TRUE, + open_world_hint = TRUE, + idempotent_hint = FALSE, + btw_can_register = function() btw_can_register_cran_versions() + ), + arguments = list( + package_name = ellmer::type_string( + "The name of a package on CRAN.", + required = TRUE + ), + after = ellmer::type_string( + "Only return releases on or after this ISO date (YYYY-MM-DD).", + required = FALSE + ), + before = ellmer::type_string( + "Only return releases on or before this ISO date (YYYY-MM-DD).", + required = FALSE + ) + ) + ) + } +) + .btw_add_to_tools( name = "btw_tool_cran_package", group = "cran", diff --git a/R/tool-docs-news.R b/R/tool-docs-news.R index 8eb8817b..19e91480 100644 --- a/R/tool-docs-news.R +++ b/R/tool-docs-news.R @@ -26,33 +26,47 @@ NULL #' #' btw_tool_docs_package_news("dplyr", "join_by") #' +#' # Read the NEWS entries for a specific installed package version +#' btw_tool_docs_package_news("dplyr", version = "1.1.4") +#' #' @param package_name The name of the package as a string, e.g. `"shiny"`. #' @param search_term A regular expression to search for in the NEWS entries. #' If empty, the release notes of the current installed version is included. +#' @param version An installed package version whose NEWS entries should be +#' included. If `NULL` (the default), the current installed version is used. #' @param _intent An optional string describing the intent of the tool use. #' When the tool is used by an LLM, the model will use this argument to #' explain why it called the tool. #' -#' @returns Returns the release notes for the currently installed version of the -#' package, or the release notes matching the search term. +#' @returns Returns the release notes for the requested version (or the +#' currently installed version by default), or matching entries from the NEWS +#' file. #' #' @seealso [btw_tools()] #' @family docs tools #' @export #' @rdname btw_tool_docs_package_news -btw_tool_docs_package_news <- function(package_name, search_term, `_intent`) {} - -btw_tool_docs_package_news_impl <- function(package_name, search_term = "") { - news <- package_news_search(package_name, search_term %||% "") +btw_tool_docs_package_news <- function(package_name, search_term, version, `_intent`) {} + +btw_tool_docs_package_news_impl <- function( + package_name, + search_term = "", + version = NULL +) { + news <- package_news_search( + package_name, + search_term = search_term %||% "", + version = version + ) if (nrow(news) == 0) { if (nzchar(search_term)) { cli::cli_abort( - "No NEWS entries found for package '{package_name}' matching '{search_term}'." + "No NEWS entries found for package '{package_name}'{if (!is.null(version)) paste0(' v', version)} matching '{search_term}'." ) } else { cli::cli_abort( - "No NEWS entries found for package '{package_name}' v{package_version(package_name)}." + "No NEWS entries found for package '{package_name}' v{version %||% package_version(package_name)}." ) } } @@ -102,6 +116,13 @@ btw_tool_docs_package_news_impl <- function(package_name, search_term = "") { "If empty, the tool returns the release notes for the current installed version." ), required = FALSE + ), + version = ellmer::type_string( + paste( + "A specific installed package version whose NEWS entries to return.", + "When omitted, the current installed version is used." + ), + required = FALSE ) ) ) @@ -175,7 +196,14 @@ r_docs_versions <- function() { c("R", sprintf("R-%d", seq_len(R.version$major))) } -package_news_search <- function(package_name, search_term = "") { +package_news_search <- function( + package_name, + search_term = "", + version = NULL +) { + check_string(search_term) + check_string(version, allow_null = TRUE) + r_docs <- r_docs_versions() if (!package_name %in% r_docs) { check_installed(package_name) @@ -191,14 +219,18 @@ package_news_search <- function(package_name, search_term = "") { } news$Version <- base::package_version(news$Version) + if (!is.null(version)) { + news <- news[news$Version == base::package_version(version), ] + } + if (!nzchar(search_term)) { - version <- + selected_version <- version %||% if (!package_name %in% setdiff(r_docs, "R")) { package_version(package_name) } else { max(news$Version) } - news <- news[news$Version == version, ] + news <- news[news$Version == selected_version, ] news$match <- news$HTML } else { news$match <- map_chr( @@ -208,12 +240,14 @@ package_news_search <- function(package_name, search_term = "") { ) news <- news[!is.na(news$match), ] - # Take at most the results from the 5 most recent versions - versions <- unique(news$Version) - if (length(versions) > 5) { - versions <- sort(versions, decreasing = TRUE)[1:5] + if (is.null(version)) { + # Take at most the results from the 5 most recent versions + versions <- unique(news$Version) + if (length(versions) > 5) { + versions <- sort(versions, decreasing = TRUE)[1:5] + } + news <- news[news$Version %in% versions, ] } - news <- news[news$Version %in% versions, ] } class(news) <- c("btw_filtered_news_db", class(news)) diff --git a/exec/btw.R b/exec/btw.R index d0954b02..09dee304 100755 --- a/exec/btw.R +++ b/exec/btw.R @@ -257,10 +257,32 @@ btw_docs_vignette <- function(package, name, list, no_dev = FALSE) { } } -btw_docs_news <- function(package, search, no_dev = FALSE) { +btw_docs_news <- function(package, version, search, no_dev = FALSE) { btw_maybe_load_dev_package(package, no_dev) search_term <- if (has_value(search)) search else "" - btw_output(btw:::btw_tool_docs_package_news_impl(package, search_term)) + if (!is.null(version)) { + supplied_version <- version + version <- sub("^v", "", version) + if (!grepl("^\\d+(?:[.-]\\d+)*$", version, perl = TRUE)) { + command <- paste( + "btw docs news", + package, + "--search", + shQuote(supplied_version) + ) + cli::cli_abort(c( + "The optional positional argument to {.code btw docs news} must be a package version.", + "i" = "To search NEWS, run {.run {command}}." + )) + } + } + btw_output( + btw:::btw_tool_docs_package_news_impl( + package, + search_term = search_term, + version = version + ) + ) } btw_pkg_document <- function(path) { @@ -544,6 +566,24 @@ btw_cran_search <- function(query, format, n, json = FALSE) { } } +btw_cran_versions <- function( + package, + after = NULL, + before = NULL, + json = FALSE +) { + result <- btw:::btw_tool_cran_versions_impl( + package, + after = if (has_value(after)) after else NULL, + before = if (has_value(before)) before else NULL + ) + if (json) { + btw_json_output(S7::prop(result, "extra")$data) + } else { + btw_output(result) + } +} + btw_skills_get <- function(source, skills, all, json = FALSE) { is_github <- grepl("/", source, fixed = TRUE) skill_dirs <- if (is_github) { @@ -875,11 +915,17 @@ switch( news = { #| description: Package name. package <- NULL + #| description: Package version whose NEWS entries to show. A leading "v" is accepted. + #| required: false + version <- NULL #| description: Search term to filter NEWS entries. #| short: 's' search <- "" - tryCatch(btw_docs_news(package, search, no_dev), error = btw_error) + tryCatch( + btw_docs_news(package, version, search, no_dev), + error = btw_error + ) } ) if (docs_cmd == "") btw_self_help("docs") @@ -1116,6 +1162,23 @@ switch( #| description: Package name. package <- NULL tryCatch(btw_cran_info(package, json), error = btw_error) + }, + + #| title: List CRAN package releases + #| examples: + #| - "# List v1.1.4 and every newer release" + #| - "btw cran versions dplyr --json | jq --arg version '1.1.4' 'first(.[] | select(.version == $version)) as $release | map(select(.released_at >= $release.released_at))'" + versions = { + #| description: Package name. + package <- NULL + #| description: Only return releases on or after this ISO date (YYYY-MM-DD). + after <- "" + #| description: Only return releases on or before this ISO date (YYYY-MM-DD). + before <- "" + tryCatch( + btw_cran_versions(package, after, before, json), + error = btw_error + ) } ) if (cran_cmd == "") btw_self_help("cran") diff --git a/inst/cli-skill/r-btw-cli/SKILL.md b/inst/cli-skill/r-btw-cli/SKILL.md index 4a708669..8c939e27 100644 --- a/inst/cli-skill/r-btw-cli/SKILL.md +++ b/inst/cli-skill/r-btw-cli/SKILL.md @@ -22,7 +22,7 @@ btw docs topics [--only help|vignettes] [--json] List help topics and vig btw docs help [-p ] Read an R help page btw docs help :: Read a specific help page (scoped) btw docs vignette [-n ] Read a vignette (--list to list available) -btw docs news [-s ] Read package NEWS/changelog +btw docs news [version] [-s ] Read package NEWS/changelog ``` Use `btw pkg` to run development tasks on an R package under active development. @@ -67,6 +67,7 @@ Use `btw cran` to search CRAN for packages or retrieve basic metadata (version, ``` btw cran search [-n ] [--json] Search CRAN for packages btw cran info [--json] CRAN package details +btw cran versions [--after ] [--before ] [--json] List CRAN release versions and dates ``` Use `btw skills` to discover and fetch skills from R packages or GitHub repositories. diff --git a/man/btw_this.character.Rd b/man/btw_this.character.Rd index 6cf072e2..233842f7 100644 --- a/man/btw_this.character.Rd +++ b/man/btw_this.character.Rd @@ -50,9 +50,13 @@ topics using \code{\link[=btw_tool_docs_help_page]{btw_tool_docs_help_page()}}. \item \code{btw_this("@help dplyr across")} - space-separated format \item \code{btw_this("@help across")} - searches all packages } -\item \code{"@news {{package_name}} {{search_term}}"} \cr -Include the release notes (NEWS) from the latest package release, e.g. -\code{"@news dplyr"}, or that match a search term, e.g. \code{"@news dplyr join_by"}. +\item \code{"@news {{package_name}} [{{version}}] [{{search_term}}]"} \cr +Include the release notes (NEWS) from the latest package release, a +specific version, or entries that match a search term, e.g. \code{"@news dplyr"}, +\code{"@news dplyr v1.1.4"}, or \code{"@news dplyr join_by"}. +\item \code{"@cran versions {{package_name}}"} \cr +Include CRAN release versions and dates for a package, e.g. +\code{"@cran versions dplyr"}. \item \code{"@url {{url}}"} \cr Include the contents of a web page at the specified URL as markdown, e.g. \code{"@url https://cran.r-project.org/doc/FAQ/R-FAQ.html"}. Requires the diff --git a/man/btw_tool_cran_package.Rd b/man/btw_tool_cran_package.Rd index 7b80b5de..069535fc 100644 --- a/man/btw_tool_cran_package.Rd +++ b/man/btw_tool_cran_package.Rd @@ -29,6 +29,7 @@ cli::cat_line( } \seealso{ Other cran tools: -\code{\link[=btw_tool_cran_search]{btw_tool_cran_search()}} +\code{\link[=btw_tool_cran_search]{btw_tool_cran_search()}}, +\code{\link[=btw_tool_cran_versions]{btw_tool_cran_versions()}} } \concept{cran tools} diff --git a/man/btw_tool_cran_search.Rd b/man/btw_tool_cran_search.Rd index 547732a6..e046fe71 100644 --- a/man/btw_tool_cran_search.Rd +++ b/man/btw_tool_cran_search.Rd @@ -55,6 +55,7 @@ btw( \code{\link[=btw_tools]{btw_tools()}} Other cran tools: -\code{\link[=btw_tool_cran_package]{btw_tool_cran_package()}} +\code{\link[=btw_tool_cran_package]{btw_tool_cran_package()}}, +\code{\link[=btw_tool_cran_versions]{btw_tool_cran_versions()}} } \concept{cran tools} diff --git a/man/btw_tool_cran_versions.Rd b/man/btw_tool_cran_versions.Rd new file mode 100644 index 00000000..e721dc4a --- /dev/null +++ b/man/btw_tool_cran_versions.Rd @@ -0,0 +1,41 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/tool-cran.R +\name{btw_tool_cran_versions} +\alias{btw_tool_cran_versions} +\title{Tool: List CRAN package versions} +\usage{ +btw_tool_cran_versions( + package_name, + after = NULL, + before = NULL, + `_intent` = "" +) +} +\arguments{ +\item{package_name}{The name of a package on CRAN.} + +\item{after}{Only return releases on or after this ISO date (\code{YYYY-MM-DD}).} + +\item{before}{Only return releases on or before this ISO date +(\code{YYYY-MM-DD}).} + +\item{_intent}{An optional string describing the intent of the tool use. +When the tool is used by an LLM, the model will use this argument to +explain why it called the tool.} +} +\value{ +A data frame with the version, release date and timestamp, current +release status, and source tarball URL for each package release. +} +\description{ +Lists the current CRAN version and archived package versions with their +release dates. Archive dates are taken from CRAN's package archive index. +} +\seealso{ +\code{\link[=btw_tools]{btw_tools()}} + +Other cran tools: +\code{\link[=btw_tool_cran_package]{btw_tool_cran_package()}}, +\code{\link[=btw_tool_cran_search]{btw_tool_cran_search()}} +} +\concept{cran tools} diff --git a/man/btw_tool_docs_package_news.Rd b/man/btw_tool_docs_package_news.Rd index 4a1c9ab1..1faa855d 100644 --- a/man/btw_tool_docs_package_news.Rd +++ b/man/btw_tool_docs_package_news.Rd @@ -4,7 +4,12 @@ \alias{btw_tool_docs_package_news} \title{Tool: Package Release Notes} \usage{ -btw_tool_docs_package_news(package_name, search_term = "", `_intent` = "") +btw_tool_docs_package_news( + package_name, + search_term = "", + version = NULL, + `_intent` = "" +) } \arguments{ \item{package_name}{The name of the package as a string, e.g. \code{"shiny"}.} @@ -12,13 +17,17 @@ btw_tool_docs_package_news(package_name, search_term = "", `_intent` = "") \item{search_term}{A regular expression to search for in the NEWS entries. If empty, the release notes of the current installed version is included.} +\item{version}{An installed package version whose NEWS entries should be +included. If \code{NULL} (the default), the current installed version is used.} + \item{_intent}{An optional string describing the intent of the tool use. When the tool is used by an LLM, the model will use this argument to explain why it called the tool.} } \value{ -Returns the release notes for the currently installed version of the -package, or the release notes matching the search term. +Returns the release notes for the requested version (or the +currently installed version by default), or matching entries from the NEWS +file. } \description{ Include release notes for a package, either the release notes for the most @@ -43,6 +52,9 @@ if (interactive()) { # can be slow btw_tool_docs_package_news("dplyr") btw_tool_docs_package_news("dplyr", "join_by") + +# Read the NEWS entries for a specific installed package version +btw_tool_docs_package_news("dplyr", version = "1.1.4") \dontshow{\}) # examplesIf} } \seealso{ diff --git a/man/btw_tools.Rd b/man/btw_tools.Rd index 2866d13a..29c20ed4 100644 --- a/man/btw_tools.Rd +++ b/man/btw_tools.Rd @@ -42,6 +42,7 @@ this function have access to the tools: Name \tab Description \cr \code{\link[=btw_tool_cran_package]{btw_tool_cran_package()}} \tab Describe a CRAN package. \cr \code{\link[=btw_tool_cran_search]{btw_tool_cran_search()}} \tab Search for an R package on CRAN. \cr + \code{\link[=btw_tool_cran_versions]{btw_tool_cran_versions()}} \tab List a CRAN package's release versions and dates. \cr } } diff --git a/tests/testthat/helpers.R b/tests/testthat/helpers.R index 5b4a3b7e..7cb995c7 100644 --- a/tests/testthat/helpers.R +++ b/tests/testthat/helpers.R @@ -104,6 +104,7 @@ local_enable_tools <- function( rstudioapi_has_source_editor_context = TRUE, btw_can_register_git_tool = TRUE, btw_can_register_gh_tool = TRUE, + btw_can_register_cran_versions = TRUE, btw_can_register_run_r_tool = TRUE, btw_can_register_subagent_tool = TRUE, .env = caller_env() @@ -124,6 +125,9 @@ local_enable_tools <- function( ), btw_can_register_git_tool = maybe_set(btw_can_register_git_tool), btw_can_register_gh_tool = maybe_set(btw_can_register_gh_tool), + btw_can_register_cran_versions = maybe_set( + btw_can_register_cran_versions + ), btw_can_register_run_r_tool = maybe_set(btw_can_register_run_r_tool), btw_can_register_subagent_tool = maybe_set(btw_can_register_subagent_tool) )) diff --git a/tests/testthat/test-btw_this.R b/tests/testthat/test-btw_this.R index a8bbd769..e070f8f7 100644 --- a/tests/testthat/test-btw_this.R +++ b/tests/testthat/test-btw_this.R @@ -530,8 +530,14 @@ test_that("@ commands are case-sensitive", { test_that("@news command works", { local_mocked_bindings( - btw_tool_docs_package_news_impl = function(package_name, search_term) { - btw_tool_result(paste("News for", package_name, search_term)) + btw_tool_docs_package_news_impl = function( + package_name, + search_term, + version = NULL + ) { + btw_tool_result( + paste(c("News for", package_name, version, search_term), collapse = " ") + ) } ) @@ -540,6 +546,12 @@ test_that("@news command works", { result <- btw_this("@news dplyr join_by") expect_match(result, "News for dplyr join_by") + + result <- btw_this("@news dplyr v1.1.4 join_by") + expect_match(result, "News for dplyr 1.1.4 join_by") + + result <- btw_this("@news dplyr 1.1.4") + expect_match(result, "News for dplyr 1.1.4") }) test_that("@news requires package name", { @@ -554,6 +566,29 @@ test_that("@news requires package name", { ) }) +# Test @cran command ---------------------------------------------------------- + +test_that("@cran versions command works", { + local_mocked_bindings( + btw_tool_cran_versions_impl = function(package_name) { + btw_tool_result(paste("CRAN versions for", package_name)) + } + ) + + expect_match(btw_this("@cran versions dplyr"), "CRAN versions for dplyr") +}) + +test_that("@cran versions requires a package name", { + expect_error( + btw_this("@cran versions"), + "@cran.*versions.*package name" + ) + expect_error( + btw_this("@cran dplyr"), + "@cran.*versions.*package name" + ) +}) + # Test @url command ----------------------------------------------------------- test_that("@url requires chromote", { diff --git a/tests/testthat/test-cli.R b/tests/testthat/test-cli.R index 7f3bf74a..caea274b 100644 --- a/tests/testthat/test-cli.R +++ b/tests/testthat/test-cli.R @@ -176,6 +176,40 @@ test_that("btw docs news -s searches news", { expect_equal(env$search, "filter") }) +test_that("btw docs news reads that version", { + local_skip_pandoc_convert_text() + seen_version <- NULL + local_mocked_bindings( + btw_tool_docs_package_news_impl = function( + package_name, + search_term, + version + ) { + seen_version <<- version + btw_tool_result("NEWS") + } + ) + env <- run_btw_quietly("docs", "news", "dplyr", "v1.1.4") + expect_equal(env$package, "dplyr") + expect_equal(env$version, "v1.1.4") + expect_equal(seen_version, "1.1.4") + + env <- run_btw_quietly("docs", "news", "dplyr", "1.1.4") + expect_equal(env$version, "1.1.4") + expect_equal(seen_version, "1.1.4") +}) + +test_that("btw docs news guides positional search terms to --search", { + result <- run_btw_subprocess("docs", "news", "dplyr", "a search term") + expect_equal(result$status, 1) + expect_match(result$stderr, "must be a package\\s+version") + expect_match( + result$stderr, + "btw docs news dplyr --search 'a search term'", + fixed = TRUE + ) +}) + test_that("btw docs news errors for non-existent package", { result <- run_btw_subprocess("docs", "news", "nonexistent_pkg_xyz") expect_equal(result$status, 1) @@ -733,6 +767,61 @@ test_that("btw cran info --json outputs valid JSON", { expect_equal(parsed$Version, "0.3.5") }) +test_that("btw cran versions supports text and JSON output", { + seen_dates <- NULL + local_mocked_bindings( + cran_versions = function(package_name, after = NULL, before = NULL) { + seen_dates <<- list(after = after, before = before) + cran_versions_data( + version = c("1.1.4", "1.1.3"), + released = as.Date(c("2023-11-17", "2023-10-15")), + released_at = c("2023-11-17T00:00:00Z", "2023-10-15T00:00:00Z"), + current = c(TRUE, FALSE), + tarball_url = c( + "https://cran.r-project.org/src/contrib/dplyr_1.1.4.tar.gz", + "https://cran.r-project.org/src/contrib/Archive/dplyr/dplyr_1.1.3.tar.gz" + ) + ) + } + ) + + env <- run_btw_quietly("cran", "versions", "dplyr") + expect_equal(env$package, "dplyr") + expect_null(seen_dates$after) + expect_null(seen_dates$before) + + env <- run_btw_quietly( + "cran", + "versions", + "dplyr", + "--after", + "2023-01-01", + "--before", + "2023-12-31", + "--json" + ) + expect_equal(seen_dates$after, "2023-01-01") + expect_equal(seen_dates$before, "2023-12-31") + parsed <- jsonlite::fromJSON(paste(env$.output, collapse = "\n")) + expect_equal(parsed$version, c("1.1.4", "1.1.3")) + expect_equal(parsed$released, c("2023-11-17", "2023-10-15")) + expect_equal( + parsed$released_at, + c("2023-11-17T00:00:00Z", "2023-10-15T00:00:00Z") + ) + expect_equal(parsed$current, c(TRUE, FALSE)) +}) + +test_that("btw cran versions help includes a jq release-selection example", { + output <- capture.output(run_btw("cran", "versions", "--help")) + output <- paste(output, collapse = "\n") + + expect_match(output, "# List v1.1.4 and every newer release", fixed = TRUE) + expect_match(output, "jq --arg version") + expect_match(output, "released_at", fixed = TRUE) + expect_match(output, "$release.released_at", fixed = TRUE) +}) + # error handling --------------------------------------------------------- test_that("btw pkg error exits with code 1 and message on stderr", { diff --git a/tests/testthat/test-deprecated.R b/tests/testthat/test-deprecated.R index 43f8ad5f..dce5342e 100644 --- a/tests/testthat/test-deprecated.R +++ b/tests/testthat/test-deprecated.R @@ -16,6 +16,7 @@ test_that("deprecated session tools emit deprecation warnings", { }) test_that("deprecated search tools emit deprecation warnings", { + skip_on_cran() skip_if_offline() expect_warning( @@ -108,6 +109,7 @@ test_that("new sessioninfo tools work without warnings", { }) test_that("new cran tools work without warnings", { + skip_on_cran() skip_if_offline() expect_no_warning(btw_tool_cran_search(query = "shiny")) diff --git a/tests/testthat/test-tool-cran.R b/tests/testthat/test-tool-cran.R index eba5b795..3604367a 100644 --- a/tests/testthat/test-tool-cran.R +++ b/tests/testthat/test-tool-cran.R @@ -39,6 +39,7 @@ test_that("btw_tool_cran_search() snapshots", { }) test_that("btw_tool_cran_search() warns for too many results", { + skip_on_cran() skip_if_offline() expect_warning( @@ -52,6 +53,7 @@ test_that("btw_tool_cran_search() warns for too many results", { }) test_that("btw_tool_cran_package()", { + skip_on_cran() skip_if_offline() search_result <- pkgsearch::cran_package("anyflights") @@ -85,3 +87,109 @@ test_that("btw_tool_cran_package() snapshots", { cli::cat_line(btw_this(mock_cran_package("anyflights"))) ) }) + +test_that("btw_can_register_cran_versions() requires an internet connection", { + local_mocked_bindings(btw_has_internet = function() FALSE) + expect_false(btw_can_register_cran_versions()) +}) + +test_that("btw_tool_cran_versions registration requires internet", { + local_mocked_bindings(btw_can_register_cran_versions = function() FALSE) + expect_false("btw_tool_cran_versions" %in% names(btw_tools())) + expect_false("btw_tool_cran_versions" %in% names(btw_tools("cran"))) + + local_mocked_bindings(btw_can_register_cran_versions = function() TRUE) + expect_true("btw_tool_cran_versions" %in% names(btw_tools("cran"))) +}) + +test_that("btw_tool_cran_versions() combines archive and current releases", { + archive <- xml2::read_html( + paste( + "", + "", + "", + "
dplyr_1.0.0.tar.gz2020-05-29 17:00
dplyr_1.1.0.tar.gz2023-03-10 12:00
" + ) + ) + local_mocked_bindings( + cran_archive_page = function(package_name) archive, + cran_current_version = function(package_name) { + cran_versions_data( + version = "1.1.4", + released = as.Date("2023-11-17"), + released_at = "2023-11-17T00:00:00Z", + current = TRUE, + tarball_url = "https://cran.r-project.org/src/contrib/dplyr_1.1.4.tar.gz" + ) + } + ) + + result <- btw_tool_cran_versions("dplyr") + expect_btw_tool_result(result, has_data = TRUE) + expect_equal(result@extra$data$version, c("1.1.4", "1.1.0", "1.0.0")) + expect_equal( + as.character(result@extra$data$released), + c("2023-11-17", "2023-03-10", "2020-05-29") + ) + expect_equal( + result@extra$data$released_at, + c("2023-11-17T00:00:00Z", "2023-03-10T12:00:00Z", "2020-05-29T17:00:00Z") + ) + expect_equal(result@extra$data$current, c(TRUE, FALSE, FALSE)) + expect_equal( + result@extra$data$tarball_url, + c( + "https://cran.r-project.org/src/contrib/dplyr_1.1.4.tar.gz", + "https://cran.r-project.org/src/contrib/Archive/dplyr/dplyr_1.1.0.tar.gz", + "https://cran.r-project.org/src/contrib/Archive/dplyr/dplyr_1.0.0.tar.gz" + ) + ) + expect_match(result@value, "CRAN releases for dplyr") +}) + +test_that("cran_versions() supports archived packages", { + local_mocked_bindings( + cran_archive_versions = function(package_name) { + cran_versions_data("0.1.0", as.Date("2020-01-01")) + }, + cran_current_version = function(package_name) cran_versions_data() + ) + + expect_equal(cran_versions("archivedpkg")$version, "0.1.0") +}) + +test_that("cran_versions_data() returns an empty, typed result", { + result <- cran_versions_data() + expect_equal(nrow(result), 0) + expect_named( + result, + c("version", "released", "released_at", "current", "tarball_url") + ) + expect_s3_class(result$released, "Date") + expect_type(result$current, "logical") +}) + +test_that("cran_versions() filters releases by inclusive date range", { + local_mocked_bindings( + cran_current_version = function(package_name) { + cran_versions_data("1.2.0", as.Date("2024-01-15")) + }, + cran_archive_versions = function(package_name) { + cran_versions_data( + c("1.1.0", "1.0.0"), + as.Date(c("2023-06-01", "2022-12-31")) + ) + } + ) + + expect_equal( + cran_versions("dplyr", after = "2023-06-01", before = "2024-01-15")$version, + c("1.2.0", "1.1.0") + ) + expect_equal(nrow(cran_versions("dplyr", after = "2025-01-01")), 0) + expect_error(cran_versions("dplyr", after = "not-a-date"), "ISO date") + expect_error( + cran_versions("dplyr", after = "2024-01-02", before = "2024-01-01"), + "on or before" + ) +}) diff --git a/tests/testthat/test-tool-docs-news.R b/tests/testthat/test-tool-docs-news.R index 68de0c8a..cad67fee 100644 --- a/tests/testthat/test-tool-docs-news.R +++ b/tests/testthat/test-tool-docs-news.R @@ -28,6 +28,41 @@ test_that("btw_tool_docs_package_news() with search term", { ) }) +test_that("btw_tool_docs_package_news() selects a requested version", { + local_skip_pandoc_convert_text() + local_mocked_bindings( + package_news = function(package_name) { + structure( + data.frame( + Version = c("1.1.4", "1.1.3"), + Category = "", + HTML = c("

Current release

", "

Previous release

"), + stringsAsFactors = FALSE + ), + package = package_name, + class = c("news_db", "data.frame") + ) + } + ) + + result <- btw_tool_docs_package_news("dplyr", version = "1.1.3")@value + expect_match(result, "dplyr v1.1.3") + expect_match(result, "Previous release") + expect_no_match(result, "Current release") + + result <- btw_tool_docs_package_news( + "dplyr", + search_term = "previous", + version = "1.1.3" + )@value + expect_match(result, "Previous release") + + expect_equal( + I(btw_tool_docs_package_news("dplyr", version = "1.1.3")@value), + btw_this("@news dplyr v1.1.3") + ) +}) + test_that("btw_tool_docs_package_news() with non-existent package", { expect_error( btw_tool_docs_package_news("nonexistentpackage"), @@ -142,6 +177,10 @@ test_that("btw_tool_docs_package_news() with unmatched search term or version", btw("@news dplyr asdfsdfgdfghfghj"), "No NEWS entries" ) + expect_error( + btw_tool_docs_package_news("dplyr", version = "0.0.0"), + "No NEWS entries" + ) }) test_that("btw_tool_docs_package_news() snapshots", {