feat(tasks): manage tasks + create tasklists#10
Conversation
There was a problem hiding this comment.
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` |
There was a problem hiding this comment.
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.
| - `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>` |
| 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) | ||
| } | ||
| } |
There was a problem hiding this comment.
The test file covers only 5 of the 8 task commands introduced. Missing test coverage for:
tasks updatecommand (newTasksUpdateCmd)tasks undocommand (newTasksUndoCmd)tasks clearcommand (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.
|
Addressed Copilot review items (more README examples + missing JSON tests) and fixed fmt-check. Local checks (on latest head
Looks like GitHub Actions for PRs from forks is currently in |
|
Thank you! |
Adds fuller Google Tasks support:
gog tasks add|update|done|undo|delete|clearfor task managementgog tasks lists create <title>for creating tasklistsTested:
go test ./...gog tasks lists create projects(created list successfully)