kanboard-api/tasks.go
Oliver Jakoubek 449cd2626c feat: add OperationFailedError with actionable hints
The Kanboard API returns only true/false for many operations without
explaining why they failed. Added OperationFailedError type that
includes operation details and hints about possible causes.

Updated MoveTaskPosition and MoveTaskToProject to use this new error
type, providing users with actionable debugging information instead
of generic "failed to move task" messages.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 11:12:25 +01:00

231 lines
6.2 KiB
Go

package kanboard
import (
"context"
"fmt"
"golang.org/x/sync/errgroup"
)
// GetTask returns a task by its ID.
// Returns ErrTaskNotFound if the task does not exist.
func (c *Client) GetTask(ctx context.Context, taskID int) (*Task, error) {
params := map[string]int{"task_id": taskID}
var result *Task
if err := c.call(ctx, "getTask", params, &result); err != nil {
return nil, fmt.Errorf("getTask: %w", err)
}
if result == nil {
return nil, fmt.Errorf("%w: task %d", ErrTaskNotFound, taskID)
}
return result, nil
}
// GetAllTasks returns all tasks for a project with the specified status.
func (c *Client) GetAllTasks(ctx context.Context, projectID int, status TaskStatus) ([]Task, error) {
params := map[string]int{
"project_id": projectID,
"status_id": int(status),
}
var result []Task
if err := c.call(ctx, "getAllTasks", params, &result); err != nil {
return nil, fmt.Errorf("getAllTasks: %w", err)
}
return result, nil
}
// CreateTask creates a new task and returns the created task.
func (c *Client) CreateTask(ctx context.Context, req CreateTaskRequest) (*Task, error) {
var taskID IntOrFalse
if err := c.call(ctx, "createTask", req, &taskID); err != nil {
return nil, fmt.Errorf("createTask: %w", err)
}
if taskID == 0 {
return nil, fmt.Errorf("createTask: failed to create task")
}
// Fetch the created task to return full details
return c.GetTask(ctx, int(taskID))
}
// UpdateTask updates an existing task.
// Only non-nil fields in the request will be updated.
func (c *Client) UpdateTask(ctx context.Context, req UpdateTaskRequest) error {
var success bool
if err := c.call(ctx, "updateTask", req, &success); err != nil {
return fmt.Errorf("updateTask: %w", err)
}
if !success {
return fmt.Errorf("updateTask: update failed")
}
return nil
}
// UpdateTaskFromParams updates an existing task using TaskUpdateParams.
// This provides a fluent interface for task updates.
func (c *Client) UpdateTaskFromParams(ctx context.Context, taskID int, params *TaskUpdateParams) error {
req := params.toUpdateTaskRequest(taskID)
return c.UpdateTask(ctx, req)
}
// CloseTask closes a task (sets it to inactive).
// Returns ErrTaskClosed if the task is already closed.
func (c *Client) CloseTask(ctx context.Context, taskID int) error {
params := map[string]int{"task_id": taskID}
var success bool
if err := c.call(ctx, "closeTask", params, &success); err != nil {
return fmt.Errorf("closeTask: %w", err)
}
if !success {
return fmt.Errorf("%w: task %d", ErrTaskClosed, taskID)
}
return nil
}
// OpenTask opens a task (sets it to active).
// Returns ErrTaskOpen if the task is already open.
func (c *Client) OpenTask(ctx context.Context, taskID int) error {
params := map[string]int{"task_id": taskID}
var success bool
if err := c.call(ctx, "openTask", params, &success); err != nil {
return fmt.Errorf("openTask: %w", err)
}
if !success {
return fmt.Errorf("%w: task %d", ErrTaskOpen, taskID)
}
return nil
}
// SearchTasks searches for tasks in a project using Kanboard's query syntax.
// The query supports filters like: status:open, assignee:me, color:red, etc.
func (c *Client) SearchTasks(ctx context.Context, projectID int, query string) ([]Task, error) {
params := map[string]any{
"project_id": projectID,
"query": query,
}
var result []Task
if err := c.call(ctx, "searchTasks", params, &result); err != nil {
return nil, fmt.Errorf("searchTasks: %w", err)
}
return result, nil
}
// MoveTaskPosition moves a task to a specific position within a column and swimlane.
// Use position=1 for first position, position=0 to append at end.
func (c *Client) MoveTaskPosition(ctx context.Context, projectID, taskID, columnID, position, swimlaneID int) error {
params := map[string]int{
"project_id": projectID,
"task_id": taskID,
"column_id": columnID,
"position": position,
"swimlane_id": swimlaneID,
}
var success bool
if err := c.call(ctx, "moveTaskPosition", params, &success); err != nil {
return fmt.Errorf("moveTaskPosition: %w", err)
}
if !success {
return &OperationFailedError{
Operation: fmt.Sprintf("moveTaskPosition(task=%d, column=%d, project=%d)", taskID, columnID, projectID),
Hints: []string{
"task may not exist",
"column may not belong to project",
"insufficient permissions",
"task may already be in target position",
},
}
}
return nil
}
// MoveTaskToProject moves a task to a different project.
func (c *Client) MoveTaskToProject(ctx context.Context, taskID, projectID int) error {
params := map[string]int{
"task_id": taskID,
"project_id": projectID,
}
var success bool
if err := c.call(ctx, "moveTaskToProject", params, &success); err != nil {
return fmt.Errorf("moveTaskToProject: %w", err)
}
if !success {
return &OperationFailedError{
Operation: fmt.Sprintf("moveTaskToProject(task=%d, project=%d)", taskID, projectID),
Hints: []string{
"task may not exist",
"target project may not exist",
"insufficient permissions",
},
}
}
return nil
}
// SearchTasksGlobally searches for tasks across all accessible projects.
// The search is executed in parallel across all projects using errgroup.
// If any project search fails, all ongoing searches are cancelled.
func (c *Client) SearchTasksGlobally(ctx context.Context, query string) ([]Task, error) {
// Get all accessible projects
projects, err := c.GetAllProjects(ctx)
if err != nil {
return nil, fmt.Errorf("searchTasksGlobally: %w", err)
}
if len(projects) == 0 {
return []Task{}, nil
}
// Use errgroup for parallel execution with context cancellation
g, ctx := errgroup.WithContext(ctx)
// Slice to store results from each project (one per project, thread-safe by index)
results := make([][]Task, len(projects))
// Launch parallel searches
for i, project := range projects {
i, projectID := i, int(project.ID)
g.Go(func() error {
tasks, err := c.SearchTasks(ctx, projectID, query)
if err != nil {
return err
}
results[i] = tasks
return nil
})
}
// Wait for all goroutines
if err := g.Wait(); err != nil {
return nil, fmt.Errorf("searchTasksGlobally: %w", err)
}
// Aggregate results
var allTasks []Task
for _, tasks := range results {
allTasks = append(allTasks, tasks...)
}
return allTasks, nil
}