diff --git a/internal/cli/projects.go b/internal/cli/projects.go index 376beed..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" ) @@ -31,6 +33,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,9 +52,21 @@ 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 { + // 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 @@ -76,7 +91,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 +101,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 +115,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..20a98bf --- /dev/null +++ b/internal/cli/projects_status_test.go @@ -0,0 +1,112 @@ +package cli + +import ( + "testing" + + "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 288c60c..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,47 +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"` - Content string `json:"content"` - Issues []IssueRefDTO `json:"issues"` - CreatedAt string `json:"createdAt"` - UpdatedAt string `json:"updatedAt"` + 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"` } // --- 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 @@ -146,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 @@ -166,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) --- @@ -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"` @@ -396,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, @@ -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..4a752f1 100644 --- a/internal/service/client_interfaces.go +++ b/internal/service/client_interfaces.go @@ -65,13 +65,20 @@ 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) + + // 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/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..3afa29a 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) } @@ -116,12 +117,12 @@ 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) } - 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) } @@ -136,12 +137,12 @@ 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) } - 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 + } + 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) + } + 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 + } + 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) + } + 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) } @@ -260,10 +316,17 @@ 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) } return s.formatter.Project(project), nil } + +func (s *ProjectService) resolveStatusIDs(raw string) ([]string, error) { + if raw == "" { + return nil, nil + } + 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 c20f8d0..82f0bc4 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,16 +461,43 @@ func (c *Client) ListUserProjects(userID string, limit int) ([]core.Project, err return c.Projects.ListUserProjects(userID, limit) } -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) 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 projects.UpdateProjectInput) (*core.Project, error) { + return c.Projects.UpdateProject(projectID, input) } 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..68c9dca 100644 --- a/pkg/linear/projects/client.go +++ b/pkg/linear/projects/client.go @@ -21,6 +21,101 @@ func NewClient(base *core.BaseClient) *Client { return &Client{base: base} } +// 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 +129,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 +139,11 @@ func (pc *Client) CreateProject(name, description, teamID string) (*core.Project name description state + status { + id + name + type + } createdAt updatedAt issues { @@ -57,7 +157,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 +169,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 +198,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 +212,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 +221,11 @@ func (pc *Client) GetProject(projectID string) (*core.Project, error) { description content state + status { + id + name + type + } createdAt updatedAt issues { @@ -142,15 +247,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 +321,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 +331,11 @@ func (pc *Client) ListAllProjects(limit int) ([]core.Project, error) { description content state + status { + id + name + type + } createdAt updatedAt } @@ -290,6 +400,11 @@ func (pc *Client) ListByTeam(teamID string, limit int) ([]core.Project, error) { description content state + status { + id + name + type + } createdAt updatedAt } @@ -332,23 +447,23 @@ 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"} + return pc.listUserProjects(userID, limit, nil) +} + +// 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 ListUserProjects($filter: ProjectFilter, $first: Int) { + query ListProjects($filter: ProjectFilter, $first: Int) { projects(filter: $filter, first: $first) { nodes { id @@ -356,78 +471,176 @@ func (pc *Client) ListUserProjects(userID string, limit int) ([]core.Project, er 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"` } + 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 +} - err := pc.base.ExecuteRequest(query, variables, &response) - if err != nil { - return nil, fmt.Errorf("failed to list user projects: %w", err) +// 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) { + 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 } - // 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 len(project.Issues.Nodes) > 0 { - filteredProjects = append(filteredProjects, project) + const query = ` + query ListUserProjects($filter: ProjectFilter, $issueFilter: IssueFilter, $first: Int, $after: String) { + projects(filter: $filter, first: $first, after: $after) { + nodes { + id + name + description + content + state + status { id name type } + createdAt + updatedAt + 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{}{"some": issueFilter}, + } + if len(statusIDs) > 0 { + filter["status"] = map[string]interface{}{"id": map[string]interface{}{"in": statusIDs}} } - // 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 + 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 + } + 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 +} - return filteredProjects, 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 + } + } } -// UpdateProjectInput represents the input for updating a 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"` @@ -458,6 +671,11 @@ func (pc *Client) UpdateProject(projectID string, input UpdateProjectInput) (*co description content state + status { + id + name + type + } createdAt updatedAt } @@ -500,7 +718,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 +735,55 @@ 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. 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 &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 +990,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..df5197f --- /dev/null +++ b/pkg/linear/projects/client_test.go @@ -0,0 +1,220 @@ +package projects + +import ( + "bytes" + "encoding/json" + "io" + "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 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}}} +}