feat(docs): add +revisions helper to list document revision history#563
feat(docs): add +revisions helper to list document revision history#563khali wants to merge 6 commits intogoogleworkspace:mainfrom
Conversation
Adds a new `gws docs +revisions` helper command that lists revision metadata for a Google Docs document by calling the Drive v3 revisions API directly (drive.readonly scope). Flags: --document (required) Document ID --limit (optional, default 20) Number of revisions to return Each revision entry includes: id, modifiedTime, lastModifyingUser, keepForever, and size. A clear note documents the Google API limitation: content of past revisions is not accessible for native Docs files. Also adds the gws-docs-revisions SKILL.md and updates gws-docs SKILL.md to reference the new helper.
|
Thanks for your pull request! It looks like this may be your first contribution to a Google open source project. Before we can look at your pull request, you'll need to sign a Contributor License Agreement (CLA). View this failed invocation of the CLA check for more information. For the most up to date status, view the checks section at the bottom of the pull request. |
🦋 Changeset detectedLatest commit: 61c5390 The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
Summary of ChangesHello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed! This pull request enhances the Google Workspace CLI by adding a new helper command for Google Docs. The primary goal is to provide users with the ability to quickly retrieve the revision history metadata of any Google Docs document directly from the command line. This improves the utility of the Highlights
Using Gemini Code AssistThe full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips. Invoking Gemini You can request assistance from Gemini at any point by creating a comment using either
Customization To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a Limitations & Feedback Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here. Footnotes
|
src/helpers/docs.rs
Outdated
| let scope = "https://www.googleapis.com/auth/drive.readonly"; | ||
| let token = auth::get_token(&[scope]) | ||
| .await | ||
| .map_err(|e| GwsError::Auth(format!("Docs auth failed: {e}")))?; |
There was a problem hiding this comment.
The error string Docs auth failed: {e} is printed to the terminal without sanitization. This could allow escape sequence injection if the underlying error e contains malicious escape sequences. Sanitize the error string before printing it to the terminal.
| .map_err(|e| GwsError::Auth(format!("Docs auth failed: {e}")))?; | |
| .map_err(|e| GwsError::Auth(format!("Docs auth failed: {}", crate::output::sanitize_for_terminal(&e.to_string())))? |
References
- Sanitize error strings printed to the terminal to prevent escape sequence injection.
|
/gemini review |
There was a problem hiding this comment.
Code Review
This pull request introduces a new gws docs +revisions helper command to list revision history for a Google Docs document. The implementation correctly uses reqwest to call the Google Drive API directly and includes new documentation and tests. My review focuses on improving input validation and code maintainability. I've identified a missing validation for the --limit parameter and suggested refactoring a hardcoded string into a constant to improve readability and prevent potential errors.
|
|
||
| async fn handle_revisions(matches: &ArgMatches) -> Result<(), GwsError> { | ||
| let document_id = matches.get_one::<String>("document").unwrap(); | ||
| let limit = matches.get_one::<u32>("limit").copied().unwrap_or(20); |
There was a problem hiding this comment.
The limit parameter is not validated to be within the range of 1 to 1000, which is what the Google Drive API expects for pageSize. The command's own documentation in skills/gws-docs-revisions/SKILL.md also specifies this range. Providing a value outside this range (e.g., 0 or 1001) will result in an API error. Please add validation to ensure the limit is within the accepted range.
let limit = matches.get_one::<u32>("limit").copied().unwrap_or(20);
if !(1..=1000).contains(&limit) {
return Err(GwsError::Validation(
"Invalid limit: value must be between 1 and 1000.".to_string(),
));
}
src/helpers/docs.rs
Outdated
| .query(&[ | ||
| ( | ||
| "fields", | ||
| "revisions(id,modifiedTime,lastModifyingUser/displayName,keepForever,size)", |
There was a problem hiding this comment.
This long, hardcoded string for the fields query parameter is difficult to read and maintain. A typo could easily break the API call. I suggest defining it as a constant at the top of the handle_revisions function, like this:
const REVISION_FIELDS: &str = "revisions(id,modifiedTime,lastModifyingUser/displayName,keepForever,size)";Then you can use the constant here.
REVISION_FIELDS|
/gemini review |
There was a problem hiding this comment.
Code Review
This pull request introduces a new helper command gws docs +revisions to list the revision history of a Google Docs document. The implementation makes a direct call to the Google Drive API. The changes include the command logic, tests, and documentation.
My review has identified a critical security vulnerability related to URL injection, as well as opportunities to improve error handling and input validation for a more robust and user-friendly experience. Please see the detailed comments for suggestions.
src/helpers/docs.rs
Outdated
| .get(format!( | ||
| "https://www.googleapis.com/drive/v3/files/{}/revisions", | ||
| document_id | ||
| )) |
There was a problem hiding this comment.
The document_id is used directly in the URL without proper encoding. This is a critical security risk that can lead to URL injection or path traversal vulnerabilities if a malicious document ID is provided. For example, an ID like ../... could alter the request path.
You should percent-encode the document_id before inserting it into the URL string. You can use the percent_encoding crate, which is already a dependency in the project.
| .get(format!( | |
| "https://www.googleapis.com/drive/v3/files/{}/revisions", | |
| document_id | |
| )) | |
| .get(format!( | |
| "https://www.googleapis.com/drive/v3/files/{}/revisions", | |
| percent_encoding::utf8_percent_encode(document_id, percent_encoding::NON_ALPHANUMERIC) | |
| )) |
src/helpers/docs.rs
Outdated
| .long("limit") | ||
| .help("Maximum number of revisions to return (default: 20)") | ||
| .value_name("N") | ||
| .value_parser(clap::value_parser!(u32)), |
There was a problem hiding this comment.
The help text for --limit correctly states the accepted range is 1-1000, but this is not enforced by the argument parser. An invalid value (e.g., 0 or 2000) will be sent to the API, likely resulting in an API error. It's better to validate this on the client-side using clap's range validator for a better user experience.
| .value_parser(clap::value_parser!(u32)), | |
| .value_parser(clap::value_parser!(u32).range(1..=1000)), |
src/helpers/docs.rs
Outdated
|
|
||
| if !resp.status().is_success() { | ||
| let status = resp.status(); | ||
| let body = resp.text().await.unwrap_or_default(); |
There was a problem hiding this comment.
Using unwrap_or_default() here can hide the true cause of an error. If the request fails and reading the error response body also fails, this will result in an empty error message, making debugging difficult. It would be more robust to handle the potential error from resp.text().await.
| let body = resp.text().await.unwrap_or_default(); | |
| let body = resp.text().await.unwrap_or_else(|e| format!("Failed to read error response body: {e}")); |
- Add .range(1..=1000) to --limit clap value_parser so invalid values are rejected client-side before reaching the API - Extract REVISION_FIELDS constant to avoid hardcoded string duplication and make field list easier to maintain - Percent-encode document_id in the Drive API URL to prevent URL injection / path traversal (uses existing percent-encoding crate) - Replace unwrap_or_default() on error body with unwrap_or_else so secondary failures include a meaningful error message Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
/gemini review |
There was a problem hiding this comment.
Code Review
This pull request introduces a new gws docs +revisions helper command to list revision history for a Google Docs document. The implementation directly calls the Google Drive API. The changes include the new command logic, associated documentation, and tests. My review focuses on ensuring consistency with existing commands. I've found one high-severity issue related to the handling of the --dry-run flag, which is inconsistent with other helpers and could lead to unexpected network requests.
| async fn handle_revisions(matches: &ArgMatches) -> Result<(), GwsError> { | ||
| const REVISION_FIELDS: &str = | ||
| "revisions(id,modifiedTime,lastModifyingUser/displayName,keepForever,size)"; | ||
|
|
||
| let document_id = matches.get_one::<String>("document").unwrap(); | ||
| let limit = matches.get_one::<u32>("limit").copied().unwrap_or(20); | ||
|
|
||
| let scope = "https://www.googleapis.com/auth/drive.readonly"; | ||
| let token = auth::get_token(&[scope]).await.map_err(|e| { | ||
| GwsError::Auth(format!( | ||
| "Docs auth failed: {}", | ||
| crate::output::sanitize_for_terminal(&e.to_string()) | ||
| )) | ||
| })?; | ||
|
|
||
| let client = crate::client::build_client()?; | ||
| let limit_str = limit.to_string(); | ||
| let encoded_id = | ||
| percent_encoding::utf8_percent_encode(document_id, percent_encoding::NON_ALPHANUMERIC); | ||
|
|
||
| let resp = client | ||
| .get(format!( | ||
| "https://www.googleapis.com/drive/v3/files/{}/revisions", | ||
| encoded_id | ||
| )) | ||
| .query(&[ | ||
| ("fields", REVISION_FIELDS), | ||
| ("pageSize", limit_str.as_str()), | ||
| ]) | ||
| .bearer_auth(&token) | ||
| .send() | ||
| .await | ||
| .map_err(|e| GwsError::Other(anyhow::anyhow!("HTTP request failed: {e}")))?; | ||
|
|
||
| if !resp.status().is_success() { | ||
| let status = resp.status(); | ||
| let body = resp | ||
| .text() | ||
| .await | ||
| .unwrap_or_else(|e| format!("Failed to read error response body: {e}")); | ||
| return Err(GwsError::Api { | ||
| code: status.as_u16(), | ||
| message: body, | ||
| reason: "revisions_request_failed".to_string(), | ||
| enable_url: None, | ||
| }); | ||
| } | ||
|
|
||
| let value: Value = resp | ||
| .json() | ||
| .await | ||
| .map_err(|e| GwsError::Other(anyhow::anyhow!("JSON parse failed: {e}")))?; | ||
|
|
||
| let fmt = matches | ||
| .get_one::<String>("format") | ||
| .map(|s| crate::formatter::OutputFormat::from_str(s)) | ||
| .unwrap_or_default(); | ||
| println!("{}", crate::formatter::format_value(&value, &fmt)); | ||
| Ok(()) | ||
| } |
There was a problem hiding this comment.
The new +revisions helper does not handle the global --dry-run flag. This is inconsistent with other helpers like +write and can lead to unexpected behavior, as the command will attempt to make a real API call even when a dry run is requested.
While the PR description mentions that dry-run is 'N/A' because it doesn't produce a local JSON request body, it should still prevent network activity and provide informative output about what it would have done. I suggest implementing dry-run logic to improve consistency and user experience.
async fn handle_revisions(matches: &ArgMatches) -> Result<(), GwsError> {
const REVISION_FIELDS: &str =
"revisions(id,modifiedTime,lastModifyingUser/displayName,keepForever,size)";
let document_id = matches.get_one::<String>("document").unwrap();
let limit = matches.get_one::<u32>("limit").copied().unwrap_or(20);
let dry_run = matches.get_flag("dry-run");
let scope = "https://www.googleapis.com/auth/drive.readonly";
let token = if dry_run {
None
} else {
Some(auth::get_token(&[scope]).await.map_err(|e| {
GwsError::Auth(format!(
"Docs auth failed: {}",
crate::output::sanitize_for_terminal(&e.to_string())
))
})?)
};
let limit_str = limit.to_string();
let encoded_id =
percent_encoding::utf8_percent_encode(document_id, percent_encoding::NON_ALPHANUMERIC);
let url = format!(
"https://www.googleapis.com/drive/v3/files/{}/revisions",
encoded_id
);
if dry_run {
let dry_run_info = json!({
"dry_run": true,
"url": url,
"method": "GET",
"query_params": {
"fields": REVISION_FIELDS,
"pageSize": limit_str,
},
});
println!("{}", serde_json::to_string_pretty(&dry_run_info).unwrap_or_default());
return Ok(());
}
let client = crate::client::build_client()?;
let resp = client
.get(url)
.query(&[
("fields", REVISION_FIELDS),
("pageSize", limit_str.as_str()),
])
.bearer_auth(token.unwrap()) // Safe to unwrap due to dry_run check
.send()
.await
.map_err(|e| GwsError::Other(anyhow::anyhow!("HTTP request failed: {e}")))?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp
.text()
.await
.unwrap_or_else(|e| format!("Failed to read error response body: {e}"));
return Err(GwsError::Api {
code: status.as_u16(),
message: body,
reason: "revisions_request_failed".to_string(),
enable_url: None,
});
}
let value: Value = resp
.json()
.await
.map_err(|e| GwsError::Other(anyhow::anyhow!("JSON parse failed: {e}")))?;
let fmt = matches
.get_one::<String>("format")
.map(|s| crate::formatter::OutputFormat::from_str(s))
.unwrap_or_default();
println!("{}", crate::formatter::format_value(&value, &fmt));
Ok(())
}All other helpers respect --dry-run. Without it, +revisions makes a real API call even when the user passes --dry-run, which is inconsistent with +write and the rest of the codebase. When --dry-run is set: skip auth, print the URL + query params as JSON, return early. Token is now Option<String> to avoid fetching it unnecessarily. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
/gemini review |
There was a problem hiding this comment.
Code Review
This pull request introduces a gws docs +revisions helper command to list revision history for a Google Docs document. The implementation is clean, with corresponding documentation and tests. I have one high-severity suggestion to improve error handling by reusing existing logic, which will enhance consistency and provide better error details to the user.
| if !resp.status().is_success() { | ||
| let status = resp.status(); | ||
| let body = resp | ||
| .text() | ||
| .await | ||
| .unwrap_or_else(|e| format!("Failed to read error response body: {e}")); | ||
| return Err(GwsError::Api { | ||
| code: status.as_u16(), | ||
| message: body, | ||
| reason: "revisions_request_failed".to_string(), | ||
| enable_url: None, | ||
| }); | ||
| } |
There was a problem hiding this comment.
This custom error handling for failed API requests duplicates logic from executor::handle_error_response. Reusing the shared error handler will provide more consistent and detailed error messages to the user, such as automatically parsing Google API error structures and extracting helpful information like the URL to enable a disabled API.
You can replace this block with a call to crate::executor::handle_error_response. The authentication method is OAuth because this code path is only taken when not in dry_run mode and an auth token has been successfully retrieved.
if !resp.status().is_success() {
let status = resp.status();
let body = resp
.text()
.await
.unwrap_or_else(|e| format!("Failed to read error response body: {e}"));
return crate::executor::handle_error_response(status, &body, &executor::AuthMethod::OAuth);
}
Summary
Adds a new
gws docs +revisionshelper command that lists revision metadata for a Google Docs document.Output per revision: id, modifiedTime, lastModifyingUser, keepForever, size
Implementation: Calls the Drive v3 revisions API directly (
drive.readonlyscope) using the same cross-service HTTP pattern as other helpers (reqwest + bearer_auth).Limitation documented: Content of past revisions is not accessible for native Docs files via the Google API. The help text makes this explicit so users are not surprised.
Dry Run
N/A -- this helper calls the Drive API directly via reqwest rather than through the gws schema/discovery system, so
--dry-rundoes not produce a local JSON request body.Changes
src/helpers/docs.rs-- new+revisionssubcommandskills/gws-docs-revisions/SKILL.md-- new AI agent skillskills/gws-docs/SKILL.md-- updated helper commands tableChecklist
google-*crates -- uses reqwest + bearer_auth directlycargo fmt --all-- run and appliedcargo clippy -- -D warnings-- zero warnings.changeset/docs-revisions-helper.md,minor)