Skip to content

Commit c6072b4

Browse files
authored
Download snapshots from GitHub (#2206)
Don't see any obvious way to test this, but I tried it out a bunch interactively. Fixes #1779
1 parent a0fb6cd commit c6072b4

File tree

12 files changed

+248
-10
lines changed

12 files changed

+248
-10
lines changed

.github/workflows/claude-code-review.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ name: Claude Code Review
22

33
on:
44
pull_request:
5-
types: [opened, synchronize]
5+
types: [opened]
66
# Optional: Only run on specific file changes
77
# paths:
88
# - "src/**/*.ts"
@@ -48,7 +48,7 @@ jobs:
4848
to add, just reply LGTM.
4949
5050
# Optional: Use sticky comments to make Claude reuse the same comment on subsequent pushes to the same PR
51-
use_sticky_comment: true
51+
# use_sticky_comment: true
5252

5353
# Optional: Add specific tools for running tests or linting
5454
# allowed_tools: "Bash(npm run test),Bash(npm run lint),Bash(npm run typecheck)"

DESCRIPTION

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ Suggests:
4040
curl (>= 0.9.5),
4141
diffviewer (>= 0.1.0),
4242
digest (>= 0.6.33),
43+
gh,
4344
knitr,
4445
rmarkdown,
4546
rstudioapi,

NAMESPACE

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,7 @@ export(skip_on_os)
195195
export(skip_on_travis)
196196
export(skip_unless_r)
197197
export(snapshot_accept)
198+
export(snapshot_download_gh)
198199
export(snapshot_reject)
199200
export(snapshot_review)
200201
export(source_dir)

NEWS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
# testthat (development version)
22

3+
* New `snapshot_download_gh()` makes it easy to get snapshots off GitHub and into your local package (#1779).
34
* New `local_mocked_s3_method()`, `local_mocked_s4_method()`, and `local_mocked_r6_class()` allow you to mock S3 and S4 methods and R6 classes (#1892, #1916)
45
* `expect_snapshot_file(name=)` must have a unique file path. If a snapshot file attempts to be saved with a duplicate `name`, an error will be thrown. (#1592)
56
* `test_dir()`, `test_file()`, `test_package()`, `test_check()`, `test_local()`, `source_file()` gain a `shuffle` argument uses `sample()` to randomly reorder the top-level expressions in each test file (#1942). This random reordering surfaces dependencies between tests and code outside of any test, as well as dependencies between tests. This helps you find and eliminate unintentional dependencies.

R/snapshot-file.R

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -180,14 +180,28 @@ snapshot_review_hint <- function(
180180

181181
path <- paste0("tests/testthat/_snaps/", test, "/", new_name(name))
182182

183+
if (check) {
184+
if (on_gh()) {
185+
bullets <- snap_download_hint()
186+
} else {
187+
bullets <- c(
188+
if (ci) "* Download and unzip run artifact\n",
189+
if (!ci) "* Locate check directory\n",
190+
paste0("* Copy '", path, "' to local test directory\n")
191+
)
192+
}
193+
} else {
194+
bullets <- NULL
195+
}
196+
183197
paste0(
184-
if (check && ci) "* Download and unzip run artifact\n",
185-
if (check && !ci) "* Locate check directory\n",
186-
if (check) paste0("* Copy '", path, "' to local test directory\n"),
187-
if (check) "* ",
188-
cli::format_inline(
189-
"Run {.run testthat::snapshot_review('{test}/')} to review changes"
190-
)
198+
c(
199+
bullets,
200+
cli::format_inline(
201+
"* Run {.run testthat::snapshot_review('{test}/')} to review changes"
202+
)
203+
),
204+
collapse = ""
191205
)
192206
}
193207

R/snapshot-github.R

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
#' Download snapshots from GitHub
2+
#'
3+
#' @description
4+
#' If your snapshots fail on GitHub, it can be a pain to figure out exactly
5+
#' why, or to incorporate them into your local package. This function makes it
6+
#' easy, only requiring you to interactively select which job you want to
7+
#' take the artifacts from.
8+
#'
9+
#' Note that you should not generally need to use this function manually;
10+
#' instead copy and paste from the hint emitted on GitHub.
11+
#'
12+
#' @param repository Repository owner/name, e.g. `"r-lib/testthat"`.
13+
#' @param run_id Run ID, e.g. `"47905180716"`. You can find this in the action url.
14+
#' @param dest_dir Directory to download to. Defaults to the current directory.
15+
#' @export
16+
snapshot_download_gh <- function(repository, run_id, dest_dir = ".") {
17+
check_string(repository)
18+
check_string(run_id)
19+
check_string(dest_dir)
20+
21+
check_installed("gh")
22+
23+
dest_snaps <- file.path(dest_dir, "tests", "testthat", "_snaps")
24+
if (!dir.exists(dest_snaps)) {
25+
cli::cli_abort("No snapshot directory found in {.file {dest_dir}}.")
26+
}
27+
28+
job_id <- gh_find_job(repository, run_id)
29+
artifact_id <- gh_find_artifact(repository, job_id)
30+
31+
path <- withr::local_tempfile(pattern = "gh-snaps-")
32+
gh_download_artifact(repository, artifact_id, path)
33+
34+
files <- dir(path, full.names = TRUE)
35+
if (length(files) != 1) {
36+
cli::cli_abort("Unexpected artifact format.")
37+
}
38+
inner_dir <- files[[1]]
39+
src_snaps <- file.path(inner_dir, "tests", "testthat", "_snaps")
40+
dir_copy(src_snaps, dest_snaps)
41+
}
42+
43+
snap_download_hint <- function() {
44+
repository <- Sys.getenv("GITHUB_REPOSITORY")
45+
run_id <- Sys.getenv("GITHUB_RUN_ID")
46+
47+
sprintf(
48+
"* Call `snapshot_download_gh(\"%s\", \"%s\")` to download the snapshots from GitHub.\n",
49+
repository,
50+
run_id
51+
)
52+
}
53+
54+
gh_find_job <- function(repository, run_id) {
55+
jobs_json <- gh::gh(
56+
"/repos/{repository}/actions/runs/{run_id}/jobs",
57+
repository = repository,
58+
run_id = run_id
59+
)
60+
jobs <- data.frame(
61+
id = map_dbl(jobs_json$jobs, \(x) x$id),
62+
name = map_chr(jobs_json$jobs, \(x) x$name)
63+
)
64+
jobs <- jobs[order(jobs$name), ]
65+
66+
idx <- utils::menu(jobs$name, title = "Which job?")
67+
if (idx == 0) {
68+
cli::cli_abort("Selection cancelled.")
69+
}
70+
jobs$id[[idx]]
71+
}
72+
73+
gh_find_artifact <- function(repository, job_id) {
74+
job_logs <- gh::gh(
75+
"GET /repos/{repository}/actions/jobs/{job_id}/logs",
76+
repository = repository,
77+
job_id = job_id,
78+
.send_headers = c("Accept" = "application/vnd.github.v3+json")
79+
)
80+
81+
log_lines <- strsplit(job_logs$message, "\r?\n")[[1]]
82+
matches <- re_match(log_lines, "Artifact download URL: (?<artifact_url>.*)")
83+
matches <- matches[!is.na(matches$artifact_url), ]
84+
if (nrow(matches) == 0) {
85+
cli::cli_abort("Failed to find artifact.")
86+
}
87+
88+
# Take last artifact URL; if the job has failed the previous artifact will
89+
# be the R CMD check logs
90+
artifact_url <- matches$artifact_url[nrow(matches)]
91+
basename(artifact_url)
92+
}
93+
94+
gh_download_artifact <- function(repository, artifact_id, path) {
95+
zip_path <- withr::local_tempfile(pattern = "gh-zip-")
96+
gh::gh(
97+
"/repos/{repository}/actions/artifacts/{artifact_id}/{archive_format}",
98+
repository = repository,
99+
artifact_id = artifact_id,
100+
archive_format = "zip",
101+
.destfile = zip_path
102+
)
103+
utils::unzip(zip_path, exdir = path)
104+
invisible(path)
105+
}
106+
107+
# Directory helpers ------------------------------------------------------------
108+
109+
dir_create <- function(paths) {
110+
for (path in paths) {
111+
dir.create(path, recursive = TRUE, showWarnings = FALSE)
112+
}
113+
invisible(paths)
114+
}
115+
116+
dir_copy <- function(src_dir, dst_dir) {
117+
# First create directories
118+
dirs <- list.dirs(src_dir, recursive = TRUE, full.names = FALSE)
119+
dir_create(file.path(dst_dir, dirs))
120+
121+
# Then copy files
122+
files <- dir(src_dir, recursive = TRUE)
123+
src_files <- file.path(src_dir, files)
124+
dst_files <- file.path(dst_dir, files)
125+
same <- map_lgl(seq_along(files), \(i) {
126+
same_file(src_files[[i]], dst_files[[i]])
127+
})
128+
129+
n_new <- sum(!same)
130+
if (n_new == 0) {
131+
cli::cli_inform(c(i = "No new snapshots."))
132+
} else {
133+
cli::cli_inform(c(
134+
v = "Copying {n_new} new snapshots: {.file {files[!same]}}."
135+
))
136+
}
137+
138+
file.copy(src_files[!same], dst_files[!same], overwrite = TRUE)
139+
invisible()
140+
}
141+
142+
same_file <- function(x, y) {
143+
file.exists(x) && file.exists(y) && hash_file(x) == hash_file(y)
144+
}
145+
146+
on_gh <- function() {
147+
Sys.getenv("GITHUB_ACTIONS") == "true"
148+
}

R/snapshot.R

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -368,6 +368,7 @@ snapshot_accept_hint <- function(variant, file, reset_output = TRUE) {
368368
}
369369

370370
paste0(
371+
if (on_gh()) snap_download_hint(),
371372
cli::format_inline(
372373
"* Run {.run testthat::snapshot_accept('{name}')} to accept the change."
373374
),

R/utils.R

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,3 +67,28 @@ no_wrap <- function(x) {
6767
paste_c <- function(...) {
6868
paste0(c(...), collapse = "")
6969
}
70+
71+
# from rematch2
72+
re_match <- function(text, pattern, perl = TRUE, ...) {
73+
stopifnot(is.character(pattern), length(pattern) == 1, !is.na(pattern))
74+
text <- as.character(text)
75+
match <- regexpr(pattern, text, perl = perl, ...)
76+
start <- as.vector(match)
77+
length <- attr(match, "match.length")
78+
end <- start + length - 1L
79+
matchstr <- substring(text, start, end)
80+
matchstr[start == -1] <- NA_character_
81+
res <- data.frame(stringsAsFactors = FALSE, .text = text, .match = matchstr)
82+
if (!is.null(attr(match, "capture.start"))) {
83+
gstart <- attr(match, "capture.start")
84+
glength <- attr(match, "capture.length")
85+
gend <- gstart + glength - 1L
86+
groupstr <- substring(text, gstart, gend)
87+
groupstr[gstart == -1] <- NA_character_
88+
dim(groupstr) <- dim(gstart)
89+
res <- cbind(groupstr, res, stringsAsFactors = FALSE)
90+
}
91+
names(res) <- c(attr(match, "capture.names"), ".text", ".match")
92+
class(res) <- c("tbl_df", "tbl", class(res))
93+
res
94+
}

man/snapshot_download_gh.Rd

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

tests/testthat/_snaps/snapshot-file.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
Code
2828
cat(snapshot_review_hint("lala", "foo.r", check = FALSE, ci = FALSE))
2929
Output
30-
Run `testthat::snapshot_review('lala/')` to review changes
30+
* Run `testthat::snapshot_review('lala/')` to review changes
3131

3232
---
3333

@@ -47,6 +47,14 @@
4747
* Copy 'tests/testthat/_snaps/lala/foo.new.r' to local test directory
4848
* Run `testthat::snapshot_review('lala/')` to review changes
4949

50+
---
51+
52+
Code
53+
cat(snapshot_review_hint("lala", "foo.r", check = TRUE, ci = TRUE))
54+
Output
55+
* Call `snapshot_download_gh("r-lib/testthat", "123")` to download the snapshots from GitHub.
56+
* Run `testthat::snapshot_review('lala/')` to review changes
57+
5058
# expect_snapshot_file validates its inputs
5159

5260
Code

0 commit comments

Comments
 (0)