diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 6fb086f..3db2768 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,5 +1,5 @@ {"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-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":"open","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-15T17:36:08.526810135+01:00","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-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"} {"id":"kanboard-api-2ze","title":"Implement BoardScope fluent builder","description":"Implement BoardScope for fluent project-scoped operations.\n\n## Requirements\n- BoardScope struct with client and projectID\n- Client.Board(projectID int) *BoardScope method\n- BoardScope methods:\n - GetColumns(ctx) ([]Column, error)\n - GetCategories(ctx) ([]Category, error)\n - GetTasks(ctx, status TaskStatus) ([]Task, error)\n - SearchTasks(ctx, query string) ([]Task, error)\n - CreateTask(ctx, task *TaskParams) (*Task, error)\n\n## Files to create\n- board_scope.go\n\n## Example usage\n```go\ncolumns, _ := client.Board(1).GetColumns(ctx)\ntask, _ := client.Board(1).CreateTask(ctx, kanboard.NewTask(\"Title\"))\n```\n\n## Acceptance criteria\n- All methods delegate to direct Client methods\n- Proper error propagation","status":"open","priority":1,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-15T17:35:40.044649709+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-15T17:35:40.044649709+01:00","dependencies":[{"issue_id":"kanboard-api-2ze","depends_on_id":"kanboard-api-apl","type":"blocks","created_at":"2026-01-15T17:43:30.81063282+01:00","created_by":"Oliver Jakoubek"},{"issue_id":"kanboard-api-2ze","depends_on_id":"kanboard-api-l9b","type":"blocks","created_at":"2026-01-15T17:43:30.874964284+01:00","created_by":"Oliver Jakoubek"},{"issue_id":"kanboard-api-2ze","depends_on_id":"kanboard-api-0fz","type":"blocks","created_at":"2026-01-15T17:43:30.939377116+01:00","created_by":"Oliver Jakoubek"},{"issue_id":"kanboard-api-2ze","depends_on_id":"kanboard-api-91a","type":"blocks","created_at":"2026-01-15T17:43:31.005026627+01:00","created_by":"Oliver Jakoubek"}]} diff --git a/tags.go b/tags.go new file mode 100644 index 0000000..4f40b20 --- /dev/null +++ b/tags.go @@ -0,0 +1,111 @@ +package kanboard + +import ( + "context" + "fmt" +) + +// GetTaskTags returns the tags assigned to a task as a map of tagID to tag name. +func (c *Client) GetTaskTags(ctx context.Context, taskID int) (map[int]string, error) { + params := map[string]int{"task_id": taskID} + + // Kanboard returns map[string]string where keys are string tag IDs + var result map[string]string + if err := c.call(ctx, "getTaskTags", params, &result); err != nil { + return nil, fmt.Errorf("getTaskTags: %w", err) + } + + // Convert string keys to int + tags := make(map[int]string, len(result)) + for idStr, name := range result { + var id int + if _, err := fmt.Sscanf(idStr, "%d", &id); err != nil { + continue // Skip invalid IDs + } + tags[id] = name + } + + return tags, nil +} + +// SetTaskTags sets the tags for a task, replacing all existing tags. +// Tags are specified by name. Non-existent tags will be auto-created. +func (c *Client) SetTaskTags(ctx context.Context, projectID, taskID int, tags []string) error { + params := map[string]interface{}{ + "project_id": projectID, + "task_id": taskID, + "tags": tags, + } + + var result bool + if err := c.call(ctx, "setTaskTags", params, &result); err != nil { + return fmt.Errorf("setTaskTags: %w", err) + } + + return nil +} + +// GetAllTags returns all tags in the system. +func (c *Client) GetAllTags(ctx context.Context) ([]Tag, error) { + var result []Tag + if err := c.call(ctx, "getAllTags", nil, &result); err != nil { + return nil, fmt.Errorf("getAllTags: %w", err) + } + return result, nil +} + +// GetTagsByProject returns all tags for a specific project. +func (c *Client) GetTagsByProject(ctx context.Context, projectID int) ([]Tag, error) { + params := map[string]int{"project_id": projectID} + + var result []Tag + if err := c.call(ctx, "getTagsByProject", params, &result); err != nil { + return nil, fmt.Errorf("getTagsByProject: %w", err) + } + return result, nil +} + +// CreateTag creates a new tag in a project and returns the tag ID. +func (c *Client) CreateTag(ctx context.Context, projectID int, name, colorID string) (int, error) { + params := map[string]interface{}{ + "project_id": projectID, + "tag": name, + } + if colorID != "" { + params["color_id"] = colorID + } + + var result int + if err := c.call(ctx, "createTag", params, &result); err != nil { + return 0, fmt.Errorf("createTag: %w", err) + } + return result, nil +} + +// UpdateTag updates an existing tag's name and/or color. +func (c *Client) UpdateTag(ctx context.Context, tagID int, name, colorID string) error { + params := map[string]interface{}{ + "tag_id": tagID, + "tag": name, + } + if colorID != "" { + params["color_id"] = colorID + } + + var result bool + if err := c.call(ctx, "updateTag", params, &result); err != nil { + return fmt.Errorf("updateTag: %w", err) + } + return nil +} + +// RemoveTag deletes a tag from the system. +func (c *Client) RemoveTag(ctx context.Context, tagID int) error { + params := map[string]int{"tag_id": tagID} + + var result bool + if err := c.call(ctx, "removeTag", params, &result); err != nil { + return fmt.Errorf("removeTag: %w", err) + } + return nil +} diff --git a/tags_test.go b/tags_test.go new file mode 100644 index 0000000..d98faa2 --- /dev/null +++ b/tags_test.go @@ -0,0 +1,349 @@ +package kanboard + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestClient_GetTaskTags(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 != "getTaskTags" { + t.Errorf("expected method=getTaskTags, got %s", req.Method) + } + + params := req.Params.(map[string]interface{}) + if params["task_id"].(float64) != 42 { + t.Errorf("expected task_id=42, got %v", params["task_id"]) + } + + // Kanboard returns map[string]string + resp := JSONRPCResponse{ + JSONRPC: "2.0", + ID: req.ID, + Result: json.RawMessage(`{"1": "urgent", "2": "backend", "5": "bug"}`), + } + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + client := NewClient(server.URL).WithAPIToken("test-token") + + tags, err := client.GetTaskTags(context.Background(), 42) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(tags) != 3 { + t.Errorf("expected 3 tags, got %d", len(tags)) + } + if tags[1] != "urgent" { + t.Errorf("expected tags[1]='urgent', got %s", tags[1]) + } + if tags[2] != "backend" { + t.Errorf("expected tags[2]='backend', got %s", tags[2]) + } + if tags[5] != "bug" { + t.Errorf("expected tags[5]='bug', got %s", tags[5]) + } +} + +func TestClient_GetTaskTags_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") + + tags, err := client.GetTaskTags(context.Background(), 42) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(tags) != 0 { + t.Errorf("expected 0 tags, got %d", len(tags)) + } +} + +func TestClient_SetTaskTags(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 != "setTaskTags" { + t.Errorf("expected method=setTaskTags, got %s", req.Method) + } + + params := req.Params.(map[string]interface{}) + if params["project_id"].(float64) != 1 { + t.Errorf("expected project_id=1, got %v", params["project_id"]) + } + if params["task_id"].(float64) != 42 { + t.Errorf("expected task_id=42, got %v", params["task_id"]) + } + + tags := params["tags"].([]interface{}) + if len(tags) != 2 { + t.Errorf("expected 2 tags, got %d", len(tags)) + } + + 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.SetTaskTags(context.Background(), 1, 42, []string{"urgent", "backend"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestClient_SetTaskTags_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) + + params := req.Params.(map[string]interface{}) + tags := params["tags"].([]interface{}) + if len(tags) != 0 { + t.Errorf("expected 0 tags, got %d", len(tags)) + } + + 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") + + // Clear all tags by passing empty slice + err := client.SetTaskTags(context.Background(), 1, 42, []string{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestClient_GetAllTags(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 != "getAllTags" { + t.Errorf("expected method=getAllTags, got %s", req.Method) + } + + resp := JSONRPCResponse{ + JSONRPC: "2.0", + ID: req.ID, + Result: json.RawMessage(`[{"id": "1", "name": "urgent", "project_id": "1", "color_id": "red"}]`), + } + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + client := NewClient(server.URL).WithAPIToken("test-token") + + tags, err := client.GetAllTags(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(tags) != 1 { + t.Errorf("expected 1 tag, got %d", len(tags)) + } + if tags[0].Name != "urgent" { + t.Errorf("expected name='urgent', got %s", tags[0].Name) + } +} + +func TestClient_GetTagsByProject(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 != "getTagsByProject" { + t.Errorf("expected method=getTagsByProject, got %s", req.Method) + } + + params := req.Params.(map[string]interface{}) + if params["project_id"].(float64) != 5 { + t.Errorf("expected project_id=5, got %v", params["project_id"]) + } + + resp := JSONRPCResponse{ + JSONRPC: "2.0", + ID: req.ID, + Result: json.RawMessage(`[{"id": "1", "name": "urgent", "project_id": "5", "color_id": "red"}]`), + } + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + client := NewClient(server.URL).WithAPIToken("test-token") + + tags, err := client.GetTagsByProject(context.Background(), 5) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(tags) != 1 { + t.Errorf("expected 1 tag, got %d", len(tags)) + } +} + +func TestClient_CreateTag(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 != "createTag" { + t.Errorf("expected method=createTag, got %s", req.Method) + } + + params := req.Params.(map[string]interface{}) + if params["project_id"].(float64) != 1 { + t.Errorf("expected project_id=1, got %v", params["project_id"]) + } + if params["tag"] != "new-tag" { + t.Errorf("expected tag='new-tag', got %v", params["tag"]) + } + if params["color_id"] != "blue" { + t.Errorf("expected color_id='blue', got %v", params["color_id"]) + } + + resp := JSONRPCResponse{ + JSONRPC: "2.0", + ID: req.ID, + Result: json.RawMessage(`42`), + } + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + client := NewClient(server.URL).WithAPIToken("test-token") + + tagID, err := client.CreateTag(context.Background(), 1, "new-tag", "blue") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if tagID != 42 { + t.Errorf("expected tagID=42, got %d", tagID) + } +} + +func TestClient_CreateTag_NoColor(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req JSONRPCRequest + json.NewDecoder(r.Body).Decode(&req) + + params := req.Params.(map[string]interface{}) + if _, exists := params["color_id"]; exists { + t.Error("color_id should not be present when empty") + } + + resp := JSONRPCResponse{ + JSONRPC: "2.0", + ID: req.ID, + Result: json.RawMessage(`42`), + } + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + client := NewClient(server.URL).WithAPIToken("test-token") + + _, err := client.CreateTag(context.Background(), 1, "new-tag", "") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestClient_UpdateTag(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 != "updateTag" { + t.Errorf("expected method=updateTag, got %s", req.Method) + } + + params := req.Params.(map[string]interface{}) + if params["tag_id"].(float64) != 5 { + t.Errorf("expected tag_id=5, got %v", params["tag_id"]) + } + if params["tag"] != "updated-name" { + t.Errorf("expected tag='updated-name', got %v", params["tag"]) + } + + 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.UpdateTag(context.Background(), 5, "updated-name", "green") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestClient_RemoveTag(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 != "removeTag" { + t.Errorf("expected method=removeTag, got %s", req.Method) + } + + params := req.Params.(map[string]interface{}) + if params["tag_id"].(float64) != 5 { + t.Errorf("expected tag_id=5, got %v", params["tag_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.RemoveTag(context.Background(), 5) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +}