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>
231 lines
6.2 KiB
Go
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
|
|
}
|