- Notifications
You must be signed in to change notification settings - Fork 5
feat(tasks): manage tasks + create tasklists #10
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
There was a problem hiding this 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` |
Copilot AI Dec 14, 2025
There was a problem hiding this comment.
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.
| - `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) | ||
| } | ||
| } |
Copilot AI Dec 14, 2025
There was a problem hiding this comment.
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 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)