feat(tasks): add typed Priority API

Amolith created

Replace *int with *Priority for type safety. Rename WithPriority(int) to
Priority(Priority) on TaskBuilder and TaskUpdateBuilder.

Includes:
- Priority constants: PriorityLowest (-2) to PriorityHighest (2)
- String(), Valid(), ParsePriority(), JSON marshal/unmarshal
- Sentinel errors: ErrInvalidPriority, ErrPriorityOutOfRange

Assisted-by: Claude Sonnet 4 via Amp

Change summary

priority.go      | 109 ++++++++++++++++
priority_test.go | 339 ++++++++++++++++++++++++++++++++++++++++++++++++++
tasks.go         |  18 +-
tasks_test.go    |   4 
4 files changed, 459 insertions(+), 11 deletions(-)

Detailed changes

priority.go 🔗

@@ -0,0 +1,109 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+package lunatask
+
+import (
+	"encoding/json"
+	"errors"
+	"fmt"
+	"strings"
+)
+
+// Errors returned by Priority operations.
+var (
+	// ErrInvalidPriority is returned when parsing an unknown priority string.
+	ErrInvalidPriority = errors.New("invalid priority")
+	// ErrPriorityOutOfRange is returned when a priority value is outside [-2, 2].
+	ErrPriorityOutOfRange = errors.New("priority out of range")
+)
+
+// Priority represents task priority level from lowest (-2) to highest (2).
+// The default priority is [PriorityNormal] (0).
+type Priority int
+
+// Task priority levels. See [Priority] for the full range.
+const (
+	PriorityLowest  Priority = -2
+	PriorityLow     Priority = -1
+	PriorityNormal  Priority = 0
+	PriorityHigh    Priority = 1
+	PriorityHighest Priority = 2
+)
+
+// String returns the lowercase name of the priority level.
+func (p *Priority) String() string {
+	switch *p {
+	case PriorityLowest:
+		return "lowest"
+	case PriorityLow:
+		return "low"
+	case PriorityNormal:
+		return "normal"
+	case PriorityHigh:
+		return "high"
+	case PriorityHighest:
+		return "highest"
+	default:
+		return fmt.Sprintf("Priority(%d)", *p)
+	}
+}
+
+// Valid reports whether the priority is within the valid range [-2, 2].
+func (p *Priority) Valid() bool {
+	return *p >= PriorityLowest && *p <= PriorityHighest
+}
+
+// ParsePriority parses a string to a Priority value (case-insensitive).
+// Valid values: "lowest", "low", "normal", "high", "highest".
+func ParsePriority(str string) (Priority, error) {
+	switch strings.ToLower(str) {
+	case "lowest":
+		return PriorityLowest, nil
+	case "low":
+		return PriorityLow, nil
+	case "normal":
+		return PriorityNormal, nil
+	case "high":
+		return PriorityHigh, nil
+	case "highest":
+		return PriorityHighest, nil
+	default:
+		return 0, fmt.Errorf("%w: %q", ErrInvalidPriority, str)
+	}
+}
+
+// MarshalJSON implements [json.Marshaler].
+// Priority marshals as its integer value.
+func (p *Priority) MarshalJSON() ([]byte, error) {
+	data, err := json.Marshal(int(*p))
+	if err != nil {
+		return nil, fmt.Errorf("marshaling priority: %w", err)
+	}
+
+	return data, nil
+}
+
+// UnmarshalJSON implements [json.Unmarshaler].
+// Priority unmarshals from an integer value in the range [-2, 2].
+func (p *Priority) UnmarshalJSON(data []byte) error {
+	if string(data) == "null" {
+		*p = PriorityNormal
+
+		return nil
+	}
+
+	var val int
+	if err := json.Unmarshal(data, &val); err != nil {
+		return fmt.Errorf("priority must be an integer: %w", err)
+	}
+
+	if val < -2 || val > 2 {
+		return fmt.Errorf("%w: %d", ErrPriorityOutOfRange, val)
+	}
+
+	*p = Priority(val)
+
+	return nil
+}

priority_test.go 🔗

@@ -0,0 +1,339 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+package lunatask_test
+
+import (
+	"encoding/json"
+	"testing"
+
+	lunatask "git.secluded.site/go-lunatask"
+)
+
+// --- Priority Type Constants ---
+
+func TestPriority_Constants(t *testing.T) {
+	t.Parallel()
+
+	tests := []struct {
+		name  string
+		value lunatask.Priority
+		want  int
+	}{
+		{"lowest", lunatask.PriorityLowest, -2},
+		{"low", lunatask.PriorityLow, -1},
+		{"normal", lunatask.PriorityNormal, 0},
+		{"high", lunatask.PriorityHigh, 1},
+		{"highest", lunatask.PriorityHighest, 2},
+	}
+
+	for _, tc := range tests {
+		t.Run(tc.name, func(t *testing.T) {
+			t.Parallel()
+
+			if int(tc.value) != tc.want {
+				t.Errorf("Priority constant = %d, want %d", tc.value, tc.want)
+			}
+		})
+	}
+}
+
+// --- Priority String Method ---
+
+func TestPriority_String(t *testing.T) {
+	t.Parallel()
+
+	tests := []struct {
+		name  string
+		value lunatask.Priority
+		want  string
+	}{
+		{"lowest", lunatask.PriorityLowest, "lowest"},
+		{"low", lunatask.PriorityLow, "low"},
+		{"normal", lunatask.PriorityNormal, "normal"},
+		{"high", lunatask.PriorityHigh, "high"},
+		{"highest", lunatask.PriorityHighest, "highest"},
+	}
+
+	for _, tc := range tests {
+		t.Run(tc.name, func(t *testing.T) {
+			t.Parallel()
+
+			if got := tc.value.String(); got != tc.want {
+				t.Errorf("Priority(%d).String() = %q, want %q", tc.value, got, tc.want)
+			}
+		})
+	}
+}
+
+func TestPriority_String_Unknown(t *testing.T) {
+	t.Parallel()
+
+	p := lunatask.Priority(99)
+	if got := p.String(); got != "Priority(99)" {
+		t.Errorf("Priority(99).String() = %q, want %q", got, "Priority(99)")
+	}
+}
+
+// --- Priority Valid Method ---
+
+func TestPriority_Valid(t *testing.T) {
+	t.Parallel()
+
+	tests := []struct {
+		name  string
+		value lunatask.Priority
+		want  bool
+	}{
+		{"lowest", lunatask.PriorityLowest, true},
+		{"low", lunatask.PriorityLow, true},
+		{"normal", lunatask.PriorityNormal, true},
+		{"high", lunatask.PriorityHigh, true},
+		{"highest", lunatask.PriorityHighest, true},
+		{"below_range", lunatask.Priority(-3), false},
+		{"above_range", lunatask.Priority(3), false},
+		{"way_above", lunatask.Priority(99), false},
+	}
+
+	for _, tc := range tests {
+		t.Run(tc.name, func(t *testing.T) {
+			t.Parallel()
+
+			if got := tc.value.Valid(); got != tc.want {
+				t.Errorf("Priority(%d).Valid() = %v, want %v", tc.value, got, tc.want)
+			}
+		})
+	}
+}
+
+// --- ParsePriority Function ---
+
+func TestParsePriority(t *testing.T) {
+	t.Parallel()
+
+	tests := []struct {
+		name    string
+		input   string
+		want    lunatask.Priority
+		wantErr bool
+	}{
+		{"lowest_lower", "lowest", lunatask.PriorityLowest, false},
+		{"lowest_upper", "LOWEST", lunatask.PriorityLowest, false},
+		{"lowest_mixed", "LoWeSt", lunatask.PriorityLowest, false},
+		{"low_lower", "low", lunatask.PriorityLow, false},
+		{"low_upper", "LOW", lunatask.PriorityLow, false},
+		{"normal_lower", "normal", lunatask.PriorityNormal, false},
+		{"normal_upper", "NORMAL", lunatask.PriorityNormal, false},
+		{"high_lower", "high", lunatask.PriorityHigh, false},
+		{"high_upper", "HIGH", lunatask.PriorityHigh, false},
+		{"highest_lower", "highest", lunatask.PriorityHighest, false},
+		{"highest_upper", "HIGHEST", lunatask.PriorityHighest, false},
+		{"invalid", "invalid", 0, true},
+		{"empty", "", 0, true},
+		{"numeric", "1", 0, true},
+	}
+
+	for _, testCase := range tests {
+		t.Run(testCase.name, func(t *testing.T) {
+			t.Parallel()
+
+			got, err := lunatask.ParsePriority(testCase.input)
+			if (err != nil) != testCase.wantErr {
+				t.Errorf("ParsePriority(%q) error = %v, wantErr %v", testCase.input, err, testCase.wantErr)
+
+				return
+			}
+
+			if !testCase.wantErr && got != testCase.want {
+				t.Errorf("ParsePriority(%q) = %d, want %d", testCase.input, got, testCase.want)
+			}
+		})
+	}
+}
+
+// --- Priority JSON Marshaling ---
+
+func TestPriority_MarshalJSON(t *testing.T) {
+	t.Parallel()
+
+	tests := []struct {
+		name  string
+		value lunatask.Priority
+		want  string
+	}{
+		{"lowest", lunatask.PriorityLowest, "-2"},
+		{"low", lunatask.PriorityLow, "-1"},
+		{"normal", lunatask.PriorityNormal, "0"},
+		{"high", lunatask.PriorityHigh, "1"},
+		{"highest", lunatask.PriorityHighest, "2"},
+	}
+
+	for _, testCase := range tests {
+		t.Run(testCase.name, func(t *testing.T) {
+			t.Parallel()
+
+			got, err := json.Marshal(testCase.value)
+			if err != nil {
+				t.Fatalf("Marshal error: %v", err)
+			}
+
+			if string(got) != testCase.want {
+				t.Errorf("json.Marshal(Priority) = %s, want %s", got, testCase.want)
+			}
+		})
+	}
+}
+
+func TestPriority_UnmarshalJSON(t *testing.T) {
+	t.Parallel()
+
+	tests := []struct {
+		name    string
+		input   string
+		want    lunatask.Priority
+		wantErr bool
+	}{
+		{"lowest", "-2", lunatask.PriorityLowest, false},
+		{"low", "-1", lunatask.PriorityLow, false},
+		{"normal", "0", lunatask.PriorityNormal, false},
+		{"high", "1", lunatask.PriorityHigh, false},
+		{"highest", "2", lunatask.PriorityHighest, false},
+		{"null", "null", lunatask.PriorityNormal, false},
+		{"out_of_range_high", "5", 0, true},
+		{"out_of_range_low", "-5", 0, true},
+		{"string", `"high"`, 0, true},
+	}
+
+	for _, testCase := range tests {
+		t.Run(testCase.name, func(t *testing.T) {
+			t.Parallel()
+
+			var got lunatask.Priority
+
+			err := json.Unmarshal([]byte(testCase.input), &got)
+
+			if (err != nil) != testCase.wantErr {
+				t.Errorf("Unmarshal error = %v, wantErr %v", err, testCase.wantErr)
+
+				return
+			}
+
+			if !testCase.wantErr && got != testCase.want {
+				t.Errorf("Unmarshal(%s) = %d, want %d", testCase.input, got, testCase.want)
+			}
+		})
+	}
+}
+
+// --- TaskBuilder Priority Method ---
+
+func TestTaskBuilder_Priority(t *testing.T) {
+	t.Parallel()
+
+	server, capture := newPOSTServer(t, "/tasks", singleTaskResponseBody)
+	defer server.Close()
+
+	client := lunatask.NewClient(testToken, lunatask.BaseURL(server.URL))
+
+	_, err := client.NewTask("High priority task").
+		Priority(lunatask.PriorityHigh).
+		Create(ctx())
+	if err != nil {
+		t.Fatalf("error = %v", err)
+	}
+
+	assertBodyFieldFloat(t, capture.Body, "priority", 1)
+}
+
+func TestTaskBuilder_Priority_AllValues(t *testing.T) {
+	t.Parallel()
+
+	tests := []struct {
+		name     string
+		priority lunatask.Priority
+		want     float64
+	}{
+		{"lowest", lunatask.PriorityLowest, -2},
+		{"low", lunatask.PriorityLow, -1},
+		{"normal", lunatask.PriorityNormal, 0},
+		{"high", lunatask.PriorityHigh, 1},
+		{"highest", lunatask.PriorityHighest, 2},
+	}
+
+	for _, testCase := range tests {
+		t.Run(testCase.name, func(t *testing.T) {
+			t.Parallel()
+
+			server, capture := newPOSTServer(t, "/tasks", singleTaskResponseBody)
+			defer server.Close()
+
+			client := lunatask.NewClient(testToken, lunatask.BaseURL(server.URL))
+
+			_, err := client.NewTask("Task").
+				Priority(testCase.priority).
+				Create(ctx())
+			if err != nil {
+				t.Fatalf("error = %v", err)
+			}
+
+			assertBodyFieldFloat(t, capture.Body, "priority", testCase.want)
+		})
+	}
+}
+
+// --- TaskUpdateBuilder Priority Method ---
+
+func TestTaskUpdateBuilder_Priority(t *testing.T) {
+	t.Parallel()
+
+	server, capture := newPUTServer(t, "/tasks/"+taskID, singleTaskResponseBody)
+	defer server.Close()
+
+	client := lunatask.NewClient(testToken, lunatask.BaseURL(server.URL))
+
+	_, err := client.NewTaskUpdate(taskID).
+		Priority(lunatask.PriorityLowest).
+		Update(ctx())
+	if err != nil {
+		t.Fatalf("error = %v", err)
+	}
+
+	assertBodyFieldFloat(t, capture.Body, "priority", -2)
+}
+
+func TestTaskUpdateBuilder_Priority_AllValues(t *testing.T) {
+	t.Parallel()
+
+	tests := []struct {
+		name     string
+		priority lunatask.Priority
+		want     float64
+	}{
+		{"lowest", lunatask.PriorityLowest, -2},
+		{"low", lunatask.PriorityLow, -1},
+		{"normal", lunatask.PriorityNormal, 0},
+		{"high", lunatask.PriorityHigh, 1},
+		{"highest", lunatask.PriorityHighest, 2},
+	}
+
+	for _, testCase := range tests {
+		t.Run(testCase.name, func(t *testing.T) {
+			t.Parallel()
+
+			server, capture := newPUTServer(t, "/tasks/"+taskID, singleTaskResponseBody)
+			defer server.Close()
+
+			client := lunatask.NewClient(testToken, lunatask.BaseURL(server.URL))
+
+			_, err := client.NewTaskUpdate(taskID).
+				Priority(testCase.priority).
+				Update(ctx())
+			if err != nil {
+				t.Fatalf("error = %v", err)
+			}
+
+			assertBodyFieldFloat(t, capture.Body, "priority", testCase.want)
+		})
+	}
+}

tasks.go 🔗

@@ -20,7 +20,7 @@ type Task struct {
 	Status         *TaskStatus `json:"status"`
 	PreviousStatus *TaskStatus `json:"previous_status"`
 	Estimate       *int        `json:"estimate"`
-	Priority       *int        `json:"priority"`
+	Priority       *Priority   `json:"priority"`
 	Progress       *int        `json:"progress"`
 	Motivation     *Motivation `json:"motivation"`
 	Eisenhower     *Eisenhower `json:"eisenhower"`
@@ -40,7 +40,7 @@ type createTaskRequest struct {
 	Status      *TaskStatus `json:"status,omitempty"`
 	Motivation  *Motivation `json:"motivation,omitempty"`
 	Estimate    *int        `json:"estimate,omitempty"`
-	Priority    *int        `json:"priority,omitempty"`
+	Priority    *Priority   `json:"priority,omitempty"`
 	Eisenhower  *Eisenhower `json:"eisenhower,omitempty"`
 	ScheduledOn *Date       `json:"scheduled_on,omitempty"`
 	CompletedAt *time.Time  `json:"completed_at,omitempty"`
@@ -57,7 +57,7 @@ type updateTaskRequest struct {
 	Status      *TaskStatus `json:"status,omitempty"`
 	Motivation  *Motivation `json:"motivation,omitempty"`
 	Estimate    *int        `json:"estimate,omitempty"`
-	Priority    *int        `json:"priority,omitempty"`
+	Priority    *Priority   `json:"priority,omitempty"`
 	Eisenhower  *Eisenhower `json:"eisenhower,omitempty"`
 	ScheduledOn *Date       `json:"scheduled_on,omitempty"`
 	CompletedAt *time.Time  `json:"completed_at,omitempty"`
@@ -168,9 +168,9 @@ func (b *TaskBuilder) WithEstimate(minutes int) *TaskBuilder {
 	return b
 }
 
-// WithPriority sets importance from -2 (lowest) to 2 (highest).
-func (b *TaskBuilder) WithPriority(priority int) *TaskBuilder {
-	b.req.Priority = &priority
+// Priority sets the priority level. Use Priority* constants (e.g., [PriorityHigh]).
+func (b *TaskBuilder) Priority(p Priority) *TaskBuilder {
+	b.req.Priority = &p
 
 	return b
 }
@@ -328,9 +328,9 @@ func (b *TaskUpdateBuilder) WithEstimate(minutes int) *TaskUpdateBuilder {
 	return b
 }
 
-// WithPriority sets importance from -2 (lowest) to 2 (highest).
-func (b *TaskUpdateBuilder) WithPriority(priority int) *TaskUpdateBuilder {
-	b.req.Priority = &priority
+// Priority sets the priority level. Use Priority* constants (e.g., [PriorityHigh]).
+func (b *TaskUpdateBuilder) Priority(p Priority) *TaskUpdateBuilder {
+	b.req.Priority = &p
 
 	return b
 }

tasks_test.go 🔗

@@ -206,7 +206,7 @@ func TestCreateTask_AllBuilderFields(t *testing.T) {
 		WithMotivation(lunatask.MotivationWant).
 		WithEisenhower(1).
 		WithEstimate(60).
-		WithPriority(2).
+		Priority(lunatask.PriorityHighest).
 		ScheduledOn(scheduledDate).
 		CompletedAt(completedTime).
 		FromSource(sourceGitHub, sourceID123).
@@ -313,7 +313,7 @@ func TestUpdateTask_AllBuilderFields(t *testing.T) {
 		WithMotivation(lunatask.MotivationShould).
 		WithEisenhower(2).
 		WithEstimate(90).
-		WithPriority(-1).
+		Priority(lunatask.PriorityLow).
 		ScheduledOn(scheduledDate).
 		CompletedAt(completedTime).
 		Update(ctx())