diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 1cf46de..2adb8bc 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -12,7 +12,7 @@ {"id":"kanboard-api-c3i","title":"Create README.md with documentation","description":"Create comprehensive README.md documentation in English.\n\n## Sections to include\n- Project overview and features\n- Installation instructions\n- Quick start example\n- API documentation overview\n- Authentication methods (API token, user/password)\n- Fluent API examples\n- Direct API examples\n- Error handling\n- Thread-safety notes\n- Tag operations warning (non-atomic)\n- License (MIT)\n\n## Example code to include\n```go\n// Client creation\nclient := kanboard.NewClient(\"https://kanboard.example.com\").\n WithAPIToken(\"my-token\").\n WithTimeout(30 * time.Second)\n\n// Fluent API\ntask, _ := client.Board(1).CreateTask(ctx,\n kanboard.NewTask(\"My Task\").WithDescription(\"Details\"))\n\n// Direct API\ntasks, _ := client.GetAllTasks(ctx, 1, kanboard.StatusActive)\n```\n\n## Files to create\n- README.md\n\n## Acceptance criteria\n- Clear installation instructions\n- Working code examples\n- Documents all major features","status":"open","priority":2,"issue_type":"task","owner":"mail@oliverjakoubek.de","created_at":"2026-01-15T17:36:53.228407343+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-15T17:36:53.228407343+01:00","dependencies":[{"issue_id":"kanboard-api-c3i","depends_on_id":"kanboard-api-uls","type":"blocks","created_at":"2026-01-15T17:46:55.445847253+01:00","created_by":"Oliver Jakoubek"}]} {"id":"kanboard-api-cyc","title":"Implement entity structs (types.go)","description":"Implement all entity structs for Kanboard API responses.\n\n## Structs to implement\n- Project (ID, Name, Description, IsActive, Token, LastModified, etc.)\n- Task (ID, Title, Description, dates, ColorID, ProjectID, ColumnID, etc.)\n- Column (ID, Title, Position, ProjectID, TaskLimit, Description)\n- Category (ID, Name, ProjectID, ColorID)\n- Comment (ID, TaskID, UserID, DateCreation, Content, Username, etc.)\n- TaskLink (ID, LinkID, TaskID, OppositeTaskID, Label, Title)\n- TaskFile (ID, Name, Path, IsImage, TaskID, DateCreation, UserID, Size)\n- Tag (ID, Name, ProjectID, ColorID)\n- TaskStatus enum (StatusActive, StatusInactive)\n\n## Request structs\n- CreateTaskRequest\n- UpdateTaskRequest (with pointer fields for optional values)\n\n## Files to create\n- types.go\n\n## Acceptance criteria\n- All JSON tags match Kanboard API\n- Optional fields use pointers with omitempty\n- Timestamp fields use custom Timestamp type","status":"closed","priority":0,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-15T17:34:55.484472208+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-15T18:18:40.163015715+01:00","closed_at":"2026-01-15T18:18:40.163015715+01:00","close_reason":"Closed","dependencies":[{"issue_id":"kanboard-api-cyc","depends_on_id":"kanboard-api-25y","type":"blocks","created_at":"2026-01-15T17:42:48.385166815+01:00","created_by":"Oliver Jakoubek"}]} {"id":"kanboard-api-fue","title":"Implement Task search and move methods","description":"Implement task search and movement API methods.\n\n## Methods to implement\n- SearchTasks(ctx, projectID int, query string) ([]Task, error) - searchTasks\n- MoveTaskPosition(ctx, projectID, taskID, columnID, position, swimlaneID int) error - moveTaskPosition\n- MoveTaskToProject(ctx, taskID, projectID int) error - moveTaskToProject\n\n## Files to create\n- tasks.go (extend)\n\n## Acceptance criteria\n- Search supports Kanboard query syntax\n- MoveTaskPosition handles column and position\n- Proper error handling for invalid moves","status":"closed","priority":1,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-15T17:35:17.380817511+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-15T18:28:05.809359198+01:00","closed_at":"2026-01-15T18:28:05.809359198+01:00","close_reason":"Closed","dependencies":[{"issue_id":"kanboard-api-fue","depends_on_id":"kanboard-api-91a","type":"blocks","created_at":"2026-01-15T17:42:53.59290181+01:00","created_by":"Oliver Jakoubek"}]} -{"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-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":"closed","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-15T18:38:19.679658075+01:00","closed_at":"2026-01-15T18:38:19.679658075+01:00","close_reason":"Closed","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":"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"}]} diff --git a/comments.go b/comments.go new file mode 100644 index 0000000..53a1a0f --- /dev/null +++ b/comments.go @@ -0,0 +1,91 @@ +package kanboard + +import ( + "context" + "fmt" +) + +// GetAllComments returns all comments for a task. +func (c *Client) GetAllComments(ctx context.Context, taskID int) ([]Comment, error) { + params := map[string]int{"task_id": taskID} + + var result []Comment + if err := c.call(ctx, "getAllComments", params, &result); err != nil { + return nil, fmt.Errorf("getAllComments: %w", err) + } + + return result, nil +} + +// GetComment returns a comment by its ID. +// Returns ErrCommentNotFound if the comment does not exist. +func (c *Client) GetComment(ctx context.Context, commentID int) (*Comment, error) { + params := map[string]int{"comment_id": commentID} + + var result *Comment + if err := c.call(ctx, "getComment", params, &result); err != nil { + return nil, fmt.Errorf("getComment: %w", err) + } + + if result == nil { + return nil, fmt.Errorf("%w: comment %d", ErrCommentNotFound, commentID) + } + + return result, nil +} + +// CreateComment creates a new comment on a task and returns the created comment. +func (c *Client) CreateComment(ctx context.Context, taskID, userID int, content string) (*Comment, error) { + params := map[string]any{ + "task_id": taskID, + "user_id": userID, + "content": content, + } + + var commentID int + if err := c.call(ctx, "createComment", params, &commentID); err != nil { + return nil, fmt.Errorf("createComment: %w", err) + } + + if commentID == 0 { + return nil, fmt.Errorf("createComment: failed to create comment") + } + + // Fetch the created comment to return full details + return c.GetComment(ctx, commentID) +} + +// UpdateComment updates the content of a comment. +func (c *Client) UpdateComment(ctx context.Context, commentID int, content string) error { + params := map[string]any{ + "id": commentID, + "content": content, + } + + var success bool + if err := c.call(ctx, "updateComment", params, &success); err != nil { + return fmt.Errorf("updateComment: %w", err) + } + + if !success { + return fmt.Errorf("updateComment: update failed") + } + + return nil +} + +// RemoveComment deletes a comment. +func (c *Client) RemoveComment(ctx context.Context, commentID int) error { + params := map[string]int{"comment_id": commentID} + + var success bool + if err := c.call(ctx, "removeComment", params, &success); err != nil { + return fmt.Errorf("removeComment: %w", err) + } + + if !success { + return fmt.Errorf("removeComment: delete failed") + } + + return nil +} diff --git a/comments_test.go b/comments_test.go new file mode 100644 index 0000000..2645bfd --- /dev/null +++ b/comments_test.go @@ -0,0 +1,415 @@ +package kanboard + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" +) + +func TestClient_GetAllComments(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 != "getAllComments" { + t.Errorf("expected method=getAllComments, got %s", req.Method) + } + + params := req.Params.(map[string]any) + if params["task_id"].(float64) != 42 { + t.Errorf("expected task_id=42, got %v", params["task_id"]) + } + + resp := JSONRPCResponse{ + JSONRPC: "2.0", + ID: req.ID, + Result: json.RawMessage(`[ + {"id": "1", "task_id": "42", "user_id": "1", "comment": "First comment", "username": "admin"}, + {"id": "2", "task_id": "42", "user_id": "2", "comment": "Second comment", "username": "user"} + ]`), + } + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + client := NewClient(server.URL).WithAPIToken("test-token") + + comments, err := client.GetAllComments(context.Background(), 42) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(comments) != 2 { + t.Errorf("expected 2 comments, got %d", len(comments)) + } + if comments[0].Content != "First comment" { + t.Errorf("expected first comment='First comment', got %s", comments[0].Content) + } + if comments[0].Username != "admin" { + t.Errorf("expected username='admin', got %s", comments[0].Username) + } +} + +func TestClient_GetAllComments_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") + + comments, err := client.GetAllComments(context.Background(), 42) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(comments) != 0 { + t.Errorf("expected 0 comments, got %d", len(comments)) + } +} + +func TestClient_GetComment(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 != "getComment" { + t.Errorf("expected method=getComment, got %s", req.Method) + } + + params := req.Params.(map[string]any) + if params["comment_id"].(float64) != 5 { + t.Errorf("expected comment_id=5, got %v", params["comment_id"]) + } + + resp := JSONRPCResponse{ + JSONRPC: "2.0", + ID: req.ID, + Result: json.RawMessage(`{"id": "5", "task_id": "42", "user_id": "1", "comment": "Test comment", "username": "admin"}`), + } + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + client := NewClient(server.URL).WithAPIToken("test-token") + + comment, err := client.GetComment(context.Background(), 5) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if int(comment.ID) != 5 { + t.Errorf("expected ID=5, got %d", comment.ID) + } + if comment.Content != "Test comment" { + t.Errorf("expected content='Test comment', got %s", comment.Content) + } +} + +func TestClient_GetComment_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) + + 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.GetComment(context.Background(), 999) + if err == nil { + t.Fatal("expected error for non-existent comment") + } + + if !errors.Is(err, ErrCommentNotFound) { + t.Errorf("expected ErrCommentNotFound, got %v", err) + } +} + +func TestClient_CreateComment(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++ + if callCount == 1 { + // First call: createComment + if req.Method != "createComment" { + t.Errorf("expected method=createComment, got %s", req.Method) + } + + params := req.Params.(map[string]any) + if params["task_id"].(float64) != 42 { + t.Errorf("expected task_id=42, got %v", params["task_id"]) + } + if params["user_id"].(float64) != 1 { + t.Errorf("expected user_id=1, got %v", params["user_id"]) + } + if params["content"] != "New comment" { + t.Errorf("expected content='New comment', got %v", params["content"]) + } + + resp := JSONRPCResponse{ + JSONRPC: "2.0", + ID: req.ID, + Result: json.RawMessage(`10`), + } + json.NewEncoder(w).Encode(resp) + } else { + // Second call: getComment to fetch created comment + resp := JSONRPCResponse{ + JSONRPC: "2.0", + ID: req.ID, + Result: json.RawMessage(`{"id": "10", "task_id": "42", "user_id": "1", "comment": "New comment", "username": "admin"}`), + } + json.NewEncoder(w).Encode(resp) + } + })) + defer server.Close() + + client := NewClient(server.URL).WithAPIToken("test-token") + + comment, err := client.CreateComment(context.Background(), 42, 1, "New comment") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if int(comment.ID) != 10 { + t.Errorf("expected ID=10, got %d", comment.ID) + } + if comment.Content != "New comment" { + t.Errorf("expected content='New comment', got %s", comment.Content) + } +} + +func TestClient_CreateComment_Failure(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(`0`), + } + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + client := NewClient(server.URL).WithAPIToken("test-token") + + _, err := client.CreateComment(context.Background(), 42, 1, "New comment") + if err == nil { + t.Fatal("expected error for failed comment creation") + } +} + +func TestClient_UpdateComment(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 != "updateComment" { + t.Errorf("expected method=updateComment, got %s", req.Method) + } + + params := req.Params.(map[string]any) + if params["id"].(float64) != 5 { + t.Errorf("expected id=5, got %v", params["id"]) + } + if params["content"] != "Updated content" { + t.Errorf("expected content='Updated content', got %v", params["content"]) + } + + 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.UpdateComment(context.Background(), 5, "Updated content") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestClient_UpdateComment_Failure(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(`false`), + } + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + client := NewClient(server.URL).WithAPIToken("test-token") + + err := client.UpdateComment(context.Background(), 5, "Updated content") + if err == nil { + t.Fatal("expected error for failed update") + } +} + +func TestClient_RemoveComment(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 != "removeComment" { + t.Errorf("expected method=removeComment, got %s", req.Method) + } + + params := req.Params.(map[string]any) + if params["comment_id"].(float64) != 5 { + t.Errorf("expected comment_id=5, got %v", params["comment_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.RemoveComment(context.Background(), 5) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestClient_RemoveComment_Failure(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(`false`), + } + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + client := NewClient(server.URL).WithAPIToken("test-token") + + err := client.RemoveComment(context.Background(), 5) + if err == nil { + t.Fatal("expected error for failed delete") + } +} + +func TestTaskScope_GetComments(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 != "getAllComments" { + t.Errorf("expected method=getAllComments, got %s", req.Method) + } + + params := req.Params.(map[string]any) + if params["task_id"].(float64) != 42 { + t.Errorf("expected task_id=42, got %v", params["task_id"]) + } + + resp := JSONRPCResponse{ + JSONRPC: "2.0", + ID: req.ID, + Result: json.RawMessage(`[{"id": "1", "task_id": "42", "comment": "Test", "username": "admin"}]`), + } + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + client := NewClient(server.URL).WithAPIToken("test-token") + + comments, err := client.Task(42).GetComments(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(comments) != 1 { + t.Errorf("expected 1 comment, got %d", len(comments)) + } +} + +func TestTaskScope_AddComment(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++ + if callCount == 1 { + // First call: createComment + if req.Method != "createComment" { + t.Errorf("expected method=createComment, got %s", req.Method) + } + + params := req.Params.(map[string]any) + if params["task_id"].(float64) != 42 { + t.Errorf("expected task_id=42, got %v", params["task_id"]) + } + + resp := JSONRPCResponse{ + JSONRPC: "2.0", + ID: req.ID, + Result: json.RawMessage(`10`), + } + json.NewEncoder(w).Encode(resp) + } else { + // Second call: getComment + resp := JSONRPCResponse{ + JSONRPC: "2.0", + ID: req.ID, + Result: json.RawMessage(`{"id": "10", "task_id": "42", "comment": "Added via scope", "username": "admin"}`), + } + json.NewEncoder(w).Encode(resp) + } + })) + defer server.Close() + + client := NewClient(server.URL).WithAPIToken("test-token") + + comment, err := client.Task(42).AddComment(context.Background(), 1, "Added via scope") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if int(comment.ID) != 10 { + t.Errorf("expected ID=10, got %d", comment.ID) + } +} diff --git a/task_scope.go b/task_scope.go index a3dd540..7abec26 100644 --- a/task_scope.go +++ b/task_scope.go @@ -163,3 +163,14 @@ func (t *TaskScope) HasTag(ctx context.Context, tag string) (bool, error) { return false, nil } + +// GetComments returns all comments for this task. +func (t *TaskScope) GetComments(ctx context.Context) ([]Comment, error) { + return t.client.GetAllComments(ctx, t.taskID) +} + +// AddComment adds a comment to this task and returns the created comment. +// The userID is the ID of the user creating the comment. +func (t *TaskScope) AddComment(ctx context.Context, userID int, content string) (*Comment, error) { + return t.client.CreateComment(ctx, t.taskID, userID, content) +}