Skip to content

Commit ee33182

Browse files
authored
Merge pull request #64 from posit-dev/feat/docs-news
feat: `btw_tool_docs_package_news()`
2 parents 20e056d + d551334 commit ee33182

22 files changed

+518
-11
lines changed

DESCRIPTION

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,8 @@ Imports:
3535
skimr,
3636
tibble,
3737
utils,
38-
withr
38+
withr,
39+
xml2
3940
Suggests:
4041
bslib (>= 0.7.0),
4142
gh,
@@ -61,6 +62,7 @@ Collate:
6162
'import-standalone-types-check.R'
6263
'tool-data-frame.R'
6364
'tool-result.R'
65+
'tool-docs-news.R'
6466
'tool-docs.R'
6567
'tool-environment.R'
6668
'tool-files.R'

NAMESPACE

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ S3method(btw_this,data.frame)
1414
S3method(btw_this,default)
1515
S3method(btw_this,environment)
1616
S3method(btw_this,help_files_with_topic)
17+
S3method(btw_this,news_db)
1718
S3method(btw_this,packageIQR)
1819
S3method(btw_this,pkg_search_result)
1920
S3method(btw_this,tbl)
@@ -25,6 +26,7 @@ export(btw_this)
2526
export(btw_tool_docs_available_vignettes)
2627
export(btw_tool_docs_help_page)
2728
export(btw_tool_docs_package_help_topics)
29+
export(btw_tool_docs_package_news)
2830
export(btw_tool_docs_vignette)
2931
export(btw_tool_env_describe_data_frame)
3032
export(btw_tool_env_describe_environment)

NEWS.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# btw (development version)
2+
3+
* Initial CRAN submission.

R/btw_this.R

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,10 @@ as_btw_capture <- function(x) {
7878
#' * `btw_this("?dplyr::across")` includes the reference page for
7979
#' `dplyr::across`.
8080
#'
81+
#' * `"@news {{package_name}} {{search_term}}"` \cr
82+
#' Include the release notes (NEWS) from the latest package release, e.g.
83+
#' `"@news dplyr"`, or that match a search term, e.g. `"@news dplyr join_by"`.
84+
#'
8185
#' * `"@current_file"` or `"@current_selection"` \cr
8286
#' When used in RStudio or Positron, or anywhere else that the
8387
#' \pkg{rstudioapi} is supported, `btw("@current_file")` includes the contents
@@ -154,6 +158,24 @@ btw_this.character <- function(x, ..., caller_env = parent.frame()) {
154158
if (identical(x, "@last_value")) {
155159
return(btw_this(get_last_value()))
156160
}
161+
if (identical(substring(x, 1, 5), "@news")) {
162+
# Special syntax for @news: '@news dplyr' or '@news dplyr join_by'
163+
args <- substring(x, 7)
164+
if (!nzchar(args)) {
165+
cli::cli_abort(c(
166+
"{.code @news} must be followed by a package name and an optional search term.",
167+
"i" = 'e.g. {.code "@news dplyr"} or {.code "@news dplyr join_by"}'
168+
))
169+
}
170+
parts <- strsplit(args, " ", fixed = TRUE)[[1]]
171+
package_name <- parts[1]
172+
search_term <- if (length(parts) > 1) {
173+
paste(parts[-1], collapse = " ")
174+
} else {
175+
""
176+
}
177+
return(I(btw_tool_docs_package_news(package_name, search_term)@value))
178+
}
157179

158180
if (grepl("^\\./", x)) {
159181
path <- substring(x, 3, nchar(x))

R/tool-docs-news.R

Lines changed: 256 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,256 @@
1+
#' @include tool-result.R
2+
NULL
3+
4+
#' Tool: Package Release Notes
5+
#'
6+
#' @description
7+
#' Include release notes for a package, either the release notes for the most
8+
#' recent package release or release notes matching a search term.
9+
#'
10+
#' @examples
11+
#' # Copy release notes to the clipboard for use in any AI app
12+
#' btw("@news dplyr", clipboard = FALSE)
13+
#'
14+
#' btw("@news dplyr join_by", clipboard = FALSE)
15+
#'
16+
#' if (R.version$major == 4 && R.version$minor > "2.0") {
17+
#' # Should find a NEWS entry from R 4.2
18+
#' btw("@news R dynamic rd content", clipboard = FALSE)
19+
#' }
20+
#'
21+
#' # Tool use by LLMs via ellmer or MCP ----
22+
#' btw_tool_docs_package_news("dplyr")
23+
#'
24+
#' btw_tool_docs_package_news("dplyr", "join_by")
25+
#'
26+
#' @param package_name The name of the package as a string, e.g. `"shiny"`.
27+
#' @param search_term A regular expression to search for in the NEWS entries.
28+
#' If empty, the release notes of the current installed version is included.
29+
#'
30+
#' @returns Returns the release notes for the currently installed version of the
31+
#' package, or the release notes matching the search term.
32+
#'
33+
#' @seealso [btw_tools()]
34+
#' @family Tools
35+
#' @export
36+
btw_tool_docs_package_news <- function(package_name, search_term = "") {
37+
news <- package_news_search(package_name, search_term %||% "")
38+
39+
if (!nrow(news)) {
40+
if (nzchar(search_term)) {
41+
cli::cli_abort(
42+
"No NEWS entries found for package '{package_name}' matching '{search_term}'."
43+
)
44+
} else {
45+
cli::cli_abort(
46+
"No NEWS entries found for package '{package_name}' v{package_version(package_name)}."
47+
)
48+
}
49+
}
50+
51+
BtwPackageNewsToolResult(unclass(btw_this(news)))
52+
}
53+
54+
.btw_add_to_tools(
55+
"btw_tool_docs_package_news",
56+
group = "docs",
57+
tool = function() {
58+
ellmer::tool(
59+
btw_tool_docs_package_news,
60+
.description = paste0(
61+
"Read the release notes (NEWS) for a package.",
62+
"\n\n",
63+
"Use this tool when you need to learn what changed in a package release, i.e. when code no longer works after a package update, or when the user asks to learn about new features.",
64+
"\n\n",
65+
"If no search term is provided, the release notes for the current installed version are returned. ",
66+
"If a search term is provided, the tool returns relevant entries in the NEWS file matching the search term from the most recent 5 versions of the package where the term is matched.",
67+
"\n\n",
68+
"Use a search term to learn about recent changes to a function, feature or argument over the last few package releases. ",
69+
"For example, if a user recently updated a package and asks why a function no longer works, you can use this tool to find out what changed in the package release notes."
70+
),
71+
.annotations = ellmer::tool_annotations(
72+
title = "Package Release Notes",
73+
read_only_hint = TRUE,
74+
open_world_hint = FALSE
75+
),
76+
package_name = ellmer::type_string(
77+
"The name of the package.",
78+
required = TRUE
79+
),
80+
search_term = ellmer::type_string(
81+
paste(
82+
"A regular expression to use to search the NEWS entries.",
83+
"Use simple regular expressions (perl style is supported).",
84+
"The search term is case-insensitive.",
85+
"If empty, the tool returns the release notes for the current installed version."
86+
),
87+
required = FALSE
88+
)
89+
)
90+
}
91+
)
92+
93+
BtwPackageNewsToolResult <- S7::new_class(
94+
"BtwPackageNewsToolResult",
95+
parent = BtwToolResult
96+
)
97+
98+
#' @export
99+
btw_this.news_db <- function(x, ...) {
100+
news <- x
101+
package_name <- attr(x, "package")
102+
103+
if (!"match" %in% names(x)) {
104+
news$match <- news$HTML
105+
}
106+
107+
if (!inherits(news, "btw_filtered_news_db")) {
108+
news <- news[news$Version == package_version(package_name), ]
109+
}
110+
111+
if (!nrow(news)) {
112+
return(btw_ignore())
113+
}
114+
115+
news$Category[news$Category == "Full changelog"] <- ""
116+
117+
news <- dplyr::summarize(
118+
news,
119+
md = paste(.data$match, collapse = "\n\n"),
120+
.by = c("Version", "Category")
121+
)
122+
news$md <- pandoc_convert_text(news$md, to = "markdown")
123+
124+
has_cat <- nzchar(news$Category)
125+
news$Category[has_cat] <- paste0("#### ", news$Category[has_cat], "\n\n")
126+
127+
news <- dplyr::summarize(
128+
news,
129+
md = paste(paste0(.data$Category, .data$md), collapse = "\n\n"),
130+
.by = "Version"
131+
)
132+
133+
news <- news[order(news$Version, decreasing = TRUE), ]
134+
news$Version <- as.character(news$Version)
135+
136+
news_md <- glue_(
137+
"
138+
### {{package_name}} v{{news$Version}}
139+
140+
{{news$md}}",
141+
)
142+
143+
# Returns as-is so that btw(news(package = package_name)) is treated as
144+
# pre-formatted text and not formatted as an object/result pair
145+
I(paste(news_md, collapse = "\n\n"))
146+
}
147+
148+
package_news <- function(package_name) {
149+
news <- utils::news(package = package_name)
150+
if (package_name %in% r_docs_versions()) {
151+
news$Version <- map_chr(news$Version, as_package_or_r_version)
152+
}
153+
news
154+
}
155+
156+
r_docs_versions <- function() {
157+
c("R", sprintf("R-%d", seq_len(R.version$major)))
158+
}
159+
160+
package_news_search <- function(package_name, search_term = "") {
161+
r_docs <- r_docs_versions()
162+
if (!package_name %in% r_docs) {
163+
check_installed(package_name)
164+
} else {
165+
if (package_name == sprintf("R-%s", R.version$major)) {
166+
package_name <- "R"
167+
}
168+
}
169+
170+
news <- package_news(package_name)
171+
if (is.null(news)) {
172+
return(data.frame())
173+
}
174+
news$Version <- base::package_version(news$Version)
175+
176+
if (!nzchar(search_term)) {
177+
version <-
178+
if (!package_name %in% setdiff(r_docs, "R")) {
179+
package_version(package_name)
180+
} else {
181+
max(news$Version)
182+
}
183+
news <- news[news$Version == version, ]
184+
news$match <- news$HTML
185+
} else {
186+
news$match <- map_chr(
187+
news$HTML,
188+
extract_relevant_news,
189+
search_term = search_term
190+
)
191+
news <- news[!is.na(news$match), ]
192+
193+
# Take at most the results from the 5 most recent versions
194+
versions <- unique(news$Version)
195+
if (length(versions) > 5) {
196+
versions <- sort(versions, decreasing = TRUE)[1:5]
197+
}
198+
news <- news[news$Version %in% versions, ]
199+
}
200+
201+
class(news) <- c("btw_filtered_news_db", class(news))
202+
news
203+
}
204+
205+
as_package_or_r_version <- function(v) {
206+
if (!grepl("[^\\d.-]", v)) {
207+
return(v)
208+
}
209+
210+
if (identical(v, "R-devel")) {
211+
# Assuming the presence of `R-devel` means we're using dev R
212+
return(package_version("R"))
213+
}
214+
215+
if (grepl("patched", v)) {
216+
# Remove " patched" suffix
217+
v <- sub(" patched", "", v, fixed = TRUE)
218+
v <- unclass(base::package_version(v))[[1]]
219+
v[3] <- v[3] + 1L
220+
return(paste(v, collapse = "."))
221+
}
222+
223+
v
224+
}
225+
226+
extract_relevant_news <- function(news_html, search_term) {
227+
doc <- xml2::read_html(news_html)
228+
229+
# Find all first-level <li> elements and top-level <p> elements
230+
# First-level <li> are direct children of <ul> or <ol>
231+
# Top-level <p> are direct children of the root (not nested in <li>)
232+
233+
li_elements <- xml2::xml_find_all(
234+
doc,
235+
"//ul[not(ancestor::ul) and not(ancestor::ol)]/li"
236+
)
237+
top_level_p <- xml2::xml_find_all(
238+
doc,
239+
"//p[not(ancestor::li)]"
240+
)
241+
242+
all_elements <- c(li_elements, top_level_p)
243+
244+
has_text <- map_lgl(all_elements, function(el) {
245+
grepl(search_term, xml2::xml_text(el), perl = TRUE, ignore.case = TRUE)
246+
})
247+
248+
if (!any(has_text)) {
249+
return(NA_character_)
250+
}
251+
252+
res <- map_chr(all_elements[has_text], as.character)
253+
is_li <- grepl("^<li>", res, fixed = TRUE)
254+
res[is_li] <- paste0("<ul>", res[is_li], "</ul>")
255+
paste(res, collapse = "\n")
256+
}

R/tool-environment.R

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,9 @@ btw_tool_env_describe_environment <- function(
7676
if (identical(class(item), "character")) {
7777
# Only string literals passed through btw() hit `btw_this.character()`.
7878
# We rely on `dots_list()` turning `"foo"` into `list('"foo"' = "foo")`.
79-
if (!identical(item_name, sprintf('"%s"', item))) {
79+
item_name_dots_listed <- gsub("\\", "\\\\", item, fixed = TRUE)
80+
item_name_dots_listed <- sprintf('"%s"', item_name_dots_listed)
81+
if (!identical(item_name, item_name_dots_listed)) {
8082
item <- btw_returns_character(item)
8183
}
8284
}
@@ -123,7 +125,7 @@ btw_tool_env_describe_environment <- function(
123125
}
124126

125127
if (identical(res, c("## Context", ""))) {
126-
return("")
128+
return(BtwToolResult(""))
127129
}
128130

129131
BtwToolResult(res)

R/tool-session-package-installed.R

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,9 @@ check_installed <- function(package_name, call = caller_env()) {
7676
}
7777

7878
package_version <- function(package_name) {
79+
if (identical(package_name, "R")) {
80+
return(paste(R.version[c("major", "minor")], collapse = "."))
81+
}
7982
if (is_installed(package_name)) {
8083
as.character(utils::packageVersion(package_name))
8184
}

R/utils.R

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,14 @@ pandoc_convert <- function(path, ..., from = "html", to = "markdown") {
1212
readLines(tmp_file)
1313
}
1414

15+
pandoc_convert_text <- function(text, ..., from = "html", to = "markdown") {
16+
map_chr(text, function(x) {
17+
tmp_input <- withr::local_tempfile()
18+
writeLines(x, tmp_input)
19+
paste(pandoc_convert(tmp_input, from = from, to = to, ...), collapse = "\n")
20+
})
21+
}
22+
1523
cli_escape <- function(x) {
1624
x <- gsub("{", "{{", x, fixed = TRUE)
1725
gsub("}", "}}", x, fixed = TRUE)

man/btw_this.character.Rd

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)