From 7c8ebcc4910801d95bbe757d6078fa37bb9fbc7f Mon Sep 17 00:00:00 2001 From: Oliver Jakoubek Date: Thu, 15 Jan 2026 18:21:42 +0100 Subject: [PATCH] Implement Project API methods Add direct client methods for project/board operations: - GetAllProjects(ctx) - list all accessible projects - GetProjectByID(ctx, projectID) - get project by ID - GetProjectByName(ctx, name) - get project by name All methods: - Use context for cancellation support - Return ErrProjectNotFound when project doesn't exist - Properly wrap errors with context Closes: kanboard-api-apl --- .beads/issues.jsonl | 2 +- projects.go | 49 ++++++++++ projects_test.go | 219 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 269 insertions(+), 1 deletion(-) create mode 100644 projects.go create mode 100644 projects_test.go diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 3db2768..8969eee 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -8,7 +8,7 @@ {"id":"kanboard-api-70e","title":"Add GoDoc documentation to all public APIs","description":"Add comprehensive GoDoc comments to all exported types and functions.\n\n## Requirements\n- Package-level documentation in doc.go\n- All exported types documented\n- All exported functions documented with examples\n- All exported constants/variables documented\n\n## Documentation standards\n- Start with the name being documented\n- Include usage examples where helpful\n- Document error conditions\n- Note thread-safety characteristics\n\n## Files to create/modify\n- doc.go (package documentation)\n- All .go files with exported symbols\n\n## Acceptance criteria\n- `go doc` produces clean output\n- All exports have documentation\n- Examples are runnable","status":"open","priority":2,"issue_type":"task","owner":"mail@oliverjakoubek.de","created_at":"2026-01-15T17:36:54.342657373+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-15T17:36:54.342657373+01:00","dependencies":[{"issue_id":"kanboard-api-70e","depends_on_id":"kanboard-api-uls","type":"blocks","created_at":"2026-01-15T17:46:55.508211397+01:00","created_by":"Oliver Jakoubek"}]} {"id":"kanboard-api-91a","title":"Implement Task API methods (CRUD)","description":"Implement direct API methods for core task operations.\n\n## Methods to implement\n- GetTask(ctx, taskID int) (*Task, error) - getTask\n- GetAllTasks(ctx, projectID int, status TaskStatus) ([]Task, error) - getAllTasks\n- CreateTask(ctx, req CreateTaskRequest) (*Task, error) - createTask\n- UpdateTask(ctx, req UpdateTaskRequest) error - updateTask\n- CloseTask(ctx, taskID int) error - closeTask\n- OpenTask(ctx, taskID int) error - openTask\n\n## Files to create\n- tasks.go\n\n## Acceptance criteria\n- CreateTask returns the created task\n- UpdateTask supports partial updates via pointer fields\n- Proper error types (ErrTaskNotFound, ErrTaskClosed, etc.)","status":"open","priority":1,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-15T17:35:16.9962543+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-15T17:35:16.9962543+01:00","dependencies":[{"issue_id":"kanboard-api-91a","depends_on_id":"kanboard-api-uls","type":"blocks","created_at":"2026-01-15T17:42:53.291442735+01:00","created_by":"Oliver Jakoubek"},{"issue_id":"kanboard-api-91a","depends_on_id":"kanboard-api-cyc","type":"blocks","created_at":"2026-01-15T17:42:53.434459365+01:00","created_by":"Oliver Jakoubek"}]} {"id":"kanboard-api-9k8","title":"Implement TaskParams builder (Options Pattern)","description":"Implement TaskParams for fluent task creation configuration.\n\n## Requirements\n- TaskParams struct with private fields\n- NewTask(title string) *TaskParams constructor\n- Fluent setter methods:\n - WithDescription(desc string) *TaskParams\n - InColumn(columnID int) *TaskParams\n - WithCategory(categoryID int) *TaskParams\n - WithOwner(ownerID int) *TaskParams\n - WithColor(colorID string) *TaskParams\n - WithPriority(priority int) *TaskParams\n - WithDueDate(date time.Time) *TaskParams\n - WithTags(tags ...string) *TaskParams\n - WithReference(ref string) *TaskParams\n- Internal method to convert to CreateTaskRequest\n\n## Files to create\n- task_params.go\n\n## Example usage\n```go\nparams := kanboard.NewTask(\"My Task\").\n WithDescription(\"Details\").\n InColumn(2).\n WithTags(\"urgent\", \"backend\")\n```\n\n## Acceptance criteria\n- Pure configuration object (no I/O)\n- All setters return *TaskParams for chaining\n- Unset optional fields remain nil","status":"open","priority":1,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-15T17:35:40.439513879+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-15T17:35:40.439513879+01:00","dependencies":[{"issue_id":"kanboard-api-9k8","depends_on_id":"kanboard-api-cyc","type":"blocks","created_at":"2026-01-15T17:43:31.070100884+01:00","created_by":"Oliver Jakoubek"}]} -{"id":"kanboard-api-apl","title":"Implement Project API methods","description":"Implement direct API methods for project/board operations.\n\n## Methods to implement\n- GetAllProjects(ctx) ([]Project, error) - getAllProjects\n- GetProjectByID(ctx, projectID int) (*Project, error) - getProjectById\n- GetProjectByName(ctx, name string) (*Project, error) - getProjectByName (Nice-to-have)\n\n## Files to create\n- projects.go\n\n## Acceptance criteria\n- All methods use context for cancellation\n- Proper error handling and wrapping\n- Returns ErrProjectNotFound when appropriate","status":"open","priority":1,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-15T17:35:15.864764497+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-15T17:35:15.864764497+01:00","dependencies":[{"issue_id":"kanboard-api-apl","depends_on_id":"kanboard-api-uls","type":"blocks","created_at":"2026-01-15T17:42:52.850751716+01:00","created_by":"Oliver Jakoubek"},{"issue_id":"kanboard-api-apl","depends_on_id":"kanboard-api-cyc","type":"blocks","created_at":"2026-01-15T17:42:52.946556814+01:00","created_by":"Oliver Jakoubek"}]} +{"id":"kanboard-api-apl","title":"Implement Project API methods","description":"Implement direct API methods for project/board operations.\n\n## Methods to implement\n- GetAllProjects(ctx) ([]Project, error) - getAllProjects\n- GetProjectByID(ctx, projectID int) (*Project, error) - getProjectById\n- GetProjectByName(ctx, name string) (*Project, error) - getProjectByName (Nice-to-have)\n\n## Files to create\n- projects.go\n\n## Acceptance criteria\n- All methods use context for cancellation\n- Proper error handling and wrapping\n- Returns ErrProjectNotFound when appropriate","status":"closed","priority":1,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-15T17:35:15.864764497+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-15T18:21:36.226187777+01:00","closed_at":"2026-01-15T18:21:36.226187777+01:00","close_reason":"Closed","dependencies":[{"issue_id":"kanboard-api-apl","depends_on_id":"kanboard-api-uls","type":"blocks","created_at":"2026-01-15T17:42:52.850751716+01:00","created_by":"Oliver Jakoubek"},{"issue_id":"kanboard-api-apl","depends_on_id":"kanboard-api-cyc","type":"blocks","created_at":"2026-01-15T17:42:52.946556814+01:00","created_by":"Oliver Jakoubek"}]} {"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":"open","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-15T17:35:17.380817511+01:00","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"}]} diff --git a/projects.go b/projects.go new file mode 100644 index 0000000..5f53e91 --- /dev/null +++ b/projects.go @@ -0,0 +1,49 @@ +package kanboard + +import ( + "context" + "fmt" +) + +// GetAllProjects returns all projects accessible to the authenticated user. +func (c *Client) GetAllProjects(ctx context.Context) ([]Project, error) { + var result []Project + if err := c.call(ctx, "getAllProjects", nil, &result); err != nil { + return nil, fmt.Errorf("getAllProjects: %w", err) + } + return result, nil +} + +// GetProjectByID returns a project by its ID. +// Returns ErrProjectNotFound if the project does not exist. +func (c *Client) GetProjectByID(ctx context.Context, projectID int) (*Project, error) { + params := map[string]int{"project_id": projectID} + + var result *Project + if err := c.call(ctx, "getProjectById", params, &result); err != nil { + return nil, fmt.Errorf("getProjectById: %w", err) + } + + if result == nil { + return nil, fmt.Errorf("%w: project %d", ErrProjectNotFound, projectID) + } + + return result, nil +} + +// GetProjectByName returns a project by its name. +// Returns ErrProjectNotFound if the project does not exist. +func (c *Client) GetProjectByName(ctx context.Context, name string) (*Project, error) { + params := map[string]string{"name": name} + + var result *Project + if err := c.call(ctx, "getProjectByName", params, &result); err != nil { + return nil, fmt.Errorf("getProjectByName: %w", err) + } + + if result == nil { + return nil, fmt.Errorf("%w: project %q", ErrProjectNotFound, name) + } + + return result, nil +} diff --git a/projects_test.go b/projects_test.go new file mode 100644 index 0000000..d007eeb --- /dev/null +++ b/projects_test.go @@ -0,0 +1,219 @@ +package kanboard + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" +) + +func TestClient_GetAllProjects(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 != "getAllProjects" { + t.Errorf("expected method=getAllProjects, got %s", req.Method) + } + + resp := JSONRPCResponse{ + JSONRPC: "2.0", + ID: req.ID, + Result: json.RawMessage(`[ + {"id": "1", "name": "Project One", "is_active": "1"}, + {"id": "2", "name": "Project Two", "is_active": "0"} + ]`), + } + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + client := NewClient(server.URL).WithAPIToken("test-token") + + projects, err := client.GetAllProjects(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(projects) != 2 { + t.Errorf("expected 2 projects, got %d", len(projects)) + } + if projects[0].Name != "Project One" { + t.Errorf("expected name='Project One', got %s", projects[0].Name) + } + if int(projects[0].ID) != 1 { + t.Errorf("expected ID=1, got %d", projects[0].ID) + } +} + +func TestClient_GetAllProjects_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") + + projects, err := client.GetAllProjects(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(projects) != 0 { + t.Errorf("expected 0 projects, got %d", len(projects)) + } +} + +func TestClient_GetProjectByID(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 != "getProjectById" { + t.Errorf("expected method=getProjectById, got %s", req.Method) + } + + params := req.Params.(map[string]any) + if params["project_id"].(float64) != 42 { + t.Errorf("expected project_id=42, got %v", params["project_id"]) + } + + resp := JSONRPCResponse{ + JSONRPC: "2.0", + ID: req.ID, + Result: json.RawMessage(`{"id": "42", "name": "Test Project", "is_active": "1"}`), + } + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + client := NewClient(server.URL).WithAPIToken("test-token") + + project, err := client.GetProjectByID(context.Background(), 42) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if int(project.ID) != 42 { + t.Errorf("expected ID=42, got %d", project.ID) + } + if project.Name != "Test Project" { + t.Errorf("expected name='Test Project', got %s", project.Name) + } +} + +func TestClient_GetProjectByID_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 projects + 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.GetProjectByID(context.Background(), 999) + if err == nil { + t.Fatal("expected error for non-existent project") + } + + if !errors.Is(err, ErrProjectNotFound) { + t.Errorf("expected ErrProjectNotFound, got %v", err) + } +} + +func TestClient_GetProjectByName(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 != "getProjectByName" { + t.Errorf("expected method=getProjectByName, got %s", req.Method) + } + + params := req.Params.(map[string]any) + if params["name"] != "Test Project" { + t.Errorf("expected name='Test Project', got %v", params["name"]) + } + + resp := JSONRPCResponse{ + JSONRPC: "2.0", + ID: req.ID, + Result: json.RawMessage(`{"id": "42", "name": "Test Project", "is_active": "1"}`), + } + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + client := NewClient(server.URL).WithAPIToken("test-token") + + project, err := client.GetProjectByName(context.Background(), "Test Project") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if project.Name != "Test Project" { + t.Errorf("expected name='Test Project', got %s", project.Name) + } +} + +func TestClient_GetProjectByName_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.GetProjectByName(context.Background(), "Non-Existent") + if err == nil { + t.Fatal("expected error for non-existent project") + } + + if !errors.Is(err, ErrProjectNotFound) { + t.Errorf("expected ErrProjectNotFound, got %v", err) + } +} + +func TestClient_GetProjectByID_ContextCanceled(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Simulate slow response + select {} + })) + defer server.Close() + + client := NewClient(server.URL).WithAPIToken("test-token") + + ctx, cancel := context.WithCancel(context.Background()) + cancel() // Cancel immediately + + _, err := client.GetProjectByID(ctx, 42) + if err == nil { + t.Fatal("expected error due to canceled context") + } +}