diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 8969eee..b7fa33d 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -15,7 +15,7 @@ {"id":"kanboard-api-h4u","title":"Implement Comment API methods","description":"Implement direct API methods for comment operations.\n\n## Methods to implement\n- GetAllComments(ctx, taskID int) ([]Comment, error) - getAllComments\n- CreateComment(ctx, taskID, userID int, content string) (*Comment, error) - createComment\n- UpdateComment(ctx, commentID int, content string) error - updateComment\n- RemoveComment(ctx, commentID int) error - removeComment\n\n## TaskScope methods to add\n- AddComment(ctx, content string) (*Comment, error)\n- GetComments(ctx) ([]Comment, error)\n\n## Files to create\n- comments.go\n- task_scope.go (extend)\n\n## Acceptance criteria\n- CreateComment returns the created comment\n- Returns ErrCommentNotFound when appropriate","status":"open","priority":1,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-15T17:36:08.156950163+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-15T17:36:08.156950163+01:00","dependencies":[{"issue_id":"kanboard-api-h4u","depends_on_id":"kanboard-api-uls","type":"blocks","created_at":"2026-01-15T17:43:48.898295911+01:00","created_by":"Oliver Jakoubek"},{"issue_id":"kanboard-api-h4u","depends_on_id":"kanboard-api-cyc","type":"blocks","created_at":"2026-01-15T17:43:49.058259216+01:00","created_by":"Oliver Jakoubek"}]} {"id":"kanboard-api-k33","title":"Implement authentication system","description":"Implement HTTP Basic Auth support for both API token and user/password authentication.\n\n## Requirements\n- Authenticator interface for auth strategies\n- API Token auth: HTTP Basic with username `jsonrpc` and API token as password\n- User/Password auth: HTTP Basic with username and password\n- WithAPIToken(token string) fluent method\n- WithBasicAuth(username, password string) fluent method\n- Secure handling - no credential storage/logging\n\n## Files to create\n- auth.go\n\n## Acceptance criteria\n- Both auth methods work correctly\n- Credentials properly encoded in Authorization header\n- No sensitive data logged","status":"closed","priority":0,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-15T17:34:53.631074781+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-15T18:11:42.58778642+01:00","closed_at":"2026-01-15T18:11:42.58778642+01:00","close_reason":"Closed"} {"id":"kanboard-api-l72","title":"Implement global task search with parallel execution","description":"Implement cross-project task search with parallel execution.\n\n## Method to implement\n- SearchTasksGlobally(ctx, query string) ([]Task, error)\n\n## Workflow\n1. Get all projects via getAllProjects\n2. Execute searchTasks for each project in parallel (errgroup)\n3. Aggregate and return all results\n4. Respect context cancellation\n\n## Implementation details\n- Use golang.org/x/sync/errgroup for parallel execution\n- Results channel to collect tasks\n- Handle errors from any project search\n- Context propagation for cancellation\n\n## Files to modify\n- tasks.go\n\n## Acceptance criteria\n- Parallel execution improves performance\n- Context cancellation stops all goroutines\n- Single project failure cancels remaining searches","status":"open","priority":1,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-15T17:36:29.533649255+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-15T17:36:29.533649255+01:00","dependencies":[{"issue_id":"kanboard-api-l72","depends_on_id":"kanboard-api-apl","type":"blocks","created_at":"2026-01-15T17:45:32.11473352+01:00","created_by":"Oliver Jakoubek"},{"issue_id":"kanboard-api-l72","depends_on_id":"kanboard-api-fue","type":"blocks","created_at":"2026-01-15T17:45:32.178153305+01:00","created_by":"Oliver Jakoubek"}]} -{"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":"open","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-15T17:35:16.23369865+01:00","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-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"}]} diff --git a/columns.go b/columns.go new file mode 100644 index 0000000..4e3c45c --- /dev/null +++ b/columns.go @@ -0,0 +1,41 @@ +package kanboard + +import ( + "context" + "fmt" + "sort" +) + +// GetColumns returns all columns for a project, sorted by position. +func (c *Client) GetColumns(ctx context.Context, projectID int) ([]Column, error) { + params := map[string]int{"project_id": projectID} + + var result []Column + if err := c.call(ctx, "getColumns", params, &result); err != nil { + return nil, fmt.Errorf("getColumns: %w", err) + } + + // Sort by position + sort.Slice(result, func(i, j int) bool { + return int(result[i].Position) < int(result[j].Position) + }) + + return result, nil +} + +// GetColumn returns a column by its ID. +// Returns ErrColumnNotFound if the column does not exist. +func (c *Client) GetColumn(ctx context.Context, columnID int) (*Column, error) { + params := map[string]int{"column_id": columnID} + + var result *Column + if err := c.call(ctx, "getColumn", params, &result); err != nil { + return nil, fmt.Errorf("getColumn: %w", err) + } + + if result == nil { + return nil, fmt.Errorf("%w: column %d", ErrColumnNotFound, columnID) + } + + return result, nil +} diff --git a/columns_test.go b/columns_test.go new file mode 100644 index 0000000..31ced90 --- /dev/null +++ b/columns_test.go @@ -0,0 +1,172 @@ +package kanboard + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" +) + +func TestClient_GetColumns(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req JSONRPCRequest + json.NewDecoder(r.Body).Decode(&req) + + if req.Method != "getColumns" { + t.Errorf("expected method=getColumns, got %s", req.Method) + } + + params := req.Params.(map[string]any) + if params["project_id"].(float64) != 1 { + t.Errorf("expected project_id=1, got %v", params["project_id"]) + } + + // Return columns in wrong order to test sorting + resp := JSONRPCResponse{ + JSONRPC: "2.0", + ID: req.ID, + Result: json.RawMessage(`[ + {"id": "3", "title": "Done", "position": "3", "project_id": "1", "task_limit": "0"}, + {"id": "1", "title": "Backlog", "position": "1", "project_id": "1", "task_limit": "10"}, + {"id": "2", "title": "In Progress", "position": "2", "project_id": "1", "task_limit": "5"} + ]`), + } + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + client := NewClient(server.URL).WithAPIToken("test-token") + + columns, err := client.GetColumns(context.Background(), 1) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(columns) != 3 { + t.Errorf("expected 3 columns, got %d", len(columns)) + } + + // Verify sorted by position + if columns[0].Title != "Backlog" { + t.Errorf("expected first column='Backlog', got %s", columns[0].Title) + } + if columns[1].Title != "In Progress" { + t.Errorf("expected second column='In Progress', got %s", columns[1].Title) + } + if columns[2].Title != "Done" { + t.Errorf("expected third column='Done', got %s", columns[2].Title) + } +} + +func TestClient_GetColumns_Empty(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req JSONRPCRequest + json.NewDecoder(r.Body).Decode(&req) + + resp := JSONRPCResponse{ + JSONRPC: "2.0", + ID: req.ID, + Result: json.RawMessage(`[]`), + } + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + client := NewClient(server.URL).WithAPIToken("test-token") + + columns, err := client.GetColumns(context.Background(), 1) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(columns) != 0 { + t.Errorf("expected 0 columns, got %d", len(columns)) + } +} + +func TestClient_GetColumn(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req JSONRPCRequest + json.NewDecoder(r.Body).Decode(&req) + + if req.Method != "getColumn" { + t.Errorf("expected method=getColumn, got %s", req.Method) + } + + params := req.Params.(map[string]any) + if params["column_id"].(float64) != 5 { + t.Errorf("expected column_id=5, got %v", params["column_id"]) + } + + resp := JSONRPCResponse{ + JSONRPC: "2.0", + ID: req.ID, + Result: json.RawMessage(`{"id": "5", "title": "Backlog", "position": "1", "project_id": "1", "task_limit": "10"}`), + } + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + client := NewClient(server.URL).WithAPIToken("test-token") + + column, err := client.GetColumn(context.Background(), 5) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if int(column.ID) != 5 { + t.Errorf("expected ID=5, got %d", column.ID) + } + if column.Title != "Backlog" { + t.Errorf("expected title='Backlog', got %s", column.Title) + } + if int(column.TaskLimit) != 10 { + t.Errorf("expected task_limit=10, got %d", column.TaskLimit) + } +} + +func TestClient_GetColumn_NotFound(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req JSONRPCRequest + json.NewDecoder(r.Body).Decode(&req) + + // Kanboard returns null for non-existent columns + resp := JSONRPCResponse{ + JSONRPC: "2.0", + ID: req.ID, + Result: json.RawMessage(`null`), + } + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + client := NewClient(server.URL).WithAPIToken("test-token") + + _, err := client.GetColumn(context.Background(), 999) + if err == nil { + t.Fatal("expected error for non-existent column") + } + + if !errors.Is(err, ErrColumnNotFound) { + t.Errorf("expected ErrColumnNotFound, got %v", err) + } +} + +func TestClient_GetColumns_ContextCanceled(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + select {} + })) + defer server.Close() + + client := NewClient(server.URL).WithAPIToken("test-token") + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + _, err := client.GetColumns(ctx, 1) + if err == nil { + t.Fatal("expected error due to canceled context") + } +}