Detailed changes
@@ -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
)
@@ -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=
@@ -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
}
@@ -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
}
@@ -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
}
@@ -0,0 +1,80 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// 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())
+}
@@ -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
}
}