diff --git a/.golangci.yaml b/.golangci.yaml index 55ca355..7148025 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -148,6 +148,9 @@ linters: - dupl # Builder types differ but share method signatures - path: cmd/ text: unused-parameter # Cobra callback signatures can't be changed + - path: internal/mcp/ + linters: + - nilerr # MCP handlers return errors in result, not as Go error - path: internal/ui/ linters: - gochecknoglobals # Style constants are package-level vars diff --git a/cmd/init/ui.go b/cmd/init/ui.go index 76977b3..f3d7d2e 100644 --- a/cmd/init/ui.go +++ b/cmd/init/ui.go @@ -166,7 +166,7 @@ func validateReference(input string, _ bool) (string, error) { return "", errRefRequired } - _, id, err := lunatask.ParseDeepLink(input) + _, id, err := lunatask.ParseReference(input) if err != nil { return "", errRefFormat } diff --git a/cmd/task/list.go b/cmd/task/list.go index e437477..a775205 100644 --- a/cmd/task/list.go +++ b/cmd/task/list.go @@ -158,43 +158,21 @@ func resolveStatusFilter(cmd *cobra.Command) (string, error) { } func applyFilters(tasks []lunatask.Task, areaID, statusFilter string, showAll bool) []lunatask.Task { - filtered := make([]lunatask.Task, 0, len(tasks)) - today := time.Now().Truncate(24 * time.Hour) - - for _, task := range tasks { - if !matchesFilters(task, areaID, statusFilter, showAll, today) { - continue - } - - filtered = append(filtered, task) - } - - return filtered -} - -func matchesFilters(task lunatask.Task, areaID, statusFilter string, showAll bool, today time.Time) bool { - if areaID != "" && (task.AreaID == nil || *task.AreaID != areaID) { - return false - } - - if statusFilter != "" && (task.Status == nil || string(*task.Status) != statusFilter) { - return false + opts := &lunatask.TaskFilterOptions{ + IncludeCompleted: showAll, + Today: time.Now(), } - // Default filter: exclude completed tasks unless completed today - if !showAll && statusFilter == "" && isOldCompleted(task, today) { - return false + if areaID != "" { + opts.AreaID = &areaID } - return true -} - -func isOldCompleted(task lunatask.Task, today time.Time) bool { - if task.Status == nil || *task.Status != lunatask.StatusCompleted { - return false + if statusFilter != "" { + s := lunatask.TaskStatus(statusFilter) + opts.Status = &s } - return task.CompletedAt == nil || task.CompletedAt.Before(today) + return lunatask.FilterTasks(tasks, opts) } func outputJSON(cmd *cobra.Command, tasks []lunatask.Task) error { diff --git a/go.mod b/go.mod index 3880e5f..d01b0c0 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,7 @@ module git.secluded.site/lune go 1.25.5 require ( - git.secluded.site/go-lunatask v0.1.0-rc10 + git.secluded.site/go-lunatask v0.1.0-rc9.1 github.com/BurntSushi/toml v1.6.0 github.com/charmbracelet/fang v0.4.4 github.com/charmbracelet/huh v0.8.0 diff --git a/go.sum b/go.sum index 2c72bcb..0d5d2d7 100644 --- a/go.sum +++ b/go.sum @@ -2,8 +2,8 @@ al.essio.dev/pkg/shellescape v1.5.1 h1:86HrALUujYS/h+GtqoB26SBEdkWfmMI6FubjXlsXy al.essio.dev/pkg/shellescape v1.5.1/go.mod h1:6sIqp7X2P6mThCQ7twERpZTuigpr6KbZWtls1U8I890= charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251106193318-19329a3e8410 h1:D9PbaszZYpB4nj+d6HTWr1onlmlyuGVNfL9gAi8iB3k= charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251106193318-19329a3e8410/go.mod h1:1qZyvvVCenJO2M1ac2mX0yyiIZJoZmDM4DG4s0udJkU= -git.secluded.site/go-lunatask v0.1.0-rc10 h1:KKkYNs/cipNjIlRPXAvpPm5QcWSuA3REcG8XZ8sALk4= -git.secluded.site/go-lunatask v0.1.0-rc10/go.mod h1:rxps7BBqF+BkY8VN5E7J9zSOzSbtZ1hDmLEOHxjTHZQ= +git.secluded.site/go-lunatask v0.1.0-rc9.1 h1:6dJcP3P+2QraPQ/wfPjCWaXv2mr1B4lMvBuQCNZd1t8= +git.secluded.site/go-lunatask v0.1.0-rc9.1/go.mod h1:rxps7BBqF+BkY8VN5E7J9zSOzSbtZ1hDmLEOHxjTHZQ= github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= diff --git a/internal/mcp/tools/habit/track.go b/internal/mcp/tools/habit/track.go index 3947045..91340de 100644 --- a/internal/mcp/tools/habit/track.go +++ b/internal/mcp/tools/habit/track.go @@ -56,8 +56,6 @@ func NewHandler(accessToken string, habits []shared.HabitProvider) *Handler { } // HandleTrack records a habit activity. -// -//nolint:nilerr // MCP returns errors in result, not Go error. func (h *Handler) HandleTrack( ctx context.Context, _ *mcp.CallToolRequest, diff --git a/internal/mcp/tools/task/create.go b/internal/mcp/tools/task/create.go index 12dabb7..a99a324 100644 --- a/internal/mcp/tools/task/create.go +++ b/internal/mcp/tools/task/create.go @@ -58,6 +58,21 @@ type CreateOutput struct { DeepLink string `json:"deep_link"` } +// parsedCreateInput holds validated and parsed create input fields. +type parsedCreateInput struct { + Name string + AreaID *string + GoalID *string + Status *lunatask.TaskStatus + Note *string + Priority *lunatask.Priority + Estimate *int + Motivation *lunatask.Motivation + Important *bool + Urgent *bool + ScheduledOn *lunatask.Date +} + // Handler handles task-related MCP tool requests. type Handler struct { client *lunatask.Client @@ -73,110 +88,148 @@ func NewHandler(accessToken string, areas []shared.AreaProvider) *Handler { } // HandleCreate creates a new task. -// -//nolint:cyclop,funlen,gocognit,nilerr // MCP error pattern; repetitive field handling. func (h *Handler) HandleCreate( ctx context.Context, _ *mcp.CallToolRequest, input CreateInput, ) (*mcp.CallToolResult, CreateOutput, error) { + parsed, errResult := parseCreateInput(input) + if errResult != nil { + return errResult, CreateOutput{}, nil + } + + builder := h.client.NewTask(parsed.Name) + applyToTaskBuilder(builder, parsed) + + task, err := builder.Create(ctx) + if err != nil { + return shared.ErrorResult(err.Error()), CreateOutput{}, nil + } + + deepLink, _ := lunatask.BuildDeepLink(lunatask.ResourceTask, task.ID) + + return nil, CreateOutput{ + ID: task.ID, + DeepLink: deepLink, + }, nil +} + +//nolint:cyclop,funlen +func parseCreateInput(input CreateInput) (*parsedCreateInput, *mcp.CallToolResult) { + parsed := &parsedCreateInput{ + Name: input.Name, + AreaID: input.AreaID, + GoalID: input.GoalID, + Note: input.Note, + Estimate: input.Estimate, + Important: input.Important, + Urgent: input.Urgent, + } + if input.AreaID != nil { if err := lunatask.ValidateUUID(*input.AreaID); err != nil { - return shared.ErrorResult("invalid area_id: expected UUID"), CreateOutput{}, nil + return nil, shared.ErrorResult("invalid area_id: expected UUID") } } if input.GoalID != nil { if err := lunatask.ValidateUUID(*input.GoalID); err != nil { - return shared.ErrorResult("invalid goal_id: expected UUID"), CreateOutput{}, nil + return nil, shared.ErrorResult("invalid goal_id: expected UUID") } } if input.Estimate != nil { if err := shared.ValidateEstimate(*input.Estimate); err != nil { - return shared.ErrorResult(err.Error()), CreateOutput{}, nil + return nil, shared.ErrorResult(err.Error()) } } - builder := h.client.NewTask(input.Name) - - if input.AreaID != nil { - builder.InArea(*input.AreaID) - } - - if input.GoalID != nil { - builder.InGoal(*input.GoalID) - } - if input.Status != nil { status, err := lunatask.ParseTaskStatus(*input.Status) if err != nil { - return shared.ErrorResult(err.Error()), CreateOutput{}, nil + return nil, shared.ErrorResult(err.Error()) } - builder.WithStatus(status) - } - - if input.Note != nil { - builder.WithNote(*input.Note) + parsed.Status = &status } if input.Priority != nil { priority, err := lunatask.ParsePriority(*input.Priority) if err != nil { - return shared.ErrorResult(err.Error()), CreateOutput{}, nil + return nil, shared.ErrorResult(err.Error()) } - builder.Priority(priority) - } - - if input.Estimate != nil { - builder.WithEstimate(*input.Estimate) + parsed.Priority = &priority } if input.Motivation != nil { motivation, err := lunatask.ParseMotivation(*input.Motivation) if err != nil { - return shared.ErrorResult(err.Error()), CreateOutput{}, nil + return nil, shared.ErrorResult(err.Error()) } - builder.WithMotivation(motivation) + parsed.Motivation = &motivation } - if input.Important != nil { - if *input.Important { + if input.ScheduledOn != nil { + date, err := dateutil.Parse(*input.ScheduledOn) + if err != nil { + return nil, shared.ErrorResult(err.Error()) + } + + parsed.ScheduledOn = &date + } + + return parsed, nil +} + +//nolint:cyclop +func applyToTaskBuilder(builder *lunatask.TaskBuilder, parsed *parsedCreateInput) { + if parsed.AreaID != nil { + builder.InArea(*parsed.AreaID) + } + + if parsed.GoalID != nil { + builder.InGoal(*parsed.GoalID) + } + + if parsed.Status != nil { + builder.WithStatus(*parsed.Status) + } + + if parsed.Note != nil { + builder.WithNote(*parsed.Note) + } + + if parsed.Priority != nil { + builder.Priority(*parsed.Priority) + } + + if parsed.Estimate != nil { + builder.WithEstimate(*parsed.Estimate) + } + + if parsed.Motivation != nil { + builder.WithMotivation(*parsed.Motivation) + } + + if parsed.Important != nil { + if *parsed.Important { builder.Important() } else { builder.NotImportant() } } - if input.Urgent != nil { - if *input.Urgent { + if parsed.Urgent != nil { + if *parsed.Urgent { builder.Urgent() } else { builder.NotUrgent() } } - if input.ScheduledOn != nil { - date, err := dateutil.Parse(*input.ScheduledOn) - if err != nil { - return shared.ErrorResult(err.Error()), CreateOutput{}, nil - } - - builder.ScheduledOn(date) - } - - task, err := builder.Create(ctx) - if err != nil { - return shared.ErrorResult(err.Error()), CreateOutput{}, nil + if parsed.ScheduledOn != nil { + builder.ScheduledOn(*parsed.ScheduledOn) } - - deepLink, _ := lunatask.BuildDeepLink(lunatask.ResourceTask, task.ID) - - return nil, CreateOutput{ - ID: task.ID, - DeepLink: deepLink, - }, nil } diff --git a/internal/mcp/tools/task/delete.go b/internal/mcp/tools/task/delete.go index dbe1bfc..64502cf 100644 --- a/internal/mcp/tools/task/delete.go +++ b/internal/mcp/tools/task/delete.go @@ -7,6 +7,7 @@ package task import ( "context" + "git.secluded.site/go-lunatask" "git.secluded.site/lune/internal/mcp/shared" "github.com/modelcontextprotocol/go-sdk/mcp" ) @@ -39,9 +40,9 @@ func (h *Handler) HandleDelete( _ *mcp.CallToolRequest, input DeleteInput, ) (*mcp.CallToolResult, DeleteOutput, error) { - id, err := resolveID(input.ID) + _, id, err := lunatask.ParseReference(input.ID) if err != nil { - return shared.ErrorResult(err.Error()), DeleteOutput{}, nil + return shared.ErrorResult("invalid ID: expected UUID or lunatask:// deep link"), DeleteOutput{}, nil } if _, err := h.client.DeleteTask(ctx, id); err != nil { diff --git a/internal/mcp/tools/task/list.go b/internal/mcp/tools/task/list.go index c5b1feb..c1c588e 100644 --- a/internal/mcp/tools/task/list.go +++ b/internal/mcp/tools/task/list.go @@ -54,11 +54,7 @@ type Summary struct { GoalID *string `json:"goal_id,omitempty"` } -const hoursPerDay = 24 - // HandleList lists tasks. -// -//nolint:cyclop,funlen,nilerr // Complexity from field handling; MCP returns errors in result, not Go error. func (h *Handler) HandleList( ctx context.Context, _ *mcp.CallToolRequest, @@ -81,22 +77,30 @@ func (h *Handler) HandleList( return shared.ErrorResult(err.Error()), ListOutput{}, nil } - includeCompleted := input.IncludeCompleted != nil && *input.IncludeCompleted - today := time.Now().Truncate(hoursPerDay * time.Hour) + opts := &lunatask.TaskFilterOptions{ + AreaID: input.AreaID, + IncludeCompleted: input.IncludeCompleted != nil && *input.IncludeCompleted, + Today: time.Now(), + } - filtered := make([]lunatask.Task, 0, len(tasks)) + if input.Status != nil { + s := lunatask.TaskStatus(*input.Status) + opts.Status = &s + } - for _, task := range tasks { - if !matchesFilters(task, input.AreaID, input.Status, includeCompleted, today) { - continue - } + filtered := lunatask.FilterTasks(tasks, opts) + summaries := buildSummaries(filtered) - filtered = append(filtered, task) - } + return nil, ListOutput{ + Tasks: summaries, + Count: len(summaries), + }, nil +} - summaries := make([]Summary, 0, len(filtered)) +func buildSummaries(tasks []lunatask.Task) []Summary { + summaries := make([]Summary, 0, len(tasks)) - for _, task := range filtered { + for _, task := range tasks { summary := Summary{ ID: task.ID, CreatedAt: task.CreatedAt.Format(time.RFC3339), @@ -124,39 +128,5 @@ func (h *Handler) HandleList( summaries = append(summaries, summary) } - return nil, ListOutput{ - Tasks: summaries, - Count: len(summaries), - }, nil -} - -func matchesFilters( - task lunatask.Task, - areaID, status *string, - includeCompleted bool, - today time.Time, -) bool { - if areaID != nil && (task.AreaID == nil || *task.AreaID != *areaID) { - return false - } - - if status != nil { - if task.Status == nil || string(*task.Status) != *status { - return false - } - } - - if !includeCompleted && isOldCompleted(task, today) { - return false - } - - return true -} - -func isOldCompleted(task lunatask.Task, today time.Time) bool { - if task.Status == nil || *task.Status != lunatask.StatusCompleted { - return false - } - - return task.CompletedAt == nil || task.CompletedAt.Before(today) + return summaries } diff --git a/internal/mcp/tools/task/show.go b/internal/mcp/tools/task/show.go index 303e396..6f90f00 100644 --- a/internal/mcp/tools/task/show.go +++ b/internal/mcp/tools/task/show.go @@ -53,9 +53,9 @@ func (h *Handler) HandleShow( _ *mcp.CallToolRequest, input ShowInput, ) (*mcp.CallToolResult, ShowOutput, error) { - id, err := resolveID(input.ID) + _, id, err := lunatask.ParseReference(input.ID) if err != nil { - return shared.ErrorResult(err.Error()), ShowOutput{}, nil + return shared.ErrorResult("invalid ID: expected UUID or lunatask:// deep link"), ShowOutput{}, nil } task, err := h.client.GetTask(ctx, id) diff --git a/internal/mcp/tools/task/update.go b/internal/mcp/tools/task/update.go index c4b2428..e9fd19e 100644 --- a/internal/mcp/tools/task/update.go +++ b/internal/mcp/tools/task/update.go @@ -6,7 +6,6 @@ package task import ( "context" - "fmt" "git.secluded.site/go-lunatask" "git.secluded.site/lune/internal/dateutil" @@ -60,134 +59,175 @@ type UpdateOutput struct { DeepLink string `json:"deep_link"` } +// parsedUpdateInput holds validated and parsed update input fields. +type parsedUpdateInput struct { + ID string + Name *string + AreaID *string + GoalID *string + Status *lunatask.TaskStatus + Note *string + Priority *lunatask.Priority + Estimate *int + Motivation *lunatask.Motivation + Important *bool + Urgent *bool + ScheduledOn *lunatask.Date +} + // HandleUpdate updates an existing task. -// -//nolint:cyclop,funlen,gocognit,nilerr // MCP error pattern; repetitive field handling. func (h *Handler) HandleUpdate( ctx context.Context, _ *mcp.CallToolRequest, input UpdateInput, ) (*mcp.CallToolResult, UpdateOutput, error) { - id, err := resolveID(input.ID) + parsed, errResult := parseUpdateInput(input) + if errResult != nil { + return errResult, UpdateOutput{}, nil + } + + builder := h.client.NewTaskUpdate(parsed.ID) + applyToTaskUpdateBuilder(builder, parsed) + + task, err := builder.Update(ctx) if err != nil { return shared.ErrorResult(err.Error()), UpdateOutput{}, nil } + deepLink, _ := lunatask.BuildDeepLink(lunatask.ResourceTask, task.ID) + + return nil, UpdateOutput{ + ID: task.ID, + DeepLink: deepLink, + }, nil +} + +//nolint:cyclop,funlen +func parseUpdateInput(input UpdateInput) (*parsedUpdateInput, *mcp.CallToolResult) { + _, id, err := lunatask.ParseReference(input.ID) + if err != nil { + return nil, shared.ErrorResult("invalid ID: expected UUID or lunatask:// deep link") + } + + parsed := &parsedUpdateInput{ + ID: id, + Name: input.Name, + AreaID: input.AreaID, + GoalID: input.GoalID, + Note: input.Note, + Estimate: input.Estimate, + Important: input.Important, + Urgent: input.Urgent, + } + if input.AreaID != nil { if err := lunatask.ValidateUUID(*input.AreaID); err != nil { - return shared.ErrorResult("invalid area_id: expected UUID"), UpdateOutput{}, nil + return nil, shared.ErrorResult("invalid area_id: expected UUID") } } if input.GoalID != nil { if err := lunatask.ValidateUUID(*input.GoalID); err != nil { - return shared.ErrorResult("invalid goal_id: expected UUID"), UpdateOutput{}, nil + return nil, shared.ErrorResult("invalid goal_id: expected UUID") } } if input.Estimate != nil { if err := shared.ValidateEstimate(*input.Estimate); err != nil { - return shared.ErrorResult(err.Error()), UpdateOutput{}, nil + return nil, shared.ErrorResult(err.Error()) } } - builder := h.client.NewTaskUpdate(id) - - if input.Name != nil { - builder.Name(*input.Name) - } - - if input.AreaID != nil { - builder.InArea(*input.AreaID) - } - - if input.GoalID != nil { - builder.InGoal(*input.GoalID) - } - if input.Status != nil { status, err := lunatask.ParseTaskStatus(*input.Status) if err != nil { - return shared.ErrorResult(err.Error()), UpdateOutput{}, nil + return nil, shared.ErrorResult(err.Error()) } - builder.WithStatus(status) - } - - if input.Note != nil { - builder.WithNote(*input.Note) + parsed.Status = &status } if input.Priority != nil { priority, err := lunatask.ParsePriority(*input.Priority) if err != nil { - return shared.ErrorResult(err.Error()), UpdateOutput{}, nil + return nil, shared.ErrorResult(err.Error()) } - builder.Priority(priority) - } - - if input.Estimate != nil { - builder.WithEstimate(*input.Estimate) + parsed.Priority = &priority } if input.Motivation != nil { motivation, err := lunatask.ParseMotivation(*input.Motivation) if err != nil { - return shared.ErrorResult(err.Error()), UpdateOutput{}, nil + return nil, shared.ErrorResult(err.Error()) } - builder.WithMotivation(motivation) + parsed.Motivation = &motivation } - if input.Important != nil { - if *input.Important { - builder.Important() - } else { - builder.NotImportant() + if input.ScheduledOn != nil { + date, err := dateutil.Parse(*input.ScheduledOn) + if err != nil { + return nil, shared.ErrorResult(err.Error()) } + + parsed.ScheduledOn = &date } - if input.Urgent != nil { - if *input.Urgent { - builder.Urgent() - } else { - builder.NotUrgent() - } + return parsed, nil +} + +//nolint:cyclop +func applyToTaskUpdateBuilder(builder *lunatask.TaskUpdateBuilder, parsed *parsedUpdateInput) { + if parsed.Name != nil { + builder.Name(*parsed.Name) } - if input.ScheduledOn != nil { - date, err := dateutil.Parse(*input.ScheduledOn) - if err != nil { - return shared.ErrorResult(err.Error()), UpdateOutput{}, nil - } + if parsed.AreaID != nil { + builder.InArea(*parsed.AreaID) + } - builder.ScheduledOn(date) + if parsed.GoalID != nil { + builder.InGoal(*parsed.GoalID) } - task, err := builder.Update(ctx) - if err != nil { - return shared.ErrorResult(err.Error()), UpdateOutput{}, nil + if parsed.Status != nil { + builder.WithStatus(*parsed.Status) } - deepLink, _ := lunatask.BuildDeepLink(lunatask.ResourceTask, task.ID) + if parsed.Note != nil { + builder.WithNote(*parsed.Note) + } - return nil, UpdateOutput{ - ID: task.ID, - DeepLink: deepLink, - }, nil -} + if parsed.Priority != nil { + builder.Priority(*parsed.Priority) + } -// resolveID extracts a UUID from either a raw UUID or a lunatask:// deep link. -func resolveID(input string) (string, error) { - _, id, err := lunatask.ParseDeepLink(input) - if err == nil { - return id, nil + if parsed.Estimate != nil { + builder.WithEstimate(*parsed.Estimate) } - if err := lunatask.ValidateUUID(input); err != nil { - return "", fmt.Errorf("invalid ID: %w", err) + if parsed.Motivation != nil { + builder.WithMotivation(*parsed.Motivation) } - return input, nil + if parsed.Important != nil { + if *parsed.Important { + builder.Important() + } else { + builder.NotImportant() + } + } + + if parsed.Urgent != nil { + if *parsed.Urgent { + builder.Urgent() + } else { + builder.NotUrgent() + } + } + + if parsed.ScheduledOn != nil { + builder.ScheduledOn(*parsed.ScheduledOn) + } } diff --git a/internal/mcp/tools/timestamp/handler.go b/internal/mcp/tools/timestamp/handler.go index d6a1660..808a0ba 100644 --- a/internal/mcp/tools/timestamp/handler.go +++ b/internal/mcp/tools/timestamp/handler.go @@ -63,7 +63,6 @@ func (h *Handler) Handle( ) (*mcp.CallToolResult, Output, error) { parsed, err := dateutil.Parse(input.Date) if err != nil { - //nolint:nilerr // MCP pattern: user errors in CallToolResult, nil Go error return &mcp.CallToolResult{ IsError: true, Content: []mcp.Content{ diff --git a/internal/validate/validate.go b/internal/validate/validate.go index 2abfff9..3e0f303 100644 --- a/internal/validate/validate.go +++ b/internal/validate/validate.go @@ -20,7 +20,7 @@ var ErrInvalidReference = errors.New("invalid reference: expected UUID or lunata // - UUID: "3bbf1923-64ae-4bcf-96a9-9bb86c799dab" // - Deep link: "lunatask://areas/3bbf1923-64ae-4bcf-96a9-9bb86c799dab" func Reference(input string) (string, error) { - _, id, err := lunatask.ParseDeepLink(input) + _, id, err := lunatask.ParseReference(input) if err != nil { return "", fmt.Errorf("%w: %s", ErrInvalidReference, input) }