From a2fb6849162f2bc5e0ad4b1b2fbc4276aef3552b Mon Sep 17 00:00:00 2001 From: Felix Lisczyk <5102728+FelixLisczyk@users.noreply.github.com> Date: Thu, 6 Aug 2026 12:57:43 +0200 Subject: [PATCH 1/2] Add named project status filtering Expose project status names alongside the deprecated state type so CLI users can distinguish statuses such as In Progress and On Hold. Add server-side status filtering, compatibility rendering, mutation result support, and focused tests across the client, service, CLI, and formatters.\n\nCo-Authored-By: Claude --- internal/cli/projects.go | 9 +- internal/cli/projects_status_test.go | 10 + internal/format/json_dtos.go | 21 +- internal/format/project.go | 4 +- internal/format/project_status_test.go | 55 ++++ internal/format/text_renderer.go | 4 +- internal/service/client_interfaces.go | 7 + internal/service/interfaces.go | 3 + internal/service/project.go | 87 +++++- pkg/linear/client.go | 33 ++- pkg/linear/core/types.go | 38 ++- pkg/linear/projects/client.go | 380 +++++++++++++++++++++---- pkg/linear/projects/client_test.go | 75 +++++ 13 files changed, 654 insertions(+), 72 deletions(-) create mode 100644 internal/cli/projects_status_test.go create mode 100644 internal/format/project_status_test.go create mode 100644 pkg/linear/projects/client_test.go diff --git a/internal/cli/projects.go b/internal/cli/projects.go index 376beed..56b4ad5 100644 --- a/internal/cli/projects.go +++ b/internal/cli/projects.go @@ -31,6 +31,7 @@ func newProjectsListCmd() *cobra.Command { var mine bool var teamID string var limit int + var status string var formatStr, outputType string cmd := &cobra.Command{ @@ -49,6 +50,9 @@ func newProjectsListCmd() *cobra.Command { # List with custom limit linear projects list --limit 50 + # Filter by named project status (comma-separated OR filter) + linear projects list --status "In Progress,On Hold" + # Output as JSON linear projects list --output json`, RunE: func(cmd *cobra.Command, args []string) error { @@ -76,7 +80,7 @@ func newProjectsListCmd() *cobra.Command { var result string if mine { // --mine overrides team requirement - result, err = deps.Projects.ListUserProjectsWithOutput(limit, verbosity, output) + result, err = deps.Projects.ListUserProjectsWithStatusOutput(limit, status, verbosity, output) } else { // Get team from flag or config if teamID == "" { @@ -86,7 +90,7 @@ func newProjectsListCmd() *cobra.Command { return errors.New(ErrTeamRequired) } - result, err = deps.Projects.ListByTeamWithOutput(teamID, limit, verbosity, output) + result, err = deps.Projects.ListByTeamWithStatusOutput(teamID, limit, status, verbosity, output) } if err != nil { return fmt.Errorf("failed to list projects: %w", err) @@ -100,6 +104,7 @@ func newProjectsListCmd() *cobra.Command { cmd.Flags().BoolVar(&mine, "mine", false, "Only show projects you're involved in (ignores team)") cmd.Flags().StringVarP(&teamID, "team", "t", "", TeamFlagDescription) cmd.Flags().IntVarP(&limit, "limit", "n", 25, "Number of projects to return") + cmd.Flags().StringVar(&status, "status", "", "Filter by project status name(s), comma-separated") cmd.Flags().StringVarP(&formatStr, "format", "f", "compact", "Verbosity: minimal|compact|detailed|full") cmd.Flags().StringVarP(&outputType, "output", "o", "text", "Output: text|json") diff --git a/internal/cli/projects_status_test.go b/internal/cli/projects_status_test.go new file mode 100644 index 0000000..b437e85 --- /dev/null +++ b/internal/cli/projects_status_test.go @@ -0,0 +1,10 @@ +package cli + +import "testing" + +func TestProjectsListHasStatusFlag(t *testing.T) { + flag := newProjectsListCmd().Flags().Lookup("status") + if flag == nil { + t.Fatal("projects list is missing --status") + } +} diff --git a/internal/format/json_dtos.go b/internal/format/json_dtos.go index 288c60c..d0999f5 100644 --- a/internal/format/json_dtos.go +++ b/internal/format/json_dtos.go @@ -118,6 +118,7 @@ type ProjectDTO struct { Name string `json:"name"` Description string `json:"description"` State string `json:"state"` + Status *ProjectStatusDTO `json:"status,omitempty"` Content string `json:"content"` Issues []IssueRefDTO `json:"issues"` CreatedAt string `json:"createdAt"` @@ -183,6 +184,13 @@ type StateDTO struct { Name string `json:"name"` } +// ProjectStatusDTO represents a named project status. +type ProjectStatusDTO struct { + ID string `json:"id"` + Name string `json:"name"` + Type string `json:"type"` +} + // LabelDTO represents an issue label type LabelDTO struct { ID string `json:"id"` @@ -471,15 +479,26 @@ func CycleToFullDTO(cycle *core.Cycle) CycleFullDTO { // ProjectToDTO converts a project to DTO func ProjectToDTO(project *core.Project) ProjectDTO { + state := project.State + if project.Status != nil { + state = project.Status.Type + } dto := ProjectDTO{ ID: project.ID, Name: project.Name, Description: project.Description, - State: project.State, + State: state, Content: project.Content, CreatedAt: project.CreatedAt, UpdatedAt: project.UpdatedAt, } + if project.Status != nil { + dto.Status = &ProjectStatusDTO{ + ID: project.Status.ID, + Name: project.Status.Name, + Type: project.Status.Type, + } + } // Convert issues issues := project.GetIssues() diff --git a/internal/format/project.go b/internal/format/project.go index 0acd533..95618a3 100644 --- a/internal/format/project.go +++ b/internal/format/project.go @@ -20,7 +20,7 @@ func (f *Formatter) Project(project *core.Project) string { b.WriteString("\n") // State - b.WriteString(fmtSprintf("State: %s\n", project.State)) + b.WriteString(fmtSprintf("State: %s\n", project.StatusName())) // Description if project.Description != "" { @@ -92,7 +92,7 @@ func (f *Formatter) projectCompact(project *core.Project) string { var b strings.Builder // Line 1: Name and state - b.WriteString(fmtSprintf("%s [%s]\n", project.Name, project.State)) + b.WriteString(fmtSprintf("%s [%s]\n", project.Name, project.StatusName())) // Line 2: Description (if any) if project.Description != "" { diff --git a/internal/format/project_status_test.go b/internal/format/project_status_test.go new file mode 100644 index 0000000..19a6e56 --- /dev/null +++ b/internal/format/project_status_test.go @@ -0,0 +1,55 @@ +package format + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/joa23/linear-cli/pkg/linear/core" +) + +func TestProjectStatusOutputKeepsNamedStatusAndStateAlias(t *testing.T) { + project := &core.Project{ + ID: "p1", + Name: "Planning", + State: "started", + Status: &core.ProjectStatus{ + ID: "s1", + Name: "On Hold", + Type: "started", + }, + } + + var output map[string]interface{} + if err := json.Unmarshal([]byte((&JSONRenderer{}).RenderProject(project, VerbosityCompact)), &output); err != nil { + t.Fatalf("invalid project JSON: %v", err) + } + if output["state"] != "started" { + t.Fatalf("state = %v, want started", output["state"]) + } + status, ok := output["status"].(map[string]interface{}) + if !ok || status["name"] != "On Hold" { + t.Fatalf("status = %#v, want named status", output["status"]) + } + + text := (&TextRenderer{}).RenderProject(project, VerbosityCompact) + if !strings.Contains(text, "State: On Hold") { + t.Fatalf("text = %q, want named status", text) + } +} + +func TestProjectStatusOutputFallsBackForLegacyProject(t *testing.T) { + project := &core.Project{Name: "Legacy", State: "started"} + text := (&TextRenderer{}).RenderProject(project, VerbosityCompact) + if !strings.Contains(text, "State: started") { + t.Fatalf("text = %q, want legacy state fallback", text) + } + + var output map[string]interface{} + if err := json.Unmarshal([]byte((&JSONRenderer{}).RenderProject(project, VerbosityCompact)), &output); err != nil { + t.Fatalf("invalid project JSON: %v", err) + } + if _, ok := output["status"]; ok { + t.Fatal("legacy project unexpectedly contains status") + } +} diff --git a/internal/format/text_renderer.go b/internal/format/text_renderer.go index 16ca2df..1004e7c 100644 --- a/internal/format/text_renderer.go +++ b/internal/format/text_renderer.go @@ -338,7 +338,7 @@ func (r *TextRenderer) projectFull(project *core.Project) string { b.WriteString("\n") // State - b.WriteString(fmtSprintf("State: %s\n", project.State)) + b.WriteString(fmtSprintf("State: %s\n", project.StatusName())) // Description if project.Description != "" { @@ -381,7 +381,7 @@ func (r *TextRenderer) projectCompact(project *core.Project) string { var b strings.Builder // Line 1: Name and state - b.WriteString(fmtSprintf("%s [%s]\n", project.Name, project.State)) + b.WriteString(fmtSprintf("%s [%s]\n", project.Name, project.StatusName())) // Line 2: Description (if any) if project.Description != "" { diff --git a/internal/service/client_interfaces.go b/internal/service/client_interfaces.go index b97ee84..b2e7796 100644 --- a/internal/service/client_interfaces.go +++ b/internal/service/client_interfaces.go @@ -65,6 +65,13 @@ type ProjectClientOperations interface { // Smart resolver-aware methods (kept in Phase 2) CreateProject(name, description, teamKeyOrName string) (*core.Project, error) + // Project reads and named-status filtering + GetProject(projectID string) (*core.Project, error) + ListAllProjectsWithStatus(limit int, statusIDs []string) ([]core.Project, error) + ListByTeamWithStatus(teamID string, limit int, statusIDs []string) ([]core.Project, error) + ListUserProjectsWithStatus(userID string, limit int, statusIDs []string) ([]core.Project, error) + ResolveProjectStatusNames(names []string) ([]string, error) + // Resolver operations ResolveTeamIdentifier(keyOrName string) (string, error) ResolveUserIdentifier(nameOrEmail string) (*linear.ResolvedUser, error) diff --git a/internal/service/interfaces.go b/internal/service/interfaces.go index 8df9956..8603bc7 100644 --- a/internal/service/interfaces.go +++ b/internal/service/interfaces.go @@ -39,10 +39,13 @@ type ProjectServiceInterface interface { GetWithOutput(projectID string, verbosity format.Verbosity, outputType format.OutputType) (string, error) ListAll(limit int) (string, error) ListAllWithOutput(limit int, verbosity format.Verbosity, outputType format.OutputType) (string, error) + ListAllWithStatusOutput(limit int, status string, verbosity format.Verbosity, outputType format.OutputType) (string, error) ListByTeam(teamID string, limit int) (string, error) ListByTeamWithOutput(teamID string, limit int, verbosity format.Verbosity, outputType format.OutputType) (string, error) + ListByTeamWithStatusOutput(teamID string, limit int, status string, verbosity format.Verbosity, outputType format.OutputType) (string, error) ListUserProjects(limit int) (string, error) ListUserProjectsWithOutput(limit int, verbosity format.Verbosity, outputType format.OutputType) (string, error) + ListUserProjectsWithStatusOutput(limit int, status string, verbosity format.Verbosity, outputType format.OutputType) (string, error) Create(input *CreateProjectInput) (string, error) Update(projectID string, input *UpdateProjectInput) (string, error) } diff --git a/internal/service/project.go b/internal/service/project.go index 40c8583..3835c80 100644 --- a/internal/service/project.go +++ b/internal/service/project.go @@ -2,6 +2,7 @@ package service import ( "fmt" + "strings" "github.com/joa23/linear-cli/internal/format" "github.com/joa23/linear-cli/pkg/linear/projects" @@ -23,7 +24,7 @@ func NewProjectService(client ProjectClientOperations, formatter *format.Formatt // Get retrieves a single project by ID (legacy method) func (s *ProjectService) Get(projectID string) (string, error) { - project, err := s.client.ProjectClient().GetProject(projectID) + project, err := s.client.GetProject(projectID) if err != nil { return "", fmt.Errorf("failed to get project %s: %w", projectID, err) } @@ -33,7 +34,7 @@ func (s *ProjectService) Get(projectID string) (string, error) { // GetWithOutput retrieves a single project with new renderer architecture func (s *ProjectService) GetWithOutput(projectID string, verbosity format.Verbosity, outputType format.OutputType) (string, error) { - project, err := s.client.ProjectClient().GetProject(projectID) + project, err := s.client.GetProject(projectID) if err != nil { return "", fmt.Errorf("failed to get project %s: %w", projectID, err) } @@ -47,7 +48,7 @@ func (s *ProjectService) ListAll(limit int) (string, error) { limit = 50 } - projects, err := s.client.ProjectClient().ListAllProjects(limit) + projects, err := s.client.ListAllProjectsWithStatus(limit, nil) if err != nil { return "", fmt.Errorf("failed to list projects: %w", err) } @@ -61,7 +62,7 @@ func (s *ProjectService) ListAllWithOutput(limit int, verbosity format.Verbosity limit = 50 } - projects, err := s.client.ProjectClient().ListAllProjects(limit) + projects, err := s.client.ListAllProjectsWithStatus(limit, nil) if err != nil { return "", fmt.Errorf("failed to list projects: %w", err) } @@ -81,7 +82,7 @@ func (s *ProjectService) ListByTeam(teamID string, limit int) (string, error) { return "", fmt.Errorf("failed to resolve team '%s': %w", teamID, err) } - projects, err := s.client.ProjectClient().ListByTeam(resolvedTeamID, limit) + projects, err := s.client.ListByTeamWithStatus(resolvedTeamID, limit, nil) if err != nil { return "", fmt.Errorf("failed to list projects by team: %w", err) } @@ -101,7 +102,7 @@ func (s *ProjectService) ListByTeamWithOutput(teamID string, limit int, verbosit return "", fmt.Errorf("failed to resolve team '%s': %w", teamID, err) } - projects, err := s.client.ProjectClient().ListByTeam(resolvedTeamID, limit) + projects, err := s.client.ListByTeamWithStatus(resolvedTeamID, limit, nil) if err != nil { return "", fmt.Errorf("failed to list projects by team: %w", err) } @@ -121,7 +122,7 @@ func (s *ProjectService) ListUserProjects(limit int) (string, error) { return "", fmt.Errorf("failed to get current user: %w", err) } - projects, err := s.client.ProjectClient().ListUserProjects(viewer.ID, limit) + projects, err := s.client.ListUserProjectsWithStatus(viewer.ID, limit, nil) if err != nil { return "", fmt.Errorf("failed to list user projects: %w", err) } @@ -141,7 +142,7 @@ func (s *ProjectService) ListUserProjectsWithOutput(limit int, verbosity format. return "", fmt.Errorf("failed to get current user: %w", err) } - projects, err := s.client.ProjectClient().ListUserProjects(viewer.ID, limit) + projects, err := s.client.ListUserProjectsWithStatus(viewer.ID, limit, nil) if err != nil { return "", fmt.Errorf("failed to list user projects: %w", err) } @@ -149,7 +150,62 @@ func (s *ProjectService) ListUserProjectsWithOutput(limit int, verbosity format. return s.formatter.RenderProjectList(projects, verbosity, outputType, nil), nil } -// CreateProjectInput represents input for creating a project +// ListByTeamWithStatusOutput lists team projects filtered by named status values. +func (s *ProjectService) ListByTeamWithStatusOutput(teamID string, limit int, status string, verbosity format.Verbosity, outputType format.OutputType) (string, error) { + if limit <= 0 { + limit = 50 + } + resolvedTeamID, err := s.client.ResolveTeamIdentifier(teamID) + if err != nil { + return "", fmt.Errorf("failed to resolve team '%s': %w", teamID, err) + } + statusIDs, err := s.resolveStatusIDs(status) + if err != nil { + return "", err + } + projects, err := s.client.ListByTeamWithStatus(resolvedTeamID, limit, statusIDs) + if err != nil { + return "", fmt.Errorf("failed to list projects by team: %w", err) + } + return s.formatter.RenderProjectList(projects, verbosity, outputType, nil), nil +} + +// ListUserProjectsWithStatusOutput lists the viewer's projects filtered by named status values. +func (s *ProjectService) ListUserProjectsWithStatusOutput(limit int, status string, verbosity format.Verbosity, outputType format.OutputType) (string, error) { + if limit <= 0 { + limit = 50 + } + viewer, err := s.client.TeamClient().GetViewer() + if err != nil { + return "", fmt.Errorf("failed to get current user: %w", err) + } + statusIDs, err := s.resolveStatusIDs(status) + if err != nil { + return "", err + } + projects, err := s.client.ListUserProjectsWithStatus(viewer.ID, limit, statusIDs) + if err != nil { + return "", fmt.Errorf("failed to list user projects: %w", err) + } + return s.formatter.RenderProjectList(projects, verbosity, outputType, nil), nil +} + +// ListAllWithStatusOutput lists workspace projects filtered by named statuses. +func (s *ProjectService) ListAllWithStatusOutput(limit int, status string, verbosity format.Verbosity, outputType format.OutputType) (string, error) { + if limit <= 0 { + limit = 50 + } + statusIDs, err := s.resolveStatusIDs(status) + if err != nil { + return "", err + } + projects, err := s.client.ListAllProjectsWithStatus(limit, statusIDs) + if err != nil { + return "", fmt.Errorf("failed to list projects: %w", err) + } + return s.formatter.RenderProjectList(projects, verbosity, outputType, nil), nil +} + type CreateProjectInput struct { Name string Description string @@ -207,7 +263,7 @@ func (s *ProjectService) Create(input *CreateProjectInput) (string, error) { return "", fmt.Errorf("failed to update project after creation: %w", err) } // Re-fetch to get updated project - project, err = s.client.ProjectClient().GetProject(project.ID) + project, err = s.client.GetProject(project.ID) if err != nil { return "", fmt.Errorf("failed to get updated project: %w", err) } @@ -267,3 +323,14 @@ func (s *ProjectService) Update(projectID string, input *UpdateProjectInput) (st return s.formatter.Project(project), nil } + +func (s *ProjectService) resolveStatusIDs(raw string) ([]string, error) { + if raw == "" { + return nil, nil + } + names, err := projects.NormalizeStatusNames(strings.Split(raw, ",")) + if err != nil { + return nil, err + } + return s.client.ResolveProjectStatusNames(names) +} diff --git a/pkg/linear/client.go b/pkg/linear/client.go index c20f8d0..7b82109 100644 --- a/pkg/linear/client.go +++ b/pkg/linear/client.go @@ -6,6 +6,8 @@ import ( "os" "github.com/joa23/linear-cli/internal/config" + "github.com/joa23/linear-cli/internal/oauth" + "github.com/joa23/linear-cli/internal/token" "github.com/joa23/linear-cli/pkg/linear/attachments" "github.com/joa23/linear-cli/pkg/linear/comments" "github.com/joa23/linear-cli/pkg/linear/core" @@ -16,8 +18,6 @@ import ( "github.com/joa23/linear-cli/pkg/linear/teams" "github.com/joa23/linear-cli/pkg/linear/users" "github.com/joa23/linear-cli/pkg/linear/workflows" - "github.com/joa23/linear-cli/internal/oauth" - "github.com/joa23/linear-cli/internal/token" ) // Client represents the main Linear API client that orchestrates all sub-clients. @@ -461,6 +461,31 @@ func (c *Client) ListUserProjects(userID string, limit int) ([]core.Project, err return c.Projects.ListUserProjects(userID, limit) } +func (c *Client) ListAllProjectsWithStatus(limit int, statusIDs []string) ([]core.Project, error) { + if limit <= 0 { + limit = 50 + } + return c.Projects.ListAllProjectsWithStatus(limit, statusIDs) +} + +func (c *Client) ListByTeamWithStatus(teamID string, limit int, statusIDs []string) ([]core.Project, error) { + if limit <= 0 { + limit = 50 + } + return c.Projects.ListByTeamWithStatus(teamID, limit, statusIDs) +} + +func (c *Client) ListUserProjectsWithStatus(userID string, limit int, statusIDs []string) ([]core.Project, error) { + if limit <= 0 { + limit = 50 + } + return c.Projects.ListUserProjectsWithStatus(userID, limit, statusIDs) +} + +func (c *Client) ResolveProjectStatusNames(names []string) ([]string, error) { + return c.Projects.ResolveProjectStatusNames(names) +} + func (c *Client) UpdateProject(projectID string, input interface{}) (*core.Project, error) { // Convert interface{} to the actual type expected by Projects client // This is a temporary solution for Phase 1 to maintain flexibility @@ -471,6 +496,10 @@ func (c *Client) UpdateProjectState(projectID, state string) error { return c.Projects.UpdateProjectState(projectID, state) } +func (c *Client) UpdateProjectStateWithResult(projectID, state string) (*core.Project, error) { + return c.Projects.UpdateProjectStateWithResult(projectID, state) +} + func (c *Client) UpdateProjectDescription(projectID, newDescription string) error { return c.Projects.UpdateProjectDescription(projectID, newDescription) } diff --git a/pkg/linear/core/types.go b/pkg/linear/core/types.go index 37b6f7f..021ecd4 100644 --- a/pkg/linear/core/types.go +++ b/pkg/linear/core/types.go @@ -398,19 +398,55 @@ type ParentIssue struct { Metadata map[string]interface{} `json:"metadata,omitempty"` } +// ProjectStatus represents a named Linear project status. +type ProjectStatus struct { + ID string `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + ArchivedAt *string `json:"archivedAt,omitempty"` +} + // Project represents a Linear project type Project struct { ID string `json:"id"` Name string `json:"name"` Description string `json:"description"` // Short description (255 char limit) Content string `json:"content,omitempty"` // Long markdown content (no limit) - State string `json:"state"` // planned, started, completed, etc. + State string `json:"state"` // Deprecated status type (planned, started, etc.) + Status *ProjectStatus `json:"status,omitempty"` Issues *IssueConnection `json:"issues,omitempty"` Metadata map[string]interface{} `json:"metadata,omitempty"` CreatedAt string `json:"createdAt"` UpdatedAt string `json:"updatedAt"` } +// UnmarshalJSON preserves the deprecated state alias while preferring the +// status type whenever the API returns the named project status. +func (p *Project) UnmarshalJSON(data []byte) error { + type projectAlias Project + var decoded projectAlias + if err := json.Unmarshal(data, &decoded); err != nil { + return err + } + if decoded.Status != nil { + decoded.State = decoded.Status.Type + } + *p = Project(decoded) + return nil +} + +// StatusName returns the named status, falling back to the deprecated state +// value for responses produced by older API versions. +func (p *Project) StatusName() string { + if p != nil && p.Status != nil && p.Status.Name != "" { + return p.Status.Name + } + if p == nil { + return "" + } + return p.State +} + // IssueConnection represents the GraphQL connection for issues type IssueConnection struct { Nodes []ProjectIssue `json:"nodes"` diff --git a/pkg/linear/projects/client.go b/pkg/linear/projects/client.go index 67a7e2a..599c358 100644 --- a/pkg/linear/projects/client.go +++ b/pkg/linear/projects/client.go @@ -21,6 +21,106 @@ func NewClient(base *core.BaseClient) *Client { return &Client{base: base} } +// StatusFilter contains resolved project status IDs used by server-side list filters. +type StatusFilter struct { + IDs []string +} + +// NormalizeStatusNames trims, validates, and de-duplicates project status names. +func NormalizeStatusNames(names []string) ([]string, error) { + normalized := make([]string, 0, len(names)) + seen := make(map[string]struct{}, len(names)) + for _, name := range names { + name = strings.TrimSpace(name) + if name == "" { + return nil, fmt.Errorf("project status filter contains an empty value") + } + key := strings.ToLower(name) + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + normalized = append(normalized, name) + } + return normalized, nil +} + +// ListProjectStatuses returns active workspace project statuses. +func (pc *Client) ListProjectStatuses() ([]core.ProjectStatus, error) { + const query = ` + query ListProjectStatuses { + organization { + projectStatuses { + id + name + type + archivedAt + } + } + } + ` + var response struct { + Organization struct { + ProjectStatuses []core.ProjectStatus `json:"projectStatuses"` + } `json:"organization"` + } + if err := pc.base.ExecuteRequest(query, nil, &response); err != nil { + return nil, fmt.Errorf("failed to list project statuses: %w", err) + } + statuses := make([]core.ProjectStatus, 0, len(response.Organization.ProjectStatuses)) + for _, status := range response.Organization.ProjectStatuses { + if status.ArchivedAt == nil { + statuses = append(statuses, status) + } + } + return statuses, nil +} + +// ResolveProjectStatusNames resolves normalized names to active status IDs. +func (pc *Client) ResolveProjectStatusNames(names []string) ([]string, error) { + normalized, err := NormalizeStatusNames(names) + if err != nil { + return nil, err + } + if len(normalized) == 0 { + return nil, nil + } + statuses, err := pc.ListProjectStatuses() + if err != nil { + return nil, err + } + ids := make([]string, 0, len(normalized)) + for _, name := range normalized { + key := strings.ToLower(name) + var match *core.ProjectStatus + for i := range statuses { + if strings.ToLower(strings.TrimSpace(statuses[i].Name)) != key { + continue + } + if match != nil { + return nil, fmt.Errorf("project status '%s' is ambiguous", name) + } + match = &statuses[i] + } + if match == nil { + return nil, fmt.Errorf("project status '%s' not found", name) + } + ids = append(ids, match.ID) + } + return ids, nil +} + +func statusFilterMap(statusIDs []string) map[string]interface{} { + if len(statusIDs) == 0 { + return nil + } + return map[string]interface{}{ + "status": map[string]interface{}{ + "id": map[string]interface{}{"in": statusIDs}, + }, + } +} + // CreateProject creates a new project in Linear // Why: Projects are containers for organizing related issues. This method // enables project creation with proper team assignment. @@ -34,7 +134,7 @@ func (pc *Client) CreateProject(name, description, teamID string) (*core.Project if teamID == "" { return nil, &core.ValidationError{Field: "teamID", Message: "teamID cannot be empty"} } - + const mutation = ` mutation CreateProject($input: ProjectCreateInput!) { projectCreate(input: $input) { @@ -44,6 +144,11 @@ func (pc *Client) CreateProject(name, description, teamID string) (*core.Project name description state + status { + id + name + type + } createdAt updatedAt issues { @@ -57,7 +162,7 @@ func (pc *Client) CreateProject(name, description, teamID string) (*core.Project } } ` - + // Build the input object // Why: Linear's API expects specific fields. We conditionally include // description only if provided to avoid sending empty strings. @@ -69,27 +174,27 @@ func (pc *Client) CreateProject(name, description, teamID string) (*core.Project if description != "" { input["description"] = description } - + variables := map[string]interface{}{ "input": input, } - + var response struct { ProjectCreate struct { - Success bool `json:"success"` + Success bool `json:"success"` Project core.Project `json:"project"` } `json:"projectCreate"` } - + err := pc.base.ExecuteRequest(mutation, variables, &response) if err != nil { return nil, fmt.Errorf("failed to create project: %w", err) } - + if !response.ProjectCreate.Success { return nil, fmt.Errorf("project creation was not successful") } - + // Extract metadata from description if present // Why: Projects can have metadata stored in descriptions. We extract // it immediately after creation for consistent access. @@ -98,7 +203,7 @@ func (pc *Client) CreateProject(name, description, teamID string) (*core.Project response.ProjectCreate.Project.Metadata = metadata response.ProjectCreate.Project.Description = cleanDesc } - + return &response.ProjectCreate.Project, nil } @@ -112,7 +217,7 @@ func (pc *Client) GetProject(projectID string) (*core.Project, error) { if projectID == "" { return nil, &core.ValidationError{Field: "projectID", Message: "projectID cannot be empty"} } - + const query = ` query GetProject($id: String!) { project(id: $id) { @@ -121,6 +226,11 @@ func (pc *Client) GetProject(projectID string) (*core.Project, error) { description content state + status { + id + name + type + } createdAt updatedAt issues { @@ -142,15 +252,15 @@ func (pc *Client) GetProject(projectID string) (*core.Project, error) { } } ` - + variables := map[string]interface{}{ "id": projectID, } - + var response struct { Project core.Project `json:"project"` } - + err := pc.base.ExecuteRequest(query, variables, &response) if err != nil { // Check if this is a "not found" error and provide helpful guidance @@ -216,7 +326,7 @@ func (pc *Client) ListAllProjects(limit int) ([]core.Project, error) { if limit <= 0 { limit = 50 } - + const query = ` query ListProjects($first: Int) { projects(first: $first) { @@ -226,6 +336,11 @@ func (pc *Client) ListAllProjects(limit int) ([]core.Project, error) { description content state + status { + id + name + type + } createdAt updatedAt } @@ -290,6 +405,11 @@ func (pc *Client) ListByTeam(teamID string, limit int) ([]core.Project, error) { description content state + status { + id + name + type + } createdAt updatedAt } @@ -342,11 +462,11 @@ func (pc *Client) ListUserProjects(userID string, limit int) ([]core.Project, er if userID == "" { return nil, &core.ValidationError{Field: "userID", Message: "userID cannot be empty"} } - + if limit <= 0 { limit = 50 } - + const query = ` query ListUserProjects($filter: ProjectFilter, $first: Int) { projects(filter: $filter, first: $first) { @@ -356,6 +476,11 @@ func (pc *Client) ListUserProjects(userID string, limit int) ([]core.Project, er description content state + status { + id + name + type + } createdAt updatedAt issues { @@ -405,7 +530,7 @@ func (pc *Client) ListUserProjects(userID string, limit int) ([]core.Project, er // to ensure we only return projects where the user actually has assigned issues. var filteredProjects []core.Project for _, project := range response.Projects.Nodes { - if len(project.Issues.Nodes) > 0 { + if project.Issues != nil && len(project.Issues.Nodes) > 0 { filteredProjects = append(filteredProjects, project) } } @@ -427,7 +552,162 @@ func (pc *Client) ListUserProjects(userID string, limit int) ([]core.Project, er return filteredProjects, nil } -// UpdateProjectInput represents the input for updating a project +// ListAllProjectsWithStatus retrieves projects matching any of the supplied status IDs. +// The status predicate is sent to Linear so it is applied before the limit. +func (pc *Client) ListAllProjectsWithStatus(limit int, statusIDs []string) ([]core.Project, error) { + if len(statusIDs) == 0 { + return pc.ListAllProjects(limit) + } + if limit <= 0 { + limit = 50 + } + const query = ` + query ListProjects($filter: ProjectFilter, $first: Int) { + projects(filter: $filter, first: $first) { + nodes { + id + name + description + content + state + status { id name type } + createdAt + updatedAt + } + } + } + ` + var response struct { + Projects struct { + Nodes []core.Project `json:"nodes"` + } `json:"projects"` + } + variables := map[string]interface{}{"filter": statusFilterMap(statusIDs), "first": limit} + if err := pc.base.ExecuteRequest(query, variables, &response); err != nil { + return nil, fmt.Errorf("failed to list projects: %w", err) + } + pc.extractMetadata(response.Projects.Nodes) + return response.Projects.Nodes, nil +} + +// ListByTeamWithStatus retrieves team projects matching any supplied status IDs. +func (pc *Client) ListByTeamWithStatus(teamID string, limit int, statusIDs []string) ([]core.Project, error) { + if len(statusIDs) == 0 { + return pc.ListByTeam(teamID, limit) + } + if teamID == "" { + return nil, &core.ValidationError{Field: "teamID", Message: "teamID cannot be empty"} + } + if limit <= 0 { + limit = 50 + } + const query = ` + query ListProjectsByTeam($teamId: String!, $filter: ProjectFilter, $first: Int) { + team(id: $teamId) { + projects(filter: $filter, first: $first) { + nodes { + id + name + description + content + state + status { id name type } + createdAt + updatedAt + } + } + } + } + ` + var response struct { + Team struct { + Projects struct { + Nodes []core.Project `json:"nodes"` + } `json:"projects"` + } `json:"team"` + } + variables := map[string]interface{}{"teamId": teamID, "filter": statusFilterMap(statusIDs), "first": limit} + if err := pc.base.ExecuteRequest(query, variables, &response); err != nil { + return nil, fmt.Errorf("failed to list projects by team: %w", err) + } + pc.extractMetadata(response.Team.Projects.Nodes) + return response.Team.Projects.Nodes, nil +} + +// ListUserProjectsWithStatus retrieves user projects with both predicates applied server-side. +func (pc *Client) ListUserProjectsWithStatus(userID string, limit int, statusIDs []string) ([]core.Project, error) { + if len(statusIDs) == 0 { + return pc.ListUserProjects(userID, limit) + } + if userID == "" { + return nil, &core.ValidationError{Field: "userID", Message: "userID cannot be empty"} + } + if limit <= 0 { + limit = 50 + } + const query = ` + query ListUserProjects($filter: ProjectFilter, $first: Int) { + projects(filter: $filter, first: $first) { + nodes { + id + name + description + content + state + status { id name type } + createdAt + updatedAt + issues { nodes { id assignee { id } } } + } + } + } + ` + filter := map[string]interface{}{ + "issues": map[string]interface{}{"assignee": map[string]interface{}{"id": map[string]interface{}{"eq": userID}}}, + "status": map[string]interface{}{"id": map[string]interface{}{"in": statusIDs}}, + } + var response struct { + Projects struct { + Nodes []core.Project `json:"nodes"` + } `json:"projects"` + } + // Request a larger candidate page because the local assignee safeguard is + // applied after decoding; the user-visible limit is applied below. + candidateLimit := limit + if candidateLimit < 250 { + candidateLimit = 250 + } + variables := map[string]interface{}{"filter": filter, "first": candidateLimit} + if err := pc.base.ExecuteRequest(query, variables, &response); err != nil { + return nil, fmt.Errorf("failed to list user projects: %w", err) + } + filtered := make([]core.Project, 0, len(response.Projects.Nodes)) + for _, project := range response.Projects.Nodes { + if project.Issues != nil && len(project.Issues.Nodes) > 0 { + filtered = append(filtered, project) + } + } + if len(filtered) > limit { + filtered = filtered[:limit] + } + pc.extractMetadata(filtered) + return filtered, nil +} + +func (pc *Client) extractMetadata(projects []core.Project) { + for i := range projects { + if projects[i].Content != "" { + projectMetadata, cleanContent := metadata.ExtractMetadataFromDescription(projects[i].Content) + projects[i].Metadata = projectMetadata + projects[i].Content = cleanContent + } else if projects[i].Description != "" { + projectMetadata, cleanDescription := metadata.ExtractMetadataFromDescription(projects[i].Description) + projects[i].Metadata = projectMetadata + projects[i].Description = cleanDescription + } + } +} + type UpdateProjectInput struct { Name *string `json:"name,omitempty"` Description *string `json:"description,omitempty"` @@ -458,6 +738,11 @@ func (pc *Client) UpdateProject(projectID string, input UpdateProjectInput) (*co description content state + status { + id + name + type + } createdAt updatedAt } @@ -500,7 +785,7 @@ func (pc *Client) UpdateProject(projectID string, input UpdateProjectInput) (*co var response struct { ProjectUpdate struct { - Success bool `json:"success"` + Success bool `json:"success"` Project core.Project `json:"project"` } `json:"projectUpdate"` } @@ -517,60 +802,51 @@ func (pc *Client) UpdateProject(projectID string, input UpdateProjectInput) (*co return &response.ProjectUpdate.Project, nil } -// UpdateProjectState updates the state of a project -// Why: Projects have states (planned, started, completed, etc.) that need -// to be updated as work progresses. This method provides that capability. +// UpdateProjectState updates the state of a project. +// UpdateProjectStateWithResult retains the mutation's project response for +// callers that need the named status; the deprecated state write is preserved +// for compatibility and is not migrated to statusId in this ticket. func (pc *Client) UpdateProjectState(projectID, state string) error { - // Validate inputs - // Why: Both project ID and state are required. Empty values would - // cause the mutation to fail with unclear errors. + _, err := pc.UpdateProjectStateWithResult(projectID, state) + return err +} + +// UpdateProjectStateWithResult updates a project using the legacy state input +// and returns the response including its named status. +func (pc *Client) UpdateProjectStateWithResult(projectID, state string) (*core.Project, error) { if projectID == "" { - return &core.ValidationError{Field: "projectID", Message: "projectID cannot be empty"} + return nil, &core.ValidationError{Field: "projectID", Message: "projectID cannot be empty"} } if state == "" { - return &core.ValidationError{Field: "state", Message: "state cannot be empty"} + return nil, &core.ValidationError{Field: "state", Message: "state cannot be empty"} } - + const mutation = ` mutation UpdateProjectState($projectId: String!, $state: String!) { - projectUpdate( - id: $projectId, - input: { state: $state } - ) { + projectUpdate(id: $projectId, input: { state: $state }) { success project { id state + status { id name type } } } } ` - - variables := map[string]interface{}{ - "projectId": projectID, - "state": state, - } - var response struct { ProjectUpdate struct { - Success bool `json:"success"` - Project struct { - ID string `json:"id"` - State string `json:"state"` - } `json:"project"` + Success bool `json:"success"` + Project core.Project `json:"project"` } `json:"projectUpdate"` } - - err := pc.base.ExecuteRequest(mutation, variables, &response) - if err != nil { - return fmt.Errorf("failed to update project state: %w", err) + variables := map[string]interface{}{"projectId": projectID, "state": state} + if err := pc.base.ExecuteRequest(mutation, variables, &response); err != nil { + return nil, fmt.Errorf("failed to update project state: %w", err) } - if !response.ProjectUpdate.Success { - return fmt.Errorf("project state update was not successful") + return nil, fmt.Errorf("project state update was not successful") } - - return nil + return &response.ProjectUpdate.Project, nil } // UpdateProjectDescription updates a project's content while preserving metadata @@ -777,4 +1053,4 @@ func (pc *Client) RemoveProjectMetadataKey(projectID, key string) error { } return nil -} \ No newline at end of file +} diff --git a/pkg/linear/projects/client_test.go b/pkg/linear/projects/client_test.go new file mode 100644 index 0000000..2ed0517 --- /dev/null +++ b/pkg/linear/projects/client_test.go @@ -0,0 +1,75 @@ +package projects + +import ( + "encoding/json" + "net/http" + "strings" + "testing" + + "github.com/joa23/linear-cli/pkg/linear/core" + "github.com/joa23/linear-cli/pkg/linear/testutil" +) + +func TestNormalizeStatusNames(t *testing.T) { + got, err := NormalizeStatusNames([]string{" In Progress ", "on hold", "IN PROGRESS"}) + if err != nil { + t.Fatalf("NormalizeStatusNames() error = %v", err) + } + want := []string{"In Progress", "on hold"} + if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] { + t.Fatalf("NormalizeStatusNames() = %#v, want %#v", got, want) + } + + _, err = NormalizeStatusNames([]string{"In Progress", ""}) + if err == nil || !strings.Contains(err.Error(), "empty value") { + t.Fatalf("expected empty-value error, got %v", err) + } +} + +func TestProjectUnmarshalStatusPreservesStateAlias(t *testing.T) { + var project core.Project + err := json.Unmarshal([]byte(`{"id":"p1","state":"started","status":{"id":"s1","name":"On Hold","type":"started"}}`), &project) + if err != nil { + t.Fatalf("json.Unmarshal() error = %v", err) + } + if project.Status == nil || project.Status.Name != "On Hold" { + t.Fatalf("status = %#v, want named status", project.Status) + } + if project.State != "started" { + t.Fatalf("state = %q, want started", project.State) + } + if project.StatusName() != "On Hold" { + t.Fatalf("StatusName() = %q, want On Hold", project.StatusName()) + } +} + +func TestResolveProjectStatusNamesIgnoresArchivedAndRejectsUnknown(t *testing.T) { + archived := "2026-01-01T00:00:00Z" + body := testutil.NewGraphQLDataResponse(map[string]interface{}{ + "organization": map[string]interface{}{ + "projectStatuses": []map[string]interface{}{ + {"id": "active", "name": "In Progress", "type": "started"}, + {"id": "archived", "name": "Old", "type": "started", "archivedAt": archived}, + }, + }, + }) + base := core.NewBaseClient("test-token") + base.SetHTTPClient(testHTTPClient(body)) + client := NewClient(base) + + ids, err := client.ResolveProjectStatusNames([]string{" in progress "}) + if err != nil || len(ids) != 1 || ids[0] != "active" { + t.Fatalf("ResolveProjectStatusNames() = %#v, %v", ids, err) + } + base = core.NewBaseClient("test-token") + base.SetHTTPClient(testHTTPClient(body)) + client = NewClient(base) + _, err = client.ResolveProjectStatusNames([]string{"Old"}) + if err == nil || !strings.Contains(err.Error(), "not found") { + t.Fatalf("expected archived status to be unavailable, got %v", err) + } +} + +func testHTTPClient(body interface{}) *http.Client { + return &http.Client{Transport: testutil.NewSuccessTransport(body)} +} From 5744b63dbf75855542c0cfb64c5f2ce04327cd33 Mon Sep 17 00:00:00 2001 From: Felix Lisczyk <5102728+FelixLisczyk@users.noreply.github.com> Date: Thu, 6 Aug 2026 13:37:08 +0200 Subject: [PATCH 2/2] Fix project status filtering verification gaps Validate explicit empty status filters before dispatch and paginate user project queries so assignee matching happens before the visible limit. Narrow project service seams and add client, service, CLI, and mutation compatibility coverage. Co-Authored-By: Claude --- internal/cli/projects.go | 11 ++ internal/cli/projects_status_test.go | 110 +++++++++++++- internal/format/json_dtos.go | 114 +++++++-------- internal/service/client_interfaces.go | 8 +- internal/service/project.go | 28 ++-- internal/service/project_test.go | 105 ++++++++++++++ pkg/linear/client.go | 6 +- pkg/linear/projects/client.go | 201 +++++++++----------------- pkg/linear/projects/client_test.go | 145 +++++++++++++++++++ 9 files changed, 511 insertions(+), 217 deletions(-) create mode 100644 internal/service/project_test.go diff --git a/internal/cli/projects.go b/internal/cli/projects.go index 56b4ad5..b7f8405 100644 --- a/internal/cli/projects.go +++ b/internal/cli/projects.go @@ -3,9 +3,11 @@ package cli import ( "errors" "fmt" + "strings" "github.com/joa23/linear-cli/internal/format" "github.com/joa23/linear-cli/internal/service" + "github.com/joa23/linear-cli/pkg/linear/projects" "github.com/spf13/cobra" ) @@ -56,6 +58,15 @@ func newProjectsListCmd() *cobra.Command { # Output as JSON linear projects list --output json`, RunE: func(cmd *cobra.Command, args []string) error { + // An omitted flag means no status filter. Once --status is explicitly + // provided, validate every comma-separated entry before resolving teams, + // viewers, or listing projects so malformed input cannot broaden a query. + if cmd.Flags().Changed("status") { + if _, err := projects.NormalizeStatusNames(strings.Split(status, ",")); err != nil { + return err + } + } + deps, err := getDeps(cmd) if err != nil { return err diff --git a/internal/cli/projects_status_test.go b/internal/cli/projects_status_test.go index b437e85..20a98bf 100644 --- a/internal/cli/projects_status_test.go +++ b/internal/cli/projects_status_test.go @@ -1,10 +1,112 @@ package cli -import "testing" +import ( + "testing" -func TestProjectsListHasStatusFlag(t *testing.T) { - flag := newProjectsListCmd().Flags().Lookup("status") - if flag == nil { + "github.com/joa23/linear-cli/internal/format" + "github.com/joa23/linear-cli/internal/service" +) + +type projectListServiceMock struct { + mineCalls int + teamCalls int +} + +func (m *projectListServiceMock) Get(string) (string, error) { return "", nil } +func (m *projectListServiceMock) GetWithOutput(string, format.Verbosity, format.OutputType) (string, error) { + return "[]", nil +} +func (m *projectListServiceMock) ListAll(int) (string, error) { return "", nil } +func (m *projectListServiceMock) ListAllWithOutput(int, format.Verbosity, format.OutputType) (string, error) { + return "[]", nil +} +func (m *projectListServiceMock) ListAllWithStatusOutput(int, string, format.Verbosity, format.OutputType) (string, error) { + return "[]", nil +} +func (m *projectListServiceMock) ListByTeam(string, int) (string, error) { return "", nil } +func (m *projectListServiceMock) ListByTeamWithOutput(string, int, format.Verbosity, format.OutputType) (string, error) { + return "[]", nil +} +func (m *projectListServiceMock) ListByTeamWithStatusOutput(string, int, string, format.Verbosity, format.OutputType) (string, error) { + m.teamCalls++ + return "[]", nil +} +func (m *projectListServiceMock) ListUserProjects(int) (string, error) { return "", nil } +func (m *projectListServiceMock) ListUserProjectsWithOutput(int, format.Verbosity, format.OutputType) (string, error) { + m.mineCalls++ + return "[]", nil +} +func (m *projectListServiceMock) ListUserProjectsWithStatusOutput(int, string, format.Verbosity, format.OutputType) (string, error) { + m.mineCalls++ + return "[]", nil +} +func (m *projectListServiceMock) Create(*service.CreateProjectInput) (string, error) { return "", nil } +func (m *projectListServiceMock) Update(string, *service.UpdateProjectInput) (string, error) { + return "", nil +} + +func TestProjectsListExplicitEmptyStatusFailsBeforeDispatch(t *testing.T) { + mock := &projectListServiceMock{} + deps := &Dependencies{Projects: mock} + cmd := NewCmdWithDeps(deps, newProjectsListCmd) + cmd.SetArgs([]string{"--mine", "--status", ""}) + + err := cmd.Execute() + if err == nil || err.Error() != "project status filter contains an empty value" { + t.Fatalf("error = %v, want empty-value validation", err) + } + if mock.mineCalls != 0 { + t.Fatal("project list service was called for an explicit empty status") + } +} + +func TestProjectsListMalformedStatusEntriesFailBeforeDispatch(t *testing.T) { + for _, status := range []string{"", ",In Progress", "In Progress,", "In Progress,,On Hold", " "} { + t.Run(status, func(t *testing.T) { + mock := &projectListServiceMock{} + cmd := NewCmdWithDeps(&Dependencies{Projects: mock}, newProjectsListCmd) + cmd.SetArgs([]string{"--mine", "--status", status}) + + err := cmd.Execute() + if err == nil || err.Error() != "project status filter contains an empty value" { + t.Fatalf("error = %v, want empty-value validation", err) + } + if mock.mineCalls != 0 { + t.Fatal("project list service was called for malformed status") + } + }) + } +} + +func TestProjectsListOmittedStatusDispatchesMinePath(t *testing.T) { + mock := &projectListServiceMock{} + deps := &Dependencies{Projects: mock} + cmd := NewCmdWithDeps(deps, newProjectsListCmd) + cmd.SetArgs([]string{"--mine"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute() error = %v", err) + } + if mock.mineCalls != 1 { + t.Fatalf("mine calls = %d, want 1", mock.mineCalls) + } +} + +func TestProjectsListTeamStatusDispatchesTeamPath(t *testing.T) { + mock := &projectListServiceMock{} + cmd := NewCmdWithDeps(&Dependencies{Projects: mock}, newProjectsListCmd) + cmd.SetArgs([]string{"--team", "ENG", "--status", "In Progress"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute() error = %v", err) + } + if mock.teamCalls != 1 || mock.mineCalls != 0 { + t.Fatalf("dispatch = team %d, mine %d; want team path", mock.teamCalls, mock.mineCalls) + } +} + +func TestProjectsListStatusFlagExists(t *testing.T) { + if flag := newProjectsListCmd().Flags().Lookup("status"); flag == nil { t.Fatal("projects list is missing --status") } } diff --git a/internal/format/json_dtos.go b/internal/format/json_dtos.go index d0999f5..f388858 100644 --- a/internal/format/json_dtos.go +++ b/internal/format/json_dtos.go @@ -13,18 +13,18 @@ type IssueMinimalDTO struct { // IssueCompactDTO contains key metadata (~150 tokens) type IssueCompactDTO struct { - Identifier string `json:"identifier"` - Title string `json:"title"` - State string `json:"state"` - Priority *int `json:"priority"` - Assignee *string `json:"assignee"` - Delegate *string `json:"delegate,omitempty"` // OAuth app delegate - Estimate *float64 `json:"estimate"` - DueDate *string `json:"dueDate"` - CycleNumber *int `json:"cycleNumber"` - ProjectName *string `json:"projectName"` - CreatedAt string `json:"createdAt"` - UpdatedAt string `json:"updatedAt"` + Identifier string `json:"identifier"` + Title string `json:"title"` + State string `json:"state"` + Priority *int `json:"priority"` + Assignee *string `json:"assignee"` + Delegate *string `json:"delegate,omitempty"` // OAuth app delegate + Estimate *float64 `json:"estimate"` + DueDate *string `json:"dueDate"` + CycleNumber *int `json:"cycleNumber"` + ProjectName *string `json:"projectName"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` } // issueBaseFields contains the shared fields between IssueDetailedDTO and IssueFullDTO. @@ -93,48 +93,48 @@ type CycleCompactDTO struct { // CycleFullDTO contains complete cycle details type CycleFullDTO struct { - Number int `json:"number"` - Name string `json:"name"` - Status string `json:"status"` - StartsAt string `json:"startsAt"` - EndsAt string `json:"endsAt"` - Progress float64 `json:"progress"` - Description string `json:"description"` - Team *TeamDTO `json:"team"` - ScopeHistory []int `json:"scopeHistory"` - CompletedScopeHistory []int `json:"completedScopeHistory"` - InProgressScopeHistory []int `json:"inProgressScopeHistory"` - IssueCountHistory []int `json:"issueCountHistory"` - CompletedIssueCountHistory []int `json:"completedIssueCountHistory"` - CreatedAt string `json:"createdAt"` - UpdatedAt string `json:"updatedAt"` + Number int `json:"number"` + Name string `json:"name"` + Status string `json:"status"` + StartsAt string `json:"startsAt"` + EndsAt string `json:"endsAt"` + Progress float64 `json:"progress"` + Description string `json:"description"` + Team *TeamDTO `json:"team"` + ScopeHistory []int `json:"scopeHistory"` + CompletedScopeHistory []int `json:"completedScopeHistory"` + InProgressScopeHistory []int `json:"inProgressScopeHistory"` + IssueCountHistory []int `json:"issueCountHistory"` + CompletedIssueCountHistory []int `json:"completedIssueCountHistory"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` } // --- Project DTOs --- // ProjectDTO represents a project in JSON format type ProjectDTO struct { - ID string `json:"id"` - Name string `json:"name"` - Description string `json:"description"` - State string `json:"state"` + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + State string `json:"state"` Status *ProjectStatusDTO `json:"status,omitempty"` - Content string `json:"content"` - Issues []IssueRefDTO `json:"issues"` - CreatedAt string `json:"createdAt"` - UpdatedAt string `json:"updatedAt"` + Content string `json:"content"` + Issues []IssueRefDTO `json:"issues"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` } // --- Team DTOs --- // TeamDTO represents a team in JSON format type TeamDTO struct { - ID string `json:"id"` - Key string `json:"key"` - Name string `json:"name"` - Description string `json:"description"` - IssueEstimationType string `json:"issueEstimationType"` - EstimateScale *EstimateScale `json:"estimateScale"` + ID string `json:"id"` + Key string `json:"key"` + Name string `json:"name"` + Description string `json:"description"` + IssueEstimationType string `json:"issueEstimationType"` + EstimateScale *EstimateScale `json:"estimateScale"` } // EstimateScale represents the estimation scale for a team @@ -147,14 +147,14 @@ type EstimateScale struct { // UserDTO represents a user in JSON format type UserDTO struct { - ID string `json:"id"` - Name string `json:"name"` - DisplayName string `json:"displayName"` - Email string `json:"email"` - Active bool `json:"active"` - Admin bool `json:"admin"` - Teams []TeamRef `json:"teams"` - CreatedAt string `json:"createdAt"` + ID string `json:"id"` + Name string `json:"name"` + DisplayName string `json:"displayName"` + Email string `json:"email"` + Active bool `json:"active"` + Admin bool `json:"admin"` + Teams []TeamRef `json:"teams"` + CreatedAt string `json:"createdAt"` } // TeamRef is a minimal team reference @@ -167,13 +167,13 @@ type TeamRef struct { // CommentDTO represents a comment in JSON format type CommentDTO struct { - ID string `json:"id"` - Body string `json:"body"` - User *UserDTO `json:"user"` - Issue *IssueRefDTO `json:"issue"` + ID string `json:"id"` + Body string `json:"body"` + User *UserDTO `json:"user"` + Issue *IssueRefDTO `json:"issue"` Parent *CommentRefDTO `json:"parent"` - CreatedAt string `json:"createdAt"` - UpdatedAt string `json:"updatedAt"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` } // --- Reference DTOs (nested objects) --- @@ -404,8 +404,8 @@ func IssueToDetailedDTO(issue *core.Issue) IssueDetailedDTO { dto.Comments = make([]CommentSummaryDTO, len(issue.Comments.Nodes)) for i, comment := range issue.Comments.Nodes { dto.Comments[i] = CommentSummaryDTO{ - ID: comment.ID, - Body: truncate(cleanDescription(comment.Body), 200), + ID: comment.ID, + Body: truncate(cleanDescription(comment.Body), 200), User: &UserDTO{ ID: comment.User.ID, Name: comment.User.Name, diff --git a/internal/service/client_interfaces.go b/internal/service/client_interfaces.go index b2e7796..4a752f1 100644 --- a/internal/service/client_interfaces.go +++ b/internal/service/client_interfaces.go @@ -72,13 +72,13 @@ type ProjectClientOperations interface { ListUserProjectsWithStatus(userID string, limit int, statusIDs []string) ([]core.Project, error) ResolveProjectStatusNames(names []string) ([]string, error) + // Viewer and project mutation operations + GetViewer() (*core.User, error) + UpdateProject(projectID string, input projects.UpdateProjectInput) (*core.Project, error) + // Resolver operations ResolveTeamIdentifier(keyOrName string) (string, error) ResolveUserIdentifier(nameOrEmail string) (*linear.ResolvedUser, error) - - // Sub-client access (Phase 2 - use sub-clients directly) - ProjectClient() *projects.Client - TeamClient() *teams.Client } // UserClientOperations defines the minimal interface needed by UserService diff --git a/internal/service/project.go b/internal/service/project.go index 3835c80..3afa29a 100644 --- a/internal/service/project.go +++ b/internal/service/project.go @@ -117,7 +117,7 @@ func (s *ProjectService) ListUserProjects(limit int) (string, error) { } // Get current user - viewer, err := s.client.TeamClient().GetViewer() + viewer, err := s.client.GetViewer() if err != nil { return "", fmt.Errorf("failed to get current user: %w", err) } @@ -137,7 +137,7 @@ func (s *ProjectService) ListUserProjectsWithOutput(limit int, verbosity format. } // Get current user - viewer, err := s.client.TeamClient().GetViewer() + viewer, err := s.client.GetViewer() if err != nil { return "", fmt.Errorf("failed to get current user: %w", err) } @@ -155,14 +155,14 @@ func (s *ProjectService) ListByTeamWithStatusOutput(teamID string, limit int, st if limit <= 0 { limit = 50 } - resolvedTeamID, err := s.client.ResolveTeamIdentifier(teamID) - if err != nil { - return "", fmt.Errorf("failed to resolve team '%s': %w", teamID, err) - } statusIDs, err := s.resolveStatusIDs(status) if err != nil { return "", err } + resolvedTeamID, err := s.client.ResolveTeamIdentifier(teamID) + if err != nil { + return "", fmt.Errorf("failed to resolve team '%s': %w", teamID, err) + } projects, err := s.client.ListByTeamWithStatus(resolvedTeamID, limit, statusIDs) if err != nil { return "", fmt.Errorf("failed to list projects by team: %w", err) @@ -175,14 +175,14 @@ func (s *ProjectService) ListUserProjectsWithStatusOutput(limit int, status stri if limit <= 0 { limit = 50 } - viewer, err := s.client.TeamClient().GetViewer() - if err != nil { - return "", fmt.Errorf("failed to get current user: %w", err) - } statusIDs, err := s.resolveStatusIDs(status) if err != nil { return "", err } + viewer, err := s.client.GetViewer() + if err != nil { + return "", fmt.Errorf("failed to get current user: %w", err) + } projects, err := s.client.ListUserProjectsWithStatus(viewer.ID, limit, statusIDs) if err != nil { return "", fmt.Errorf("failed to list user projects: %w", err) @@ -316,7 +316,7 @@ func (s *ProjectService) Update(projectID string, input *UpdateProjectInput) (st } // Update project - project, err := s.client.ProjectClient().UpdateProject(projectID, linearInput) + project, err := s.client.UpdateProject(projectID, linearInput) if err != nil { return "", fmt.Errorf("failed to update project: %w", err) } @@ -328,9 +328,5 @@ func (s *ProjectService) resolveStatusIDs(raw string) ([]string, error) { if raw == "" { return nil, nil } - names, err := projects.NormalizeStatusNames(strings.Split(raw, ",")) - if err != nil { - return nil, err - } - return s.client.ResolveProjectStatusNames(names) + return s.client.ResolveProjectStatusNames(strings.Split(raw, ",")) } diff --git a/internal/service/project_test.go b/internal/service/project_test.go new file mode 100644 index 0000000..d931bd5 --- /dev/null +++ b/internal/service/project_test.go @@ -0,0 +1,105 @@ +package service + +import ( + "errors" + "testing" + + "github.com/joa23/linear-cli/internal/format" + "github.com/joa23/linear-cli/pkg/linear" + "github.com/joa23/linear-cli/pkg/linear/core" + "github.com/joa23/linear-cli/pkg/linear/projects" +) + +type projectServiceMock struct { + resolvedTeam string + viewer *core.User + statusIDs []string + statusNames []string + projects []core.Project + teamCalls int + userCalls int + allCalls int + resolveErr error +} + +func (m *projectServiceMock) CreateProject(string, string, string) (*core.Project, error) { + return nil, nil +} +func (m *projectServiceMock) GetProject(string) (*core.Project, error) { return nil, nil } +func (m *projectServiceMock) ListAllProjectsWithStatus(_ int, ids []string) ([]core.Project, error) { + m.allCalls++ + m.statusIDs = append([]string(nil), ids...) + return m.projects, nil +} +func (m *projectServiceMock) ListByTeamWithStatus(_ string, _ int, ids []string) ([]core.Project, error) { + m.teamCalls++ + m.statusIDs = append([]string(nil), ids...) + return m.projects, nil +} +func (m *projectServiceMock) ListUserProjectsWithStatus(_ string, _ int, ids []string) ([]core.Project, error) { + m.userCalls++ + m.statusIDs = append([]string(nil), ids...) + return m.projects, nil +} +func (m *projectServiceMock) ResolveProjectStatusNames(names []string) ([]string, error) { + normalized, err := projects.NormalizeStatusNames(names) + if err != nil { + return nil, err + } + m.statusNames = append([]string(nil), normalized...) + if m.resolveErr != nil { + return nil, m.resolveErr + } + return []string{"status-1", "status-2"}, nil +} +func (m *projectServiceMock) GetViewer() (*core.User, error) { return m.viewer, nil } +func (m *projectServiceMock) UpdateProject(string, projects.UpdateProjectInput) (*core.Project, error) { + return nil, nil +} +func (m *projectServiceMock) ResolveTeamIdentifier(string) (string, error) { + return m.resolvedTeam, nil +} +func (m *projectServiceMock) ResolveUserIdentifier(string) (*linear.ResolvedUser, error) { + return &linear.ResolvedUser{ID: "user-1"}, nil +} + +func TestProjectServiceNormalizesStatusAndDispatchesTeamList(t *testing.T) { + mock := &projectServiceMock{resolvedTeam: "team-1", projects: []core.Project{{ID: "p1", Name: "Project"}}} + svc := NewProjectService(mock, format.New()) + + _, err := svc.ListByTeamWithStatusOutput("ENG", 2, " In Progress,On Hold,in progress ", format.VerbosityCompact, format.OutputText) + if err != nil { + t.Fatalf("ListByTeamWithStatusOutput() error = %v", err) + } + if mock.teamCalls != 1 || len(mock.statusIDs) != 2 || mock.statusIDs[0] != "status-1" { + t.Fatalf("team dispatch = calls %d, IDs %#v", mock.teamCalls, mock.statusIDs) + } + if len(mock.statusNames) != 2 || mock.statusNames[0] != "In Progress" || mock.statusNames[1] != "On Hold" { + t.Fatalf("resolved names = %#v", mock.statusNames) + } +} + +func TestProjectServiceMineUsesViewerAndPropagatesStatusErrors(t *testing.T) { + mock := &projectServiceMock{viewer: &core.User{ID: "viewer-1"}, resolveErr: errors.New("ambiguous")} + svc := NewProjectService(mock, format.New()) + + _, err := svc.ListUserProjectsWithStatusOutput(1, "In Progress", format.VerbosityCompact, format.OutputJSON) + if err == nil || err.Error() != "ambiguous" { + t.Fatalf("error = %v, want ambiguous", err) + } + if mock.userCalls != 0 { + t.Fatal("project list called after status resolution failed") + } +} + +func TestProjectServiceUnfilteredListPreservesNilStatusIDs(t *testing.T) { + mock := &projectServiceMock{resolvedTeam: "team-1", projects: []core.Project{{ID: "p1", Name: "Project"}}} + svc := NewProjectService(mock, format.New()) + + if _, err := svc.ListAllWithStatusOutput(1, "", format.VerbosityCompact, format.OutputJSON); err != nil { + t.Fatalf("ListAllWithStatusOutput() error = %v", err) + } + if mock.allCalls != 1 || mock.statusIDs != nil { + t.Fatalf("unfiltered dispatch = calls %d, IDs %#v", mock.allCalls, mock.statusIDs) + } +} diff --git a/pkg/linear/client.go b/pkg/linear/client.go index 7b82109..82f0bc4 100644 --- a/pkg/linear/client.go +++ b/pkg/linear/client.go @@ -486,10 +486,8 @@ func (c *Client) ResolveProjectStatusNames(names []string) ([]string, error) { return c.Projects.ResolveProjectStatusNames(names) } -func (c *Client) UpdateProject(projectID string, input interface{}) (*core.Project, error) { - // Convert interface{} to the actual type expected by Projects client - // This is a temporary solution for Phase 1 to maintain flexibility - return c.Projects.UpdateProject(projectID, input.(projects.UpdateProjectInput)) +func (c *Client) UpdateProject(projectID string, input projects.UpdateProjectInput) (*core.Project, error) { + return c.Projects.UpdateProject(projectID, input) } func (c *Client) UpdateProjectState(projectID, state string) error { diff --git a/pkg/linear/projects/client.go b/pkg/linear/projects/client.go index 599c358..68c9dca 100644 --- a/pkg/linear/projects/client.go +++ b/pkg/linear/projects/client.go @@ -21,11 +21,6 @@ func NewClient(base *core.BaseClient) *Client { return &Client{base: base} } -// StatusFilter contains resolved project status IDs used by server-side list filters. -type StatusFilter struct { - IDs []string -} - // NormalizeStatusNames trims, validates, and de-duplicates project status names. func NormalizeStatusNames(names []string) ([]string, error) { normalized := make([]string, 0, len(names)) @@ -452,104 +447,10 @@ func (pc *Client) ListByTeam(teamID string, limit int) ([]core.Project, error) { return response.Team.Projects.Nodes, nil } -// ListUserProjects retrieves projects that have issues assigned to a specific user -// Why: Users often want to see only projects they're actively working on. -// This method filters projects based on issue assignments. +// ListUserProjects retrieves projects that have issues assigned to a specific user. +// The visible limit is applied after the assignee predicate has been verified. func (pc *Client) ListUserProjects(userID string, limit int) ([]core.Project, error) { - // Validate input - // Why: User ID is required for filtering. Without it, we can't - // determine which projects to return. - if userID == "" { - return nil, &core.ValidationError{Field: "userID", Message: "userID cannot be empty"} - } - - if limit <= 0 { - limit = 50 - } - - const query = ` - query ListUserProjects($filter: ProjectFilter, $first: Int) { - projects(filter: $filter, first: $first) { - nodes { - id - name - description - content - state - status { - id - name - type - } - createdAt - updatedAt - issues { - nodes { - id - assignee { - id - } - } - } - } - } - } - ` - - // Filter for projects with issues assigned to the user - // Why: Linear doesn't have direct user-project relationships. - // We filter through issues to find projects the user is working on. - filter := map[string]interface{}{ - "issues": map[string]interface{}{ - "assignee": map[string]interface{}{ - "id": map[string]interface{}{ - "eq": userID, - }, - }, - }, - } - - variables := map[string]interface{}{ - "filter": filter, - "first": limit, - } - - var response struct { - Projects struct { - Nodes []core.Project `json:"nodes"` - } `json:"projects"` - } - - err := pc.base.ExecuteRequest(query, variables, &response) - if err != nil { - return nil, fmt.Errorf("failed to list user projects: %w", err) - } - - // Filter projects to only include those with issues assigned to the user - // Why: The API filter may not work as expected in all cases, and we need - // to ensure we only return projects where the user actually has assigned issues. - var filteredProjects []core.Project - for _, project := range response.Projects.Nodes { - if project.Issues != nil && len(project.Issues.Nodes) > 0 { - filteredProjects = append(filteredProjects, project) - } - } - - // Extract metadata from content (or fallback to description) - for i := range filteredProjects { - if filteredProjects[i].Content != "" { - metadata, cleanContent := metadata.ExtractMetadataFromDescription(filteredProjects[i].Content) - filteredProjects[i].Metadata = metadata - filteredProjects[i].Content = cleanContent - } else if filteredProjects[i].Description != "" { - // Fallback to description for backwards compatibility - metadata, cleanDesc := metadata.ExtractMetadataFromDescription(filteredProjects[i].Description) - filteredProjects[i].Metadata = metadata - filteredProjects[i].Description = cleanDesc - } - } - - return filteredProjects, nil + return pc.listUserProjects(userID, limit, nil) } // ListAllProjectsWithStatus retrieves projects matching any of the supplied status IDs. @@ -636,18 +537,20 @@ func (pc *Client) ListByTeamWithStatus(teamID string, limit int, statusIDs []str // ListUserProjectsWithStatus retrieves user projects with both predicates applied server-side. func (pc *Client) ListUserProjectsWithStatus(userID string, limit int, statusIDs []string) ([]core.Project, error) { - if len(statusIDs) == 0 { - return pc.ListUserProjects(userID, limit) - } + return pc.listUserProjects(userID, limit, statusIDs) +} + +func (pc *Client) listUserProjects(userID string, limit int, statusIDs []string) ([]core.Project, error) { if userID == "" { return nil, &core.ValidationError{Field: "userID", Message: "userID cannot be empty"} } if limit <= 0 { limit = 50 } + const query = ` - query ListUserProjects($filter: ProjectFilter, $first: Int) { - projects(filter: $filter, first: $first) { + query ListUserProjects($filter: ProjectFilter, $issueFilter: IssueFilter, $first: Int, $after: String) { + projects(filter: $filter, first: $first, after: $after) { nodes { id name @@ -657,38 +560,63 @@ func (pc *Client) ListUserProjectsWithStatus(userID string, limit int, statusIDs status { id name type } createdAt updatedAt - issues { nodes { id assignee { id } } } + issues(filter: $issueFilter, first: 1) { nodes { id assignee { id } } } } + pageInfo { hasNextPage endCursor } } } ` + + issueFilter := map[string]interface{}{"assignee": map[string]interface{}{"id": map[string]interface{}{"eq": userID}}} filter := map[string]interface{}{ - "issues": map[string]interface{}{"assignee": map[string]interface{}{"id": map[string]interface{}{"eq": userID}}}, - "status": map[string]interface{}{"id": map[string]interface{}{"in": statusIDs}}, + "issues": map[string]interface{}{"some": issueFilter}, } - var response struct { - Projects struct { - Nodes []core.Project `json:"nodes"` - } `json:"projects"` + if len(statusIDs) > 0 { + filter["status"] = map[string]interface{}{"id": map[string]interface{}{"in": statusIDs}} } - // Request a larger candidate page because the local assignee safeguard is - // applied after decoding; the user-visible limit is applied below. - candidateLimit := limit - if candidateLimit < 250 { - candidateLimit = 250 - } - variables := map[string]interface{}{"filter": filter, "first": candidateLimit} - if err := pc.base.ExecuteRequest(query, variables, &response); err != nil { - return nil, fmt.Errorf("failed to list user projects: %w", err) - } - filtered := make([]core.Project, 0, len(response.Projects.Nodes)) - for _, project := range response.Projects.Nodes { - if project.Issues != nil && len(project.Issues.Nodes) > 0 { - filtered = append(filtered, project) + + const pageSize = 250 + filtered := make([]core.Project, 0, limit) + seenCursors := map[string]struct{}{} + var after string + for { + variables := map[string]interface{}{"filter": filter, "issueFilter": issueFilter, "first": pageSize} + if after != "" { + variables["after"] = after } - } - if len(filtered) > limit { - filtered = filtered[:limit] + var response struct { + Projects struct { + Nodes []core.Project `json:"nodes"` + PageInfo struct { + HasNextPage bool `json:"hasNextPage"` + EndCursor string `json:"endCursor"` + } `json:"pageInfo"` + } `json:"projects"` + } + if err := pc.base.ExecuteRequest(query, variables, &response); err != nil { + return nil, fmt.Errorf("failed to list user projects: %w", err) + } + for _, project := range response.Projects.Nodes { + if project.Issues != nil && len(project.Issues.Nodes) > 0 { + filtered = append(filtered, project) + if len(filtered) == limit { + pc.extractMetadata(filtered) + return filtered, nil + } + } + } + if !response.Projects.PageInfo.HasNextPage { + break + } + next := response.Projects.PageInfo.EndCursor + if next == "" { + return nil, fmt.Errorf("failed to list user projects: pagination returned an empty cursor") + } + if _, ok := seenCursors[next]; ok { + return nil, fmt.Errorf("failed to list user projects: pagination returned a repeated cursor") + } + seenCursors[next] = struct{}{} + after = next } pc.extractMetadata(filtered) return filtered, nil @@ -708,6 +636,11 @@ func (pc *Client) extractMetadata(projects []core.Project) { } } +// UpdateProjectInput contains the legacy project write fields used by this client. +// The checked-in Linear schema exposes statusId, not state, on ProjectUpdateInput. +// State is intentionally retained for compatibility with the existing --state +// surface; this ticket does not change that write semantics or infer a named +// status because multiple named statuses may share one status type. type UpdateProjectInput struct { Name *string `json:"name,omitempty"` Description *string `json:"description,omitempty"` @@ -812,7 +745,11 @@ func (pc *Client) UpdateProjectState(projectID, state string) error { } // UpdateProjectStateWithResult updates a project using the legacy state input -// and returns the response including its named status. +// and returns the response including its named status. The current checked-in +// schema declares statusId rather than state for ProjectUpdateInput; the legacy +// state write is deliberately preserved and may be rejected by newer APIs. +// A type such as started cannot be converted safely to statusId because several +// named statuses can share the same type. func (pc *Client) UpdateProjectStateWithResult(projectID, state string) (*core.Project, error) { if projectID == "" { return nil, &core.ValidationError{Field: "projectID", Message: "projectID cannot be empty"} diff --git a/pkg/linear/projects/client_test.go b/pkg/linear/projects/client_test.go index 2ed0517..df5197f 100644 --- a/pkg/linear/projects/client_test.go +++ b/pkg/linear/projects/client_test.go @@ -1,7 +1,9 @@ package projects import ( + "bytes" "encoding/json" + "io" "net/http" "strings" "testing" @@ -70,6 +72,149 @@ func TestResolveProjectStatusNamesIgnoresArchivedAndRejectsUnknown(t *testing.T) } } +func TestListAllProjectsWithStatusSendsORStatusFilter(t *testing.T) { + transport := &sequentialTransport{responses: []interface{}{testutil.NewGraphQLDataResponse(map[string]interface{}{ + "projects": map[string]interface{}{"nodes": []map[string]interface{}{ + {"id": "p1", "state": "started", "status": map[string]interface{}{"id": "s1", "name": "In Progress", "type": "started"}}, + }}, + })}} + base := core.NewBaseClient("test-token") + base.SetHTTPClient(&http.Client{Transport: transport}) + client := NewClient(base) + + if _, err := client.ListAllProjectsWithStatus(3, []string{"s1", "s2"}); err != nil { + t.Fatalf("ListAllProjectsWithStatus() error = %v", err) + } + var request struct { + Variables map[string]interface{} `json:"variables"` + } + if err := json.Unmarshal([]byte(transport.requests[0]), &request); err != nil { + t.Fatalf("request JSON error = %v", err) + } + filter := request.Variables["filter"].(map[string]interface{}) + status := filter["status"].(map[string]interface{}) + ids := status["id"].(map[string]interface{})["in"].([]interface{}) + if len(ids) != 2 || ids[0] != "s1" || ids[1] != "s2" { + t.Fatalf("status IDs = %#v, want s1,s2", ids) + } +} + +func TestResolveProjectStatusNamesRejectsAmbiguousActiveNames(t *testing.T) { + body := testutil.NewGraphQLDataResponse(map[string]interface{}{ + "organization": map[string]interface{}{ + "projectStatuses": []map[string]interface{}{ + {"id": "s1", "name": "Started", "type": "started"}, + {"id": "s2", "name": " started ", "type": "started"}, + }, + }, + }) + base := core.NewBaseClient("test-token") + base.SetHTTPClient(withResponses(body)) + client := NewClient(base) + _, err := client.ResolveProjectStatusNames([]string{"started"}) + if err == nil || !strings.Contains(err.Error(), "ambiguous") { + t.Fatalf("expected ambiguous status error, got %v", err) + } +} + +func TestListUserProjectsPaginatesBeforeApplyingLimit(t *testing.T) { + transport := &sequentialTransport{responses: []interface{}{ + testutil.NewGraphQLDataResponse(map[string]interface{}{ + "projects": map[string]interface{}{ + "nodes": []map[string]interface{}{ + {"id": "p1", "name": "First", "issues": map[string]interface{}{"nodes": []map[string]interface{}{{"id": "i1", "assignee": map[string]interface{}{"id": "u1"}}}}}, + {"id": "p2", "name": "Not Mine", "issues": map[string]interface{}{"nodes": []interface{}{}}}, + }, + "pageInfo": map[string]interface{}{"hasNextPage": true, "endCursor": "cursor-1"}, + }, + }), + testutil.NewGraphQLDataResponse(map[string]interface{}{ + "projects": map[string]interface{}{ + "nodes": []map[string]interface{}{ + {"id": "p3", "name": "Third", "issues": map[string]interface{}{"nodes": []map[string]interface{}{{"id": "i3", "assignee": map[string]interface{}{"id": "u1"}}}}}, + }, + "pageInfo": map[string]interface{}{"hasNextPage": false}, + }, + }), + }} + base := core.NewBaseClient("test-token") + base.SetHTTPClient(&http.Client{Transport: transport}) + client := NewClient(base) + + got, err := client.ListUserProjects("u1", 2) + if err != nil { + t.Fatalf("ListUserProjects() error = %v", err) + } + if len(got) != 2 || got[0].ID != "p1" || got[1].ID != "p3" { + t.Fatalf("projects = %#v, want p1 then p3", got) + } + if len(transport.requests) != 2 { + t.Fatalf("requests = %d, want 2", len(transport.requests)) + } + if !strings.Contains(transport.requests[1], `"after":"cursor-1"`) { + t.Fatalf("second request = %s, missing cursor", transport.requests[1]) + } +} + +func TestUpdateProjectStateWithResultKeepsLegacyVariablesAndDecodesStatus(t *testing.T) { + transport := &sequentialTransport{responses: []interface{}{testutil.NewGraphQLDataResponse(map[string]interface{}{ + "projectUpdate": map[string]interface{}{ + "success": true, + "project": map[string]interface{}{ + "id": "p1", "state": "started", + "status": map[string]interface{}{"id": "s1", "name": "In Progress", "type": "started"}, + }, + }, + })}} + base := core.NewBaseClient("test-token") + base.SetHTTPClient(&http.Client{Transport: transport}) + client := NewClient(base) + + project, err := client.UpdateProjectStateWithResult("p1", "started") + if err != nil { + t.Fatalf("UpdateProjectStateWithResult() error = %v", err) + } + if project.Status == nil || project.Status.Name != "In Progress" || project.State != "started" { + t.Fatalf("project = %#v, want named status and legacy state", project) + } + var request struct { + Query string `json:"query"` + Variables map[string]interface{} `json:"variables"` + } + if err := json.Unmarshal([]byte(transport.requests[0]), &request); err != nil { + t.Fatalf("request JSON error = %v", err) + } + if !strings.Contains(request.Query, "input: { state: $state }") || request.Variables["state"] != "started" { + t.Fatalf("request = %#v, want legacy state mutation", request) + } +} + +type sequentialTransport struct { + responses []interface{} + requests []string +} + +func (t *sequentialTransport) RoundTrip(req *http.Request) (*http.Response, error) { + body, err := io.ReadAll(req.Body) + if err != nil { + return nil, err + } + t.requests = append(t.requests, string(body)) + response := t.responses[0] + if len(t.responses) > 1 { + t.responses = t.responses[1:] + } + encoded, err := json.Marshal(response) + if err != nil { + return nil, err + } + return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(bytes.NewReader(encoded)), Header: make(http.Header)}, nil +} + func testHTTPClient(body interface{}) *http.Client { return &http.Client{Transport: testutil.NewSuccessTransport(body)} } + +func withResponses(body interface{}) *http.Client { + return &http.Client{Transport: &sequentialTransport{responses: []interface{}{body}}} +}