diff --git a/internal/service/issue.go b/internal/service/issue.go index 2a1537d..689f277 100644 --- a/internal/service/issue.go +++ b/internal/service/issue.go @@ -406,7 +406,10 @@ func convertIssueDetails(details []core.IssueWithDetails) []core.Issue { Name string `json:"name"` }{ID: d.State.ID, Name: d.State.Name}, Priority: &priority, + Estimate: d.Estimate, + DueDate: d.DueDate, Assignee: d.Assignee, + Delegate: d.Delegate, CreatedAt: d.CreatedAt, UpdatedAt: d.UpdatedAt, } diff --git a/internal/service/issue_convert_test.go b/internal/service/issue_convert_test.go new file mode 100644 index 0000000..0561c30 --- /dev/null +++ b/internal/service/issue_convert_test.go @@ -0,0 +1,80 @@ +package service + +import ( + "testing" + + "github.com/joa23/linear-cli/pkg/linear/core" +) + +// TestConvertIssueDetails_CarriesDueDateEstimateDelegate is a regression test +// for TL-563: ListAllIssues's IssueWithDetails carried DueDate/Estimate/Delegate, +// but convertIssueDetails (used by ListAssignedWithPagination) dropped them +// when converting to core.Issue, which would have silently undone the fix one +// layer up. The fixture below starts with all three fields already populated +// (not both-nil, which would pass trivially) to catch a half-applied fix. +func TestConvertIssueDetails_CarriesDueDateEstimateDelegate(t *testing.T) { + estimate := 3.5 + dueDate := "2026-09-01" + details := []core.IssueWithDetails{ + { + ID: "issue-1", + Identifier: "TL-1", + Title: "Issue with due date, estimate, and delegate", + Priority: 2, + Estimate: &estimate, + DueDate: &dueDate, + Delegate: &core.User{ID: "delegate-1", Name: "Bot", Email: "bot@example.com"}, + CreatedAt: "2026-01-01T00:00:00Z", + UpdatedAt: "2026-01-02T00:00:00Z", + }, + } + + issues := convertIssueDetails(details) + + if len(issues) != 1 { + t.Fatalf("expected 1 issue, got %d", len(issues)) + } + got := issues[0] + + if got.Estimate == nil || *got.Estimate != estimate { + t.Errorf("Estimate = %v, want %v", got.Estimate, estimate) + } + if got.DueDate == nil || *got.DueDate != dueDate { + t.Errorf("DueDate = %v, want %v", got.DueDate, dueDate) + } + if got.Delegate == nil || got.Delegate.ID != "delegate-1" { + t.Errorf("Delegate = %v, want ID \"delegate-1\"", got.Delegate) + } +} + +// TestConvertIssueDetails_OmitsUnsetDueDateEstimateDelegate confirms issues +// without these fields set continue to convert to nil, not false positives. +func TestConvertIssueDetails_OmitsUnsetDueDateEstimateDelegate(t *testing.T) { + details := []core.IssueWithDetails{ + { + ID: "issue-2", + Identifier: "TL-2", + Title: "Issue without due date, estimate, or delegate", + Priority: 1, + CreatedAt: "2026-01-01T00:00:00Z", + UpdatedAt: "2026-01-02T00:00:00Z", + }, + } + + issues := convertIssueDetails(details) + + if len(issues) != 1 { + t.Fatalf("expected 1 issue, got %d", len(issues)) + } + got := issues[0] + + if got.Estimate != nil { + t.Errorf("Estimate = %v, want nil", got.Estimate) + } + if got.DueDate != nil { + t.Errorf("DueDate = %v, want nil", got.DueDate) + } + if got.Delegate != nil { + t.Errorf("Delegate = %v, want nil", got.Delegate) + } +} diff --git a/pkg/linear/core/types.go b/pkg/linear/core/types.go index 37b6f7f..3f3b99d 100644 --- a/pkg/linear/core/types.go +++ b/pkg/linear/core/types.go @@ -686,10 +686,13 @@ type IssueWithDetails struct { Title string `json:"title"` Description string `json:"description"` Priority int `json:"priority"` + Estimate *float64 `json:"estimate,omitempty"` + DueDate *string `json:"dueDate,omitempty"` CreatedAt string `json:"createdAt"` UpdatedAt string `json:"updatedAt"` State WorkflowState `json:"state"` Assignee *User `json:"assignee,omitempty"` + Delegate *User `json:"delegate,omitempty"` Labels []Label `json:"labels"` Project *Project `json:"project,omitempty"` Team Team `json:"team"` diff --git a/pkg/linear/issues/client.go b/pkg/linear/issues/client.go index 1631f88..72abe3f 100644 --- a/pkg/linear/issues/client.go +++ b/pkg/linear/issues/client.go @@ -804,6 +804,13 @@ func (ic *Client) ListAssignedIssues(limit int) ([]core.Issue, error) { name email } + delegate { + id + name + email + } + estimate + dueDate createdAt updatedAt url @@ -831,7 +838,7 @@ func (ic *Client) ListAssignedIssues(limit int) ([]core.Issue, error) { } } ` - + // Filter for issues assigned to the current user // Why: The "me" identifier is Linear's way of referring to the // authenticated user without needing to know their specific ID. @@ -907,6 +914,14 @@ func (ic *Client) SearchIssuesEnhanced(filters *core.IssueSearchFilters) (*core. name email } + delegate { + id + name + email + } + priority + estimate + dueDate labels { nodes { id @@ -918,7 +933,6 @@ func (ic *Client) SearchIssuesEnhanced(filters *core.IssueSearchFilters) (*core. } } } - priority createdAt updatedAt url @@ -1886,7 +1900,6 @@ func (ic *Client) ListAllIssues(filter *core.IssueFilter) (*core.ListAllIssuesRe identifier title description - priority createdAt updatedAt state { @@ -1912,6 +1925,14 @@ func (ic *Client) ListAllIssues(filter *core.IssueFilter) (*core.ListAllIssuesRe createdAt isMe } + delegate { + id + name + email + } + priority + estimate + dueDate labels { nodes { id @@ -1972,14 +1993,17 @@ func (ic *Client) ListAllIssues(filter *core.IssueFilter) (*core.ListAllIssuesRe Title string `json:"title"` Description string `json:"description"` Priority int `json:"priority"` + Estimate *float64 `json:"estimate,omitempty"` + DueDate *string `json:"dueDate,omitempty"` CreatedAt string `json:"createdAt"` UpdatedAt string `json:"updatedAt"` State core.WorkflowState `json:"state"` - Assignee *core.User `json:"assignee"` + Assignee *core.User `json:"assignee,omitempty"` + Delegate *core.User `json:"delegate,omitempty"` Labels struct { Nodes []core.Label `json:"nodes"` } `json:"labels"` - Project *core.Project `json:"project"` + Project *core.Project `json:"project,omitempty"` Team core.Team `json:"team"` } `json:"nodes"` PageInfo struct { @@ -2010,10 +2034,13 @@ func (ic *Client) ListAllIssues(filter *core.IssueFilter) (*core.ListAllIssuesRe Title: node.Title, Description: node.Description, Priority: node.Priority, + Estimate: node.Estimate, + DueDate: node.DueDate, CreatedAt: node.CreatedAt, UpdatedAt: node.UpdatedAt, State: node.State, Assignee: node.Assignee, + Delegate: node.Delegate, Labels: node.Labels.Nodes, Project: node.Project, Team: node.Team, diff --git a/pkg/linear/issues/client_test.go b/pkg/linear/issues/client_test.go index 14f63d0..9d4f979 100644 --- a/pkg/linear/issues/client_test.go +++ b/pkg/linear/issues/client_test.go @@ -2,9 +2,12 @@ package issues import ( "encoding/json" + "net/http" + "strings" "testing" "github.com/joa23/linear-cli/pkg/linear/core" + "github.com/joa23/linear-cli/pkg/linear/testutil" ) func TestBuildUpdateInput_DelegateID(t *testing.T) { @@ -354,3 +357,260 @@ func TestLabelParent_Deserialization(t *testing.T) { t.Errorf("Labels.Nodes[1].Parent = %+v, want nil", second.Parent) } } + +// newCapturingTestClient wires an issues.Client to a testutil.CapturingTransport, +// so tests can inspect the outgoing GraphQL request while feeding back a canned +// response body. +func newCapturingTestClient(responseBody interface{}) (*Client, *testutil.CapturingTransport) { + transport := testutil.NewCapturingTransport(http.StatusOK, responseBody) + base := core.NewBaseClient("test-token") + base.SetHTTPClient(&http.Client{Transport: transport}) + return NewClient(base), transport +} + +// assertQueryRequestsFields fails the test if any of the given field names is +// not present as a token in the outgoing GraphQL selection set. This is a +// lightweight substring check (not a GraphQL parser), used as regression +// protection against a field silently going missing from a query's selection +// set — the exact bug class TL-563 fixed for SearchIssuesEnhanced, +// ListAssignedIssues, and ListAllIssues. +func assertQueryRequestsFields(t *testing.T, query string, fields []string) { + t.Helper() + for _, field := range fields { + if !strings.Contains(query, field) { + t.Errorf("query does not request field %q:\n%s", field, query) + } + } +} + +// requiredIssueFields is the set of fields TL-563 added to SearchIssuesEnhanced, +// ListAssignedIssues, and ListAllIssues, matching what GetIssue already requested. +var requiredIssueFields = []string{"dueDate", "estimate", "delegate"} + +// TestSearchIssuesEnhanced_RequestsAndDecodesDueDateEstimateDelegate is a +// regression test for TL-563: SearchIssuesEnhanced's query silently omitted +// dueDate, estimate, and delegate, so "issues list"/"search" never returned +// them even though GetIssue's identical selection worked fine. +func TestSearchIssuesEnhanced_RequestsAndDecodesDueDateEstimateDelegate(t *testing.T) { + responseBody := testutil.NewGraphQLDataResponse(testutil.IssuesData{ + Issues: testutil.IssuesNodes{ + Nodes: []interface{}{ + // Populated: has a due date, estimate, and delegate. + map[string]interface{}{ + "id": "issue-1", + "identifier": "TL-1", + "title": "Issue with due date, estimate, and delegate", + "state": map[string]interface{}{"id": "state-1", "name": "Todo"}, + "priority": 2, + "estimate": 3.5, + "dueDate": "2026-09-01", + "delegate": map[string]interface{}{"id": "delegate-1", "name": "Bot", "email": "bot@example.com"}, + "createdAt": "2026-01-01T00:00:00Z", + "updatedAt": "2026-01-02T00:00:00Z", + "url": "https://linear.app/test/issue/TL-1", + }, + // Unpopulated: none of the three fields set. + map[string]interface{}{ + "id": "issue-2", + "identifier": "TL-2", + "title": "Issue without due date, estimate, or delegate", + "state": map[string]interface{}{"id": "state-1", "name": "Todo"}, + "labels": map[string]interface{}{"nodes": []interface{}{}}, + "createdAt": "2026-01-01T00:00:00Z", + "updatedAt": "2026-01-02T00:00:00Z", + "url": "https://linear.app/test/issue/TL-2", + }, + }, + PageInfo: &testutil.PageInfo{HasNextPage: false}, + }, + }) + + client, transport := newCapturingTestClient(responseBody) + + result, err := client.SearchIssuesEnhanced(&core.IssueSearchFilters{TeamID: "team-1"}) + if err != nil { + t.Fatalf("SearchIssuesEnhanced returned error: %v", err) + } + + query, err := transport.CapturedQuery() + if err != nil { + t.Fatalf("failed to extract captured query: %v", err) + } + assertQueryRequestsFields(t, query, requiredIssueFields) + + if len(result.Issues) != 2 { + t.Fatalf("expected 2 issues, got %d", len(result.Issues)) + } + + populated := result.Issues[0] + if populated.Estimate == nil || *populated.Estimate != 3.5 { + t.Errorf("Issues[0].Estimate = %v, want 3.5", populated.Estimate) + } + if populated.DueDate == nil || *populated.DueDate != "2026-09-01" { + t.Errorf("Issues[0].DueDate = %v, want \"2026-09-01\"", populated.DueDate) + } + if populated.Delegate == nil || populated.Delegate.ID != "delegate-1" { + t.Errorf("Issues[0].Delegate = %v, want ID \"delegate-1\"", populated.Delegate) + } + + unpopulated := result.Issues[1] + if unpopulated.Estimate != nil { + t.Errorf("Issues[1].Estimate = %v, want nil", unpopulated.Estimate) + } + if unpopulated.DueDate != nil { + t.Errorf("Issues[1].DueDate = %v, want nil", unpopulated.DueDate) + } + if unpopulated.Delegate != nil { + t.Errorf("Issues[1].Delegate = %v, want nil", unpopulated.Delegate) + } +} + +// TestListAssignedIssues_RequestsAndDecodesDueDateEstimateDelegate is a +// regression test for TL-563: ListAssignedIssues's query, like +// SearchIssuesEnhanced's, silently omitted dueDate, estimate, and delegate. +func TestListAssignedIssues_RequestsAndDecodesDueDateEstimateDelegate(t *testing.T) { + responseBody := testutil.NewGraphQLDataResponse(testutil.IssuesData{ + Issues: testutil.IssuesNodes{ + Nodes: []interface{}{ + map[string]interface{}{ + "id": "issue-1", + "identifier": "TL-1", + "title": "Issue with due date, estimate, and delegate", + "state": map[string]interface{}{"id": "state-1", "name": "Todo"}, + "estimate": 2.0, + "dueDate": "2026-09-01", + "delegate": map[string]interface{}{"id": "delegate-1", "name": "Bot", "email": "bot@example.com"}, + "createdAt": "2026-01-01T00:00:00Z", + "updatedAt": "2026-01-02T00:00:00Z", + "url": "https://linear.app/test/issue/TL-1", + }, + map[string]interface{}{ + "id": "issue-2", + "identifier": "TL-2", + "title": "Issue without due date, estimate, or delegate", + "state": map[string]interface{}{"id": "state-1", "name": "Todo"}, + "createdAt": "2026-01-01T00:00:00Z", + "updatedAt": "2026-01-02T00:00:00Z", + "url": "https://linear.app/test/issue/TL-2", + }, + }, + }, + }) + + client, transport := newCapturingTestClient(responseBody) + + issues, err := client.ListAssignedIssues(10) + if err != nil { + t.Fatalf("ListAssignedIssues returned error: %v", err) + } + + query, err := transport.CapturedQuery() + if err != nil { + t.Fatalf("failed to extract captured query: %v", err) + } + assertQueryRequestsFields(t, query, requiredIssueFields) + + if len(issues) != 2 { + t.Fatalf("expected 2 issues, got %d", len(issues)) + } + + populated := issues[0] + if populated.Estimate == nil || *populated.Estimate != 2.0 { + t.Errorf("issues[0].Estimate = %v, want 2.0", populated.Estimate) + } + if populated.DueDate == nil || *populated.DueDate != "2026-09-01" { + t.Errorf("issues[0].DueDate = %v, want \"2026-09-01\"", populated.DueDate) + } + if populated.Delegate == nil || populated.Delegate.ID != "delegate-1" { + t.Errorf("issues[0].Delegate = %v, want ID \"delegate-1\"", populated.Delegate) + } + + unpopulated := issues[1] + if unpopulated.Estimate != nil { + t.Errorf("issues[1].Estimate = %v, want nil", unpopulated.Estimate) + } + if unpopulated.DueDate != nil { + t.Errorf("issues[1].DueDate = %v, want nil", unpopulated.DueDate) + } + if unpopulated.Delegate != nil { + t.Errorf("issues[1].Delegate = %v, want nil", unpopulated.Delegate) + } +} + +// TestListAllIssues_RequestsAndDecodesDueDateEstimateDelegate is a regression +// test for TL-563: ListAllIssues's query, like SearchIssuesEnhanced's and +// ListAssignedIssues's, silently omitted dueDate, estimate, and delegate. +// Unlike those two, ListAllIssues decodes into a local anonymous struct and +// maps into core.IssueWithDetails, so this also exercises that mapping. +func TestListAllIssues_RequestsAndDecodesDueDateEstimateDelegate(t *testing.T) { + responseBody := testutil.NewGraphQLDataResponse(testutil.IssuesData{ + Issues: testutil.IssuesNodes{ + Nodes: []interface{}{ + map[string]interface{}{ + "id": "issue-1", + "identifier": "TL-1", + "title": "Issue with due date, estimate, and delegate", + "priority": 2, + "estimate": 1.5, + "dueDate": "2026-09-01", + "createdAt": "2026-01-01T00:00:00Z", + "updatedAt": "2026-01-02T00:00:00Z", + "state": map[string]interface{}{"id": "state-1", "name": "Todo", "type": "unstarted"}, + "delegate": map[string]interface{}{"id": "delegate-1", "name": "Bot", "email": "bot@example.com"}, + "team": map[string]interface{}{"id": "team-1", "name": "Team", "key": "TL"}, + }, + map[string]interface{}{ + "id": "issue-2", + "identifier": "TL-2", + "title": "Issue without due date, estimate, or delegate", + "priority": 1, + "createdAt": "2026-01-01T00:00:00Z", + "updatedAt": "2026-01-02T00:00:00Z", + "state": map[string]interface{}{"id": "state-1", "name": "Todo", "type": "unstarted"}, + "labels": map[string]interface{}{"nodes": []interface{}{}}, + "team": map[string]interface{}{"id": "team-1", "name": "Team", "key": "TL"}, + }, + }, + PageInfo: &testutil.PageInfo{HasNextPage: false}, + }, + }) + + client, transport := newCapturingTestClient(responseBody) + + result, err := client.ListAllIssues(&core.IssueFilter{First: 10}) + if err != nil { + t.Fatalf("ListAllIssues returned error: %v", err) + } + + query, err := transport.CapturedQuery() + if err != nil { + t.Fatalf("failed to extract captured query: %v", err) + } + assertQueryRequestsFields(t, query, requiredIssueFields) + + if len(result.Issues) != 2 { + t.Fatalf("expected 2 issues, got %d", len(result.Issues)) + } + + populated := result.Issues[0] + if populated.Estimate == nil || *populated.Estimate != 1.5 { + t.Errorf("Issues[0].Estimate = %v, want 1.5", populated.Estimate) + } + if populated.DueDate == nil || *populated.DueDate != "2026-09-01" { + t.Errorf("Issues[0].DueDate = %v, want \"2026-09-01\"", populated.DueDate) + } + if populated.Delegate == nil || populated.Delegate.ID != "delegate-1" { + t.Errorf("Issues[0].Delegate = %v, want ID \"delegate-1\"", populated.Delegate) + } + + unpopulated := result.Issues[1] + if unpopulated.Estimate != nil { + t.Errorf("Issues[1].Estimate = %v, want nil", unpopulated.Estimate) + } + if unpopulated.DueDate != nil { + t.Errorf("Issues[1].DueDate = %v, want nil", unpopulated.DueDate) + } + if unpopulated.Delegate != nil { + t.Errorf("Issues[1].Delegate = %v, want nil", unpopulated.Delegate) + } +} diff --git a/pkg/linear/testutil/mock_transport.go b/pkg/linear/testutil/mock_transport.go index f1af976..e162539 100644 --- a/pkg/linear/testutil/mock_transport.go +++ b/pkg/linear/testutil/mock_transport.go @@ -43,6 +43,63 @@ func NewSuccessTransport(body interface{}) *MockTransport { return NewMockTransport(http.StatusOK, body) } +// CapturingTransport implements http.RoundTripper for testing HTTP clients. +// It records the outgoing request body (so tests can assert on the GraphQL +// query text that was actually sent) while returning a canned response. +type CapturingTransport struct { + Response *http.Response + + // CapturedBody holds the raw bytes of the last request body seen by + // RoundTrip. Populated after the request under test has been made. + CapturedBody []byte +} + +// RoundTrip implements the http.RoundTripper interface. +func (c *CapturingTransport) RoundTrip(req *http.Request) (*http.Response, error) { + if req.Body != nil { + body, err := io.ReadAll(req.Body) + if err != nil { + return nil, err + } + req.Body.Close() + c.CapturedBody = body + } + return c.Response, nil +} + +// NewCapturingTransport creates a capturing transport with a JSON response body. +func NewCapturingTransport(statusCode int, body interface{}) *CapturingTransport { + var bodyBytes []byte + switch v := body.(type) { + case string: + bodyBytes = []byte(v) + case []byte: + bodyBytes = v + default: + bodyBytes, _ = json.Marshal(body) + } + + return &CapturingTransport{ + Response: &http.Response{ + StatusCode: statusCode, + Body: io.NopCloser(bytes.NewBuffer(bodyBytes)), + Header: make(http.Header), + }, + } +} + +// CapturedQuery extracts the "query" field from the captured GraphQL request +// body, so tests can assert on the selection set that was actually sent. +func (c *CapturingTransport) CapturedQuery() (string, error) { + var payload struct { + Query string `json:"query"` + } + if err := json.Unmarshal(c.CapturedBody, &payload); err != nil { + return "", err + } + return payload.Query, nil +} + // GraphQLResponse is a helper for building GraphQL response bodies. type GraphQLResponse struct { Data interface{} `json:"data,omitempty"`