diff --git a/CHANGELOG.md b/CHANGELOG.md index 06123738..38a66f11 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [Unreleased] + +### Added + +- Drive: `drive comments resolve` command to mark a comment as resolved. +- Docs: `docs comments` subcommand with list, read, create, reply, resolve, and delete operations (aliases to drive comments). + ## 0.9.0 - 2026-01-22 ### Highlights diff --git a/internal/cmd/docs.go b/internal/cmd/docs.go index 2446049a..4c20c480 100644 --- a/internal/cmd/docs.go +++ b/internal/cmd/docs.go @@ -24,13 +24,14 @@ import ( var newDocsService = googleapi.NewDocs type DocsCmd struct { - Export DocsExportCmd `cmd:"" name:"export" help:"Export a Google Doc (pdf|docx|txt)"` - Info DocsInfoCmd `cmd:"" name:"info" help:"Get Google Doc metadata"` - Create DocsCreateCmd `cmd:"" name:"create" help:"Create a Google Doc"` - Copy DocsCopyCmd `cmd:"" name:"copy" help:"Copy a Google Doc"` - Write DocsWriteCmd `cmd:"" name:"write" help:"Write content to a Google Doc"` - Update DocsUpdateCmd `cmd:"" name:"update" help:"Insert text at a specific index in a Google Doc"` - Cat DocsCatCmd `cmd:"" name:"cat" help:"Print a Google Doc as plain text"` + Export DocsExportCmd `cmd:"" name:"export" help:"Export a Google Doc (pdf|docx|txt)"` + Info DocsInfoCmd `cmd:"" name:"info" help:"Get Google Doc metadata"` + Create DocsCreateCmd `cmd:"" name:"create" help:"Create a Google Doc"` + Copy DocsCopyCmd `cmd:"" name:"copy" help:"Copy a Google Doc"` + Write DocsWriteCmd `cmd:"" name:"write" help:"Write content to a Google Doc"` + Update DocsUpdateCmd `cmd:"" name:"update" help:"Insert text at a specific index in a Google Doc"` + Cat DocsCatCmd `cmd:"" name:"cat" help:"Print a Google Doc as plain text"` + Comments DocsCommentsCmd `cmd:"" name:"comments" help:"Manage comments on a Google Doc"` } type DocsExportCmd struct { diff --git a/internal/cmd/docs_comments.go b/internal/cmd/docs_comments.go new file mode 100644 index 00000000..e4d9d78d --- /dev/null +++ b/internal/cmd/docs_comments.go @@ -0,0 +1,13 @@ +package cmd + +// DocsCommentsCmd embeds drive comments commands for Google Docs. +// Since Google Docs comments use the same Drive API endpoints, +// we reuse the drive comments implementation directly. +type DocsCommentsCmd struct { + List DriveCommentsListCmd `cmd:"" name:"list" help:"List comments on a Google Doc"` + Read DriveCommentsGetCmd `cmd:"" name:"read" help:"Read a comment with replies"` + Create DriveCommentsCreateCmd `cmd:"" name:"create" help:"Create a comment on a Google Doc"` + Reply DriveCommentReplyCmd `cmd:"" name:"reply" help:"Reply to a comment"` + Resolve DriveCommentsResolveCmd `cmd:"" name:"resolve" help:"Mark a comment as resolved"` + Delete DriveCommentsDeleteCmd `cmd:"" name:"delete" help:"Delete a comment"` +} diff --git a/internal/cmd/drive_comments.go b/internal/cmd/drive_comments.go index 61ce286f..c1f73252 100644 --- a/internal/cmd/drive_comments.go +++ b/internal/cmd/drive_comments.go @@ -14,12 +14,13 @@ import ( // DriveCommentsCmd is the parent command for comments subcommands type DriveCommentsCmd struct { - List DriveCommentsListCmd `cmd:"" name:"list" help:"List comments on a file"` - Get DriveCommentsGetCmd `cmd:"" name:"get" help:"Get a comment by ID"` - Create DriveCommentsCreateCmd `cmd:"" name:"create" help:"Create a comment on a file"` - Update DriveCommentsUpdateCmd `cmd:"" name:"update" help:"Update a comment"` - Delete DriveCommentsDeleteCmd `cmd:"" name:"delete" help:"Delete a comment"` - Reply DriveCommentReplyCmd `cmd:"" name:"reply" help:"Reply to a comment"` + List DriveCommentsListCmd `cmd:"" name:"list" help:"List comments on a file"` + Get DriveCommentsGetCmd `cmd:"" name:"get" help:"Get a comment by ID"` + Create DriveCommentsCreateCmd `cmd:"" name:"create" help:"Create a comment on a file"` + Update DriveCommentsUpdateCmd `cmd:"" name:"update" help:"Update a comment"` + Delete DriveCommentsDeleteCmd `cmd:"" name:"delete" help:"Delete a comment"` + Reply DriveCommentReplyCmd `cmd:"" name:"reply" help:"Reply to a comment"` + Resolve DriveCommentsResolveCmd `cmd:"" name:"resolve" help:"Mark a comment as resolved"` } type DriveCommentsListCmd struct { @@ -384,6 +385,59 @@ func (c *DriveCommentReplyCmd) Run(ctx context.Context, flags *RootFlags) error return nil } +type DriveCommentsResolveCmd struct { + FileID string `arg:"" name:"fileId" help:"File ID"` + CommentID string `arg:"" name:"commentId" help:"Comment ID"` +} + +func (c *DriveCommentsResolveCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + fileID := strings.TrimSpace(c.FileID) + commentID := strings.TrimSpace(c.CommentID) + if fileID == "" { + return usage("empty fileId") + } + if commentID == "" { + return usage("empty commentId") + } + + svc, err := newDriveService(ctx, account) + if err != nil { + return err + } + + comment := &drive.Comment{ + Resolved: true, + } + + updated, err := svc.Comments.Update(fileID, commentID, comment). + Fields("id, resolved, modifiedTime"). + Context(ctx). + Do() + if err != nil { + return err + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, map[string]any{ + "fileId": fileID, + "commentId": updated.Id, + "resolved": updated.Resolved, + "modified": updated.ModifiedTime, + }) + } + + u.Out().Printf("file_id\t%s", fileID) + u.Out().Printf("comment_id\t%s", updated.Id) + u.Out().Printf("resolved\t%t", updated.Resolved) + u.Out().Printf("modified\t%s", updated.ModifiedTime) + return nil +} + // truncateString truncates a string to maxLen and adds "..." if truncated func truncateString(s string, maxLen int) string { // Replace newlines with spaces for table display diff --git a/internal/cmd/drive_comments_resolve_test.go b/internal/cmd/drive_comments_resolve_test.go new file mode 100644 index 00000000..de0a5997 --- /dev/null +++ b/internal/cmd/drive_comments_resolve_test.go @@ -0,0 +1,182 @@ +package cmd + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "google.golang.org/api/drive/v3" + "google.golang.org/api/option" +) + +func TestDriveCommentsResolveCmd(t *testing.T) { + origNew := newDriveService + t.Cleanup(func() { newDriveService = origNew }) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + path := strings.TrimPrefix(r.URL.Path, "/drive/v3") + switch { + case r.Method == http.MethodPatch && path == "/files/file1/comments/c1": + var payload map[string]any + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + t.Fatalf("decode body: %v", err) + } + if payload["resolved"] != true { + t.Fatalf("expected resolved=true, got: %#v", payload["resolved"]) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": "c1", + "resolved": true, + "modifiedTime": "2026-02-04T12:00:00Z", + }) + return + default: + http.NotFound(w, r) + return + } + })) + defer srv.Close() + + svc, err := drive.NewService(context.Background(), + option.WithoutAuthentication(), + option.WithHTTPClient(srv.Client()), + option.WithEndpoint(srv.URL+"/"), + ) + if err != nil { + t.Fatalf("NewService: %v", err) + } + newDriveService = func(context.Context, string) (*drive.Service, error) { return svc, nil } + + // Test JSON output + jsonOut := captureStdout(t, func() { + _ = captureStderr(t, func() { + if err := Execute([]string{"--json", "--account", "a@b.com", "drive", "comments", "resolve", "file1", "c1"}); err != nil { + t.Fatalf("Execute resolve: %v", err) + } + }) + }) + var parsed struct { + FileID string `json:"fileId"` + CommentID string `json:"commentId"` + Resolved bool `json:"resolved"` + Modified string `json:"modified"` + } + if err := json.Unmarshal([]byte(jsonOut), &parsed); err != nil { + t.Fatalf("json parse: %v out=%q", err, jsonOut) + } + if parsed.FileID != "file1" || parsed.CommentID != "c1" || !parsed.Resolved { + t.Fatalf("unexpected json: %#v", parsed) + } + + // Test plain text output + plainOut := captureStdout(t, func() { + _ = captureStderr(t, func() { + if err := Execute([]string{"--account", "a@b.com", "drive", "comments", "resolve", "file1", "c1"}); err != nil { + t.Fatalf("Execute resolve plain: %v", err) + } + }) + }) + if !strings.Contains(plainOut, "resolved") || !strings.Contains(plainOut, "true") { + t.Fatalf("unexpected plain output: %q", plainOut) + } +} + +func TestDocsCommentsResolveCmd(t *testing.T) { + origNew := newDriveService + t.Cleanup(func() { newDriveService = origNew }) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + path := strings.TrimPrefix(r.URL.Path, "/drive/v3") + switch { + case r.Method == http.MethodPatch && path == "/files/doc1/comments/c1": + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": "c1", + "resolved": true, + "modifiedTime": "2026-02-04T12:00:00Z", + }) + return + default: + http.NotFound(w, r) + return + } + })) + defer srv.Close() + + svc, err := drive.NewService(context.Background(), + option.WithoutAuthentication(), + option.WithHTTPClient(srv.Client()), + option.WithEndpoint(srv.URL+"/"), + ) + if err != nil { + t.Fatalf("NewService: %v", err) + } + newDriveService = func(context.Context, string) (*drive.Service, error) { return svc, nil } + + // Test docs comments resolve (should use same drive endpoint) + jsonOut := captureStdout(t, func() { + _ = captureStderr(t, func() { + if err := Execute([]string{"--json", "--account", "a@b.com", "docs", "comments", "resolve", "doc1", "c1"}); err != nil { + t.Fatalf("Execute docs comments resolve: %v", err) + } + }) + }) + var parsed struct { + Resolved bool `json:"resolved"` + } + if err := json.Unmarshal([]byte(jsonOut), &parsed); err != nil { + t.Fatalf("json parse: %v out=%q", err, jsonOut) + } + if !parsed.Resolved { + t.Fatalf("expected resolved=true, got: %#v", parsed) + } +} + +func TestDocsCommentsReadCmd(t *testing.T) { + origNew := newDriveService + t.Cleanup(func() { newDriveService = origNew }) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + path := strings.TrimPrefix(r.URL.Path, "/drive/v3") + switch { + case r.Method == http.MethodGet && path == "/files/doc1/comments/c1": + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": "c1", + "content": "test comment", + "createdTime": "2026-02-04T12:00:00Z", + }) + return + default: + http.NotFound(w, r) + return + } + })) + defer srv.Close() + + svc, err := drive.NewService(context.Background(), + option.WithoutAuthentication(), + option.WithHTTPClient(srv.Client()), + option.WithEndpoint(srv.URL+"/"), + ) + if err != nil { + t.Fatalf("NewService: %v", err) + } + newDriveService = func(context.Context, string) (*drive.Service, error) { return svc, nil } + + // Test docs comments read (alias to get) + jsonOut := captureStdout(t, func() { + _ = captureStderr(t, func() { + if err := Execute([]string{"--json", "--account", "a@b.com", "docs", "comments", "read", "doc1", "c1"}); err != nil { + t.Fatalf("Execute docs comments read: %v", err) + } + }) + }) + if !strings.Contains(jsonOut, "test comment") { + t.Fatalf("unexpected read output: %q", jsonOut) + } +}