diff --git a/AGENTS.md b/AGENTS.md index 1dfc7462..138edb73 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -104,8 +104,10 @@ Tools are grouped by capability: - `btw_this.environment()` - Lists and describes objects - `btw_this.character()` - **Command dispatcher** for special strings: - `"./path"` → read file or list directory - - `"{pkgName}"` → package help topics + intro vignette - - `"?help_topic"` → help page lookup + - `"{pkgName}"` or `"@pkg pkgName"` → package help topics + intro vignette + - `"?help_topic"` or `"@help topic"` → help page lookup (supports `@help pkg::topic` and `@help pkg topic` formats) + - `"@git status|diff|log"` → git repository information + - `"@issue #number"` or `"@pr #number"` → GitHub issues/PRs with auto-repo detection - `"@platform_info"` → session/platform details - `"@current_file"` → read active editor - And more (see `?btw_this.character`) @@ -329,6 +331,7 @@ Edit files in `inst/prompts/`. These are assembled in `btw_client()` based on co - **Tool output formats are fluid** - btw intentionally experiments with different formats (JSON, markdown, plain text) to discover what works best with different models - **clipboard operations may fail** in non-interactive contexts or without clipboard support - **IDE tools** (e.g., `@current_file`) require RStudio or Positron with rstudioapi support +- **Git and GitHub commands** - `@git` and `@issue`/`@pr` commands require gert and gh packages respectively, plus appropriate repository access - **MCP server blocks** the R process - run non-interactively - **Test snapshots require frequent updates** as output formatting evolves diff --git a/R/btw_this.R b/R/btw_this.R index e4559d71..96d2cd0b 100644 --- a/R/btw_this.R +++ b/R/btw_this.R @@ -62,21 +62,22 @@ as_btw_capture <- function(x) { #' * `btw_this("./R/load_data.R")` reads the source of the `R/load_data.R` #' file. #' -#' * `"{pkgName}"` \cr -#' A package name wrapped in braces. Returns the list of help topics -#' ([btw_tool_docs_package_help_topics()]) and, if it exists, the -#' introductory vignette for the package ([btw_tool_docs_vignette()]). +#' * `"{pkgName}"` or `"@pkg pkgName"` \cr +#' A package name wrapped in braces, or using the `@pkg` command. Returns the +#' list of help topics ([btw_tool_docs_package_help_topics()]) and, if it +#' exists, the introductory vignette for the package ([btw_tool_docs_vignette()]). #' -#' * `btw_this("{dplyr}")` includes dplyr's introductory vignette. +#' * `btw_this("{dplyr}")` or `btw_this("@pkg dplyr")` includes dplyr's introductory vignette. #' * `btw_this("{btw}")` returns only the package help index (because `btw` #' doesn't have an intro vignette, yet). #' -#' * `"?help_topic"` \cr -#' When the string starts with `?`, btw searches R's help -#' topics using [btw_tool_docs_help_page()]. +#' * `"?help_topic"` or `"@help topic"` \cr +#' When the string starts with `?` or `@help`, btw searches R's help +#' topics using [btw_tool_docs_help_page()]. Supports multiple formats: #' -#' * `btw_this("?dplyr::across")` includes the reference page for -#' `dplyr::across`. +#' * `btw_this("?dplyr::across")` or `btw_this("@help dplyr::across")` +#' * `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. @@ -87,6 +88,26 @@ as_btw_capture <- function(x) { #' `"@url https://cran.r-project.org/doc/FAQ/R-FAQ.html"`. Requires the #' \pkg{chromote} package to be installed. #' +#' * `"@git status"`, `"@git diff"`, `"@git log"` \cr +#' Git commands for viewing repository status, diffs, and commit history. +#' Requires \pkg{gert} package and a git repository. +#' +#' * `btw_this("@git status")` - show working directory status +#' * `btw_this("@git status staged")` - show only staged files +#' * `btw_this("@git diff")` - show unstaged changes +#' * `btw_this("@git diff HEAD")` - show staged changes +#' * `btw_this("@git log")` - show recent commits (default 10) +#' * `btw_this("@git log main 20")` - show 20 commits from main branch +#' +#' * `"@issue #number"` or `"@pr #number"` \cr +#' Fetch a GitHub issue or pull request. Automatically detects the current +#' repository, or you can specify `owner/repo#number` or `owner/repo number`. +#' Requires \pkg{gh} package and GitHub authentication. +#' +#' * `btw_this("@issue #65")` - issue from current repo +#' * `btw_this("@pr posit-dev/btw#64")` - PR from specific repo +#' * `btw_this("@issue tidyverse/dplyr 1234")` - space-separated format +#' #' * `"@current_file"` or `"@current_selection"` \cr #' When used in RStudio or Positron, or anywhere else that the #' \pkg{rstudioapi} is supported, `btw("@current_file")` includes the contents @@ -116,6 +137,12 @@ as_btw_capture <- function(x) { #' #' @examples #' btw_this("?btw::btw_this") +#' \dontrun{ +#' btw_this("@pkg dplyr") +#' btw_this("@help mutate") +#' btw_this("@git status") +#' btw_this("@issue #65") +#' } #' #' @param x A character string #' @param ... Ignored. @@ -129,7 +156,59 @@ btw_this.character <- function(x, ..., caller_env = parent.frame()) { check_string(x) x <- trimws(x) - if (identical(x, "@current_file")) { + # Try @ command parsing first + if (substring(x, 1, 1) == "@") { + cmd <- parse_at_command(x) + result <- dispatch_at_command(cmd, caller_env) + if (!is.null(result)) { + return(result) + } + } + + # ./path + if (grepl("^\\./", x)) { + return(btw_this_file_path(x)) + } + + # {pkgName} + if (grepl("^\\{[a-zA-Z][a-zA-Z0-9.]+\\}$", x)) { + return(btw_this_package_braces(x)) + } + + # ?help_topic + if (substring(x, 1, 1) == "?") { + return(btw_this_help_question(x)) + } + + # Default: user prompt + btw_user_prompt(x) +} + +# @ Command Parsing and Dispatch ---------------------------------------------- + +parse_at_command <- function(x) { + if (substring(x, 1, 1) != "@") { + return(NULL) + } + + # Find first space to split command from args + space_pos <- regexpr(" ", x, fixed = TRUE) + + if (space_pos == -1) { + # No arguments, just the command + command <- substring(x, 2) + args <- "" + } else { + command <- substring(x, 2, space_pos - 1) + args <- trimws(substring(x, space_pos + 1)) + } + + list(command = command, args = args) +} + +dispatch_at_command <- function(cmd, caller_env) { + # Simple commands (no arguments needed) + if (cmd$command == "current_file") { return(I( btw_tool_ide_read_current_editor_impl( selection = FALSE, @@ -137,7 +216,8 @@ btw_this.character <- function(x, ..., caller_env = parent.frame()) { )@value )) } - if (identical(x, "@current_selection")) { + + if (cmd$command == "current_selection") { return(I( btw_tool_ide_read_current_editor_impl( selection = TRUE, @@ -145,110 +225,472 @@ btw_this.character <- function(x, ..., caller_env = parent.frame()) { )@value )) } - if (identical(x, "@clipboard")) { + + if (cmd$command == "clipboard") { return(I(clipr::read_clip())) } - if (identical(x, "@platform_info")) { + + if (cmd$command == "platform_info") { return(btw_tool_session_platform_info_impl()@value) } - if (identical(x, "@attached_packages")) { + + if (cmd$command == "attached_packages") { return(I(btw_tool_session_package_info_impl("attached")@value)) } - if (identical(x, "@loaded_packages")) { + + if (cmd$command == "loaded_packages") { return(I(btw_tool_session_package_info_impl("loaded")@value)) } - if (identical(x, "@installed_packages")) { + + if (cmd$command == "installed_packages") { return(I(btw_tool_session_package_info_impl("installed")@value)) } - if (identical(x, "@last_error")) { + + if (cmd$command == "last_error") { err <- get_last_error() - return( - if (is.null(err)) btw_ignore() else capture_print(err) - ) + return(if (is.null(err)) btw_ignore() else capture_print(err)) } - if (identical(x, "@last_value")) { + + if (cmd$command == "last_value") { return(btw_this(get_last_value())) } - if (identical(substring(x, 1, 5), "@news")) { - # Special syntax for @news: '@news dplyr' or '@news dplyr join_by' - args <- substring(x, 7) - if (!nzchar(args)) { - cli::cli_abort(c( + + btw_this_cmd <- switch( + cmd$command, + news = btw_this_news, + url = btw_this_url, + pkg = btw_this_pkg, + help = btw_this_help, + git = btw_this_git, + issue = btw_this_github_issue, + pr = btw_this_github_pr, + # Unknown command - return NULL to fall through + function(args) NULL + ) + + btw_this_cmd(cmd$args) +} + +# Command Handlers ------------------------------------------------------------ + +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"}' - )) - } - parts <- strsplit(args, " ", fixed = TRUE)[[1]] - package_name <- parts[1] - search_term <- if (length(parts) > 1) { - paste(parts[-1], collapse = " ") - } else { - "" - } - return(I(btw_tool_docs_package_news_impl(package_name, search_term)@value)) + ), + 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 = " ") + } else { + "" + } + + I(btw_tool_docs_package_news_impl(package_name, search_term)@value) +} + +btw_this_url <- function(args) { + if (!has_chromote()) { + cli::cli_abort(c( + "{.strong @url} requires the {.pkg chromote} package to be installed.", + "i" = "Please install it with {.run install.packages('chromote')}." + )) } - if (identical(substring(x, 1, 4), "@url")) { - if (!has_chromote()) { - cli::cli_abort(c( - "{.strong @url} requires the {.pkg chromote} package to be installed.", - "i" = "Please install it with {.run install.packages('chromote')}." - )) - return(btw_ignore()) - } - # Special syntax for @url: '@url https://example.com' - url <- trimws(substring(x, 5)) - if (!nzchar(url)) { - cli::cli_abort(c( + url <- trimws(args) + if (!nzchar(url)) { + cli::cli_abort( + c( "{.strong @url} must be followed by a valid URL.", "i" = 'e.g. {.code "@url https://example.com"}' - )) - } - return(I(btw_tool_web_read_url_impl(url)@value)) + ), + call = caller_env(n = 2) + ) } - if (grepl("^\\./", x)) { - path <- substring(x, 3, nchar(x)) - if (!nzchar(path)) { - path <- "." + I(btw_tool_web_read_url_impl(url)@value) +} + +btw_this_pkg <- function(args) { + pkg <- trimws(args) + if (!nzchar(pkg)) { + cli::cli_abort(c( + "{.code @pkg} must be followed by a package name.", + "i" = 'e.g. {.code "@pkg dplyr"}' + )) + } + + # Reuse the same logic as {pkg} syntax + res <- c( + btw_tool_docs_package_help_topics_impl(pkg)@value, + tryCatch( + c("", btw_tool_docs_vignette_impl(pkg)@value), + error = function(e) NULL + ) + ) + res +} + +btw_this_help <- function(args) { + args <- trimws(args) + if (!nzchar(args)) { + cli::cli_abort( + c( + "{.code @help} must be followed by a help topic.", + "i" = 'e.g. {.code "@help mutate"} or {.code "@help dplyr::mutate"} or {.code "@help dplyr mutate"}' + ), + call = caller_env(n = 2) + ) + } + + # Parse the arguments: can be "pkg::topic", "topic", or "pkg topic" + if (grepl("::", args, fixed = TRUE)) { + # Format: @help pkg::topic + parts <- strsplit(args, "::", fixed = TRUE)[[1]] + return(btw_this(as_btw_docs_topic(parts[1], parts[2]))) + } else if (grepl(" ", args)) { + # Format: @help pkg topic + parts <- strsplit(args, " ", fixed = TRUE)[[1]] + pkg <- parts[1] + topic <- paste(parts[-1], collapse = " ") + return(btw_this(as_btw_docs_topic(pkg, topic))) + } else { + # Format: @help topic (search all packages) + return(btw_this(as_btw_docs_topic(NULL, args))) + } +} + +btw_this_git <- function(args) { + check_installed("gert") + + # Try to get git info early to provide better error messages + tryCatch( + gert::git_info(), + error = function(e) { + cli::cli_abort( + c( + "Not in a git repository or {.pkg gert} cannot access git.", + "i" = "Run {.code gert::git_info()} to check your git repository status." + ), + call = caller_env(n = 2) + ) } - if (fs::is_file(path)) { - return(btw_tool_files_read_text_file_impl(path)@value) + ) + + args <- trimws(args) + if (!nzchar(args)) { + cli::cli_abort( + c( + "{.code @git} must be followed by a subcommand.", + "i" = 'Use {.code "@git status"}, {.code "@git diff"}, or {.code "@git log"}' + ), + call = caller_env(n = 2) + ) + } + + # Parse subcommand and remaining args + git_parts <- strsplit(args, " ", fixed = TRUE)[[1]] + subcommand <- git_parts[1] + remaining_args <- if (length(git_parts) > 1) git_parts[-1] else character() + + switch( + subcommand, + status = btw_this_git_status(remaining_args), + diff = btw_this_git_diff(remaining_args), + log = btw_this_git_log(remaining_args), + cli::cli_abort( + c( + "Unknown git subcommand: {.val {subcommand}}", + "i" = 'Supported subcommands: {.code status}, {.code diff}, {.code log}' + ), + call = caller_env(n = 2) + ) + ) +} + +btw_this_git_status <- function(args) { + # args can be: [] or ["staged"] or ["unstaged"] or ["both"] or [pathspec...] + include <- "both" + pathspec <- NULL + + if (length(args) > 0) { + # Check if first arg is a valid include option + if (args[1] %in% c("staged", "unstaged", "both")) { + include <- args[1] + if (length(args) > 1) { + pathspec <- args[-1] + } } else { - return(btw_tool_files_list_files_impl(path)@value) + # All args are pathspec + pathspec <- args } } - if (grepl("^\\{[a-zA-Z][a-zA-Z0-9.]+\\}$", x)) { - # Catch R packages in the form: {dplyr} or {btw} - # R packages must: - # * start with a letter - # * use only letters, numbers or . - # * be two or more characters long - pkg <- substring(x, 2, nchar(x) - 1) - res <- c( - btw_tool_docs_package_help_topics_impl(pkg)@value, - tryCatch( - c("", btw_tool_docs_vignette_impl(pkg)@value), - error = function(e) NULL + I(md_code_block( + paste("git status", paste(args, collapse = " ")), + btw_tool_git_status_impl(include = include, pathspec = pathspec)@value + )) +} + +btw_this_git_diff <- function(args) { + ref <- if (length(args) > 0) args[1] else NULL + I(md_code_block( + paste("git diff", paste(args, collapse = " ")), + btw_tool_git_diff_impl(ref = ref)@value + )) +} + +btw_this_git_log <- function(args) { + ref <- "HEAD" + max <- 10 + + if (length(args) > 0) { + ref <- args[1] + } + if (length(args) > 1) { + max <- suppressWarnings(as.integer(args[2])) + if (is.na(max) || max < 1) { + cli::cli_abort( + c( + "Invalid max value for {.code @git log}: {.val {args[2]}}", + "i" = "Must be a positive integer." + ), + call = caller_env(n = 3) ) + } + } + + I(md_code_block( + paste("git log", paste(args, collapse = " ")), + btw_tool_git_log_impl(ref = ref, max = max)@value + )) +} + +btw_this_github_issue <- function(args) { + btw_this_github_item(args, type = "issue") +} + +btw_this_github_pr <- function(args) { + btw_this_github_item(args, type = "pr") +} + +# Handle @issue or @pr command +# Supports formats: +# - "#123" (uses current repo) +# - "owner/repo#123" +# - "owner/repo 123" +btw_this_github_item <- function(args, type = c("issue", "pr")) { + check_installed("gh") + type <- match.arg(type) + + args <- trimws(args) + if (!nzchar(args)) { + cli::cli_abort( + c( + "{.code @{type}} must be followed by an issue/PR number.", + "i" = 'e.g. {.code "@{type} #65"} or {.code "@{type} owner/repo#123"}' + ), + call = caller_env(n = 3) ) - return(res) } - if (substring(x, 1, 1) == "?") { - x <- substring(x, 2, nchar(x)) - x <- strsplit(x, "::", fixed = TRUE)[[1]] - if (length(x) == 2) { - return(btw_this(as_btw_docs_topic(x[1], x[2]))) - } else { - return(btw_this(as_btw_docs_topic(NULL, x[1]))) + # Parse the arguments to extract owner, repo, and number + parsed <- parse_github_reference(args) + + # Get owner and repo (will auto-detect if not provided) + if (is.null(parsed$owner) || is.null(parsed$repo)) { + repo_info <- get_github_repo(NULL, NULL) + owner <- parsed$owner %||% repo_info$owner + repo <- parsed$repo %||% repo_info$repo + } else { + owner <- parsed$owner + repo <- parsed$repo + } + + number <- parsed$number + + # Fetch from GitHub API + tryCatch( + { + result <- gh::gh( + "/repos/{owner}/{repo}/issues/{number}", + owner = owner, + repo = repo, + number = number + ) + + # Format the output + format_github_item(result, owner, repo, type) + }, + error = function(e) { + cli::cli_abort( + "Failed to fetch {type} from GitHub: {owner}/{repo}#{number}", + parent = e, + call = caller_env(n = 3) + ) + } + ) +} + +# Parse GitHub reference string +# Supports: "#123", "owner/repo#123", "owner/repo 123" +# @return List with owner, repo, number (owner/repo may be NULL) +parse_github_reference <- function(ref) { + ref <- trimws(ref) + + # Pattern 1: "#123" (just number) + if (grepl("^#[0-9]+$", ref)) { + return(list( + owner = NULL, + repo = NULL, + number = as.integer(substring(ref, 2)) + )) + } + + # Pattern 2: "owner/repo#123" + if (grepl("^[^/]+/[^#]+#[0-9]+$", ref)) { + parts <- strsplit(ref, "[/#]")[[1]] + return(list( + owner = parts[1], + repo = parts[2], + number = as.integer(parts[3]) + )) + } + + # Pattern 3: "owner/repo 123" (space-separated) + if (grepl("^[^/]+/[^ ]+ +[0-9]+$", ref)) { + parts <- strsplit(ref, "[ /]")[[1]] + parts <- parts[parts != ""] # Remove empty strings from multiple spaces + if (length(parts) >= 3) { + return(list( + owner = parts[1], + repo = parts[2], + number = as.integer(parts[3]) + )) } } - btw_user_prompt(x) + # Pattern 4: Just a number "123" + if (grepl("^[0-9]+$", ref)) { + return(list( + owner = NULL, + repo = NULL, + number = as.integer(ref) + )) + } + + cli::cli_abort(c( + "Invalid GitHub reference format: {.val {ref}}", + "i" = 'Expected formats: {.code "#123"}, {.code "owner/repo#123"}, or {.code "owner/repo 123"}' + )) } +format_github_item <- function(item, owner, repo, type) { + outer_tag <- if (type == "issue") "github-issue" else "github-pull-request" + lines <- c( + sprintf( + '<%s owner="%s" repo="%s" number="%d">', + outer_tag, + owner, + repo, + item$number + ), + "", + sprintf("title: %s", item$title), + sprintf("url: %s", item$html_url), + sprintf( + "type: %s", + if (is.null(item$pull_request)) "Issue" else "Pull Request" + ), + sprintf("state: %s", item$state), + sprintf("author: %s", item$user$login), + sprintf("created: %s", item$created_at), + sprintf("updated: %s", item$updated_at) + ) + + if (!is.null(item$closed_at)) { + lines <- c(lines, sprintf("closed: %s", item$closed_at)) + } + + if (!is.null(item$merged_at)) { + lines <- c(lines, sprintf("merged: %s", item$merged_at)) + } + + if (length(item$labels) > 0) { + label_names <- vapply(item$labels, function(l) l$name, character(1)) + lines <- c( + lines, + sprintf("labels: %s", paste(label_names, collapse = ", ")) + ) + } + + if (!is.null(item$milestone)) { + lines <- c(lines, sprintf("milestone: %s", item$milestone$title)) + } + + lines <- c( + lines, + "", + "", + if (is.null(item$body) || !nzchar(item$body)) { + "_No description provided._" + } else { + trimws(item$body) + }, + "", + sprintf("", outer_tag) + ) + + I(paste(lines, collapse = "\n")) +} + +btw_this_file_path <- function(x) { + path <- substring(x, 3, nchar(x)) + if (!nzchar(path)) { + path <- "." + } + if (fs::is_file(path)) { + return(btw_tool_files_read_text_file_impl(path)@value) + } else { + return(btw_tool_files_list_files_impl(path)@value) + } +} + +btw_this_package_braces <- function(x) { + # Catch R packages in the form: {dplyr} or {btw} + # R packages must: + # * start with a letter + # * use only letters, numbers or . + # * be two or more characters long + pkg <- substring(x, 2, nchar(x) - 1) + res <- c( + btw_tool_docs_package_help_topics_impl(pkg)@value, + tryCatch( + c("", btw_tool_docs_vignette_impl(pkg)@value), + error = function(e) NULL + ) + ) + res +} + +btw_this_help_question <- function(x) { + topic_str <- substring(x, 2, nchar(x)) + parts <- strsplit(topic_str, "::", fixed = TRUE)[[1]] + if (length(parts) == 2) { + return(btw_this(as_btw_docs_topic(parts[1], parts[2]))) + } else { + return(btw_this(as_btw_docs_topic(NULL, parts[1]))) + } +} + +# Other btw_this methods ------------------------------------------------------ + #' @export btw_this.Chat <- function(x, ...) { btw_ignore() diff --git a/man/btw_this.character.Rd b/man/btw_this.character.Rd index 21fe1048..1c7873f2 100644 --- a/man/btw_this.character.Rd +++ b/man/btw_this.character.Rd @@ -33,21 +33,22 @@ is a directory we call \code{\link[=btw_tool_files_list_files]{btw_tool_files_li \item \code{btw_this("./R/load_data.R")} reads the source of the \code{R/load_data.R} file. } -\item \code{"{pkgName}"} \cr -A package name wrapped in braces. Returns the list of help topics -(\code{\link[=btw_tool_docs_package_help_topics]{btw_tool_docs_package_help_topics()}}) and, if it exists, the -introductory vignette for the package (\code{\link[=btw_tool_docs_vignette]{btw_tool_docs_vignette()}}). +\item \code{"{pkgName}"} or \code{"@pkg pkgName"} \cr +A package name wrapped in braces, or using the \verb{@pkg} command. Returns the +list of help topics (\code{\link[=btw_tool_docs_package_help_topics]{btw_tool_docs_package_help_topics()}}) and, if it +exists, the introductory vignette for the package (\code{\link[=btw_tool_docs_vignette]{btw_tool_docs_vignette()}}). \itemize{ -\item \code{btw_this("{dplyr}")} includes dplyr's introductory vignette. +\item \code{btw_this("{dplyr}")} or \code{btw_this("@pkg dplyr")} includes dplyr's introductory vignette. \item \code{btw_this("{btw}")} returns only the package help index (because \code{btw} doesn't have an intro vignette, yet). } -\item \code{"?help_topic"} \cr -When the string starts with \verb{?}, btw searches R's help -topics using \code{\link[=btw_tool_docs_help_page]{btw_tool_docs_help_page()}}. +\item \code{"?help_topic"} or \code{"@help topic"} \cr +When the string starts with \verb{?} or \verb{@help}, btw searches R's help +topics using \code{\link[=btw_tool_docs_help_page]{btw_tool_docs_help_page()}}. Supports multiple formats: \itemize{ -\item \code{btw_this("?dplyr::across")} includes the reference page for -\code{dplyr::across}. +\item \code{btw_this("?dplyr::across")} or \code{btw_this("@help dplyr::across")} +\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. @@ -56,6 +57,26 @@ Include the release notes (NEWS) from the latest package release, e.g. 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 \pkg{chromote} package to be installed. +\item \code{"@git status"}, \code{"@git diff"}, \code{"@git log"} \cr +Git commands for viewing repository status, diffs, and commit history. +Requires \pkg{gert} package and a git repository. +\itemize{ +\item \code{btw_this("@git status")} - show working directory status +\item \code{btw_this("@git status staged")} - show only staged files +\item \code{btw_this("@git diff")} - show unstaged changes +\item \code{btw_this("@git diff HEAD")} - show staged changes +\item \code{btw_this("@git log")} - show recent commits (default 10) +\item \code{btw_this("@git log main 20")} - show 20 commits from main branch +} +\item \code{"@issue #number"} or \code{"@pr #number"} \cr +Fetch a GitHub issue or pull request. Automatically detects the current +repository, or you can specify \code{owner/repo#number} or \verb{owner/repo number}. +Requires \pkg{gh} package and GitHub authentication. +\itemize{ +\item \code{btw_this("@issue #65")} - issue from current repo +\item \code{btw_this("@pr posit-dev/btw#64")} - PR from specific repo +\item \code{btw_this("@issue tidyverse/dplyr 1234")} - space-separated format +} \item \code{"@current_file"} or \code{"@current_selection"} \cr When used in RStudio or Positron, or anywhere else that the \pkg{rstudioapi} is supported, \code{btw("@current_file")} includes the contents @@ -81,6 +102,12 @@ evaluated in your R console. } \examples{ btw_this("?btw::btw_this") +\dontrun{ +btw_this("@pkg dplyr") +btw_this("@help mutate") +btw_this("@git status") +btw_this("@issue #65") +} } \seealso{ diff --git a/tests/testthat/_snaps/btw_this.md b/tests/testthat/_snaps/btw_this.md index 946840a9..b9124273 100644 --- a/tests/testthat/_snaps/btw_this.md +++ b/tests/testthat/_snaps/btw_this.md @@ -17,3 +17,47 @@ Output Error: ! That didn't work. +# @issue detects current repo when only number provided + + Code + cli::cat_line(result) + Output + + + title: Test Issue + url: https://github.com/posit-dev/btw/issues/65 + type: Issue + state: open + author: testuser + created: 2025-01-01T00:00:00Z + updated: 2025-01-01T00:00:00Z + + + Test body + + + +# @pr marks pull requests correctly + + Code + cli::cat_line(result) + Output + + + title: Test PR + url: https://github.com/posit-dev/btw/pull/64 + type: Pull Request + state: closed + author: testuser + created: 2025-01-01T00:00:00Z + updated: 2025-01-01T00:00:00Z + closed: 2025-01-02T00:00:00Z + merged: 2025-01-02T00:00:00Z + labels: bug, urgent + milestone: v1.0 + + + Test PR body + + + diff --git a/tests/testthat/helpers-git.R b/tests/testthat/helpers-git.R index 55be3554..0f2ce6a3 100644 --- a/tests/testthat/helpers-git.R +++ b/tests/testthat/helpers-git.R @@ -17,6 +17,31 @@ local_temp_git_repo <- function( invisible(repo) } +local_git_info <- function(mock_has_git = TRUE, .local_envir = parent.frame()) { + mock_git_info <- function() { + if (!mock_has_git) { + stop("Not in a git repository or gert cannot access git.") + } + + list( + path = "/path/to/repo", + bare = FALSE, + head = "refs/heads/main", + shorthand = "main", + commit = "abcdef01234567890", + remote = "origin", + upstream = "origin/main", + reflist = c("refs/heads/main", "refs/remotes/origin/main") + ) + } + + local_mocked_bindings( + git_info = mock_git_info, + .package = "gert", + .env = .local_envir + ) +} + scrub_git_details <- function(x) { # Replace timestamps (YYYY-MM-DD HH:MM:SS format) x <- gsub( diff --git a/tests/testthat/test-btw_this.R b/tests/testthat/test-btw_this.R index a6c8e767..da3be750 100644 --- a/tests/testthat/test-btw_this.R +++ b/tests/testthat/test-btw_this.R @@ -67,3 +67,509 @@ test_that('btw_this("@last_value")', { btw_this(mtcars[2:4, ]) ) }) + + +# Test @pkg command ----------------------------------------------------------- + +test_that("@pkg command works like {pkg} syntax", { + # Both syntaxes should produce identical results + expect_equal( + btw_this("@pkg dplyr"), + btw_this("{dplyr}") + ) + + expect_equal( + btw_this("@pkg cli"), + btw_this("{cli}") + ) +}) + +test_that("@pkg requires package name", { + expect_error( + btw_this("@pkg"), + "@pkg.*must be followed by a package name" + ) + + expect_error( + btw_this("@pkg "), + "@pkg.*must be followed by a package name" + ) +}) + +# Test @help command ---------------------------------------------------------- + +test_that("@help command works with pkg::topic syntax", { + expect_equal( + btw_this("@help dplyr::mutate"), + btw_this("?dplyr::mutate") + ) +}) + +test_that("@help command works with space-separated syntax", { + expect_equal( + btw_this("@help dplyr mutate"), + btw_this("?dplyr::mutate") + ) +}) + +test_that("@help command works with topic only", { + # Should search all packages + result <- btw_this("@help mutate") + expect_true(is.character(result)) + expect_true(length(result) > 0) +}) + +test_that("@help requires topic", { + expect_error( + btw_this("@help"), + "@help.*must be followed by a help topic" + ) + + expect_error( + btw_this("@help "), + "@help.*must be followed by a help topic" + ) +}) + +# Test @git commands ---------------------------------------------------------- + +test_that("@git status works with mocked implementation", { + local_mocked_bindings( + btw_tool_git_status_impl = function(include, pathspec) { + btw_tool_result("Status: OK") + } + ) + local_git_info() + + result <- btw_this("@git status") + expect_match(result, "Status: OK", all = FALSE) + expect_match(result, "git status", all = FALSE) +}) + +test_that("@git status accepts include argument", { + local_mocked_bindings( + btw_tool_git_status_impl = function(include, pathspec) { + btw_tool_result(paste("Include:", include)) + } + ) + local_git_info() + + result <- btw_this("@git status staged") + expect_match(result, "Include: staged", all = FALSE) + + result <- btw_this("@git status unstaged") + expect_match(result, "Include: unstaged", all = FALSE) + + result <- btw_this("@git status both") + expect_match(result, "Include: both", all = FALSE) +}) + +test_that("@git diff works", { + local_mocked_bindings( + btw_tool_git_diff_impl = function(ref) { + ref_str <- if (is.null(ref)) "NULL" else ref + btw_tool_result(paste("Ref:", ref_str)) + } + ) + local_git_info() + + result <- btw_this("@git diff") + expect_match(result, "Ref: NULL", all = FALSE) + + result <- btw_this("@git diff HEAD") + expect_match(result, "Ref: HEAD", all = FALSE) +}) + +test_that("@git log works with defaults", { + local_mocked_bindings( + btw_tool_git_log_impl = function(ref, max, after = NULL) { + btw_tool_result(paste("Log:", ref, max)) + } + ) + local_git_info() + + result <- btw_this("@git log") + expect_match(result, "Log: HEAD 10", all = FALSE) +}) + +test_that("@git log works with custom ref", { + local_mocked_bindings( + btw_tool_git_log_impl = function(ref, max, after = NULL) { + btw_tool_result(paste("Log:", ref, max)) + } + ) + local_git_info() + + result <- btw_this("@git log main") + expect_match(result, "Log: main 10", all = FALSE) +}) + +test_that("@git log works with custom max", { + local_mocked_bindings( + btw_tool_git_log_impl = function(ref, max, after = NULL) { + btw_tool_result(paste("Log:", ref, max)) + } + ) + local_git_info() + + result <- btw_this("@git log main 20") + expect_match(result, "Log: main 20", all = FALSE) +}) + +test_that("@git requires gert package", { + local_mocked_bindings( + is_installed = function(pkg) pkg != "gert" + ) + local_git_info() + + expect_error( + btw_this("@git status"), + "gert" + ) +}) + +test_that("@git requires subcommand", { + local_git_info() + + expect_error( + btw_this("@git"), + "@git.*must be followed by a subcommand" + ) +}) + +test_that("@git rejects unknown subcommands", { + local_git_info() + + expect_error( + btw_this("@git unknown"), + "Unknown git subcommand" + ) +}) + +test_that("@git log validates max argument", { + local_git_info() + + expect_error( + btw_this("@git log main abc"), + "Invalid max value" + ) + + expect_error( + btw_this("@git log main -5"), + "Invalid max value" + ) +}) + +# Test @issue and @pr commands ------------------------------------------------ + +test_that("@issue and @pr parse number-only format", { + result <- parse_github_reference("#65") + expect_null(result$owner) + expect_null(result$repo) + expect_equal(result$number, 65) + + result <- parse_github_reference("123") + expect_null(result$owner) + expect_null(result$repo) + expect_equal(result$number, 123) +}) + +test_that("@issue and @pr parse owner/repo#number format", { + result <- parse_github_reference("posit-dev/btw#65") + expect_equal(result$owner, "posit-dev") + expect_equal(result$repo, "btw") + expect_equal(result$number, 65) + + result <- parse_github_reference("tidyverse/dplyr#1234") + expect_equal(result$owner, "tidyverse") + expect_equal(result$repo, "dplyr") + expect_equal(result$number, 1234) +}) + +test_that("@issue and @pr parse owner/repo number format", { + result <- parse_github_reference("posit-dev/btw 65") + expect_equal(result$owner, "posit-dev") + expect_equal(result$repo, "btw") + expect_equal(result$number, 65) + + result <- parse_github_reference("tidyverse/dplyr 1234") + expect_equal(result$owner, "tidyverse") + expect_equal(result$repo, "dplyr") + expect_equal(result$number, 1234) + + # Multiple spaces should be handled + result <- parse_github_reference("tidyverse/dplyr 1234") + expect_equal(result$owner, "tidyverse") + expect_equal(result$repo, "dplyr") + expect_equal(result$number, 1234) +}) + +test_that("parse_github_reference handles edge cases", { + # Format with # but no number + expect_error( + parse_github_reference("owner/repo#"), + "Invalid GitHub reference format" + ) + + # Format with / but no # or space + expect_error( + parse_github_reference("owner/repo"), + "Invalid GitHub reference format" + ) +}) + +test_that("@issue and @pr handle invalid formats", { + expect_error( + parse_github_reference("invalid"), + "Invalid GitHub reference format" + ) + + expect_error( + parse_github_reference("owner#123"), # Missing repo + "Invalid GitHub reference format" + ) + + expect_error( + parse_github_reference("invalid/format/here"), + "Invalid GitHub reference format" + ) +}) + +test_that("@issue and @pr require issue number", { + skip_if_not(is_installed("gh")) + + expect_error( + btw_this("@issue"), + "@issue.*must be followed" + ) + + expect_error( + btw_this("@pr "), + "@pr.*must be followed" + ) +}) + +test_that("@issue detects current repo when only number provided", { + skip_if_not(is_installed("gh")) + + local_posit_dev_btw_repo() + local_mocked_gh( + list( + number = 65, + title = "Test Issue", + html_url = "https://github.com/posit-dev/btw/issues/65", + state = "open", + user = list(login = "testuser"), + created_at = "2025-01-01T00:00:00Z", + updated_at = "2025-01-01T00:00:00Z", + body = "Test body", + labels = list(), + pull_request = NULL, + closed_at = NULL, + merged_at = NULL, + milestone = NULL + ) + ) + + result <- btw_this("@issue #65") + expect_match(result, "github-issue") + expect_match(result, 'owner="posit-dev"') + expect_match(result, 'repo="btw"') + expect_match(result, 'number="65"') + expect_match(result, "Test Issue") + expect_match(result, "Test body") + + skip_if_not_macos() + expect_snapshot(cli::cat_line(result)) +}) + +test_that("@pr marks pull requests correctly", { + skip_if_not(is_installed("gh")) + + local_posit_dev_btw_repo() + local_mocked_gh( + list( + number = 64, + title = "Test PR", + html_url = "https://github.com/posit-dev/btw/pull/64", + state = "closed", + user = list(login = "testuser"), + created_at = "2025-01-01T00:00:00Z", + updated_at = "2025-01-01T00:00:00Z", + merged_at = "2025-01-02T00:00:00Z", + body = "Test PR body", + labels = list( + list(name = "bug"), + list(name = "urgent") + ), + pull_request = list( + url = "https://api.github.com/repos/posit-dev/btw/pulls/64" + ), + closed_at = "2025-01-02T00:00:00Z", + milestone = list(title = "v1.0") + ) + ) + + result <- btw_this("@pr #64") + expect_match(result, "github-pull-request") + expect_match(result, "Pull Request") + expect_match(result, "merged: 2025-01-02T00:00:00Z") + + skip_if_not_macos() + expect_snapshot(cli::cat_line(result)) +}) + +test_that("format_github_item handles empty body", { + skip_if_not(is_installed("gh")) + + item <- list( + number = 1, + title = "Test", + html_url = "https://github.com/test/test/issues/1", + state = "open", + user = list(login = "user"), + created_at = "2025-01-01T00:00:00Z", + updated_at = "2025-01-01T00:00:00Z", + body = "", + labels = list(), + pull_request = NULL, + closed_at = NULL, + merged_at = NULL, + milestone = NULL + ) + + result <- format_github_item(item, "owner", "repo", "issue") + expect_match(result, "_No description provided._") +}) + +# Test backward compatibility ------------------------------------------------- + +test_that("legacy {pkg} syntax still works", { + expect_equal( + btw_this("{dplyr}"), + c( + btw_tool_docs_package_help_topics("dplyr")@value, + "", + btw_tool_docs_vignette("dplyr")@value + ) + ) +}) + +test_that("legacy ?topic syntax still works", { + expect_equal( + btw_this(?dplyr::mutate), + btw_this("?dplyr::mutate") + ) +}) + +test_that("legacy ./path syntax still works", { + skip_on_cran() + + # Create a temporary file for testing + temp_file <- tempfile(fileext = ".txt") + writeLines("test content", temp_file) + on.exit(unlink(temp_file)) + + # Change to temp directory + old_dir <- getwd() + temp_dir <- dirname(temp_file) + setwd(temp_dir) + on.exit(setwd(old_dir), add = TRUE) + + # Test reading file + result <- btw_this(paste0("./", basename(temp_file))) + expect_match(result, "test content") +}) + +# Test unknown @ commands ----------------------------------------------------- + +test_that("unknown @ commands return user prompt", { + # Unknown commands should fall through to user prompt + result <- btw_this("@unknown_command") + expect_s3_class(result, "btw_user_prompt") + expect_equal(as.character(result), "@unknown_command") +}) + +test_that("@unknown_with_args returns user prompt", { + result <- btw_this("@unknown_command with args") + expect_s3_class(result, "btw_user_prompt") + expect_equal(as.character(result), "@unknown_command with args") +}) + +# Test @ command edge cases --------------------------------------------------- + +test_that("@ commands handle extra whitespace", { + expect_equal( + btw_this("@pkg dplyr"), # Extra space + btw_this("@pkg dplyr") + ) + + expect_equal( + btw_this(" @help dplyr::mutate "), # Leading/trailing + btw_this("@help dplyr::mutate") + ) +}) + +test_that("@ commands are case-sensitive", { + # @PKG should not match @pkg - should be treated as user prompt + result <- btw_this("@PKG dplyr") + expect_s3_class(result, "btw_user_prompt") +}) + +# Test @news command ---------------------------------------------------------- + +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)) + } + ) + + result <- btw_this("@news dplyr") + expect_match(result, "News for dplyr") + + result <- btw_this("@news dplyr join_by") + expect_match(result, "News for dplyr join_by") +}) + +test_that("@news requires package name", { + expect_error( + btw_this("@news"), + "@news.*must be followed by a package name" + ) + + expect_error( + btw_this("@news "), + "@news.*must be followed by a package name" + ) +}) + +# Test @url command ----------------------------------------------------------- + +test_that("@url requires chromote", { + local_mocked_bindings( + has_chromote = function() FALSE + ) + + expect_error( + btw_this("@url https://example.com"), + "chromote" + ) +}) + +test_that("@url requires URL", { + local_mocked_bindings( + has_chromote = function() TRUE, + read_url_main_content = function(...) NULL + ) + + expect_error( + btw_this("@url"), + "@url.*must be followed by a valid URL" + ) + + expect_error( + btw_this("@url "), + "@url.*must be followed by a valid URL" + ) +})