Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 18 additions & 2 deletions internal/cli/projects.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand All @@ -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{
Expand All @@ -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
Expand All @@ -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 == "" {
Expand All @@ -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)
Expand All @@ -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")

Expand Down
112 changes: 112 additions & 0 deletions internal/cli/projects_status_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
135 changes: 77 additions & 58 deletions internal/format/json_dtos.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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) ---
Expand All @@ -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"`
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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()
Expand Down
Loading