refactor(lunatask): introduce client for task creation

Amolith created

Change summary

lunatask/tasks.go | 154 +++++++++++++++++++++++++++++++++++++++++++++++++
main.go           | 112 ++++-------------------------------
2 files changed, 167 insertions(+), 99 deletions(-)

Detailed changes

lunatask/tasks.go 🔗

@@ -3,3 +3,157 @@
 // SPDX-License-Identifier: AGPL-3.0-or-later
 
 package lunatask
+
+import (
+	"bytes"
+	"context"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"io"
+	"net/http"
+	"strings"
+
+	"github.com/go-playground/validator/v10"
+)
+
+// Client handles communication with the Lunatask API
+type Client struct {
+	AccessToken string
+	BaseURL     string
+	HTTPClient  *http.Client
+}
+
+// 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{},
+	}
+}
+
+// CreateTaskRequest represents the request to create a task in Lunatask
+type CreateTaskRequest struct {
+	AreaID      string `json:"area_id"`
+	GoalID      string `json:"goal_id,omitempty" validate:"omitempty"`
+	Name        string `json:"name" validate:"max=100"`
+	Note        string `json:"note,omitempty" validate:"omitempty"`
+	Status      string `json:"status,omitempty" validate:"omitempty,oneof=later next started waiting completed"`
+	Motivation  string `json:"motivation,omitempty" validate:"omitempty,oneof=must should want unknown"`
+	Estimate    int    `json:"estimate,omitempty" validate:"omitempty,min=0,max=720"`
+	Priority    int    `json:"priority,omitempty" validate:"omitempty,min=-2,max=2"`
+	ScheduledOn string `json:"scheduled_on,omitempty" validate:"omitempty"`
+	CompletedAt string `json:"completed_at,omitempty" validate:"omitempty"`
+	Source      string `json:"source,omitempty" validate:"omitempty"`
+}
+
+// CreateTaskResponse represents the response from Lunatask API when creating a task
+type CreateTaskResponse struct {
+	Task struct {
+		ID string `json:"id"`
+	} `json:"task"`
+}
+
+// ValidationError represents errors returned by the validator
+type ValidationError struct {
+	Field   string
+	Tag     string
+	Message string
+}
+
+// Error implements the error interface for ValidationError
+func (e ValidationError) Error() string {
+	return e.Message
+}
+
+// ValidateTask validates the create task request
+func ValidateTask(task *CreateTaskRequest) error {
+	validate := validator.New()
+	if err := validate.Struct(task); 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("task 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
+}
+
+// CreateTask creates a new task in Lunatask
+func (c *Client) CreateTask(ctx context.Context, task *CreateTaskRequest) (*CreateTaskResponse, error) {
+	// Validate the task
+	if err := ValidateTask(task); err != nil {
+		return nil, err
+	}
+
+	// Marshal the task to JSON
+	payloadBytes, err := json.Marshal(task)
+	if err != nil {
+		return nil, fmt.Errorf("failed to marshal payload: %w", err)
+	}
+
+	// Create the request
+	req, err := http.NewRequestWithContext(
+		ctx,
+		"POST",
+		fmt.Sprintf("%s/tasks", c.BaseURL),
+		bytes.NewBuffer(payloadBytes),
+	)
+	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 already exists (no content) case
+	if resp.StatusCode == http.StatusNoContent {
+		return nil, nil // Task already exists (not an error)
+	}
+
+	// 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 CreateTaskResponse
+	err = json.Unmarshal(respBody, &response)
+	if err != nil {
+		return nil, fmt.Errorf("failed to parse response: %w", err)
+	}
+
+	return &response, nil
+}

main.go 🔗

@@ -5,24 +5,21 @@
 package main
 
 import (
-	"bytes"
 	"context"
 	"encoding/json"
-	"errors"
 	"fmt"
-	"io"
 	"log"
-	"net/http"
 	"os"
 	"strings"
 	"time"
 
-	"github.com/go-playground/validator/v10"
 	"github.com/ijt/go-anytime"
 
 	"github.com/BurntSushi/toml"
 	"github.com/mark3labs/mcp-go/mcp"
 	"github.com/mark3labs/mcp-go/server"
+
+	"git.sr.ht/~amolith/lunatask-mcp-server/lunatask"
 )
 
 // Goal represents a Lunatask goal with its name and ID
@@ -122,14 +119,6 @@ func loadLocation(timezone string) (*time.Location, error) {
 	return loc, nil
 }
 
-// closeResponseBody properly closes an HTTP response body, handling any errors
-func closeResponseBody(resp *http.Response) {
-	err := resp.Body.Close()
-	if err != nil {
-		log.Printf("Error closing response body: %v", err)
-	}
-}
-
 // closeFile properly closes a file, handling any errors
 func closeFile(f *os.File) {
 	err := f.Close()
@@ -267,28 +256,6 @@ func NewMCPServer(config *Config) *server.MCPServer {
 	return mcpServer
 }
 
-// LunataskCreateTaskRequest represents the request payload for creating a task in Lunatask
-type LunataskCreateTaskRequest struct {
-	AreaID      string `json:"area_id"`
-	GoalID      string `json:"goal_id,omitempty" validate:"omitempty"`
-	Name        string `json:"name" validate:"max=100"`
-	Note        string `json:"note,omitempty" validate:"omitempty"`
-	Status      string `json:"status,omitempty" validate:"omitempty,oneof=later next started waiting completed"`
-	Motivation  string `json:"motivation,omitempty" validate:"omitempty,oneof=must should want unknown"`
-	Estimate    int    `json:"estimate,omitempty" validate:"omitempty,min=0,max=720"`
-	Priority    int    `json:"priority,omitempty" validate:"omitempty,min=-2,max=2"`
-	ScheduledOn string `json:"scheduled_on,omitempty" validate:"omitempty"`
-	CompletedAt string `json:"completed_at,omitempty" validate:"omitempty"`
-	Source      string `json:"source,omitempty" validate:"omitempty"`
-}
-
-// LunataskCreateTaskResponse represents the response from Lunatask API when creating a task
-type LunataskCreateTaskResponse struct {
-	Task struct {
-		ID string `json:"id"`
-	} `json:"task"`
-}
-
 func reportMCPError(msg string) (*mcp.CallToolResult, error) {
 	return &mcp.CallToolResult{
 		IsError: true,
@@ -387,59 +354,27 @@ func handleCreateTask(
 		// If it's an empty string, it's handled by the API or omitempty later, no need to validate format.
 	}
 
-	var payload LunataskCreateTaskRequest
+	// Create Lunatask client
+	client := lunatask.NewClient(config.AccessToken)
+
+	// Prepare the task request
+	var task lunatask.CreateTaskRequest
 	argBytes, err := json.Marshal(arguments)
 	if err != nil {
 		return reportMCPError(fmt.Sprintf("Failed to process arguments: %v", err))
 	}
-	if err := json.Unmarshal(argBytes, &payload); err != nil {
+	if err := json.Unmarshal(argBytes, &task); err != nil {
 		return reportMCPError(fmt.Sprintf("Failed to parse arguments: %v", err))
 	}
 
-	validate := validator.New()
-	if err := validate.Struct(payload); err != nil {
-		var invalidValidationError *validator.InvalidValidationError
-		if errors.As(err, &invalidValidationError) {
-			return reportMCPError(fmt.Sprintf("Invalid validation error: %v", err))
-		}
-		var validationErrs validator.ValidationErrors
-		if errors.As(err, &validationErrs) {
-			var msgBuilder strings.Builder
-			msgBuilder.WriteString("task validation failed:")
-			for _, e := range validationErrs {
-				fmt.Fprintf(&msgBuilder, " field '%s' failed '%s' validation (was: %v);", e.Field(), e.Tag(), e.Value())
-			}
-			return reportMCPError(msgBuilder.String())
-		}
-		return reportMCPError(fmt.Sprintf("Validation error: %v", err))
-	}
-
-	payloadBytes, err := json.Marshal(payload)
-	if err != nil {
-		return reportMCPError(fmt.Sprintf("Failed to marshal payload: %v", err))
-	}
-
-	req, err := http.NewRequestWithContext(
-		ctx,
-		"POST",
-		"https://api.lunatask.app/v1/tasks",
-		bytes.NewBuffer(payloadBytes),
-	)
+	// Call the client to create the task
+	response, err := client.CreateTask(ctx, &task)
 	if err != nil {
-		return reportMCPError(fmt.Sprintf("Failed to create HTTP request: %v", err))
+		return reportMCPError(fmt.Sprintf("%v", err))
 	}
 
-	req.Header.Set("Content-Type", "application/json")
-	req.Header.Set("Authorization", "bearer "+config.AccessToken)
-
-	client := &http.Client{}
-	resp, err := client.Do(req)
-	if err != nil {
-		return reportMCPError(fmt.Sprintf("Failed to send HTTP request: %v", err))
-	}
-	defer closeResponseBody(resp)
-
-	if resp.StatusCode == http.StatusNoContent {
+	// Handle the case where task already exists
+	if response == nil {
 		return &mcp.CallToolResult{
 			Content: []mcp.Content{
 				mcp.TextContent{
@@ -450,27 +385,6 @@ func handleCreateTask(
 		}, nil
 	}
 
-	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
-		respBody, _ := io.ReadAll(resp.Body)
-		log.Printf("Lunatask API error (status %d): %s", resp.StatusCode, string(respBody))
-		return &mcp.CallToolResult{
-			IsError: true,
-			Content: []mcp.Content{mcp.TextContent{Type: "text", Text: fmt.Sprintf("API error (status %d): %s", resp.StatusCode, string(respBody))}},
-		}, nil
-	}
-
-	var response LunataskCreateTaskResponse
-
-	respBody, err := io.ReadAll(resp.Body)
-	if err != nil {
-		return reportMCPError(fmt.Sprintf("Failed to read response body: %v", err))
-	}
-
-	err = json.Unmarshal(respBody, &response)
-	if err != nil {
-		return reportMCPError(fmt.Sprintf("Failed to parse response: %v", err))
-	}
-
 	return &mcp.CallToolResult{
 		Content: []mcp.Content{
 			mcp.TextContent{