diff --git a/go.mod b/go.mod index edc67148cac6b2048117fba2eb1c36e222a31ca3..0f1f210c8ea4af3718090c5c4a3219d57728005c 100644 --- a/go.mod +++ b/go.mod @@ -8,22 +8,13 @@ go 1.24.2 require ( github.com/BurntSushi/toml v1.5.0 - github.com/go-playground/validator/v10 v10.26.0 github.com/ijt/go-anytime v1.9.2 github.com/mark3labs/mcp-go v0.23.1 ) require ( - github.com/gabriel-vasile/mimetype v1.4.9 // indirect - github.com/go-playground/locales v0.14.1 // indirect - github.com/go-playground/universal-translator v0.18.1 // indirect github.com/google/uuid v1.6.0 // indirect github.com/ijt/goparsify v0.0.0-20221203142333-3a5276334b8d // indirect - github.com/leodido/go-urn v1.4.0 // indirect github.com/spf13/cast v1.7.1 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect - golang.org/x/crypto v0.37.0 // indirect - golang.org/x/net v0.39.0 // indirect - golang.org/x/sys v0.32.0 // indirect - golang.org/x/text v0.24.0 // indirect ) diff --git a/go.sum b/go.sum index f60bfcbe8b8ff67217f935d20f3312179fd1d77b..363ee2919d454a5188a4cd5e60d6455fb0111145 100644 --- a/go.sum +++ b/go.sum @@ -4,16 +4,6 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= -github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY= -github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok= -github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= -github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= -github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= -github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= -github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= -github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= -github.com/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc/iMaVtFbr3Sw2k= -github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -26,8 +16,6 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= -github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/mark3labs/mcp-go v0.23.1 h1:RzTzZ5kJ+HxwnutKA4rll8N/pKV6Wh5dhCmiJUu5S9I= github.com/mark3labs/mcp-go v0.23.1/go.mod h1:rXqOudj/djTORU/ThxYx8fqEVj/5pvTuuebQ2RC7uk4= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -42,13 +30,5 @@ github.com/tj/assert v0.0.0-20190920132354-ee03d75cd160 h1:NSWpaDaurcAJY7PkL8Xt0 github.com/tj/assert v0.0.0-20190920132354-ee03d75cd160/go.mod h1:mZ9/Rh9oLWpLLDRpvE+3b7gP/C2YyLFYxNmcLnPTMe0= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= -golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= -golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= -golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= -golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= -golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= -golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= -golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/lunatask/client.go b/lunatask/client.go index 1d045df7f9255e1798daf91276e61e98ef933c98..e053d2b2eeef2b5d68f8769f02cbec18337f455c 100644 --- a/lunatask/client.go +++ b/lunatask/client.go @@ -5,7 +5,9 @@ package lunatask import ( + "bytes" "context" + "encoding/json" "errors" "fmt" "io" @@ -76,52 +78,119 @@ func newAPIError(statusCode int, body string) *APIError { return &APIError{StatusCode: statusCode, Body: body, Err: err} } -// Client handles communication with the Lunatask API +// DefaultBaseURL is the default Lunatask API base URL. +const DefaultBaseURL = "https://api.lunatask.app/v1" + +// Client handles communication with the Lunatask API. type Client struct { - AccessToken string - BaseURL string - HTTPClient *http.Client + accessToken string + baseURL string + httpClient *http.Client +} + +// Option configures a Client. +type Option func(*Client) + +// WithHTTPClient sets a custom HTTP client. +func WithHTTPClient(client *http.Client) Option { + return func(c *Client) { + c.httpClient = client + } +} + +// WithBaseURL sets a custom base URL (useful for testing). +func WithBaseURL(url string) Option { + return func(c *Client) { + c.baseURL = url + } } -// NewClient creates a new Lunatask API client -func NewClient(accessToken string) *Client { - return &Client{ - AccessToken: accessToken, - BaseURL: "https://api.lunatask.app/v1", - HTTPClient: &http.Client{}, +// NewClient creates a new Lunatask API client. +func NewClient(accessToken string, opts ...Option) *Client { + c := &Client{ + accessToken: accessToken, + baseURL: DefaultBaseURL, + httpClient: &http.Client{}, + } + for _, opt := range opts { + opt(c) } + return c } -// doRequest performs an HTTP request and handles common response processing -func (c *Client) doRequest(req *http.Request) ([]byte, error) { - req.Header.Set("Authorization", "bearer "+c.AccessToken) +// doRequest performs an HTTP request and handles common response processing. +// Returns the response body and status code. 204 No Content returns nil body. +func (c *Client) doRequest(req *http.Request) ([]byte, int, error) { + req.Header.Set("Authorization", "bearer "+c.accessToken) - resp, err := c.HTTPClient.Do(req) + resp, err := c.httpClient.Do(req) if err != nil { - return nil, fmt.Errorf("failed to send HTTP request: %w", err) + return nil, 0, fmt.Errorf("failed to send HTTP request: %w", err) } defer func() { _ = resp.Body.Close() }() body, err := io.ReadAll(resp.Body) if err != nil { - return nil, fmt.Errorf("failed to read response body: %w", err) + return nil, resp.StatusCode, fmt.Errorf("failed to read response body: %w", err) } if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return nil, newAPIError(resp.StatusCode, string(body)) + return nil, resp.StatusCode, newAPIError(resp.StatusCode, string(body)) + } + + // 204 No Content is a valid success with no body + if resp.StatusCode == http.StatusNoContent { + return nil, resp.StatusCode, nil } - return body, nil + return body, resp.StatusCode, nil } -// Ping verifies the access token is valid by calling the /ping endpoint. -// Returns nil on success, or an error (typically ErrUnauthorized) on failure. -func (c *Client) Ping(ctx context.Context) error { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.BaseURL+"/ping", nil) +// doJSON performs an HTTP request with optional JSON body and unmarshals the response. +// Returns (nil, true, nil) for 204 No Content responses. +func doJSON[T any](c *Client, ctx context.Context, method, path string, body any) (*T, bool, error) { + var reqBody io.Reader + if body != nil { + data, err := json.Marshal(body) + if err != nil { + return nil, false, fmt.Errorf("failed to marshal request body: %w", err) + } + reqBody = bytes.NewReader(data) + } + + req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, reqBody) if err != nil { - return fmt.Errorf("failed to create HTTP request: %w", err) + return nil, false, fmt.Errorf("failed to create HTTP request: %w", err) + } + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + + respBody, statusCode, err := c.doRequest(req) + if err != nil { + return nil, false, err } - _, err = c.doRequest(req) - return err + if statusCode == http.StatusNoContent { + return nil, true, nil + } + + var result T + if err := json.Unmarshal(respBody, &result); err != nil { + return nil, false, fmt.Errorf("failed to parse response: %w", err) + } + + return &result, false, nil +} + +// PingResponse represents the response from the /ping endpoint. +type PingResponse struct { + Message string `json:"message"` +} + +// Ping verifies the access token is valid by calling the /ping endpoint. +// Returns the ping response on success, or an error (typically ErrUnauthorized) on failure. +func (c *Client) Ping(ctx context.Context) (*PingResponse, error) { + resp, _, err := doJSON[PingResponse](c, ctx, http.MethodGet, "/ping", nil) + return resp, err } diff --git a/lunatask/habits.go b/lunatask/habits.go index 8593a2cec2c4365545e011059bb4aa61400e8778..c979e77eb4b1b490b0dcff4e58ce0ea9e8d7a795 100644 --- a/lunatask/habits.go +++ b/lunatask/habits.go @@ -5,115 +5,43 @@ package lunatask import ( - "bytes" "context" - "encoding/json" - "errors" "fmt" - "io" "net/http" - "strings" - - "github.com/go-playground/validator/v10" + "time" ) -// TrackHabitActivityRequest represents the request to track a habit activity +// TrackHabitActivityRequest represents the request to track a habit activity. type TrackHabitActivityRequest struct { - PerformedOn string `json:"performed_on" validate:"required,datetime=2006-01-02T15:04:05Z07:00"` + // PerformedOn is the ISO-8601 date when the activity was performed (e.g., "2024-08-26"). + PerformedOn string `json:"performed_on"` } -// TrackHabitActivityResponse represents the response from Lunatask API when tracking a habit activity +// TrackHabitActivityResponse represents the response from Lunatask API when tracking a habit activity. type TrackHabitActivityResponse struct { Status string `json:"status"` Message string `json:"message,omitempty"` } -// ValidateTrackHabitActivity validates the track habit activity request -func ValidateTrackHabitActivity(request *TrackHabitActivityRequest) error { - validate := validator.New() - if err := validate.Struct(request); err != nil { - var invalidValidationError *validator.InvalidValidationError - if errors.As(err, &invalidValidationError) { - return fmt.Errorf("invalid validation error: %w", err) - } - - var validationErrs validator.ValidationErrors - if errors.As(err, &validationErrs) { - var msgBuilder strings.Builder - msgBuilder.WriteString("habit activity validation failed:") - for _, e := range validationErrs { - fmt.Fprintf(&msgBuilder, " field '%s' failed '%s' validation (was: %v);", e.Field(), e.Tag(), e.Value()) - } - return errors.New(msgBuilder.String()) - } - return fmt.Errorf("validation error: %w", err) - } - return nil -} - -// TrackHabitActivity tracks an activity for a habit in Lunatask +// TrackHabitActivity tracks an activity for a habit in Lunatask. +// The PerformedOn field must be an ISO-8601 date (e.g., "2024-08-26"). func (c *Client) TrackHabitActivity(ctx context.Context, habitID string, request *TrackHabitActivityRequest) (*TrackHabitActivityResponse, error) { if habitID == "" { - return nil, errors.New("habit ID cannot be empty") + return nil, fmt.Errorf("%w: habit ID cannot be empty", ErrBadRequest) } - - // Validate the request - if err := ValidateTrackHabitActivity(request); err != nil { - return nil, err + if request.PerformedOn == "" { + return nil, fmt.Errorf("%w: performed_on is required", ErrBadRequest) } - // Marshal the request to JSON - payloadBytes, err := json.Marshal(request) - if err != nil { - return nil, fmt.Errorf("failed to marshal payload: %w", err) + // Validate date format (YYYY-MM-DD) + if _, err := time.Parse("2006-01-02", request.PerformedOn); err != nil { + return nil, fmt.Errorf("%w: performed_on must be a valid ISO-8601 date (YYYY-MM-DD)", ErrBadRequest) } - // Create the request - req, err := http.NewRequestWithContext( - ctx, - "POST", - fmt.Sprintf("%s/habits/%s/track", c.BaseURL, habitID), - bytes.NewBuffer(payloadBytes), - ) + resp, _, err := doJSON[TrackHabitActivityResponse](c, ctx, http.MethodPost, "/habits/"+habitID+"/track", request) if err != nil { - return nil, fmt.Errorf("failed to create HTTP request: %w", err) - } - - // Set headers - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Authorization", "bearer "+c.AccessToken) - - // Send the request - resp, err := c.HTTPClient.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to send HTTP request: %w", err) - } - defer func() { - if resp.Body != nil { - if err := resp.Body.Close(); err != nil { - // We're in a defer, so we can only log the error - fmt.Printf("Error closing response body: %v\n", err) - } - } - }() - - // Handle error status codes - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - respBody, _ := io.ReadAll(resp.Body) - return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(respBody)) - } - - // Read and parse the response - respBody, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("failed to read response body: %w", err) - } - - var response TrackHabitActivityResponse - err = json.Unmarshal(respBody, &response) - if err != nil { - return nil, fmt.Errorf("failed to parse response: %w", err) + return nil, err } - return &response, nil + return resp, nil } diff --git a/lunatask/tasks.go b/lunatask/tasks.go index 15bdcb8069026a78d45e36265c09495e9e4e23ab..911aac63346bb1001fc407c17afb29c36d33f1b8 100644 --- a/lunatask/tasks.go +++ b/lunatask/tasks.go @@ -5,82 +5,74 @@ package lunatask import ( - "bytes" "context" - "encoding/json" - "errors" "fmt" "net/http" "net/url" + "time" ) -// Source represents a task source like GitHub or other integrations -type Source struct { - Source string `json:"source"` - SourceID string `json:"source_id"` -} - // Task represents a task returned from the Lunatask API type Task struct { - ID string `json:"id"` - AreaID *string `json:"area_id"` - GoalID *string `json:"goal_id"` - Name *string `json:"name"` - Note *string `json:"note"` - Status *string `json:"status"` - PreviousStatus *string `json:"previous_status"` - Estimate *int `json:"estimate"` - Priority *int `json:"priority"` - Progress *int `json:"progress"` - Motivation *string `json:"motivation"` - Eisenhower *int `json:"eisenhower"` - Sources []Source `json:"sources"` - ScheduledOn *string `json:"scheduled_on"` - CompletedAt *string `json:"completed_at"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` + ID string `json:"id"` + AreaID *string `json:"area_id"` + GoalID *string `json:"goal_id"` + Name *string `json:"name"` + Note *string `json:"note"` + Status *string `json:"status"` + PreviousStatus *string `json:"previous_status"` + Estimate *int `json:"estimate"` + Priority *int `json:"priority"` + Progress *int `json:"progress"` + Motivation *string `json:"motivation"` + Eisenhower *int `json:"eisenhower"` + Sources []Source `json:"sources"` + ScheduledOn *Date `json:"scheduled_on"` + CompletedAt *time.Time `json:"completed_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } // CreateTaskRequest represents the request to create a task in Lunatask type CreateTaskRequest struct { - Name string `json:"name"` - AreaID *string `json:"area_id,omitempty"` - GoalID *string `json:"goal_id,omitempty"` - Note *string `json:"note,omitempty"` - Status *string `json:"status,omitempty"` - Motivation *string `json:"motivation,omitempty"` - Estimate *int `json:"estimate,omitempty"` - Priority *int `json:"priority,omitempty"` - Eisenhower *int `json:"eisenhower,omitempty"` - ScheduledOn *string `json:"scheduled_on,omitempty"` - CompletedAt *string `json:"completed_at,omitempty"` - Source *string `json:"source,omitempty"` - SourceID *string `json:"source_id,omitempty"` + Name string `json:"name"` + AreaID *string `json:"area_id,omitempty"` + GoalID *string `json:"goal_id,omitempty"` + Note *string `json:"note,omitempty"` + Status *string `json:"status,omitempty"` + Motivation *string `json:"motivation,omitempty"` + Estimate *int `json:"estimate,omitempty"` + Priority *int `json:"priority,omitempty"` + Eisenhower *int `json:"eisenhower,omitempty"` + ScheduledOn *Date `json:"scheduled_on,omitempty"` + CompletedAt *time.Time `json:"completed_at,omitempty"` + Source *string `json:"source,omitempty"` + SourceID *string `json:"source_id,omitempty"` } // UpdateTaskRequest represents the request to update a task in Lunatask. // All fields are optional; only provided fields will be updated. type UpdateTaskRequest struct { - Name *string `json:"name,omitempty"` - AreaID *string `json:"area_id,omitempty"` - GoalID *string `json:"goal_id,omitempty"` - Note *string `json:"note,omitempty"` - Status *string `json:"status,omitempty"` - Motivation *string `json:"motivation,omitempty"` - Estimate *int `json:"estimate,omitempty"` - Priority *int `json:"priority,omitempty"` - Eisenhower *int `json:"eisenhower,omitempty"` - ScheduledOn *string `json:"scheduled_on,omitempty"` - CompletedAt *string `json:"completed_at,omitempty"` + Name *string `json:"name,omitempty"` + AreaID *string `json:"area_id,omitempty"` + GoalID *string `json:"goal_id,omitempty"` + Note *string `json:"note,omitempty"` + Status *string `json:"status,omitempty"` + Motivation *string `json:"motivation,omitempty"` + Estimate *int `json:"estimate,omitempty"` + Priority *int `json:"priority,omitempty"` + Eisenhower *int `json:"eisenhower,omitempty"` + ScheduledOn *Date `json:"scheduled_on,omitempty"` + CompletedAt *time.Time `json:"completed_at,omitempty"` } -// TaskResponse represents a single task response from the API -type TaskResponse struct { +// taskResponse represents a single task response from the API +type taskResponse struct { Task Task `json:"task"` } -// TasksResponse represents a list of tasks response from the API -type TasksResponse struct { +// tasksResponse represents a list of tasks response from the API +type tasksResponse struct { Tasks []Task `json:"tasks"` } @@ -92,7 +84,7 @@ type ListTasksOptions struct { // ListTasks retrieves all tasks, optionally filtered by source and/or source_id func (c *Client) ListTasks(ctx context.Context, opts *ListTasksOptions) ([]Task, error) { - u := fmt.Sprintf("%s/tasks", c.BaseURL) + path := "/tasks" if opts != nil { params := url.Values{} @@ -103,26 +95,16 @@ func (c *Client) ListTasks(ctx context.Context, opts *ListTasksOptions) ([]Task, params.Set("source_id", *opts.SourceID) } if len(params) > 0 { - u = fmt.Sprintf("%s?%s", u, params.Encode()) + path = fmt.Sprintf("%s?%s", path, params.Encode()) } } - req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) - if err != nil { - return nil, fmt.Errorf("failed to create HTTP request: %w", err) - } - - body, err := c.doRequest(req) + resp, _, err := doJSON[tasksResponse](c, ctx, http.MethodGet, path, nil) if err != nil { return nil, err } - var response TasksResponse - if err := json.Unmarshal(body, &response); err != nil { - return nil, fmt.Errorf("failed to parse response: %w", err) - } - - return response.Tasks, nil + return resp.Tasks, nil } // GetTask retrieves a specific task by ID @@ -131,27 +113,12 @@ func (c *Client) GetTask(ctx context.Context, taskID string) (*Task, error) { return nil, fmt.Errorf("%w: task ID cannot be empty", ErrBadRequest) } - req, err := http.NewRequestWithContext( - ctx, - http.MethodGet, - fmt.Sprintf("%s/tasks/%s", c.BaseURL, taskID), - nil, - ) - if err != nil { - return nil, fmt.Errorf("failed to create HTTP request: %w", err) - } - - body, err := c.doRequest(req) + resp, _, err := doJSON[taskResponse](c, ctx, http.MethodGet, "/tasks/"+taskID, nil) if err != nil { return nil, err } - var response TaskResponse - if err := json.Unmarshal(body, &response); err != nil { - return nil, fmt.Errorf("failed to parse response: %w", err) - } - - return &response.Task, nil + return &resp.Task, nil } // CreateTask creates a new task in Lunatask. @@ -161,43 +128,15 @@ func (c *Client) CreateTask(ctx context.Context, task *CreateTaskRequest) (*Task return nil, fmt.Errorf("%w: name is required", ErrBadRequest) } - payloadBytes, err := json.Marshal(task) - if err != nil { - return nil, fmt.Errorf("failed to marshal payload: %w", err) - } - - req, err := http.NewRequestWithContext( - ctx, - http.MethodPost, - fmt.Sprintf("%s/tasks", c.BaseURL), - bytes.NewBuffer(payloadBytes), - ) - if err != nil { - return nil, fmt.Errorf("failed to create HTTP request: %w", err) - } - req.Header.Set("Content-Type", "application/json") - - body, err := c.doRequest(req) + resp, noContent, err := doJSON[taskResponse](c, ctx, http.MethodPost, "/tasks", task) if err != nil { - // Check for 204 No Content (task already exists) - var apiErr *APIError - if errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusNoContent { - return nil, nil - } return nil, err } - - // Handle empty body (204 case that slipped through) - if len(body) == 0 { + if noContent { return nil, nil } - var response TaskResponse - if err := json.Unmarshal(body, &response); err != nil { - return nil, fmt.Errorf("failed to parse response: %w", err) - } - - return &response.Task, nil + return &resp.Task, nil } // UpdateTask updates an existing task in Lunatask @@ -206,33 +145,12 @@ func (c *Client) UpdateTask(ctx context.Context, taskID string, task *UpdateTask return nil, fmt.Errorf("%w: task ID cannot be empty", ErrBadRequest) } - payloadBytes, err := json.Marshal(task) - if err != nil { - return nil, fmt.Errorf("failed to marshal payload: %w", err) - } - - req, err := http.NewRequestWithContext( - ctx, - http.MethodPut, - fmt.Sprintf("%s/tasks/%s", c.BaseURL, taskID), - bytes.NewBuffer(payloadBytes), - ) - if err != nil { - return nil, fmt.Errorf("failed to create HTTP request: %w", err) - } - req.Header.Set("Content-Type", "application/json") - - body, err := c.doRequest(req) + resp, _, err := doJSON[taskResponse](c, ctx, http.MethodPut, "/tasks/"+taskID, task) if err != nil { return nil, err } - var response TaskResponse - if err := json.Unmarshal(body, &response); err != nil { - return nil, fmt.Errorf("failed to parse response: %w", err) - } - - return &response.Task, nil + return &resp.Task, nil } // DeleteTask deletes a task in Lunatask @@ -241,25 +159,10 @@ func (c *Client) DeleteTask(ctx context.Context, taskID string) (*Task, error) { return nil, fmt.Errorf("%w: task ID cannot be empty", ErrBadRequest) } - req, err := http.NewRequestWithContext( - ctx, - http.MethodDelete, - fmt.Sprintf("%s/tasks/%s", c.BaseURL, taskID), - nil, - ) - if err != nil { - return nil, fmt.Errorf("failed to create HTTP request: %w", err) - } - - body, err := c.doRequest(req) + resp, _, err := doJSON[taskResponse](c, ctx, http.MethodDelete, "/tasks/"+taskID, nil) if err != nil { return nil, err } - var response TaskResponse - if err := json.Unmarshal(body, &response); err != nil { - return nil, fmt.Errorf("failed to parse response: %w", err) - } - - return &response.Task, nil + return &resp.Task, nil } diff --git a/lunatask/types.go b/lunatask/types.go new file mode 100644 index 0000000000000000000000000000000000000000..c4e3b4cd0e83ab5de9908d0d5f2752ec2ec9a4ca --- /dev/null +++ b/lunatask/types.go @@ -0,0 +1,80 @@ +// SPDX-FileCopyrightText: Amolith +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +package lunatask + +import ( + "encoding/json" + "time" +) + +// Source represents an external source integration (e.g., GitHub, Todoist). +// Used across multiple entity types for tracking where items originated. +type Source struct { + Source string `json:"source"` + SourceID string `json:"source_id"` +} + +// Date represents a date-only value (YYYY-MM-DD) with proper JSON marshaling. +// Used for fields like scheduled_on that don't include time components. +type Date struct { + time.Time +} + +const dateFormat = "2006-01-02" + +// MarshalJSON implements json.Marshaler for Date. +func (d Date) MarshalJSON() ([]byte, error) { + if d.IsZero() { + return []byte("null"), nil + } + return json.Marshal(d.Format(dateFormat)) +} + +// UnmarshalJSON implements json.Unmarshaler for Date. +func (d *Date) UnmarshalJSON(data []byte) error { + if string(data) == "null" { + return nil + } + + var s string + if err := json.Unmarshal(data, &s); err != nil { + return err + } + + t, err := time.Parse(dateFormat, s) + if err != nil { + return err + } + + d.Time = t + return nil +} + +// String returns the date in YYYY-MM-DD format. +func (d Date) String() string { + if d.IsZero() { + return "" + } + return d.Format(dateFormat) +} + +// NewDate creates a Date from a time.Time, discarding time components. +func NewDate(t time.Time) Date { + return Date{time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC)} +} + +// ParseDate parses a date string in YYYY-MM-DD format. +func ParseDate(s string) (Date, error) { + t, err := time.Parse(dateFormat, s) + if err != nil { + return Date{}, err + } + return Date{t}, nil +} + +// Today returns the current date. +func Today() Date { + return NewDate(time.Now()) +} diff --git a/tools/tasks.go b/tools/tasks.go index c490db4cf5a97a35abafa60254466c69f8f5c6ad..3c12356a803391b124cc45fd54e1f93c7e453465 100644 --- a/tools/tasks.go +++ b/tools/tasks.go @@ -8,7 +8,6 @@ import ( "context" "fmt" "strings" - "time" "git.sr.ht/~amolith/lunatask-mcp-server/lunatask" "github.com/mark3labs/mcp-go/mcp" @@ -155,10 +154,11 @@ func (h *Handlers) HandleCreateTask(ctx context.Context, request mcp.CallToolReq return reportMCPError("Invalid type for scheduled_on argument: expected string.") } if scheduledOnStr != "" { - if _, err := time.Parse(time.RFC3339, scheduledOnStr); err != nil { - return reportMCPError(fmt.Sprintf("Invalid format for scheduled_on: '%s'. Must be RFC3339 timestamp (e.g., YYYY-MM-DDTHH:MM:SSZ). Use get_timestamp tool first.", scheduledOnStr)) + date, err := lunatask.ParseDate(scheduledOnStr) + if err != nil { + return reportMCPError(fmt.Sprintf("Invalid format for scheduled_on: '%s'. Must be YYYY-MM-DD.", scheduledOnStr)) } - task.ScheduledOn = &scheduledOnStr + task.ScheduledOn = &date } } @@ -368,13 +368,12 @@ func (h *Handlers) HandleUpdateTask(ctx context.Context, request mcp.CallToolReq if !ok && scheduledOnArg != nil { return reportMCPError("Invalid type for scheduled_on argument: expected string.") } - if ok { - if scheduledOnStr != "" { - if _, err := time.Parse(time.RFC3339, scheduledOnStr); err != nil { - return reportMCPError(fmt.Sprintf("Invalid format for scheduled_on: '%s'. Must be RFC3339. Use get_timestamp tool.", scheduledOnStr)) - } + if ok && scheduledOnStr != "" { + date, err := lunatask.ParseDate(scheduledOnStr) + if err != nil { + return reportMCPError(fmt.Sprintf("Invalid format for scheduled_on: '%s'. Must be YYYY-MM-DD.", scheduledOnStr)) } - updatePayload.ScheduledOn = &scheduledOnStr + updatePayload.ScheduledOn = &date } }