diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index ef56767..d346ac7 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -18,7 +18,7 @@ {"id":"kanboard-api-l9b","title":"Implement Column API methods","description":"Implement direct API methods for column operations.\n\n## Methods to implement\n- GetColumns(ctx, projectID int) ([]Column, error) - getColumns\n- GetColumn(ctx, columnID int) (*Column, error) - getColumn\n\n## Files to create\n- columns.go\n\n## Acceptance criteria\n- Columns returned sorted by position\n- Returns ErrColumnNotFound when appropriate","status":"closed","priority":1,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-15T17:35:16.23369865+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-15T18:23:40.255170515+01:00","closed_at":"2026-01-15T18:23:40.255170515+01:00","close_reason":"Closed","dependencies":[{"issue_id":"kanboard-api-l9b","depends_on_id":"kanboard-api-uls","type":"blocks","created_at":"2026-01-15T17:42:53.022754817+01:00","created_by":"Oliver Jakoubek"},{"issue_id":"kanboard-api-l9b","depends_on_id":"kanboard-api-cyc","type":"blocks","created_at":"2026-01-15T17:42:53.094484504+01:00","created_by":"Oliver Jakoubek"}]} {"id":"kanboard-api-ob2","title":"Add MIT license","description":"Add MIT license file to the repository.\n\n## Files to create\n- LICENSE\n\n## Content\nStandard MIT license text with appropriate copyright holder.\n\n## Acceptance criteria\n- Valid MIT license\n- Proper copyright attribution","status":"open","priority":3,"issue_type":"chore","owner":"mail@oliverjakoubek.de","created_at":"2026-01-15T17:36:53.955909551+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-15T17:36:53.955909551+01:00"} {"id":"kanboard-api-s7k","title":"Implement error types and handling","description":"Implement comprehensive error types for the Kanboard API client.\n\n## Sentinel Errors\n- ErrConnectionFailed, ErrTimeout (network)\n- ErrUnauthorized, ErrForbidden (auth)\n- ErrNotFound, ErrProjectNotFound, ErrTaskNotFound, ErrColumnNotFound, ErrCommentNotFound (resources)\n- ErrAlreadyInLastColumn, ErrAlreadyInFirstColumn, ErrTaskClosed, ErrTaskOpen (logic)\n- ErrEmptyTitle, ErrInvalidProjectID (validation)\n- ErrAPIError (API)\n\n## APIError struct\n- Code int\n- Message string\n- Error() string method\n\n## Helper functions\n- IsNotFound(err error) bool\n- IsUnauthorized(err error) bool\n- IsAPIError(err error) bool\n\n## Files to create\n- errors.go\n\n## Acceptance criteria\n- All errors properly support errors.Is/errors.As\n- Error wrapping with %w for context preservation","status":"closed","priority":0,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-15T17:34:54.484116412+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-15T18:13:03.195687148+01:00","closed_at":"2026-01-15T18:13:03.195687148+01:00","close_reason":"Closed"} -{"id":"kanboard-api-t36","title":"Implement MoveToNextColumn with column logic","description":"Implement TaskScope.MoveToNextColumn with automatic column position logic.\n\n## Method to implement\n- TaskScope.MoveToNextColumn(ctx) error\n\n## Workflow\n1. Get task via getTask\n2. Get columns for task's project via getColumns\n3. Sort columns by position\n4. Find current column position\n5. If last column: return ErrAlreadyInLastColumn\n6. Move to next column via moveTaskPosition\n\n## Files to modify\n- task_scope.go\n\n## Acceptance criteria\n- Correctly identifies next column by position\n- Returns ErrAlreadyInLastColumn for last column\n- Handles column gaps (positions 1, 3, 5 etc.)","status":"open","priority":1,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-15T17:36:29.900647957+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-15T17:36:29.900647957+01:00","dependencies":[{"issue_id":"kanboard-api-t36","depends_on_id":"kanboard-api-una","type":"blocks","created_at":"2026-01-15T17:45:32.256714732+01:00","created_by":"Oliver Jakoubek"},{"issue_id":"kanboard-api-t36","depends_on_id":"kanboard-api-l9b","type":"blocks","created_at":"2026-01-15T17:45:32.319227723+01:00","created_by":"Oliver Jakoubek"}]} +{"id":"kanboard-api-t36","title":"Implement MoveToNextColumn with column logic","description":"Implement TaskScope.MoveToNextColumn with automatic column position logic.\n\n## Method to implement\n- TaskScope.MoveToNextColumn(ctx) error\n\n## Workflow\n1. Get task via getTask\n2. Get columns for task's project via getColumns\n3. Sort columns by position\n4. Find current column position\n5. If last column: return ErrAlreadyInLastColumn\n6. Move to next column via moveTaskPosition\n\n## Files to modify\n- task_scope.go\n\n## Acceptance criteria\n- Correctly identifies next column by position\n- Returns ErrAlreadyInLastColumn for last column\n- Handles column gaps (positions 1, 3, 5 etc.)","status":"closed","priority":1,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-15T17:36:29.900647957+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-15T18:41:56.137534164+01:00","closed_at":"2026-01-15T18:41:56.137534164+01:00","close_reason":"Closed","dependencies":[{"issue_id":"kanboard-api-t36","depends_on_id":"kanboard-api-una","type":"blocks","created_at":"2026-01-15T17:45:32.256714732+01:00","created_by":"Oliver Jakoubek"},{"issue_id":"kanboard-api-t36","depends_on_id":"kanboard-api-l9b","type":"blocks","created_at":"2026-01-15T17:45:32.319227723+01:00","created_by":"Oliver Jakoubek"}]} {"id":"kanboard-api-ukc","title":"Set up Mage build system","description":"Set up Mage build system for development automation.\n\n## Directory structure\n```\nmagefiles/\n├── go.mod\n├── go.sum\n└── magefile.go\n```\n\n## Targets to implement\n- Test() - Run all tests with race detection and coverage\n- Coverage() - Run tests and open HTML coverage report\n- Lint() - Run golangci-lint\n- Build() - Verify module compiles\n\n## Files to create\n- magefiles/go.mod\n- magefiles/magefile.go\n\n## Acceptance criteria\n- `mage test` runs tests with -race and -coverprofile\n- `mage lint` runs golangci-lint\n- `mage build` verifies compilation","status":"open","priority":2,"issue_type":"chore","owner":"mail@oliverjakoubek.de","created_at":"2026-01-15T17:36:30.260235504+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-15T17:36:30.260235504+01:00"} {"id":"kanboard-api-uls","title":"Implement Client struct with fluent configuration","description":"Implement the main Client struct with fluent builder pattern for configuration.\n\n## Requirements\n- Client struct with baseURL, httpClient, auth, logger, requestID\n- NewClient(baseURL string) constructor\n- Fluent configuration methods:\n - WithAPIToken(token string) *Client\n - WithBasicAuth(username, password string) *Client\n - WithHTTPClient(client *http.Client) *Client\n - WithTimeout(timeout time.Duration) *Client\n - WithLogger(logger *slog.Logger) *Client\n- Default timeout of 30 seconds\n- Thread-safe for concurrent use\n\n## Files to create\n- client.go\n\n## Acceptance criteria\n- Client is immutable after configuration\n- Thread-safe concurrent usage\n- Configurable HTTP client and logger","status":"closed","priority":0,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-15T17:34:54.051926732+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-15T18:14:16.530243958+01:00","closed_at":"2026-01-15T18:14:16.530243958+01:00","close_reason":"Closed","dependencies":[{"issue_id":"kanboard-api-uls","depends_on_id":"kanboard-api-2g1","type":"blocks","created_at":"2026-01-15T17:42:48.457362117+01:00","created_by":"Oliver Jakoubek"},{"issue_id":"kanboard-api-uls","depends_on_id":"kanboard-api-k33","type":"blocks","created_at":"2026-01-15T17:42:48.576622508+01:00","created_by":"Oliver Jakoubek"},{"issue_id":"kanboard-api-uls","depends_on_id":"kanboard-api-s7k","type":"blocks","created_at":"2026-01-15T17:42:48.749342195+01:00","created_by":"Oliver Jakoubek"}]} {"id":"kanboard-api-una","title":"Implement TaskScope fluent builder (core methods)","description":"Implement TaskScope for fluent task-scoped operations (core methods).\n\n## Requirements\n- TaskScope struct with client and taskID\n- Client.Task(taskID int) *TaskScope method\n- Core TaskScope methods:\n - Get(ctx) (*Task, error)\n - Close(ctx) error\n - Open(ctx) error\n - MoveToColumn(ctx, columnID int) error\n - MoveToProject(ctx, projectID int) error\n - Update(ctx, params *TaskUpdateParams) error\n\n## Files to create\n- task_scope.go\n\n## Example usage\n```go\ntask, _ := client.Task(123).Get(ctx)\nclient.Task(123).Close(ctx)\nclient.Task(123).Update(ctx, kanboard.NewTaskUpdate().SetTitle(\"New\"))\n```\n\n## Acceptance criteria\n- All methods delegate to direct Client methods\n- Proper error propagation","status":"closed","priority":1,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-15T17:35:41.173930396+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-15T18:34:32.369975567+01:00","closed_at":"2026-01-15T18:34:32.369975567+01:00","close_reason":"Closed","dependencies":[{"issue_id":"kanboard-api-una","depends_on_id":"kanboard-api-91a","type":"blocks","created_at":"2026-01-15T17:43:31.198935686+01:00","created_by":"Oliver Jakoubek"},{"issue_id":"kanboard-api-una","depends_on_id":"kanboard-api-5fb","type":"blocks","created_at":"2026-01-15T17:43:31.261453756+01:00","created_by":"Oliver Jakoubek"}]} diff --git a/task_scope.go b/task_scope.go index 7abec26..a92da6f 100644 --- a/task_scope.go +++ b/task_scope.go @@ -42,6 +42,86 @@ func (t *TaskScope) MoveToColumn(ctx context.Context, columnID int) error { return t.client.MoveTaskPosition(ctx, int(task.ProjectID), t.taskID, columnID, 0, int(task.SwimlaneID)) } +// MoveToNextColumn moves the task to the next column in the workflow. +// Columns are ordered by their position field. +// Returns ErrAlreadyInLastColumn if the task is already in the last column. +func (t *TaskScope) MoveToNextColumn(ctx context.Context) error { + // Get task to find current column and project + task, err := t.Get(ctx) + if err != nil { + return err + } + + // Get all columns for the project (already sorted by position) + columns, err := t.client.GetColumns(ctx, int(task.ProjectID)) + if err != nil { + return err + } + + if len(columns) == 0 { + return ErrAlreadyInLastColumn + } + + // Find the current column index + currentColumnID := int(task.ColumnID) + currentIndex := -1 + for i, col := range columns { + if int(col.ID) == currentColumnID { + currentIndex = i + break + } + } + + // If current column not found or is the last column + if currentIndex == -1 || currentIndex >= len(columns)-1 { + return ErrAlreadyInLastColumn + } + + // Move to the next column + nextColumn := columns[currentIndex+1] + return t.client.MoveTaskPosition(ctx, int(task.ProjectID), t.taskID, int(nextColumn.ID), 0, int(task.SwimlaneID)) +} + +// MoveToPreviousColumn moves the task to the previous column in the workflow. +// Columns are ordered by their position field. +// Returns ErrAlreadyInFirstColumn if the task is already in the first column. +func (t *TaskScope) MoveToPreviousColumn(ctx context.Context) error { + // Get task to find current column and project + task, err := t.Get(ctx) + if err != nil { + return err + } + + // Get all columns for the project (already sorted by position) + columns, err := t.client.GetColumns(ctx, int(task.ProjectID)) + if err != nil { + return err + } + + if len(columns) == 0 { + return ErrAlreadyInFirstColumn + } + + // Find the current column index + currentColumnID := int(task.ColumnID) + currentIndex := -1 + for i, col := range columns { + if int(col.ID) == currentColumnID { + currentIndex = i + break + } + } + + // If current column not found or is the first column + if currentIndex <= 0 { + return ErrAlreadyInFirstColumn + } + + // Move to the previous column + prevColumn := columns[currentIndex-1] + return t.client.MoveTaskPosition(ctx, int(task.ProjectID), t.taskID, int(prevColumn.ID), 0, int(task.SwimlaneID)) +} + // MoveToProject moves the task to a different project. func (t *TaskScope) MoveToProject(ctx context.Context, projectID int) error { return t.client.MoveTaskToProject(ctx, t.taskID, projectID) diff --git a/task_scope_test.go b/task_scope_test.go index 2a5679e..f6a32d9 100644 --- a/task_scope_test.go +++ b/task_scope_test.go @@ -3,6 +3,7 @@ package kanboard import ( "context" "encoding/json" + "errors" "net/http" "net/http/httptest" "testing" @@ -180,6 +181,209 @@ func TestTaskScope_MoveToColumn(t *testing.T) { } } +func TestTaskScope_MoveToNextColumn(t *testing.T) { + callCount := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req JSONRPCRequest + json.NewDecoder(r.Body).Decode(&req) + + callCount++ + switch callCount { + case 1: + // First call: getTask + resp := JSONRPCResponse{ + JSONRPC: "2.0", + ID: req.ID, + Result: json.RawMessage(`{"id": "42", "project_id": "1", "column_id": "2", "swimlane_id": "0", "is_active": "1"}`), + } + json.NewEncoder(w).Encode(resp) + case 2: + // Second call: getColumns + if req.Method != "getColumns" { + t.Errorf("expected method=getColumns, got %s", req.Method) + } + // Return columns in position order + resp := JSONRPCResponse{ + JSONRPC: "2.0", + ID: req.ID, + Result: json.RawMessage(`[ + {"id": "1", "title": "Backlog", "position": "1", "project_id": "1"}, + {"id": "2", "title": "In Progress", "position": "2", "project_id": "1"}, + {"id": "3", "title": "Done", "position": "3", "project_id": "1"} + ]`), + } + json.NewEncoder(w).Encode(resp) + case 3: + // Third call: moveTaskPosition + if req.Method != "moveTaskPosition" { + t.Errorf("expected method=moveTaskPosition, got %s", req.Method) + } + params := req.Params.(map[string]any) + if params["column_id"].(float64) != 3 { + t.Errorf("expected column_id=3 (Done), got %v", params["column_id"]) + } + resp := JSONRPCResponse{ + JSONRPC: "2.0", + ID: req.ID, + Result: json.RawMessage(`true`), + } + json.NewEncoder(w).Encode(resp) + } + })) + defer server.Close() + + client := NewClient(server.URL).WithAPIToken("test-token") + + err := client.Task(42).MoveToNextColumn(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestTaskScope_MoveToNextColumn_AlreadyInLastColumn(t *testing.T) { + callCount := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req JSONRPCRequest + json.NewDecoder(r.Body).Decode(&req) + + callCount++ + switch callCount { + case 1: + // Task is already in column 3 (Done) + resp := JSONRPCResponse{ + JSONRPC: "2.0", + ID: req.ID, + Result: json.RawMessage(`{"id": "42", "project_id": "1", "column_id": "3", "swimlane_id": "0", "is_active": "1"}`), + } + json.NewEncoder(w).Encode(resp) + case 2: + // getColumns + resp := JSONRPCResponse{ + JSONRPC: "2.0", + ID: req.ID, + Result: json.RawMessage(`[ + {"id": "1", "title": "Backlog", "position": "1", "project_id": "1"}, + {"id": "2", "title": "In Progress", "position": "2", "project_id": "1"}, + {"id": "3", "title": "Done", "position": "3", "project_id": "1"} + ]`), + } + json.NewEncoder(w).Encode(resp) + default: + t.Error("moveTaskPosition should not be called when already in last column") + } + })) + defer server.Close() + + client := NewClient(server.URL).WithAPIToken("test-token") + + err := client.Task(42).MoveToNextColumn(context.Background()) + if err == nil { + t.Fatal("expected error for task already in last column") + } + + if !errors.Is(err, ErrAlreadyInLastColumn) { + t.Errorf("expected ErrAlreadyInLastColumn, got %v", err) + } +} + +func TestTaskScope_MoveToPreviousColumn(t *testing.T) { + callCount := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req JSONRPCRequest + json.NewDecoder(r.Body).Decode(&req) + + callCount++ + switch callCount { + case 1: + // Task in column 2 (In Progress) + resp := JSONRPCResponse{ + JSONRPC: "2.0", + ID: req.ID, + Result: json.RawMessage(`{"id": "42", "project_id": "1", "column_id": "2", "swimlane_id": "0", "is_active": "1"}`), + } + json.NewEncoder(w).Encode(resp) + case 2: + // getColumns + resp := JSONRPCResponse{ + JSONRPC: "2.0", + ID: req.ID, + Result: json.RawMessage(`[ + {"id": "1", "title": "Backlog", "position": "1", "project_id": "1"}, + {"id": "2", "title": "In Progress", "position": "2", "project_id": "1"}, + {"id": "3", "title": "Done", "position": "3", "project_id": "1"} + ]`), + } + json.NewEncoder(w).Encode(resp) + case 3: + // moveTaskPosition + params := req.Params.(map[string]any) + if params["column_id"].(float64) != 1 { + t.Errorf("expected column_id=1 (Backlog), got %v", params["column_id"]) + } + resp := JSONRPCResponse{ + JSONRPC: "2.0", + ID: req.ID, + Result: json.RawMessage(`true`), + } + json.NewEncoder(w).Encode(resp) + } + })) + defer server.Close() + + client := NewClient(server.URL).WithAPIToken("test-token") + + err := client.Task(42).MoveToPreviousColumn(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestTaskScope_MoveToPreviousColumn_AlreadyInFirstColumn(t *testing.T) { + callCount := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req JSONRPCRequest + json.NewDecoder(r.Body).Decode(&req) + + callCount++ + switch callCount { + case 1: + // Task is already in column 1 (Backlog) + resp := JSONRPCResponse{ + JSONRPC: "2.0", + ID: req.ID, + Result: json.RawMessage(`{"id": "42", "project_id": "1", "column_id": "1", "swimlane_id": "0", "is_active": "1"}`), + } + json.NewEncoder(w).Encode(resp) + case 2: + // getColumns + resp := JSONRPCResponse{ + JSONRPC: "2.0", + ID: req.ID, + Result: json.RawMessage(`[ + {"id": "1", "title": "Backlog", "position": "1", "project_id": "1"}, + {"id": "2", "title": "In Progress", "position": "2", "project_id": "1"}, + {"id": "3", "title": "Done", "position": "3", "project_id": "1"} + ]`), + } + json.NewEncoder(w).Encode(resp) + default: + t.Error("moveTaskPosition should not be called when already in first column") + } + })) + defer server.Close() + + client := NewClient(server.URL).WithAPIToken("test-token") + + err := client.Task(42).MoveToPreviousColumn(context.Background()) + if err == nil { + t.Fatal("expected error for task already in first column") + } + + if !errors.Is(err, ErrAlreadyInFirstColumn) { + t.Errorf("expected ErrAlreadyInFirstColumn, got %v", err) + } +} + func TestTaskScope_MoveToProject(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var req JSONRPCRequest