diff --git a/CHANGELOG.md b/CHANGELOG.md index 0596e365..d1b2b75a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- Drive/Docs: add `--since` to `drive comments list` and `docs comments list` for server-side comment modified-time filtering. (#688) — thanks @sebsnyk. - Gmail: add `--thread-id` to `gmail drafts create` and `gmail drafts update` so drafts can reply within a thread using the latest message headers. (#673, #674) — thanks @chrischall. ### Fixed diff --git a/docs/commands/gog-docs-comments-list.md b/docs/commands/gog-docs-comments-list.md index 3452dc75..eb30496e 100644 --- a/docs/commands/gog-docs-comments-list.md +++ b/docs/commands/gog-docs-comments-list.md @@ -40,6 +40,7 @@ gog docs (doc) comments list (ls) [flags] | `-p`
`--plain`
`--tsv` | `bool` | false | Output stable, parseable text to stdout (TSV; no colors) | | `--results-only` | `bool` | | In JSON mode, emit only the primary result (drops envelope fields like nextPageToken) | | `--select`
`--pick`
`--project` | `string` | | In JSON mode, select comma-separated fields (best-effort; supports dot paths). Desire path: use --fields for most commands. | +| `--since` | `string` | | Only return comments modified at or after this RFC3339 timestamp | | `-v`
`--verbose` | `bool` | | Enable verbose logging | | `--version` | `kong.VersionFlag` | | Print version and exit | | `--wrap-untrusted` | `bool` | false | In JSON/raw output, wrap fetched text fields in external untrusted-content markers | diff --git a/docs/commands/gog-drive-comments-list.md b/docs/commands/gog-drive-comments-list.md index 709962a6..9875d783 100644 --- a/docs/commands/gog-drive-comments-list.md +++ b/docs/commands/gog-drive-comments-list.md @@ -40,6 +40,7 @@ gog drive (drv) comments list (ls) [flags] | `-p`
`--plain`
`--tsv` | `bool` | false | Output stable, parseable text to stdout (TSV; no colors) | | `--results-only` | `bool` | | In JSON mode, emit only the primary result (drops envelope fields like nextPageToken) | | `--select`
`--pick`
`--project` | `string` | | In JSON mode, select comma-separated fields (best-effort; supports dot paths). Desire path: use --fields for most commands. | +| `--since` | `string` | | Only return comments modified at or after this RFC3339 timestamp | | `-v`
`--verbose` | `bool` | | Enable verbose logging | | `--version` | `kong.VersionFlag` | | Print version and exit | | `--wrap-untrusted` | `bool` | false | In JSON/raw output, wrap fetched text fields in external untrusted-content markers | diff --git a/internal/cmd/comment_ops.go b/internal/cmd/comment_ops.go index 52dbf7a8..c3d3f6f4 100644 --- a/internal/cmd/comment_ops.go +++ b/internal/cmd/comment_ops.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "strings" + "time" "google.golang.org/api/drive/v3" gapi "google.golang.org/api/googleapi" @@ -61,6 +62,7 @@ type driveCommentListOptions struct { includeQuoted bool scanForOpen bool page string + since string all bool failEmpty bool max int64 @@ -70,7 +72,7 @@ type driveCommentListOptions struct { func listDriveComments(ctx context.Context, svc *drive.Service, fileID string, opts driveCommentListOptions) ([]*drive.Comment, string, error) { fetch := func(pageToken string) ([]*drive.Comment, string, error) { - return fetchDriveCommentsPage(ctx, svc, fileID, opts.max, pageToken, driveCommentFieldsForList(opts)) + return fetchDriveCommentsPage(ctx, svc, fileID, opts.max, pageToken, opts.since, driveCommentFieldsForList(opts)) } if opts.all { @@ -106,7 +108,7 @@ func listDriveComments(ctx context.Context, svc *drive.Service, fileID string, o } } -func fetchDriveCommentsPage(ctx context.Context, svc *drive.Service, fileID string, pageSize int64, pageToken string, commentFields string) ([]*drive.Comment, string, error) { +func fetchDriveCommentsPage(ctx context.Context, svc *drive.Service, fileID string, pageSize int64, pageToken string, since string, commentFields string) ([]*drive.Comment, string, error) { call := svc.Comments.List(fileID). IncludeDeleted(false). PageSize(pageSize). @@ -115,6 +117,9 @@ func fetchDriveCommentsPage(ctx context.Context, svc *drive.Service, fileID stri if strings.TrimSpace(pageToken) != "" { call = call.PageToken(pageToken) } + if strings.TrimSpace(since) != "" { + call = call.StartModifiedTime(since) + } resp, err := call.Do() if err != nil { return nil, "", err @@ -122,6 +127,18 @@ func fetchDriveCommentsPage(ctx context.Context, svc *drive.Service, fileID stri return resp.Comments, resp.NextPageToken, nil } +func normalizeDriveCommentSince(raw string) (string, error) { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return "", nil + } + parsed, err := time.Parse(time.RFC3339Nano, trimmed) + if err != nil { + return "", usagef("invalid --since %q (expected RFC3339 timestamp with timezone)", raw) + } + return parsed.Format(time.RFC3339Nano), nil +} + func driveCommentFieldsForList(opts driveCommentListOptions) string { if opts.mode == driveCommentListModeExpanded { return docsCommentListFields diff --git a/internal/cmd/docs_comments.go b/internal/cmd/docs_comments.go index 65b015f2..4ea7af45 100644 --- a/internal/cmd/docs_comments.go +++ b/internal/cmd/docs_comments.go @@ -28,6 +28,7 @@ type DocsCommentsListCmd struct { Page string `name:"page" aliases:"cursor" help:"Page token for pagination"` All bool `name:"all" aliases:"all-pages" help:"Fetch all pages"` FailEmpty bool `name:"fail-empty" aliases:"non-empty,require-results" help:"Exit with code 3 if no results"` + Since string `name:"since" help:"Only return comments modified at or after this RFC3339 timestamp"` } func (c *DocsCommentsListCmd) Run(ctx context.Context, flags *RootFlags) error { @@ -39,6 +40,10 @@ func (c *DocsCommentsListCmd) Run(ctx context.Context, flags *RootFlags) error { if c.Max <= 0 { return usage("max must be > 0") } + since, err := normalizeDriveCommentSince(c.Since) + if err != nil { + return err + } _, svc, err := requireDriveService(ctx, flags) if err != nil { @@ -50,6 +55,7 @@ func (c *DocsCommentsListCmd) Run(ctx context.Context, flags *RootFlags) error { includeResolved: c.IncludeResolved, scanForOpen: true, page: c.Page, + since: since, all: c.All, failEmpty: c.FailEmpty, max: c.Max, diff --git a/internal/cmd/docs_comments_test.go b/internal/cmd/docs_comments_test.go index a5bf628f..ef1e570b 100644 --- a/internal/cmd/docs_comments_test.go +++ b/internal/cmd/docs_comments_test.go @@ -57,6 +57,26 @@ func newCommentsTestServer(t *testing.T) *httptest.Server { }) return + // List comments with modified-time filter. + case r.Method == http.MethodGet && path == "/files/doc-since/comments": + if r.URL.Query().Get("startModifiedTime") != "2026-06-04T10:00:00Z" { + t.Fatalf("expected startModifiedTime, got: %q", r.URL.RawQuery) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "comments": []map[string]any{ + { + "id": "c-since", + "author": map[string]any{"displayName": "Alice"}, + "content": "Fresh", + "createdTime": "2026-06-04T10:00:01Z", + "modifiedTime": "2026-06-04T10:00:01Z", + "resolved": false, + }, + }, + }) + return + // List comments: first page has only resolved, second page has open. case r.Method == http.MethodGet && path == "/files/scan/comments": w.Header().Set("Content-Type", "application/json") @@ -244,6 +264,30 @@ func TestDocsCommentsList_IncludeResolved(t *testing.T) { } } +func TestDocsCommentsList_Since(t *testing.T) { + srv := newCommentsTestServer(t) + defer srv.Close() + setupDriveServiceFromServer(t, srv) + + jsonOut := captureStdout(t, func() { + _ = captureStderr(t, func() { + if err := Execute([]string{"--json", "--account", "a@b.com", "docs", "comments", "list", "--since", "2026-06-04T10:00:00Z", "doc-since"}); err != nil { + t.Fatalf("Execute: %v", err) + } + }) + }) + + var parsed struct { + Comments []*drive.Comment `json:"comments"` + } + if err := json.Unmarshal([]byte(jsonOut), &parsed); err != nil { + t.Fatalf("json parse: %v\nout=%q", err, jsonOut) + } + if len(parsed.Comments) != 1 || parsed.Comments[0].Id != "c-since" { + t.Fatalf("unexpected comments: %#v", parsed.Comments) + } +} + func TestDocsCommentsList_PlainText(t *testing.T) { srv := newCommentsTestServer(t) defer srv.Close() @@ -525,6 +569,9 @@ func TestDocsComments_ValidationErrors(t *testing.T) { if err := (&DocsCommentsListCmd{}).Run(ctx, flags); err == nil { t.Fatal("expected list missing docId error") } + if err := (&DocsCommentsListCmd{DocID: "d1", Max: 1, Since: "2026-06-04T10:00:00"}).Run(ctx, flags); err == nil { + t.Fatal("expected list invalid since error") + } if err := (&DocsCommentsGetCmd{}).Run(ctx, flags); err == nil { t.Fatal("expected get missing docId error") } diff --git a/internal/cmd/drive_comments.go b/internal/cmd/drive_comments.go index c2dd67a1..74291d5f 100644 --- a/internal/cmd/drive_comments.go +++ b/internal/cmd/drive_comments.go @@ -27,6 +27,7 @@ type DriveCommentsListCmd struct { All bool `name:"all" aliases:"all-pages,allpages" help:"Fetch all pages"` FailEmpty bool `name:"fail-empty" aliases:"non-empty,require-results" help:"Exit with code 3 if no results"` IncludeQuoted bool `name:"include-quoted" help:"Include the quoted content the comment is anchored to"` + Since string `name:"since" help:"Only return comments modified at or after this RFC3339 timestamp"` } func (c *DriveCommentsListCmd) Run(ctx context.Context, flags *RootFlags) error { @@ -38,6 +39,10 @@ func (c *DriveCommentsListCmd) Run(ctx context.Context, flags *RootFlags) error if c.Max <= 0 { return usage("max must be > 0") } + since, err := normalizeDriveCommentSince(c.Since) + if err != nil { + return err + } _, svc, err := requireDriveService(ctx, flags) if err != nil { @@ -48,6 +53,7 @@ func (c *DriveCommentsListCmd) Run(ctx context.Context, flags *RootFlags) error resourceID: fileID, includeQuoted: c.IncludeQuoted, page: c.Page, + since: since, all: c.All, failEmpty: c.FailEmpty, max: c.Max, diff --git a/internal/cmd/drive_comments_cmd_test.go b/internal/cmd/drive_comments_cmd_test.go index b3787bbc..b00bbcf6 100644 --- a/internal/cmd/drive_comments_cmd_test.go +++ b/internal/cmd/drive_comments_cmd_test.go @@ -31,6 +31,9 @@ func TestDriveCommentsListCmd_TextAndJSON(t *testing.T) { if r.URL.Query().Get("pageToken") != "p1" { t.Fatalf("expected pageToken=p1, got: %q", r.URL.RawQuery) } + if r.URL.Query().Get("startModifiedTime") != "2026-06-04T10:00:00Z" { + t.Fatalf("expected startModifiedTime, got: %q", r.URL.RawQuery) + } fields := r.URL.Query().Get("fields") if !strings.Contains(fields, "quotedFileContent") { t.Fatalf("expected quotedFileContent in fields, got: %q", fields) @@ -82,7 +85,7 @@ func TestDriveCommentsListCmd_TextAndJSON(t *testing.T) { textOut := captureStdout(t, func() { cmd := &DriveCommentsListCmd{} - if execErr := runKong(t, cmd, []string{"--max", "1", "--page", "p1", "--include-quoted", "id1"}, ctx, flags); execErr != nil { + if execErr := runKong(t, cmd, []string{"--max", "1", "--page", "p1", "--since", "2026-06-04T10:00:00Z", "--include-quoted", "id1"}, ctx, flags); execErr != nil { t.Fatalf("execute: %v", execErr) } }) @@ -103,7 +106,7 @@ func TestDriveCommentsListCmd_TextAndJSON(t *testing.T) { jsonOut := captureStdout(t, func() { cmd := &DriveCommentsListCmd{} - if execErr := runKong(t, cmd, []string{"--max", "1", "--page", "p1", "--include-quoted", "id1"}, ctx2, flags); execErr != nil { + if execErr := runKong(t, cmd, []string{"--max", "1", "--page", "p1", "--since", "2026-06-04T10:00:00Z", "--include-quoted", "id1"}, ctx2, flags); execErr != nil { t.Fatalf("execute: %v", execErr) } }) diff --git a/internal/cmd/drive_comments_validation_more_test.go b/internal/cmd/drive_comments_validation_more_test.go index b369f9fc..266eb9ee 100644 --- a/internal/cmd/drive_comments_validation_more_test.go +++ b/internal/cmd/drive_comments_validation_more_test.go @@ -29,6 +29,9 @@ func TestDriveComments_ValidationErrors(t *testing.T) { if err := (&DriveCommentsListCmd{FileID: "f1", Max: 0}).Run(ctx, flags); err == nil { t.Fatalf("expected list max error") } + if err := (&DriveCommentsListCmd{FileID: "f1", Max: 1, Since: "2026-06-04T10:00:00"}).Run(ctx, flags); err == nil { + t.Fatalf("expected list since error") + } if err := (&DriveCommentsGetCmd{}).Run(ctx, flags); err == nil { t.Fatalf("expected get missing fileId error") }