From 7b27012cb865118c2d19c1ecc4d13601a31f22a2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 13 Mar 2026 13:30:05 +0000 Subject: [PATCH 1/7] Initial plan From 5bc66f82e5ec7692d20021bb6d311470b6dff9ba Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 13 Mar 2026 13:51:29 +0000 Subject: [PATCH 2/7] feat: use_skill_create_issue() (#3) Co-authored-by: jonthegeek <33983824+jonthegeek@users.noreply.github.com> --- .github/skills/create-issue/SKILL.md | 1 + .github/skills/document/SKILL.md | 1 + .github/skills/github/SKILL.md | 1 + .github/skills/implement-issue/SKILL.md | 1 + .github/skills/r-code/SKILL.md | 1 + .github/skills/search-code/SKILL.md | 1 + .github/skills/tdd-workflow/SKILL.md | 1 + DESCRIPTION | 2 +- NAMESPACE | 1 + NEWS.md | 3 + R/aaa-conditions.R | 16 + R/use_skill.R | 178 +++++++++ R/use_skill_create_issue.R | 106 ++++++ inst/templates/skills/create-issue/SKILL.md | 125 +++++++ man/dot-pkg_abort.Rd | 22 ++ man/dot-read_skill_trigger.Rd | 20 + man/dot-upsert_agents_skills_row.Rd | 22 ++ man/dot-use_skill.Rd | 42 +++ man/use_skill_create_issue.Rd | 44 +++ tests/testthat/_snaps/use_skill.md | 8 + .../testthat/_snaps/use_skill_create_issue.md | 20 + tests/testthat/test-use_skill.R | 348 ++++++++++++++++++ tests/testthat/test-use_skill_create_issue.R | 177 +++++++++ 23 files changed, 1140 insertions(+), 1 deletion(-) create mode 100644 R/aaa-conditions.R create mode 100644 R/use_skill.R create mode 100644 R/use_skill_create_issue.R create mode 100644 inst/templates/skills/create-issue/SKILL.md create mode 100644 man/dot-pkg_abort.Rd create mode 100644 man/dot-read_skill_trigger.Rd create mode 100644 man/dot-upsert_agents_skills_row.Rd create mode 100644 man/dot-use_skill.Rd create mode 100644 man/use_skill_create_issue.Rd create mode 100644 tests/testthat/_snaps/use_skill.md create mode 100644 tests/testthat/_snaps/use_skill_create_issue.md create mode 100644 tests/testthat/test-use_skill.R create mode 100644 tests/testthat/test-use_skill_create_issue.R diff --git a/.github/skills/create-issue/SKILL.md b/.github/skills/create-issue/SKILL.md index 878a03e..665c573 100644 --- a/.github/skills/create-issue/SKILL.md +++ b/.github/skills/create-issue/SKILL.md @@ -1,5 +1,6 @@ --- name: create-issue +trigger: create GitHub issues description: Creates GitHub issues for the package repository. Use when asked to create, file, or open a GitHub issue, or when planning new features or functions that need to be tracked. compatibility: Requires the `gh` CLI and an authenticated GitHub session. --- diff --git a/.github/skills/document/SKILL.md b/.github/skills/document/SKILL.md index bc6103c..8e1c8a5 100644 --- a/.github/skills/document/SKILL.md +++ b/.github/skills/document/SKILL.md @@ -1,5 +1,6 @@ --- name: document +trigger: document functions description: Document package functions. Use when asked to document functions. --- diff --git a/.github/skills/github/SKILL.md b/.github/skills/github/SKILL.md index 84b3bbd..668c835 100644 --- a/.github/skills/github/SKILL.md +++ b/.github/skills/github/SKILL.md @@ -1,5 +1,6 @@ --- name: github +trigger: github workflows description: GitHub workflows using the `gh` CLI, including viewing issues/PRs and commit message conventions. Use when interacting with GitHub in any way, such as viewing, creating, or editing issues and pull requests, making commits, or running any `gh` command. compatibility: Requires the `gh` CLI and an authenticated GitHub session. --- diff --git a/.github/skills/implement-issue/SKILL.md b/.github/skills/implement-issue/SKILL.md index 1e36009..f17891d 100644 --- a/.github/skills/implement-issue/SKILL.md +++ b/.github/skills/implement-issue/SKILL.md @@ -1,5 +1,6 @@ --- name: implement-issue +trigger: implement issue / work on #NNN description: Implements a GitHub issue end-to-end. Use when asked to implement, work on, or fix a specific issue number. compatibility: Requires the `gh` CLI and an authenticated GitHub session. --- diff --git a/.github/skills/r-code/SKILL.md b/.github/skills/r-code/SKILL.md index d01c3f7..7347846 100644 --- a/.github/skills/r-code/SKILL.md +++ b/.github/skills/r-code/SKILL.md @@ -1,5 +1,6 @@ --- name: r-code +trigger: writing R functions / API design / error handling description: Guide for writing R code. Use when writing new functions, designing APIs, or reviewing/modifying existing R code. --- diff --git a/.github/skills/search-code/SKILL.md b/.github/skills/search-code/SKILL.md index 49e327e..c5cf978 100644 --- a/.github/skills/search-code/SKILL.md +++ b/.github/skills/search-code/SKILL.md @@ -1,5 +1,6 @@ --- name: search-code +trigger: search / rewrite code description: Search and rewrite R source code by syntax using astgrepr. Use when asked to find patterns in code, search for function calls, identify usage of specific arguments, locate structural patterns across R files, or perform find-and-replace on code structure. --- diff --git a/.github/skills/tdd-workflow/SKILL.md b/.github/skills/tdd-workflow/SKILL.md index 5a79db9..7982445 100644 --- a/.github/skills/tdd-workflow/SKILL.md +++ b/.github/skills/tdd-workflow/SKILL.md @@ -1,5 +1,6 @@ --- name: tdd-workflow +trigger: writing or reviewing tests description: Test-driven development workflow. Use when writing any R code (writing new features, fixing bugs, refactoring, or reviewing tests). --- diff --git a/DESCRIPTION b/DESCRIPTION index 0e62cae..6453e7e 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -14,11 +14,11 @@ Imports: cli, desc, fs, + gh, rlang, stbl, usethis Suggests: - gh, testthat (>= 3.0.0), withr, yaml diff --git a/NAMESPACE b/NAMESPACE index 787256b..b3e3f63 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -1,5 +1,6 @@ # Generated by roxygen2: do not edit by hand export(use_agent) +export(use_skill_create_issue) importFrom(rlang,caller_arg) importFrom(rlang,caller_env) diff --git a/NEWS.md b/NEWS.md index c6e8d28..53d2260 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,5 +1,8 @@ # pkgskills (development version) +* `use_skill_create_issue()` installs the `create-issue` skill, fetching + repository metadata from GitHub and rendering a tailored skill template into + the project (#3). * `use_agent()` installs a structured `AGENTS.md` file, populating the repository overview from the project's `DESCRIPTION` (#2). diff --git a/R/aaa-conditions.R b/R/aaa-conditions.R new file mode 100644 index 0000000..3426c55 --- /dev/null +++ b/R/aaa-conditions.R @@ -0,0 +1,16 @@ +#' Raise a package-scoped error +#' +#' @param message (`character(1)`) A `{cli}` message string. +#' @param subclass (`character(1)`) Error subclass. +#' @inheritParams .shared-params +#' @returns Does not return. +#' @keywords internal +.pkg_abort <- function(message, subclass, call = rlang::caller_env()) { + stbl::pkg_abort( + "pkgskills", + message, + subclass, + call = call, + message_env = rlang::caller_env() + ) +} diff --git a/R/use_skill.R b/R/use_skill.R new file mode 100644 index 0000000..4f5c5f6 --- /dev/null +++ b/R/use_skill.R @@ -0,0 +1,178 @@ +#' Install a skill into a project +#' +#' Internal helper that renders and writes a skill template, and upserts the +#' `## Skills` table in `AGENTS.md` when it exists. +#' +#' @param skill (`character(1)`) Skill name — folder name under +#' `inst/templates/skills/`, e.g. `"create-issue"`. Determines the template +#' path and the install subdirectory. +#' @param data (`list`) Named list of whisker template variables. +#' @param target_dir (`character(1)`) Directory where the skill will be +#' installed, relative to the project root. Defaults to `".github"`. +#' @param use_skills_subdir (`logical(1)`) Whether to place the skill folder +#' under a `skills` subdirectory of `target_dir`. Defaults to `TRUE`, +#' producing `.github/skills/{skill}/SKILL.md`. +#' @param overwrite (`logical(1)`) Whether to overwrite an existing skill file. +#' Defaults to `TRUE`. +#' @inheritParams .shared-params +#' @returns The path to the installed skill file, invisibly. +#' @keywords internal +.use_skill <- function( + skill, + data, + target_dir = ".github", + use_skills_subdir = TRUE, + overwrite = TRUE, + open = rlang::is_interactive() +) { + skill <- .to_string(skill) + data <- stbl::to_list(data) + target_dir <- .to_string(target_dir) + use_skills_subdir <- stbl::to_lgl_scalar( + use_skills_subdir, + allow_null = FALSE + ) + overwrite <- stbl::to_lgl_scalar(overwrite, allow_null = FALSE) + + if (use_skills_subdir) { + save_as <- fs::path(target_dir, "skills", skill, "SKILL.md") + } else { + save_as <- fs::path(target_dir, skill, "SKILL.md") + } + + template_path <- system.file( + "templates", + "skills", + skill, + "SKILL.md", + package = "pkgskills" + ) + trigger <- .read_skill_trigger(template_path) + + path <- usethis::proj_path(save_as) + if (overwrite && fs::file_exists(path)) { + fs::file_delete(path) + } else if (!overwrite && fs::file_exists(path)) { + cli::cli_inform("Skill {.file {save_as}} already exists. Skipping.") + return(invisible(path)) + } + + fs::dir_create(fs::path_dir(path)) + .use_template(paste0("skills/", skill, "/SKILL.md"), save_as, data, open) + + agents_path <- usethis::proj_path("AGENTS.md") + if (fs::file_exists(agents_path)) { + .upsert_agents_skills_row(agents_path, trigger, save_as) + } + + cli::cli_inform("Skill {.file {save_as}} installed.") + invisible(path) +} + +#' Read the trigger field from a skill template's YAML front matter +#' +#' @param path (`character(1)`) Path to the skill template file. +#' @inheritParams .shared-params +#' @returns (`character(1)`) The trigger phrase. +#' @keywords internal +.read_skill_trigger <- function(path, call = rlang::caller_env()) { + path <- .to_string(path, call = call) + if (!fs::file_exists(path)) { + .pkg_abort( + "Template not found: {.file {path}}.", + "template_not_found", + call = call + ) + } + lines <- readLines(path, warn = FALSE) + delim_idx <- which(lines == "---") + if (length(delim_idx) < 2L) { + .pkg_abort( + "No YAML front matter found in {.file {path}}.", + "no_front_matter", + call = call + ) + } + front_matter <- lines[(delim_idx[[1L]] + 1L):(delim_idx[[2L]] - 1L)] + trigger_line <- grep("^trigger:", front_matter, value = TRUE) + if (length(trigger_line) == 0L) { + .pkg_abort( + "No {.field trigger} field in front matter of {.file {path}}.", + "no_trigger", + call = call + ) + } + trimws(sub("^trigger:", "", trigger_line[[1L]])) +} + +#' Upsert a skill row in the ## Skills table of AGENTS.md +#' +#' @param agents_path (`character(1)`) Path to the `AGENTS.md` file. +#' @param trigger (`character(1)`) Trigger phrase for the skill. +#' @param save_as (`character(1)`) Relative path to the installed skill file. +#' @returns The path to `AGENTS.md`, invisibly. +#' @keywords internal +.upsert_agents_skills_row <- function(agents_path, trigger, save_as) { + agents_path <- .to_string(agents_path) + trigger <- .to_string(trigger) + save_as <- .to_string(save_as) + + lines <- readLines(agents_path, warn = FALSE) + save_as_escaped <- gsub("([.|*+?^${}()\\[\\]\\\\])", "\\\\\\1", save_as) + existing_idx <- grep(paste0("@", save_as_escaped), lines) + + if (length(existing_idx) > 0L) { + lines[[existing_idx[[1L]]]] <- paste0("| ", trigger, " | @", save_as, " |") + writeLines(lines, agents_path) + return(invisible(agents_path)) + } + + skills_idx <- grep("^## Skills", lines) + + if (length(skills_idx) == 0L) { + new_section <- c( + "", + "## Skills", + "", + "| Triggers | Path |", + "|----------|------|", + paste0("| ", trigger, " | @", save_as, " |") + ) + writeLines(c(lines, new_section), agents_path) + return(invisible(agents_path)) + } + + # Find the last table row in the ## Skills section + section_start <- skills_idx[[1L]] + last_table_row <- NA_integer_ + for (i in seq(section_start + 1L, length(lines))) { + if (grepl("^\\|", lines[[i]])) { + last_table_row <- i + } else if (!is.na(last_table_row)) { + break + } + } + + new_row <- paste0("| ", trigger, " | @", save_as, " |") + if (is.na(last_table_row)) { + insert_idx <- section_start + lines <- c( + lines[seq_len(insert_idx)], + "", + "| Triggers | Path |", + "|----------|------|", + new_row, + lines[seq(insert_idx + 1L, length(lines))] + ) + } else { + tail_lines <- if (last_table_row < length(lines)) { + lines[seq(last_table_row + 1L, length(lines))] + } else { + character(0L) + } + lines <- c(lines[seq_len(last_table_row)], new_row, tail_lines) + } + + writeLines(lines, agents_path) + invisible(agents_path) +} diff --git a/R/use_skill_create_issue.R b/R/use_skill_create_issue.R new file mode 100644 index 0000000..1c5b394 --- /dev/null +++ b/R/use_skill_create_issue.R @@ -0,0 +1,106 @@ +#' Install the create-issue skill into a project +#' +#' Fetches repository metadata from GitHub and renders the `create-issue` skill +#' template into the project. The installed skill teaches AI agents how to +#' create well-structured GitHub issues for the package. +#' +#' @param target_dir (`character(1)`) Directory where the skill will be +#' installed, relative to the project root. Defaults to `".github"`. +#' @param use_skills_subdir (`logical(1)`) Whether to place the `create-issue` +#' folder under a `skills` subdirectory of `target_dir`. Defaults to `TRUE`, +#' producing `.github/skills/create-issue/SKILL.md`. +#' @param overwrite (`logical(1)`) Whether to overwrite an existing skill file. +#' Defaults to `TRUE`. +#' @param gh_token (`character(1)`) A GitHub personal access token. Defaults to +#' `gh::gh_token()`. +#' @inheritParams .shared-params +#' @returns The path to the installed skill file, invisibly. +#' @export +#' @examplesIf interactive() +#' +#' use_skill_create_issue() +use_skill_create_issue <- function( + target_dir = ".github", + use_skills_subdir = TRUE, + overwrite = TRUE, + open = rlang::is_interactive(), + gh_token = gh::gh_token() +) { + target_dir <- .to_string(target_dir) + use_skills_subdir <- stbl::to_lgl_scalar( + use_skills_subdir, + allow_null = FALSE + ) + overwrite <- stbl::to_lgl_scalar(overwrite, allow_null = FALSE) + gh_token <- .to_string(gh_token) + + bug_reports <- desc::desc_get( + "BugReports", + file = usethis::proj_path("DESCRIPTION") + )[[1L]] + + if (is.na(bug_reports)) { + .pkg_abort( + c( + "No {.field BugReports} field found in {.file DESCRIPTION}.", + "i" = "Run {.run usethis::use_github()} to set one up." + ), + "no_bug_reports" + ) + } + + pattern <- "^https://github\\.com/([^/]+)/([^/]+)/issues" + m <- regmatches(bug_reports, regexec(pattern, bug_reports))[[1L]] + if (length(m) < 3L) { + .pkg_abort( + c( + "{.field BugReports} in {.file DESCRIPTION} must be a GitHub issues URL.", + "i" = "Run {.run usethis::use_github()} to set one up." + ), + "invalid_bug_reports" + ) + } + owner <- m[[2L]] + repo <- m[[3L]] + + repo_result <- gh::gh( + "POST /graphql", + query = sprintf( + '{ repository(owner: "%s", name: "%s") { id } }', + owner, + repo + ), + .token = gh_token + ) + repo_id <- repo_result$data$repository$id + + types_result <- gh::gh( + "POST /graphql", + query = sprintf( + paste0( + '{ repository(owner: "%s", name: "%s") {', + " issueTypes(first: 20) { nodes { id name description } } } }" + ), + owner, + repo + ), + .token = gh_token + ) + issue_types <- types_result$data$repository$issueTypes$nodes + + data <- list( + owner = owner, + repo = repo, + repo_id = repo_id, + issue_types = issue_types + ) + + .use_skill( + "create-issue", + data = data, + target_dir = target_dir, + use_skills_subdir = use_skills_subdir, + overwrite = overwrite, + open = open + ) +} diff --git a/inst/templates/skills/create-issue/SKILL.md b/inst/templates/skills/create-issue/SKILL.md new file mode 100644 index 0000000..fb1c324 --- /dev/null +++ b/inst/templates/skills/create-issue/SKILL.md @@ -0,0 +1,125 @@ +--- +name: create-issue +trigger: create GitHub issues +description: Creates GitHub issues for the package repository. Use when asked to create, file, or open a GitHub issue, or when planning new features or functions that need to be tracked. +compatibility: Requires the `gh` CLI and an authenticated GitHub session. +--- + +# Create a GitHub issue + +Use `gh api graphql` with the `createIssue` mutation to create issues. This sets the issue type in a single step. Write the body to a temp file first, then pass it via `$(cat ...)`. + +If `gh` is not authenticated, stop and ask the user to authenticate before continuing. + +## Looking up IDs + +The hardcoded IDs below are correct for this repo as of the time of install. If they ever change, or if you're working in a fork, re-run these queries to get fresh values: + +```bash +# Repository node ID +gh api graphql -f query='{ repository(owner: "{{{owner}}}", name: "{{{repo}}}") { id } }' + +# Available issue type IDs +gh api graphql -f query='{ repository(owner: "{{{owner}}}", name: "{{{repo}}}") { issueTypes(first: 20) { nodes { id name description } } } }' +``` + +## Issue type + +Choose the type that best fits the issue: + +| Type | ID | Use for | +|---|---|---| +{{#issue_types}} +| {{{name}}} | `{{{id}}}` | {{{description}}} | +{{/issue_types}} + +## Issue title + +Titles use conventional commit prefixes: + +- `feat: my_function()` — new exported function or feature +- `fix: short description` — bug fix +- `docs: short description` — documentation +- `chore: short description` — maintenance or task + +## Issue body structure + +Which sections to include depends on the issue type: + +| Section | Feature | Bug | Documentation | Task | +|---|---|---|---|---| +| `## Summary` | ✓ | ✓ | ✓ | ✓ | +| `## Details` | optional | optional | optional | optional | +| `## Proposed signature` | ✓ | — | — | — | +| `## Behavior` | ✓ | ✓ | — | — | +| `## Implementation` | optional | optional | optional | optional | +| `## References` | optional | optional | optional | optional | + +### `## Summary` (all types) + +A single user story sentence (no other content in this section): + +```markdown +> As a [role], in order to [goal], I would like to [feature]. +``` + +Example: + +```markdown +## Summary + +> As a package developer, in order to set up agent skills quickly, I would like to generate a skill template from a single function call. +``` + +### `## Details` (optional, all types) + +For information that's important to capture but doesn't fit naturally into any other section. Use sparingly — if the content belongs in `## Behavior`, `## Proposed signature`, or `## References`, put it there instead. + +### `## Proposed signature` (Feature only) + +The proposed R function signature, arguments table, and return value description: + +````markdown +## Proposed signature + +```r +function_name(arg1, arg2) +``` + +**Arguments** + +- `arg1` (`TYPE`) — Description. +- `arg2` (`TYPE`) — Description. + +**Returns** a `TYPE` with description. +```` + +### `## Behavior` (Feature and Bug) + +- **Feature**: bullet points describing expected behavior, edge cases, and any internal helpers to implement as part of this issue. +- **Bug**: describe the current (broken) behavior, the expected behavior, and steps to reproduce if known. + +### `## Implementation` (optional, all types) + +Bullet points describing additional details about implementation of the feature, such as packages to add to `Imports` in `DESCRIPTION` or files to add to `inst`. + +### `## References` (optional, all types) + +Only include when there are specific reference implementations, external URLs, or related code to link to. Omit it entirely when there are none. + +## Creating the issue + +Use the `repoId` and the `typeId` for the chosen issue type from the table above. + +```bash +gh api graphql \ + -f query='mutation($repoId:ID!, $title:String!, $body:String!, $typeId:ID!) { + createIssue(input:{repositoryId:$repoId, title:$title, body:$body, issueTypeId:$typeId}) { + issue { url } + } + }' \ + -f repoId="{{{repo_id}}}" \ + -f title="feat: my_function()" \ + -f body="$(cat /tmp/issue_body.md)" \ + -f typeId="{typeId}" +``` diff --git a/man/dot-pkg_abort.Rd b/man/dot-pkg_abort.Rd new file mode 100644 index 0000000..fa10e20 --- /dev/null +++ b/man/dot-pkg_abort.Rd @@ -0,0 +1,22 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/aaa-conditions.R +\name{.pkg_abort} +\alias{.pkg_abort} +\title{Raise a package-scoped error} +\usage{ +.pkg_abort(message, subclass, call = rlang::caller_env()) +} +\arguments{ +\item{message}{(\code{character(1)}) A \code{{cli}} message string.} + +\item{subclass}{(\code{character(1)}) Error subclass.} + +\item{call}{(\code{environment}) The caller environment for error messages.} +} +\value{ +Does not return. +} +\description{ +Raise a package-scoped error +} +\keyword{internal} diff --git a/man/dot-read_skill_trigger.Rd b/man/dot-read_skill_trigger.Rd new file mode 100644 index 0000000..18af2ac --- /dev/null +++ b/man/dot-read_skill_trigger.Rd @@ -0,0 +1,20 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/use_skill.R +\name{.read_skill_trigger} +\alias{.read_skill_trigger} +\title{Read the trigger field from a skill template's YAML front matter} +\usage{ +.read_skill_trigger(path, call = rlang::caller_env()) +} +\arguments{ +\item{path}{(\code{character(1)}) Path to the skill template file.} + +\item{call}{(\code{environment}) The caller environment for error messages.} +} +\value{ +(\code{character(1)}) The trigger phrase. +} +\description{ +Read the trigger field from a skill template's YAML front matter +} +\keyword{internal} diff --git a/man/dot-upsert_agents_skills_row.Rd b/man/dot-upsert_agents_skills_row.Rd new file mode 100644 index 0000000..dc00eba --- /dev/null +++ b/man/dot-upsert_agents_skills_row.Rd @@ -0,0 +1,22 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/use_skill.R +\name{.upsert_agents_skills_row} +\alias{.upsert_agents_skills_row} +\title{Upsert a skill row in the ## Skills table of AGENTS.md} +\usage{ +.upsert_agents_skills_row(agents_path, trigger, save_as) +} +\arguments{ +\item{agents_path}{(\code{character(1)}) Path to the \code{AGENTS.md} file.} + +\item{trigger}{(\code{character(1)}) Trigger phrase for the skill.} + +\item{save_as}{(\code{character(1)}) Relative path to the installed skill file.} +} +\value{ +The path to \code{AGENTS.md}, invisibly. +} +\description{ +Upsert a skill row in the ## Skills table of AGENTS.md +} +\keyword{internal} diff --git a/man/dot-use_skill.Rd b/man/dot-use_skill.Rd new file mode 100644 index 0000000..a51beb2 --- /dev/null +++ b/man/dot-use_skill.Rd @@ -0,0 +1,42 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/use_skill.R +\name{.use_skill} +\alias{.use_skill} +\title{Install a skill into a project} +\usage{ +.use_skill( + skill, + data, + target_dir = ".github", + use_skills_subdir = TRUE, + overwrite = TRUE, + open = rlang::is_interactive() +) +} +\arguments{ +\item{skill}{(\code{character(1)}) Skill name — folder name under +\verb{inst/templates/skills/}, e.g. \code{"create-issue"}. Determines the template +path and the install subdirectory.} + +\item{data}{(\code{list}) Named list of whisker template variables.} + +\item{target_dir}{(\code{character(1)}) Directory where the skill will be +installed, relative to the project root. Defaults to \code{".github"}.} + +\item{use_skills_subdir}{(\code{logical(1)}) Whether to place the skill folder +under a \code{skills} subdirectory of \code{target_dir}. Defaults to \code{TRUE}, +producing \code{.github/skills/{skill}/SKILL.md}.} + +\item{overwrite}{(\code{logical(1)}) Whether to overwrite an existing skill file. +Defaults to \code{TRUE}.} + +\item{open}{(\code{logical(1)}) Whether to open the file after creation.} +} +\value{ +The path to the installed skill file, invisibly. +} +\description{ +Internal helper that renders and writes a skill template, and upserts the +\verb{## Skills} table in \code{AGENTS.md} when it exists. +} +\keyword{internal} diff --git a/man/use_skill_create_issue.Rd b/man/use_skill_create_issue.Rd new file mode 100644 index 0000000..612e5f4 --- /dev/null +++ b/man/use_skill_create_issue.Rd @@ -0,0 +1,44 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/use_skill_create_issue.R +\name{use_skill_create_issue} +\alias{use_skill_create_issue} +\title{Install the create-issue skill into a project} +\usage{ +use_skill_create_issue( + target_dir = ".github", + use_skills_subdir = TRUE, + overwrite = TRUE, + open = rlang::is_interactive(), + gh_token = gh::gh_token() +) +} +\arguments{ +\item{target_dir}{(\code{character(1)}) Directory where the skill will be +installed, relative to the project root. Defaults to \code{".github"}.} + +\item{use_skills_subdir}{(\code{logical(1)}) Whether to place the \code{create-issue} +folder under a \code{skills} subdirectory of \code{target_dir}. Defaults to \code{TRUE}, +producing \code{.github/skills/create-issue/SKILL.md}.} + +\item{overwrite}{(\code{logical(1)}) Whether to overwrite an existing skill file. +Defaults to \code{TRUE}.} + +\item{open}{(\code{logical(1)}) Whether to open the file after creation.} + +\item{gh_token}{(\code{character(1)}) A GitHub personal access token. Defaults to +\code{gh::gh_token()}.} +} +\value{ +The path to the installed skill file, invisibly. +} +\description{ +Fetches repository metadata from GitHub and renders the \code{create-issue} skill +template into the project. The installed skill teaches AI agents how to +create well-structured GitHub issues for the package. +} +\examples{ +\dontshow{if (interactive()) withAutoprint(\{ # examplesIf} + + use_skill_create_issue() +\dontshow{\}) # examplesIf} +} diff --git a/tests/testthat/_snaps/use_skill.md b/tests/testthat/_snaps/use_skill.md new file mode 100644 index 0000000..46ae323 --- /dev/null +++ b/tests/testthat/_snaps/use_skill.md @@ -0,0 +1,8 @@ +# .use_skill() emits a cli_inform message (#3) + + Code + .use_skill("create-issue", data = list(owner = "testowner", repo = "testrepo", + repo_id = "R_test", issue_types = list()), open = FALSE) + Message + Skill '.github/skills/create-issue/SKILL.md' installed. + diff --git a/tests/testthat/_snaps/use_skill_create_issue.md b/tests/testthat/_snaps/use_skill_create_issue.md new file mode 100644 index 0000000..b1108bc --- /dev/null +++ b/tests/testthat/_snaps/use_skill_create_issue.md @@ -0,0 +1,20 @@ +# use_skill_create_issue() errors when BugReports is absent (#3) + + Code + (expect_error(use_skill_create_issue(open = FALSE), class = "pkgskills-error")) + Output + + Error in `use_skill_create_issue()`: + ! No BugReports field found in 'DESCRIPTION'. + i Run `usethis::use_github()` to set one up. + +# use_skill_create_issue() errors when BugReports is not a GitHub URL (#3) + + Code + (expect_error(use_skill_create_issue(open = FALSE), class = "pkgskills-error")) + Output + + Error in `use_skill_create_issue()`: + ! BugReports in 'DESCRIPTION' must be a GitHub issues URL. + i Run `usethis::use_github()` to set one up. + diff --git a/tests/testthat/test-use_skill.R b/tests/testthat/test-use_skill.R new file mode 100644 index 0000000..f437e08 --- /dev/null +++ b/tests/testthat/test-use_skill.R @@ -0,0 +1,348 @@ +test_that(".use_skill() returns path invisibly (#3)", { + proj_dir <- local_pkg() + result <- withVisible( + suppressMessages( + .use_skill( + "create-issue", + data = list( + owner = "testowner", + repo = "testrepo", + repo_id = "R_test", + issue_types = list(list( + name = "Feature", + id = "IT_1", + description = "New stuff" + )) + ), + open = FALSE + ) + ) + ) + expect_false(result$visible) + expect_equal( + fs::path_real(result$value), + fs::path_real(fs::path(proj_dir, ".github/skills/create-issue/SKILL.md")) + ) +}) + +test_that(".use_skill() creates file at correct path with use_skills_subdir = TRUE (#3)", { + proj_dir <- local_pkg() + suppressMessages( + .use_skill( + "create-issue", + data = list( + owner = "testowner", + repo = "testrepo", + repo_id = "R_test", + issue_types = list(list( + name = "Feature", + id = "IT_1", + description = "New stuff" + )) + ), + open = FALSE + ) + ) + expect_true( + fs::file_exists(fs::path(proj_dir, ".github/skills/create-issue/SKILL.md")) + ) +}) + +test_that(".use_skill() creates file at correct path with use_skills_subdir = FALSE (#3)", { + proj_dir <- local_pkg() + suppressMessages( + .use_skill( + "create-issue", + data = list( + owner = "testowner", + repo = "testrepo", + repo_id = "R_test", + issue_types = list(list( + name = "Feature", + id = "IT_1", + description = "New stuff" + )) + ), + use_skills_subdir = FALSE, + open = FALSE + ) + ) + expect_true( + fs::file_exists(fs::path(proj_dir, ".github/create-issue/SKILL.md")) + ) +}) + +test_that(".use_skill() renders template variables into skill file (#3)", { + proj_dir <- local_pkg() + suppressMessages( + .use_skill( + "create-issue", + data = list( + owner = "myowner", + repo = "myrepo", + repo_id = "R_myid", + issue_types = list( + list(name = "Feature", id = "IT_feat", description = "New features"), + list(name = "Bug", id = "IT_bug", description = "Broken things") + ) + ), + open = FALSE + ) + ) + content <- readLines(fs::path( + proj_dir, + ".github/skills/create-issue/SKILL.md" + )) + expect_true(any(grepl("myowner", content))) + expect_true(any(grepl("myrepo", content))) + expect_true(any(grepl("R_myid", content))) + expect_true(any(grepl("IT_feat", content))) + expect_true(any(grepl("IT_bug", content))) +}) + +test_that(".use_skill() emits a cli_inform message (#3)", { + local_pkg() + expect_snapshot( + .use_skill( + "create-issue", + data = list( + owner = "testowner", + repo = "testrepo", + repo_id = "R_test", + issue_types = list() + ), + open = FALSE + ) + ) +}) + +test_that(".use_skill() upserts into AGENTS.md when it exists (#3)", { + proj_dir <- local_pkg( + "AGENTS.md" = c( + "## Skills", + "", + "| Triggers | Path |", + "|----------|------|" + ) + ) + suppressMessages( + .use_skill( + "create-issue", + data = list( + owner = "o", + repo = "r", + repo_id = "id", + issue_types = list() + ), + open = FALSE + ) + ) + content <- readLines(fs::path(proj_dir, "AGENTS.md")) + expect_true(any(grepl("create GitHub issues", content))) + expect_true(any(grepl( + "@.github/skills/create-issue/SKILL.md", + content, + fixed = TRUE + ))) +}) + +test_that(".use_skill() creates ## Skills section in AGENTS.md if missing (#3)", { + proj_dir <- local_pkg( + "AGENTS.md" = c( + "# My Project", + "", + "Some content." + ) + ) + suppressMessages( + .use_skill( + "create-issue", + data = list( + owner = "o", + repo = "r", + repo_id = "id", + issue_types = list() + ), + open = FALSE + ) + ) + content <- readLines(fs::path(proj_dir, "AGENTS.md")) + expect_true(any(grepl("^## Skills", content))) + expect_true(any(grepl("create GitHub issues", content))) +}) + +test_that(".use_skill() updates trigger for existing row in AGENTS.md (#3)", { + proj_dir <- local_pkg( + "AGENTS.md" = c( + "## Skills", + "", + "| Triggers | Path |", + "|----------|------|", + "| old trigger | @.github/skills/create-issue/SKILL.md |" + ) + ) + suppressMessages( + .use_skill( + "create-issue", + data = list( + owner = "o", + repo = "r", + repo_id = "id", + issue_types = list() + ), + open = FALSE + ) + ) + content <- readLines(fs::path(proj_dir, "AGENTS.md")) + expect_false(any(grepl("old trigger", content))) + expect_true(any(grepl("create GitHub issues", content))) +}) + +test_that(".use_skill() does not touch AGENTS.md when it does not exist (#3)", { + proj_dir <- local_pkg() + suppressMessages( + .use_skill( + "create-issue", + data = list( + owner = "o", + repo = "r", + repo_id = "id", + issue_types = list() + ), + open = FALSE + ) + ) + expect_false(fs::file_exists(fs::path(proj_dir, "AGENTS.md"))) +}) + +test_that(".use_skill() skips file when overwrite = FALSE and file exists (#3)", { + proj_dir <- local_pkg() + existing_path <- fs::path(proj_dir, ".github/skills/create-issue/SKILL.md") + fs::dir_create(fs::path_dir(existing_path)) + writeLines("original content", existing_path) + suppressMessages( + .use_skill( + "create-issue", + data = list( + owner = "o", + repo = "r", + repo_id = "id", + issue_types = list() + ), + overwrite = FALSE, + open = FALSE + ) + ) + expect_equal(readLines(existing_path), "original content") +}) + +test_that(".use_skill() overwrites file when overwrite = TRUE and file exists (#3)", { + proj_dir <- local_pkg() + existing_path <- fs::path(proj_dir, ".github/skills/create-issue/SKILL.md") + fs::dir_create(fs::path_dir(existing_path)) + writeLines("original content", existing_path) + suppressMessages( + .use_skill( + "create-issue", + data = list( + owner = "o", + repo = "r", + repo_id = "id", + issue_types = list() + ), + overwrite = TRUE, + open = FALSE + ) + ) + content <- readLines(existing_path) + expect_false(identical(content, "original content")) + expect_true(any(grepl("Create a GitHub issue", content))) +}) + +test_that(".use_skill() errors on non-scalar skill (#3)", { + stbl::expect_pkg_error_classes( + .use_skill(c("a", "b"), data = list(), open = FALSE), + "stbl", + "non_scalar" + ) +}) + +test_that(".use_skill() errors on non-logical use_skills_subdir (#3)", { + local_pkg() + stbl::expect_pkg_error_classes( + .use_skill( + "create-issue", + data = list(), + use_skills_subdir = "yes", + open = FALSE + ), + "stbl", + "incompatible_type" + ) +}) + +test_that(".use_skill() errors on non-logical overwrite (#3)", { + local_pkg() + stbl::expect_pkg_error_classes( + .use_skill("create-issue", data = list(), overwrite = "yes", open = FALSE), + "stbl", + "incompatible_type" + ) +}) + +test_that(".read_skill_trigger() errors when template file not found (#3)", { + stbl::expect_pkg_error_classes( + .read_skill_trigger("/tmp/nonexistent/SKILL.md"), + "pkgskills", + "template_not_found" + ) +}) + +test_that(".read_skill_trigger() errors when front matter is missing (#3)", { + tmp <- withr::local_tempfile(fileext = ".md") + writeLines(c("# No front matter here", "Just content."), tmp) + stbl::expect_pkg_error_classes( + .read_skill_trigger(tmp), + "pkgskills", + "no_front_matter" + ) +}) + +test_that(".read_skill_trigger() errors when trigger field is absent (#3)", { + tmp <- withr::local_tempfile(fileext = ".md") + writeLines(c("---", "name: my-skill", "---", "# Content"), tmp) + stbl::expect_pkg_error_classes( + .read_skill_trigger(tmp), + "pkgskills", + "no_trigger" + ) +}) + +test_that(".upsert_agents_skills_row() creates table when ## Skills has no table (#3)", { + tmp <- withr::local_tempfile(fileext = ".md") + writeLines(c("# Project", "", "## Skills", "", "No table here."), tmp) + .upsert_agents_skills_row(tmp, "my trigger", ".github/skills/test/SKILL.md") + content <- readLines(tmp) + expect_true(any(grepl("\\| Triggers \\| Path \\|", content))) + expect_true(any(grepl("my trigger", content))) +}) + +test_that(".upsert_agents_skills_row() appends row after non-terminal table (#3)", { + tmp <- withr::local_tempfile(fileext = ".md") + writeLines( + c( + "## Skills", + "", + "| Triggers | Path |", + "|----------|------|", + "| existing skill | @.github/skills/other/SKILL.md |", + "", + "## Other section" + ), + tmp + ) + .upsert_agents_skills_row(tmp, "new skill", ".github/skills/new/SKILL.md") + content <- readLines(tmp) + expect_true(any(grepl("new skill", content))) + expect_true(any(grepl("existing skill", content))) +}) diff --git a/tests/testthat/test-use_skill_create_issue.R b/tests/testthat/test-use_skill_create_issue.R new file mode 100644 index 0000000..c1a8d65 --- /dev/null +++ b/tests/testthat/test-use_skill_create_issue.R @@ -0,0 +1,177 @@ +test_that("use_skill_create_issue() errors when BugReports is absent (#3)", { + local_pkg( + DESCRIPTION = c( + "Package: mypkg", + "Title: My Package", + "Version: 0.1.0" + ) + ) + expect_snapshot( + (expect_error( + use_skill_create_issue(open = FALSE), + class = "pkgskills-error" + )) + ) +}) + +test_that("use_skill_create_issue() errors when BugReports is not a GitHub URL (#3)", { + local_pkg( + DESCRIPTION = c( + "Package: mypkg", + "Title: My Package", + "Version: 0.1.0", + "BugReports: https://gitlab.com/myorg/mypkg/issues" + ) + ) + expect_snapshot( + (expect_error( + use_skill_create_issue(open = FALSE), + class = "pkgskills-error" + )) + ) +}) + +test_that("use_skill_create_issue() calls gh::gh() with correct queries (#3)", { + local_pkg( + DESCRIPTION = c( + "Package: mypkg", + "Title: My Package", + "Version: 0.1.0", + "BugReports: https://github.com/myorg/mypkg/issues" + ) + ) + gh_calls <- list() + local_mocked_bindings( + gh = function(...) { + args <- list(...) + gh_calls[[length(gh_calls) + 1]] <<- args + if (grepl("issueTypes", args$query)) { + list(data = list(repository = list(issueTypes = list(nodes = list())))) + } else { + list(data = list(repository = list(id = "R_testid"))) + } + }, + .package = "gh" + ) + suppressMessages(use_skill_create_issue(open = FALSE)) + expect_length(gh_calls, 2L) + expect_true(any(vapply( + gh_calls, + function(x) grepl("issueTypes", x$query), + logical(1) + ))) + expect_true(any(vapply( + gh_calls, + function(x) grepl("myorg", x$query), + logical(1) + ))) + expect_true(any(vapply( + gh_calls, + function(x) grepl("mypkg", x$query), + logical(1) + ))) +}) + +test_that("use_skill_create_issue() passes correct data to .use_skill() (#3)", { + local_pkg( + DESCRIPTION = c( + "Package: mypkg", + "Title: My Package", + "Version: 0.1.0", + "BugReports: https://github.com/myorg/mypkg/issues" + ) + ) + local_mocked_bindings( + gh = function(...) { + args <- list(...) + if (grepl("issueTypes", args$query)) { + list( + data = list( + repository = list( + issueTypes = list( + nodes = list( + list( + name = "Feature", + id = "IT_feat", + description = "New stuff" + ) + ) + ) + ) + ) + ) + } else { + list(data = list(repository = list(id = "R_myid"))) + } + }, + .package = "gh" + ) + captured_data <- NULL + local_mocked_bindings( + .use_skill = function(skill, data, ...) { + captured_data <<- data + invisible(usethis::proj_path(".github/skills/create-issue/SKILL.md")) + } + ) + suppressMessages(use_skill_create_issue(open = FALSE)) + expect_equal(captured_data$owner, "myorg") + expect_equal(captured_data$repo, "mypkg") + expect_equal(captured_data$repo_id, "R_myid") + expect_equal(captured_data$issue_types[[1]]$name, "Feature") +}) + +test_that("use_skill_create_issue() returns path invisibly (#3)", { + local_pkg( + DESCRIPTION = c( + "Package: mypkg", + "Title: My Package", + "Version: 0.1.0", + "BugReports: https://github.com/myorg/mypkg/issues" + ) + ) + local_mocked_bindings( + gh = function(...) { + args <- list(...) + if (grepl("issueTypes", args$query)) { + list(data = list(repository = list(issueTypes = list(nodes = list())))) + } else { + list(data = list(repository = list(id = "R_testid"))) + } + }, + .package = "gh" + ) + result <- withVisible(suppressMessages(use_skill_create_issue(open = FALSE))) + expect_false(result$visible) +}) + +test_that("use_skill_create_issue() errors on non-scalar target_dir (#3)", { + local_pkg( + DESCRIPTION = c( + "Package: mypkg", + "Title: My Package", + "Version: 0.1.0", + "BugReports: https://github.com/myorg/mypkg/issues" + ) + ) + stbl::expect_pkg_error_classes( + use_skill_create_issue(target_dir = c("a", "b"), open = FALSE), + "stbl", + "non_scalar" + ) +}) + +test_that("use_skill_create_issue() errors on non-logical overwrite (#3)", { + local_pkg( + DESCRIPTION = c( + "Package: mypkg", + "Title: My Package", + "Version: 0.1.0", + "BugReports: https://github.com/myorg/mypkg/issues" + ) + ) + stbl::expect_pkg_error_classes( + use_skill_create_issue(overwrite = "yes", open = FALSE), + "stbl", + "incompatible_type" + ) +}) From 42cedc0ed89410f1ee222d2cb051c7591ccbe883 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 13 Mar 2026 15:16:34 +0000 Subject: [PATCH 3/7] refactor: address review feedback Co-authored-by: jonthegeek <33983824+jonthegeek@users.noreply.github.com> --- .github/skills/create-issue/SKILL.md | 7 +-- .github/skills/github/SKILL.md | 2 +- R/aaa-shared_params.R | 8 +++ R/use_agent.R | 1 - R/use_skill.R | 60 ++++++-------------- R/use_skill_create_issue.R | 16 ++++-- R/utils.R | 55 ++++++++++++++++++ inst/templates/skills/create-issue/SKILL.md | 9 +-- man/dot-check_file_exists.Rd | 24 ++++++++ man/dot-path_pkg.Rd | 18 ++++++ man/dot-path_template.Rd | 19 +++++++ man/dot-shared-params.Rd | 12 ++++ man/dot-to_boolean.Rd | 22 +++++++ man/dot-upsert_agents_skills_row.Rd | 7 +-- man/dot-use_skill.Rd | 5 +- man/dot-use_template.Rd | 2 +- tests/testthat/test-use_skill.R | 60 ++++++++++++-------- tests/testthat/test-use_skill_create_issue.R | 5 ++ 18 files changed, 237 insertions(+), 95 deletions(-) create mode 100644 man/dot-check_file_exists.Rd create mode 100644 man/dot-path_pkg.Rd create mode 100644 man/dot-path_template.Rd create mode 100644 man/dot-to_boolean.Rd diff --git a/.github/skills/create-issue/SKILL.md b/.github/skills/create-issue/SKILL.md index 665c573..f57e7ef 100644 --- a/.github/skills/create-issue/SKILL.md +++ b/.github/skills/create-issue/SKILL.md @@ -54,7 +54,6 @@ Which sections to include depends on the issue type: | `## Details` | optional | optional | optional | optional | | `## Proposed signature` | ✓ | — | — | — | | `## Behavior` | ✓ | ✓ | — | — | -| `## Implementation` | optional | optional | optional | optional | | `## References` | optional | optional | optional | optional | ### `## Summary` (all types) @@ -75,7 +74,7 @@ Example: ### `## Details` (optional, all types) -For information that's important to capture but doesn't fit naturally into any other section. Use sparingly — if the content belongs in `## Behavior`, `## Proposed signature`, or `## References`, put it there instead. +For information that's important to capture but doesn't fit naturally into any other section, including implementation details such as packages to add to `Imports` in `DESCRIPTION` or files to add to `inst`. Use sparingly — if the content belongs in `## Behavior`, `## Proposed signature`, or `## References`, put it there instead. ### `## Proposed signature` (Feature only) @@ -101,10 +100,6 @@ function_name(arg1, arg2) - **Feature**: bullet points describing expected behavior, edge cases, and any internal helpers to implement as part of this issue. - **Bug**: describe the current (broken) behavior, the expected behavior, and steps to reproduce if known. -### `## Implementation` (optional, all types) - -Bullet points describing additional details about implementation of the feature, such as packages to add to `Imports` in `DESCRIPTION` or files to add to `inst`. - ### `## References` (optional, all types) Only include when there are specific reference implementations, external URLs, or related code to link to. Omit it entirely when there are none. diff --git a/.github/skills/github/SKILL.md b/.github/skills/github/SKILL.md index 668c835..8fa04d5 100644 --- a/.github/skills/github/SKILL.md +++ b/.github/skills/github/SKILL.md @@ -1,6 +1,6 @@ --- name: github -trigger: github workflows +trigger: from github description: GitHub workflows using the `gh` CLI, including viewing issues/PRs and commit message conventions. Use when interacting with GitHub in any way, such as viewing, creating, or editing issues and pull requests, making commits, or running any `gh` command. compatibility: Requires the `gh` CLI and an authenticated GitHub session. --- diff --git a/R/aaa-shared_params.R b/R/aaa-shared_params.R index 467f8d1..894783b 100644 --- a/R/aaa-shared_params.R +++ b/R/aaa-shared_params.R @@ -4,8 +4,16 @@ #' make them easier to import and to find. #' #' @param call (`environment`) The caller environment for error messages. +#' @param data (`list`) Named list of whisker template variables for rendering. #' @param open (`logical(1)`) Whether to open the file after creation. +#' @param overwrite (`logical(1)`) Whether to overwrite an existing skill file. +#' Defaults to `TRUE`. #' @param save_as (`character(1)`) Output file path, relative to project root. +#' @param target_dir (`character(1)`) Directory where the skill will be +#' installed, relative to the project root. Defaults to `".github"`. +#' @param use_skills_subdir (`logical(1)`) Whether to place the skill folder +#' under a `skills` subdirectory of `target_dir`. Defaults to `TRUE`, +#' producing `.github/skills/{skill}/SKILL.md`. #' @param x_arg (`character(1)`) Argument name for `x`, used in error messages. #' #' @name .shared-params diff --git a/R/use_agent.R b/R/use_agent.R index 2d1915e..24bcda7 100644 --- a/R/use_agent.R +++ b/R/use_agent.R @@ -34,7 +34,6 @@ use_agent <- function(save_as = "AGENTS.md", open = rlang::is_interactive()) { #' Wrapper around [usethis::use_template()] #' #' @param template (`character(1)`) Template name within `inst/templates/`. -#' @param data (`list`) Named list of values for whisker rendering. #' @inheritParams .shared-params #' @returns Called for side effects. #' @keywords internal diff --git a/R/use_skill.R b/R/use_skill.R index 4f5c5f6..979aa8c 100644 --- a/R/use_skill.R +++ b/R/use_skill.R @@ -1,19 +1,8 @@ #' Install a skill into a project #' -#' Internal helper that renders and writes a skill template, and upserts the -#' `## Skills` table in `AGENTS.md` when it exists. -#' #' @param skill (`character(1)`) Skill name — folder name under #' `inst/templates/skills/`, e.g. `"create-issue"`. Determines the template #' path and the install subdirectory. -#' @param data (`list`) Named list of whisker template variables. -#' @param target_dir (`character(1)`) Directory where the skill will be -#' installed, relative to the project root. Defaults to `".github"`. -#' @param use_skills_subdir (`logical(1)`) Whether to place the skill folder -#' under a `skills` subdirectory of `target_dir`. Defaults to `TRUE`, -#' producing `.github/skills/{skill}/SKILL.md`. -#' @param overwrite (`logical(1)`) Whether to overwrite an existing skill file. -#' Defaults to `TRUE`. #' @inheritParams .shared-params #' @returns The path to the installed skill file, invisibly. #' @keywords internal @@ -28,42 +17,25 @@ skill <- .to_string(skill) data <- stbl::to_list(data) target_dir <- .to_string(target_dir) - use_skills_subdir <- stbl::to_lgl_scalar( - use_skills_subdir, - allow_null = FALSE - ) - overwrite <- stbl::to_lgl_scalar(overwrite, allow_null = FALSE) + use_skills_subdir <- .to_boolean(use_skills_subdir) + overwrite <- .to_boolean(overwrite) if (use_skills_subdir) { - save_as <- fs::path(target_dir, "skills", skill, "SKILL.md") - } else { - save_as <- fs::path(target_dir, skill, "SKILL.md") + target_dir <- fs::path(target_dir, "skills") } + save_as <- fs::path(target_dir, skill, "SKILL.md") + + .check_file_exists(save_as, overwrite) - template_path <- system.file( - "templates", - "skills", - skill, - "SKILL.md", - package = "pkgskills" - ) + skill_subpath <- fs::path("skills", skill, "SKILL.md") + template_path <- .path_template(skill_subpath) trigger <- .read_skill_trigger(template_path) path <- usethis::proj_path(save_as) - if (overwrite && fs::file_exists(path)) { - fs::file_delete(path) - } else if (!overwrite && fs::file_exists(path)) { - cli::cli_inform("Skill {.file {save_as}} already exists. Skipping.") - return(invisible(path)) - } - fs::dir_create(fs::path_dir(path)) - .use_template(paste0("skills/", skill, "/SKILL.md"), save_as, data, open) + .use_template(skill_subpath, save_as, data, open) - agents_path <- usethis::proj_path("AGENTS.md") - if (fs::file_exists(agents_path)) { - .upsert_agents_skills_row(agents_path, trigger, save_as) - } + .upsert_agents_skills_row(trigger, save_as) cli::cli_inform("Skill {.file {save_as}} installed.") invisible(path) @@ -107,16 +79,20 @@ #' Upsert a skill row in the ## Skills table of AGENTS.md #' -#' @param agents_path (`character(1)`) Path to the `AGENTS.md` file. #' @param trigger (`character(1)`) Trigger phrase for the skill. #' @param save_as (`character(1)`) Relative path to the installed skill file. -#' @returns The path to `AGENTS.md`, invisibly. +#' @returns The path to `AGENTS.md`, invisibly, or `NULL` invisibly if +#' `AGENTS.md` does not exist. #' @keywords internal -.upsert_agents_skills_row <- function(agents_path, trigger, save_as) { - agents_path <- .to_string(agents_path) +.upsert_agents_skills_row <- function(trigger, save_as) { trigger <- .to_string(trigger) save_as <- .to_string(save_as) + agents_path <- usethis::proj_path("AGENTS.md") + if (!fs::file_exists(agents_path)) { + return(invisible(NULL)) + } + lines <- readLines(agents_path, warn = FALSE) save_as_escaped <- gsub("([.|*+?^${}()\\[\\]\\\\])", "\\\\\\1", save_as) existing_idx <- grep(paste0("@", save_as_escaped), lines) diff --git a/R/use_skill_create_issue.R b/R/use_skill_create_issue.R index 1c5b394..ab47192 100644 --- a/R/use_skill_create_issue.R +++ b/R/use_skill_create_issue.R @@ -27,11 +27,8 @@ use_skill_create_issue <- function( gh_token = gh::gh_token() ) { target_dir <- .to_string(target_dir) - use_skills_subdir <- stbl::to_lgl_scalar( - use_skills_subdir, - allow_null = FALSE - ) - overwrite <- stbl::to_lgl_scalar(overwrite, allow_null = FALSE) + use_skills_subdir <- .to_boolean(use_skills_subdir) + overwrite <- .to_boolean(overwrite) gh_token <- .to_string(gh_token) bug_reports <- desc::desc_get( @@ -88,11 +85,18 @@ use_skill_create_issue <- function( ) issue_types <- types_result$data$repository$issueTypes$nodes + update_time <- format( + Sys.time(), + tz = "UTC", + format = "%Y-%m-%d %H:%M:%S UTC" + ) + data <- list( owner = owner, repo = repo, repo_id = repo_id, - issue_types = issue_types + issue_types = issue_types, + update_time = update_time ) .use_skill( diff --git a/R/utils.R b/R/utils.R index e03fa41..b69f9ee 100644 --- a/R/utils.R +++ b/R/utils.R @@ -13,3 +13,58 @@ call = call ) } + +#' Coerce to a non-null logical scalar +#' +#' @param x (`any`) The value to coerce. +#' @inheritParams .shared-params +#' @returns (`logical(1)`) `x` coerced to a logical scalar. +#' @keywords internal +.to_boolean <- function(x, x_arg = caller_arg(x), call = caller_env()) { + stbl::to_lgl_scalar(x, allow_null = FALSE, x_arg = x_arg, call = call) +} + +#' Build a path within the pkgskills package +#' +#' @param ... Path components, passed to [fs::path_package()]. +#' @returns (`character(1)`) Absolute path within the pkgskills package. +#' @keywords internal +.path_pkg <- function(...) { + fs::path_package("pkgskills", ...) +} + +#' Build a path to a file in `inst/templates/` +#' +#' @param ... Path components appended after `"templates"`, passed to +#' [.path_pkg()]. +#' @returns (`character(1)`) Absolute path to the template file. +#' @keywords internal +.path_template <- function(...) { + .path_pkg("templates", ...) +} + +#' Check whether a file exists and act on it +#' +#' Deletes the file if it exists and `overwrite = TRUE`. Errors if the file +#' exists and `overwrite = FALSE`. +#' +#' @inheritParams .shared-params +#' @returns `NULL`, invisibly. +#' @keywords internal +.check_file_exists <- function(save_as, overwrite, call = rlang::caller_env()) { + save_as <- .to_string(save_as, call = call) + overwrite <- .to_boolean(overwrite, call = call) + path <- usethis::proj_path(save_as) + if (fs::file_exists(path)) { + if (overwrite) { + fs::file_delete(path) + } else { + .pkg_abort( + "File {.file {save_as}} already exists.", + "file_exists", + call = call + ) + } + } + invisible(NULL) +} diff --git a/inst/templates/skills/create-issue/SKILL.md b/inst/templates/skills/create-issue/SKILL.md index fb1c324..dd107e8 100644 --- a/inst/templates/skills/create-issue/SKILL.md +++ b/inst/templates/skills/create-issue/SKILL.md @@ -13,7 +13,7 @@ If `gh` is not authenticated, stop and ask the user to authenticate before conti ## Looking up IDs -The hardcoded IDs below are correct for this repo as of the time of install. If they ever change, or if you're working in a fork, re-run these queries to get fresh values: +The hardcoded IDs below are correct for this repo as of {{{update_time}}}. If they ever change, or if you're working in a fork, re-run these queries to get fresh values: ```bash # Repository node ID @@ -52,7 +52,6 @@ Which sections to include depends on the issue type: | `## Details` | optional | optional | optional | optional | | `## Proposed signature` | ✓ | — | — | — | | `## Behavior` | ✓ | ✓ | — | — | -| `## Implementation` | optional | optional | optional | optional | | `## References` | optional | optional | optional | optional | ### `## Summary` (all types) @@ -73,7 +72,7 @@ Example: ### `## Details` (optional, all types) -For information that's important to capture but doesn't fit naturally into any other section. Use sparingly — if the content belongs in `## Behavior`, `## Proposed signature`, or `## References`, put it there instead. +For information that's important to capture but doesn't fit naturally into any other section, including implementation details such as packages to add to `Imports` in `DESCRIPTION` or files to add to `inst`. Use sparingly — if the content belongs in `## Behavior`, `## Proposed signature`, or `## References`, put it there instead. ### `## Proposed signature` (Feature only) @@ -99,10 +98,6 @@ function_name(arg1, arg2) - **Feature**: bullet points describing expected behavior, edge cases, and any internal helpers to implement as part of this issue. - **Bug**: describe the current (broken) behavior, the expected behavior, and steps to reproduce if known. -### `## Implementation` (optional, all types) - -Bullet points describing additional details about implementation of the feature, such as packages to add to `Imports` in `DESCRIPTION` or files to add to `inst`. - ### `## References` (optional, all types) Only include when there are specific reference implementations, external URLs, or related code to link to. Omit it entirely when there are none. diff --git a/man/dot-check_file_exists.Rd b/man/dot-check_file_exists.Rd new file mode 100644 index 0000000..b4330b5 --- /dev/null +++ b/man/dot-check_file_exists.Rd @@ -0,0 +1,24 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/utils.R +\name{.check_file_exists} +\alias{.check_file_exists} +\title{Check whether a file exists and act on it} +\usage{ +.check_file_exists(save_as, overwrite, call = rlang::caller_env()) +} +\arguments{ +\item{save_as}{(\code{character(1)}) Output file path, relative to project root.} + +\item{overwrite}{(\code{logical(1)}) Whether to overwrite an existing skill file. +Defaults to \code{TRUE}.} + +\item{call}{(\code{environment}) The caller environment for error messages.} +} +\value{ +\code{NULL}, invisibly. +} +\description{ +Deletes the file if it exists and \code{overwrite = TRUE}. Errors if the file +exists and \code{overwrite = FALSE}. +} +\keyword{internal} diff --git a/man/dot-path_pkg.Rd b/man/dot-path_pkg.Rd new file mode 100644 index 0000000..619c06e --- /dev/null +++ b/man/dot-path_pkg.Rd @@ -0,0 +1,18 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/utils.R +\name{.path_pkg} +\alias{.path_pkg} +\title{Build a path within the pkgskills package} +\usage{ +.path_pkg(...) +} +\arguments{ +\item{...}{Path components, passed to \code{\link[fs:path_package]{fs::path_package()}}.} +} +\value{ +(\code{character(1)}) Absolute path within the pkgskills package. +} +\description{ +Build a path within the pkgskills package +} +\keyword{internal} diff --git a/man/dot-path_template.Rd b/man/dot-path_template.Rd new file mode 100644 index 0000000..5ce8c7d --- /dev/null +++ b/man/dot-path_template.Rd @@ -0,0 +1,19 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/utils.R +\name{.path_template} +\alias{.path_template} +\title{Build a path to a file in \verb{inst/templates/}} +\usage{ +.path_template(...) +} +\arguments{ +\item{...}{Path components appended after \code{"templates"}, passed to +\code{\link[=.path_pkg]{.path_pkg()}}.} +} +\value{ +(\code{character(1)}) Absolute path to the template file. +} +\description{ +Build a path to a file in \verb{inst/templates/} +} +\keyword{internal} diff --git a/man/dot-shared-params.Rd b/man/dot-shared-params.Rd index f39ee73..d62ec62 100644 --- a/man/dot-shared-params.Rd +++ b/man/dot-shared-params.Rd @@ -6,10 +6,22 @@ \arguments{ \item{call}{(\code{environment}) The caller environment for error messages.} +\item{data}{(\code{list}) Named list of whisker template variables for rendering.} + \item{open}{(\code{logical(1)}) Whether to open the file after creation.} +\item{overwrite}{(\code{logical(1)}) Whether to overwrite an existing skill file. +Defaults to \code{TRUE}.} + \item{save_as}{(\code{character(1)}) Output file path, relative to project root.} +\item{target_dir}{(\code{character(1)}) Directory where the skill will be +installed, relative to the project root. Defaults to \code{".github"}.} + +\item{use_skills_subdir}{(\code{logical(1)}) Whether to place the skill folder +under a \code{skills} subdirectory of \code{target_dir}. Defaults to \code{TRUE}, +producing \code{.github/skills/{skill}/SKILL.md}.} + \item{x_arg}{(\code{character(1)}) Argument name for \code{x}, used in error messages.} } \description{ diff --git a/man/dot-to_boolean.Rd b/man/dot-to_boolean.Rd new file mode 100644 index 0000000..1681125 --- /dev/null +++ b/man/dot-to_boolean.Rd @@ -0,0 +1,22 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/utils.R +\name{.to_boolean} +\alias{.to_boolean} +\title{Coerce to a non-null logical scalar} +\usage{ +.to_boolean(x, x_arg = caller_arg(x), call = caller_env()) +} +\arguments{ +\item{x}{(\code{any}) The value to coerce.} + +\item{x_arg}{(\code{character(1)}) Argument name for \code{x}, used in error messages.} + +\item{call}{(\code{environment}) The caller environment for error messages.} +} +\value{ +(\code{logical(1)}) \code{x} coerced to a logical scalar. +} +\description{ +Coerce to a non-null logical scalar +} +\keyword{internal} diff --git a/man/dot-upsert_agents_skills_row.Rd b/man/dot-upsert_agents_skills_row.Rd index dc00eba..cb70813 100644 --- a/man/dot-upsert_agents_skills_row.Rd +++ b/man/dot-upsert_agents_skills_row.Rd @@ -4,17 +4,16 @@ \alias{.upsert_agents_skills_row} \title{Upsert a skill row in the ## Skills table of AGENTS.md} \usage{ -.upsert_agents_skills_row(agents_path, trigger, save_as) +.upsert_agents_skills_row(trigger, save_as) } \arguments{ -\item{agents_path}{(\code{character(1)}) Path to the \code{AGENTS.md} file.} - \item{trigger}{(\code{character(1)}) Trigger phrase for the skill.} \item{save_as}{(\code{character(1)}) Relative path to the installed skill file.} } \value{ -The path to \code{AGENTS.md}, invisibly. +The path to \code{AGENTS.md}, invisibly, or \code{NULL} invisibly if +\code{AGENTS.md} does not exist. } \description{ Upsert a skill row in the ## Skills table of AGENTS.md diff --git a/man/dot-use_skill.Rd b/man/dot-use_skill.Rd index a51beb2..574cfc7 100644 --- a/man/dot-use_skill.Rd +++ b/man/dot-use_skill.Rd @@ -18,7 +18,7 @@ \verb{inst/templates/skills/}, e.g. \code{"create-issue"}. Determines the template path and the install subdirectory.} -\item{data}{(\code{list}) Named list of whisker template variables.} +\item{data}{(\code{list}) Named list of whisker template variables for rendering.} \item{target_dir}{(\code{character(1)}) Directory where the skill will be installed, relative to the project root. Defaults to \code{".github"}.} @@ -36,7 +36,6 @@ Defaults to \code{TRUE}.} The path to the installed skill file, invisibly. } \description{ -Internal helper that renders and writes a skill template, and upserts the -\verb{## Skills} table in \code{AGENTS.md} when it exists. +Install a skill into a project } \keyword{internal} diff --git a/man/dot-use_template.Rd b/man/dot-use_template.Rd index 0dfeb29..246a3c6 100644 --- a/man/dot-use_template.Rd +++ b/man/dot-use_template.Rd @@ -11,7 +11,7 @@ \item{save_as}{(\code{character(1)}) Output file path, relative to project root.} -\item{data}{(\code{list}) Named list of values for whisker rendering.} +\item{data}{(\code{list}) Named list of whisker template variables for rendering.} \item{open}{(\code{logical(1)}) Whether to open the file after creation.} diff --git a/tests/testthat/test-use_skill.R b/tests/testthat/test-use_skill.R index f437e08..1c67c98 100644 --- a/tests/testthat/test-use_skill.R +++ b/tests/testthat/test-use_skill.R @@ -215,23 +215,27 @@ test_that(".use_skill() does not touch AGENTS.md when it does not exist (#3)", { expect_false(fs::file_exists(fs::path(proj_dir, "AGENTS.md"))) }) -test_that(".use_skill() skips file when overwrite = FALSE and file exists (#3)", { +test_that(".use_skill() errors when overwrite = FALSE and file exists (#3)", { proj_dir <- local_pkg() existing_path <- fs::path(proj_dir, ".github/skills/create-issue/SKILL.md") fs::dir_create(fs::path_dir(existing_path)) writeLines("original content", existing_path) - suppressMessages( - .use_skill( - "create-issue", - data = list( - owner = "o", - repo = "r", - repo_id = "id", - issue_types = list() - ), - overwrite = FALSE, - open = FALSE - ) + stbl::expect_pkg_error_classes( + suppressMessages( + .use_skill( + "create-issue", + data = list( + owner = "o", + repo = "r", + repo_id = "id", + issue_types = list() + ), + overwrite = FALSE, + open = FALSE + ) + ), + "pkgskills", + "file_exists" ) expect_equal(readLines(existing_path), "original content") }) @@ -319,18 +323,18 @@ test_that(".read_skill_trigger() errors when trigger field is absent (#3)", { }) test_that(".upsert_agents_skills_row() creates table when ## Skills has no table (#3)", { - tmp <- withr::local_tempfile(fileext = ".md") - writeLines(c("# Project", "", "## Skills", "", "No table here."), tmp) - .upsert_agents_skills_row(tmp, "my trigger", ".github/skills/test/SKILL.md") - content <- readLines(tmp) + proj_dir <- local_pkg( + "AGENTS.md" = c("# Project", "", "## Skills", "", "No table here.") + ) + .upsert_agents_skills_row("my trigger", ".github/skills/test/SKILL.md") + content <- readLines(fs::path(proj_dir, "AGENTS.md")) expect_true(any(grepl("\\| Triggers \\| Path \\|", content))) expect_true(any(grepl("my trigger", content))) }) test_that(".upsert_agents_skills_row() appends row after non-terminal table (#3)", { - tmp <- withr::local_tempfile(fileext = ".md") - writeLines( - c( + proj_dir <- local_pkg( + "AGENTS.md" = c( "## Skills", "", "| Triggers | Path |", @@ -338,11 +342,19 @@ test_that(".upsert_agents_skills_row() appends row after non-terminal table (#3) "| existing skill | @.github/skills/other/SKILL.md |", "", "## Other section" - ), - tmp + ) ) - .upsert_agents_skills_row(tmp, "new skill", ".github/skills/new/SKILL.md") - content <- readLines(tmp) + .upsert_agents_skills_row("new skill", ".github/skills/new/SKILL.md") + content <- readLines(fs::path(proj_dir, "AGENTS.md")) expect_true(any(grepl("new skill", content))) expect_true(any(grepl("existing skill", content))) }) + +test_that(".upsert_agents_skills_row() returns NULL invisibly when AGENTS.md absent (#3)", { + proj_dir <- local_pkg() + result <- withVisible( + .upsert_agents_skills_row("my trigger", ".github/skills/test/SKILL.md") + ) + expect_false(result$visible) + expect_null(result$value) +}) diff --git a/tests/testthat/test-use_skill_create_issue.R b/tests/testthat/test-use_skill_create_issue.R index c1a8d65..f5289fd 100644 --- a/tests/testthat/test-use_skill_create_issue.R +++ b/tests/testthat/test-use_skill_create_issue.R @@ -118,6 +118,11 @@ test_that("use_skill_create_issue() passes correct data to .use_skill() (#3)", { expect_equal(captured_data$repo, "mypkg") expect_equal(captured_data$repo_id, "R_myid") expect_equal(captured_data$issue_types[[1]]$name, "Feature") + expect_true(grepl("UTC$", captured_data$update_time)) + expect_match( + captured_data$update_time, + "^\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2} UTC$" + ) }) test_that("use_skill_create_issue() returns path invisibly (#3)", { From dc620afb834aed2c7868e81cdcc7539ce5b2344c Mon Sep 17 00:00:00 2001 From: Jon Harmon Date: Fri, 13 Mar 2026 11:34:03 -0500 Subject: [PATCH 4/7] Manual cleanup. --- DESCRIPTION | 3 +- R/aaa-conditions.R | 12 +- R/aaa-shared_params.R | 6 +- R/use_agent.R | 6 +- R/use_skill.R | 57 +++++---- R/use_skill_create_issue.R | 113 +++++++++++------- R/utils.R | 7 +- man/dot-check_file_exists.Rd | 6 +- man/dot-extract_repo_from_desc.Rd | 18 +++ man/dot-fetch_repo_id.Rd | 23 ++++ man/dot-fetch_repo_issue_types.Rd | 24 ++++ man/dot-format_now_utc.Rd | 15 +++ man/dot-get_create_issue_data.Rd | 21 ++++ man/dot-pkg_abort.Rd | 11 +- man/dot-shared-params.Rd | 9 +- ...ills_row.Rd => dot-upsert_agents_skill.Rd} | 6 +- man/dot-upsert_agents_skill_from_template.Rd | 24 ++++ man/dot-use_skill.Rd | 9 +- man/use_skill_create_issue.Rd | 8 +- tests/testthat/_snaps/use_skill.md | 2 +- .../testthat/_snaps/use_skill_create_issue.md | 4 +- tests/testthat/test-use_skill.R | 46 +++---- tests/testthat/test-use_skill_create_issue.R | 14 +-- 23 files changed, 317 insertions(+), 127 deletions(-) create mode 100644 man/dot-extract_repo_from_desc.Rd create mode 100644 man/dot-fetch_repo_id.Rd create mode 100644 man/dot-fetch_repo_issue_types.Rd create mode 100644 man/dot-format_now_utc.Rd create mode 100644 man/dot-get_create_issue_data.Rd rename man/{dot-upsert_agents_skills_row.Rd => dot-upsert_agents_skill.Rd} (82%) create mode 100644 man/dot-upsert_agents_skill_from_template.Rd diff --git a/DESCRIPTION b/DESCRIPTION index 6453e7e..fc3a78b 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -20,8 +20,7 @@ Imports: usethis Suggests: testthat (>= 3.0.0), - withr, - yaml + withr Remotes: stbl=wranglezone/stbl Config/testthat/edition: 3 diff --git a/R/aaa-conditions.R b/R/aaa-conditions.R index 3426c55..c5c04f1 100644 --- a/R/aaa-conditions.R +++ b/R/aaa-conditions.R @@ -1,16 +1,20 @@ #' Raise a package-scoped error #' -#' @param message (`character(1)`) A `{cli}` message string. -#' @param subclass (`character(1)`) Error subclass. #' @inheritParams .shared-params +#' @inheritParams stbl::pkg_abort #' @returns Does not return. #' @keywords internal -.pkg_abort <- function(message, subclass, call = rlang::caller_env()) { +.pkg_abort <- function( + message, + subclass, + call = caller_env(), + message_env = caller_env() +) { stbl::pkg_abort( "pkgskills", message, subclass, call = call, - message_env = rlang::caller_env() + message_env = message_env ) } diff --git a/R/aaa-shared_params.R b/R/aaa-shared_params.R index 894783b..134ca7e 100644 --- a/R/aaa-shared_params.R +++ b/R/aaa-shared_params.R @@ -5,9 +5,13 @@ #' #' @param call (`environment`) The caller environment for error messages. #' @param data (`list`) Named list of whisker template variables for rendering. +#' @param gh_token (`character(1)`) A GitHub personal access token. Defaults to +#' `gh::gh_token()`. #' @param open (`logical(1)`) Whether to open the file after creation. -#' @param overwrite (`logical(1)`) Whether to overwrite an existing skill file. +#' @param overwrite (`logical(1)`) Whether to overwrite an existing file. #' Defaults to `TRUE`. +#' @param owner (`character(1)`) GitHub repository owner (user or organization). +#' @param repo (`character(1)`) GitHub repository name. #' @param save_as (`character(1)`) Output file path, relative to project root. #' @param target_dir (`character(1)`) Directory where the skill will be #' installed, relative to the project root. Defaults to `".github"`. diff --git a/R/use_agent.R b/R/use_agent.R index 24bcda7..db09cd3 100644 --- a/R/use_agent.R +++ b/R/use_agent.R @@ -17,8 +17,6 @@ use_agent <- function(save_as = "AGENTS.md", open = rlang::is_interactive()) { file = usethis::proj_path("DESCRIPTION") ) )) - path <- usethis::proj_path(save_as) - fs::dir_create(fs::path_dir(path)) .use_template("AGENTS.md", save_as, data, open) cli::cli_inform(c( "{.file AGENTS.md} created.", @@ -28,7 +26,7 @@ use_agent <- function(save_as = "AGENTS.md", open = rlang::is_interactive()) { "Focus on the **Repository overview** and the **Key files** table.\"" ) )) - invisible(path) + invisible(usethis::proj_path(save_as)) } #' Wrapper around [usethis::use_template()] @@ -39,8 +37,10 @@ use_agent <- function(save_as = "AGENTS.md", open = rlang::is_interactive()) { #' @keywords internal .use_template <- function(template, save_as, data, open, call = caller_env()) { save_as <- .to_string(save_as, call = call) + data <- stbl::to_list(data, call = call) template <- .to_string(template, call = call) open <- stbl::to_lgl_scalar(open, allow_null = FALSE, call = call) + fs::dir_create(fs::path_dir(usethis::proj_path(save_as))) usethis::use_template( template, save_as = save_as, diff --git a/R/use_skill.R b/R/use_skill.R index 979aa8c..132e547 100644 --- a/R/use_skill.R +++ b/R/use_skill.R @@ -1,6 +1,6 @@ #' Install a skill into a project #' -#' @param skill (`character(1)`) Skill name — folder name under +#' @param skill (`character(1)`) Skill name. A folder name under #' `inst/templates/skills/`, e.g. `"create-issue"`. Determines the template #' path and the install subdirectory. #' @inheritParams .shared-params @@ -12,35 +12,46 @@ target_dir = ".github", use_skills_subdir = TRUE, overwrite = TRUE, - open = rlang::is_interactive() + open = rlang::is_interactive(), + call = caller_env() ) { - skill <- .to_string(skill) - data <- stbl::to_list(data) - target_dir <- .to_string(target_dir) - use_skills_subdir <- .to_boolean(use_skills_subdir) - overwrite <- .to_boolean(overwrite) + # Validate inputs. + skill <- .to_string(skill, call = call) + target_dir <- .to_string(target_dir, call = call) + use_skills_subdir <- .to_boolean(use_skills_subdir, call = call) + # Set up path vars. if (use_skills_subdir) { target_dir <- fs::path(target_dir, "skills") } save_as <- fs::path(target_dir, skill, "SKILL.md") - - .check_file_exists(save_as, overwrite) - - skill_subpath <- fs::path("skills", skill, "SKILL.md") - template_path <- .path_template(skill_subpath) - trigger <- .read_skill_trigger(template_path) - path <- usethis::proj_path(save_as) - fs::dir_create(fs::path_dir(path)) - .use_template(skill_subpath, save_as, data, open) - - .upsert_agents_skills_row(trigger, save_as) + .check_file_exists(path, overwrite, call = call) + skill_subpath <- fs::path("skills", skill, "SKILL.md") + .use_template(skill_subpath, save_as, data, open, call = call) + .upsert_agents_skill_from_template(skill_subpath, save_as, call = call) cli::cli_inform("Skill {.file {save_as}} installed.") invisible(path) } +#' Upsert a template skill row in the ## Skills table of AGENTS.md +#' +#' @param skill_subpath (`character(1)`) The relative path to the `SKILL.md` +#' file. +#' @inheritParams .shared-params +#' @inherit .upsert_agents_skill return +#' @keywords internal +.upsert_agents_skill_from_template <- function( + skill_subpath, + save_as, + call = caller_env() +) { + template_path <- .path_template(skill_subpath) + trigger <- .read_skill_trigger(template_path, call = call) + .upsert_agents_skill(trigger, save_as) +} + #' Read the trigger field from a skill template's YAML front matter #' #' @param path (`character(1)`) Path to the skill template file. @@ -67,7 +78,7 @@ } front_matter <- lines[(delim_idx[[1L]] + 1L):(delim_idx[[2L]] - 1L)] trigger_line <- grep("^trigger:", front_matter, value = TRUE) - if (length(trigger_line) == 0L) { + if (!length(trigger_line)) { .pkg_abort( "No {.field trigger} field in front matter of {.file {path}}.", "no_trigger", @@ -84,9 +95,11 @@ #' @returns The path to `AGENTS.md`, invisibly, or `NULL` invisibly if #' `AGENTS.md` does not exist. #' @keywords internal -.upsert_agents_skills_row <- function(trigger, save_as) { - trigger <- .to_string(trigger) - save_as <- .to_string(save_as) +.upsert_agents_skill <- function(trigger, save_as, call = caller_env) { + # TODO: Clean this code and make sure it's stable. Consider using a dedicated + # MD package. + trigger <- .to_string(trigger, call = call) + save_as <- .to_string(save_as, call = call) agents_path <- usethis::proj_path("AGENTS.md") if (!fs::file_exists(agents_path)) { diff --git a/R/use_skill_create_issue.R b/R/use_skill_create_issue.R index ab47192..ffb4e60 100644 --- a/R/use_skill_create_issue.R +++ b/R/use_skill_create_issue.R @@ -4,15 +4,6 @@ #' template into the project. The installed skill teaches AI agents how to #' create well-structured GitHub issues for the package. #' -#' @param target_dir (`character(1)`) Directory where the skill will be -#' installed, relative to the project root. Defaults to `".github"`. -#' @param use_skills_subdir (`logical(1)`) Whether to place the `create-issue` -#' folder under a `skills` subdirectory of `target_dir`. Defaults to `TRUE`, -#' producing `.github/skills/create-issue/SKILL.md`. -#' @param overwrite (`logical(1)`) Whether to overwrite an existing skill file. -#' Defaults to `TRUE`. -#' @param gh_token (`character(1)`) A GitHub personal access token. Defaults to -#' `gh::gh_token()`. #' @inheritParams .shared-params #' @returns The path to the installed skill file, invisibly. #' @export @@ -26,40 +17,84 @@ use_skill_create_issue <- function( open = rlang::is_interactive(), gh_token = gh::gh_token() ) { - target_dir <- .to_string(target_dir) - use_skills_subdir <- .to_boolean(use_skills_subdir) - overwrite <- .to_boolean(overwrite) - gh_token <- .to_string(gh_token) + data <- .get_create_issue_data(gh_token) + .use_skill( + "create-issue", + data = data, + target_dir = target_dir, + use_skills_subdir = use_skills_subdir, + overwrite = overwrite, + open = open + ) +} +#' Collect template data for the create-issue skill +#' +#' @inheritParams .shared-params +#' @returns (`list`) Named list of whisker template variables. +#' @keywords internal +.get_create_issue_data <- function(gh_token, call = caller_env()) { + repo_parts <- .extract_repo_from_desc(call = call) + owner <- repo_parts[["owner"]] + repo <- repo_parts[["repo"]] + update_time <- .format_now_utc() + repo_id <- .fetch_repo_id(owner, repo, gh_token) + issue_types <- .fetch_repo_issue_types(owner, repo, gh_token) + list( + owner = owner, + repo = repo, + repo_id = repo_id, + issue_types = issue_types, + update_time = update_time + ) +} + +#' Extract owner and repo from DESCRIPTION BugReports field +#' +#' @inheritParams .shared-params +#' @returns (`list`) Named list with `owner` and `repo` elements. +#' @keywords internal +.extract_repo_from_desc <- function(call = caller_env()) { bug_reports <- desc::desc_get( "BugReports", file = usethis::proj_path("DESCRIPTION") )[[1L]] - if (is.na(bug_reports)) { + if (!length(bug_reports) || is.na(bug_reports)) { .pkg_abort( c( "No {.field BugReports} field found in {.file DESCRIPTION}.", "i" = "Run {.run usethis::use_github()} to set one up." ), - "no_bug_reports" + "no_bug_reports", + call = call ) } pattern <- "^https://github\\.com/([^/]+)/([^/]+)/issues" - m <- regmatches(bug_reports, regexec(pattern, bug_reports))[[1L]] - if (length(m) < 3L) { + url_pieces <- regmatches(bug_reports, regexec(pattern, bug_reports))[[1L]] + if (length(url_pieces) < 3L) { .pkg_abort( c( "{.field BugReports} in {.file DESCRIPTION} must be a GitHub issues URL.", "i" = "Run {.run usethis::use_github()} to set one up." ), - "invalid_bug_reports" + "invalid_bug_reports", + call = call ) } - owner <- m[[2L]] - repo <- m[[3L]] + list( + owner = url_pieces[[2L]], + repo = url_pieces[[3L]] + ) +} +#' Fetch the GraphQL node ID for a GitHub repository +#' +#' @inheritParams .shared-params +#' @returns (`character(1)`) The repository's GraphQL node ID. +#' @keywords internal +.fetch_repo_id <- function(owner, repo, gh_token) { repo_result <- gh::gh( "POST /graphql", query = sprintf( @@ -69,8 +104,16 @@ use_skill_create_issue <- function( ), .token = gh_token ) - repo_id <- repo_result$data$repository$id + repo_result$data$repository$id +} +#' Fetch issue types defined for a GitHub repository +#' +#' @inheritParams .shared-params +#' @returns (`list`) Issue type nodes, each with `id`, `name`, and +#' `description` fields. +#' @keywords internal +.fetch_repo_issue_types <- function(owner, repo, gh_token) { types_result <- gh::gh( "POST /graphql", query = sprintf( @@ -83,28 +126,18 @@ use_skill_create_issue <- function( ), .token = gh_token ) - issue_types <- types_result$data$repository$issueTypes$nodes + types_result$data$repository$issueTypes$nodes +} - update_time <- format( +#' Format the current time as a UTC timestamp string +#' +#' @returns (`character(1)`) Current time formatted as `"YYYY-MM-DD HH:MM:SS +#' UTC"`. +#' @keywords internal +.format_now_utc <- function() { + format( Sys.time(), tz = "UTC", format = "%Y-%m-%d %H:%M:%S UTC" ) - - data <- list( - owner = owner, - repo = repo, - repo_id = repo_id, - issue_types = issue_types, - update_time = update_time - ) - - .use_skill( - "create-issue", - data = data, - target_dir = target_dir, - use_skills_subdir = use_skills_subdir, - overwrite = overwrite, - open = open - ) } diff --git a/R/utils.R b/R/utils.R index b69f9ee..e6ab7b0 100644 --- a/R/utils.R +++ b/R/utils.R @@ -51,16 +51,15 @@ #' @inheritParams .shared-params #' @returns `NULL`, invisibly. #' @keywords internal -.check_file_exists <- function(save_as, overwrite, call = rlang::caller_env()) { - save_as <- .to_string(save_as, call = call) +.check_file_exists <- function(path, overwrite, call = rlang::caller_env()) { + path <- .to_string(path, call = call) overwrite <- .to_boolean(overwrite, call = call) - path <- usethis::proj_path(save_as) if (fs::file_exists(path)) { if (overwrite) { fs::file_delete(path) } else { .pkg_abort( - "File {.file {save_as}} already exists.", + "File {.file {path}} already exists.", "file_exists", call = call ) diff --git a/man/dot-check_file_exists.Rd b/man/dot-check_file_exists.Rd index b4330b5..0081f22 100644 --- a/man/dot-check_file_exists.Rd +++ b/man/dot-check_file_exists.Rd @@ -4,12 +4,10 @@ \alias{.check_file_exists} \title{Check whether a file exists and act on it} \usage{ -.check_file_exists(save_as, overwrite, call = rlang::caller_env()) +.check_file_exists(path, overwrite, call = rlang::caller_env()) } \arguments{ -\item{save_as}{(\code{character(1)}) Output file path, relative to project root.} - -\item{overwrite}{(\code{logical(1)}) Whether to overwrite an existing skill file. +\item{overwrite}{(\code{logical(1)}) Whether to overwrite an existing file. Defaults to \code{TRUE}.} \item{call}{(\code{environment}) The caller environment for error messages.} diff --git a/man/dot-extract_repo_from_desc.Rd b/man/dot-extract_repo_from_desc.Rd new file mode 100644 index 0000000..566f806 --- /dev/null +++ b/man/dot-extract_repo_from_desc.Rd @@ -0,0 +1,18 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/use_skill_create_issue.R +\name{.extract_repo_from_desc} +\alias{.extract_repo_from_desc} +\title{Extract owner and repo from DESCRIPTION BugReports field} +\usage{ +.extract_repo_from_desc(call = caller_env()) +} +\arguments{ +\item{call}{(\code{environment}) The caller environment for error messages.} +} +\value{ +(\code{list}) Named list with \code{owner} and \code{repo} elements. +} +\description{ +Extract owner and repo from DESCRIPTION BugReports field +} +\keyword{internal} diff --git a/man/dot-fetch_repo_id.Rd b/man/dot-fetch_repo_id.Rd new file mode 100644 index 0000000..0faae46 --- /dev/null +++ b/man/dot-fetch_repo_id.Rd @@ -0,0 +1,23 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/use_skill_create_issue.R +\name{.fetch_repo_id} +\alias{.fetch_repo_id} +\title{Fetch the GraphQL node ID for a GitHub repository} +\usage{ +.fetch_repo_id(owner, repo, gh_token) +} +\arguments{ +\item{owner}{(\code{character(1)}) GitHub repository owner (user or organization).} + +\item{repo}{(\code{character(1)}) GitHub repository name.} + +\item{gh_token}{(\code{character(1)}) A GitHub personal access token. Defaults to +\code{gh::gh_token()}.} +} +\value{ +(\code{character(1)}) The repository's GraphQL node ID. +} +\description{ +Fetch the GraphQL node ID for a GitHub repository +} +\keyword{internal} diff --git a/man/dot-fetch_repo_issue_types.Rd b/man/dot-fetch_repo_issue_types.Rd new file mode 100644 index 0000000..9cf6bf0 --- /dev/null +++ b/man/dot-fetch_repo_issue_types.Rd @@ -0,0 +1,24 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/use_skill_create_issue.R +\name{.fetch_repo_issue_types} +\alias{.fetch_repo_issue_types} +\title{Fetch issue types defined for a GitHub repository} +\usage{ +.fetch_repo_issue_types(owner, repo, gh_token) +} +\arguments{ +\item{owner}{(\code{character(1)}) GitHub repository owner (user or organization).} + +\item{repo}{(\code{character(1)}) GitHub repository name.} + +\item{gh_token}{(\code{character(1)}) A GitHub personal access token. Defaults to +\code{gh::gh_token()}.} +} +\value{ +(\code{list}) Issue type nodes, each with \code{id}, \code{name}, and +\code{description} fields. +} +\description{ +Fetch issue types defined for a GitHub repository +} +\keyword{internal} diff --git a/man/dot-format_now_utc.Rd b/man/dot-format_now_utc.Rd new file mode 100644 index 0000000..45c798a --- /dev/null +++ b/man/dot-format_now_utc.Rd @@ -0,0 +1,15 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/use_skill_create_issue.R +\name{.format_now_utc} +\alias{.format_now_utc} +\title{Format the current time as a UTC timestamp string} +\usage{ +.format_now_utc() +} +\value{ +(\code{character(1)}) Current time formatted as \code{"YYYY-MM-DD HH:MM:SS UTC"}. +} +\description{ +Format the current time as a UTC timestamp string +} +\keyword{internal} diff --git a/man/dot-get_create_issue_data.Rd b/man/dot-get_create_issue_data.Rd new file mode 100644 index 0000000..36807ae --- /dev/null +++ b/man/dot-get_create_issue_data.Rd @@ -0,0 +1,21 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/use_skill_create_issue.R +\name{.get_create_issue_data} +\alias{.get_create_issue_data} +\title{Collect template data for the create-issue skill} +\usage{ +.get_create_issue_data(gh_token, call = caller_env()) +} +\arguments{ +\item{gh_token}{(\code{character(1)}) A GitHub personal access token. Defaults to +\code{gh::gh_token()}.} + +\item{call}{(\code{environment}) The caller environment for error messages.} +} +\value{ +(\code{list}) Named list of whisker template variables. +} +\description{ +Collect template data for the create-issue skill +} +\keyword{internal} diff --git a/man/dot-pkg_abort.Rd b/man/dot-pkg_abort.Rd index fa10e20..b9f0032 100644 --- a/man/dot-pkg_abort.Rd +++ b/man/dot-pkg_abort.Rd @@ -4,14 +4,19 @@ \alias{.pkg_abort} \title{Raise a package-scoped error} \usage{ -.pkg_abort(message, subclass, call = rlang::caller_env()) +.pkg_abort(message, subclass, call = caller_env(), message_env = caller_env()) } \arguments{ -\item{message}{(\code{character(1)}) A \code{{cli}} message string.} +\item{message}{(\code{character}) The message for the new error. Messages will be +formatted with \code{\link[cli:cli_bullets]{cli::cli_bullets()}}.} -\item{subclass}{(\code{character(1)}) Error subclass.} +\item{subclass}{(\code{character}) Class(es) to assign to the error. Will be +prefixed by "\{package\}-error-".} \item{call}{(\code{environment}) The caller environment for error messages.} + +\item{message_env}{(\code{environment}) The execution environment to use to +evaluate variables in error messages.} } \value{ Does not return. diff --git a/man/dot-shared-params.Rd b/man/dot-shared-params.Rd index d62ec62..98d9970 100644 --- a/man/dot-shared-params.Rd +++ b/man/dot-shared-params.Rd @@ -8,11 +8,18 @@ \item{data}{(\code{list}) Named list of whisker template variables for rendering.} +\item{gh_token}{(\code{character(1)}) A GitHub personal access token. Defaults to +\code{gh::gh_token()}.} + \item{open}{(\code{logical(1)}) Whether to open the file after creation.} -\item{overwrite}{(\code{logical(1)}) Whether to overwrite an existing skill file. +\item{overwrite}{(\code{logical(1)}) Whether to overwrite an existing file. Defaults to \code{TRUE}.} +\item{owner}{(\code{character(1)}) GitHub repository owner (user or organization).} + +\item{repo}{(\code{character(1)}) GitHub repository name.} + \item{save_as}{(\code{character(1)}) Output file path, relative to project root.} \item{target_dir}{(\code{character(1)}) Directory where the skill will be diff --git a/man/dot-upsert_agents_skills_row.Rd b/man/dot-upsert_agents_skill.Rd similarity index 82% rename from man/dot-upsert_agents_skills_row.Rd rename to man/dot-upsert_agents_skill.Rd index cb70813..82fc37a 100644 --- a/man/dot-upsert_agents_skills_row.Rd +++ b/man/dot-upsert_agents_skill.Rd @@ -1,10 +1,10 @@ % Generated by roxygen2: do not edit by hand % Please edit documentation in R/use_skill.R -\name{.upsert_agents_skills_row} -\alias{.upsert_agents_skills_row} +\name{.upsert_agents_skill} +\alias{.upsert_agents_skill} \title{Upsert a skill row in the ## Skills table of AGENTS.md} \usage{ -.upsert_agents_skills_row(trigger, save_as) +.upsert_agents_skill(trigger, save_as, call = caller_env) } \arguments{ \item{trigger}{(\code{character(1)}) Trigger phrase for the skill.} diff --git a/man/dot-upsert_agents_skill_from_template.Rd b/man/dot-upsert_agents_skill_from_template.Rd new file mode 100644 index 0000000..d891ec0 --- /dev/null +++ b/man/dot-upsert_agents_skill_from_template.Rd @@ -0,0 +1,24 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/use_skill.R +\name{.upsert_agents_skill_from_template} +\alias{.upsert_agents_skill_from_template} +\title{Upsert a template skill row in the ## Skills table of AGENTS.md} +\usage{ +.upsert_agents_skill_from_template(skill_subpath, save_as, call = caller_env()) +} +\arguments{ +\item{skill_subpath}{(\code{character(1)}) The relative path to the \code{SKILL.md} +file.} + +\item{save_as}{(\code{character(1)}) Output file path, relative to project root.} + +\item{call}{(\code{environment}) The caller environment for error messages.} +} +\value{ +The path to \code{AGENTS.md}, invisibly, or \code{NULL} invisibly if +\code{AGENTS.md} does not exist. +} +\description{ +Upsert a template skill row in the ## Skills table of AGENTS.md +} +\keyword{internal} diff --git a/man/dot-use_skill.Rd b/man/dot-use_skill.Rd index 574cfc7..ead97d7 100644 --- a/man/dot-use_skill.Rd +++ b/man/dot-use_skill.Rd @@ -10,11 +10,12 @@ target_dir = ".github", use_skills_subdir = TRUE, overwrite = TRUE, - open = rlang::is_interactive() + open = rlang::is_interactive(), + call = caller_env() ) } \arguments{ -\item{skill}{(\code{character(1)}) Skill name — folder name under +\item{skill}{(\code{character(1)}) Skill name. A folder name under \verb{inst/templates/skills/}, e.g. \code{"create-issue"}. Determines the template path and the install subdirectory.} @@ -27,10 +28,12 @@ installed, relative to the project root. Defaults to \code{".github"}.} under a \code{skills} subdirectory of \code{target_dir}. Defaults to \code{TRUE}, producing \code{.github/skills/{skill}/SKILL.md}.} -\item{overwrite}{(\code{logical(1)}) Whether to overwrite an existing skill file. +\item{overwrite}{(\code{logical(1)}) Whether to overwrite an existing file. Defaults to \code{TRUE}.} \item{open}{(\code{logical(1)}) Whether to open the file after creation.} + +\item{call}{(\code{environment}) The caller environment for error messages.} } \value{ The path to the installed skill file, invisibly. diff --git a/man/use_skill_create_issue.Rd b/man/use_skill_create_issue.Rd index 612e5f4..734e99a 100644 --- a/man/use_skill_create_issue.Rd +++ b/man/use_skill_create_issue.Rd @@ -16,11 +16,11 @@ use_skill_create_issue( \item{target_dir}{(\code{character(1)}) Directory where the skill will be installed, relative to the project root. Defaults to \code{".github"}.} -\item{use_skills_subdir}{(\code{logical(1)}) Whether to place the \code{create-issue} -folder under a \code{skills} subdirectory of \code{target_dir}. Defaults to \code{TRUE}, -producing \code{.github/skills/create-issue/SKILL.md}.} +\item{use_skills_subdir}{(\code{logical(1)}) Whether to place the skill folder +under a \code{skills} subdirectory of \code{target_dir}. Defaults to \code{TRUE}, +producing \code{.github/skills/{skill}/SKILL.md}.} -\item{overwrite}{(\code{logical(1)}) Whether to overwrite an existing skill file. +\item{overwrite}{(\code{logical(1)}) Whether to overwrite an existing file. Defaults to \code{TRUE}.} \item{open}{(\code{logical(1)}) Whether to open the file after creation.} diff --git a/tests/testthat/_snaps/use_skill.md b/tests/testthat/_snaps/use_skill.md index 46ae323..d7864bd 100644 --- a/tests/testthat/_snaps/use_skill.md +++ b/tests/testthat/_snaps/use_skill.md @@ -1,4 +1,4 @@ -# .use_skill() emits a cli_inform message (#3) +# .use_skill() emits a cli_inform message (#6) Code .use_skill("create-issue", data = list(owner = "testowner", repo = "testrepo", diff --git a/tests/testthat/_snaps/use_skill_create_issue.md b/tests/testthat/_snaps/use_skill_create_issue.md index b1108bc..268b02d 100644 --- a/tests/testthat/_snaps/use_skill_create_issue.md +++ b/tests/testthat/_snaps/use_skill_create_issue.md @@ -1,4 +1,4 @@ -# use_skill_create_issue() errors when BugReports is absent (#3) +# use_skill_create_issue() errors when BugReports is absent (#6) Code (expect_error(use_skill_create_issue(open = FALSE), class = "pkgskills-error")) @@ -8,7 +8,7 @@ ! No BugReports field found in 'DESCRIPTION'. i Run `usethis::use_github()` to set one up. -# use_skill_create_issue() errors when BugReports is not a GitHub URL (#3) +# use_skill_create_issue() errors when BugReports is not a GitHub URL (#6) Code (expect_error(use_skill_create_issue(open = FALSE), class = "pkgskills-error")) diff --git a/tests/testthat/test-use_skill.R b/tests/testthat/test-use_skill.R index 1c67c98..7308590 100644 --- a/tests/testthat/test-use_skill.R +++ b/tests/testthat/test-use_skill.R @@ -1,4 +1,4 @@ -test_that(".use_skill() returns path invisibly (#3)", { +test_that(".use_skill() returns path invisibly (#6)", { proj_dir <- local_pkg() result <- withVisible( suppressMessages( @@ -25,7 +25,7 @@ test_that(".use_skill() returns path invisibly (#3)", { ) }) -test_that(".use_skill() creates file at correct path with use_skills_subdir = TRUE (#3)", { +test_that(".use_skill() creates file at correct path with use_skills_subdir = TRUE (#6)", { proj_dir <- local_pkg() suppressMessages( .use_skill( @@ -48,7 +48,7 @@ test_that(".use_skill() creates file at correct path with use_skills_subdir = TR ) }) -test_that(".use_skill() creates file at correct path with use_skills_subdir = FALSE (#3)", { +test_that(".use_skill() creates file at correct path with use_skills_subdir = FALSE (#6)", { proj_dir <- local_pkg() suppressMessages( .use_skill( @@ -72,7 +72,7 @@ test_that(".use_skill() creates file at correct path with use_skills_subdir = FA ) }) -test_that(".use_skill() renders template variables into skill file (#3)", { +test_that(".use_skill() renders template variables into skill file (#6)", { proj_dir <- local_pkg() suppressMessages( .use_skill( @@ -100,7 +100,7 @@ test_that(".use_skill() renders template variables into skill file (#3)", { expect_true(any(grepl("IT_bug", content))) }) -test_that(".use_skill() emits a cli_inform message (#3)", { +test_that(".use_skill() emits a cli_inform message (#6)", { local_pkg() expect_snapshot( .use_skill( @@ -116,7 +116,7 @@ test_that(".use_skill() emits a cli_inform message (#3)", { ) }) -test_that(".use_skill() upserts into AGENTS.md when it exists (#3)", { +test_that(".use_skill() upserts into AGENTS.md when it exists (#6)", { proj_dir <- local_pkg( "AGENTS.md" = c( "## Skills", @@ -146,7 +146,7 @@ test_that(".use_skill() upserts into AGENTS.md when it exists (#3)", { ))) }) -test_that(".use_skill() creates ## Skills section in AGENTS.md if missing (#3)", { +test_that(".use_skill() creates ## Skills section in AGENTS.md if missing (#6)", { proj_dir <- local_pkg( "AGENTS.md" = c( "# My Project", @@ -171,7 +171,7 @@ test_that(".use_skill() creates ## Skills section in AGENTS.md if missing (#3)", expect_true(any(grepl("create GitHub issues", content))) }) -test_that(".use_skill() updates trigger for existing row in AGENTS.md (#3)", { +test_that(".use_skill() updates trigger for existing row in AGENTS.md (#6)", { proj_dir <- local_pkg( "AGENTS.md" = c( "## Skills", @@ -198,7 +198,7 @@ test_that(".use_skill() updates trigger for existing row in AGENTS.md (#3)", { expect_true(any(grepl("create GitHub issues", content))) }) -test_that(".use_skill() does not touch AGENTS.md when it does not exist (#3)", { +test_that(".use_skill() does not touch AGENTS.md when it does not exist (#6)", { proj_dir <- local_pkg() suppressMessages( .use_skill( @@ -215,7 +215,7 @@ test_that(".use_skill() does not touch AGENTS.md when it does not exist (#3)", { expect_false(fs::file_exists(fs::path(proj_dir, "AGENTS.md"))) }) -test_that(".use_skill() errors when overwrite = FALSE and file exists (#3)", { +test_that(".use_skill() errors when overwrite = FALSE and file exists (#6)", { proj_dir <- local_pkg() existing_path <- fs::path(proj_dir, ".github/skills/create-issue/SKILL.md") fs::dir_create(fs::path_dir(existing_path)) @@ -240,7 +240,7 @@ test_that(".use_skill() errors when overwrite = FALSE and file exists (#3)", { expect_equal(readLines(existing_path), "original content") }) -test_that(".use_skill() overwrites file when overwrite = TRUE and file exists (#3)", { +test_that(".use_skill() overwrites file when overwrite = TRUE and file exists (#6)", { proj_dir <- local_pkg() existing_path <- fs::path(proj_dir, ".github/skills/create-issue/SKILL.md") fs::dir_create(fs::path_dir(existing_path)) @@ -263,7 +263,7 @@ test_that(".use_skill() overwrites file when overwrite = TRUE and file exists (# expect_true(any(grepl("Create a GitHub issue", content))) }) -test_that(".use_skill() errors on non-scalar skill (#3)", { +test_that(".use_skill() errors on non-scalar skill (#6)", { stbl::expect_pkg_error_classes( .use_skill(c("a", "b"), data = list(), open = FALSE), "stbl", @@ -271,7 +271,7 @@ test_that(".use_skill() errors on non-scalar skill (#3)", { ) }) -test_that(".use_skill() errors on non-logical use_skills_subdir (#3)", { +test_that(".use_skill() errors on non-logical use_skills_subdir (#6)", { local_pkg() stbl::expect_pkg_error_classes( .use_skill( @@ -285,7 +285,7 @@ test_that(".use_skill() errors on non-logical use_skills_subdir (#3)", { ) }) -test_that(".use_skill() errors on non-logical overwrite (#3)", { +test_that(".use_skill() errors on non-logical overwrite (#6)", { local_pkg() stbl::expect_pkg_error_classes( .use_skill("create-issue", data = list(), overwrite = "yes", open = FALSE), @@ -294,7 +294,7 @@ test_that(".use_skill() errors on non-logical overwrite (#3)", { ) }) -test_that(".read_skill_trigger() errors when template file not found (#3)", { +test_that(".read_skill_trigger() errors when template file not found (#6)", { stbl::expect_pkg_error_classes( .read_skill_trigger("/tmp/nonexistent/SKILL.md"), "pkgskills", @@ -302,7 +302,7 @@ test_that(".read_skill_trigger() errors when template file not found (#3)", { ) }) -test_that(".read_skill_trigger() errors when front matter is missing (#3)", { +test_that(".read_skill_trigger() errors when front matter is missing (#6)", { tmp <- withr::local_tempfile(fileext = ".md") writeLines(c("# No front matter here", "Just content."), tmp) stbl::expect_pkg_error_classes( @@ -312,7 +312,7 @@ test_that(".read_skill_trigger() errors when front matter is missing (#3)", { ) }) -test_that(".read_skill_trigger() errors when trigger field is absent (#3)", { +test_that(".read_skill_trigger() errors when trigger field is absent (#6)", { tmp <- withr::local_tempfile(fileext = ".md") writeLines(c("---", "name: my-skill", "---", "# Content"), tmp) stbl::expect_pkg_error_classes( @@ -322,17 +322,17 @@ test_that(".read_skill_trigger() errors when trigger field is absent (#3)", { ) }) -test_that(".upsert_agents_skills_row() creates table when ## Skills has no table (#3)", { +test_that(".upsert_agents_skill() creates table when ## Skills has no table (#6)", { proj_dir <- local_pkg( "AGENTS.md" = c("# Project", "", "## Skills", "", "No table here.") ) - .upsert_agents_skills_row("my trigger", ".github/skills/test/SKILL.md") + .upsert_agents_skill("my trigger", ".github/skills/test/SKILL.md") content <- readLines(fs::path(proj_dir, "AGENTS.md")) expect_true(any(grepl("\\| Triggers \\| Path \\|", content))) expect_true(any(grepl("my trigger", content))) }) -test_that(".upsert_agents_skills_row() appends row after non-terminal table (#3)", { +test_that(".upsert_agents_skill() appends row after non-terminal table (#6)", { proj_dir <- local_pkg( "AGENTS.md" = c( "## Skills", @@ -344,16 +344,16 @@ test_that(".upsert_agents_skills_row() appends row after non-terminal table (#3) "## Other section" ) ) - .upsert_agents_skills_row("new skill", ".github/skills/new/SKILL.md") + .upsert_agents_skill("new skill", ".github/skills/new/SKILL.md") content <- readLines(fs::path(proj_dir, "AGENTS.md")) expect_true(any(grepl("new skill", content))) expect_true(any(grepl("existing skill", content))) }) -test_that(".upsert_agents_skills_row() returns NULL invisibly when AGENTS.md absent (#3)", { +test_that(".upsert_agents_skill() returns NULL invisibly when AGENTS.md absent (#6)", { proj_dir <- local_pkg() result <- withVisible( - .upsert_agents_skills_row("my trigger", ".github/skills/test/SKILL.md") + .upsert_agents_skill("my trigger", ".github/skills/test/SKILL.md") ) expect_false(result$visible) expect_null(result$value) diff --git a/tests/testthat/test-use_skill_create_issue.R b/tests/testthat/test-use_skill_create_issue.R index f5289fd..e30184f 100644 --- a/tests/testthat/test-use_skill_create_issue.R +++ b/tests/testthat/test-use_skill_create_issue.R @@ -1,4 +1,4 @@ -test_that("use_skill_create_issue() errors when BugReports is absent (#3)", { +test_that("use_skill_create_issue() errors when BugReports is absent (#6)", { local_pkg( DESCRIPTION = c( "Package: mypkg", @@ -14,7 +14,7 @@ test_that("use_skill_create_issue() errors when BugReports is absent (#3)", { ) }) -test_that("use_skill_create_issue() errors when BugReports is not a GitHub URL (#3)", { +test_that("use_skill_create_issue() errors when BugReports is not a GitHub URL (#6)", { local_pkg( DESCRIPTION = c( "Package: mypkg", @@ -31,7 +31,7 @@ test_that("use_skill_create_issue() errors when BugReports is not a GitHub URL ( ) }) -test_that("use_skill_create_issue() calls gh::gh() with correct queries (#3)", { +test_that("use_skill_create_issue() calls gh::gh() with correct queries (#6)", { local_pkg( DESCRIPTION = c( "Package: mypkg", @@ -72,7 +72,7 @@ test_that("use_skill_create_issue() calls gh::gh() with correct queries (#3)", { ))) }) -test_that("use_skill_create_issue() passes correct data to .use_skill() (#3)", { +test_that("use_skill_create_issue() passes correct data to .use_skill() (#6)", { local_pkg( DESCRIPTION = c( "Package: mypkg", @@ -125,7 +125,7 @@ test_that("use_skill_create_issue() passes correct data to .use_skill() (#3)", { ) }) -test_that("use_skill_create_issue() returns path invisibly (#3)", { +test_that("use_skill_create_issue() returns path invisibly (#6)", { local_pkg( DESCRIPTION = c( "Package: mypkg", @@ -149,7 +149,7 @@ test_that("use_skill_create_issue() returns path invisibly (#3)", { expect_false(result$visible) }) -test_that("use_skill_create_issue() errors on non-scalar target_dir (#3)", { +test_that("use_skill_create_issue() errors on non-scalar target_dir (#6)", { local_pkg( DESCRIPTION = c( "Package: mypkg", @@ -165,7 +165,7 @@ test_that("use_skill_create_issue() errors on non-scalar target_dir (#3)", { ) }) -test_that("use_skill_create_issue() errors on non-logical overwrite (#3)", { +test_that("use_skill_create_issue() errors on non-logical overwrite (#6)", { local_pkg( DESCRIPTION = c( "Package: mypkg", From 0c7be3df082cb91d1d9e486998f26701f2035b57 Mon Sep 17 00:00:00 2001 From: Jon Harmon Date: Tue, 17 Mar 2026 06:07:51 -0500 Subject: [PATCH 5/7] More cleaning, combining, and abstracting. --- .github/skills/document/SKILL.md | 3 +- .github/skills/tdd-workflow/SKILL.md | 17 +- DESCRIPTION | 3 + R/aaa-shared_params.R | 11 +- R/use_skill.R | 278 ++++++++++++------ R/use_skill_create_issue.R | 18 +- R/utils-coerce.R | 25 ++ R/utils-path.R | 55 ++++ R/utils-text.R | 103 +++++++ R/utils.R | 78 ++--- README.Rmd | 4 +- README.md | 4 +- man/dot-agents_lines_add_skill_row.Rd | 22 ++ man/dot-agents_lines_add_to_section.Rd | 24 ++ man/dot-agents_lines_append_row.Rd | 23 ++ man/dot-agents_lines_append_section.Rd | 21 ++ man/dot-agents_lines_insert_table.Rd | 23 ++ man/dot-agents_lines_replace_row.Rd | 23 ++ man/dot-agents_lines_upsert_skill.Rd | 26 ++ man/dot-call_gh.Rd | 18 ++ man/dot-check_file_exists.Rd | 22 -- man/dot-check_path_writable.Rd | 25 ++ man/dot-extract_yaml_scalar.Rd | 24 ++ man/dot-find_pattern_idx.Rd | 22 ++ man/dot-find_skill_row_idx.Rd | 21 ++ man/dot-find_skills_section_idx.Rd | 18 ++ man/dot-find_table_last_row_idx.Rd | 23 ++ man/dot-format_now_utc.Rd | 2 +- man/dot-is_first_run.Rd | 20 ++ man/dot-lines_insert_after.Rd | 22 ++ man/dot-make_skill_row.Rd | 23 ++ man/dot-make_skills_section.Rd | 19 ++ man/dot-make_skills_table.Rd | 20 ++ man/dot-parse_yaml_front_matter.Rd | 23 ++ man/dot-path_pkg.Rd | 6 +- man/dot-path_proj_save_as.Rd | 25 ++ man/dot-path_skill_save_as.Rd | 35 +++ man/dot-path_template.Rd | 2 +- man/dot-read_skill_trigger.Rd | 2 +- man/dot-shared-params.Rd | 16 +- man/dot-to_boolean.Rd | 2 +- man/dot-to_string.Rd | 2 +- man/dot-upsert_agents_skill.Rd | 7 +- man/dot-upsert_agents_skill_from_template.Rd | 13 +- man/dot-use_template.Rd | 3 +- man/use_agent.Rd | 3 +- tests/testthat/.gitignore | 2 - tests/testthat/_snaps/use_skill.md | 42 +++ .../testthat/_snaps/use_skill_create_issue.md | 8 +- tests/testthat/_snaps/utils-path.md | 20 ++ tests/testthat/_snaps/utils-text.md | 30 ++ tests/testthat/helper-expectations.R | 46 +++ tests/testthat/helper-local_pkg.R | 23 +- tests/testthat/test-use_skill.R | 47 +-- tests/testthat/test-use_skill_create_issue.R | 187 +++--------- tests/testthat/test-utils-coerce.R | 10 + tests/testthat/test-utils-path.R | 53 ++++ tests/testthat/test-utils-text.R | 94 ++++++ tests/testthat/test-utils.R | 8 +- 59 files changed, 1348 insertions(+), 401 deletions(-) create mode 100644 R/utils-coerce.R create mode 100644 R/utils-path.R create mode 100644 R/utils-text.R create mode 100644 man/dot-agents_lines_add_skill_row.Rd create mode 100644 man/dot-agents_lines_add_to_section.Rd create mode 100644 man/dot-agents_lines_append_row.Rd create mode 100644 man/dot-agents_lines_append_section.Rd create mode 100644 man/dot-agents_lines_insert_table.Rd create mode 100644 man/dot-agents_lines_replace_row.Rd create mode 100644 man/dot-agents_lines_upsert_skill.Rd create mode 100644 man/dot-call_gh.Rd delete mode 100644 man/dot-check_file_exists.Rd create mode 100644 man/dot-check_path_writable.Rd create mode 100644 man/dot-extract_yaml_scalar.Rd create mode 100644 man/dot-find_pattern_idx.Rd create mode 100644 man/dot-find_skill_row_idx.Rd create mode 100644 man/dot-find_skills_section_idx.Rd create mode 100644 man/dot-find_table_last_row_idx.Rd create mode 100644 man/dot-is_first_run.Rd create mode 100644 man/dot-lines_insert_after.Rd create mode 100644 man/dot-make_skill_row.Rd create mode 100644 man/dot-make_skills_section.Rd create mode 100644 man/dot-make_skills_table.Rd create mode 100644 man/dot-parse_yaml_front_matter.Rd create mode 100644 man/dot-path_proj_save_as.Rd create mode 100644 man/dot-path_skill_save_as.Rd delete mode 100644 tests/testthat/.gitignore create mode 100644 tests/testthat/_snaps/utils-path.md create mode 100644 tests/testthat/_snaps/utils-text.md create mode 100644 tests/testthat/helper-expectations.R create mode 100644 tests/testthat/test-utils-coerce.R create mode 100644 tests/testthat/test-utils-path.R create mode 100644 tests/testthat/test-utils-text.R diff --git a/.github/skills/document/SKILL.md b/.github/skills/document/SKILL.md index 8e1c8a5..c6b71fa 100644 --- a/.github/skills/document/SKILL.md +++ b/.github/skills/document/SKILL.md @@ -11,6 +11,7 @@ description: Document package functions. Use when asked to document functions. - Run `air format .` then `devtools::document()` after changing any roxygen2 docs. - Use sentence case for all headings. - Files matching `R/import-standalone-*.R` are imported from other packages and have their own conventions. Do not modify their documentation. +- After documenting functions, run `devtools::document(roclets = c('rd', 'collate', 'namespace'))`. ## Shared parameters @@ -114,7 +115,7 @@ Internal helpers (identified by a dot prefix, e.g. `.parse_response()`) use abbr #' @keywords internal ``` -No description paragraph, fewer blank `#'` lines, and no `@examples`. +Description paragraph is optional (only include when usage isn't obvious), fewer blank `#'` lines, and no `@examples`. ## S3 methods and `@rdname` grouping diff --git a/.github/skills/tdd-workflow/SKILL.md b/.github/skills/tdd-workflow/SKILL.md index 7982445..4a3cfca 100644 --- a/.github/skills/tdd-workflow/SKILL.md +++ b/.github/skills/tdd-workflow/SKILL.md @@ -73,9 +73,12 @@ For complex outputs that are hard to specify with equality assertions: test_that("build_summary print method is stable (#123)", { expect_snapshot(print(build_summary(sample_data))) }) +``` + +For errors, wrap expect_error() inside expect_snapshot() so both the error +class and the message text are captured in the snapshot: -# For errors, wrap expect_error() inside expect_snapshot() so both the error -# class and the message text are captured in the snapshot: +```r test_that("fetch_records errors on invalid input (#456)", { expect_snapshot( (expect_error( @@ -85,15 +88,15 @@ test_that("fetch_records errors on invalid input (#456)", { ) }) ``` +(see also "Testing errors with `stbl::expect_pkg_error_classes()`" below) -When snapshots change intentionally: +When snapshots change intentionally, check the content of the file corresponding to the edited test file, then accept: ```r -testthat::snapshot_review("test_name") testthat::snapshot_accept("test_name") ``` -Snapshots are stored in `tests/testthat/_snaps/`. +Snapshots are stored in `tests/testthat/_snaps/`. The filename corresponds to the R file being tested, ending with `.md`. ## Test design principles @@ -196,11 +199,11 @@ Combine with `expect_snapshot()` to lock down both the class hierarchy and the u ```r test_that("process_data() error message is stable (#42)", { expect_snapshot( - stbl::expect_pkg_error_classes( + (stbl::expect_pkg_error_classes( process_data(data.frame()), "mypkg", "empty_input" - ) + )) ) }) ``` diff --git a/DESCRIPTION b/DESCRIPTION index fc3a78b..6e71080 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -10,6 +10,8 @@ License: MIT + file LICENSE URL: https://github.com/api2r/pkgskills, https://api2r.github.io/pkgskills/ BugReports: https://github.com/api2r/pkgskills/issues +Depends: + R (>= 4.1.0) Imports: cli, desc, @@ -17,6 +19,7 @@ Imports: gh, rlang, stbl, + stringr, usethis Suggests: testthat (>= 3.0.0), diff --git a/R/aaa-shared_params.R b/R/aaa-shared_params.R index 134ca7e..cd528f2 100644 --- a/R/aaa-shared_params.R +++ b/R/aaa-shared_params.R @@ -3,18 +3,27 @@ #' These parameters are used in multiple functions. They are defined here to #' make them easier to import and to find. #' +#' @param agents_lines (`character`) Lines of `AGENTS.md`. #' @param call (`environment`) The caller environment for error messages. #' @param data (`list`) Named list of whisker template variables for rendering. #' @param gh_token (`character(1)`) A GitHub personal access token. Defaults to #' `gh::gh_token()`. +#' @param lines (`character`) Lines of a file, as returned by [readLines()]. +#' @param new_row (`character(1)`) A pre-built skill row string, as produced by +#' `.make_skill_row()`. #' @param open (`logical(1)`) Whether to open the file after creation. #' @param overwrite (`logical(1)`) Whether to overwrite an existing file. #' Defaults to `TRUE`. #' @param owner (`character(1)`) GitHub repository owner (user or organization). #' @param repo (`character(1)`) GitHub repository name. -#' @param save_as (`character(1)`) Output file path, relative to project root. +#' @param save_as (`character(1)`) Output file path, relative to the project +#' root. +#' @param skill (`character(1)`) Skill name. A folder name under +#' `inst/templates/skills/`, e.g. `"create-issue"`. Determines the template +#' path and the install subdirectory. #' @param target_dir (`character(1)`) Directory where the skill will be #' installed, relative to the project root. Defaults to `".github"`. +#' @param trigger (`character(1)`) Trigger phrase for the skill. #' @param use_skills_subdir (`logical(1)`) Whether to place the skill folder #' under a `skills` subdirectory of `target_dir`. Defaults to `TRUE`, #' producing `.github/skills/{skill}/SKILL.md`. diff --git a/R/use_skill.R b/R/use_skill.R index 132e547..726442a 100644 --- a/R/use_skill.R +++ b/R/use_skill.R @@ -1,8 +1,5 @@ #' Install a skill into a project #' -#' @param skill (`character(1)`) Skill name. A folder name under -#' `inst/templates/skills/`, e.g. `"create-issue"`. Determines the template -#' path and the install subdirectory. #' @inheritParams .shared-params #' @returns The path to the installed skill file, invisibly. #' @keywords internal @@ -15,39 +12,55 @@ open = rlang::is_interactive(), call = caller_env() ) { - # Validate inputs. + save_as <- .path_skill_save_as( + skill, + target_dir = target_dir, + use_skills_subdir = use_skills_subdir, + call = call + ) + save_as_absolute <- .path_proj_save_as(save_as, overwrite, call = call) + skill_path_relative <- fs::path("skills", skill, "SKILL.md") + + .use_template(skill_path_relative, save_as, data, open, call = call) + .upsert_agents_skill_from_template(skill_path_relative, save_as, call = call) + cli::cli_inform("Skill {.file {save_as}} installed.") + invisible(save_as_absolute) +} + +#' Build the project-relative save path for a skill file +#' +#' @inheritParams .shared-params +#' @returns (`character(1)`) Relative path to the `SKILL.md` file within the +#' project. +#' @keywords internal +.path_skill_save_as <- function( + skill, + target_dir = ".github", + use_skills_subdir = TRUE, + call = caller_env() +) { skill <- .to_string(skill, call = call) target_dir <- .to_string(target_dir, call = call) use_skills_subdir <- .to_boolean(use_skills_subdir, call = call) - - # Set up path vars. if (use_skills_subdir) { target_dir <- fs::path(target_dir, "skills") } - save_as <- fs::path(target_dir, skill, "SKILL.md") - path <- usethis::proj_path(save_as) - .check_file_exists(path, overwrite, call = call) - skill_subpath <- fs::path("skills", skill, "SKILL.md") - - .use_template(skill_subpath, save_as, data, open, call = call) - .upsert_agents_skill_from_template(skill_subpath, save_as, call = call) - cli::cli_inform("Skill {.file {save_as}} installed.") - invisible(path) + fs::path(target_dir, skill, "SKILL.md") } #' Upsert a template skill row in the ## Skills table of AGENTS.md #' -#' @param skill_subpath (`character(1)`) The relative path to the `SKILL.md` -#' file. +#' @param skill_path_relative (`character(1)`) The relative path to the +#' `SKILL.md` file, relative to the template dir. #' @inheritParams .shared-params #' @inherit .upsert_agents_skill return #' @keywords internal .upsert_agents_skill_from_template <- function( - skill_subpath, + skill_path_relative, save_as, call = caller_env() ) { - template_path <- .path_template(skill_subpath) + template_path <- .path_template(skill_path_relative) trigger <- .read_skill_trigger(template_path, call = call) .upsert_agents_skill(trigger, save_as) } @@ -58,7 +71,7 @@ #' @inheritParams .shared-params #' @returns (`character(1)`) The trigger phrase. #' @keywords internal -.read_skill_trigger <- function(path, call = rlang::caller_env()) { +.read_skill_trigger <- function(path, call = caller_env()) { path <- .to_string(path, call = call) if (!fs::file_exists(path)) { .pkg_abort( @@ -68,100 +81,173 @@ ) } lines <- readLines(path, warn = FALSE) - delim_idx <- which(lines == "---") - if (length(delim_idx) < 2L) { - .pkg_abort( - "No YAML front matter found in {.file {path}}.", - "no_front_matter", - call = call - ) - } - front_matter <- lines[(delim_idx[[1L]] + 1L):(delim_idx[[2L]] - 1L)] - trigger_line <- grep("^trigger:", front_matter, value = TRUE) - if (!length(trigger_line)) { - .pkg_abort( - "No {.field trigger} field in front matter of {.file {path}}.", - "no_trigger", - call = call - ) - } - trimws(sub("^trigger:", "", trigger_line[[1L]])) + front_matter <- .parse_yaml_front_matter(lines, path, call = call) + .extract_yaml_scalar(front_matter, "trigger", path, call = call) } #' Upsert a skill row in the ## Skills table of AGENTS.md #' -#' @param trigger (`character(1)`) Trigger phrase for the skill. -#' @param save_as (`character(1)`) Relative path to the installed skill file. +#' @inheritParams .shared-params #' @returns The path to `AGENTS.md`, invisibly, or `NULL` invisibly if #' `AGENTS.md` does not exist. #' @keywords internal -.upsert_agents_skill <- function(trigger, save_as, call = caller_env) { - # TODO: Clean this code and make sure it's stable. Consider using a dedicated - # MD package. - trigger <- .to_string(trigger, call = call) - save_as <- .to_string(save_as, call = call) - +.upsert_agents_skill <- function(trigger, save_as, call = caller_env()) { agents_path <- usethis::proj_path("AGENTS.md") if (!fs::file_exists(agents_path)) { return(invisible(NULL)) } - lines <- readLines(agents_path, warn = FALSE) - save_as_escaped <- gsub("([.|*+?^${}()\\[\\]\\\\])", "\\\\\\1", save_as) - existing_idx <- grep(paste0("@", save_as_escaped), lines) - - if (length(existing_idx) > 0L) { - lines[[existing_idx[[1L]]]] <- paste0("| ", trigger, " | @", save_as, " |") - writeLines(lines, agents_path) - return(invisible(agents_path)) - } + writeLines( + .agents_lines_upsert_skill(agents_path, trigger, save_as, call = call), + agents_path + ) + invisible(agents_path) +} - skills_idx <- grep("^## Skills", lines) +#' Compute updated AGENTS.md lines with a skill row upserted +#' +#' Reads `path` and validates inputs. Replaces the existing row for `save_as` +#' if found; otherwise delegates to `.agents_lines_add_skill_row()`. +#' +#' @param path (`character(1)`) Path to `AGENTS.md`. +#' @inheritParams .shared-params +#' @returns (`character`) Updated lines. +#' @keywords internal +.agents_lines_upsert_skill <- function( + path, + trigger, + save_as, + call = caller_env() +) { + save_as <- .to_string(save_as, call = call) + lines <- readLines(path, warn = FALSE) + new_row <- .make_skill_row(trigger, save_as) - if (length(skills_idx) == 0L) { - new_section <- c( - "", - "## Skills", - "", - "| Triggers | Path |", - "|----------|------|", - paste0("| ", trigger, " | @", save_as, " |") - ) - writeLines(c(lines, new_section), agents_path) - return(invisible(agents_path)) + existing_idx <- .find_skill_row_idx(lines, save_as) + if (length(existing_idx)) { + return(.agents_lines_replace_row(lines, existing_idx, new_row)) } + .agents_lines_add_skill_row(lines, new_row) +} - # Find the last table row in the ## Skills section - section_start <- skills_idx[[1L]] - last_table_row <- NA_integer_ - for (i in seq(section_start + 1L, length(lines))) { - if (grepl("^\\|", lines[[i]])) { - last_table_row <- i - } else if (!is.na(last_table_row)) { - break - } +#' Add a new skill row to AGENTS.md lines +#' +#' Appends a new `## Skills` section if none exists; otherwise delegates to +#' [.agents_lines_add_to_section()]. +#' +#' @inheritParams .shared-params +#' @returns (`character`) Updated lines. +#' @keywords internal +.agents_lines_add_skill_row <- function(lines, new_row) { + section_idx <- .find_skills_section_idx(lines) + if (!length(section_idx)) { + return(.agents_lines_append_section(lines, new_row)) } + .agents_lines_add_to_section(lines, section_idx, new_row) +} - new_row <- paste0("| ", trigger, " | @", save_as, " |") - if (is.na(last_table_row)) { - insert_idx <- section_start - lines <- c( - lines[seq_len(insert_idx)], - "", - "| Triggers | Path |", - "|----------|------|", - new_row, - lines[seq(insert_idx + 1L, length(lines))] - ) - } else { - tail_lines <- if (last_table_row < length(lines)) { - lines[seq(last_table_row + 1L, length(lines))] - } else { - character(0L) - } - lines <- c(lines[seq_len(last_table_row)], new_row, tail_lines) +#' Add a skill row to an existing ## Skills section +#' +#' Inserts a new table if none exists in the section; otherwise appends the +#' row to the existing table. +#' +#' @param section_idx (`integer(1)`) Index of the `## Skills` heading line. +#' @inheritParams .shared-params +#' @returns (`character`) Updated lines. +#' @keywords internal +.agents_lines_add_to_section <- function(lines, section_idx, new_row) { + last_row_idx <- .find_table_last_row_idx(lines, section_idx) + if (!length(last_row_idx)) { + return(.agents_lines_insert_table(lines, section_idx, new_row)) } + .agents_lines_append_row(lines, last_row_idx, new_row) +} - writeLines(lines, agents_path) - invisible(agents_path) +#' Replace an existing skill row in AGENTS.md lines +#' +#' @param row_idx (`integer(1)`) Index of the row to replace. +#' @inheritParams .shared-params +#' @returns (`character`) Updated lines. +#' @keywords internal +.agents_lines_replace_row <- function(lines, row_idx, new_row) { + lines[[row_idx]] <- new_row + lines +} + +#' Append a new ## Skills section to AGENTS.md lines +#' +#' @inheritParams .shared-params +#' @returns (`character`) Updated lines. +#' @keywords internal +.agents_lines_append_section <- function(lines, new_row) { + c(lines, .make_skills_section(new_row)) +} + +#' Insert a skills table after the ## Skills heading in AGENTS.md lines +#' +#' @param section_idx (`integer(1)`) Index of the `## Skills` heading line. +#' @inheritParams .shared-params +#' @returns (`character`) Updated lines. +#' @keywords internal +.agents_lines_insert_table <- function(lines, section_idx, new_row) { + .lines_insert_after(lines, section_idx, .make_skills_table(new_row)) +} + +#' Append a skill row after the last table row in AGENTS.md lines +#' +#' @param last_row_idx (`integer(1)`) Index of the current last table row. +#' @inheritParams .shared-params +#' @returns (`character`) Updated lines. +#' @keywords internal +.agents_lines_append_row <- function(lines, last_row_idx, new_row) { + .lines_insert_after(lines, last_row_idx, new_row) +} + +#' Build a markdown table row for a skill +#' +#' @inheritParams .shared-params +#' @returns (`character(1)`) A markdown table row string. +#' @keywords internal +.make_skill_row <- function(trigger, save_as, call = caller_env()) { + trigger <- .to_string(trigger, call = call) + paste0("| ", trigger, " | @", save_as, " |") +} + +#' Build the header and data rows for a Skills markdown table +#' +#' @param row (`character(1)`) A pre-built skill row, e.g. from +#' [.make_skill_row()]. +#' @returns (`character`) Lines comprising the blank spacer, table header, +#' separator, and data row. +#' @keywords internal +.make_skills_table <- function(row) { + c("", "| Triggers | Path |", "|----------|------|", row) +} + +#' Build a complete ## Skills section with a table +#' +#' @param row (`character(1)`) A pre-built skill row, e.g. from +#' [.make_skill_row()]. +#' @returns (`character`) Lines comprising the section heading and table. +#' @keywords internal +.make_skills_section <- function(row) { + c("", "## Skills", .make_skills_table(row)) +} + +#' Find the line index of an existing skill row in AGENTS.md +#' +#' @inheritParams .shared-params +#' @returns (`integer(1)`) Index of the first matching line, or `integer()`. +#' @keywords internal +.find_skill_row_idx <- function(agents_lines, save_as) { + .find_pattern_idx(agents_lines, stringr::fixed(paste0("@", save_as))) +} + +#' Find the line index of the ## Skills section heading in AGENTS.md +#' +#' @inheritParams .shared-params +#' @returns (`integer(1)`) Index of the `## Skills` heading, or `integer()`. +#' @keywords internal +.find_skills_section_idx <- function(agents_lines) { + .find_pattern_idx(agents_lines, "^## Skills") } diff --git a/R/use_skill_create_issue.R b/R/use_skill_create_issue.R index ffb4e60..47e9495 100644 --- a/R/use_skill_create_issue.R +++ b/R/use_skill_create_issue.R @@ -79,7 +79,7 @@ use_skill_create_issue <- function( "{.field BugReports} in {.file DESCRIPTION} must be a GitHub issues URL.", "i" = "Run {.run usethis::use_github()} to set one up." ), - "invalid_bug_reports", + "unsupported_bug_reports", call = call ) } @@ -95,7 +95,7 @@ use_skill_create_issue <- function( #' @returns (`character(1)`) The repository's GraphQL node ID. #' @keywords internal .fetch_repo_id <- function(owner, repo, gh_token) { - repo_result <- gh::gh( + repo_result <- .call_gh( "POST /graphql", query = sprintf( '{ repository(owner: "%s", name: "%s") { id } }', @@ -114,7 +114,7 @@ use_skill_create_issue <- function( #' `description` fields. #' @keywords internal .fetch_repo_issue_types <- function(owner, repo, gh_token) { - types_result <- gh::gh( + types_result <- .call_gh( "POST /graphql", query = sprintf( paste0( @@ -129,15 +129,3 @@ use_skill_create_issue <- function( types_result$data$repository$issueTypes$nodes } -#' Format the current time as a UTC timestamp string -#' -#' @returns (`character(1)`) Current time formatted as `"YYYY-MM-DD HH:MM:SS -#' UTC"`. -#' @keywords internal -.format_now_utc <- function() { - format( - Sys.time(), - tz = "UTC", - format = "%Y-%m-%d %H:%M:%S UTC" - ) -} diff --git a/R/utils-coerce.R b/R/utils-coerce.R new file mode 100644 index 0000000..c21235e --- /dev/null +++ b/R/utils-coerce.R @@ -0,0 +1,25 @@ +#' Coerce to a non-null, non-empty character scalar +#' +#' @param x (`any`) The value to coerce. +#' @inheritParams .shared-params +#' @returns (`character(1)`) `x` coerced to a character scalar. +#' @keywords internal +.to_string <- function(x, x_arg = caller_arg(x), call = caller_env()) { + stbl::to_character_scalar( + x, + allow_null = FALSE, + allow_zero_length = FALSE, + x_arg = x_arg, + call = call + ) +} + +#' Coerce to a non-null logical scalar +#' +#' @param x (`any`) The value to coerce. +#' @inheritParams .shared-params +#' @returns (`logical(1)`) `x` coerced to a logical scalar. +#' @keywords internal +.to_boolean <- function(x, x_arg = caller_arg(x), call = caller_env()) { + stbl::to_lgl_scalar(x, allow_null = FALSE, x_arg = x_arg, call = call) +} diff --git a/R/utils-path.R b/R/utils-path.R new file mode 100644 index 0000000..8f05881 --- /dev/null +++ b/R/utils-path.R @@ -0,0 +1,55 @@ +#' Build a path to a inst file within installed or dev pkgskills +#' +#' @param ... Path components, passed to [fs::path_package()]. +#' @returns (`character(1)`) Absolute path within the pkgskills package. +#' @keywords internal +.path_pkg <- function(...) { + fs::path_package("pkgskills", ...) +} + +#' Build a path to a file in `inst/templates/` +#' +#' @param ... Path components appended after `"templates"`, passed to +#' [.path_pkg()]. +#' @returns (`character(1)`) Absolute path to the template file. +#' @keywords internal +.path_template <- function(...) { + .path_pkg("templates", ...) +} + +#' Build and validate a project-relative output path +#' +#' @inheritParams .shared-params +#' @returns (`character(1)`) Absolute path to `save_as` within the active +#' project. +#' @keywords internal +.path_proj_save_as <- function(save_as, overwrite, call = caller_env()) { + path <- usethis::proj_path(save_as) + .check_path_writable(path, overwrite, call = call) + path +} + +#' Check whether a path is writable +#' +#' @param path (`character(1)`) Absolute path to the file. +#' @inheritParams .shared-params +#' @returns `NULL`, invisibly. In part called for side effects: Deletes the file +#' if it exists and `overwrite = TRUE`. Errors if the file exists and +#' `overwrite = FALSE`. +#' @keywords internal +.check_path_writable <- function(path, overwrite, call = caller_env()) { + path <- .to_string(path, call = call) + overwrite <- .to_boolean(overwrite, call = call) + if (fs::file_exists(path)) { + if (overwrite) { + fs::file_delete(path) + } else { + .pkg_abort( + "File {.file {path}} already exists.", + "file_exists", + call = call + ) + } + } + invisible(NULL) +} diff --git a/R/utils-text.R b/R/utils-text.R new file mode 100644 index 0000000..558c6be --- /dev/null +++ b/R/utils-text.R @@ -0,0 +1,103 @@ +#' Find the index of the first line matching a pattern +#' +#' @inheritParams .shared-params +#' @param pattern (`character(1)`) A regex pattern or [stringr::fixed()] string +#' to match against each line. +#' @returns (`integer(1)`) Index of the first matching line, or `integer(0)` if +#' no line matches. +#' @keywords internal +.find_pattern_idx <- function(lines, pattern) { + utils::head(stringr::str_which(lines, pattern), 1L) +} + +#' Identify elements belonging to the first contiguous run +#' +#' @param x (`integer`) A sorted integer vector. +#' @returns (`logical`) `TRUE` for each element that belongs to the first +#' unbroken run (no gap of more than 1 between consecutive values), `FALSE` +#' once a gap is encountered. +#' @keywords internal +.is_first_run <- function(x) { + !cumsum(c(FALSE, diff(x) > 1L)) +} + +#' Find the index of the last markdown table row after a given position +#' +#' Returns the last index of the first contiguous block of `|`-starting lines +#' found after `from`. +#' +#' @inheritParams .shared-params +#' @param from (`integer(1)`) Starting position; only lines after `from` are +#' considered. +#' @returns (`integer(1)`) Index of the last table row, or `integer(0)` if +#' none was found. +#' @keywords internal +.find_table_last_row_idx <- function(lines, from) { + idx <- which(stringr::str_starts(lines, stringr::fixed("|"))) + idx <- idx[idx > from] + if (!length(idx)) { + return(integer(0)) + } + utils::tail(idx[.is_first_run(idx)], 1L) +} + +#' Extract YAML front matter lines from a character vector +#' +#' @param path (`character(1)`) File path, used only in error messages. +#' @inheritParams .shared-params +#' @returns (`character`) Lines between the opening and closing `---` +#' delimiters. +#' @keywords internal +.parse_yaml_front_matter <- function(lines, path, call = caller_env()) { + delim_idx <- which(lines == "---") + if (length(delim_idx) < 2L) { + .pkg_abort( + "No YAML front matter found in {.file {path}}.", + "no_front_matter", + call = call + ) + } + lines[(delim_idx[[1L]] + 1L):(delim_idx[[2L]] - 1L)] +} + +#' Extract a scalar value from YAML front matter lines +#' +#' @param front_matter (`character`) Lines of YAML front matter. +#' @param field (`character(1)`) Field name to extract. +#' @param path (`character(1)`) File path, used only in error messages. +#' @inheritParams .shared-params +#' @returns (`character(1)`) The trimmed value for `field`. +#' @keywords internal +.extract_yaml_scalar <- function( + front_matter, + field, + path, + call = caller_env() +) { + pattern <- stringr::regex(paste0("^", field, ":")) + match <- stringr::str_subset(front_matter, pattern) + if (!length(match)) { + .pkg_abort( + "No {.field {field}} field in front matter of {.file {path}}.", + paste0("no_", field), + call = call + ) + } + stringr::str_remove(match[[1L]], pattern) |> trimws() +} + +#' Insert lines into a character vector after a given index +#' +#' @inheritParams .shared-params +#' @param idx (`integer(1)`) Position after which `new_lines` will be inserted. +#' @param new_lines (`character`) Lines to insert. +#' @returns (`character`) Updated lines with `new_lines` spliced in after `idx`. +#' @keywords internal +.lines_insert_after <- function(lines, idx, new_lines) { + tail <- if (idx < length(lines)) { + lines[seq(idx + 1L, length(lines))] + } else { + character(0L) + } + c(lines[seq_len(idx)], new_lines, tail) +} diff --git a/R/utils.R b/R/utils.R index e6ab7b0..43ec2af 100644 --- a/R/utils.R +++ b/R/utils.R @@ -1,69 +1,25 @@ -#' Coerce to a non-null, non-empty character scalar +#' Call the GitHub API #' -#' @param x (`any`) The value to coerce. -#' @inheritParams .shared-params -#' @returns (`character(1)`) `x` coerced to a character scalar. -#' @keywords internal -.to_string <- function(x, x_arg = caller_arg(x), call = caller_env()) { - stbl::to_character_scalar( - x, - allow_null = FALSE, - allow_zero_length = FALSE, - x_arg = x_arg, - call = call - ) -} - -#' Coerce to a non-null logical scalar -#' -#' @param x (`any`) The value to coerce. -#' @inheritParams .shared-params -#' @returns (`logical(1)`) `x` coerced to a logical scalar. -#' @keywords internal -.to_boolean <- function(x, x_arg = caller_arg(x), call = caller_env()) { - stbl::to_lgl_scalar(x, allow_null = FALSE, x_arg = x_arg, call = call) -} - -#' Build a path within the pkgskills package +#' Thin wrapper around [gh::gh()] to facilitate mocking in tests. #' -#' @param ... Path components, passed to [fs::path_package()]. -#' @returns (`character(1)`) Absolute path within the pkgskills package. +#' @param ... Arguments passed to [gh::gh()]. +#' @returns The API response. #' @keywords internal -.path_pkg <- function(...) { - fs::path_package("pkgskills", ...) +.call_gh <- function(...) { + # nocov start + gh::gh(...) + # nocov end } -#' Build a path to a file in `inst/templates/` +#' Format the current time as a UTC timestamp string #' -#' @param ... Path components appended after `"templates"`, passed to -#' [.path_pkg()]. -#' @returns (`character(1)`) Absolute path to the template file. +#' @returns (`character(1)`) Current time formatted as `"YYYY-MM-DD HH:MM:SS +#' UTC"`. #' @keywords internal -.path_template <- function(...) { - .path_pkg("templates", ...) -} - -#' Check whether a file exists and act on it -#' -#' Deletes the file if it exists and `overwrite = TRUE`. Errors if the file -#' exists and `overwrite = FALSE`. -#' -#' @inheritParams .shared-params -#' @returns `NULL`, invisibly. -#' @keywords internal -.check_file_exists <- function(path, overwrite, call = rlang::caller_env()) { - path <- .to_string(path, call = call) - overwrite <- .to_boolean(overwrite, call = call) - if (fs::file_exists(path)) { - if (overwrite) { - fs::file_delete(path) - } else { - .pkg_abort( - "File {.file {path}} already exists.", - "file_exists", - call = call - ) - } - } - invisible(NULL) +.format_now_utc <- function() { + format( + Sys.time(), + tz = "UTC", + format = "%Y-%m-%d %H:%M:%S UTC" + ) } diff --git a/README.Rmd b/README.Rmd index c1e14a6..6d61ce8 100644 --- a/README.Rmd +++ b/README.Rmd @@ -29,8 +29,8 @@ A collection of curated, opinionated skills and agent instructions to improve ag You can install the development version of pkgskills from [GitHub](https://github.com/) with: ``` r -# install.packages("remotes") -remotes::install_github("api2r/pkgskills") +# install.packages("pak") +pak::pak("api2r/pkgskills") ``` ## Usage diff --git a/README.md b/README.md index d433ef0..203d8d6 100644 --- a/README.md +++ b/README.md @@ -23,8 +23,8 @@ You can install the development version of pkgskills from [GitHub](https://github.com/) with: ``` r -# install.packages("remotes") -remotes::install_github("api2r/pkgskills") +# install.packages("pak") +pak::pak("api2r/pkgskills") ``` ## Usage diff --git a/man/dot-agents_lines_add_skill_row.Rd b/man/dot-agents_lines_add_skill_row.Rd new file mode 100644 index 0000000..54a11b6 --- /dev/null +++ b/man/dot-agents_lines_add_skill_row.Rd @@ -0,0 +1,22 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/use_skill.R +\name{.agents_lines_add_skill_row} +\alias{.agents_lines_add_skill_row} +\title{Add a new skill row to AGENTS.md lines} +\usage{ +.agents_lines_add_skill_row(lines, new_row) +} +\arguments{ +\item{lines}{(\code{character}) Lines of a file, as returned by \code{\link[=readLines]{readLines()}}.} + +\item{new_row}{(\code{character(1)}) A pre-built skill row string, as produced by +\code{.make_skill_row()}.} +} +\value{ +(\code{character}) Updated lines. +} +\description{ +Appends a new \verb{## Skills} section if none exists; otherwise delegates to +\code{\link[=.agents_lines_add_to_section]{.agents_lines_add_to_section()}}. +} +\keyword{internal} diff --git a/man/dot-agents_lines_add_to_section.Rd b/man/dot-agents_lines_add_to_section.Rd new file mode 100644 index 0000000..1b0aa5a --- /dev/null +++ b/man/dot-agents_lines_add_to_section.Rd @@ -0,0 +1,24 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/use_skill.R +\name{.agents_lines_add_to_section} +\alias{.agents_lines_add_to_section} +\title{Add a skill row to an existing ## Skills section} +\usage{ +.agents_lines_add_to_section(lines, section_idx, new_row) +} +\arguments{ +\item{lines}{(\code{character}) Lines of a file, as returned by \code{\link[=readLines]{readLines()}}.} + +\item{section_idx}{(\code{integer(1)}) Index of the \verb{## Skills} heading line.} + +\item{new_row}{(\code{character(1)}) A pre-built skill row string, as produced by +\code{.make_skill_row()}.} +} +\value{ +(\code{character}) Updated lines. +} +\description{ +Inserts a new table if none exists in the section; otherwise appends the +row to the existing table. +} +\keyword{internal} diff --git a/man/dot-agents_lines_append_row.Rd b/man/dot-agents_lines_append_row.Rd new file mode 100644 index 0000000..f3c9685 --- /dev/null +++ b/man/dot-agents_lines_append_row.Rd @@ -0,0 +1,23 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/use_skill.R +\name{.agents_lines_append_row} +\alias{.agents_lines_append_row} +\title{Append a skill row after the last table row in AGENTS.md lines} +\usage{ +.agents_lines_append_row(lines, last_row_idx, new_row) +} +\arguments{ +\item{lines}{(\code{character}) Lines of a file, as returned by \code{\link[=readLines]{readLines()}}.} + +\item{last_row_idx}{(\code{integer(1)}) Index of the current last table row.} + +\item{new_row}{(\code{character(1)}) A pre-built skill row string, as produced by +\code{.make_skill_row()}.} +} +\value{ +(\code{character}) Updated lines. +} +\description{ +Append a skill row after the last table row in AGENTS.md lines +} +\keyword{internal} diff --git a/man/dot-agents_lines_append_section.Rd b/man/dot-agents_lines_append_section.Rd new file mode 100644 index 0000000..5df154d --- /dev/null +++ b/man/dot-agents_lines_append_section.Rd @@ -0,0 +1,21 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/use_skill.R +\name{.agents_lines_append_section} +\alias{.agents_lines_append_section} +\title{Append a new ## Skills section to AGENTS.md lines} +\usage{ +.agents_lines_append_section(lines, new_row) +} +\arguments{ +\item{lines}{(\code{character}) Lines of a file, as returned by \code{\link[=readLines]{readLines()}}.} + +\item{new_row}{(\code{character(1)}) A pre-built skill row string, as produced by +\code{.make_skill_row()}.} +} +\value{ +(\code{character}) Updated lines. +} +\description{ +Append a new ## Skills section to AGENTS.md lines +} +\keyword{internal} diff --git a/man/dot-agents_lines_insert_table.Rd b/man/dot-agents_lines_insert_table.Rd new file mode 100644 index 0000000..3c4b075 --- /dev/null +++ b/man/dot-agents_lines_insert_table.Rd @@ -0,0 +1,23 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/use_skill.R +\name{.agents_lines_insert_table} +\alias{.agents_lines_insert_table} +\title{Insert a skills table after the ## Skills heading in AGENTS.md lines} +\usage{ +.agents_lines_insert_table(lines, section_idx, new_row) +} +\arguments{ +\item{lines}{(\code{character}) Lines of a file, as returned by \code{\link[=readLines]{readLines()}}.} + +\item{section_idx}{(\code{integer(1)}) Index of the \verb{## Skills} heading line.} + +\item{new_row}{(\code{character(1)}) A pre-built skill row string, as produced by +\code{.make_skill_row()}.} +} +\value{ +(\code{character}) Updated lines. +} +\description{ +Insert a skills table after the ## Skills heading in AGENTS.md lines +} +\keyword{internal} diff --git a/man/dot-agents_lines_replace_row.Rd b/man/dot-agents_lines_replace_row.Rd new file mode 100644 index 0000000..443633c --- /dev/null +++ b/man/dot-agents_lines_replace_row.Rd @@ -0,0 +1,23 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/use_skill.R +\name{.agents_lines_replace_row} +\alias{.agents_lines_replace_row} +\title{Replace an existing skill row in AGENTS.md lines} +\usage{ +.agents_lines_replace_row(lines, row_idx, new_row) +} +\arguments{ +\item{lines}{(\code{character}) Lines of a file, as returned by \code{\link[=readLines]{readLines()}}.} + +\item{row_idx}{(\code{integer(1)}) Index of the row to replace.} + +\item{new_row}{(\code{character(1)}) A pre-built skill row string, as produced by +\code{.make_skill_row()}.} +} +\value{ +(\code{character}) Updated lines. +} +\description{ +Replace an existing skill row in AGENTS.md lines +} +\keyword{internal} diff --git a/man/dot-agents_lines_upsert_skill.Rd b/man/dot-agents_lines_upsert_skill.Rd new file mode 100644 index 0000000..4aa1cd8 --- /dev/null +++ b/man/dot-agents_lines_upsert_skill.Rd @@ -0,0 +1,26 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/use_skill.R +\name{.agents_lines_upsert_skill} +\alias{.agents_lines_upsert_skill} +\title{Compute updated AGENTS.md lines with a skill row upserted} +\usage{ +.agents_lines_upsert_skill(path, trigger, save_as, call = caller_env()) +} +\arguments{ +\item{path}{(\code{character(1)}) Path to \code{AGENTS.md}.} + +\item{trigger}{(\code{character(1)}) Trigger phrase for the skill.} + +\item{save_as}{(\code{character(1)}) Output file path, relative to the project +root.} + +\item{call}{(\code{environment}) The caller environment for error messages.} +} +\value{ +(\code{character}) Updated lines. +} +\description{ +Reads \code{path} and validates inputs. Replaces the existing row for \code{save_as} +if found; otherwise delegates to \code{.agents_lines_add_skill_row()}. +} +\keyword{internal} diff --git a/man/dot-call_gh.Rd b/man/dot-call_gh.Rd new file mode 100644 index 0000000..2c5bf9a --- /dev/null +++ b/man/dot-call_gh.Rd @@ -0,0 +1,18 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/utils.R +\name{.call_gh} +\alias{.call_gh} +\title{Call the GitHub API} +\usage{ +.call_gh(...) +} +\arguments{ +\item{...}{Arguments passed to \code{\link[gh:gh]{gh::gh()}}.} +} +\value{ +The API response. +} +\description{ +Thin wrapper around \code{\link[gh:gh]{gh::gh()}} to facilitate mocking in tests. +} +\keyword{internal} diff --git a/man/dot-check_file_exists.Rd b/man/dot-check_file_exists.Rd deleted file mode 100644 index 0081f22..0000000 --- a/man/dot-check_file_exists.Rd +++ /dev/null @@ -1,22 +0,0 @@ -% Generated by roxygen2: do not edit by hand -% Please edit documentation in R/utils.R -\name{.check_file_exists} -\alias{.check_file_exists} -\title{Check whether a file exists and act on it} -\usage{ -.check_file_exists(path, overwrite, call = rlang::caller_env()) -} -\arguments{ -\item{overwrite}{(\code{logical(1)}) Whether to overwrite an existing file. -Defaults to \code{TRUE}.} - -\item{call}{(\code{environment}) The caller environment for error messages.} -} -\value{ -\code{NULL}, invisibly. -} -\description{ -Deletes the file if it exists and \code{overwrite = TRUE}. Errors if the file -exists and \code{overwrite = FALSE}. -} -\keyword{internal} diff --git a/man/dot-check_path_writable.Rd b/man/dot-check_path_writable.Rd new file mode 100644 index 0000000..18ca926 --- /dev/null +++ b/man/dot-check_path_writable.Rd @@ -0,0 +1,25 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/utils-path.R +\name{.check_path_writable} +\alias{.check_path_writable} +\title{Check whether a path is writable} +\usage{ +.check_path_writable(path, overwrite, call = caller_env()) +} +\arguments{ +\item{path}{(\code{character(1)}) Absolute path to the file.} + +\item{overwrite}{(\code{logical(1)}) Whether to overwrite an existing file. +Defaults to \code{TRUE}.} + +\item{call}{(\code{environment}) The caller environment for error messages.} +} +\value{ +\code{NULL}, invisibly. In part called for side effects: Deletes the file +if it exists and \code{overwrite = TRUE}. Errors if the file exists and +\code{overwrite = FALSE}. +} +\description{ +Check whether a path is writable +} +\keyword{internal} diff --git a/man/dot-extract_yaml_scalar.Rd b/man/dot-extract_yaml_scalar.Rd new file mode 100644 index 0000000..f588acf --- /dev/null +++ b/man/dot-extract_yaml_scalar.Rd @@ -0,0 +1,24 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/utils-text.R +\name{.extract_yaml_scalar} +\alias{.extract_yaml_scalar} +\title{Extract a scalar value from YAML front matter lines} +\usage{ +.extract_yaml_scalar(front_matter, field, path, call = caller_env()) +} +\arguments{ +\item{front_matter}{(\code{character}) Lines of YAML front matter.} + +\item{field}{(\code{character(1)}) Field name to extract.} + +\item{path}{(\code{character(1)}) File path, used only in error messages.} + +\item{call}{(\code{environment}) The caller environment for error messages.} +} +\value{ +(\code{character(1)}) The trimmed value for \code{field}. +} +\description{ +Extract a scalar value from YAML front matter lines +} +\keyword{internal} diff --git a/man/dot-find_pattern_idx.Rd b/man/dot-find_pattern_idx.Rd new file mode 100644 index 0000000..deff9f9 --- /dev/null +++ b/man/dot-find_pattern_idx.Rd @@ -0,0 +1,22 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/utils-text.R +\name{.find_pattern_idx} +\alias{.find_pattern_idx} +\title{Find the index of the first line matching a pattern} +\usage{ +.find_pattern_idx(lines, pattern) +} +\arguments{ +\item{lines}{(\code{character}) Lines of a file, as returned by \code{\link[=readLines]{readLines()}}.} + +\item{pattern}{(\code{character(1)}) A regex pattern or \code{\link[stringr:modifiers]{stringr::fixed()}} string +to match against each line.} +} +\value{ +(\code{integer(1)}) Index of the first matching line, or \code{integer(0)} if +no line matches. +} +\description{ +Find the index of the first line matching a pattern +} +\keyword{internal} diff --git a/man/dot-find_skill_row_idx.Rd b/man/dot-find_skill_row_idx.Rd new file mode 100644 index 0000000..8fd445a --- /dev/null +++ b/man/dot-find_skill_row_idx.Rd @@ -0,0 +1,21 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/use_skill.R +\name{.find_skill_row_idx} +\alias{.find_skill_row_idx} +\title{Find the line index of an existing skill row in AGENTS.md} +\usage{ +.find_skill_row_idx(agents_lines, save_as) +} +\arguments{ +\item{agents_lines}{(\code{character}) Lines of \code{AGENTS.md}.} + +\item{save_as}{(\code{character(1)}) Output file path, relative to the project +root.} +} +\value{ +(\code{integer(1)}) Index of the first matching line, or \code{integer()}. +} +\description{ +Find the line index of an existing skill row in AGENTS.md +} +\keyword{internal} diff --git a/man/dot-find_skills_section_idx.Rd b/man/dot-find_skills_section_idx.Rd new file mode 100644 index 0000000..d6760b8 --- /dev/null +++ b/man/dot-find_skills_section_idx.Rd @@ -0,0 +1,18 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/use_skill.R +\name{.find_skills_section_idx} +\alias{.find_skills_section_idx} +\title{Find the line index of the ## Skills section heading in AGENTS.md} +\usage{ +.find_skills_section_idx(agents_lines) +} +\arguments{ +\item{agents_lines}{(\code{character}) Lines of \code{AGENTS.md}.} +} +\value{ +(\code{integer(1)}) Index of the \verb{## Skills} heading, or \code{integer()}. +} +\description{ +Find the line index of the ## Skills section heading in AGENTS.md +} +\keyword{internal} diff --git a/man/dot-find_table_last_row_idx.Rd b/man/dot-find_table_last_row_idx.Rd new file mode 100644 index 0000000..125140f --- /dev/null +++ b/man/dot-find_table_last_row_idx.Rd @@ -0,0 +1,23 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/utils-text.R +\name{.find_table_last_row_idx} +\alias{.find_table_last_row_idx} +\title{Find the index of the last markdown table row after a given position} +\usage{ +.find_table_last_row_idx(lines, from) +} +\arguments{ +\item{lines}{(\code{character}) Lines of a file, as returned by \code{\link[=readLines]{readLines()}}.} + +\item{from}{(\code{integer(1)}) Starting position; only lines after \code{from} are +considered.} +} +\value{ +(\code{integer(1)}) Index of the last table row, or \code{integer(0)} if +none was found. +} +\description{ +Returns the last index of the first contiguous block of \code{|}-starting lines +found after \code{from}. +} +\keyword{internal} diff --git a/man/dot-format_now_utc.Rd b/man/dot-format_now_utc.Rd index 45c798a..7f0ecaf 100644 --- a/man/dot-format_now_utc.Rd +++ b/man/dot-format_now_utc.Rd @@ -1,5 +1,5 @@ % Generated by roxygen2: do not edit by hand -% Please edit documentation in R/use_skill_create_issue.R +% Please edit documentation in R/utils.R \name{.format_now_utc} \alias{.format_now_utc} \title{Format the current time as a UTC timestamp string} diff --git a/man/dot-is_first_run.Rd b/man/dot-is_first_run.Rd new file mode 100644 index 0000000..021ccb3 --- /dev/null +++ b/man/dot-is_first_run.Rd @@ -0,0 +1,20 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/utils-text.R +\name{.is_first_run} +\alias{.is_first_run} +\title{Identify elements belonging to the first contiguous run} +\usage{ +.is_first_run(x) +} +\arguments{ +\item{x}{(\code{integer}) A sorted integer vector.} +} +\value{ +(\code{logical}) \code{TRUE} for each element that belongs to the first +unbroken run (no gap of more than 1 between consecutive values), \code{FALSE} +once a gap is encountered. +} +\description{ +Identify elements belonging to the first contiguous run +} +\keyword{internal} diff --git a/man/dot-lines_insert_after.Rd b/man/dot-lines_insert_after.Rd new file mode 100644 index 0000000..647f8a1 --- /dev/null +++ b/man/dot-lines_insert_after.Rd @@ -0,0 +1,22 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/utils-text.R +\name{.lines_insert_after} +\alias{.lines_insert_after} +\title{Insert lines into a character vector after a given index} +\usage{ +.lines_insert_after(lines, idx, new_lines) +} +\arguments{ +\item{lines}{(\code{character}) Lines of a file, as returned by \code{\link[=readLines]{readLines()}}.} + +\item{idx}{(\code{integer(1)}) Position after which \code{new_lines} will be inserted.} + +\item{new_lines}{(\code{character}) Lines to insert.} +} +\value{ +(\code{character}) Updated lines with \code{new_lines} spliced in after \code{idx}. +} +\description{ +Insert lines into a character vector after a given index +} +\keyword{internal} diff --git a/man/dot-make_skill_row.Rd b/man/dot-make_skill_row.Rd new file mode 100644 index 0000000..88a06ec --- /dev/null +++ b/man/dot-make_skill_row.Rd @@ -0,0 +1,23 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/use_skill.R +\name{.make_skill_row} +\alias{.make_skill_row} +\title{Build a markdown table row for a skill} +\usage{ +.make_skill_row(trigger, save_as, call = caller_env()) +} +\arguments{ +\item{trigger}{(\code{character(1)}) Trigger phrase for the skill.} + +\item{save_as}{(\code{character(1)}) Output file path, relative to the project +root.} + +\item{call}{(\code{environment}) The caller environment for error messages.} +} +\value{ +(\code{character(1)}) A markdown table row string. +} +\description{ +Build a markdown table row for a skill +} +\keyword{internal} diff --git a/man/dot-make_skills_section.Rd b/man/dot-make_skills_section.Rd new file mode 100644 index 0000000..d09b421 --- /dev/null +++ b/man/dot-make_skills_section.Rd @@ -0,0 +1,19 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/use_skill.R +\name{.make_skills_section} +\alias{.make_skills_section} +\title{Build a complete ## Skills section with a table} +\usage{ +.make_skills_section(row) +} +\arguments{ +\item{row}{(\code{character(1)}) A pre-built skill row, e.g. from +\code{\link[=.make_skill_row]{.make_skill_row()}}.} +} +\value{ +(\code{character}) Lines comprising the section heading and table. +} +\description{ +Build a complete ## Skills section with a table +} +\keyword{internal} diff --git a/man/dot-make_skills_table.Rd b/man/dot-make_skills_table.Rd new file mode 100644 index 0000000..4766d71 --- /dev/null +++ b/man/dot-make_skills_table.Rd @@ -0,0 +1,20 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/use_skill.R +\name{.make_skills_table} +\alias{.make_skills_table} +\title{Build the header and data rows for a Skills markdown table} +\usage{ +.make_skills_table(row) +} +\arguments{ +\item{row}{(\code{character(1)}) A pre-built skill row, e.g. from +\code{\link[=.make_skill_row]{.make_skill_row()}}.} +} +\value{ +(\code{character}) Lines comprising the blank spacer, table header, +separator, and data row. +} +\description{ +Build the header and data rows for a Skills markdown table +} +\keyword{internal} diff --git a/man/dot-parse_yaml_front_matter.Rd b/man/dot-parse_yaml_front_matter.Rd new file mode 100644 index 0000000..025d713 --- /dev/null +++ b/man/dot-parse_yaml_front_matter.Rd @@ -0,0 +1,23 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/utils-text.R +\name{.parse_yaml_front_matter} +\alias{.parse_yaml_front_matter} +\title{Extract YAML front matter lines from a character vector} +\usage{ +.parse_yaml_front_matter(lines, path, call = caller_env()) +} +\arguments{ +\item{lines}{(\code{character}) Lines of a file, as returned by \code{\link[=readLines]{readLines()}}.} + +\item{path}{(\code{character(1)}) File path, used only in error messages.} + +\item{call}{(\code{environment}) The caller environment for error messages.} +} +\value{ +(\code{character}) Lines between the opening and closing \verb{---} +delimiters. +} +\description{ +Extract YAML front matter lines from a character vector +} +\keyword{internal} diff --git a/man/dot-path_pkg.Rd b/man/dot-path_pkg.Rd index 619c06e..dac5898 100644 --- a/man/dot-path_pkg.Rd +++ b/man/dot-path_pkg.Rd @@ -1,8 +1,8 @@ % Generated by roxygen2: do not edit by hand -% Please edit documentation in R/utils.R +% Please edit documentation in R/utils-path.R \name{.path_pkg} \alias{.path_pkg} -\title{Build a path within the pkgskills package} +\title{Build a path to a inst file within installed or dev pkgskills} \usage{ .path_pkg(...) } @@ -13,6 +13,6 @@ (\code{character(1)}) Absolute path within the pkgskills package. } \description{ -Build a path within the pkgskills package +Build a path to a inst file within installed or dev pkgskills } \keyword{internal} diff --git a/man/dot-path_proj_save_as.Rd b/man/dot-path_proj_save_as.Rd new file mode 100644 index 0000000..3cbdc92 --- /dev/null +++ b/man/dot-path_proj_save_as.Rd @@ -0,0 +1,25 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/utils-path.R +\name{.path_proj_save_as} +\alias{.path_proj_save_as} +\title{Build and validate a project-relative output path} +\usage{ +.path_proj_save_as(save_as, overwrite, call = caller_env()) +} +\arguments{ +\item{save_as}{(\code{character(1)}) Output file path, relative to the project +root.} + +\item{overwrite}{(\code{logical(1)}) Whether to overwrite an existing file. +Defaults to \code{TRUE}.} + +\item{call}{(\code{environment}) The caller environment for error messages.} +} +\value{ +(\code{character(1)}) Absolute path to \code{save_as} within the active +project. +} +\description{ +Build and validate a project-relative output path +} +\keyword{internal} diff --git a/man/dot-path_skill_save_as.Rd b/man/dot-path_skill_save_as.Rd new file mode 100644 index 0000000..deec4e0 --- /dev/null +++ b/man/dot-path_skill_save_as.Rd @@ -0,0 +1,35 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/use_skill.R +\name{.path_skill_save_as} +\alias{.path_skill_save_as} +\title{Build the project-relative save path for a skill file} +\usage{ +.path_skill_save_as( + skill, + target_dir = ".github", + use_skills_subdir = TRUE, + call = caller_env() +) +} +\arguments{ +\item{skill}{(\code{character(1)}) Skill name. A folder name under +\verb{inst/templates/skills/}, e.g. \code{"create-issue"}. Determines the template +path and the install subdirectory.} + +\item{target_dir}{(\code{character(1)}) Directory where the skill will be +installed, relative to the project root. Defaults to \code{".github"}.} + +\item{use_skills_subdir}{(\code{logical(1)}) Whether to place the skill folder +under a \code{skills} subdirectory of \code{target_dir}. Defaults to \code{TRUE}, +producing \code{.github/skills/{skill}/SKILL.md}.} + +\item{call}{(\code{environment}) The caller environment for error messages.} +} +\value{ +(\code{character(1)}) Relative path to the \code{SKILL.md} file within the +project. +} +\description{ +Build the project-relative save path for a skill file +} +\keyword{internal} diff --git a/man/dot-path_template.Rd b/man/dot-path_template.Rd index 5ce8c7d..cf417aa 100644 --- a/man/dot-path_template.Rd +++ b/man/dot-path_template.Rd @@ -1,5 +1,5 @@ % Generated by roxygen2: do not edit by hand -% Please edit documentation in R/utils.R +% Please edit documentation in R/utils-path.R \name{.path_template} \alias{.path_template} \title{Build a path to a file in \verb{inst/templates/}} diff --git a/man/dot-read_skill_trigger.Rd b/man/dot-read_skill_trigger.Rd index 18af2ac..7e0fa01 100644 --- a/man/dot-read_skill_trigger.Rd +++ b/man/dot-read_skill_trigger.Rd @@ -4,7 +4,7 @@ \alias{.read_skill_trigger} \title{Read the trigger field from a skill template's YAML front matter} \usage{ -.read_skill_trigger(path, call = rlang::caller_env()) +.read_skill_trigger(path, call = caller_env()) } \arguments{ \item{path}{(\code{character(1)}) Path to the skill template file.} diff --git a/man/dot-shared-params.Rd b/man/dot-shared-params.Rd index 98d9970..88a1c2f 100644 --- a/man/dot-shared-params.Rd +++ b/man/dot-shared-params.Rd @@ -4,6 +4,8 @@ \alias{.shared-params} \title{Shared parameters} \arguments{ +\item{agents_lines}{(\code{character}) Lines of \code{AGENTS.md}.} + \item{call}{(\code{environment}) The caller environment for error messages.} \item{data}{(\code{list}) Named list of whisker template variables for rendering.} @@ -11,6 +13,11 @@ \item{gh_token}{(\code{character(1)}) A GitHub personal access token. Defaults to \code{gh::gh_token()}.} +\item{lines}{(\code{character}) Lines of a file, as returned by \code{\link[=readLines]{readLines()}}.} + +\item{new_row}{(\code{character(1)}) A pre-built skill row string, as produced by +\code{.make_skill_row()}.} + \item{open}{(\code{logical(1)}) Whether to open the file after creation.} \item{overwrite}{(\code{logical(1)}) Whether to overwrite an existing file. @@ -20,11 +27,18 @@ Defaults to \code{TRUE}.} \item{repo}{(\code{character(1)}) GitHub repository name.} -\item{save_as}{(\code{character(1)}) Output file path, relative to project root.} +\item{save_as}{(\code{character(1)}) Output file path, relative to the project +root.} + +\item{skill}{(\code{character(1)}) Skill name. A folder name under +\verb{inst/templates/skills/}, e.g. \code{"create-issue"}. Determines the template +path and the install subdirectory.} \item{target_dir}{(\code{character(1)}) Directory where the skill will be installed, relative to the project root. Defaults to \code{".github"}.} +\item{trigger}{(\code{character(1)}) Trigger phrase for the skill.} + \item{use_skills_subdir}{(\code{logical(1)}) Whether to place the skill folder under a \code{skills} subdirectory of \code{target_dir}. Defaults to \code{TRUE}, producing \code{.github/skills/{skill}/SKILL.md}.} diff --git a/man/dot-to_boolean.Rd b/man/dot-to_boolean.Rd index 1681125..f09bd5d 100644 --- a/man/dot-to_boolean.Rd +++ b/man/dot-to_boolean.Rd @@ -1,5 +1,5 @@ % Generated by roxygen2: do not edit by hand -% Please edit documentation in R/utils.R +% Please edit documentation in R/utils-coerce.R \name{.to_boolean} \alias{.to_boolean} \title{Coerce to a non-null logical scalar} diff --git a/man/dot-to_string.Rd b/man/dot-to_string.Rd index 55e9559..11a4d61 100644 --- a/man/dot-to_string.Rd +++ b/man/dot-to_string.Rd @@ -1,5 +1,5 @@ % Generated by roxygen2: do not edit by hand -% Please edit documentation in R/utils.R +% Please edit documentation in R/utils-coerce.R \name{.to_string} \alias{.to_string} \title{Coerce to a non-null, non-empty character scalar} diff --git a/man/dot-upsert_agents_skill.Rd b/man/dot-upsert_agents_skill.Rd index 82fc37a..710d111 100644 --- a/man/dot-upsert_agents_skill.Rd +++ b/man/dot-upsert_agents_skill.Rd @@ -4,12 +4,15 @@ \alias{.upsert_agents_skill} \title{Upsert a skill row in the ## Skills table of AGENTS.md} \usage{ -.upsert_agents_skill(trigger, save_as, call = caller_env) +.upsert_agents_skill(trigger, save_as, call = caller_env()) } \arguments{ \item{trigger}{(\code{character(1)}) Trigger phrase for the skill.} -\item{save_as}{(\code{character(1)}) Relative path to the installed skill file.} +\item{save_as}{(\code{character(1)}) Output file path, relative to the project +root.} + +\item{call}{(\code{environment}) The caller environment for error messages.} } \value{ The path to \code{AGENTS.md}, invisibly, or \code{NULL} invisibly if diff --git a/man/dot-upsert_agents_skill_from_template.Rd b/man/dot-upsert_agents_skill_from_template.Rd index d891ec0..520f634 100644 --- a/man/dot-upsert_agents_skill_from_template.Rd +++ b/man/dot-upsert_agents_skill_from_template.Rd @@ -4,13 +4,18 @@ \alias{.upsert_agents_skill_from_template} \title{Upsert a template skill row in the ## Skills table of AGENTS.md} \usage{ -.upsert_agents_skill_from_template(skill_subpath, save_as, call = caller_env()) +.upsert_agents_skill_from_template( + skill_path_relative, + save_as, + call = caller_env() +) } \arguments{ -\item{skill_subpath}{(\code{character(1)}) The relative path to the \code{SKILL.md} -file.} +\item{skill_path_relative}{(\code{character(1)}) The relative path to the +\code{SKILL.md} file, relative to the template dir.} -\item{save_as}{(\code{character(1)}) Output file path, relative to project root.} +\item{save_as}{(\code{character(1)}) Output file path, relative to the project +root.} \item{call}{(\code{environment}) The caller environment for error messages.} } diff --git a/man/dot-use_template.Rd b/man/dot-use_template.Rd index 246a3c6..7591c83 100644 --- a/man/dot-use_template.Rd +++ b/man/dot-use_template.Rd @@ -9,7 +9,8 @@ \arguments{ \item{template}{(\code{character(1)}) Template name within \verb{inst/templates/}.} -\item{save_as}{(\code{character(1)}) Output file path, relative to project root.} +\item{save_as}{(\code{character(1)}) Output file path, relative to the project +root.} \item{data}{(\code{list}) Named list of whisker template variables for rendering.} diff --git a/man/use_agent.Rd b/man/use_agent.Rd index 2bed18b..c38c831 100644 --- a/man/use_agent.Rd +++ b/man/use_agent.Rd @@ -7,7 +7,8 @@ use_agent(save_as = "AGENTS.md", open = rlang::is_interactive()) } \arguments{ -\item{save_as}{(\code{character(1)}) Output file path, relative to project root.} +\item{save_as}{(\code{character(1)}) Output file path, relative to the project +root.} \item{open}{(\code{logical(1)}) Whether to open the file after creation.} } diff --git a/tests/testthat/.gitignore b/tests/testthat/.gitignore deleted file mode 100644 index c0ccdb4..0000000 --- a/tests/testthat/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -_problems -testthat-problems.rds diff --git a/tests/testthat/_snaps/use_skill.md b/tests/testthat/_snaps/use_skill.md index d7864bd..8ad53dd 100644 --- a/tests/testthat/_snaps/use_skill.md +++ b/tests/testthat/_snaps/use_skill.md @@ -6,3 +6,45 @@ Message Skill '.github/skills/create-issue/SKILL.md' installed. +# .use_skill() errors when overwrite = FALSE and file exists (#6) + + Code + (stbl::expect_pkg_error_classes({ + suppressMessages(.use_skill("create-issue", data = list(owner = "o", repo = "r", + repo_id = "id", issue_types = list()), overwrite = FALSE, open = FALSE)) + }, "pkgskills", "file_exists")) + Output + + Error: + ! File 'PATH' already exists. + +# .read_skill_trigger() errors when template file not found (#6) + + Code + (stbl::expect_pkg_error_classes(.read_skill_trigger("/tmp/nonexistent/SKILL.md"), + "pkgskills", "template_not_found")) + Output + + Error: + ! Template not found: '/tmp/nonexistent/SKILL.md'. + +# .read_skill_trigger() errors when front matter is missing (#6) + + Code + (stbl::expect_pkg_error_classes(.read_skill_trigger(tmp), "pkgskills", + "no_front_matter")) + Output + + Error: + ! No YAML front matter found in 'PATH'. + +# .read_skill_trigger() errors when trigger field is absent (#6) + + Code + (stbl::expect_pkg_error_classes(.read_skill_trigger(tmp), "pkgskills", + "no_trigger")) + Output + + Error: + ! No trigger field in front matter of 'PATH'. + diff --git a/tests/testthat/_snaps/use_skill_create_issue.md b/tests/testthat/_snaps/use_skill_create_issue.md index 268b02d..b056961 100644 --- a/tests/testthat/_snaps/use_skill_create_issue.md +++ b/tests/testthat/_snaps/use_skill_create_issue.md @@ -1,7 +1,8 @@ # use_skill_create_issue() errors when BugReports is absent (#6) Code - (expect_error(use_skill_create_issue(open = FALSE), class = "pkgskills-error")) + (stbl::expect_pkg_error_classes(use_skill_create_issue(open = FALSE), + "pkgskills", "no_bug_reports")) Output Error in `use_skill_create_issue()`: @@ -11,9 +12,10 @@ # use_skill_create_issue() errors when BugReports is not a GitHub URL (#6) Code - (expect_error(use_skill_create_issue(open = FALSE), class = "pkgskills-error")) + (stbl::expect_pkg_error_classes(use_skill_create_issue(open = FALSE), + "pkgskills", "unsupported_bug_reports")) Output - + Error in `use_skill_create_issue()`: ! BugReports in 'DESCRIPTION' must be a GitHub issues URL. i Run `usethis::use_github()` to set one up. diff --git a/tests/testthat/_snaps/utils-path.md b/tests/testthat/_snaps/utils-path.md new file mode 100644 index 0000000..6871c0d --- /dev/null +++ b/tests/testthat/_snaps/utils-path.md @@ -0,0 +1,20 @@ +# .path_proj_save_as() errors when file exists and overwrite = FALSE (#noissue) + + Code + (stbl::expect_pkg_error_classes(.path_proj_save_as("output.md", overwrite = FALSE), + "pkgskills", "file_exists")) + Output + + Error: + ! File 'PATH' already exists. + +# .check_path_writable() errors when file exists and overwrite = FALSE (#noissue) + + Code + (stbl::expect_pkg_error_classes(.check_path_writable(tmp, overwrite = FALSE), + "pkgskills", "file_exists")) + Output + + Error: + ! File 'PATH' already exists. + diff --git a/tests/testthat/_snaps/utils-text.md b/tests/testthat/_snaps/utils-text.md new file mode 100644 index 0000000..3996a07 --- /dev/null +++ b/tests/testthat/_snaps/utils-text.md @@ -0,0 +1,30 @@ +# .parse_yaml_front_matter() errors when no delimiters found (#noissue) + + Code + (stbl::expect_pkg_error_classes(.parse_yaml_front_matter(lines, "test.md"), + "pkgskills", "no_front_matter")) + Output + + Error: + ! No YAML front matter found in 'test.md'. + +# .parse_yaml_front_matter() errors when only one delimiter found (#noissue) + + Code + (stbl::expect_pkg_error_classes(.parse_yaml_front_matter(lines, "test.md"), + "pkgskills", "no_front_matter")) + Output + + Error: + ! No YAML front matter found in 'test.md'. + +# .extract_yaml_scalar() errors when field not found (#noissue) + + Code + (stbl::expect_pkg_error_classes(.extract_yaml_scalar(front_matter, "trigger", + "test.md"), "pkgskills", "no_trigger")) + Output + + Error: + ! No trigger field in front matter of 'test.md'. + diff --git a/tests/testthat/helper-expectations.R b/tests/testthat/helper-expectations.R new file mode 100644 index 0000000..fcfc71a --- /dev/null +++ b/tests/testthat/helper-expectations.R @@ -0,0 +1,46 @@ +# `rlang::enexpr()` is used instead of `rlang::enquo()` because `inject()` +# splices a quosure as `^(expr)`, which breaks `expect_snapshot()`'s internal +# `parse(deparse(x))` round-trip. `enexpr()` captures only the bare expression, +# so the snapshot's Code section shows the full inner call transparently. +# +# The `call` parameter (defaulting to `caller_env()`) is forwarded to `inject()` +# as `env =` so that `expect_snapshot()` is evaluated in the test's environment. +# Without this, local variables in the expression (e.g. `tmp`) would be out of +# scope. +# +# The `transform` parameter is forwarded to `expect_snapshot()` to allow callers +# to scrub volatile values (e.g. temp paths) before snapshot comparison. +expect_pkg_error_snapshot <- function( + object, + error_class_component, + package = "pkgskills", + transform = NULL, + call = caller_env() +) { + obj_expr <- rlang::enexpr(object) + transform_expr <- rlang::enexpr(transform) + rlang::inject( + expect_snapshot( + { + (stbl::expect_pkg_error_classes( + !!obj_expr, + !!package, + !!error_class_component + )) + }, + transform = !!transform_expr + ), + env = call + ) +} + +# Used to scrub temp paths from snapshots. +.transform_path <- function(path) { + function(x) { + stringr::str_replace_all( + x, + stringr::fixed(as.character(path)), + "PATH" + ) + } +} diff --git a/tests/testthat/helper-local_pkg.R b/tests/testthat/helper-local_pkg.R index 5b658dd..c4fd713 100644 --- a/tests/testthat/helper-local_pkg.R +++ b/tests/testthat/helper-local_pkg.R @@ -1,3 +1,23 @@ +local_gh_mock <- function( + issue_types = list(), + repo_id = "R_testid", + .local_envir = parent.frame() +) { + local_mocked_bindings( + .call_gh = function(...) { + args <- list(...) + if (grepl("issueTypes", args$query)) { + list( + data = list(repository = list(issueTypes = list(nodes = issue_types))) + ) + } else { + list(data = list(repository = list(id = repo_id))) + } + }, + .env = .local_envir + ) +} + local_pkg <- function( ..., DESCRIPTION = c( @@ -5,7 +25,8 @@ local_pkg <- function( "Title: My Test Package", "Description: A package for testing.", "Version: 0.1.0", - "URL: https://example.com" + "URL: https://example.com", + "BugReports: https://github.com/myorg/mypkg/issues" ), .local_envir = parent.frame() ) { diff --git a/tests/testthat/test-use_skill.R b/tests/testthat/test-use_skill.R index 7308590..a020c23 100644 --- a/tests/testthat/test-use_skill.R +++ b/tests/testthat/test-use_skill.R @@ -220,22 +220,24 @@ test_that(".use_skill() errors when overwrite = FALSE and file exists (#6)", { existing_path <- fs::path(proj_dir, ".github/skills/create-issue/SKILL.md") fs::dir_create(fs::path_dir(existing_path)) writeLines("original content", existing_path) - stbl::expect_pkg_error_classes( - suppressMessages( - .use_skill( - "create-issue", - data = list( - owner = "o", - repo = "r", - repo_id = "id", - issue_types = list() - ), - overwrite = FALSE, - open = FALSE + expect_pkg_error_snapshot( + { + suppressMessages( + .use_skill( + "create-issue", + data = list( + owner = "o", + repo = "r", + repo_id = "id", + issue_types = list() + ), + overwrite = FALSE, + open = FALSE + ) ) - ), - "pkgskills", - "file_exists" + }, + "file_exists", + transform = .transform_path(existing_path) ) expect_equal(readLines(existing_path), "original content") }) @@ -295,9 +297,8 @@ test_that(".use_skill() errors on non-logical overwrite (#6)", { }) test_that(".read_skill_trigger() errors when template file not found (#6)", { - stbl::expect_pkg_error_classes( + expect_pkg_error_snapshot( .read_skill_trigger("/tmp/nonexistent/SKILL.md"), - "pkgskills", "template_not_found" ) }) @@ -305,20 +306,20 @@ test_that(".read_skill_trigger() errors when template file not found (#6)", { test_that(".read_skill_trigger() errors when front matter is missing (#6)", { tmp <- withr::local_tempfile(fileext = ".md") writeLines(c("# No front matter here", "Just content."), tmp) - stbl::expect_pkg_error_classes( + expect_pkg_error_snapshot( .read_skill_trigger(tmp), - "pkgskills", - "no_front_matter" + "no_front_matter", + transform = .transform_path(tmp) ) }) test_that(".read_skill_trigger() errors when trigger field is absent (#6)", { tmp <- withr::local_tempfile(fileext = ".md") writeLines(c("---", "name: my-skill", "---", "# Content"), tmp) - stbl::expect_pkg_error_classes( + expect_pkg_error_snapshot( .read_skill_trigger(tmp), - "pkgskills", - "no_trigger" + "no_trigger", + transform = .transform_path(tmp) ) }) diff --git a/tests/testthat/test-use_skill_create_issue.R b/tests/testthat/test-use_skill_create_issue.R index e30184f..57e7b2e 100644 --- a/tests/testthat/test-use_skill_create_issue.R +++ b/tests/testthat/test-use_skill_create_issue.R @@ -1,182 +1,79 @@ -test_that("use_skill_create_issue() errors when BugReports is absent (#6)", { - local_pkg( - DESCRIPTION = c( - "Package: mypkg", - "Title: My Package", - "Version: 0.1.0" - ) +test_that("use_skill_create_issue() errors on non-scalar target_dir (#6)", { + local_pkg() + local_gh_mock() + stbl::expect_pkg_error_classes( + use_skill_create_issue(target_dir = c("a", "b"), open = FALSE), + "stbl", + "non_scalar" ) - expect_snapshot( - (expect_error( - use_skill_create_issue(open = FALSE), - class = "pkgskills-error" - )) +}) + +test_that("use_skill_create_issue() errors on non-logical overwrite (#6)", { + local_pkg() + local_gh_mock() + stbl::expect_pkg_error_classes( + use_skill_create_issue(overwrite = "yes", open = FALSE), + "stbl", + "incompatible_type" ) }) -test_that("use_skill_create_issue() errors when BugReports is not a GitHub URL (#6)", { +test_that("use_skill_create_issue() errors when BugReports is absent (#6)", { local_pkg( DESCRIPTION = c( "Package: mypkg", "Title: My Package", - "Version: 0.1.0", - "BugReports: https://gitlab.com/myorg/mypkg/issues" + "Version: 0.1.0" ) ) - expect_snapshot( - (expect_error( - use_skill_create_issue(open = FALSE), - class = "pkgskills-error" - )) + expect_pkg_error_snapshot( + use_skill_create_issue(open = FALSE), + "no_bug_reports" ) }) -test_that("use_skill_create_issue() calls gh::gh() with correct queries (#6)", { +test_that("use_skill_create_issue() errors when BugReports is not a GitHub URL (#6)", { local_pkg( DESCRIPTION = c( "Package: mypkg", "Title: My Package", "Version: 0.1.0", - "BugReports: https://github.com/myorg/mypkg/issues" + "BugReports: https://gitlab.com/myorg/mypkg/issues" ) ) - gh_calls <- list() - local_mocked_bindings( - gh = function(...) { - args <- list(...) - gh_calls[[length(gh_calls) + 1]] <<- args - if (grepl("issueTypes", args$query)) { - list(data = list(repository = list(issueTypes = list(nodes = list())))) - } else { - list(data = list(repository = list(id = "R_testid"))) - } - }, - .package = "gh" + expect_pkg_error_snapshot( + use_skill_create_issue(open = FALSE), + "unsupported_bug_reports" ) - suppressMessages(use_skill_create_issue(open = FALSE)) - expect_length(gh_calls, 2L) - expect_true(any(vapply( - gh_calls, - function(x) grepl("issueTypes", x$query), - logical(1) - ))) - expect_true(any(vapply( - gh_calls, - function(x) grepl("myorg", x$query), - logical(1) - ))) - expect_true(any(vapply( - gh_calls, - function(x) grepl("mypkg", x$query), - logical(1) - ))) }) test_that("use_skill_create_issue() passes correct data to .use_skill() (#6)", { - local_pkg( - DESCRIPTION = c( - "Package: mypkg", - "Title: My Package", - "Version: 0.1.0", - "BugReports: https://github.com/myorg/mypkg/issues" - ) + local_pkg() + local_gh_mock( + issue_types = list( + list(name = "Feature", id = "IT_feat", description = "New stuff") + ), + repo_id = "R_myid" ) - local_mocked_bindings( - gh = function(...) { - args <- list(...) - if (grepl("issueTypes", args$query)) { - list( - data = list( - repository = list( - issueTypes = list( - nodes = list( - list( - name = "Feature", - id = "IT_feat", - description = "New stuff" - ) - ) - ) - ) - ) - ) - } else { - list(data = list(repository = list(id = "R_myid"))) - } - }, - .package = "gh" - ) - captured_data <- NULL local_mocked_bindings( .use_skill = function(skill, data, ...) { - captured_data <<- data + expect_equal(data$owner, "myorg") + expect_equal(data$repo, "mypkg") + expect_equal(data$repo_id, "R_myid") + expect_equal(data$issue_types[[1]]$name, "Feature") + expect_match( + data$update_time, + "^\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2} UTC$" + ) invisible(usethis::proj_path(".github/skills/create-issue/SKILL.md")) } ) suppressMessages(use_skill_create_issue(open = FALSE)) - expect_equal(captured_data$owner, "myorg") - expect_equal(captured_data$repo, "mypkg") - expect_equal(captured_data$repo_id, "R_myid") - expect_equal(captured_data$issue_types[[1]]$name, "Feature") - expect_true(grepl("UTC$", captured_data$update_time)) - expect_match( - captured_data$update_time, - "^\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2} UTC$" - ) }) test_that("use_skill_create_issue() returns path invisibly (#6)", { - local_pkg( - DESCRIPTION = c( - "Package: mypkg", - "Title: My Package", - "Version: 0.1.0", - "BugReports: https://github.com/myorg/mypkg/issues" - ) - ) - local_mocked_bindings( - gh = function(...) { - args <- list(...) - if (grepl("issueTypes", args$query)) { - list(data = list(repository = list(issueTypes = list(nodes = list())))) - } else { - list(data = list(repository = list(id = "R_testid"))) - } - }, - .package = "gh" - ) + local_pkg() + local_gh_mock() result <- withVisible(suppressMessages(use_skill_create_issue(open = FALSE))) expect_false(result$visible) }) - -test_that("use_skill_create_issue() errors on non-scalar target_dir (#6)", { - local_pkg( - DESCRIPTION = c( - "Package: mypkg", - "Title: My Package", - "Version: 0.1.0", - "BugReports: https://github.com/myorg/mypkg/issues" - ) - ) - stbl::expect_pkg_error_classes( - use_skill_create_issue(target_dir = c("a", "b"), open = FALSE), - "stbl", - "non_scalar" - ) -}) - -test_that("use_skill_create_issue() errors on non-logical overwrite (#6)", { - local_pkg( - DESCRIPTION = c( - "Package: mypkg", - "Title: My Package", - "Version: 0.1.0", - "BugReports: https://github.com/myorg/mypkg/issues" - ) - ) - stbl::expect_pkg_error_classes( - use_skill_create_issue(overwrite = "yes", open = FALSE), - "stbl", - "incompatible_type" - ) -}) diff --git a/tests/testthat/test-utils-coerce.R b/tests/testthat/test-utils-coerce.R new file mode 100644 index 0000000..2d2c202 --- /dev/null +++ b/tests/testthat/test-utils-coerce.R @@ -0,0 +1,10 @@ +test_that(".to_string() tests for non-NULL character scalar (#noissue)", { + expect_identical(.to_string("hello"), "hello") + stbl::expect_pkg_error_classes(.to_string(NULL), "stbl", "bad_null") +}) + +test_that(".to_boolean() returns a logical scalar (#noissue)", { + expect_identical(.to_boolean(TRUE), TRUE) + expect_identical(.to_boolean(FALSE), FALSE) + stbl::expect_pkg_error_classes(.to_boolean(NULL), "stbl", "bad_null") +}) diff --git a/tests/testthat/test-utils-path.R b/tests/testthat/test-utils-path.R new file mode 100644 index 0000000..e9a0706 --- /dev/null +++ b/tests/testthat/test-utils-path.R @@ -0,0 +1,53 @@ +test_that(".path_pkg() returns an existing path within pkgskills (#noissue)", { + path <- .path_pkg() + expect_type(path, "character") + expect_true(fs::dir_exists(path)) +}) + +test_that(".path_template() returns a path under the templates directory (#noissue)", { + path <- .path_template() + expect_true(fs::dir_exists(path)) + expect_match(path, "templates") +}) + +test_that(".path_proj_save_as() returns the project-relative path (#noissue)", { + proj_dir <- local_pkg() + result <- .path_proj_save_as("output.md", overwrite = TRUE) + expect_identical(result, fs::path(proj_dir, "output.md")) +}) + +test_that(".path_proj_save_as() errors when file exists and overwrite = FALSE (#noissue)", { + proj_dir <- local_pkg() + output_path <- fs::path(proj_dir, "output.md") + writeLines("content", output_path) + expect_pkg_error_snapshot( + .path_proj_save_as("output.md", overwrite = FALSE), + "file_exists", + transform = .transform_path(output_path) + ) +}) + +test_that(".check_path_writable() returns NULL invisibly when path does not exist (#noissue)", { + tmp <- withr::local_tempfile() + result <- withVisible(.check_path_writable(tmp, overwrite = FALSE)) + expect_null(result$value) + expect_false(result$visible) +}) + +test_that(".check_path_writable() deletes existing file when overwrite = TRUE (#noissue)", { + tmp <- withr::local_tempfile() + writeLines("content", tmp) + expect_true(fs::file_exists(tmp)) + .check_path_writable(tmp, overwrite = TRUE) + expect_false(fs::file_exists(tmp)) +}) + +test_that(".check_path_writable() errors when file exists and overwrite = FALSE (#noissue)", { + tmp <- withr::local_tempfile() + writeLines("content", tmp) + expect_pkg_error_snapshot( + .check_path_writable(tmp, overwrite = FALSE), + "file_exists", + transform = .transform_path(tmp) + ) +}) diff --git a/tests/testthat/test-utils-text.R b/tests/testthat/test-utils-text.R new file mode 100644 index 0000000..9989469 --- /dev/null +++ b/tests/testthat/test-utils-text.R @@ -0,0 +1,94 @@ +test_that(".find_pattern_idx() returns index of first match (#noissue)", { + lines <- c("apple", "banana", "cherry") + expect_identical(.find_pattern_idx(lines, "^ban"), 2L) +}) + +test_that(".find_pattern_idx() returns integer(0) when no match (#noissue)", { + lines <- c("apple", "banana", "cherry") + expect_identical(.find_pattern_idx(lines, "^mango"), integer(0)) +}) + +test_that(".find_pattern_idx() returns first index when multiple match (#noissue)", { + lines <- c("foo", "bar", "foo", "baz") + expect_identical(.find_pattern_idx(lines, "^foo"), 1L) +}) + +test_that(".find_pattern_idx() works with stringr::fixed() pattern (#noissue)", { + lines <- c("path/a.md", "path/b.md", "other") + expect_identical(.find_pattern_idx(lines, stringr::fixed("path/b.md")), 2L) +}) + +test_that(".find_table_last_row_idx() returns integer(0) when no table rows (#noissue)", { + lines <- c("# Heading", "", "Some prose.") + expect_identical(.find_table_last_row_idx(lines, 0L), integer(0)) +}) + +test_that(".find_table_last_row_idx() returns last contiguous table row (#noissue)", { + lines <- c("## Section", "| a | b |", "|---|---|", "| 1 | 2 |", "", "## Next") + expect_identical(.find_table_last_row_idx(lines, 1L), 4L) +}) + +test_that(".find_table_last_row_idx() stops after first non-table line (#noissue)", { + lines <- c("## Section", "| a |", "prose", "| b |") + expect_identical(.find_table_last_row_idx(lines, 1L), 2L) +}) + +test_that(".parse_yaml_front_matter() returns lines between --- delimiters (#noissue)", { + lines <- c("---", "name: test", "trigger: do thing", "---", "# Content") + result <- .parse_yaml_front_matter(lines, "test.md") + expect_identical(result, c("name: test", "trigger: do thing")) +}) + +test_that(".parse_yaml_front_matter() errors when no delimiters found (#noissue)", { + lines <- c("# No front matter", "Just content.") + expect_pkg_error_snapshot( + .parse_yaml_front_matter(lines, "test.md"), + "no_front_matter" + ) +}) + +test_that(".parse_yaml_front_matter() errors when only one delimiter found (#noissue)", { + lines <- c("---", "name: test", "# No closing delimiter") + expect_pkg_error_snapshot( + .parse_yaml_front_matter(lines, "test.md"), + "no_front_matter" + ) +}) + +test_that(".extract_yaml_scalar() returns trimmed value for existing field (#noissue)", { + front_matter <- c("name: my-skill", "trigger: do the thing") + result <- .extract_yaml_scalar(front_matter, "trigger", "test.md") + expect_identical(result, "do the thing") +}) + +test_that(".extract_yaml_scalar() trims leading whitespace from value (#noissue)", { + front_matter <- c("trigger: spaced value ") + result <- .extract_yaml_scalar(front_matter, "trigger", "test.md") + expect_identical(result, "spaced value") +}) + +test_that(".extract_yaml_scalar() errors when field not found (#noissue)", { + front_matter <- c("name: my-skill") + expect_pkg_error_snapshot( + .extract_yaml_scalar(front_matter, "trigger", "test.md"), + "no_trigger" + ) +}) + +test_that(".lines_insert_after() inserts after given index (#noissue)", { + lines <- c("a", "b", "c") + result <- .lines_insert_after(lines, 2L, c("x", "y")) + expect_identical(result, c("a", "b", "x", "y", "c")) +}) + +test_that(".lines_insert_after() inserts after last index (#noissue)", { + lines <- c("a", "b", "c") + result <- .lines_insert_after(lines, 3L, "z") + expect_identical(result, c("a", "b", "c", "z")) +}) + +test_that(".lines_insert_after() inserts before all lines when idx is 0 (#noissue)", { + lines <- c("a", "b", "c") + result <- .lines_insert_after(lines, 0L, "z") + expect_identical(result, c("z", "a", "b", "c")) +}) diff --git a/tests/testthat/test-utils.R b/tests/testthat/test-utils.R index 2219c01..3b654ec 100644 --- a/tests/testthat/test-utils.R +++ b/tests/testthat/test-utils.R @@ -1,4 +1,6 @@ -test_that(".to_string() tests for non-NULL character scalar (#noissue)", { - expect_identical(.to_string("hello"), "hello") - stbl::expect_pkg_error_classes(.to_string(NULL), "stbl", "bad_null") +test_that(".format_now_utc() returns a correctly formatted UTC timestamp", { + result <- .format_now_utc() + expect_type(result, "character") + expect_length(result, 1L) + expect_match(result, "^\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2} UTC$") }) From 0c8464e8bf010680cd760cda55f3f88d570bd2f9 Mon Sep 17 00:00:00 2001 From: Jon Harmon Date: Tue, 17 Mar 2026 14:19:24 -0500 Subject: [PATCH 6/7] Make tests happier on GitHub. --- R/use_skill_create_issue.R | 1 - tests/testthat/test-use_skill.R | 1 + tests/testthat/test-utils-path.R | 8 +++++++- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/R/use_skill_create_issue.R b/R/use_skill_create_issue.R index 47e9495..11b3f64 100644 --- a/R/use_skill_create_issue.R +++ b/R/use_skill_create_issue.R @@ -128,4 +128,3 @@ use_skill_create_issue <- function( ) types_result$data$repository$issueTypes$nodes } - diff --git a/tests/testthat/test-use_skill.R b/tests/testthat/test-use_skill.R index a020c23..ff1871e 100644 --- a/tests/testthat/test-use_skill.R +++ b/tests/testthat/test-use_skill.R @@ -220,6 +220,7 @@ test_that(".use_skill() errors when overwrite = FALSE and file exists (#6)", { existing_path <- fs::path(proj_dir, ".github/skills/create-issue/SKILL.md") fs::dir_create(fs::path_dir(existing_path)) writeLines("original content", existing_path) + existing_path <- fs::path_real(existing_path) expect_pkg_error_snapshot( { suppressMessages( diff --git a/tests/testthat/test-utils-path.R b/tests/testthat/test-utils-path.R index e9a0706..145427b 100644 --- a/tests/testthat/test-utils-path.R +++ b/tests/testthat/test-utils-path.R @@ -13,13 +13,19 @@ test_that(".path_template() returns a path under the templates directory (#noiss test_that(".path_proj_save_as() returns the project-relative path (#noissue)", { proj_dir <- local_pkg() result <- .path_proj_save_as("output.md", overwrite = TRUE) - expect_identical(result, fs::path(proj_dir, "output.md")) + # Write so path_real() works. + writeLines("sample", result) + expect_identical( + fs::path_real(result), + fs::path_real(fs::path(proj_dir, "output.md")) + ) }) test_that(".path_proj_save_as() errors when file exists and overwrite = FALSE (#noissue)", { proj_dir <- local_pkg() output_path <- fs::path(proj_dir, "output.md") writeLines("content", output_path) + output_path <- fs::path_real(output_path) expect_pkg_error_snapshot( .path_proj_save_as("output.md", overwrite = FALSE), "file_exists", From cf9e0f2ec8c97c1db51e456abf928fe43b3c5ad5 Mon Sep 17 00:00:00 2001 From: Jon Harmon Date: Tue, 17 Mar 2026 14:48:10 -0500 Subject: [PATCH 7/7] Implement suggestions from copilot code review. --- R/use_skill.R | 4 ++-- R/utils-text.R | 11 ++++++----- tests/testthat/test-utils-text.R | 6 ++++++ 3 files changed, 14 insertions(+), 7 deletions(-) diff --git a/R/use_skill.R b/R/use_skill.R index 726442a..b3badf2 100644 --- a/R/use_skill.R +++ b/R/use_skill.R @@ -62,7 +62,7 @@ ) { template_path <- .path_template(skill_path_relative) trigger <- .read_skill_trigger(template_path, call = call) - .upsert_agents_skill(trigger, save_as) + .upsert_agents_skill(trigger, save_as, call = call) } #' Read the trigger field from a skill template's YAML front matter @@ -121,7 +121,7 @@ ) { save_as <- .to_string(save_as, call = call) lines <- readLines(path, warn = FALSE) - new_row <- .make_skill_row(trigger, save_as) + new_row <- .make_skill_row(trigger, save_as, call = call) existing_idx <- .find_skill_row_idx(lines, save_as) if (length(existing_idx)) { diff --git a/R/utils-text.R b/R/utils-text.R index 558c6be..a88131a 100644 --- a/R/utils-text.R +++ b/R/utils-text.R @@ -18,7 +18,10 @@ #' once a gap is encountered. #' @keywords internal .is_first_run <- function(x) { - !cumsum(c(FALSE, diff(x) > 1L)) + if (length(x)) { + return(!cumsum(c(FALSE, diff(x) > 1L))) + } + integer() } #' Find the index of the last markdown table row after a given position @@ -35,9 +38,6 @@ .find_table_last_row_idx <- function(lines, from) { idx <- which(stringr::str_starts(lines, stringr::fixed("|"))) idx <- idx[idx > from] - if (!length(idx)) { - return(integer(0)) - } utils::tail(idx[.is_first_run(idx)], 1L) } @@ -57,7 +57,8 @@ call = call ) } - lines[(delim_idx[[1L]] + 1L):(delim_idx[[2L]] - 1L)] + front_matter_lines <- rlang::seq2(delim_idx[[1L]] + 1L, delim_idx[[2L]] - 1L) + lines[front_matter_lines] } #' Extract a scalar value from YAML front matter lines diff --git a/tests/testthat/test-utils-text.R b/tests/testthat/test-utils-text.R index 9989469..485afd8 100644 --- a/tests/testthat/test-utils-text.R +++ b/tests/testthat/test-utils-text.R @@ -47,6 +47,12 @@ test_that(".parse_yaml_front_matter() errors when no delimiters found (#noissue) ) }) +test_that(".parse_yaml_front_matter() returns character(0) for empty front matter (#noissue)", { + lines <- c("---", "---", "# Content") + result <- .parse_yaml_front_matter(lines, "test.md") + expect_identical(result, character(0)) +}) + test_that(".parse_yaml_front_matter() errors when only one delimiter found (#noissue)", { lines <- c("---", "name: test", "# No closing delimiter") expect_pkg_error_snapshot(