Skip to content
Merged
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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
15 changes: 8 additions & 7 deletions internal/cmd/docs.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
13 changes: 13 additions & 0 deletions internal/cmd/docs_comments.go
Original file line number Diff line number Diff line change
@@ -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"`
}
66 changes: 60 additions & 6 deletions internal/cmd/drive_comments.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
182 changes: 182 additions & 0 deletions internal/cmd/drive_comments_resolve_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}