Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/warn-anchored-comments.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@googleworkspace/cli": patch
---

Warn when `drive comments create` uses an `anchor` field on Google Workspace editor files (Docs, Sheets, Slides, Drawings), where anchors are silently ignored by the editor UI
6 changes: 3 additions & 3 deletions .github/workflows/automation.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ jobs:
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
token: ${{ secrets.GOOGLEWORKSPACE_BOT_TOKEN }}
token: ${{ secrets.GITHUB_TOKEN }}

- name: Install Rust
uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable
Expand All @@ -59,7 +59,7 @@ jobs:
steps:
- uses: actions/labeler@8558fd74291d67161a8a78ce36a881fa63b766a9 # v5
with:
repo-token: ${{ secrets.GOOGLEWORKSPACE_BOT_TOKEN }}
repo-token: ${{ secrets.GITHUB_TOKEN }}
sync-labels: true

gemini-review:
Expand Down Expand Up @@ -94,7 +94,7 @@ jobs:
- name: Trigger Gemini Code Assist review
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
with:
github-token: ${{ secrets.GOOGLEWORKSPACE_BOT_TOKEN }}
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
await github.rest.issues.createComment({
owner: context.repo.owner,
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/cla.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ jobs:
- name: Update CLA label
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
with:
github-token: ${{ secrets.GOOGLEWORKSPACE_BOT_TOKEN }}
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const cr = context.payload.check_run;
const passed = cr.conclusion === 'success';
Expand Down
10 changes: 5 additions & 5 deletions .github/workflows/generate-skills.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ jobs:
with:
# For cron/dispatch: check out main. For push: check out the branch.
ref: ${{ github.head_ref || github.ref_name }}
token: ${{ secrets.GOOGLEWORKSPACE_BOT_TOKEN }}
token: ${{ secrets.GITHUB_TOKEN }}

- name: Install Rust
uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable
Expand Down Expand Up @@ -88,7 +88,7 @@ jobs:
(github.event_name == 'schedule' || github.event_name == 'workflow_dispatch')
uses: peter-evans/create-pull-request@22a9089034f40e5a961c8808d113e2c98fb63676 # v7
with:
token: ${{ secrets.GOOGLEWORKSPACE_BOT_TOKEN }}
token: ${{ secrets.GITHUB_TOKEN }}
branch: chore/sync-skills
title: "chore: sync skills with Discovery API"
body: |
Expand All @@ -109,10 +109,10 @@ jobs:
steps.diff.outputs.changed == 'true' &&
github.event_name == 'push'
env:
GITHUB_TOKEN: ${{ secrets.GOOGLEWORKSPACE_BOT_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
git config user.name "googleworkspace-bot"
git config user.email "googleworkspace-bot@users.noreply.github.com"
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"

git add skills/ docs/skills.md
git commit -m "chore: regenerate skills [skip ci]"
Expand Down
10 changes: 5 additions & 5 deletions .github/workflows/release-changesets.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,15 +34,15 @@ jobs:
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
token: ${{ secrets.GOOGLEWORKSPACE_BOT_TOKEN }}
token: ${{ secrets.GITHUB_TOKEN }}

- name: Install Rust
uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable

- name: Install Nix
uses: cachix/install-nix-action@08dcb3a5e62fa31e2da3d490afc4176ef55ecd72 # v30
with:
github_access_token: ${{ secrets.GOOGLEWORKSPACE_BOT_TOKEN }}
github_access_token: ${{ secrets.GITHUB_TOKEN }}

- uses: pnpm/action-setup@c5ba7f7862a0f64c1b1a05fbac13e0b8e86ba08c # v4

Expand All @@ -56,8 +56,8 @@ jobs:
run: pnpm install

- run: |
git config --global user.name "googleworkspace-bot"
git config --global user.email "googleworkspace-bot@google.com"
git config --global user.name "github-actions[bot]"
git config --global user.email "github-actions[bot]@users.noreply.github.com"

- name: Create Release Pull Request or Tag
id: changesets
Expand All @@ -69,4 +69,4 @@ jobs:
title: 'chore: release versions'
setupGitUser: false
env:
GITHUB_TOKEN: ${{ secrets.GOOGLEWORKSPACE_BOT_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
188 changes: 187 additions & 1 deletion src/helpers/drive.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,24 @@

use super::Helper;
use crate::auth;
use crate::error::GwsError;
use crate::error::{self, GwsError};
use crate::executor;
use clap::{Arg, ArgMatches, Command};
use serde_json::{json, Value};
use std::future::Future;
use std::path::Path;
use std::pin::Pin;

/// MIME types for Google Workspace editor files where the Drive API `anchor`
/// field on comments is saved but silently ignored by the editor UI.
/// See: https://developers.google.com/workspace/drive/api/v3/manage-comments
const WORKSPACE_EDITOR_MIMES: &[&str] = &[
"application/vnd.google-apps.document",
"application/vnd.google-apps.spreadsheet",
"application/vnd.google-apps.presentation",
"application/vnd.google-apps.drawing",
];

pub struct DriveHelper;

impl Helper for DriveHelper {
Expand Down Expand Up @@ -73,6 +83,9 @@ TIPS:
_sanitize_config: &'a crate::helpers::modelarmor::SanitizeConfig,
) -> Pin<Box<dyn Future<Output = Result<bool, GwsError>> + Send + 'a>> {
Box::pin(async move {
// Check for anchored comments on Workspace editor files (#169)
warn_anchored_comment_if_needed(doc, matches).await;

if let Some(matches) = matches.subcommand_matches("+upload") {
let file_path = matches.get_one::<String>("file").unwrap();
let parent_id = matches.get_one::<String>("parent");
Expand Down Expand Up @@ -127,6 +140,139 @@ TIPS:
}
}

/// Warn when `comments create` includes an `anchor` field targeting a Workspace
/// editor file (Docs, Sheets, Slides, Drawings). The Drive API accepts the anchor
/// but the editor UI silently ignores it, showing "Original content deleted".
///
/// This does not block execution — the dynamic dispatcher still runs the request.
async fn warn_anchored_comment_if_needed(
doc: &crate::discovery::RestDescription,
matches: &ArgMatches,
) {
// Walk the dynamic subcommand tree: comments → create
let create_matches = matches
.subcommand_matches("comments")
.and_then(|m| m.subcommand_matches("create"));

let create_matches = match create_matches {
Some(m) => m,
None => return,
};

// Check if the --json body contains an "anchor" field
let body_str = match create_matches.try_get_one::<String>("json").ok().flatten() {
Some(s) => s.as_str(),
None => return,
};

let body: Value = match serde_json::from_str(body_str) {
Ok(v) => v,
Err(_) => return, // Invalid JSON will be caught later by the executor
};

if body.get("anchor").is_none() {
return;
}

// Extract fileId from --params
let file_id = create_matches
.get_one::<String>("params")
.and_then(|p| serde_json::from_str::<Value>(p).ok())
.and_then(|v| v.get("fileId").and_then(|id| id.as_str()).map(String::from));

let file_id = match file_id {
Some(id) => id,
None => return, // Missing fileId will be caught later by the executor
};

// Try to fetch the file's mimeType to give a precise warning.
// If auth or the request fails, fall back to a general warning.
match fetch_file_mime_type(doc, &file_id).await {
Ok(mime) if is_workspace_editor_mime(&mime) => {
eprintln!(
"\n{}",
error::yellow(&format!(
"⚠️ Warning: anchor field ignored for {} files.\n \
Google Workspace editors treat anchored comments as un-anchored.\n \
The comment will be created but may show as \"Original content deleted\".\n \
See: https://developers.google.com/workspace/drive/api/v3/manage-comments\n",
mime_display_name(&mime),
)),
);
}
Ok(_) => {} // Non-editor file — anchor should work fine
Err(_) => {
// Could not determine file type; print a general warning
eprintln!(
"\n{}",
error::yellow(
"⚠️ Warning: anchor field may be ignored for Google Workspace editor files \
(Docs, Sheets, Slides).\n \
If the target is a Workspace file, the comment may show as \
\"Original content deleted\".\n \
See: https://developers.google.com/workspace/drive/api/v3/manage-comments\n",
),
);
}
}
}

/// Fetch the mimeType of a Drive file by ID.
async fn fetch_file_mime_type(
doc: &crate::discovery::RestDescription,
file_id: &str,
) -> Result<String, GwsError> {
let files_res = doc
.resources
.get("files")
.ok_or_else(|| GwsError::Discovery("Resource 'files' not found".to_string()))?;
let get_method = files_res
.methods
.get("get")
.ok_or_else(|| GwsError::Discovery("Method 'files.get' not found".to_string()))?;

let scopes: Vec<&str> = get_method.scopes.iter().map(|s| s.as_str()).collect();
let token = auth::get_token(&scopes).await?;

let params = format!(r#"{{"fileId":"{}","fields":"mimeType"}}"#, file_id);
Copy link
Contributor

Choose a reason for hiding this comment

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

high

Constructing JSON via string formatting is vulnerable to injection if file_id contains special characters like quotes. This can lead to a panic or API errors. It's safer to use serde_json::json! which handles escaping correctly.

Suggested change
let params = format!(r#"{{"fileId":"{}","fields":"mimeType"}}"#, file_id);
let params = serde_json::json!({ "fileId": file_id, "fields": "mimeType" }).to_string();

let output = executor::execute_method(
doc,
get_method,
Some(&params),
None,
Some(&token),
executor::AuthMethod::OAuth,
None,
None,
false,
&executor::PaginationConfig::default(),
None,
&crate::helpers::modelarmor::SanitizeMode::Warn,
&crate::formatter::OutputFormat::default(),
true, // capture_output — don't print to stdout
)
.await?;

// extract mimeType from captured output
output
.and_then(|v| v.get("mimeType").and_then(|m| m.as_str()).map(String::from))
.ok_or_else(|| GwsError::Other(anyhow::anyhow!("mimeType not found in response")))
}

fn is_workspace_editor_mime(mime: &str) -> bool {
WORKSPACE_EDITOR_MIMES.contains(&mime)
}

fn mime_display_name(mime: &str) -> &str {
match mime {
"application/vnd.google-apps.document" => "Google Docs",
"application/vnd.google-apps.spreadsheet" => "Google Sheets",
"application/vnd.google-apps.presentation" => "Google Slides",
"application/vnd.google-apps.drawing" => "Google Drawings",
_ => "Google Workspace",
}
}

fn determine_filename(file_path: &str, name_arg: Option<&str>) -> Result<String, GwsError> {
if let Some(n) = name_arg {
Ok(n.to_string())
Expand Down Expand Up @@ -190,4 +336,44 @@ mod tests {
assert_eq!(meta["name"], "file.txt");
assert_eq!(meta["parents"][0], "folder123");
}

#[test]
fn test_is_workspace_editor_mime() {
assert!(is_workspace_editor_mime(
"application/vnd.google-apps.document"
));
assert!(is_workspace_editor_mime(
"application/vnd.google-apps.spreadsheet"
));
assert!(is_workspace_editor_mime(
"application/vnd.google-apps.presentation"
));
assert!(is_workspace_editor_mime(
"application/vnd.google-apps.drawing"
));
assert!(!is_workspace_editor_mime("application/pdf"));
assert!(!is_workspace_editor_mime("image/png"));
assert!(!is_workspace_editor_mime("text/plain"));
}

#[test]
fn test_mime_display_name() {
assert_eq!(
mime_display_name("application/vnd.google-apps.document"),
"Google Docs"
);
assert_eq!(
mime_display_name("application/vnd.google-apps.spreadsheet"),
"Google Sheets"
);
assert_eq!(
mime_display_name("application/vnd.google-apps.presentation"),
"Google Slides"
);
assert_eq!(
mime_display_name("application/vnd.google-apps.drawing"),
"Google Drawings"
);
assert_eq!(mime_display_name("application/pdf"), "Google Workspace");
}
}
Loading