v0.4.0: Improve test coverage, fix bugs, and update CI#13
v0.4.0: Improve test coverage, fix bugs, and update CI#13VincentGuyader merged 22 commits intomasterfrom
Conversation
- Add comprehensive tests for workspaces, activity, backups, templates, uploads - Add HTTP error handling with httr2 req_error() - Fix glue() to character conversion in templates.R - Remove debug message from call_api() - Extract API URLs as package constants - Update GitHub Actions to modern versions (v2/v4) - Add skip_if_test_project_missing() test helper - Add NEWS.md changelog
There was a problem hiding this comment.
Pull request overview
This PR releases v0.4.0 by expanding module coverage (workspaces/activity/backups/templates/uploads), improving HTTP error handling via httr2, modernizing CI, and updating package metadata/changelog.
Changes:
- Add new API modules/functions (workspaces, activity, backups, templates, uploads, reminders, comments, extended user helpers) and expose them via NAMESPACE
- Improve HTTP request handling (constants for base URLs;
req_error()in core API helpers) - Update GitHub Actions workflow and add repository guidance/docs (NEWS, CLAUDE.md, build ignore updates)
Reviewed changes
Copilot reviewed 183 out of 330 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| R/utils.R | Adds `% |
| R/workspaces.R | Introduces workspace CRUD-like helpers via Sync API commands |
| R/users.R | Adds user info/productivity helpers and invitation/collaborator management |
| R/uploads.R | Adds upload + delete-upload helpers using REST endpoints |
| R/templates.R | Adds template import/export helpers and fixes glue() URL coercion |
| R/section.R | Adds section update/delete/move/archive/unarchive + REST section getters |
| R/reminders.R | Adds reminders CRUD via Sync API commands + listing reminders |
| R/comments.R | Adds comments CRUD + pagination-backed listing |
| R/activity.R | Adds activity log retrieval and normalization into a data.frame |
| NEWS.md | Adds v0.4.0 changelog entry |
| DESCRIPTION | Bumps version to 0.4.0 and adjusts metadata/dependencies |
| NAMESPACE | Exports new functions and imports additional httr2 helpers |
| .github/workflows/R-CMD-check.yaml | Migrates to r-lib/actions v2 and simplifies matrix/dependency setup |
| .Rbuildignore | Ignores Claude/plan files and check artifacts |
| CLAUDE.md | Adds contributor/dev workflow & architecture notes |
| check/rtodoist.Rcheck/** | Adds R CMD check output artifacts (installed tree, man pages, test snapshots, etc.) |
Comments suppressed due to low confidence (4)
R/reminders.R:1
- JSON for Sync API commands is being manually assembled with interpolated user-controlled strings (
task_id,type,due_*). If any of these contain quotes/backslashes/newlines, the resulting JSON becomes invalid (or can be interpreted differently than intended). Suggested fix: either wrap all interpolated string values withescape_json()or (preferably) build an R list forargsand serialize it with a JSON encoder (e.g., jsonlite) to avoid injection/escaping issues.
R/workspaces.R:1 - The Sync API
commandsJSON is assembled viaglue()with rawworkspace_id,email, androlevalues. These should be escaped to keep JSON valid and prevent malformed requests when values contain special characters. Suggested fix: applyescape_json()to each interpolated string field (or switch to building a list and JSON-encoding it).
R/uploads.R:1 - This introduces a hard-coded REST base URL even though
TODOIST_REST_URLwas added inR/utils.R. For consistency (and to avoid future drift if the API base changes), use the shared constants across all modules (uploads/comments/templates/etc.).
R/comments.R:1 - Core helpers (
call_api*) now includereq_error()for HTTP error handling, but this direct REST call path doesn’t. As a result, 4xx/5xx responses may be parsed as JSON (or fail later) without a clear error. Suggested fix: addreq_error(...)(consistent withcall_api_rest) beforereq_perform()or refactor to reusecall_api_rest()for REST endpoints to centralize error behavior.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
- Remove check/ directory (R CMD check artifact) and add to .gitignore - Fix tryCatch() syntax in ask_todoist_api_token (interrupt = function(e) NULL) - Add escape_json() for JSON string interpolation in reminders.R and workspaces.R - Use TODOIST_REST_URL constant instead of hardcoded URLs in uploads.R and comments.R - Add req_error() for proper HTTP error handling in comments.R - Fix DESCRIPTION comment field formatting (named elements)
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 115 out of 116 changed files in this pull request and generated 21 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- Add req_error() for HTTP error handling in: - templates.R (import_template, export_template) - labels.R (rename_shared_label, remove_shared_label) - uploads.R (delete_upload) - backups.R (download_backup) - Use TODOIST_REST_URL constant instead of hardcoded URLs in: - templates.R - labels.R (shared label functions) - Fix empty data.frame column consistency: - comments.R: add task_id and project_id columns - reminders.R: add due_date and minute_offset columns - Remove unused project_name parameter from unarchive_project() - Update NEWS.md to document all changes
- Use TODOIST_REST_URL constant in quick_add_task() instead of hardcoded URL - Add req_error() to quick_add_task() and upload_file() for HTTP error handling - Fix empty data.frame column consistency in: - get_activity_logs(): add initiator_id, parent_project_id, parent_item_id - get_tasks_by_filter(): add due_date - get_archived_projects(): add color, is_favorite - get_all_sections(): add order - get_all_workspaces(): add is_default - get_workspace_users(): add role
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 116 out of 117 changed files in this pull request and generated 9 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
R/workspaces.R
Outdated
| call_api( | ||
| token = token, | ||
| sync_token = "*", | ||
| commands = glue('[{{"type": "workspace_update", "uuid": "{random_key()}", "args": {{"id": "{workspace_id}", "name": "{escape_json(name)}"}}}}]') |
There was a problem hiding this comment.
update_workspace() builds the Sync API JSON command by interpolating workspace_id directly into the JSON string. If workspace_id contains quotes/backslashes, this will produce invalid JSON (and is inconsistent with invite_to_workspace() which uses escape_json() for IDs). Use escape_json(workspace_id) when constructing the command args (and similarly for other unescaped string fields).
| commands = glue('[{{"type": "workspace_update", "uuid": "{random_key()}", "args": {{"id": "{workspace_id}", "name": "{escape_json(name)}"}}}}]') | |
| commands = glue('[{{"type": "workspace_update", "uuid": "{random_key()}", "args": {{"id": "{escape_json(workspace_id)}", "name": "{escape_json(name)}"}}}}]') |
| call_api( | ||
| token = token, | ||
| sync_token = "*", | ||
| commands = glue('[{{"type": "item_close", "uuid": "{random_key()}", "args": {{"id": "{task_id}"}}}}]') | ||
| ) |
There was a problem hiding this comment.
close_task() interpolates task_id directly into a JSON string for the Sync API command args. For robustness (and to avoid malformed JSON if task_id contains escapable characters), pass it through escape_json() or build the command payload with a JSON encoder.
There was a problem hiding this comment.
@copilot open a new pull request to apply changes based on this feedback
| call_api( | ||
| token = token, | ||
| sync_token = "*", | ||
| commands = glue('[{{"type": "item_uncomplete", "uuid": "{random_key()}", "args": {{"id": "{task_id}"}}}}]') | ||
| ) |
There was a problem hiding this comment.
reopen_task() interpolates task_id directly into the JSON command string. Use escape_json(task_id) (or JSON encoding) to ensure the Sync API payload remains valid for all inputs.
There was a problem hiding this comment.
@copilot open a new pull request to apply changes based on this feedback
R/users.R
Outdated
| call_api( | ||
| token = token, | ||
| sync_token = "*", | ||
| commands = glue('[{{"type": "collaborator_delete", "uuid": "{random_key()}", "args": {{"project_id": "{project_id}", "email": "{email}"}}}}]') |
There was a problem hiding this comment.
delete_collaborator() interpolates email (and project_id) directly into the JSON command string without escaping. Since email is user-provided, this can break JSON and lead to malformed Sync API requests. Use escape_json() for these string fields (and consider encoding the whole args list to JSON rather than manual string assembly).
| call_api( | |
| token = token, | |
| sync_token = "*", | |
| commands = glue('[{{"type": "collaborator_delete", "uuid": "{random_key()}", "args": {{"project_id": "{project_id}", "email": "{email}"}}}}]') | |
| commands_list <- list(list( | |
| type = "collaborator_delete", | |
| uuid = random_key(), | |
| args = list( | |
| project_id = project_id, | |
| email = email | |
| ) | |
| )) | |
| call_api( | |
| token = token, | |
| sync_token = "*", | |
| commands = jsonlite::toJSON(commands_list, auto_unbox = TRUE) |
| call_api( | ||
| token = token, | ||
| sync_token = "*", | ||
| commands = glue('[{{"type": "delete_invitation", "uuid": "{random_key()}", "args": {{"invitation_id": "{invitation_id}"}}}}]') | ||
| ) |
There was a problem hiding this comment.
delete_invitation() interpolates invitation_id directly into the JSON command string without escaping. Use escape_json(invitation_id) (or JSON encoding) to ensure the payload is always valid.
| call_api( | ||
| token = token, | ||
| sync_token = "*", | ||
| commands = glue('[{{"type": "item_delete", "uuid": "{random_key()}", "args": {{"id": "{task_id}"}}}}]') | ||
| ) |
There was a problem hiding this comment.
delete_task() interpolates task_id directly into a JSON string ("id": "{task_id}"). If task_id contains characters that need escaping, the generated JSON will be invalid. Use escape_json(task_id) (or a safer JSON builder) when constructing the Sync API command args.
There was a problem hiding this comment.
@copilot open a new pull request to apply changes based on this feedback
|
@copilot open a new pull request to apply changes based on the comments in this thread |
|
@VincentGuyader I've opened a new pull request, #14, to work on those changes. Once the pull request is ready, I'll request review from you. |
|
@VincentGuyader I've opened a new pull request, #15, to work on those changes. Once the pull request is ready, I'll request review from you. |
* Fix JSON escaping in Sync API commands across all modules
…ew (#14) - Fix call_api() incorrect parameter (base_url -> url) - Add verbose flag to add_label() existing label message - Add escape_json() to all Sync API string interpolations
Apply escape_json() to all user-controlled values in JSON payloads: - workspaces.R: escape workspace_id in update_workspace() - tasks.R: - escape task_id in delete_task(), close_task(), reopen_task() - escape task_id and due_date in update_task() - escape labels array values in update_task() - users.R: - escape project_id and email in delete_collaborator() - escape invitation_id and invitation_secret in accept_invitation() - escape invitation_id and invitation_secret in reject_invitation() - escape invitation_id in delete_invitation() This prevents malformed JSON payloads when inputs contain quotes, backslashes, or other special characters.
|
@VincentGuyader I've opened a new pull request, #16, to work on those changes. Once the pull request is ready, I'll request review from you. |
|
@VincentGuyader I've opened a new pull request, #17, to work on those changes. Once the pull request is ready, I'll request review from you. |
|
@VincentGuyader I've opened a new pull request, #18, to work on those changes. Once the pull request is ready, I'll request review from you. |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 116 out of 117 changed files in this pull request and generated 13 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
R/tasks.R
Outdated
| args_parts <- c(glue('"id": "{task_id}"')) | ||
|
|
||
| if (!is.null(project_id)) { | ||
| args_parts <- c(args_parts, glue('"project_id": "{project_id}"')) | ||
| } | ||
| if (!is.null(section_id)) { | ||
| args_parts <- c(args_parts, glue('"section_id": "{section_id}"')) | ||
| } | ||
| if (!is.null(parent_id)) { | ||
| args_parts <- c(args_parts, glue('"parent_id": "{parent_id}"')) |
There was a problem hiding this comment.
move_task() builds the Sync API command JSON by interpolating task_id, project_id, section_id, and parent_id without escape_json(). This is inconsistent with other commands in the package (e.g., item_close) and can produce invalid JSON if any of these values contain characters that must be escaped. Use escape_json() for all interpolated string values (including IDs) when constructing args_json (or build the JSON via a proper serializer).
| args_parts <- c(glue('"id": "{task_id}"')) | |
| if (!is.null(project_id)) { | |
| args_parts <- c(args_parts, glue('"project_id": "{project_id}"')) | |
| } | |
| if (!is.null(section_id)) { | |
| args_parts <- c(args_parts, glue('"section_id": "{section_id}"')) | |
| } | |
| if (!is.null(parent_id)) { | |
| args_parts <- c(args_parts, glue('"parent_id": "{parent_id}"')) | |
| args_parts <- c(glue('"id": "{escape_json(task_id)}"')) | |
| if (!is.null(project_id)) { | |
| args_parts <- c(args_parts, glue('"project_id": "{escape_json(project_id)}"')) | |
| } | |
| if (!is.null(section_id)) { | |
| args_parts <- c(args_parts, glue('"section_id": "{escape_json(section_id)}"')) | |
| } | |
| if (!is.null(parent_id)) { | |
| args_parts <- c(args_parts, glue('"parent_id": "{escape_json(parent_id)}"')) |
| call_api( | ||
| token = token, | ||
| sync_token = "*", | ||
| commands = glue('[{{"type": "section_update", "uuid": "{random_key()}", "args": {{"id": "{section_id}", "name": "{escape_json(new_name)}"}}}}]') |
There was a problem hiding this comment.
update_section() interpolates section_id directly into the JSON command without escape_json(). This can break the JSON payload for IDs containing escapable characters and is inconsistent with how other functions escape IDs (e.g., update_task). Escape section_id before interpolating it into the command.
| commands = glue('[{{"type": "section_update", "uuid": "{random_key()}", "args": {{"id": "{section_id}", "name": "{escape_json(new_name)}"}}}}]') | |
| commands = glue('[{{"type": "section_update", "uuid": "{random_key()}", "args": {{"id": "{escape_json(section_id)}", "name": "{escape_json(new_name)}"}}}}]') |
R/section.R
Outdated
| call_api( | ||
| token = token, | ||
| sync_token = "*", | ||
| commands = glue('[{{"type": "section_move", "uuid": "{random_key()}", "args": {{"id": "{section_id}", "project_id": "{project_id}"}}}}]') |
There was a problem hiding this comment.
move_section() interpolates both section_id and project_id directly into the Sync API JSON command. These values should be passed through escape_json() to prevent malformed JSON and to match the escaping approach used elsewhere in the package.
| call_api( | |
| token = token, | |
| sync_token = "*", | |
| commands = glue('[{{"type": "section_move", "uuid": "{random_key()}", "args": {{"id": "{section_id}", "project_id": "{project_id}"}}}}]') | |
| escaped_section_id <- escape_json(section_id) | |
| escaped_project_id <- escape_json(project_id) | |
| call_api( | |
| token = token, | |
| sync_token = "*", | |
| commands = glue('[{{"type": "section_move", "uuid": "{random_key()}", "args": {{"id": "{escaped_section_id}", "project_id": "{escaped_project_id}"}}}}]') |
R/projects.R
Outdated
| call_api( | ||
| token = token, | ||
| sync_token = "*", | ||
| commands = glue('[{{"type": "project_delete", "uuid": "{random_key()}", "args": {{"id": "{project_id}"}}}}]') |
There was a problem hiding this comment.
delete_project() interpolates project_id into the Sync API JSON command without escape_json(), which can lead to malformed JSON if the ID ever contains escapable characters. Escape project_id here (and similarly in the archive/unarchive commands below) for consistency.
| commands = glue('[{{"type": "project_delete", "uuid": "{random_key()}", "args": {{"id": "{project_id}"}}}}]') | |
| commands = glue('[{{"type": "project_delete", "uuid": "{random_key()}", "args": {{"id": "{escape_json(project_id)}"}}}}]') |
| call_api( | ||
| token = token, | ||
| sync_token = "*", | ||
| commands = glue('[{{"type": "project_archive", "uuid": "{random_key()}", "args": {{"id": "{project_id}"}}}}]') | ||
| ) |
There was a problem hiding this comment.
archive_project() inserts project_id into the JSON command without escape_json(). Use escape_json(project_id) to avoid malformed JSON and keep escaping consistent across Sync API commands.
There was a problem hiding this comment.
@copilot open a new pull request to apply changes based on this feedback
| args_parts <- c(glue('"id": "{project_id}"')) | ||
|
|
||
| if (!is.null(new_name)) { | ||
| args_parts <- c(args_parts, glue('"name": "{escape_json(new_name)}"')) | ||
| } |
There was a problem hiding this comment.
update_project() builds a JSON fragment with project_id inserted without escape_json(). While IDs are usually simple strings, escaping here keeps the JSON generation robust and consistent with other command builders in this PR.
R/projects.R
Outdated
| call_api( | ||
| token = token, | ||
| sync_token = "*", | ||
| commands = glue('[{{"type": "project_unarchive", "uuid": "{random_key()}", "args": {{"id": "{project_id}"}}}}]') |
There was a problem hiding this comment.
unarchive_project() interpolates project_id directly into the JSON command. Escape it with escape_json() (as done for other IDs in this PR) to keep command JSON robust to special characters.
| commands = glue('[{{"type": "project_unarchive", "uuid": "{random_key()}", "args": {{"id": "{project_id}"}}}}]') | |
| commands = glue('[{{"type": "project_unarchive", "uuid": "{random_key()}", "args": {{"id": "{escape_json(project_id)}"}}}}]') |
| # Null coalescing operator | ||
| `%||%` <- function(a, b) if (is.null(a)) b else a | ||
|
|
There was a problem hiding this comment.
R/utils.R defines a local %||% operator while the package already imports purrr, which exports an equivalent %||%. Keeping a separate implementation with the same name increases ambiguity (masking) without adding functionality. Consider removing this definition and relying on purrr::%||%`` (or, if you want to avoid purrr here, stop importing purrr wholesale and importFrom() only what’s needed).
| # Null coalescing operator | |
| `%||%` <- function(a, b) if (is.null(a)) b else a |
| args_parts <- c(glue('"id": "{filter_id}"')) | ||
|
|
||
| if (!is.null(new_name)) { | ||
| args_parts <- c(args_parts, glue('"name": "{escape_json(new_name)}"')) | ||
| } |
There was a problem hiding this comment.
update_filter() inserts filter_id into the Sync API command JSON without escape_json(). For consistent/robust JSON generation (and to prevent malformed payloads if IDs ever contain escapable characters), escape filter_id before interpolating it.
| args_parts <- c(glue('"id": "{label_id}"')) | ||
|
|
||
| if (!is.null(new_name)) { | ||
| args_parts <- c(args_parts, glue('"name": "{escape_json(new_name)}"')) | ||
| } |
There was a problem hiding this comment.
update_label() interpolates label_id into the Sync API command JSON without escape_json(). Escape label_id (and other string fields) to avoid malformed JSON and to match the escaping approach used elsewhere (e.g., update_task).
|
@copilot open a new pull request to apply changes based on the comments in this thread |
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
|
@VincentGuyader I've opened a new pull request, #20, to work on those changes. Once the pull request is ready, I'll request review from you. |
|
@copilot open a new pull request to apply changes based on the comments in this thread |
|
@VincentGuyader I've opened a new pull request, #21, to work on those changes. Once the pull request is ready, I'll request review from you. |
|
@copilot open a new pull request to apply changes based on the comments in this thread |
|
@VincentGuyader I've opened a new pull request, #23, to work on those changes. Once the pull request is ready, I'll request review from you. |
* Add escape_json() to all Sync API commands with ID parameters
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 116 out of 117 changed files in this pull request and generated 4 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
R/tasks.R
Outdated
| if (!is.null(project_id)) { | ||
| args_parts <- c(args_parts, glue('"project_id": "{project_id}"')) | ||
| } | ||
| if (!is.null(section_id)) { | ||
| args_parts <- c(args_parts, glue('"section_id": "{section_id}"')) |
There was a problem hiding this comment.
move_task() can be called with project_id, section_id, and parent_id all left as NULL, but it will still send an item_move command (with only an id arg). That request is likely invalid and will fail at runtime. Add input validation to require at least one destination argument be provided.
tests/testthat/test-workspaces.R
Outdated
| result <- data.frame( | ||
| id = character(), | ||
| name = character(), | ||
| stringsAsFactors = FALSE | ||
| ) |
There was a problem hiding this comment.
The “empty workspaces returns empty dataframe” test builds an empty result without the is_default column, but get_all_workspaces() returns id, name, and is_default even when empty. Update the test to include/assert is_default so it matches the function behavior.
tests/testthat/test-activity.R
Outdated
| result <- data.frame( | ||
| id = character(), | ||
| object_type = character(), | ||
| object_id = character(), | ||
| event_type = character(), |
There was a problem hiding this comment.
The “empty activity logs returns empty dataframe” test constructs an empty result without initiator_id/parent_project_id/parent_item_id, but get_activity_logs() includes these columns in its empty return. Update the expected empty structure so the test matches the function’s output.
R/templates.R
Outdated
| request(as.character(glue("{TODOIST_REST_URL}templates/import_into_project?project_id={project_id}"))) %>% | ||
| req_headers( | ||
| Authorization = glue::glue("Bearer {token}") | ||
| ) %>% | ||
| req_body_multipart(file = curl::form_file(file_path)) %>% | ||
| req_error(is_error = function(resp) resp_status(resp) >= 400) %>% |
There was a problem hiding this comment.
import_template() manually concatenates the query string into the URL. Using req_url_query(project_id = project_id) (and a base URL without the query) would ensure proper URL-encoding and is more robust than string interpolation, especially if IDs are not strictly numeric.
|
@copilot open a new pull request to apply changes based on the comments in this thread |
|
@VincentGuyader I've opened a new pull request, #24, to work on those changes. Once the pull request is ready, I'll request review from you. |
…oding (#24) * Address PR review feedback: validation, test accuracy, and URL handling
- Add @return tags to add_section() and get_section_id() - Remove unused httr dependency (replaced by httr2) - Remove unused lubridate from Suggests - Update LICENSE file format (2019-2026, ThinkR) - Remove dead mock_response() code and rename test file - Add REVIEW.md with pre-submission package audit Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Escape IDs in move_task() with escape_json() to prevent JSON injection - Add token validation in call_api() and call_api_rest() - Replace print() with message() in users.R for CRAN compliance Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Document all 64 new exported functions across 9 new modules: - Labels, Comments, Filters, Reminders, Workspaces - Activity logs, Backups, Templates, Uploads Plus enhancements to Projects, Sections, Tasks, and Collaboration. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Replace lubridate::today() and days() with base R Sys.Date() and arithmetic operations for consistency with removed Suggests dependency. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Summary
req_error()glue()to character conversion intemplates.Rcall_api()skip_if_test_project_missing()test helperTest plan