Skip to content

Conversation

@shanelindsay
Copy link
Contributor

Adds fuller Google Tasks support:

  • gog tasks add|update|done|undo|delete|clear for task management
  • gog tasks lists create <title> for creating tasklists
  • JSON-mode tests covering new endpoints

Tested:

  • go test ./...
  • Manual: gog tasks lists create projects (created list successfully)
Copilot AI review requested due to automatic review settings December 14, 2025 22:59
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR adds comprehensive Google Tasks support to the gog CLI tool, expanding the application from managing 4 Google services (Gmail, Calendar, Drive, Contacts) to 5. The implementation follows established patterns in the codebase for service registration, API client initialization, command structure, and testing.

  • Registers Tasks as a new service with appropriate OAuth scopes
  • Implements 8 task management commands: lists, list, add, update, done, undo, delete, and clear
  • Includes JSON output mode tests for 5 of the 8 commands

Reviewed changes

Copilot reviewed 9 out of 9 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
internal/googleauth/service.go Adds ServiceTasks constant and integrates it into service parsing and scope resolution
internal/googleauth/service_test.go Updates test assertions to include Tasks service in existing tests
internal/googleapi/tasks.go Creates new Tasks API client wrapper following existing service patterns
internal/googleapi/services_more_test.go Adds initialization test for Tasks service
internal/cmd/tasks.go Implements 8 task management commands with both text and JSON output modes
internal/cmd/root.go Integrates Tasks command into root CLI and updates documentation strings
internal/cmd/auth.go Updates auth command documentation to include Tasks
internal/cmd/execute_tasks_test.go Adds JSON output integration tests for 5 task commands
README.md Documents Tasks API setup and basic usage examples

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Tasks:

- `gog tasks lists --max 50`
- `gog tasks list <tasklistId> --max 50`
Copy link

Copilot AI Dec 14, 2025

Choose a reason for hiding this comment

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

The documentation only shows 2 basic read operations for Tasks, while the PR adds 8 commands total. Consider documenting additional task management commands such as:

  • gog tasks add <tasklistId> --title "Task title"
  • gog tasks done <tasklistId> <taskId>
  • gog tasks lists create <title>

This would help users discover the full functionality added in this PR and maintain consistency with other sections like Drive, Calendar, and Gmail which show various operations.

Suggested change
- `gog tasks list <tasklistId> --max 50`
- `gog tasks list <tasklistId> --max 50`
- `gog tasks add <tasklistId> --title "Task title"`
- `gog tasks done <tasklistId> <taskId>`
- `gog tasks lists create <title>`
Copilot uses AI. Check for mistakes.
Comment on lines +1 to +336
package cmd

import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"

"google.golang.org/api/option"
"google.golang.org/api/tasks/v1"
)

func TestExecute_TasksLists_JSON(t *testing.T) {
origNew := newTasksService
t.Cleanup(func() { newTasksService = origNew })

srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !(r.URL.Path == "/tasks/v1/users/@me/lists" && r.Method == http.MethodGet) {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"items": []map[string]any{
{"id": "l1", "title": "One"},
{"id": "l2", "title": "Two"},
},
})
}))
defer srv.Close()

svc, err := tasks.NewService(context.Background(),
option.WithoutAuthentication(),
option.WithHTTPClient(srv.Client()),
option.WithEndpoint(srv.URL+"/"),
)
if err != nil {
t.Fatalf("NewService: %v", err)
}
newTasksService = func(context.Context, string) (*tasks.Service, error) { return svc, nil }

out := captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{"--output", "json", "--account", "a@b.com", "tasks", "lists", "--max", "10"}); err != nil {
t.Fatalf("Execute: %v", err)
}
})
})

var parsed struct {
Tasklists []struct {
ID string `json:"id"`
Title string `json:"title"`
} `json:"tasklists"`
}
if err := json.Unmarshal([]byte(out), &parsed); err != nil {
t.Fatalf("json parse: %v\nout=%q", err, out)
}
if len(parsed.Tasklists) != 2 || parsed.Tasklists[0].ID != "l1" || parsed.Tasklists[1].ID != "l2" {
t.Fatalf("unexpected tasklists: %#v", parsed.Tasklists)
}
}

func TestExecute_TasksListsCreate_JSON(t *testing.T) {
origNew := newTasksService
t.Cleanup(func() { newTasksService = origNew })

srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !(r.URL.Path == "/tasks/v1/users/@me/lists" && r.Method == http.MethodPost) {
http.NotFound(w, r)
return
}
var body map[string]any
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if body["title"] != "Teaching" {
http.Error(w, "expected title Teaching", http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"id": "l3",
"title": "Teaching",
})
}))
defer srv.Close()

svc, err := tasks.NewService(context.Background(),
option.WithoutAuthentication(),
option.WithHTTPClient(srv.Client()),
option.WithEndpoint(srv.URL+"/"),
)
if err != nil {
t.Fatalf("NewService: %v", err)
}
newTasksService = func(context.Context, string) (*tasks.Service, error) { return svc, nil }

out := captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{"--output", "json", "--account", "a@b.com", "tasks", "lists", "create", "Teaching"}); err != nil {
t.Fatalf("Execute: %v", err)
}
})
})

var parsed struct {
Tasklist struct {
ID string `json:"id"`
Title string `json:"title"`
} `json:"tasklist"`
}
if err := json.Unmarshal([]byte(out), &parsed); err != nil {
t.Fatalf("json parse: %v\nout=%q", err, out)
}
if parsed.Tasklist.ID != "l3" || parsed.Tasklist.Title != "Teaching" {
t.Fatalf("unexpected tasklist: %#v", parsed.Tasklist)
}
}

func TestExecute_TasksList_JSON(t *testing.T) {
origNew := newTasksService
t.Cleanup(func() { newTasksService = origNew })

srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !(strings.HasPrefix(r.URL.Path, "/tasks/v1/lists/") && strings.HasSuffix(r.URL.Path, "/tasks") && r.Method == http.MethodGet) {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"items": []map[string]any{
{"id": "t1", "title": "Task One", "status": "needsAction"},
{"id": "t2", "title": "Task Two", "status": "completed"},
},
})
}))
defer srv.Close()

svc, err := tasks.NewService(context.Background(),
option.WithoutAuthentication(),
option.WithHTTPClient(srv.Client()),
option.WithEndpoint(srv.URL+"/"),
)
if err != nil {
t.Fatalf("NewService: %v", err)
}
newTasksService = func(context.Context, string) (*tasks.Service, error) { return svc, nil }

out := captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{"--output", "json", "--account", "a@b.com", "tasks", "list", "l1"}); err != nil {
t.Fatalf("Execute: %v", err)
}
})
})

var parsed struct {
Tasks []struct {
ID string `json:"id"`
Title string `json:"title"`
Status string `json:"status"`
} `json:"tasks"`
}
if err := json.Unmarshal([]byte(out), &parsed); err != nil {
t.Fatalf("json parse: %v\nout=%q", err, out)
}
if len(parsed.Tasks) != 2 || parsed.Tasks[0].ID != "t1" || parsed.Tasks[1].ID != "t2" {
t.Fatalf("unexpected tasks: %#v", parsed.Tasks)
}
}

func TestExecute_TasksAdd_JSON(t *testing.T) {
origNew := newTasksService
t.Cleanup(func() { newTasksService = origNew })

srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !(r.URL.Path == "/tasks/v1/lists/l1/tasks" && r.Method == http.MethodPost) {
http.NotFound(w, r)
return
}
var body map[string]any
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if body["title"] != "Hello" {
http.Error(w, "expected title Hello", http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"id": "t1",
"title": "Hello",
"status": "needsAction",
})
}))
defer srv.Close()

svc, err := tasks.NewService(context.Background(),
option.WithoutAuthentication(),
option.WithHTTPClient(srv.Client()),
option.WithEndpoint(srv.URL+"/"),
)
if err != nil {
t.Fatalf("NewService: %v", err)
}
newTasksService = func(context.Context, string) (*tasks.Service, error) { return svc, nil }

out := captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{"--output", "json", "--account", "a@b.com", "tasks", "add", "l1", "--title", "Hello"}); err != nil {
t.Fatalf("Execute: %v", err)
}
})
})

var parsed struct {
Task struct {
ID string `json:"id"`
Title string `json:"title"`
Status string `json:"status"`
} `json:"task"`
}
if err := json.Unmarshal([]byte(out), &parsed); err != nil {
t.Fatalf("json parse: %v\nout=%q", err, out)
}
if parsed.Task.ID != "t1" || parsed.Task.Title != "Hello" || parsed.Task.Status != "needsAction" {
t.Fatalf("unexpected task: %#v", parsed.Task)
}
}

func TestExecute_TasksDone_JSON(t *testing.T) {
origNew := newTasksService
t.Cleanup(func() { newTasksService = origNew })

srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !(r.URL.Path == "/tasks/v1/lists/l1/tasks/t1" && r.Method == http.MethodPatch) {
http.NotFound(w, r)
return
}
var body map[string]any
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if body["status"] != "completed" {
http.Error(w, "expected status completed", http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"id": "t1",
"title": "Hello",
"status": "completed",
})
}))
defer srv.Close()

svc, err := tasks.NewService(context.Background(),
option.WithoutAuthentication(),
option.WithHTTPClient(srv.Client()),
option.WithEndpoint(srv.URL+"/"),
)
if err != nil {
t.Fatalf("NewService: %v", err)
}
newTasksService = func(context.Context, string) (*tasks.Service, error) { return svc, nil }

out := captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{"--output", "json", "--account", "a@b.com", "tasks", "done", "l1", "t1"}); err != nil {
t.Fatalf("Execute: %v", err)
}
})
})

var parsed struct {
Task struct {
ID string `json:"id"`
Status string `json:"status"`
} `json:"task"`
}
if err := json.Unmarshal([]byte(out), &parsed); err != nil {
t.Fatalf("json parse: %v\nout=%q", err, out)
}
if parsed.Task.ID != "t1" || parsed.Task.Status != "completed" {
t.Fatalf("unexpected task: %#v", parsed.Task)
}
}

func TestExecute_TasksDelete_JSON(t *testing.T) {
origNew := newTasksService
t.Cleanup(func() { newTasksService = origNew })

srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !(r.URL.Path == "/tasks/v1/lists/l1/tasks/t1" && r.Method == http.MethodDelete) {
http.NotFound(w, r)
return
}
w.WriteHeader(http.StatusNoContent)
}))
defer srv.Close()

svc, err := tasks.NewService(context.Background(),
option.WithoutAuthentication(),
option.WithHTTPClient(srv.Client()),
option.WithEndpoint(srv.URL+"/"),
)
if err != nil {
t.Fatalf("NewService: %v", err)
}
newTasksService = func(context.Context, string) (*tasks.Service, error) { return svc, nil }

out := captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{"--output", "json", "--account", "a@b.com", "tasks", "delete", "l1", "t1"}); err != nil {
t.Fatalf("Execute: %v", err)
}
})
})

var parsed struct {
Deleted bool `json:"deleted"`
ID string `json:"id"`
}
if err := json.Unmarshal([]byte(out), &parsed); err != nil {
t.Fatalf("json parse: %v\nout=%q", err, out)
}
if !parsed.Deleted || parsed.ID != "t1" {
t.Fatalf("unexpected response: %#v", parsed)
}
}
Copy link

Copilot AI Dec 14, 2025

Choose a reason for hiding this comment

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

The test file covers only 5 of the 8 task commands introduced. Missing test coverage for:

  • tasks update command (newTasksUpdateCmd)
  • tasks undo command (newTasksUndoCmd)
  • tasks clear command (newTasksClearCmd)

These commands involve different endpoints and business logic (update uses PATCH with field tracking, undo sets status to needsAction, clear calls a different API endpoint) that should be tested to ensure they work correctly in JSON mode and properly handle API responses.

Copilot uses AI. Check for mistakes.
@shanelindsay
Copy link
Contributor Author

Addressed Copilot review items (more README examples + missing JSON tests) and fixed fmt-check.

Local checks (on latest head 8c085e6):

  • make fmt-check
  • go test ./...
  • make lint

Looks like GitHub Actions for PRs from forks is currently in action_required (no jobs created), so the CI workflow may need a maintainer “approve and run” in Actions settings/UI.

@steipete
Copy link
Owner

Thank you!

@steipete steipete merged commit d50d7f7 into steipete:main Dec 24, 2025
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

2 participants