diff --git a/.changeset/warn-anchored-comments.md b/.changeset/warn-anchored-comments.md new file mode 100644 index 00000000..563fd9dd --- /dev/null +++ b/.changeset/warn-anchored-comments.md @@ -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 diff --git a/.github/workflows/automation.yml b/.github/workflows/automation.yml index 5eb3de95..14248af9 100644 --- a/.github/workflows/automation.yml +++ b/.github/workflows/automation.yml @@ -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 @@ -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: @@ -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, diff --git a/.github/workflows/cla.yml b/.github/workflows/cla.yml index c4a4809c..21e1c57d 100644 --- a/.github/workflows/cla.yml +++ b/.github/workflows/cla.yml @@ -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'; diff --git a/.github/workflows/generate-skills.yml b/.github/workflows/generate-skills.yml index a30f1bc3..120a26f9 100644 --- a/.github/workflows/generate-skills.yml +++ b/.github/workflows/generate-skills.yml @@ -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 @@ -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: | @@ -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]" diff --git a/.github/workflows/release-changesets.yml b/.github/workflows/release-changesets.yml index 25caa4f4..c766c282 100644 --- a/.github/workflows/release-changesets.yml +++ b/.github/workflows/release-changesets.yml @@ -34,7 +34,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 @@ -42,7 +42,7 @@ jobs: - 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 @@ -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 @@ -69,4 +69,4 @@ jobs: title: 'chore: release versions' setupGitUser: false env: - GITHUB_TOKEN: ${{ secrets.GOOGLEWORKSPACE_BOT_TOKEN }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/src/helpers/drive.rs b/src/helpers/drive.rs index 393e0fde..b9a882d6 100644 --- a/src/helpers/drive.rs +++ b/src/helpers/drive.rs @@ -14,7 +14,7 @@ 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}; @@ -22,6 +22,16 @@ 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 { @@ -73,6 +83,9 @@ TIPS: _sanitize_config: &'a crate::helpers::modelarmor::SanitizeConfig, ) -> Pin> + 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::("file").unwrap(); let parent_id = matches.get_one::("parent"); @@ -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::("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::("params") + .and_then(|p| serde_json::from_str::(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 { + 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); + let output = executor::execute_method( + doc, + get_method, + Some(¶ms), + 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 { if let Some(n) = name_arg { Ok(n.to_string()) @@ -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"); + } }