Skip to content

feat(docs): add +revisions helper to list document revision history#563

Open
khali wants to merge 6 commits intogoogleworkspace:mainfrom
khali:feat/docs-revisions-helper
Open

feat(docs): add +revisions helper to list document revision history#563
khali wants to merge 6 commits intogoogleworkspace:mainfrom
khali:feat/docs-revisions-helper

Conversation

@khali
Copy link

@khali khali commented Mar 19, 2026

Summary

Adds a new gws docs +revisions helper command that lists revision metadata for a Google Docs document.

gws docs +revisions --document DOC_ID
gws docs +revisions --document DOC_ID --limit 5
gws docs +revisions --document DOC_ID --format table

Output per revision: id, modifiedTime, lastModifyingUser, keepForever, size

Implementation: Calls the Drive v3 revisions API directly (drive.readonly scope) 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-run does not produce a local JSON request body.

Changes

  • src/helpers/docs.rs -- new +revisions subcommand
  • skills/gws-docs-revisions/SKILL.md -- new AI agent skill
  • skills/gws-docs/SKILL.md -- updated helper commands table

Checklist

  • No generated google-* crates -- uses reqwest + bearer_auth directly
  • cargo fmt --all -- run and applied
  • cargo clippy -- -D warnings -- zero warnings
  • Tests added and passing (3/3)
  • Changeset file added (.changeset/docs-revisions-helper.md, minor)

Khali added 2 commits March 19, 2026 15:23
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.
@google-cla
Copy link

google-cla bot commented Mar 19, 2026

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-bot
Copy link

changeset-bot bot commented Mar 19, 2026

🦋 Changeset detected

Latest commit: 61c5390

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@googleworkspace/cli Minor

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

@googleworkspace-bot googleworkspace-bot added area: skills area: core Core CLI parsing, commands, error handling, utilities labels Mar 19, 2026
@gemini-code-assist
Copy link
Contributor

Summary of Changes

Hello, 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 gws docs tool by offering a new insight into document changes and authorship, without requiring manual navigation through the Google Docs interface.

Highlights

  • New +revisions helper command: Introduced a new gws docs +revisions command to list revision metadata for a Google Docs document, supporting document ID and an optional limit for the number of revisions.
  • Direct API Integration: The helper directly calls the Drive v3 revisions API using reqwest for HTTP requests and bearer_auth for authentication, bypassing the gws schema/discovery system.
  • Revision Metadata Output: The command outputs key metadata for each revision, including ID, modified time, last modifying user, whether it's kept forever, and its size.
  • Documented Limitations: Explicitly documented that the content of past revisions for native Google Docs files is not accessible via the Google API, guiding users to the Google Docs UI for full version history.
Using Gemini Code Assist

The 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 /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

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 .gemini/ folder in the base of the repository. Detailed instructions can be found here.

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

  1. Review the Generative AI Prohibited Use Policy, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

Copy link
Contributor

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a new +revisions helper command to the gws docs tool. I have identified a critical issue related to error string sanitization that needs to be addressed.

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}")))?;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

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.

Suggested change
.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
  1. Sanitize error strings printed to the terminal to prevent escape sequence injection.

@googleworkspace-bot
Copy link
Collaborator

/gemini review

Copy link
Contributor

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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(),
        ));
    }

.query(&[
(
"fields",
"revisions(id,modifiedTime,lastModifyingUser/displayName,keepForever,size)",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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

@googleworkspace-bot
Copy link
Collaborator

/gemini review

Copy link
Contributor

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +179 to +182
.get(format!(
"https://www.googleapis.com/drive/v3/files/{}/revisions",
document_id
))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

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.

Suggested change
.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)
))

.long("limit")
.help("Maximum number of revisions to return (default: 20)")
.value_name("N")
.value_parser(clap::value_parser!(u32)),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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.

Suggested change
.value_parser(clap::value_parser!(u32)),
.value_parser(clap::value_parser!(u32).range(1..=1000)),


if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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.

Suggested change
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>
@googleworkspace-bot
Copy link
Collaborator

/gemini review

Copy link
Contributor

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +163 to +222
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(())
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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>
@googleworkspace-bot
Copy link
Collaborator

/gemini review

Copy link
Contributor

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +220 to +232
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,
});
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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);
    }

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: core Core CLI parsing, commands, error handling, utilities area: skills

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants