From 3de23be373c5998c7c003bc7b5c772b1d05df59c Mon Sep 17 00:00:00 2001 From: Pavel Varganov Date: Tue, 10 Mar 2026 12:57:58 +0400 Subject: [PATCH 01/14] feat: add all missing fields to issue query and model Add trashed, timestamps (canceledAt, completedAt, startedAt, etc.), SLA fields, branchName, customerTicketCount, creator, and cycle to issue GraphQL query and Issue struct. Co-Authored-By: Claude Sonnet 4.6 --- internal/model/issue.go | 64 ++++++++++++++++++++++++++++++----------- internal/query/issue.go | 23 +++++++++++++++ 2 files changed, 70 insertions(+), 17 deletions(-) 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/query/issue.go b/internal/query/issue.go index 4fc6498..a27e90b 100644 --- a/internal/query/issue.go +++ b/internal/query/issue.go @@ -4,21 +4,44 @@ package query const issueFields = ` id identifier + number title description + branchName priority priorityLabel estimate dueDate url + trashed + customerTicketCount createdAt updatedAt + archivedAt + autoArchivedAt + autoClosedAt + canceledAt + completedAt + startedAt + startedTriageAt + triagedAt + snoozedUntilAt + addedToCycleAt + addedToProjectAt + addedToTeamAt + slaBreachesAt + slaHighRiskAt + slaMediumRiskAt + slaStartedAt + slaType state { id name color type } assignee { id displayName email } + creator { id displayName email } team { id name key } labels { nodes { id name color } } parent { id identifier title } project { id name } + cycle { id name number } ` // IssueListQuery fetches issues with optional pagination and filter. From 02e753975850d41c2c5ed0a29b47ab526fbd1fc3 Mon Sep 17 00:00:00 2001 From: Pavel Varganov Date: Tue, 10 Mar 2026 13:07:23 +0400 Subject: [PATCH 02/14] feat: display new issue fields in text output Add Creator, Branch, Trashed, Cycle, timestamps (started, completed, canceled, triaged, archived, auto-archived, auto-closed) and SLA fields to printIssueDetail output. Co-Authored-By: Claude Sonnet 4.6 --- internal/cmd/issue_show.go | 52 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/internal/cmd/issue_show.go b/internal/cmd/issue_show.go index 5e12daf..5b063e2 100644 --- a/internal/cmd/issue_show.go +++ b/internal/cmd/issue_show.go @@ -129,6 +129,58 @@ 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.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}, + {"Archived", issue.ArchivedAt}, + {"AutoArchived", issue.AutoArchivedAt}, + {"AutoClosed", issue.AutoClosedAt}, + {"SLA Breach", issue.SlaBreachesAt}, + {"SLA Started", issue.SlaStartedAt}, + } { + 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 From d55eab92a2903f0fe3a2d0c63632bc3843d0b47c Mon Sep 17 00:00:00 2001 From: Aleksei Iatsiuk Date: Tue, 10 Mar 2026 14:26:17 +0200 Subject: [PATCH 03/14] feat: split issueFields into list and detail variants Replaces single issueFields constant with issueListFields (compact, for listings) and issueDetailFields (full, for single-issue views). List queries no longer over-fetch detail-only fields. Co-Authored-By: Claude Sonnet 4.6 --- docs/plans/2026-03-10-pr1-review-fixes.md | 164 ++++++++++++++++++++++ internal/cmd/issue_show.go | 5 +- internal/query/issue.go | 42 +++--- internal/query/issue_test.go | 79 ++++++++++- 4 files changed, 266 insertions(+), 24 deletions(-) create mode 100644 docs/plans/2026-03-10-pr1-review-fixes.md diff --git a/docs/plans/2026-03-10-pr1-review-fixes.md b/docs/plans/2026-03-10-pr1-review-fixes.md new file mode 100644 index 0000000..a4b2f01 --- /dev/null +++ b/docs/plans/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`. + +- [ ] 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 +- [ ] in `internal/cmd/issue_show_test.go`: extend `makeDetailedIssue()` to include all 9 fields in mock response +- [ ] in `internal/cmd/issue_show_test.go`: add test assertions for new fields in output (e.g. "SLA High Risk", "Triage Started", "Number", "Tickets") +- [ ] run tests -- expect failures (display code missing) +- [ ] in `internal/cmd/issue_show.go`: add `number` and `customerTicketCount` display (non-zero check) +- [ ] in `internal/cmd/issue_show.go`: add 7 missing timestamps to the display loop +- [ ] run tests -- must pass +- [ ] 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. + +- [ ] verify ALL new field types against `docs/schema.graphql` before writing tests +- [ ] 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) +- [ ] 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 +- [ ] in `internal/model/issue_test.go`: add `TestCycleRefDeserialization` -- test CycleRef with name, without name (nil), verify Number as float64 +- [ ] run tests -- must pass (these test existing PR code) +- [ ] run `make build` -- must pass + +### Task 4: Add query field presence tests for new fields + +- [ ] in `internal/query/issue_test.go`: add `TestIssueDetailFieldsContainsCycle` -- assert `issueDetailFields` contains `cycle { id name number }` +- [ ] in `internal/query/issue_test.go`: add `TestIssueDetailFieldsContainsCreator` -- assert `issueDetailFields` contains `creator { id displayName email }` +- [ ] 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 +- [ ] run tests -- must pass +- [ ] 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. + +- [ ] in `internal/cmd/issue_show_test.go`: extend `makeDetailedIssue()` with cycle (id, name, number), creator (id, displayName, email), branchName, trashed=true +- [ ] add assertions for: cycle format "#N Name", creator name, branch name, "Trashed: yes" +- [ ] add test case for issue WITHOUT cycle/creator/branch/trashed -- verify they don't appear in output +- [ ] run tests -- must pass +- [ ] run `make build` -- must pass + +### Task 6: Verify acceptance criteria +- [ ] verify all 9 previously undisplayed fields now appear in `issue show` +- [ ] verify `issueListFields` is compact (no detail-only fields) +- [ ] verify `issueDetailFields` contains all fields +- [ ] verify all field types match `docs/schema.graphql` (final check) +- [ ] run full test suite: `go test -race ./...` +- [ ] run `make build` (includes linter) +- [ ] verify test coverage for changed files + +### Task 7: [Final] Update documentation +- [ ] update `README.md:156` -- add all new displayed fields to the `issue show` description +- [ ] 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 5b063e2..0d3b558 100644 --- a/internal/cmd/issue_show.go +++ b/internal/cmd/issue_show.go @@ -157,7 +157,10 @@ func printIssueDetail(cmd *cobra.Command, issue *model.Issue) error { } } - for _, f := range []struct{ label string; value *string }{ + for _, f := range []struct { + label string + value *string + }{ {"Started", issue.StartedAt}, {"Completed", issue.CompletedAt}, {"Canceled", issue.CanceledAt}, diff --git a/internal/query/issue.go b/internal/query/issue.go index a27e90b..0e1d1ce 100644 --- a/internal/query/issue.go +++ b/internal/query/issue.go @@ -1,22 +1,32 @@ 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 - number title description - branchName priority priorityLabel estimate dueDate url - trashed - customerTicketCount 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 is the full field selection used for single-issue detail views. +const issueDetailFields = issueListFields + ` + number + branchName + trashed + customerTicketCount archivedAt autoArchivedAt autoClosedAt @@ -34,13 +44,7 @@ const issueFields = ` slaMediumRiskAt slaStartedAt slaType - state { id name color type } - assignee { id displayName email } creator { id displayName email } - team { id name key } - labels { nodes { id name color } } - parent { id identifier title } - project { id name } cycle { id name number } ` @@ -48,7 +52,7 @@ const issueFields = ` 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 } } } @@ -57,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 + `} } ` @@ -66,7 +70,7 @@ const IssueCreateMutation = ` mutation IssueCreate($input: IssueCreateInput!) { issueCreate(input: $input) { success - issue {` + issueFields + `} + issue {` + issueDetailFields + `} } } ` @@ -76,7 +80,7 @@ const IssueUpdateMutation = ` mutation IssueUpdate($id: String!, $input: IssueUpdateInput!) { issueUpdate(id: $id, input: $input) { success - issue {` + issueFields + `} + issue {` + issueDetailFields + `} } } ` @@ -103,7 +107,7 @@ mutation IssueArchive($id: String!) { const IssueBatchUpdateMutation = ` mutation IssueBatchUpdate($ids: [UUID!]!, $input: IssueUpdateInput!) { issueBatchUpdate(ids: $ids, input: $input) { - issues {` + issueFields + `} + issues {` + issueListFields + `} } } ` @@ -112,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 + `} } } ` @@ -120,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..8524f99 100644 --- a/internal/query/issue_test.go +++ b/internal/query/issue_test.go @@ -150,16 +150,16 @@ 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) } } @@ -185,3 +185,74 @@ func TestIssueFieldsPresence(t *testing.T) { } } } + +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 { + if !strings.Contains(issueListFields, f) { + 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 { + if !strings.Contains(issueDetailFields, f) { + 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 { + if !strings.Contains(issueDetailFields, f) { + t.Errorf("issueDetailFields missing detail field %q", f) + } + } +} From f20a7ee6eadce4a55cdfe0aa74bb3e28b9f5f17c Mon Sep 17 00:00:00 2001 From: Aleksei Iatsiuk Date: Tue, 10 Mar 2026 14:28:06 +0200 Subject: [PATCH 04/14] feat: display 9 previously undisplayed fields in issue show Added number, customerTicketCount, slaHighRiskAt, slaMediumRiskAt, startedTriageAt, snoozedUntilAt, addedToCycleAt, addedToProjectAt, addedToTeamAt to printIssueDetail output with TDD (test first). Co-Authored-By: Claude Sonnet 4.6 --- docs/plans/2026-03-10-pr1-review-fixes.md | 16 +++--- internal/cmd/issue_show.go | 19 ++++++ internal/cmd/issue_show_test.go | 70 +++++++++++++++++++---- 3 files changed, 86 insertions(+), 19 deletions(-) diff --git a/docs/plans/2026-03-10-pr1-review-fixes.md b/docs/plans/2026-03-10-pr1-review-fixes.md index a4b2f01..29d2bcf 100644 --- a/docs/plans/2026-03-10-pr1-review-fixes.md +++ b/docs/plans/2026-03-10-pr1-review-fixes.md @@ -74,17 +74,17 @@ Fields to add to display: `slaHighRiskAt`, `slaMediumRiskAt`, `startedTriageAt`, `snoozedUntilAt`, `addedToCycleAt`, `addedToProjectAt`, `addedToTeamAt`, `number`, `customerTicketCount`. -- [ ] verify field types against `docs/schema.graphql`: +- [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 -- [ ] in `internal/cmd/issue_show_test.go`: extend `makeDetailedIssue()` to include all 9 fields in mock response -- [ ] in `internal/cmd/issue_show_test.go`: add test assertions for new fields in output (e.g. "SLA High Risk", "Triage Started", "Number", "Tickets") -- [ ] run tests -- expect failures (display code missing) -- [ ] in `internal/cmd/issue_show.go`: add `number` and `customerTicketCount` display (non-zero check) -- [ ] in `internal/cmd/issue_show.go`: add 7 missing timestamps to the display loop -- [ ] run tests -- must pass -- [ ] run `make build` -- must pass +- [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 diff --git a/internal/cmd/issue_show.go b/internal/cmd/issue_show.go index 0d3b558..770dcc3 100644 --- a/internal/cmd/issue_show.go +++ b/internal/cmd/issue_show.go @@ -151,6 +151,18 @@ func printIssueDetail(cmd *cobra.Command, issue *model.Issue) error { } } + 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 @@ -165,11 +177,18 @@ func printIssueDetail(cmd *cobra.Command, issue *model.Issue) error { {"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 { diff --git a/internal/cmd/issue_show_test.go b/internal/cmd/issue_show_test.go index 4b7760d..a72c32d 100644 --- a/internal/cmd/issue_show_test.go +++ b/internal/cmd/issue_show_test.go @@ -23,17 +23,26 @@ 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", "state": map[string]any{ "id": "state-2", "name": "In Progress", @@ -57,6 +66,45 @@ 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"}, + } + 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_TableOutput(t *testing.T) { issue := makeDetailedIssue() server := newIssueTestServer(t, func(w http.ResponseWriter, _ *http.Request) { From fd817908bd2ffe103188f8a983faa3c540684693 Mon Sep 17 00:00:00 2001 From: Aleksei Iatsiuk Date: Tue, 10 Mar 2026 14:29:23 +0200 Subject: [PATCH 05/14] feat: add deserialization tests for all new Issue model fields Co-Authored-By: Claude Sonnet 4.6 --- docs/plans/2026-03-10-pr1-review-fixes.md | 12 +- internal/model/issue_test.go | 251 ++++++++++++++++++++++ 2 files changed, 257 insertions(+), 6 deletions(-) diff --git a/docs/plans/2026-03-10-pr1-review-fixes.md b/docs/plans/2026-03-10-pr1-review-fixes.md index 29d2bcf..241a868 100644 --- a/docs/plans/2026-03-10-pr1-review-fixes.md +++ b/docs/plans/2026-03-10-pr1-review-fixes.md @@ -90,12 +90,12 @@ Fields to add to display: `slaHighRiskAt`, `slaMediumRiskAt`, `startedTriageAt`, TDD: write tests to verify JSON -> Go struct mapping for all new fields. -- [ ] verify ALL new field types against `docs/schema.graphql` before writing tests -- [ ] 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) -- [ ] 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 -- [ ] in `internal/model/issue_test.go`: add `TestCycleRefDeserialization` -- test CycleRef with name, without name (nil), verify Number as float64 -- [ ] run tests -- must pass (these test existing PR code) -- [ ] run `make build` -- must pass +- [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 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() From 832a936a96d0e9bcaf5f39c59cf9d00c64e06a03 Mon Sep 17 00:00:00 2001 From: Aleksei Iatsiuk Date: Tue, 10 Mar 2026 14:30:31 +0200 Subject: [PATCH 06/14] feat: add query field presence tests for new issue fields Co-Authored-By: Claude Sonnet 4.6 --- docs/plans/2026-03-10-pr1-review-fixes.md | 10 +++--- internal/query/issue_test.go | 44 ++++++++++++++++++++--- 2 files changed, 45 insertions(+), 9 deletions(-) diff --git a/docs/plans/2026-03-10-pr1-review-fixes.md b/docs/plans/2026-03-10-pr1-review-fixes.md index 241a868..5fb4341 100644 --- a/docs/plans/2026-03-10-pr1-review-fixes.md +++ b/docs/plans/2026-03-10-pr1-review-fixes.md @@ -99,11 +99,11 @@ TDD: write tests to verify JSON -> Go struct mapping for all new fields. ### Task 4: Add query field presence tests for new fields -- [ ] in `internal/query/issue_test.go`: add `TestIssueDetailFieldsContainsCycle` -- assert `issueDetailFields` contains `cycle { id name number }` -- [ ] in `internal/query/issue_test.go`: add `TestIssueDetailFieldsContainsCreator` -- assert `issueDetailFields` contains `creator { id displayName email }` -- [ ] 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 -- [ ] run tests -- must pass -- [ ] run `make build` -- must pass +- [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) diff --git a/internal/query/issue_test.go b/internal/query/issue_test.go index 8524f99..460c763 100644 --- a/internal/query/issue_test.go +++ b/internal/query/issue_test.go @@ -166,24 +166,60 @@ func TestIssueFieldsContainsProject(t *testing.T) { 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, } - for qName, q := range queries { - for _, f := range fields { + for qName, q := range allQueries { + for _, f := range commonFields { if !strings.Contains(q, f) { 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, + "IssueCreateMutation": IssueCreateMutation, + "IssueUpdateMutation": IssueUpdateMutation, + } + for qName, q := range detailQueries { + for _, f := range detailFields { + if !strings.Contains(q, f) { + 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) { From b9d358bf7057adec8bd9de3da84650299e8dbfc4 Mon Sep 17 00:00:00 2001 From: Aleksei Iatsiuk Date: Tue, 10 Mar 2026 14:31:47 +0200 Subject: [PATCH 07/14] feat: add display tests for cycle, creator, branchName, trashed fields Co-Authored-By: Claude Sonnet 4.6 --- docs/plans/2026-03-10-pr1-review-fixes.md | 10 ++-- internal/cmd/issue_show_test.go | 72 +++++++++++++++++++++++ 2 files changed, 77 insertions(+), 5 deletions(-) diff --git a/docs/plans/2026-03-10-pr1-review-fixes.md b/docs/plans/2026-03-10-pr1-review-fixes.md index 5fb4341..3b1f2d1 100644 --- a/docs/plans/2026-03-10-pr1-review-fixes.md +++ b/docs/plans/2026-03-10-pr1-review-fixes.md @@ -109,11 +109,11 @@ TDD: write tests to verify JSON -> Go struct mapping for all new fields. TDD: verify the existing PR display code is tested. -- [ ] in `internal/cmd/issue_show_test.go`: extend `makeDetailedIssue()` with cycle (id, name, number), creator (id, displayName, email), branchName, trashed=true -- [ ] add assertions for: cycle format "#N Name", creator name, branch name, "Trashed: yes" -- [ ] add test case for issue WITHOUT cycle/creator/branch/trashed -- verify they don't appear in output -- [ ] run tests -- must pass -- [ ] run `make build` -- must pass +- [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 - [ ] verify all 9 previously undisplayed fields now appear in `issue show` diff --git a/internal/cmd/issue_show_test.go b/internal/cmd/issue_show_test.go index a72c32d..15ea245 100644 --- a/internal/cmd/issue_show_test.go +++ b/internal/cmd/issue_show_test.go @@ -43,6 +43,18 @@ func makeDetailedIssue() map[string]any { "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, + "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", @@ -97,6 +109,10 @@ func TestIssueShowCommand_NewFields(t *testing.T) { {"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"}, } for _, c := range checks { if !strings.Contains(result, c.want) { @@ -105,6 +121,62 @@ func TestIssueShowCommand_NewFields(t *testing.T) { } } +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:"}, + } + 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) { From 493563166555b3516f813c9bcbc3b31ad41664fe Mon Sep 17 00:00:00 2001 From: Aleksei Iatsiuk Date: Tue, 10 Mar 2026 14:32:42 +0200 Subject: [PATCH 08/14] feat: verify acceptance criteria for issue full fields PR Co-Authored-By: Claude Sonnet 4.6 --- docs/plans/2026-03-10-pr1-review-fixes.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/plans/2026-03-10-pr1-review-fixes.md b/docs/plans/2026-03-10-pr1-review-fixes.md index 3b1f2d1..c9ee59a 100644 --- a/docs/plans/2026-03-10-pr1-review-fixes.md +++ b/docs/plans/2026-03-10-pr1-review-fixes.md @@ -116,13 +116,13 @@ TDD: verify the existing PR display code is tested. - [x] run `make build` -- must pass ### Task 6: Verify acceptance criteria -- [ ] verify all 9 previously undisplayed fields now appear in `issue show` -- [ ] verify `issueListFields` is compact (no detail-only fields) -- [ ] verify `issueDetailFields` contains all fields -- [ ] verify all field types match `docs/schema.graphql` (final check) -- [ ] run full test suite: `go test -race ./...` -- [ ] run `make build` (includes linter) -- [ ] verify test coverage for changed files +- [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 - [ ] update `README.md:156` -- add all new displayed fields to the `issue show` description From b1abcdc4eeaf96081f363061bf7d7cbecc05e5d8 Mon Sep 17 00:00:00 2001 From: Aleksei Iatsiuk Date: Tue, 10 Mar 2026 14:33:15 +0200 Subject: [PATCH 09/14] feat: update README with all new issue show fields Co-Authored-By: Claude Sonnet 4.6 --- README.md | 2 +- docs/plans/2026-03-10-pr1-review-fixes.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) 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/2026-03-10-pr1-review-fixes.md b/docs/plans/2026-03-10-pr1-review-fixes.md index c9ee59a..323a975 100644 --- a/docs/plans/2026-03-10-pr1-review-fixes.md +++ b/docs/plans/2026-03-10-pr1-review-fixes.md @@ -125,8 +125,8 @@ TDD: verify the existing PR display code is tested. - [x] verify test coverage for changed files ### Task 7: [Final] Update documentation -- [ ] update `README.md:156` -- add all new displayed fields to the `issue show` description -- [ ] run `make build` -- must pass +- [x] update `README.md:156` -- add all new displayed fields to the `issue show` description +- [x] run `make build` -- must pass ## Technical Details From 9335dc9e3c09c4d0425542df7f8a9ab8a4861e38 Mon Sep 17 00:00:00 2001 From: Aleksei Iatsiuk Date: Tue, 10 Mar 2026 14:41:37 +0200 Subject: [PATCH 10/14] fix: address code review findings - use issueListFields in IssueCreate/UpdateMutation (callers use printIssueRow, not printIssueDetail) - display Number and CustomerTicketCount unconditionally (non-null schema fields) - fix column alignment: widen format from %-14s to %-15s to fit "Added to Cycle:" - add missing timestamp/SLA fields to test fixture and assertions (10 fields were untested) - add IssueBranchQuery to allQueries and detailQueries in TestIssueFieldsPresence; remove Create/UpdateMutation from detailQueries - document list/detail field set pattern in CLAUDE.md Co-Authored-By: Claude Sonnet 4.6 --- CLAUDE.md | 9 +++++++++ internal/cmd/issue_show.go | 14 +++++--------- internal/cmd/issue_show_test.go | 20 ++++++++++++++++++++ internal/query/issue.go | 4 ++-- internal/query/issue_test.go | 6 +++--- 5 files changed, 39 insertions(+), 14 deletions(-) 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/internal/cmd/issue_show.go b/internal/cmd/issue_show.go index 770dcc3..9754edb 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 } @@ -151,16 +151,12 @@ func printIssueDetail(cmd *cobra.Command, issue *model.Issue) error { } } - if issue.Number != 0 { - if err := writeLine("Number", fmt.Sprintf("%.0f", issue.Number)); err != nil { - return err - } + 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 err := writeLine("Tickets", fmt.Sprintf("%d", issue.CustomerTicketCount)); err != nil { + return err } if issue.Trashed != nil && *issue.Trashed { diff --git a/internal/cmd/issue_show_test.go b/internal/cmd/issue_show_test.go index 15ea245..567da00 100644 --- a/internal/cmd/issue_show_test.go +++ b/internal/cmd/issue_show_test.go @@ -45,6 +45,16 @@ func makeDetailedIssue() map[string]any { "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", @@ -113,6 +123,16 @@ func TestIssueShowCommand_NewFields(t *testing.T) { {"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) { diff --git a/internal/query/issue.go b/internal/query/issue.go index 0e1d1ce..d84c15f 100644 --- a/internal/query/issue.go +++ b/internal/query/issue.go @@ -70,7 +70,7 @@ const IssueCreateMutation = ` mutation IssueCreate($input: IssueCreateInput!) { issueCreate(input: $input) { success - issue {` + issueDetailFields + `} + issue {` + issueListFields + `} } } ` @@ -80,7 +80,7 @@ const IssueUpdateMutation = ` mutation IssueUpdate($id: String!, $input: IssueUpdateInput!) { issueUpdate(id: $id, input: $input) { success - issue {` + issueDetailFields + `} + issue {` + issueListFields + `} } } ` diff --git a/internal/query/issue_test.go b/internal/query/issue_test.go index 460c763..c986819 100644 --- a/internal/query/issue_test.go +++ b/internal/query/issue_test.go @@ -176,6 +176,7 @@ func TestIssueFieldsPresence(t *testing.T) { "IssueGetQuery": IssueGetQuery, "IssueCreateMutation": IssueCreateMutation, "IssueUpdateMutation": IssueUpdateMutation, + "IssueBranchQuery": IssueBranchQuery, } for qName, q := range allQueries { for _, f := range commonFields { @@ -193,9 +194,8 @@ func TestIssueFieldsPresence(t *testing.T) { "addedToCycleAt", "addedToProjectAt", "addedToTeamAt", } detailQueries := map[string]string{ - "IssueGetQuery": IssueGetQuery, - "IssueCreateMutation": IssueCreateMutation, - "IssueUpdateMutation": IssueUpdateMutation, + "IssueGetQuery": IssueGetQuery, + "IssueBranchQuery": IssueBranchQuery, } for qName, q := range detailQueries { for _, f := range detailFields { From f65a72514a90b16f2c711b78968982c9ed06a82a Mon Sep 17 00:00:00 2001 From: Aleksei Iatsiuk Date: Tue, 10 Mar 2026 14:44:31 +0200 Subject: [PATCH 11/14] fix: address code review findings Co-Authored-By: Claude Sonnet 4.6 --- internal/cmd/issue_show.go | 12 ++++++++---- internal/cmd/issue_show_test.go | 2 ++ 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/internal/cmd/issue_show.go b/internal/cmd/issue_show.go index 9754edb..7964796 100644 --- a/internal/cmd/issue_show.go +++ b/internal/cmd/issue_show.go @@ -151,12 +151,16 @@ func printIssueDetail(cmd *cobra.Command, issue *model.Issue) error { } } - if err := writeLine("Number", fmt.Sprintf("%.0f", issue.Number)); err != nil { - return err + if issue.Number != 0 { + if err := writeLine("Number", fmt.Sprintf("%.0f", issue.Number)); err != nil { + return err + } } - if err := writeLine("Tickets", fmt.Sprintf("%d", issue.CustomerTicketCount)); 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 { diff --git a/internal/cmd/issue_show_test.go b/internal/cmd/issue_show_test.go index 567da00..665e000 100644 --- a/internal/cmd/issue_show_test.go +++ b/internal/cmd/issue_show_test.go @@ -189,6 +189,8 @@ func TestIssueShowCommand_NoCycleCreatorBranchTrashed(t *testing.T) { {"creator", "Creator:"}, {"branch", "Branch:"}, {"trashed", "Trashed:"}, + {"number", "Number:"}, + {"tickets", "Tickets:"}, } for _, c := range absent { if strings.Contains(result, c.want) { From e1d8c13f0b89ebec31ae290525a6e883f65d0960 Mon Sep 17 00:00:00 2001 From: Aleksei Iatsiuk Date: Tue, 10 Mar 2026 14:52:50 +0200 Subject: [PATCH 12/14] fix: address code review findings --- internal/query/issue_test.go | 27 ++++++++++++++++++++++----- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/internal/query/issue_test.go b/internal/query/issue_test.go index c986819..b8e6f91 100644 --- a/internal/query/issue_test.go +++ b/internal/query/issue_test.go @@ -180,7 +180,8 @@ func TestIssueFieldsPresence(t *testing.T) { } for qName, q := range allQueries { for _, f := range commonFields { - if !strings.Contains(q, f) { + // 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) } } @@ -199,7 +200,8 @@ func TestIssueFieldsPresence(t *testing.T) { } for qName, q := range detailQueries { for _, f := range detailFields { - if !strings.Contains(q, f) { + // 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) } } @@ -237,7 +239,12 @@ func TestIssueListFieldsCompact(t *testing.T) { "project { id name }", } for _, f := range wantPresent { - if !strings.Contains(issueListFields, f) { + // 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) } } @@ -272,7 +279,12 @@ func TestIssueDetailFieldsContainsAll(t *testing.T) { "project { id name }", } for _, f := range listFields { - if !strings.Contains(issueDetailFields, f) { + // 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) } } @@ -287,7 +299,12 @@ func TestIssueDetailFieldsContainsAll(t *testing.T) { "cycle { id name number }", } for _, f := range detailOnly { - if !strings.Contains(issueDetailFields, f) { + // 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) } } From df213b66b8f056680a5ecc8a46e91e701c567877 Mon Sep 17 00:00:00 2001 From: Aleksei Iatsiuk Date: Tue, 10 Mar 2026 14:55:39 +0200 Subject: [PATCH 13/14] move completed plan: 2026-03-10-pr1-review-fixes.md --- docs/plans/{ => completed}/2026-03-10-pr1-review-fixes.md | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename docs/plans/{ => completed}/2026-03-10-pr1-review-fixes.md (100%) diff --git a/docs/plans/2026-03-10-pr1-review-fixes.md b/docs/plans/completed/2026-03-10-pr1-review-fixes.md similarity index 100% rename from docs/plans/2026-03-10-pr1-review-fixes.md rename to docs/plans/completed/2026-03-10-pr1-review-fixes.md From 6a146625527a46e8f7963850a3810cd39c233275 Mon Sep 17 00:00:00 2001 From: Aleksei Iatsiuk Date: Tue, 10 Mar 2026 15:00:39 +0200 Subject: [PATCH 14/14] chore: trigger PR update