Skip to content
1 change: 1 addition & 0 deletions DESCRIPTION
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ Suggests:
callr,
chromote,
covr,
curl,
DBI,
desc,
devtools,
Expand Down
1 change: 1 addition & 0 deletions NAMESPACE
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions NEWS.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

## New features

* Added `btw cran versions <packages...>` to look up CRAN release dates and versions, and versioned NEWS through `btw()`, `btw_this()`, the btw docs tools, and `btw docs news`. Use `btw("@news dplyr v1.1.4")` to paste a specific release's NEWS into a chat, or `btw("@news dplyr")` for the full file (#213).

* `btw docs` and `btw pkg src` commands now auto-load an in-development package found in the current directory (or its `pkg-r/`, `R/` subfolder) with `pkgload::load_all()` before running, so results reflect uncommitted local changes. Pass `--no-dev` to disable this and use the installed package instead (#212).

## Bug fixes
Expand Down
50 changes: 42 additions & 8 deletions R/btw_this.R
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand All @@ -276,22 +282,50 @@ 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)
)
}

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) {
Expand Down
250 changes: 250 additions & 0 deletions R/tool-cran.R
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,256 @@ 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}.")
}

if (!btw_has_internet()) {
cli::cli_abort(
"An internet connection is required to look up CRAN releases."
)
}

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(versions$released_at, 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 <- trimws(xml2::xml_text(xml2::xml_find_all(row, "./td")))
cells <- grep("^\\d{4}-\\d{2}-\\d{2}", cells, value = TRUE)
if (length(cells)) cells[[1]] else NA_character_
},
character(1)
)

keep <- !is.na(versions) & !is.na(dates)
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",
Expand Down
Loading