diff --git a/CLAUDE.md b/CLAUDE.md index f4a9e23..ddce878 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -136,6 +136,15 @@ Flags added: `--created-after`, `--created-before`, `--updated-after`, `--update `--due-after`, `--due-before`, `--completed-after`, `--completed-before`, `--no-assignee`, `--no-project`, `--no-cycle`, `--priority-gte`, `--priority-lte`, `--my`, `--or`. +### GraphQL Field Sets + +Issue query field constants in `internal/query/issue.go` are split into two variants: +- `issueListFields` - compact set for listings and mutations that display a row (IssueListQuery, IssueSearchQuery, IssueBatchUpdateMutation, IssueCreateMutation, IssueUpdateMutation) +- `issueDetailFields` - full set for single-issue detail views (IssueGetQuery, IssueBranchQuery); defined as `issueListFields + "..."` + +When adding new issue fields: add to `issueDetailFields` only unless the field is needed in list output. +Queries/mutations that call `printIssueRow` use `issueListFields`; those that call `printIssueDetail` use `issueDetailFields`. + ### Partial Update Mutations Use `map[string]any` for mutation input variables when partial updates are needed. diff --git a/README.md b/README.md index 486a0d5..f6c82f4 100644 --- a/README.md +++ b/README.md @@ -153,7 +153,7 @@ linear issue list --updated-after 2w --or --no-project --no-cycle linear issue show [flags] ``` -Displays all fields for an issue: identifier, title, status, priority, team, assignee, due date, estimate, labels, URL, created/updated timestamps, description, parent issue (if set), and project (if set). +Displays all fields for an issue: identifier, number, title, status, priority, team, assignee, due date, estimate, labels, URL, created/updated timestamps, description, parent issue (if set), project (if set), cycle (if set), creator (if set), branch name, customer ticket count, and all lifecycle timestamps (started, completed, canceled, archived, triage started, snoozed until, added to cycle/project/team). SLA fields (type, breach time, high/medium risk thresholds, started at) are shown when present. Trashed status is shown when true. Flags: ``` diff --git a/docs/plans/completed/2026-03-10-pr1-review-fixes.md b/docs/plans/completed/2026-03-10-pr1-review-fixes.md new file mode 100644 index 0000000..323a975 --- /dev/null +++ b/docs/plans/completed/2026-03-10-pr1-review-fixes.md @@ -0,0 +1,164 @@ +# PR #1 Review Fixes: Issue Full Fields + +## Overview +Fix all confirmed review findings for PR #1 "Feature/issue full fields". +The PR adds ~30 new fields to the Issue model, GraphQL query, and text output. +Confirmed issues: over-fetching via shared `issueFields`, 9 undisplayed fields, +zero tests for new code, stale README, empty PR description. + +## Context +- PR branch: `feature/issue-full-fields` +- Schema source of truth: `docs/schema.graphql` +- Files involved: + - `internal/query/issue.go` -- GraphQL query constants + - `internal/model/issue.go` -- Issue struct and CycleRef + - `internal/cmd/issue_show.go` -- detail display logic + - `internal/query/issue_test.go` -- query field presence tests + - `internal/model/issue_test.go` -- deserialization tests + - `internal/cmd/issue_show_test.go` -- display output tests + - `README.md` -- user-facing docs + +## Development Approach +- **Testing approach**: TDD -- write/update tests FIRST, then fix code to pass them +- **Schema verification**: every field type in model/query MUST be verified against + `docs/schema.graphql` before writing code. Check nullability (`!` suffix), + scalar types (`String`, `Float`, `Int`, `Boolean`, `DateTime`), and nested types. +- Complete each task fully before moving to the next +- Make small, focused changes +- **CRITICAL: every task MUST include new/updated tests** written BEFORE code changes +- **CRITICAL: all tests must pass before starting next task** +- **CRITICAL: update this plan file when scope changes during implementation** +- Run `make build` after each change (includes linter) + +## Schema Verification Rules +Before writing any code that references a GraphQL field: +1. Open `docs/schema.graphql` and find the `type Issue implements Node` block (~line 11963) +2. Confirm field name exists exactly as written +3. Check nullability: `Type!` = non-null (use Go value type), `Type` = nullable (use Go pointer) +4. Map types: `String`/`DateTime` -> `string`/`*string`, `Float` -> `float64`/`*float64`, + `Int` -> `int`/`*int`, `Boolean` -> `bool`/`*bool` +5. For nested types (e.g. `cycle: Cycle`), check the referenced type's fields too + +## Testing Strategy +- **Unit tests**: required for every task, written BEFORE implementation +- Test files: `*_test.go` in same package +- Use table-driven tests, stdlib `testing` only +- Run with `go test -race ./...` + +## Progress Tracking +- Mark completed items with `[x]` immediately when done +- Add newly discovered tasks with + prefix +- Document issues/blockers with ! prefix + +## Implementation Steps + +### Task 1: Split `issueFields` into list and detail variants (tests first) + +TDD: write tests that assert field separation, then split the constant. + +- [x] verify against `docs/schema.graphql` which fields exist on `type Issue` +- [x] in `internal/query/issue_test.go`: add `TestIssueListFieldsCompact` -- assert `issueListFields` contains only: id, identifier, title, description, priority, priorityLabel, estimate, dueDate, url, createdAt, updatedAt, state, assignee, team, labels, parent, project +- [x] in `internal/query/issue_test.go`: add `TestIssueDetailFieldsContainsAll` -- assert `issueDetailFields` contains all fields from list PLUS: number, branchName, trashed, customerTicketCount, all timestamps, all SLA fields, creator, cycle +- [x] in `internal/query/issue_test.go`: update existing `TestIssueFieldsPresence` to reference new constant names +- [x] run tests -- expect failures (constants don't exist yet) +- [x] in `internal/query/issue.go`: split `issueFields` into `issueListFields` (compact) and `issueDetailFields` (full set with all PR fields) +- [x] update query constants: `IssueListQuery`, `IssueSearchQuery`, `IssueBatchUpdateMutation` use `issueListFields`; `IssueGetQuery`, `IssueCreateMutation`, `IssueUpdateMutation`, `IssueBranchQuery` use `issueDetailFields` +- [x] run tests -- must pass +- [x] run `make build` -- must pass + +### Task 2: Display all 9 undisplayed fields in `issue show` (tests first) + +TDD: write display tests for new fields, then add display code. + +Fields to add to display: `slaHighRiskAt`, `slaMediumRiskAt`, `startedTriageAt`, +`snoozedUntilAt`, `addedToCycleAt`, `addedToProjectAt`, `addedToTeamAt`, +`number`, `customerTicketCount`. + +- [x] verify field types against `docs/schema.graphql`: + - `number: Float!` -> display as integer + - `customerTicketCount: Int!` -> display as integer + - all 7 timestamps: `DateTime` (nullable) -> display if non-nil +- [x] in `internal/cmd/issue_show_test.go`: extend `makeDetailedIssue()` to include all 9 fields in mock response +- [x] in `internal/cmd/issue_show_test.go`: add test assertions for new fields in output (e.g. "SLA High Risk", "Triage Started", "Number", "Tickets") +- [x] run tests -- expect failures (display code missing) +- [x] in `internal/cmd/issue_show.go`: add `number` and `customerTicketCount` display (non-zero check) +- [x] in `internal/cmd/issue_show.go`: add 7 missing timestamps to the display loop +- [x] run tests -- must pass +- [x] run `make build` -- must pass + +### Task 3: Add deserialization tests for all new Issue fields + +TDD: write tests to verify JSON -> Go struct mapping for all new fields. + +- [x] verify ALL new field types against `docs/schema.graphql` before writing tests +- [x] in `internal/model/issue_test.go`: add `TestIssueDeserialization_NewFields` -- JSON with all new fields (CycleRef with/without name, Creator, BranchName, Trashed true, Number, CustomerTicketCount, all timestamps, all SLA fields) +- [x] in `internal/model/issue_test.go`: add `TestIssueNullableFields_NewFields` -- JSON without optional fields, verify nil pointers for: Creator, Cycle, Trashed, all timestamp pointers, SLA pointers +- [x] in `internal/model/issue_test.go`: add `TestCycleRefDeserialization` -- test CycleRef with name, without name (nil), verify Number as float64 +- [x] run tests -- must pass (these test existing PR code) +- [x] run `make build` -- must pass + +### Task 4: Add query field presence tests for new fields + +- [x] in `internal/query/issue_test.go`: add `TestIssueDetailFieldsContainsCycle` -- assert `issueDetailFields` contains `cycle { id name number }` +- [x] in `internal/query/issue_test.go`: add `TestIssueDetailFieldsContainsCreator` -- assert `issueDetailFields` contains `creator { id displayName email }` +- [x] in `internal/query/issue_test.go`: extend `TestIssueFieldsPresence` to include new fields: number, branchName, trashed, customerTicketCount, archivedAt, canceledAt, completedAt, startedAt, slaType, slaBreachesAt, slaHighRiskAt, slaMediumRiskAt, slaStartedAt, startedTriageAt, snoozedUntilAt, addedToCycleAt, addedToProjectAt, addedToTeamAt +- [x] run tests -- must pass +- [x] run `make build` -- must pass + +### Task 5: Add display tests for existing new fields (cycle, creator, branch, trashed) + +TDD: verify the existing PR display code is tested. + +- [x] in `internal/cmd/issue_show_test.go`: extend `makeDetailedIssue()` with cycle (id, name, number), creator (id, displayName, email), branchName, trashed=true +- [x] add assertions for: cycle format "#N Name", creator name, branch name, "Trashed: yes" +- [x] add test case for issue WITHOUT cycle/creator/branch/trashed -- verify they don't appear in output +- [x] run tests -- must pass +- [x] run `make build` -- must pass + +### Task 6: Verify acceptance criteria +- [x] verify all 9 previously undisplayed fields now appear in `issue show` +- [x] verify `issueListFields` is compact (no detail-only fields) +- [x] verify `issueDetailFields` contains all fields +- [x] verify all field types match `docs/schema.graphql` (final check) +- [x] run full test suite: `go test -race ./...` +- [x] run `make build` (includes linter) +- [x] verify test coverage for changed files + +### Task 7: [Final] Update documentation +- [x] update `README.md:156` -- add all new displayed fields to the `issue show` description +- [x] run `make build` -- must pass + +## Technical Details + +### Field split strategy +- `issueListFields`: id, identifier, title, description, priority, priorityLabel, estimate, dueDate, url, createdAt, updatedAt, state { id name color type }, assignee { id displayName email }, team { id name key }, labels { nodes { id name color } }, parent { id identifier title }, project { id name } +- `issueDetailFields`: all of the above PLUS: number, branchName, trashed, customerTicketCount, archivedAt, autoArchivedAt, autoClosedAt, canceledAt, completedAt, startedAt, startedTriageAt, triagedAt, snoozedUntilAt, addedToCycleAt, addedToProjectAt, addedToTeamAt, slaBreachesAt, slaHighRiskAt, slaMediumRiskAt, slaStartedAt, slaType, creator { id displayName email }, cycle { id name number } + +### Schema type mapping (verified against docs/schema.graphql) +| GraphQL field | Schema type | Go type | Nullable | +|---|---|---|---| +| number | Float! | float64 | no | +| branchName | String! | string | no | +| customerTicketCount | Int! | int | no | +| trashed | Boolean | *bool | yes | +| creator | User | *User | yes | +| cycle | Cycle | *CycleRef | yes | +| slaType | String | *string | yes | +| all timestamps | DateTime | *string | yes | + +### Query usage after split +| Query | Fields constant | +|---|---| +| IssueListQuery | issueListFields | +| IssueSearchQuery | issueListFields | +| IssueBatchUpdateMutation | issueListFields | +| IssueGetQuery | issueDetailFields | +| IssueCreateMutation | issueDetailFields | +| IssueUpdateMutation | issueDetailFields | +| IssueBranchQuery | issueDetailFields | + +## Post-Completion + +**PR updates:** +- Fill in PR #1 description with summary of changes +- Request re-review after fixes are pushed diff --git a/internal/cmd/issue_show.go b/internal/cmd/issue_show.go index 5e12daf..7964796 100644 --- a/internal/cmd/issue_show.go +++ b/internal/cmd/issue_show.go @@ -60,7 +60,7 @@ func printIssueDetail(cmd *cobra.Command, issue *model.Issue) error { w := cmd.OutOrStdout() writeLine := func(label, value string) error { - _, err := fmt.Fprintf(w, "%-14s %s\n", label+":", value) + _, err := fmt.Fprintf(w, "%-15s %s\n", label+":", value) return err } @@ -129,6 +129,80 @@ func printIssueDetail(cmd *cobra.Command, issue *model.Issue) error { } } + if issue.Cycle != nil { + name := fmt.Sprintf("#%.0f", issue.Cycle.Number) + if issue.Cycle.Name != nil && *issue.Cycle.Name != "" { + name += " " + *issue.Cycle.Name + } + if err := writeLine("Cycle", name); err != nil { + return err + } + } + + if issue.Creator != nil { + if err := writeLine("Creator", issue.Creator.DisplayName); err != nil { + return err + } + } + + if issue.BranchName != "" { + if err := writeLine("Branch", issue.BranchName); err != nil { + return err + } + } + + if issue.Number != 0 { + if err := writeLine("Number", fmt.Sprintf("%.0f", issue.Number)); err != nil { + return err + } + } + + if issue.CustomerTicketCount != 0 { + if err := writeLine("Tickets", fmt.Sprintf("%d", issue.CustomerTicketCount)); err != nil { + return err + } + } + + if issue.Trashed != nil && *issue.Trashed { + if err := writeLine("Trashed", "yes"); err != nil { + return err + } + } + + for _, f := range []struct { + label string + value *string + }{ + {"Started", issue.StartedAt}, + {"Completed", issue.CompletedAt}, + {"Canceled", issue.CanceledAt}, + {"Triaged", issue.TriagedAt}, + {"Triage Start", issue.StartedTriageAt}, + {"Snoozed Until", issue.SnoozedUntilAt}, + {"Archived", issue.ArchivedAt}, + {"AutoArchived", issue.AutoArchivedAt}, + {"AutoClosed", issue.AutoClosedAt}, + {"Added to Cycle", issue.AddedToCycleAt}, + {"Added to Proj", issue.AddedToProjectAt}, + {"Added to Team", issue.AddedToTeamAt}, + {"SLA Breach", issue.SlaBreachesAt}, + {"SLA Started", issue.SlaStartedAt}, + {"SLA High Risk", issue.SlaHighRiskAt}, + {"SLA Med Risk", issue.SlaMediumRiskAt}, + } { + if f.value != nil { + if err := writeLine(f.label, *f.value); err != nil { + return err + } + } + } + + if issue.SlaType != nil { + if err := writeLine("SLA Type", *issue.SlaType); err != nil { + return err + } + } + if issue.Description != nil && *issue.Description != "" { _, err := fmt.Fprintf(w, "\n%s\n", *issue.Description) return err diff --git a/internal/cmd/issue_show_test.go b/internal/cmd/issue_show_test.go index 4b7760d..665e000 100644 --- a/internal/cmd/issue_show_test.go +++ b/internal/cmd/issue_show_test.go @@ -23,17 +23,48 @@ func makeDetailedIssue() map[string]any { estimate := 3.0 dueDate := "2026-04-01" return map[string]any{ - "id": "id-ENG-42", - "identifier": "ENG-42", - "title": "Implement feature X", - "description": desc, - "priority": 2.0, - "priorityLabel": "Medium", - "estimate": estimate, - "dueDate": dueDate, - "url": "https://linear.app/issue/ENG-42", - "createdAt": "2026-01-10T00:00:00Z", - "updatedAt": "2026-02-15T00:00:00Z", + "id": "id-ENG-42", + "identifier": "ENG-42", + "number": 42.0, + "title": "Implement feature X", + "description": desc, + "priority": 2.0, + "priorityLabel": "Medium", + "estimate": estimate, + "dueDate": dueDate, + "url": "https://linear.app/issue/ENG-42", + "createdAt": "2026-01-10T00:00:00Z", + "updatedAt": "2026-02-15T00:00:00Z", + "customerTicketCount": 5.0, + "slaHighRiskAt": "2026-03-01T00:00:00Z", + "slaMediumRiskAt": "2026-03-05T00:00:00Z", + "startedTriageAt": "2026-01-11T00:00:00Z", + "snoozedUntilAt": "2026-02-01T00:00:00Z", + "addedToCycleAt": "2026-01-12T00:00:00Z", + "addedToProjectAt": "2026-01-13T00:00:00Z", + "addedToTeamAt": "2026-01-09T00:00:00Z", + "branchName": "feature/eng-42-implement-feature-x", + "trashed": true, + "slaType": "standard", + "slaBreachesAt": "2026-03-15T00:00:00Z", + "slaStartedAt": "2026-01-10T00:00:00Z", + "triagedAt": "2026-01-12T00:00:00Z", + "startedAt": "2026-01-11T00:00:00Z", + "completedAt": "2026-02-23T00:00:00Z", + "canceledAt": "2026-02-24T00:00:00Z", + "archivedAt": "2026-02-20T00:00:00Z", + "autoArchivedAt": "2026-02-21T00:00:00Z", + "autoClosedAt": "2026-02-22T00:00:00Z", + "creator": map[string]any{ + "id": "user-2", + "displayName": "Bob", + "email": "bob@example.com", + }, + "cycle": map[string]any{ + "id": "cycle-1", + "name": "Sprint 5", + "number": 5.0, + }, "state": map[string]any{ "id": "state-2", "name": "In Progress", @@ -57,6 +88,117 @@ func makeDetailedIssue() map[string]any { } } +func TestIssueShowCommand_NewFields(t *testing.T) { + issue := makeDetailedIssue() + server := newIssueTestServer(t, func(w http.ResponseWriter, _ *http.Request) { + writeJSONResponse(w, issueGetResponse(issue)) + }) + setupIssueTest(t, server) + + var out bytes.Buffer + root := cmd.NewRootCommand("test") + root.SetOut(&out) + root.SetErr(&out) + root.SetArgs([]string{"issue", "show", "ENG-42"}) + + if err := root.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + result := out.String() + checks := []struct { + label string + want string + }{ + {"number", "Number"}, + {"customerTicketCount", "Tickets"}, + {"slaHighRiskAt", "SLA High Risk"}, + {"slaMediumRiskAt", "SLA Med Risk"}, + {"startedTriageAt", "Triage Start"}, + {"snoozedUntilAt", "Snoozed Until"}, + {"addedToCycleAt", "Added to Cycle"}, + {"addedToProjectAt", "Added to Proj"}, + {"addedToTeamAt", "Added to Team"}, + {"cycle", "#5 Sprint 5"}, + {"creator", "Bob"}, + {"branchName", "feature/eng-42-implement-feature-x"}, + {"trashed", "yes"}, + {"slaType", "SLA Type"}, + {"slaBreachesAt", "SLA Breach"}, + {"slaStartedAt", "SLA Started"}, + {"triagedAt", "Triaged"}, + {"startedAt", "2026-01-11T00:00:00Z"}, + {"completedAt", "Completed"}, + {"canceledAt", "Canceled"}, + {"archivedAt", "2026-02-20T00:00:00Z"}, + {"autoArchivedAt", "AutoArchived"}, + {"autoClosedAt", "AutoClosed"}, + } + for _, c := range checks { + if !strings.Contains(result, c.want) { + t.Errorf("output should contain %s (%q), got:\n%s", c.label, c.want, result) + } + } +} + +func TestIssueShowCommand_NoCycleCreatorBranchTrashed(t *testing.T) { + // issue without cycle, creator, branchName, trashed + issue := map[string]any{ + "id": "id-ENG-2", + "identifier": "ENG-2", + "title": "Minimal issue", + "priority": 0.0, + "priorityLabel": "No priority", + "url": "https://linear.app/issue/ENG-2", + "createdAt": "2026-01-01T00:00:00Z", + "updatedAt": "2026-01-01T00:00:00Z", + "state": map[string]any{ + "id": "state-1", + "name": "Todo", + "color": "#000000", + "type": "unstarted", + }, + "team": map[string]any{ + "id": "team-1", + "name": "Engineering", + }, + "labels": map[string]any{"nodes": []any{}}, + } + + server := newIssueTestServer(t, func(w http.ResponseWriter, _ *http.Request) { + writeJSONResponse(w, issueGetResponse(issue)) + }) + setupIssueTest(t, server) + + var out bytes.Buffer + root := cmd.NewRootCommand("test") + root.SetOut(&out) + root.SetErr(&out) + root.SetArgs([]string{"issue", "show", "ENG-2"}) + + if err := root.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + result := out.String() + absent := []struct { + label string + want string + }{ + {"cycle", "Cycle:"}, + {"creator", "Creator:"}, + {"branch", "Branch:"}, + {"trashed", "Trashed:"}, + {"number", "Number:"}, + {"tickets", "Tickets:"}, + } + for _, c := range absent { + if strings.Contains(result, c.want) { + t.Errorf("output should NOT contain %s (%q), got:\n%s", c.label, c.want, result) + } + } +} + func TestIssueShowCommand_TableOutput(t *testing.T) { issue := makeDetailedIssue() server := newIssueTestServer(t, func(w http.ResponseWriter, _ *http.Request) { diff --git a/internal/model/issue.go b/internal/model/issue.go index 5a62c0c..fd08166 100644 --- a/internal/model/issue.go +++ b/internal/model/issue.go @@ -18,23 +18,53 @@ type ProjectRef struct { Name string `json:"name"` } +// CycleRef is a lightweight reference to a cycle. +type CycleRef struct { + ID string `json:"id"` + Name *string `json:"name,omitempty"` + Number float64 `json:"number"` +} + // Issue represents a Linear issue. type Issue struct { - ID string `json:"id"` - Identifier string `json:"identifier"` - Title string `json:"title"` - Description *string `json:"description,omitempty"` - Priority float64 `json:"priority"` - PriorityLabel string `json:"priorityLabel"` - Estimate *float64 `json:"estimate,omitempty"` - DueDate *string `json:"dueDate,omitempty"` - URL string `json:"url"` - CreatedAt string `json:"createdAt"` - UpdatedAt string `json:"updatedAt"` - State WorkflowState `json:"state"` - Assignee *User `json:"assignee,omitempty"` - Team Team `json:"team"` - Labels IssueLabelConnection `json:"labels"` - Parent *IssueRef `json:"parent,omitempty"` - Project *ProjectRef `json:"project,omitempty"` + ID string `json:"id"` + Identifier string `json:"identifier"` + Number float64 `json:"number"` + Title string `json:"title"` + Description *string `json:"description,omitempty"` + BranchName string `json:"branchName"` + Priority float64 `json:"priority"` + PriorityLabel string `json:"priorityLabel"` + Estimate *float64 `json:"estimate,omitempty"` + DueDate *string `json:"dueDate,omitempty"` + URL string `json:"url"` + Trashed *bool `json:"trashed,omitempty"` + CustomerTicketCount int `json:"customerTicketCount"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` + ArchivedAt *string `json:"archivedAt,omitempty"` + AutoArchivedAt *string `json:"autoArchivedAt,omitempty"` + AutoClosedAt *string `json:"autoClosedAt,omitempty"` + CanceledAt *string `json:"canceledAt,omitempty"` + CompletedAt *string `json:"completedAt,omitempty"` + StartedAt *string `json:"startedAt,omitempty"` + StartedTriageAt *string `json:"startedTriageAt,omitempty"` + TriagedAt *string `json:"triagedAt,omitempty"` + SnoozedUntilAt *string `json:"snoozedUntilAt,omitempty"` + AddedToCycleAt *string `json:"addedToCycleAt,omitempty"` + AddedToProjectAt *string `json:"addedToProjectAt,omitempty"` + AddedToTeamAt *string `json:"addedToTeamAt,omitempty"` + SlaBreachesAt *string `json:"slaBreachesAt,omitempty"` + SlaHighRiskAt *string `json:"slaHighRiskAt,omitempty"` + SlaMediumRiskAt *string `json:"slaMediumRiskAt,omitempty"` + SlaStartedAt *string `json:"slaStartedAt,omitempty"` + SlaType *string `json:"slaType,omitempty"` + State WorkflowState `json:"state"` + Assignee *User `json:"assignee,omitempty"` + Creator *User `json:"creator,omitempty"` + Team Team `json:"team"` + Labels IssueLabelConnection `json:"labels"` + Parent *IssueRef `json:"parent,omitempty"` + Project *ProjectRef `json:"project,omitempty"` + Cycle *CycleRef `json:"cycle,omitempty"` } diff --git a/internal/model/issue_test.go b/internal/model/issue_test.go index e8ba97a..0051425 100644 --- a/internal/model/issue_test.go +++ b/internal/model/issue_test.go @@ -200,6 +200,257 @@ func TestIssueNullableFields(t *testing.T) { } } +func TestIssueDeserialization_NewFields(t *testing.T) { + t.Parallel() + + trashed := true + raw := `{ + "id": "new-1", + "identifier": "ENG-100", + "number": 100, + "title": "New fields issue", + "priority": 1, + "priorityLabel": "Urgent", + "branchName": "eng-100-new-fields-issue", + "url": "https://linear.app/issue/ENG-100", + "trashed": true, + "customerTicketCount": 5, + "createdAt": "2026-01-01T00:00:00.000Z", + "updatedAt": "2026-01-02T00:00:00.000Z", + "archivedAt": "2026-02-01T00:00:00.000Z", + "autoArchivedAt": "2026-02-02T00:00:00.000Z", + "autoClosedAt": "2026-02-03T00:00:00.000Z", + "canceledAt": "2026-02-04T00:00:00.000Z", + "completedAt": "2026-02-05T00:00:00.000Z", + "startedAt": "2026-02-06T00:00:00.000Z", + "startedTriageAt": "2026-02-07T00:00:00.000Z", + "triagedAt": "2026-02-08T00:00:00.000Z", + "snoozedUntilAt": "2026-02-09T00:00:00.000Z", + "addedToCycleAt": "2026-02-10T00:00:00.000Z", + "addedToProjectAt": "2026-02-11T00:00:00.000Z", + "addedToTeamAt": "2026-02-12T00:00:00.000Z", + "slaBreachesAt": "2026-03-01T00:00:00.000Z", + "slaHighRiskAt": "2026-03-02T00:00:00.000Z", + "slaMediumRiskAt": "2026-03-03T00:00:00.000Z", + "slaStartedAt": "2026-03-04T00:00:00.000Z", + "slaType": "standard", + "state": {"id": "s1", "name": "In Progress", "color": "#ff0", "type": "started"}, + "team": {"id": "t1", "name": "Engineering", "key": "ENG"}, + "labels": {"nodes": []}, + "creator": {"id": "u2", "displayName": "Bob", "email": "bob@example.com"}, + "cycle": {"id": "c1", "name": "Sprint 1", "number": 1} + }` + + var issue Issue + if err := json.Unmarshal([]byte(raw), &issue); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + if issue.Number != 100 { + t.Errorf("Number: got %v, want 100", issue.Number) + } + if issue.BranchName != "eng-100-new-fields-issue" { + t.Errorf("BranchName: got %q", issue.BranchName) + } + if issue.Trashed == nil || *issue.Trashed != trashed { + t.Errorf("Trashed: got %v, want true", issue.Trashed) + } + if issue.CustomerTicketCount != 5 { + t.Errorf("CustomerTicketCount: got %d, want 5", issue.CustomerTicketCount) + } + + // timestamps + checkStr := func(field string, got *string, want string) { + t.Helper() + if got == nil { + t.Errorf("%s: got nil, want %q", field, want) + } else if *got != want { + t.Errorf("%s: got %q, want %q", field, *got, want) + } + } + checkStr("ArchivedAt", issue.ArchivedAt, "2026-02-01T00:00:00.000Z") + checkStr("AutoArchivedAt", issue.AutoArchivedAt, "2026-02-02T00:00:00.000Z") + checkStr("AutoClosedAt", issue.AutoClosedAt, "2026-02-03T00:00:00.000Z") + checkStr("CanceledAt", issue.CanceledAt, "2026-02-04T00:00:00.000Z") + checkStr("CompletedAt", issue.CompletedAt, "2026-02-05T00:00:00.000Z") + checkStr("StartedAt", issue.StartedAt, "2026-02-06T00:00:00.000Z") + checkStr("StartedTriageAt", issue.StartedTriageAt, "2026-02-07T00:00:00.000Z") + checkStr("TriagedAt", issue.TriagedAt, "2026-02-08T00:00:00.000Z") + checkStr("SnoozedUntilAt", issue.SnoozedUntilAt, "2026-02-09T00:00:00.000Z") + checkStr("AddedToCycleAt", issue.AddedToCycleAt, "2026-02-10T00:00:00.000Z") + checkStr("AddedToProjectAt", issue.AddedToProjectAt, "2026-02-11T00:00:00.000Z") + checkStr("AddedToTeamAt", issue.AddedToTeamAt, "2026-02-12T00:00:00.000Z") + checkStr("SlaBreachesAt", issue.SlaBreachesAt, "2026-03-01T00:00:00.000Z") + checkStr("SlaHighRiskAt", issue.SlaHighRiskAt, "2026-03-02T00:00:00.000Z") + checkStr("SlaMediumRiskAt", issue.SlaMediumRiskAt, "2026-03-03T00:00:00.000Z") + checkStr("SlaStartedAt", issue.SlaStartedAt, "2026-03-04T00:00:00.000Z") + checkStr("SlaType", issue.SlaType, "standard") + + // creator + if issue.Creator == nil { + t.Fatal("Creator should not be nil") + } + if issue.Creator.ID != "u2" { + t.Errorf("Creator.ID: got %q", issue.Creator.ID) + } + if issue.Creator.DisplayName != "Bob" { + t.Errorf("Creator.DisplayName: got %q", issue.Creator.DisplayName) + } + if issue.Creator.Email != "bob@example.com" { + t.Errorf("Creator.Email: got %q", issue.Creator.Email) + } + + // cycle + if issue.Cycle == nil { + t.Fatal("Cycle should not be nil") + } + if issue.Cycle.ID != "c1" { + t.Errorf("Cycle.ID: got %q", issue.Cycle.ID) + } + if issue.Cycle.Name == nil || *issue.Cycle.Name != "Sprint 1" { + t.Errorf("Cycle.Name: unexpected value") + } + if issue.Cycle.Number != 1 { + t.Errorf("Cycle.Number: got %v, want 1", issue.Cycle.Number) + } +} + +func TestIssueNullableFields_NewFields(t *testing.T) { + t.Parallel() + + raw := `{ + "id": "null-1", + "identifier": "ENG-200", + "number": 0, + "title": "Minimal issue", + "priority": 0, + "priorityLabel": "No priority", + "branchName": "", + "url": "https://linear.app/issue/ENG-200", + "customerTicketCount": 0, + "createdAt": "2026-01-01T00:00:00.000Z", + "updatedAt": "2026-01-01T00:00:00.000Z", + "state": {"id": "s1", "name": "Backlog", "color": "#ccc", "type": "backlog"}, + "team": {"id": "t1", "name": "Engineering", "key": "ENG"}, + "labels": {"nodes": []} + }` + + var issue Issue + if err := json.Unmarshal([]byte(raw), &issue); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + if issue.Trashed != nil { + t.Errorf("Trashed should be nil, got %v", issue.Trashed) + } + if issue.Creator != nil { + t.Errorf("Creator should be nil, got %+v", issue.Creator) + } + if issue.Cycle != nil { + t.Errorf("Cycle should be nil, got %+v", issue.Cycle) + } + if issue.ArchivedAt != nil { + t.Errorf("ArchivedAt should be nil") + } + if issue.AutoArchivedAt != nil { + t.Errorf("AutoArchivedAt should be nil") + } + if issue.AutoClosedAt != nil { + t.Errorf("AutoClosedAt should be nil") + } + if issue.CanceledAt != nil { + t.Errorf("CanceledAt should be nil") + } + if issue.CompletedAt != nil { + t.Errorf("CompletedAt should be nil") + } + if issue.StartedAt != nil { + t.Errorf("StartedAt should be nil") + } + if issue.StartedTriageAt != nil { + t.Errorf("StartedTriageAt should be nil") + } + if issue.TriagedAt != nil { + t.Errorf("TriagedAt should be nil") + } + if issue.SnoozedUntilAt != nil { + t.Errorf("SnoozedUntilAt should be nil") + } + if issue.AddedToCycleAt != nil { + t.Errorf("AddedToCycleAt should be nil") + } + if issue.AddedToProjectAt != nil { + t.Errorf("AddedToProjectAt should be nil") + } + if issue.AddedToTeamAt != nil { + t.Errorf("AddedToTeamAt should be nil") + } + if issue.SlaBreachesAt != nil { + t.Errorf("SlaBreachesAt should be nil") + } + if issue.SlaHighRiskAt != nil { + t.Errorf("SlaHighRiskAt should be nil") + } + if issue.SlaMediumRiskAt != nil { + t.Errorf("SlaMediumRiskAt should be nil") + } + if issue.SlaStartedAt != nil { + t.Errorf("SlaStartedAt should be nil") + } + if issue.SlaType != nil { + t.Errorf("SlaType should be nil") + } +} + +func TestCycleRefDeserialization(t *testing.T) { + t.Parallel() + + t.Run("with name", func(t *testing.T) { + t.Parallel() + raw := `{"id": "c1", "name": "Sprint 42", "number": 42}` + var c CycleRef + if err := json.Unmarshal([]byte(raw), &c); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if c.ID != "c1" { + t.Errorf("ID: got %q", c.ID) + } + if c.Name == nil || *c.Name != "Sprint 42" { + t.Errorf("Name: unexpected value %v", c.Name) + } + if c.Number != 42 { + t.Errorf("Number: got %v, want 42", c.Number) + } + }) + + t.Run("without name", func(t *testing.T) { + t.Parallel() + raw := `{"id": "c2", "number": 7}` + var c CycleRef + if err := json.Unmarshal([]byte(raw), &c); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if c.Name != nil { + t.Errorf("Name should be nil, got %v", c.Name) + } + if c.Number != 7 { + t.Errorf("Number: got %v, want 7", c.Number) + } + }) + + t.Run("number as float64", func(t *testing.T) { + t.Parallel() + raw := `{"id": "c3", "number": 3.0}` + var c CycleRef + if err := json.Unmarshal([]byte(raw), &c); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if c.Number != 3.0 { + t.Errorf("Number: got %v, want 3.0", c.Number) + } + }) +} + func TestIssueNestedStructs(t *testing.T) { t.Parallel() diff --git a/internal/query/issue.go b/internal/query/issue.go index 4fc6498..d84c15f 100644 --- a/internal/query/issue.go +++ b/internal/query/issue.go @@ -1,7 +1,7 @@ package query -// issueFields is the common field selection for Issue. -const issueFields = ` +// issueListFields is the compact field selection used for issue listings. +const issueListFields = ` id identifier title @@ -21,11 +21,38 @@ const issueFields = ` project { id name } ` +// issueDetailFields is the full field selection used for single-issue detail views. +const issueDetailFields = issueListFields + ` + number + branchName + trashed + customerTicketCount + archivedAt + autoArchivedAt + autoClosedAt + canceledAt + completedAt + startedAt + startedTriageAt + triagedAt + snoozedUntilAt + addedToCycleAt + addedToProjectAt + addedToTeamAt + slaBreachesAt + slaHighRiskAt + slaMediumRiskAt + slaStartedAt + slaType + creator { id displayName email } + cycle { id name number } +` + // IssueListQuery fetches issues with optional pagination and filter. const IssueListQuery = ` query IssueList($first: Int, $after: String, $filter: IssueFilter, $includeArchived: Boolean, $orderBy: PaginationOrderBy) { issues(first: $first, after: $after, filter: $filter, includeArchived: $includeArchived, orderBy: $orderBy) { - nodes {` + issueFields + `} + nodes {` + issueListFields + `} pageInfo { hasNextPage endCursor } } } @@ -34,7 +61,7 @@ query IssueList($first: Int, $after: String, $filter: IssueFilter, $includeArchi // IssueGetQuery fetches a single issue by ID. const IssueGetQuery = ` query IssueGet($id: String!) { - issue(id: $id) {` + issueFields + `} + issue(id: $id) {` + issueDetailFields + `} } ` @@ -43,7 +70,7 @@ const IssueCreateMutation = ` mutation IssueCreate($input: IssueCreateInput!) { issueCreate(input: $input) { success - issue {` + issueFields + `} + issue {` + issueListFields + `} } } ` @@ -53,7 +80,7 @@ const IssueUpdateMutation = ` mutation IssueUpdate($id: String!, $input: IssueUpdateInput!) { issueUpdate(id: $id, input: $input) { success - issue {` + issueFields + `} + issue {` + issueListFields + `} } } ` @@ -80,7 +107,7 @@ mutation IssueArchive($id: String!) { const IssueBatchUpdateMutation = ` mutation IssueBatchUpdate($ids: [UUID!]!, $input: IssueUpdateInput!) { issueBatchUpdate(ids: $ids, input: $input) { - issues {` + issueFields + `} + issues {` + issueListFields + `} } } ` @@ -89,7 +116,7 @@ mutation IssueBatchUpdate($ids: [UUID!]!, $input: IssueUpdateInput!) { const IssueSearchQuery = ` query SearchIssues($term: String!, $first: Int, $teamId: String) { searchIssues(term: $term, first: $first, teamId: $teamId) { - nodes {` + issueFields + `} + nodes {` + issueListFields + `} } } ` @@ -97,6 +124,6 @@ query SearchIssues($term: String!, $first: Int, $teamId: String) { // IssueBranchQuery looks up an issue by VCS branch name. const IssueBranchQuery = ` query IssueBranch($branchName: String!) { - issueVcsBranchSearch(branchName: $branchName) {` + issueFields + `} + issueVcsBranchSearch(branchName: $branchName) {` + issueDetailFields + `} } ` diff --git a/internal/query/issue_test.go b/internal/query/issue_test.go index fe253a5..b8e6f91 100644 --- a/internal/query/issue_test.go +++ b/internal/query/issue_test.go @@ -150,38 +150,162 @@ func TestIssueArchiveMutation(t *testing.T) { func TestIssueFieldsContainsParent(t *testing.T) { t.Parallel() want := "parent { id identifier title }" - if !strings.Contains(issueFields, want) { - t.Errorf("issueFields missing %q", want) + if !strings.Contains(issueListFields, want) { + t.Errorf("issueListFields missing %q", want) } } func TestIssueFieldsContainsProject(t *testing.T) { t.Parallel() want := "project { id name }" - if !strings.Contains(issueFields, want) { - t.Errorf("issueFields missing %q", want) + if !strings.Contains(issueListFields, want) { + t.Errorf("issueListFields missing %q", want) } } func TestIssueFieldsPresence(t *testing.T) { t.Parallel() // all queries must include the common issue fields - fields := []string{ + commonFields := []string{ "id", "identifier", "title", "description", "priority", "priorityLabel", "estimate", "dueDate", "url", "createdAt", "updatedAt", } - queries := map[string]string{ + allQueries := map[string]string{ "IssueListQuery": IssueListQuery, "IssueGetQuery": IssueGetQuery, "IssueCreateMutation": IssueCreateMutation, "IssueUpdateMutation": IssueUpdateMutation, + "IssueBranchQuery": IssueBranchQuery, } - for qName, q := range queries { - for _, f := range fields { - if !strings.Contains(q, f) { + for qName, q := range allQueries { + for _, f := range commonFields { + // use tab-bounded check to avoid false positives via substrings (e.g. "id" in "identifier") + if !strings.Contains(q, "\t"+f+"\n") { t.Errorf("%s missing field %q", qName, f) } } } + // detail queries must also include all new detail-only fields + detailFields := []string{ + "number", "branchName", "trashed", "customerTicketCount", + "archivedAt", "canceledAt", "completedAt", "startedAt", + "slaType", "slaBreachesAt", "slaHighRiskAt", "slaMediumRiskAt", + "slaStartedAt", "startedTriageAt", "snoozedUntilAt", + "addedToCycleAt", "addedToProjectAt", "addedToTeamAt", + } + detailQueries := map[string]string{ + "IssueGetQuery": IssueGetQuery, + "IssueBranchQuery": IssueBranchQuery, + } + for qName, q := range detailQueries { + for _, f := range detailFields { + // use tab-bounded check to avoid false positives (e.g. "number" matching cycle's number subfield) + if !strings.Contains(q, "\t"+f+"\n") { + t.Errorf("%s missing detail field %q", qName, f) + } + } + } +} + +func TestIssueDetailFieldsContainsCycle(t *testing.T) { + t.Parallel() + want := "cycle { id name number }" + if !strings.Contains(issueDetailFields, want) { + t.Errorf("issueDetailFields missing %q", want) + } +} + +func TestIssueDetailFieldsContainsCreator(t *testing.T) { + t.Parallel() + want := "creator { id displayName email }" + if !strings.Contains(issueDetailFields, want) { + t.Errorf("issueDetailFields missing %q", want) + } +} + +func TestIssueListFieldsCompact(t *testing.T) { + t.Parallel() + // list fields must contain core fields + wantPresent := []string{ + "id", "identifier", "title", "description", + "priority", "priorityLabel", "estimate", "dueDate", + "url", "createdAt", "updatedAt", + "state { id name color type }", + "assignee { id displayName email }", + "team { id name key }", + "labels { nodes { id name color } }", + "parent { id identifier title }", + "project { id name }", + } + for _, f := range wantPresent { + // use tab-bounded check for bare tokens to avoid substring false positives + needle := f + if !strings.Contains(f, " ") { + needle = "\t" + f + "\n" + } + if !strings.Contains(issueListFields, needle) { + t.Errorf("issueListFields missing %q", f) + } + } + // detail-only fields must NOT appear in list fields + wantAbsent := []string{ + "number", "branchName", "trashed", "customerTicketCount", + "archivedAt", "autoArchivedAt", "autoClosedAt", "canceledAt", + "completedAt", "startedAt", "startedTriageAt", "triagedAt", + "snoozedUntilAt", "addedToCycleAt", "addedToProjectAt", "addedToTeamAt", + "slaBreachesAt", "slaHighRiskAt", "slaMediumRiskAt", "slaStartedAt", "slaType", + "creator {", "cycle {", + } + for _, f := range wantAbsent { + if strings.Contains(issueListFields, f) { + t.Errorf("issueListFields should not contain %q (detail-only field)", f) + } + } +} + +func TestIssueDetailFieldsContainsAll(t *testing.T) { + t.Parallel() + // detail fields must contain all list fields + listFields := []string{ + "id", "identifier", "title", "description", + "priority", "priorityLabel", "estimate", "dueDate", + "url", "createdAt", "updatedAt", + "state { id name color type }", + "assignee { id displayName email }", + "team { id name key }", + "labels { nodes { id name color } }", + "parent { id identifier title }", + "project { id name }", + } + for _, f := range listFields { + // use tab-bounded check for bare tokens to avoid substring false positives + needle := f + if !strings.Contains(f, " ") { + needle = "\t" + f + "\n" + } + if !strings.Contains(issueDetailFields, needle) { + t.Errorf("issueDetailFields missing list field %q", f) + } + } + // detail fields must also contain detail-only fields + detailOnly := []string{ + "number", "branchName", "trashed", "customerTicketCount", + "archivedAt", "autoArchivedAt", "autoClosedAt", "canceledAt", + "completedAt", "startedAt", "startedTriageAt", "triagedAt", + "snoozedUntilAt", "addedToCycleAt", "addedToProjectAt", "addedToTeamAt", + "slaBreachesAt", "slaHighRiskAt", "slaMediumRiskAt", "slaStartedAt", "slaType", + "creator { id displayName email }", + "cycle { id name number }", + } + for _, f := range detailOnly { + // use tab-bounded check for bare tokens to avoid substring false positives + needle := f + if !strings.Contains(f, " ") { + needle = "\t" + f + "\n" + } + if !strings.Contains(issueDetailFields, needle) { + t.Errorf("issueDetailFields missing detail field %q", f) + } + } }