Implement global task search with parallel execution
- SearchTasksGlobally: cross-project search using errgroup - Gets all projects, then searches each in parallel - Results aggregated from all accessible projects - Context cancellation propagates to all goroutines - Single project failure cancels remaining searches - Added golang.org/x/sync/errgroup dependency - Comprehensive test coverage
This commit is contained in:
parent
87adebd798
commit
527d27b73f
5 changed files with 165 additions and 1 deletions
|
|
@ -14,7 +14,7 @@
|
|||
{"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":"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-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":"closed","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-15T18:40:08.288405499+01:00","closed_at":"2026-01-15T18:40:08.288405499+01:00","close_reason":"Closed","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"}]}
|
||||
{"id":"kanboard-api-ob2","title":"Add MIT license","description":"Add MIT license file to the repository.\n\n## Files to create\n- LICENSE\n\n## Content\nStandard MIT license text with appropriate copyright holder.\n\n## Acceptance criteria\n- Valid MIT license\n- Proper copyright attribution","status":"open","priority":3,"issue_type":"chore","owner":"mail@oliverjakoubek.de","created_at":"2026-01-15T17:36:53.955909551+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-15T17:36:53.955909551+01:00"}
|
||||
{"id":"kanboard-api-s7k","title":"Implement error types and handling","description":"Implement comprehensive error types for the Kanboard API client.\n\n## Sentinel Errors\n- ErrConnectionFailed, ErrTimeout (network)\n- ErrUnauthorized, ErrForbidden (auth)\n- ErrNotFound, ErrProjectNotFound, ErrTaskNotFound, ErrColumnNotFound, ErrCommentNotFound (resources)\n- ErrAlreadyInLastColumn, ErrAlreadyInFirstColumn, ErrTaskClosed, ErrTaskOpen (logic)\n- ErrEmptyTitle, ErrInvalidProjectID (validation)\n- ErrAPIError (API)\n\n## APIError struct\n- Code int\n- Message string\n- Error() string method\n\n## Helper functions\n- IsNotFound(err error) bool\n- IsUnauthorized(err error) bool\n- IsAPIError(err error) bool\n\n## Files to create\n- errors.go\n\n## Acceptance criteria\n- All errors properly support errors.Is/errors.As\n- Error wrapping with %w for context preservation","status":"closed","priority":0,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-15T17:34:54.484116412+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-15T18:13:03.195687148+01:00","closed_at":"2026-01-15T18:13:03.195687148+01:00","close_reason":"Closed"}
|
||||
|
|
|
|||
2
go.mod
2
go.mod
|
|
@ -1,3 +1,5 @@
|
|||
module code.beautifulmachines.dev/jakoubek/kanboard-api
|
||||
|
||||
go 1.25.5
|
||||
|
||||
require golang.org/x/sync v0.19.0 // indirect
|
||||
|
|
|
|||
2
go.sum
Normal file
2
go.sum
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
49
tasks.go
49
tasks.go
|
|
@ -3,6 +3,8 @@ package kanboard
|
|||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"golang.org/x/sync/errgroup"
|
||||
)
|
||||
|
||||
// GetTask returns a task by its ID.
|
||||
|
|
@ -165,3 +167,50 @@ func (c *Client) MoveTaskToProject(ctx context.Context, taskID, projectID int) e
|
|||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SearchTasksGlobally searches for tasks across all accessible projects.
|
||||
// The search is executed in parallel across all projects using errgroup.
|
||||
// If any project search fails, all ongoing searches are cancelled.
|
||||
func (c *Client) SearchTasksGlobally(ctx context.Context, query string) ([]Task, error) {
|
||||
// Get all accessible projects
|
||||
projects, err := c.GetAllProjects(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("searchTasksGlobally: %w", err)
|
||||
}
|
||||
|
||||
if len(projects) == 0 {
|
||||
return []Task{}, nil
|
||||
}
|
||||
|
||||
// Use errgroup for parallel execution with context cancellation
|
||||
g, ctx := errgroup.WithContext(ctx)
|
||||
|
||||
// Slice to store results from each project (one per project, thread-safe by index)
|
||||
results := make([][]Task, len(projects))
|
||||
|
||||
// Launch parallel searches
|
||||
for i, project := range projects {
|
||||
i, projectID := i, int(project.ID)
|
||||
g.Go(func() error {
|
||||
tasks, err := c.SearchTasks(ctx, projectID, query)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
results[i] = tasks
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// Wait for all goroutines
|
||||
if err := g.Wait(); err != nil {
|
||||
return nil, fmt.Errorf("searchTasksGlobally: %w", err)
|
||||
}
|
||||
|
||||
// Aggregate results
|
||||
var allTasks []Task
|
||||
for _, tasks := range results {
|
||||
allTasks = append(allTasks, tasks...)
|
||||
}
|
||||
|
||||
return allTasks, nil
|
||||
}
|
||||
|
|
|
|||
111
tasks_test.go
111
tasks_test.go
|
|
@ -696,3 +696,114 @@ func TestClient_MoveTaskToProject_Failure(t *testing.T) {
|
|||
t.Fatal("expected error for failed move")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_SearchTasksGlobally(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var req JSONRPCRequest
|
||||
json.NewDecoder(r.Body).Decode(&req)
|
||||
|
||||
switch req.Method {
|
||||
case "getAllProjects":
|
||||
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": "1"}
|
||||
]`),
|
||||
}
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
case "searchTasks":
|
||||
params := req.Params.(map[string]any)
|
||||
projectID := int(params["project_id"].(float64))
|
||||
query := params["query"].(string)
|
||||
|
||||
if query != "status:open" {
|
||||
t.Errorf("expected query='status:open', got %s", query)
|
||||
}
|
||||
|
||||
// Return different tasks for each project
|
||||
var result string
|
||||
if projectID == 1 {
|
||||
result = `[{"id": "1", "title": "Task from P1", "project_id": "1", "is_active": "1"}]`
|
||||
} else {
|
||||
result = `[{"id": "2", "title": "Task from P2", "project_id": "2", "is_active": "1"}]`
|
||||
}
|
||||
|
||||
resp := JSONRPCResponse{
|
||||
JSONRPC: "2.0",
|
||||
ID: req.ID,
|
||||
Result: json.RawMessage(result),
|
||||
}
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL).WithAPIToken("test-token")
|
||||
|
||||
tasks, err := client.SearchTasksGlobally(context.Background(), "status:open")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if len(tasks) != 2 {
|
||||
t.Errorf("expected 2 tasks from 2 projects, got %d", len(tasks))
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_SearchTasksGlobally_NoProjects(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")
|
||||
|
||||
tasks, err := client.SearchTasksGlobally(context.Background(), "status:open")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if len(tasks) != 0 {
|
||||
t.Errorf("expected 0 tasks, got %d", len(tasks))
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_SearchTasksGlobally_ContextCanceled(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" {
|
||||
resp := JSONRPCResponse{
|
||||
JSONRPC: "2.0",
|
||||
ID: req.ID,
|
||||
Result: json.RawMessage(`[{"id": "1", "name": "Project", "is_active": "1"}]`),
|
||||
}
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
} else {
|
||||
// Hang forever for searchTasks
|
||||
select {}
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL).WithAPIToken("test-token")
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
_, err := client.SearchTasksGlobally(ctx, "status:open")
|
||||
if err == nil {
|
||||
t.Fatal("expected error due to canceled context")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue