Implement BoardScope fluent builder

- BoardScope struct for project-scoped operations
- Client.Board(projectID) returns a BoardScope
- Methods: GetColumns, GetCategories, GetTasks, SearchTasks, CreateTask
- All methods delegate to direct Client methods
- CreateTask automatically sets project ID from scope
- Comprehensive test coverage
This commit is contained in:
Oliver Jakoubek 2026-01-15 18:29:35 +01:00
commit f04ea8866f
3 changed files with 324 additions and 1 deletions

View file

@ -2,7 +2,7 @@
{"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"}]}
{"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":"closed","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-15T18:29:29.648709021+01:00","closed_at":"2026-01-15T18:29:29.648709021+01:00","close_reason":"Closed","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"}]}
{"id":"kanboard-api-3dc","title":"Implement Link API methods","description":"Implement direct API methods for task link operations.\n\n## Methods to implement\n- GetAllTaskLinks(ctx, taskID int) ([]TaskLink, error) - getAllTaskLinks\n- CreateTaskLink(ctx, taskID, oppositeTaskID, linkID int) (int, error) - createTaskLink\n- RemoveTaskLink(ctx, taskLinkID int) error - removeTaskLink (Nice-to-have)\n\n## TaskScope methods to add\n- GetLinks(ctx) ([]TaskLink, error)\n- LinkTo(ctx, oppositeTaskID, linkID int) error\n\n## Files to create\n- links.go\n- task_scope.go (extend)\n\n## Acceptance criteria\n- CreateTaskLink returns the link ID\n- Links include related task information","status":"open","priority":2,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-15T17:36:09.328552773+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-15T17:36:09.328552773+01:00","dependencies":[{"issue_id":"kanboard-api-3dc","depends_on_id":"kanboard-api-uls","type":"blocks","created_at":"2026-01-15T17:43:49.785710003+01:00","created_by":"Oliver Jakoubek"},{"issue_id":"kanboard-api-3dc","depends_on_id":"kanboard-api-cyc","type":"blocks","created_at":"2026-01-15T17:43:49.886111429+01:00","created_by":"Oliver Jakoubek"}]}
{"id":"kanboard-api-5fb","title":"Implement TaskUpdateParams builder","description":"Implement TaskUpdateParams for fluent task update configuration.\n\n## Requirements\n- TaskUpdateParams struct with pointer fields\n- NewTaskUpdate() *TaskUpdateParams constructor\n- Fluent setter methods:\n - SetTitle(title string) *TaskUpdateParams\n - SetDescription(desc string) *TaskUpdateParams\n - SetColor(colorID string) *TaskUpdateParams\n - SetOwner(ownerID int) *TaskUpdateParams\n - SetCategory(categoryID int) *TaskUpdateParams\n - SetPriority(priority int) *TaskUpdateParams\n - SetDueDate(date time.Time) *TaskUpdateParams\n- Internal method to convert to UpdateTaskRequest\n\n## Files to create\n- task_update_params.go\n\n## Example usage\n```go\nparams := kanboard.NewTaskUpdate().\n SetTitle(\"New Title\").\n SetPriority(2)\n```\n\n## Acceptance criteria\n- Only set fields are included in update request\n- All setters return *TaskUpdateParams for chaining","status":"open","priority":1,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-15T17:35:40.814955926+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-15T17:35:40.814955926+01:00","dependencies":[{"issue_id":"kanboard-api-5fb","depends_on_id":"kanboard-api-cyc","type":"blocks","created_at":"2026-01-15T17:43:31.134402453+01:00","created_by":"Oliver Jakoubek"}]}
{"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"}]}

44
board_scope.go Normal file
View file

@ -0,0 +1,44 @@
package kanboard
import "context"
// BoardScope provides fluent project-scoped operations.
type BoardScope struct {
client *Client
projectID int
}
// Board returns a BoardScope for fluent project-scoped operations.
func (c *Client) Board(projectID int) *BoardScope {
return &BoardScope{
client: c,
projectID: projectID,
}
}
// GetColumns returns all columns for the project, sorted by position.
func (b *BoardScope) GetColumns(ctx context.Context) ([]Column, error) {
return b.client.GetColumns(ctx, b.projectID)
}
// GetCategories returns all categories for the project.
func (b *BoardScope) GetCategories(ctx context.Context) ([]Category, error) {
return b.client.GetAllCategories(ctx, b.projectID)
}
// GetTasks returns all tasks for the project with the specified status.
func (b *BoardScope) GetTasks(ctx context.Context, status TaskStatus) ([]Task, error) {
return b.client.GetAllTasks(ctx, b.projectID, status)
}
// SearchTasks searches for tasks in the project using Kanboard query syntax.
func (b *BoardScope) SearchTasks(ctx context.Context, query string) ([]Task, error) {
return b.client.SearchTasks(ctx, b.projectID, query)
}
// CreateTask creates a new task in the project.
// The ProjectID field in the request is overwritten with the board's project ID.
func (b *BoardScope) CreateTask(ctx context.Context, req CreateTaskRequest) (*Task, error) {
req.ProjectID = b.projectID
return b.client.CreateTask(ctx, req)
}

279
board_scope_test.go Normal file
View file

@ -0,0 +1,279 @@
package kanboard
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestClient_Board(t *testing.T) {
client := NewClient("http://example.com").WithAPIToken("test")
board := client.Board(42)
if board == nil {
t.Fatal("expected non-nil BoardScope")
}
if board.projectID != 42 {
t.Errorf("expected projectID=42, got %d", board.projectID)
}
if board.client != client {
t.Error("expected BoardScope to reference the same client")
}
}
func TestBoardScope_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"])
}
resp := JSONRPCResponse{
JSONRPC: "2.0",
ID: req.ID,
Result: json.RawMessage(`[{"id": "1", "title": "Backlog", "position": "1", "project_id": "1"}]`),
}
json.NewEncoder(w).Encode(resp)
}))
defer server.Close()
client := NewClient(server.URL).WithAPIToken("test-token")
columns, err := client.Board(1).GetColumns(context.Background())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(columns) != 1 {
t.Errorf("expected 1 column, got %d", len(columns))
}
if columns[0].Title != "Backlog" {
t.Errorf("expected title='Backlog', got %s", columns[0].Title)
}
}
func TestBoardScope_GetCategories(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"}]`),
}
json.NewEncoder(w).Encode(resp)
}))
defer server.Close()
client := NewClient(server.URL).WithAPIToken("test-token")
categories, err := client.Board(1).GetCategories(context.Background())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(categories) != 1 {
t.Errorf("expected 1 category, got %d", len(categories))
}
if categories[0].Name != "Bug" {
t.Errorf("expected name='Bug', got %s", categories[0].Name)
}
}
func TestBoardScope_GetTasks(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 != "getAllTasks" {
t.Errorf("expected method=getAllTasks, 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"])
}
if params["status_id"].(float64) != 1 {
t.Errorf("expected status_id=1 (active), got %v", params["status_id"])
}
resp := JSONRPCResponse{
JSONRPC: "2.0",
ID: req.ID,
Result: json.RawMessage(`[{"id": "1", "title": "Task One", "project_id": "1", "is_active": "1"}]`),
}
json.NewEncoder(w).Encode(resp)
}))
defer server.Close()
client := NewClient(server.URL).WithAPIToken("test-token")
tasks, err := client.Board(1).GetTasks(context.Background(), StatusActive)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(tasks) != 1 {
t.Errorf("expected 1 task, got %d", len(tasks))
}
if tasks[0].Title != "Task One" {
t.Errorf("expected title='Task One', got %s", tasks[0].Title)
}
}
func TestBoardScope_SearchTasks(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 != "searchTasks" {
t.Errorf("expected method=searchTasks, 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"])
}
if params["query"] != "status:open" {
t.Errorf("expected query='status:open', got %v", params["query"])
}
resp := JSONRPCResponse{
JSONRPC: "2.0",
ID: req.ID,
Result: json.RawMessage(`[{"id": "1", "title": "Task One", "project_id": "1", "is_active": "1"}]`),
}
json.NewEncoder(w).Encode(resp)
}))
defer server.Close()
client := NewClient(server.URL).WithAPIToken("test-token")
tasks, err := client.Board(1).SearchTasks(context.Background(), "status:open")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(tasks) != 1 {
t.Errorf("expected 1 task, got %d", len(tasks))
}
}
func TestBoardScope_CreateTask(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: createTask
if req.Method != "createTask" {
t.Errorf("expected method=createTask, got %s", req.Method)
}
params := req.Params.(map[string]any)
// BoardScope should set the project_id
if params["project_id"].(float64) != 1 {
t.Errorf("expected project_id=1, got %v", params["project_id"])
}
if params["title"] != "New Task" {
t.Errorf("expected title='New Task', got %v", params["title"])
}
resp := JSONRPCResponse{
JSONRPC: "2.0",
ID: req.ID,
Result: json.RawMessage(`42`),
}
json.NewEncoder(w).Encode(resp)
} else {
// Second call: getTask to fetch created task
resp := JSONRPCResponse{
JSONRPC: "2.0",
ID: req.ID,
Result: json.RawMessage(`{"id": "42", "title": "New Task", "project_id": "1", "column_id": "1", "is_active": "1"}`),
}
json.NewEncoder(w).Encode(resp)
}
}))
defer server.Close()
client := NewClient(server.URL).WithAPIToken("test-token")
task, err := client.Board(1).CreateTask(context.Background(), CreateTaskRequest{
Title: "New Task",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if int(task.ID) != 42 {
t.Errorf("expected ID=42, got %d", task.ID)
}
if int(task.ProjectID) != 1 {
t.Errorf("expected ProjectID=1, got %d", task.ProjectID)
}
}
func TestBoardScope_CreateTask_OverridesProjectID(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 {
params := req.Params.(map[string]any)
// Even if user provides different project_id, BoardScope should override
if params["project_id"].(float64) != 5 {
t.Errorf("expected project_id=5 (from BoardScope), got %v", params["project_id"])
}
resp := JSONRPCResponse{
JSONRPC: "2.0",
ID: req.ID,
Result: json.RawMessage(`42`),
}
json.NewEncoder(w).Encode(resp)
} else {
resp := JSONRPCResponse{
JSONRPC: "2.0",
ID: req.ID,
Result: json.RawMessage(`{"id": "42", "title": "New Task", "project_id": "5", "column_id": "1", "is_active": "1"}`),
}
json.NewEncoder(w).Encode(resp)
}
}))
defer server.Close()
client := NewClient(server.URL).WithAPIToken("test-token")
// User tries to set different project_id, but BoardScope should override
_, err := client.Board(5).CreateTask(context.Background(), CreateTaskRequest{
Title: "New Task",
ProjectID: 999, // This should be overridden to 5
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}