diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index b7fa33d..b1d2304 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,4 +1,4 @@ -{"id":"kanboard-api-0fz","title":"Implement Category API methods","description":"Implement direct API methods for category operations.\n\n## Methods to implement\n- GetAllCategories(ctx, projectID int) ([]Category, error) - getAllCategories\n- GetCategory(ctx, categoryID int) (*Category, error) - getCategory (Nice-to-have)\n\n## Files to create\n- categories.go\n\n## Acceptance criteria\n- Proper error handling\n- Returns empty slice when no categories exist","status":"open","priority":1,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-15T17:35:16.6133153+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-15T17:35:16.6133153+01:00","dependencies":[{"issue_id":"kanboard-api-0fz","depends_on_id":"kanboard-api-uls","type":"blocks","created_at":"2026-01-15T17:42:53.161416595+01:00","created_by":"Oliver Jakoubek"},{"issue_id":"kanboard-api-0fz","depends_on_id":"kanboard-api-cyc","type":"blocks","created_at":"2026-01-15T17:42:53.226963473+01:00","created_by":"Oliver Jakoubek"}]} +{"id":"kanboard-api-0fz","title":"Implement Category API methods","description":"Implement direct API methods for category operations.\n\n## Methods to implement\n- GetAllCategories(ctx, projectID int) ([]Category, error) - getAllCategories\n- GetCategory(ctx, categoryID int) (*Category, error) - getCategory (Nice-to-have)\n\n## Files to create\n- categories.go\n\n## Acceptance criteria\n- Proper error handling\n- Returns empty slice when no categories exist","status":"closed","priority":1,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-15T17:35:16.6133153+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-15T18:25:07.250066801+01:00","closed_at":"2026-01-15T18:25:07.250066801+01:00","close_reason":"Closed","dependencies":[{"issue_id":"kanboard-api-0fz","depends_on_id":"kanboard-api-uls","type":"blocks","created_at":"2026-01-15T17:42:53.161416595+01:00","created_by":"Oliver Jakoubek"},{"issue_id":"kanboard-api-0fz","depends_on_id":"kanboard-api-cyc","type":"blocks","created_at":"2026-01-15T17:42:53.226963473+01:00","created_by":"Oliver Jakoubek"}]} {"id":"kanboard-api-16r","title":"Implement Tag API methods (CRITICAL)","description":"Implement direct API methods for tag operations. Tags are CRITICAL - heavily used.\n\n## Direct Client methods (Must-have)\n- GetTaskTags(ctx, taskID int) (map[int]string, error) - getTaskTags\n- SetTaskTags(ctx, projectID, taskID int, tags []string) error - setTaskTags\n\n## Direct Client methods (Nice-to-have)\n- GetAllTags(ctx) ([]Tag, error) - getAllTags\n- GetTagsByProject(ctx, projectID int) ([]Tag, error) - getTagsByProject\n- CreateTag(ctx, projectID int, name, colorID string) (int, error) - createTag\n- UpdateTag(ctx, tagID int, name, colorID string) error - updateTag\n- RemoveTag(ctx, tagID int) error - removeTag\n\n## Files to create\n- tags.go\n\n## IMPORTANT NOTE\nsetTaskTags REPLACES ALL tags. Individual add/remove requires read-modify-write pattern (implemented in TaskScope).\n\n## Acceptance criteria\n- GetTaskTags returns map[tagID]tagName\n- SetTaskTags accepts tag names (auto-creates if needed)","status":"closed","priority":0,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-15T17:36:08.526810135+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-15T18:20:17.392248254+01:00","closed_at":"2026-01-15T18:20:17.392248254+01:00","close_reason":"Closed","dependencies":[{"issue_id":"kanboard-api-16r","depends_on_id":"kanboard-api-uls","type":"blocks","created_at":"2026-01-15T17:43:49.223137796+01:00","created_by":"Oliver Jakoubek"},{"issue_id":"kanboard-api-16r","depends_on_id":"kanboard-api-cyc","type":"blocks","created_at":"2026-01-15T17:43:49.402237867+01:00","created_by":"Oliver Jakoubek"}]} {"id":"kanboard-api-25y","title":"Implement Timestamp type with JSON handling","description":"Implement custom Timestamp type that handles Kanboard's Unix timestamp format.\n\n## Requirements\n- Timestamp struct wrapping time.Time\n- UnmarshalJSON supporting:\n - Unix timestamps as integers\n - Empty strings and \"0\" as zero time\n - Zero value (0) as zero time\n- MarshalJSON returning Unix timestamp or 0 for zero time\n\n## Files to create\n- timestamp.go\n\n## Acceptance criteria\n- Correctly parses integer Unix timestamps\n- Handles empty strings and \"0\" strings\n- Zero time marshals to 0\n- Non-zero time marshals to Unix timestamp","status":"closed","priority":0,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-15T17:34:55.0044989+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-15T18:15:27.299644047+01:00","closed_at":"2026-01-15T18:15:27.299644047+01:00","close_reason":"Closed"} {"id":"kanboard-api-2g1","title":"Implement JSON-RPC client foundation","description":"Implement the core JSON-RPC 2.0 client for Kanboard API communication.\n\n## Requirements\n- JSONRPCRequest struct with jsonrpc, method, id, params fields\n- JSONRPCResponse struct with jsonrpc, id, result, error fields \n- JSONRPCError struct with code and message\n- Generic `call` method to send requests and parse responses\n- Automatic `/jsonrpc.php` path appending to baseURL\n- Thread-safe request ID generation via atomic.Int64\n\n## Files to create\n- jsonrpc.go\n\n## Acceptance criteria\n- All JSON-RPC structs properly marshal/unmarshal\n- Request IDs increment atomically\n- Supports subdirectory installations (e.g. /kanboard/jsonrpc.php)","status":"closed","priority":0,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-15T17:34:53.232007312+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-15T18:10:29.68466887+01:00","closed_at":"2026-01-15T18:10:29.68466887+01:00","close_reason":"Closed"} diff --git a/categories.go b/categories.go new file mode 100644 index 0000000..dab2f91 --- /dev/null +++ b/categories.go @@ -0,0 +1,35 @@ +package kanboard + +import ( + "context" + "fmt" +) + +// GetAllCategories returns all categories for a project. +func (c *Client) GetAllCategories(ctx context.Context, projectID int) ([]Category, error) { + params := map[string]int{"project_id": projectID} + + var result []Category + if err := c.call(ctx, "getAllCategories", params, &result); err != nil { + return nil, fmt.Errorf("getAllCategories: %w", err) + } + + return result, nil +} + +// GetCategory returns a category by its ID. +// Returns ErrCategoryNotFound if the category does not exist. +func (c *Client) GetCategory(ctx context.Context, categoryID int) (*Category, error) { + params := map[string]int{"category_id": categoryID} + + var result *Category + if err := c.call(ctx, "getCategory", params, &result); err != nil { + return nil, fmt.Errorf("getCategory: %w", err) + } + + if result == nil { + return nil, fmt.Errorf("%w: category %d", ErrCategoryNotFound, categoryID) + } + + return result, nil +} diff --git a/categories_test.go b/categories_test.go new file mode 100644 index 0000000..2565836 --- /dev/null +++ b/categories_test.go @@ -0,0 +1,166 @@ +package kanboard + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" +) + +func TestClient_GetAllCategories(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 != "getAllCategories" { + t.Errorf("expected method=getAllCategories, 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"]) + } + + resp := JSONRPCResponse{ + JSONRPC: "2.0", + ID: req.ID, + Result: json.RawMessage(`[ + {"id": "1", "name": "Bug", "project_id": "1", "color_id": "red"}, + {"id": "2", "name": "Feature", "project_id": "1", "color_id": "blue"} + ]`), + } + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + client := NewClient(server.URL).WithAPIToken("test-token") + + categories, err := client.GetAllCategories(context.Background(), 1) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(categories) != 2 { + t.Errorf("expected 2 categories, got %d", len(categories)) + } + + if categories[0].Name != "Bug" { + t.Errorf("expected first category='Bug', got %s", categories[0].Name) + } + if categories[1].Name != "Feature" { + t.Errorf("expected second category='Feature', got %s", categories[1].Name) + } +} + +func TestClient_GetAllCategories_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") + + categories, err := client.GetAllCategories(context.Background(), 1) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(categories) != 0 { + t.Errorf("expected 0 categories, got %d", len(categories)) + } +} + +func TestClient_GetCategory(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 != "getCategory" { + t.Errorf("expected method=getCategory, got %s", req.Method) + } + + params := req.Params.(map[string]any) + if params["category_id"].(float64) != 5 { + t.Errorf("expected category_id=5, got %v", params["category_id"]) + } + + resp := JSONRPCResponse{ + JSONRPC: "2.0", + ID: req.ID, + Result: json.RawMessage(`{"id": "5", "name": "Bug", "project_id": "1", "color_id": "red"}`), + } + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + client := NewClient(server.URL).WithAPIToken("test-token") + + category, err := client.GetCategory(context.Background(), 5) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if int(category.ID) != 5 { + t.Errorf("expected ID=5, got %d", category.ID) + } + if category.Name != "Bug" { + t.Errorf("expected name='Bug', got %s", category.Name) + } + if category.ColorID != "red" { + t.Errorf("expected color_id='red', got %s", category.ColorID) + } +} + +func TestClient_GetCategory_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 categories + 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.GetCategory(context.Background(), 999) + if err == nil { + t.Fatal("expected error for non-existent category") + } + + if !errors.Is(err, ErrCategoryNotFound) { + t.Errorf("expected ErrCategoryNotFound, got %v", err) + } +} + +func TestClient_GetAllCategories_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.GetAllCategories(ctx, 1) + if err == nil { + t.Fatal("expected error due to canceled context") + } +} diff --git a/errors.go b/errors.go index 211d79d..f58717a 100644 --- a/errors.go +++ b/errors.go @@ -39,6 +39,9 @@ var ( // ErrCommentNotFound indicates the specified comment was not found. ErrCommentNotFound = errors.New("comment not found") + + // ErrCategoryNotFound indicates the specified category was not found. + ErrCategoryNotFound = errors.New("category not found") ) // Logic errors @@ -82,7 +85,8 @@ func IsNotFound(err error) bool { errors.Is(err, ErrProjectNotFound) || errors.Is(err, ErrTaskNotFound) || errors.Is(err, ErrColumnNotFound) || - errors.Is(err, ErrCommentNotFound) + errors.Is(err, ErrCommentNotFound) || + errors.Is(err, ErrCategoryNotFound) } // IsUnauthorized returns true if the error indicates an authentication failure.