From 8abc63efdbc0bb96cd6c9aa99d6e9166e0bcabae Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Sun, 22 Mar 2026 23:45:19 +0000 Subject: chore: unify and centralize agent configuration in .agent/ --- internal/api/interfaces.go | 1 - internal/api/todoist.go | 155 ++++++++++++---------------------------- internal/api/todoist_test.go | 166 ++++--------------------------------------- 3 files changed, 60 insertions(+), 262 deletions(-) (limited to 'internal/api') diff --git a/internal/api/interfaces.go b/internal/api/interfaces.go index c9962c9..cec64be 100644 --- a/internal/api/interfaces.go +++ b/internal/api/interfaces.go @@ -15,7 +15,6 @@ type TodoistAPI interface { UpdateTask(ctx context.Context, taskID string, updates map[string]interface{}) error CompleteTask(ctx context.Context, taskID string) error ReopenTask(ctx context.Context, taskID string) error - Sync(ctx context.Context, syncToken string) (*TodoistSyncResponse, error) } // TrelloAPI defines the interface for Trello operations diff --git a/internal/api/todoist.go b/internal/api/todoist.go index be699ce..d6058d3 100644 --- a/internal/api/todoist.go +++ b/internal/api/todoist.go @@ -10,22 +10,19 @@ import ( ) const ( - todoistBaseURL = "https://api.todoist.com/api/v1" - todoistSyncBaseURL = "https://api.todoist.com/sync/v9" + todoistBaseURL = "https://api.todoist.com/api/v1" ) // TodoistClient handles interactions with the Todoist API type TodoistClient struct { BaseClient - syncClient BaseClient - apiKey string + apiKey string } // NewTodoistClient creates a new Todoist API client func NewTodoistClient(apiKey string) *TodoistClient { return &TodoistClient{ BaseClient: NewBaseClient(todoistBaseURL), - syncClient: NewBaseClient(todoistSyncBaseURL), apiKey: apiKey, } } @@ -53,43 +50,33 @@ type todoistProjectResponse struct { Name string `json:"name"` } -// Sync API v9 response types -// TodoistSyncResponse represents the Sync API response -type TodoistSyncResponse struct { - SyncToken string `json:"sync_token"` - FullSync bool `json:"full_sync"` - Items []SyncItemResponse `json:"items"` - Projects []SyncProjectResponse `json:"projects"` -} - -// SyncItemResponse represents a task item from Sync API -type SyncItemResponse struct { - ID string `json:"id"` - Content string `json:"content"` - Description string `json:"description"` - ProjectID string `json:"project_id"` - Priority int `json:"priority"` - Labels []string `json:"labels"` - Due *dueInfo `json:"due"` - IsCompleted bool `json:"is_completed"` - IsDeleted bool `json:"is_deleted"` - AddedAt string `json:"added_at"` -} - -// SyncProjectResponse represents a project from Sync API -type SyncProjectResponse struct { - ID string `json:"id"` - Name string `json:"name"` - IsDeleted bool `json:"is_deleted"` +// todoistTasksPage represents the paginated response from the Todoist REST API v1 +type todoistTasksPage struct { + Results []todoistTaskResponse `json:"results"` + NextCursor *string `json:"next_cursor"` } // GetTasks fetches all active tasks from Todoist func (c *TodoistClient) GetTasks(ctx context.Context) ([]models.Task, error) { - var apiTasks []todoistTaskResponse - if err := c.Get(ctx, "/tasks", c.authHeaders(), &apiTasks); err != nil { - return nil, fmt.Errorf("failed to fetch tasks: %w", err) + var allTasks []todoistTaskResponse + cursor := "" + for { + path := "/tasks" + if cursor != "" { + path = "/tasks?cursor=" + cursor + } + var page todoistTasksPage + if err := c.Get(ctx, path, c.authHeaders(), &page); err != nil { + return nil, fmt.Errorf("failed to fetch tasks: %w", err) + } + allTasks = append(allTasks, page.Results...) + if page.NextCursor == nil || *page.NextCursor == "" { + break + } + cursor = *page.NextCursor } + apiTasks := allTasks // Fetch projects to get project names projects, err := c.GetProjects(ctx) @@ -129,12 +116,32 @@ func (c *TodoistClient) GetTasks(ctx context.Context) ([]models.Task, error) { return tasks, nil } +// todoistProjectsPage represents the paginated response for projects +type todoistProjectsPage struct { + Results []todoistProjectResponse `json:"results"` + NextCursor *string `json:"next_cursor"` +} + // GetProjects fetches all projects func (c *TodoistClient) GetProjects(ctx context.Context) ([]models.Project, error) { - var apiProjects []todoistProjectResponse - if err := c.Get(ctx, "/projects", c.authHeaders(), &apiProjects); err != nil { - return nil, fmt.Errorf("failed to fetch projects: %w", err) + var allProjects []todoistProjectResponse + cursor := "" + for { + path := "/projects" + if cursor != "" { + path = "/projects?cursor=" + cursor + } + var page todoistProjectsPage + if err := c.Get(ctx, path, c.authHeaders(), &page); err != nil { + return nil, fmt.Errorf("failed to fetch projects: %w", err) + } + allProjects = append(allProjects, page.Results...) + if page.NextCursor == nil || *page.NextCursor == "" { + break + } + cursor = *page.NextCursor } + apiProjects := allProjects projects := make([]models.Project, 0, len(apiProjects)) for _, apiProj := range apiProjects { @@ -147,76 +154,6 @@ func (c *TodoistClient) GetProjects(ctx context.Context) ([]models.Project, erro return projects, nil } -// Sync performs an incremental sync using the Sync API v9 -func (c *TodoistClient) Sync(ctx context.Context, syncToken string) (*TodoistSyncResponse, error) { - if syncToken == "" { - syncToken = "*" // Full sync - } - - payload := map[string]interface{}{ - "sync_token": syncToken, - "resource_types": []string{"items", "projects"}, - } - - var syncResp TodoistSyncResponse - if err := c.syncClient.Post(ctx, "/sync", c.authHeaders(), payload, &syncResp); err != nil { - return nil, fmt.Errorf("failed to perform sync: %w", err) - } - - return &syncResp, nil -} - -// ConvertSyncItemToTask converts a single sync item to a Task model. -// Returns the task and true if the item is active, or a zero Task and false if it should be skipped. -func ConvertSyncItemToTask(item SyncItemResponse, projectMap map[string]string) (models.Task, bool) { - if item.IsCompleted || item.IsDeleted { - return models.Task{}, false - } - - task := models.Task{ - ID: item.ID, - Content: item.Content, - Description: item.Description, - ProjectID: item.ProjectID, - ProjectName: projectMap[item.ProjectID], - Priority: item.Priority, - Completed: false, - Labels: item.Labels, - URL: fmt.Sprintf("https://todoist.com/app/task/%s", item.ID), - } - - if item.AddedAt != "" { - if createdAt, err := time.Parse(time.RFC3339, item.AddedAt); err == nil { - task.CreatedAt = createdAt - } - } - - task.DueDate = parseDueDate(item.Due) - return task, true -} - -// ConvertSyncItemsToTasks converts sync API items to Task models -func ConvertSyncItemsToTasks(items []SyncItemResponse, projectMap map[string]string) []models.Task { - tasks := make([]models.Task, 0, len(items)) - for _, item := range items { - if task, ok := ConvertSyncItemToTask(item, projectMap); ok { - tasks = append(tasks, task) - } - } - return tasks -} - -// BuildProjectMapFromSync builds a project ID to name map from sync response -func BuildProjectMapFromSync(projects []SyncProjectResponse) map[string]string { - projectMap := make(map[string]string) - for _, proj := range projects { - if !proj.IsDeleted { - projectMap[proj.ID] = proj.Name - } - } - return projectMap -} - // CreateTask creates a new task in Todoist func (c *TodoistClient) CreateTask(ctx context.Context, content, projectID string, dueDate *time.Time, priority int) (*models.Task, error) { payload := map[string]interface{}{"content": content} diff --git a/internal/api/todoist_test.go b/internal/api/todoist_test.go index 2204469..99b9e80 100644 --- a/internal/api/todoist_test.go +++ b/internal/api/todoist_test.go @@ -14,7 +14,6 @@ import ( func newTestTodoistClient(baseURL, apiKey string) *TodoistClient { client := NewTodoistClient(apiKey) client.BaseURL = baseURL - client.syncClient.BaseURL = baseURL return client } @@ -220,9 +219,11 @@ func TestTodoistClient_GetProjects(t *testing.T) { } // Return mock response - response := []todoistProjectResponse{ - {ID: "proj-1", Name: "Project 1"}, - {ID: "proj-2", Name: "Project 2"}, + response := todoistProjectsPage{ + Results: []todoistProjectResponse{ + {ID: "proj-1", Name: "Project 1"}, + {ID: "proj-2", Name: "Project 2"}, + }, } w.Header().Set("Content-Type", "application/json") @@ -265,17 +266,21 @@ func TestTodoistClient_GetTasks(t *testing.T) { // GetTasks also calls GetProjects internally if r.URL.Path == "/projects" { - response := []todoistProjectResponse{ - {ID: "proj-1", Name: "Project 1"}, + response := todoistProjectsPage{ + Results: []todoistProjectResponse{ + {ID: "proj-1", Name: "Project 1"}, + }, } json.NewEncoder(w).Encode(response) return } if r.URL.Path == "/tasks" { - response := []todoistTaskResponse{ - {ID: "task-1", Content: "Task 1", ProjectID: "proj-1", CreatedAt: time.Now().Format(time.RFC3339)}, - {ID: "task-2", Content: "Task 2", ProjectID: "proj-1", CreatedAt: time.Now().Format(time.RFC3339)}, + response := todoistTasksPage{ + Results: []todoistTaskResponse{ + {ID: "task-1", Content: "Task 1", ProjectID: "proj-1", CreatedAt: time.Now().Format(time.RFC3339)}, + {ID: "task-2", Content: "Task 2", ProjectID: "proj-1", CreatedAt: time.Now().Format(time.RFC3339)}, + }, } json.NewEncoder(w).Encode(response) return @@ -345,146 +350,3 @@ func TestTodoistClient_UpdateTask(t *testing.T) { } } -func TestTodoistClient_Sync(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method != "POST" { - t.Errorf("Expected POST, got %s", r.Method) - } - if r.URL.Path != "/sync" { - t.Errorf("Expected path /sync, got %s", r.URL.Path) - } - - response := TodoistSyncResponse{ - SyncToken: "new-sync-token", - FullSync: true, - Items: []SyncItemResponse{ - {ID: "item-1", Content: "Item 1", ProjectID: "proj-1"}, - }, - Projects: []SyncProjectResponse{ - {ID: "proj-1", Name: "Project 1"}, - }, - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(response) - })) - defer server.Close() - - client := newTestTodoistClient(server.URL, "test-key") - resp, err := client.Sync(context.Background(), "*") - if err != nil { - t.Fatalf("Sync failed: %v", err) - } - - if resp.SyncToken != "new-sync-token" { - t.Errorf("Expected sync token 'new-sync-token', got '%s'", resp.SyncToken) - } - if len(resp.Items) != 1 { - t.Errorf("Expected 1 item, got %d", len(resp.Items)) - } -} - -func TestConvertSyncItemsToTasks(t *testing.T) { - projects := map[string]string{ - "proj-1": "Project 1", - } - - items := []SyncItemResponse{ - { - ID: "item-1", - Content: "Task 1", - Description: "Description 1", - ProjectID: "proj-1", - Priority: 3, - Labels: []string{"label1"}, - }, - { - ID: "item-2", - Content: "Completed Task", - ProjectID: "proj-1", - IsCompleted: true, - }, - } - - tasks := ConvertSyncItemsToTasks(items, projects) - - // Should skip completed task - if len(tasks) != 1 { - t.Errorf("Expected 1 task (excluding completed), got %d", len(tasks)) - } - - if tasks[0].ID != "item-1" { - t.Errorf("Expected task ID 'item-1', got '%s'", tasks[0].ID) - } - if tasks[0].ProjectName != "Project 1" { - t.Errorf("Expected project name 'Project 1', got '%s'", tasks[0].ProjectName) - } -} - -func TestConvertSyncItemToTask(t *testing.T) { - projects := map[string]string{"proj-1": "Project 1"} - - t.Run("active item returns task and true", func(t *testing.T) { - item := SyncItemResponse{ - ID: "item-1", - Content: "Active Task", - Description: "desc", - ProjectID: "proj-1", - Priority: 2, - Labels: []string{"work"}, - AddedAt: "2026-01-01T00:00:00Z", - } - task, ok := ConvertSyncItemToTask(item, projects) - if !ok { - t.Fatal("expected ok=true for active item") - } - if task.ID != "item-1" { - t.Errorf("expected ID 'item-1', got '%s'", task.ID) - } - if task.Content != "Active Task" { - t.Errorf("expected Content 'Active Task', got '%s'", task.Content) - } - if task.ProjectName != "Project 1" { - t.Errorf("expected ProjectName 'Project 1', got '%s'", task.ProjectName) - } - if task.Completed { - t.Error("expected Completed=false") - } - if task.URL != "https://todoist.com/app/task/item-1" { - t.Errorf("unexpected URL: %s", task.URL) - } - }) - - t.Run("completed item returns false", func(t *testing.T) { - item := SyncItemResponse{ID: "item-2", Content: "Done", ProjectID: "proj-1", IsCompleted: true} - _, ok := ConvertSyncItemToTask(item, projects) - if ok { - t.Error("expected ok=false for completed item") - } - }) - - t.Run("deleted item returns false", func(t *testing.T) { - item := SyncItemResponse{ID: "item-3", Content: "Gone", ProjectID: "proj-1", IsDeleted: true} - _, ok := ConvertSyncItemToTask(item, projects) - if ok { - t.Error("expected ok=false for deleted item") - } - }) -} - -func TestBuildProjectMapFromSync(t *testing.T) { - projects := []SyncProjectResponse{ - {ID: "proj-1", Name: "Project 1"}, - {ID: "proj-2", Name: "Project 2"}, - } - - projectMap := BuildProjectMapFromSync(projects) - - if len(projectMap) != 2 { - t.Errorf("Expected 2 projects in map, got %d", len(projectMap)) - } - - if projectMap["proj-1"] != "Project 1" { - t.Errorf("Expected 'Project 1', got '%s'", projectMap["proj-1"]) - } -} -- cgit v1.2.3