diff --git a/cmd/issue.go b/cmd/issue.go index f9e35ef..c6798c9 100644 --- a/cmd/issue.go +++ b/cmd/issue.go @@ -1,10 +1,11 @@ package cmd import ( - "context" - "fmt" - "os" - "strings" + "context" + "fmt" + "os" + "strings" + "regexp" "github.com/dorkitude/linctl/pkg/api" "github.com/dorkitude/linctl/pkg/auth" @@ -15,6 +16,36 @@ import ( "github.com/spf13/viper" ) +var uuidRegexp = regexp.MustCompile(`^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}$`) + +func isValidUUID(s string) bool { return uuidRegexp.MatchString(s) } + +func isProjectNotFoundErr(err error) bool { + if err == nil { return false } + e := strings.ToLower(err.Error()) + if !strings.Contains(e, "not found") { return false } + return strings.Contains(e, "project") || strings.Contains(e, "projectid") +} + +// buildProjectInput normalizes a --project flag value to a GraphQL input value. +// Returns (value, ok, err): +// - ok=false means no input should be set (flag empty / not provided) +// - value=nil with ok=true means explicitly unset (unassigned) +// - value=string (uuid) with ok=true means assign to that project +func buildProjectInput(projectFlag string) (interface{}, bool, error) { + switch strings.TrimSpace(projectFlag) { + case "": + return nil, false, nil + case "unassigned": + return nil, true, nil + default: + if !isValidUUID(projectFlag) { + return nil, false, fmt.Errorf("Invalid project ID format: %s", projectFlag) + } + return projectFlag, true, nil + } +} + // issueCmd represents the issue command var issueCmd = &cobra.Command{ Use: "issue", @@ -111,6 +142,9 @@ func renderIssueCollection(issues *api.Issues, plaintext, jsonOut bool, emptyMes if issue.Team != nil { fmt.Printf("- **Team**: %s\n", issue.Team.Key) } + if issue.Project != nil { + fmt.Printf("- **Project**: %s\n", issue.Project.Name) + } fmt.Printf("- **Created**: %s\n", issue.CreatedAt.Format("2006-01-02")) fmt.Printf("- **URL**: %s\n", issue.URL) if issue.Description != "" { @@ -122,7 +156,7 @@ func renderIssueCollection(issues *api.Issues, plaintext, jsonOut bool, emptyMes return } - headers := []string{"Title", "State", "Assignee", "Team", "Created", "URL"} + headers := []string{"Title", "State", "Assignee", "Team", "Project", "Created", "URL"} rows := make([][]string, len(issues.Nodes)) for i, issue := range issues.Nodes { @@ -136,6 +170,11 @@ func renderIssueCollection(issues *api.Issues, plaintext, jsonOut bool, emptyMes team = issue.Team.Key } + project := "" + if issue.Project != nil { + project = truncateString(issue.Project.Name, 25) + } + state := "" if issue.State != nil { state = issue.State.Name @@ -168,6 +207,7 @@ func renderIssueCollection(issues *api.Issues, plaintext, jsonOut bool, emptyMes state, assignee, team, + project, issue.CreatedAt.Format("2006-01-02"), issue.URL, } @@ -890,17 +930,42 @@ var issueCreateCmd = &cobra.Command{ input["assigneeId"] = viewer.ID } + // Handle project assignment + if cmd.Flags().Changed("project") { + projectID, _ := cmd.Flags().GetString("project") + if val, ok, err := buildProjectInput(projectID); err != nil { + output.Error(err.Error(), plaintext, jsonOut) + os.Exit(1) + } else if ok { + // For create, "unassigned" is equivalent to not setting project + if val != nil { + input["projectId"] = val + } + } + } + // Create issue - issue, err := client.CreateIssue(context.Background(), input) - if err != nil { - output.Error(fmt.Sprintf("Failed to create issue: %v", err), plaintext, jsonOut) - os.Exit(1) - } + issue, err := client.CreateIssue(context.Background(), input) + if err != nil { + // Standardize project not-found error when a project was provided + if cmd.Flags().Changed("project") { + projectID, _ := cmd.Flags().GetString("project") + if projectID != "" && projectID != "unassigned" && isProjectNotFoundErr(err) { + output.Error(fmt.Sprintf("Project '%s' not found", projectID), plaintext, jsonOut) + os.Exit(1) + } + } + output.Error(fmt.Sprintf("Failed to create issue: %v", err), plaintext, jsonOut) + os.Exit(1) + } if jsonOut { output.JSON(issue) } else if plaintext { fmt.Printf("Created issue %s: %s\n", issue.Identifier, issue.Title) + if issue.Project != nil { + fmt.Printf("Project: %s\n", issue.Project.Name) + } } else { fmt.Printf("%s Created issue %s: %s\n", color.New(color.FgGreen).Sprint("✓"), @@ -909,6 +974,9 @@ var issueCreateCmd = &cobra.Command{ if issue.Assignee != nil { fmt.Printf(" Assigned to: %s\n", color.New(color.FgCyan).Sprint(issue.Assignee.Name)) } + if issue.Project != nil { + fmt.Printf(" Project: %s\n", color.New(color.FgBlue).Sprint(issue.Project.Name)) + } } }, } @@ -1049,6 +1117,17 @@ Examples: } } + // Handle project assignment update + if cmd.Flags().Changed("project") { + projectID, _ := cmd.Flags().GetString("project") + if val, ok, err := buildProjectInput(projectID); err != nil { + output.Error(err.Error(), plaintext, jsonOut) + os.Exit(1) + } else if ok { + input["projectId"] = val + } + } + // Check if any updates were specified if len(input) == 0 { output.Error("No updates specified. Use flags to specify what to update.", plaintext, jsonOut) @@ -1056,11 +1135,19 @@ Examples: } // Update the issue - issue, err := client.UpdateIssue(context.Background(), args[0], input) - if err != nil { - output.Error(fmt.Sprintf("Failed to update issue: %v", err), plaintext, jsonOut) - os.Exit(1) - } + issue, err := client.UpdateIssue(context.Background(), args[0], input) + if err != nil { + // Standardize project not-found error when a project was provided + if cmd.Flags().Changed("project") { + projectID, _ := cmd.Flags().GetString("project") + if projectID != "" && projectID != "unassigned" && isProjectNotFoundErr(err) { + output.Error(fmt.Sprintf("Project '%s' not found", projectID), plaintext, jsonOut) + os.Exit(1) + } + } + output.Error(fmt.Sprintf("Failed to update issue: %v", err), plaintext, jsonOut) + os.Exit(1) + } if jsonOut { output.JSON(issue) @@ -1108,6 +1195,7 @@ func init() { issueCreateCmd.Flags().StringP("team", "t", "", "Team key (required)") issueCreateCmd.Flags().Int("priority", 3, "Priority (0=None, 1=Urgent, 2=High, 3=Normal, 4=Low)") issueCreateCmd.Flags().BoolP("assign-me", "m", false, "Assign to yourself") + issueCreateCmd.Flags().String("project", "", "Project ID to assign issue to") _ = issueCreateCmd.MarkFlagRequired("title") _ = issueCreateCmd.MarkFlagRequired("team") @@ -1118,4 +1206,5 @@ func init() { issueUpdateCmd.Flags().StringP("state", "s", "", "State name (e.g., 'Todo', 'In Progress', 'Done')") issueUpdateCmd.Flags().Int("priority", -1, "Priority (0=None, 1=Urgent, 2=High, 3=Normal, 4=Low)") issueUpdateCmd.Flags().String("due-date", "", "Due date (YYYY-MM-DD format, or empty to remove)") + issueUpdateCmd.Flags().String("project", "", "Project ID to assign issue to (or 'unassigned' to remove)") } diff --git a/cmd/issue_cobra_test.go b/cmd/issue_cobra_test.go new file mode 100644 index 0000000..9889e1d --- /dev/null +++ b/cmd/issue_cobra_test.go @@ -0,0 +1,52 @@ +package cmd + +import "testing" + +// These tests exercise Cobra flag parsing on the real command objects +// without invoking the Run functions (no network/API side-effects). + +func TestIssueCreateCmd_ProjectFlag_Parsing(t *testing.T) { + // Ensure the flag exists + f := issueCreateCmd.Flags().Lookup("project") + if f == nil { + t.Fatalf("expected --project flag on issueCreateCmd") + } + + // Parse and read back + uuid := "123e4567-e89b-12d3-a456-426614174000" + if err := issueCreateCmd.Flags().Set("project", uuid); err != nil { + t.Fatalf("failed to set project flag: %v", err) + } + got, err := issueCreateCmd.Flags().GetString("project") + if err != nil { + t.Fatalf("failed to get project flag: %v", err) + } + if got != uuid { + t.Errorf("project flag parsed as %q, want %q", got, uuid) + } +} + +func TestIssueUpdateCmd_ProjectFlag_Unassigned(t *testing.T) { + // Ensure the flag exists + f := issueUpdateCmd.Flags().Lookup("project") + if f == nil { + t.Fatalf("expected --project flag on issueUpdateCmd") + } + + if err := issueUpdateCmd.Flags().Set("project", "unassigned"); err != nil { + t.Fatalf("failed to set project flag: %v", err) + } + got, err := issueUpdateCmd.Flags().GetString("project") + if err != nil { + t.Fatalf("failed to get project flag: %v", err) + } + if got != "unassigned" { + t.Errorf("project flag parsed as %q, want %q", got, "unassigned") + } + + // Check helper integration contract + if val, ok, err := buildProjectInput(got); err != nil || !ok || val != nil { + t.Errorf("buildProjectInput('unassigned') => (%v,%v,%v), want (nil,true,nil)", val, ok, err) + } +} + diff --git a/cmd/issue_flag_defaults_test.go b/cmd/issue_flag_defaults_test.go new file mode 100644 index 0000000..5a7f54c --- /dev/null +++ b/cmd/issue_flag_defaults_test.go @@ -0,0 +1,36 @@ +package cmd + +import ( + "strings" + "testing" +) + +func TestIssueCreateCmd_ProjectFlag_DefaultsAndHelp(t *testing.T) { + f := issueCreateCmd.Flags().Lookup("project") + if f == nil { + t.Fatalf("expected --project flag on issueCreateCmd") + } + if f.DefValue != "" { + t.Errorf("default value = %q, want empty string", f.DefValue) + } + if !strings.Contains(f.Usage, "Project ID to assign issue to") { + t.Errorf("usage text %q does not contain expected phrase", f.Usage) + } +} + +func TestIssueUpdateCmd_ProjectFlag_DefaultsAndHelp(t *testing.T) { + f := issueUpdateCmd.Flags().Lookup("project") + if f == nil { + t.Fatalf("expected --project flag on issueUpdateCmd") + } + if f.DefValue != "" { + t.Errorf("default value = %q, want empty string", f.DefValue) + } + if !strings.Contains(f.Usage, "Project ID to assign issue to") { + t.Errorf("usage text %q does not contain expected phrase", f.Usage) + } + if !strings.Contains(f.Usage, "unassigned") { + t.Errorf("usage text %q does not mention 'unassigned' handling", f.Usage) + } +} + diff --git a/cmd/issue_help_test.go b/cmd/issue_help_test.go new file mode 100644 index 0000000..31dba24 --- /dev/null +++ b/cmd/issue_help_test.go @@ -0,0 +1,41 @@ +package cmd + +import "testing" + +func TestIssueCreateCmd_HelpIncludesProjectFlag(t *testing.T) { + usage := issueCreateCmd.UsageString() + if !containsAll(usage, []string{"--project", "Project ID to assign issue to"}) { + t.Fatalf("create usage missing project flag/help text. got:\n%s", usage) + } +} + +func TestIssueUpdateCmd_HelpIncludesProjectFlag(t *testing.T) { + usage := issueUpdateCmd.UsageString() + if !containsAll(usage, []string{"--project", "Project ID to assign issue to", "unassigned"}) { + t.Fatalf("update usage missing project flag/help text. got:\n%s", usage) + } +} + +// containsAll is a tiny helper for substring checks in tests. +func containsAll(hay string, needles []string) bool { + for _, n := range needles { + if !contains(hay, n) { + return false + } + } + return true +} + +func contains(s, sub string) bool { return len(s) >= len(sub) && (s == sub || (len(sub) > 0 && (indexOf(s, sub) >= 0))) } + +// indexOf is deliberately simple to avoid importing strings in many files. +func indexOf(s, sub string) int { + // naive search (small strings) + n, m := len(s), len(sub) + if m == 0 { return 0 } + for i := 0; i+m <= n; i++ { + if s[i:i+m] == sub { return i } + } + return -1 +} + diff --git a/cmd/issue_test.go b/cmd/issue_test.go new file mode 100644 index 0000000..1416036 --- /dev/null +++ b/cmd/issue_test.go @@ -0,0 +1,73 @@ +package cmd + +import ( + "errors" + "testing" +) + +func TestIsValidUUID(t *testing.T) { + valid := []string{ + "123e4567-e89b-12d3-a456-426614174000", + "00000000-0000-0000-0000-000000000000", + "ABCDEFAB-CDEF-ABCD-EFAB-CDEFABCDEFAB", + } + invalid := []string{ + "", "unassigned", "1234", "g23e4567-e89b-12d3-a456-426614174000", + "123e4567e89b12d3a456426614174000", + "123e4567-e89b-12d3-a456-426614174000-extra", + } + + for _, v := range valid { + if !isValidUUID(v) { + t.Errorf("expected valid UUID: %s", v) + } + } + for _, v := range invalid { + if isValidUUID(v) { + t.Errorf("expected invalid UUID: %s", v) + } + } +} + +func TestBuildProjectInput(t *testing.T) { + // Empty → ok=false, no input + if val, ok, err := buildProjectInput(""); err != nil || ok || val != nil { + t.Errorf("empty flag: want (nil,false,nil), got (%v,%v,%v)", val, ok, err) + } + + // unassigned → ok=true, val=nil + if val, ok, err := buildProjectInput("unassigned"); err != nil || !ok || val != nil { + t.Errorf("unassigned: want (nil,true,nil), got (%v,%v,%v)", val, ok, err) + } + + // valid uuid → ok=true, val=uuid + uuid := "123e4567-e89b-12d3-a456-426614174000" + if val, ok, err := buildProjectInput(uuid); err != nil || !ok || val != uuid { + t.Errorf("uuid: want (%s,true,nil), got (%v,%v,%v)", uuid, val, ok, err) + } + + // invalid uuid → error + if _, _, err := buildProjectInput("not-a-uuid"); err == nil { + t.Errorf("expected error for invalid uuid") + } +} + +func TestIsProjectNotFoundErr(t *testing.T) { + cases := []struct { + in error + want bool + }{ + {errors.New("GraphQL errors: [{ message: 'Project not found' }]"), true}, + {errors.New("something about projectId not found"), true}, + {errors.New("issue not found"), false}, + {errors.New("unknown error"), false}, + {nil, false}, + } + for _, c := range cases { + got := isProjectNotFoundErr(c.in) + if got != c.want { + t.Errorf("isProjectNotFoundErr(%v) = %v, want %v", c.in, got, c.want) + } + } +} + diff --git a/cmd/project.go b/cmd/project.go index 17b7c1f..6c8fb1d 100644 --- a/cmd/project.go +++ b/cmd/project.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "strings" + "time" "github.com/dorkitude/linctl/pkg/api" "github.com/dorkitude/linctl/pkg/auth" @@ -15,6 +16,24 @@ import ( "github.com/spf13/viper" ) +// projectAPI captures the subset of API client used by project commands. +// This enables dependency injection in tests without changing public API types. +type projectAPI interface { + GetTeam(ctx context.Context, key string) (*api.Team, error) + GetProjects(ctx context.Context, filter map[string]interface{}, first int, after string, orderBy string) (*api.Projects, error) + CreateProject(ctx context.Context, input map[string]interface{}) (*api.Project, error) + UpdateProject(ctx context.Context, id string, input map[string]interface{}) (*api.Project, error) + ArchiveProject(ctx context.Context, id string) (bool, error) + GetProject(ctx context.Context, id string) (*api.Project, error) + CreateProjectUpdate(ctx context.Context, input map[string]interface{}) (*api.ProjectUpdate, error) + ListProjectUpdates(ctx context.Context, projectID string) (*api.ProjectUpdates, error) + GetProjectUpdate(ctx context.Context, updateID string) (*api.ProjectUpdate, error) +} + +// Injection points for testing +var newAPIClient = func(authHeader string) projectAPI { return api.NewClient(authHeader) } +var getAuthHeader = auth.GetAuthHeader + // constructProjectURL constructs an ID-based project URL func constructProjectURL(projectID string, originalURL string) string { // Extract workspace from the original URL @@ -33,6 +52,98 @@ func constructProjectURL(projectID string, originalURL string) string { return originalURL } +// validateHexColor validates a hex color code format +func validateHexColor(color string) error { + if color == "" { + return nil + } + if !strings.HasPrefix(color, "#") || (len(color) != 4 && len(color) != 7) { + return fmt.Errorf("invalid hex color format (expected #RGB or #RRGGBB)") + } + // Check if all characters after # are valid hex + for _, c := range color[1:] { + if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')) { + return fmt.Errorf("invalid hex color format") + } + } + return nil +} + +// lookupUserIDsByEmails looks up user IDs from comma-separated emails +func lookupUserIDsByEmails(ctx context.Context, client projectAPI, emails string) ([]string, error) { + if emails == "" { + return nil, nil + } + + emailList := strings.Split(emails, ",") + userIDs := make([]string, 0, len(emailList)) + + // Type assert to get the full API client + fullClient, ok := client.(*api.Client) + if !ok { + return nil, fmt.Errorf("client type assertion failed") + } + + for _, email := range emailList { + email = strings.TrimSpace(email) + if email == "" { + continue + } + + user, err := fullClient.GetUser(ctx, email) + if err != nil { + return nil, fmt.Errorf("user not found with email '%s': %v", email, err) + } + userIDs = append(userIDs, user.ID) + } + + return userIDs, nil +} + +// lookupLabelIDsByNames looks up project label IDs from comma-separated names +func lookupLabelIDsByNames(ctx context.Context, client projectAPI, names string) ([]string, error) { + if names == "" { + return nil, nil + } + + nameList := strings.Split(names, ",") + labelIDs := make([]string, 0, len(nameList)) + + // Type assert to get the full API client + fullClient, ok := client.(*api.Client) + if !ok { + return nil, fmt.Errorf("client type assertion failed") + } + + // Get all project labels + labels, err := fullClient.GetProjectLabels(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get project labels: %v", err) + } + + // Build a map of label names to IDs for quick lookup + labelMap := make(map[string]string) + for _, label := range labels.Nodes { + labelMap[strings.ToLower(label.Name)] = label.ID + } + + // Look up each requested label + for _, name := range nameList { + name = strings.TrimSpace(name) + if name == "" { + continue + } + + labelID, found := labelMap[strings.ToLower(name)] + if !found { + return nil, fmt.Errorf("project label not found: '%s'", name) + } + labelIDs = append(labelIDs, labelID) + } + + return labelIDs, nil +} + // projectCmd represents the project command var projectCmd = &cobra.Command{ Use: "project", @@ -57,14 +168,14 @@ var projectListCmd = &cobra.Command{ jsonOut := viper.GetBool("json") // Get auth header - authHeader, err := auth.GetAuthHeader() + authHeader, err := getAuthHeader() if err != nil { output.Error(fmt.Sprintf("Authentication failed: %v", err), plaintext, jsonOut) os.Exit(1) } // Create API client - client := api.NewClient(authHeader) + client := newAPIClient(authHeader) // Get filters teamKey, _ := cmd.Flags().GetString("team") @@ -138,6 +249,9 @@ var projectListCmd = &cobra.Command{ fmt.Printf("## %s\n", project.Name) fmt.Printf("- **ID**: %s\n", project.ID) fmt.Printf("- **State**: %s\n", project.State) + if project.Priority > 0 { + fmt.Printf("- **Priority**: %d\n", project.Priority) + } fmt.Printf("- **Progress**: %.0f%%\n", project.Progress*100) if project.Lead != nil { fmt.Printf("- **Lead**: %s\n", project.Lead.Name) @@ -172,7 +286,7 @@ var projectListCmd = &cobra.Command{ return } else { // Table output - headers := []string{"Name", "State", "Lead", "Teams", "Created", "Updated", "URL"} + headers := []string{"Name", "State", "Priority", "Lead", "Teams", "Created", "Updated", "URL"} rows := [][]string{} for _, project := range projects.Nodes { @@ -205,9 +319,16 @@ var projectListCmd = &cobra.Command{ stateColor = color.New(color.FgRed) } + // Format priority + priorityStr := fmt.Sprintf("%d", project.Priority) + if project.Priority == 0 { + priorityStr = "-" + } + rows = append(rows, []string{ truncateString(project.Name, 25), stateColor.Sprint(project.State), + priorityStr, lead, teams, project.CreatedAt.Format("2006-01-02"), @@ -242,14 +363,14 @@ var projectGetCmd = &cobra.Command{ projectID := args[0] // Get auth header - authHeader, err := auth.GetAuthHeader() + authHeader, err := getAuthHeader() if err != nil { output.Error(fmt.Sprintf("Authentication failed: %v", err), plaintext, jsonOut) os.Exit(1) } // Create API client - client := api.NewClient(authHeader) + client := newAPIClient(authHeader) // Get project details project, err := client.GetProject(context.Background(), projectID) @@ -276,9 +397,32 @@ var projectGetCmd = &cobra.Command{ fmt.Printf("- **ID**: %s\n", project.ID) fmt.Printf("- **Slug ID**: %s\n", project.SlugId) fmt.Printf("- **State**: %s\n", project.State) + if project.Priority > 0 { + fmt.Printf("- **Priority**: %d\n", project.Priority) + } fmt.Printf("- **Progress**: %.0f%%\n", project.Progress*100) fmt.Printf("- **Health**: %s\n", project.Health) fmt.Printf("- **Scope**: %d\n", project.Scope) + if project.Initiatives != nil && len(project.Initiatives.Nodes) > 0 { + initiatives := "" + for i, initiative := range project.Initiatives.Nodes { + if i > 0 { + initiatives += ", " + } + initiatives += initiative.Name + } + fmt.Printf("- **Initiatives**: %s\n", initiatives) + } + if project.Labels != nil && len(project.Labels.Nodes) > 0 { + labels := "" + for i, label := range project.Labels.Nodes { + if i > 0 { + labels += ", " + } + labels += label.Name + } + fmt.Printf("- **Labels**: %s\n", labels) + } if project.Icon != nil && *project.Icon != "" { fmt.Printf("- **Icon**: %s\n", *project.Icon) } @@ -485,6 +629,10 @@ var projectGetCmd = &cobra.Command{ } fmt.Printf("\n%s %s\n", color.New(color.Bold).Sprint("State:"), stateColor.Sprint(project.State)) + if project.Priority > 0 { + fmt.Printf("%s %d\n", color.New(color.Bold).Sprint("Priority:"), project.Priority) + } + progressColor := color.New(color.FgRed) if project.Progress >= 0.75 { progressColor = color.New(color.FgGreen) @@ -493,6 +641,28 @@ var projectGetCmd = &cobra.Command{ } fmt.Printf("%s %s\n", color.New(color.Bold).Sprint("Progress:"), progressColor.Sprintf("%.0f%%", project.Progress*100)) + if project.Initiatives != nil && len(project.Initiatives.Nodes) > 0 { + initiatives := "" + for i, initiative := range project.Initiatives.Nodes { + if i > 0 { + initiatives += ", " + } + initiatives += initiative.Name + } + fmt.Printf("%s %s\n", color.New(color.Bold).Sprint("Initiatives:"), initiatives) + } + + if project.Labels != nil && len(project.Labels.Nodes) > 0 { + labels := "" + for i, label := range project.Labels.Nodes { + if i > 0 { + labels += ", " + } + labels += label.Name + } + fmt.Printf("%s %s\n", color.New(color.Bold).Sprint("Labels:"), labels) + } + if project.StartDate != nil || project.TargetDate != nil { fmt.Println() if project.StartDate != nil { @@ -582,10 +752,765 @@ var projectGetCmd = &cobra.Command{ }, } +var projectCreateCmd = &cobra.Command{ + Use: "create", + Short: "Create a new project", + Long: `Create a new project in Linear workspace with required and optional configuration. + +The --name and --team flags are required. All other fields are optional. + +Examples: + # Create project with minimal required fields + linctl project create --name "Q1 Backend" --team ENG + + # Create project with all optional fields + linctl project create --name "Test Project" --team ENG --state started --priority 1 --description "Test project for validation" + + # Create project with target date + linctl project create --name "Launch" --team PROD --state planned --target-date 2024-12-31`, + Run: func(cmd *cobra.Command, args []string) { + plaintext := viper.GetBool("plaintext") + jsonOut := viper.GetBool("json") + + // Get required flags + name, _ := cmd.Flags().GetString("name") + teamKey, _ := cmd.Flags().GetString("team") + + // Validate required fields + if name == "" || teamKey == "" { + output.Error("Both --name and --team are required", plaintext, jsonOut) + os.Exit(1) + } + + // Get auth header + authHeader, err := getAuthHeader() + if err != nil { + output.Error(fmt.Sprintf("Authentication failed: %v", err), plaintext, jsonOut) + os.Exit(1) + } + + // Create API client + client := newAPIClient(authHeader) + + // Resolve team key to team UUID + team, err := client.GetTeam(context.Background(), teamKey) + if err != nil { + output.Error(fmt.Sprintf("Team '%s' not found. Use 'linctl team list' to see available teams.", teamKey), plaintext, jsonOut) + os.Exit(1) + } + + // Get optional fields + description, _ := cmd.Flags().GetString("description") + state, _ := cmd.Flags().GetString("state") + targetDate, _ := cmd.Flags().GetString("target-date") + + // Validate state if provided + if state != "" { + allowedStates := []string{"planned", "started", "paused", "completed", "canceled"} + valid := false + for _, s := range allowedStates { + if state == s { + valid = true + break + } + } + if !valid { + output.Error(fmt.Sprintf("Invalid state. Must be one of: %s", strings.Join(allowedStates, ", ")), plaintext, jsonOut) + os.Exit(1) + } + } + + // Validate priority if provided + var priority int + if cmd.Flags().Changed("priority") { + priority, _ = cmd.Flags().GetInt("priority") + if priority < 0 || priority > 4 { + output.Error("Priority must be between 0 (None) and 4 (Low)", plaintext, jsonOut) + os.Exit(1) + } + } + + // Validate target-date format if provided (YYYY-MM-DD) + if targetDate != "" { + if _, err := time.Parse("2006-01-02", targetDate); err != nil { + output.Error("Invalid --target-date format. Expected YYYY-MM-DD", plaintext, jsonOut) + os.Exit(1) + } + } + + // Get and validate new optional fields + startDate, _ := cmd.Flags().GetString("start-date") + if startDate != "" { + if _, err := time.Parse("2006-01-02", startDate); err != nil { + output.Error("Invalid --start-date format. Expected YYYY-MM-DD", plaintext, jsonOut) + os.Exit(1) + } + } + + leadEmail, _ := cmd.Flags().GetString("lead") + members, _ := cmd.Flags().GetString("members") + labelNames, _ := cmd.Flags().GetString("label") + icon, _ := cmd.Flags().GetString("icon") + projectColor, _ := cmd.Flags().GetString("color") + links, _ := cmd.Flags().GetStringArray("link") + + // Validate color format + if err := validateHexColor(projectColor); err != nil { + output.Error(err.Error(), plaintext, jsonOut) + os.Exit(1) + } + + // Look up lead user ID + var leadID string + if leadEmail != "" { + user, err := client.(*api.Client).GetUser(context.Background(), leadEmail) + if err != nil { + output.Error(fmt.Sprintf("Lead user not found with email '%s': %v", leadEmail, err), plaintext, jsonOut) + os.Exit(1) + } + leadID = user.ID + } + + // Look up member user IDs + memberIDs, err := lookupUserIDsByEmails(context.Background(), client, members) + if err != nil { + output.Error(err.Error(), plaintext, jsonOut) + os.Exit(1) + } + + // Look up label IDs + labelIDs, err := lookupLabelIDsByNames(context.Background(), client, labelNames) + if err != nil { + output.Error(err.Error(), plaintext, jsonOut) + os.Exit(1) + } + + // Build input map + input := map[string]interface{}{ + "name": name, + "teamIds": []string{team.ID}, + } + + if description != "" { + input["description"] = description + } + if state != "" { + input["state"] = state + } + if cmd.Flags().Changed("priority") { + input["priority"] = priority + } + if startDate != "" { + input["startDate"] = startDate + } + if targetDate != "" { + input["targetDate"] = targetDate + } + if leadID != "" { + input["leadId"] = leadID + } + if len(memberIDs) > 0 { + input["memberIds"] = memberIDs + } + if len(labelIDs) > 0 { + input["labelIds"] = labelIDs + } + if icon != "" { + input["icon"] = icon + } + if projectColor != "" { + input["color"] = projectColor + } + if len(links) > 0 { + // For now, we'll just add the first link as a simple string + // TODO: Investigate if Linear supports multiple links or structured link objects + input["links"] = links + } + + // Create project + project, err := client.CreateProject(context.Background(), input) + if err != nil { + output.Error(fmt.Sprintf("Failed to create project: %v", err), plaintext, jsonOut) + os.Exit(1) + } + + // Handle output + if jsonOut { + output.JSON(project) + } else if plaintext { + fmt.Printf("# Project Created\n\n") + fmt.Printf("- **Name**: %s\n", project.Name) + fmt.Printf("- **ID**: %s\n", project.ID) + fmt.Printf("- **State**: %s\n", project.State) + if project.Description != "" { + fmt.Printf("- **Description**: %s\n", project.Description) + } + if project.Teams != nil && len(project.Teams.Nodes) > 0 { + teams := "" + for i, t := range project.Teams.Nodes { + if i > 0 { + teams += ", " + } + teams += t.Key + } + fmt.Printf("- **Teams**: %s\n", teams) + } + fmt.Printf("- **URL**: %s\n", constructProjectURL(project.ID, project.URL)) + } else { + fmt.Println() + fmt.Printf("%s Project created successfully\n", color.New(color.FgGreen).Sprint("✓")) + fmt.Println() + fmt.Printf("%s %s\n", color.New(color.Bold).Sprint("Name:"), project.Name) + fmt.Printf("%s %s\n", color.New(color.Bold).Sprint("ID:"), project.ID) + fmt.Printf("%s %s\n", color.New(color.Bold).Sprint("State:"), project.State) + if project.Teams != nil && len(project.Teams.Nodes) > 0 { + fmt.Printf("%s %s\n", color.New(color.Bold).Sprint("Team:"), project.Teams.Nodes[0].Key) + } + fmt.Printf("%s %s\n", color.New(color.Bold).Sprint("URL:"), color.New(color.FgBlue, color.Underline).Sprint(constructProjectURL(project.ID, project.URL))) + fmt.Println() + } + }, +} + +var projectArchiveCmd = &cobra.Command{ + Use: "archive PROJECT-UUID", + Short: "Archive a project", + Long: `Archive a project by its UUID. Archived projects are hidden from most views but can still be accessed. + +Examples: + linctl project archive abc-123-def-456`, + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + plaintext := viper.GetBool("plaintext") + jsonOut := viper.GetBool("json") + projectID := args[0] + + // Validate argument provided + if projectID == "" { + output.Error("Project UUID is required", plaintext, jsonOut) + os.Exit(1) + } + + // Get auth header + authHeader, err := getAuthHeader() + if err != nil { + output.Error(fmt.Sprintf("Authentication failed: %v", err), plaintext, jsonOut) + os.Exit(1) + } + + // Create API client + client := newAPIClient(authHeader) + + // Archive project + success, err := client.ArchiveProject(context.Background(), projectID) + if err != nil { + output.Error(fmt.Sprintf("Failed to archive project: %v", err), plaintext, jsonOut) + os.Exit(1) + } + + // Try to fetch project details to include the name in output (best effort) + var projectName string + if success { + if proj, gerr := client.GetProject(context.Background(), projectID); gerr == nil && proj != nil { + projectName = proj.Name + } + } + + // Handle output + if jsonOut { + payload := map[string]interface{}{ + "success": success, + "projectId": projectID, + } + if projectName != "" { + payload["projectName"] = projectName + } + output.JSON(payload) + } else if plaintext { + fmt.Printf("# Project Archived\n\n") + if projectName != "" { + fmt.Printf("- **Name**: %s\n", projectName) + } + fmt.Printf("- **Project ID**: %s\n", projectID) + fmt.Printf("- **Status**: Archived\n") + } else { + fmt.Println() + fmt.Printf("%s Project archived successfully\n", color.New(color.FgGreen).Sprint("✓")) + fmt.Println() + if projectName != "" { + fmt.Printf("%s %s\n", color.New(color.Bold).Sprint("Name:"), projectName) + } + fmt.Printf("%s %s\n", color.New(color.Bold).Sprint("Project ID:"), projectID) + fmt.Println() + } + }, +} + +var projectUpdateCmd = &cobra.Command{ + Use: "update PROJECT-UUID", + Short: "Update project fields", + Long: `Update one or more project fields. At least one field must be provided. + +The project UUID is required as the first argument. All field flags are optional, but at least one must be specified. + +Examples: + # Update single field + linctl project update abc-123 --name "New Project Name" + linctl project update abc-123 --state started + linctl project update abc-123 --priority 1 + + # Update multiple fields + linctl project update abc-123 --state started --priority 2 + linctl project update abc-123 --description "Full description" --summary "Short summary" + + # Update with labels + linctl project update abc-123 --label "urgent,backend"`, + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + plaintext := viper.GetBool("plaintext") + jsonOut := viper.GetBool("json") + projectID := args[0] + + // Validate project UUID provided + if projectID == "" { + output.Error("Project UUID is required", plaintext, jsonOut) + os.Exit(1) + } + + // Get auth header + authHeader, err := getAuthHeader() + if err != nil { + output.Error(fmt.Sprintf("Authentication failed: %v", err), plaintext, jsonOut) + os.Exit(1) + } + + // Create API client + client := newAPIClient(authHeader) + + // Build input map with only changed fields + input := make(map[string]interface{}) + + if cmd.Flags().Changed("name") { + name, _ := cmd.Flags().GetString("name") + input["name"] = name + } + if cmd.Flags().Changed("description") { + description, _ := cmd.Flags().GetString("description") + input["description"] = description + } + if cmd.Flags().Changed("summary") { + summary, _ := cmd.Flags().GetString("summary") + input["shortSummary"] = summary + } + if cmd.Flags().Changed("state") { + state, _ := cmd.Flags().GetString("state") + input["state"] = state + } + if cmd.Flags().Changed("priority") { + priority, _ := cmd.Flags().GetInt("priority") + input["priority"] = priority + } + if cmd.Flags().Changed("start-date") { + startDate, _ := cmd.Flags().GetString("start-date") + if startDate != "" { + if _, err := time.Parse("2006-01-02", startDate); err != nil { + output.Error("Invalid --start-date format. Expected YYYY-MM-DD", plaintext, jsonOut) + os.Exit(1) + } + } + input["startDate"] = startDate + } + if cmd.Flags().Changed("lead") { + leadEmail, _ := cmd.Flags().GetString("lead") + if leadEmail != "" { + user, err := client.(*api.Client).GetUser(context.Background(), leadEmail) + if err != nil { + output.Error(fmt.Sprintf("Lead user not found with email '%s': %v", leadEmail, err), plaintext, jsonOut) + os.Exit(1) + } + input["leadId"] = user.ID + } + } + if cmd.Flags().Changed("members") { + members, _ := cmd.Flags().GetString("members") + memberIDs, err := lookupUserIDsByEmails(context.Background(), client, members) + if err != nil { + output.Error(err.Error(), plaintext, jsonOut) + os.Exit(1) + } + if len(memberIDs) > 0 { + input["memberIds"] = memberIDs + } + } + if cmd.Flags().Changed("label") { + labelNames, _ := cmd.Flags().GetString("label") + labelIDs, err := lookupLabelIDsByNames(context.Background(), client, labelNames) + if err != nil { + output.Error(err.Error(), plaintext, jsonOut) + os.Exit(1) + } + if len(labelIDs) > 0 { + input["labelIds"] = labelIDs + } + } + if cmd.Flags().Changed("icon") { + icon, _ := cmd.Flags().GetString("icon") + input["icon"] = icon + } + if cmd.Flags().Changed("color") { + projectColor, _ := cmd.Flags().GetString("color") + if err := validateHexColor(projectColor); err != nil { + output.Error(err.Error(), plaintext, jsonOut) + os.Exit(1) + } + input["color"] = projectColor + } + if cmd.Flags().Changed("link") { + links, _ := cmd.Flags().GetStringArray("link") + if len(links) > 0 { + input["links"] = links + } + } + + // Validate at least one field provided + if len(input) == 0 { + output.Error("At least one field to update is required", plaintext, jsonOut) + os.Exit(1) + } + + // Validate state if provided + if state, ok := input["state"].(string); ok { + allowedStates := []string{"planned", "started", "paused", "completed", "canceled"} + valid := false + for _, s := range allowedStates { + if state == s { + valid = true + break + } + } + if !valid { + output.Error(fmt.Sprintf("Invalid state. Must be one of: %s", strings.Join(allowedStates, ", ")), plaintext, jsonOut) + os.Exit(1) + } + } + + // Validate priority if provided + if priority, ok := input["priority"].(int); ok { + if priority < 0 || priority > 4 { + output.Error("Priority must be between 0 and 4", plaintext, jsonOut) + os.Exit(1) + } + } + + // Update project + project, err := client.UpdateProject(context.Background(), projectID, input) + if err != nil { + output.Error(fmt.Sprintf("Failed to update project: %v", err), plaintext, jsonOut) + os.Exit(1) + } + + // Handle output + if jsonOut { + output.JSON(project) + } else if plaintext { + fmt.Printf("# Project Updated\n\n") + fmt.Printf("- **Name**: %s\n", project.Name) + fmt.Printf("- **ID**: %s\n", project.ID) + if project.State != "" { + fmt.Printf("- **State**: %s\n", project.State) + } + if project.Priority > 0 { + fmt.Printf("- **Priority**: %d\n", project.Priority) + } + if project.Description != "" { + fmt.Printf("- **Description**: %s\n", project.Description) + } + fmt.Printf("- **URL**: %s\n", constructProjectURL(project.ID, project.URL)) + } else { + fmt.Println() + fmt.Printf("%s Project updated successfully\n", color.New(color.FgGreen).Sprint("✓")) + fmt.Println() + fmt.Printf("%s %s\n", color.New(color.Bold).Sprint("Name:"), project.Name) + fmt.Printf("%s %s\n", color.New(color.Bold).Sprint("ID:"), project.ID) + if project.State != "" { + fmt.Printf("%s %s\n", color.New(color.Bold).Sprint("State:"), project.State) + } + if project.Priority > 0 { + fmt.Printf("%s %d\n", color.New(color.Bold).Sprint("Priority:"), project.Priority) + } + fmt.Printf("%s %s\n", color.New(color.Bold).Sprint("URL:"), color.New(color.FgBlue, color.Underline).Sprint(constructProjectURL(project.ID, project.URL))) + fmt.Println() + } + }, +} + +// Project update-post commands + +var projectUpdatePostCmd = &cobra.Command{ + Use: "update-post", + Short: "Manage project update posts", + Long: `Create, list, and view project update posts.`, +} + +var projectUpdatePostCreateCmd = &cobra.Command{ + Use: "create PROJECT-UUID", + Short: "Create a project update post", + Long: `Create a new update post for a project. + +The project UUID is required as the first argument. + +Examples: + linctl project update-post create PROJECT-UUID --body "Monthly update..." + linctl project update-post create PROJECT-UUID --body "Q1 progress" --health "onTrack"`, + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + plaintext := viper.GetBool("plaintext") + jsonOut := viper.GetBool("json") + + projectID := args[0] + body, _ := cmd.Flags().GetString("body") + health, _ := cmd.Flags().GetString("health") + + // Validate body is provided + if body == "" { + output.Error("--body is required", plaintext, jsonOut) + os.Exit(1) + } + + // Validate health if provided + if health != "" { + allowedHealth := []string{"onTrack", "atRisk", "offTrack"} + valid := false + for _, h := range allowedHealth { + if health == h { + valid = true + break + } + } + if !valid { + output.Error(fmt.Sprintf("Invalid health. Must be one of: %s", strings.Join(allowedHealth, ", ")), plaintext, jsonOut) + os.Exit(1) + } + } + + // Get auth header + authHeader, err := getAuthHeader() + if err != nil { + output.Error(fmt.Sprintf("Authentication failed: %v", err), plaintext, jsonOut) + os.Exit(1) + } + + // Create API client + client := newAPIClient(authHeader) + + // Build input + input := map[string]interface{}{ + "projectId": projectID, + "body": body, + } + if health != "" { + input["health"] = health + } + + // Create project update + update, err := client.CreateProjectUpdate(context.Background(), input) + if err != nil { + output.Error(fmt.Sprintf("Failed to create project update: %v", err), plaintext, jsonOut) + os.Exit(1) + } + + if jsonOut { + output.JSON(update) + return + } + + if plaintext { + fmt.Println("✓ Project update created successfully") + fmt.Printf("ID: %s\n", update.ID) + fmt.Printf("Created: %s\n", update.CreatedAt.Format("2006-01-02 15:04:05")) + } else { + fmt.Println() + fmt.Printf("%s Project update created successfully\n", color.New(color.FgGreen).Sprint("✓")) + fmt.Println() + fmt.Printf("%s %s\n", color.New(color.Bold).Sprint("ID:"), update.ID) + if update.User != nil { + fmt.Printf("%s %s\n", color.New(color.Bold).Sprint("Author:"), update.User.Name) + } + if update.Health != "" { + fmt.Printf("%s %s\n", color.New(color.Bold).Sprint("Health:"), update.Health) + } + fmt.Printf("%s %s\n", color.New(color.Bold).Sprint("Created:"), update.CreatedAt.Format("2006-01-02 15:04:05")) + fmt.Println() + } + }, +} + +var projectUpdatePostListCmd = &cobra.Command{ + Use: "list PROJECT-UUID", + Short: "List project update posts", + Long: `List all update posts for a project. + +Examples: + linctl project update-post list PROJECT-UUID + linctl project update-post list PROJECT-UUID --json`, + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + plaintext := viper.GetBool("plaintext") + jsonOut := viper.GetBool("json") + + projectID := args[0] + + // Get auth header + authHeader, err := getAuthHeader() + if err != nil { + output.Error(fmt.Sprintf("Authentication failed: %v", err), plaintext, jsonOut) + os.Exit(1) + } + + // Create API client + client := newAPIClient(authHeader) + + // List project updates + updates, err := client.ListProjectUpdates(context.Background(), projectID) + if err != nil { + output.Error(fmt.Sprintf("Failed to list project updates: %v", err), plaintext, jsonOut) + os.Exit(1) + } + + if len(updates.Nodes) == 0 { + if jsonOut { + output.JSON([]interface{}{}) + } else { + output.Info("No project updates found", plaintext, jsonOut) + } + return + } + + if jsonOut { + output.JSON(updates.Nodes) + return + } + + // Table output + headers := []string{"ID", "Author", "Health", "Created", "Updated"} + rows := [][]string{} + + for _, update := range updates.Nodes { + author := "" + if update.User != nil { + author = update.User.Name + } + + health := update.Health + if health == "" { + health = "N/A" + } + + created := update.CreatedAt.Format("2006-01-02") + updated := update.UpdatedAt.Format("2006-01-02") + + rows = append(rows, []string{ + update.ID, + author, + health, + created, + updated, + }) + } + + output.Table(output.TableData{Headers: headers, Rows: rows}, plaintext, jsonOut) + }, +} + +var projectUpdatePostGetCmd = &cobra.Command{ + Use: "get UPDATE-UUID", + Short: "Get a project update post", + Long: `Get details of a specific project update post. + +Examples: + linctl project update-post get UPDATE-UUID + linctl project update-post get UPDATE-UUID --json`, + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + plaintext := viper.GetBool("plaintext") + jsonOut := viper.GetBool("json") + + updateID := args[0] + + // Get auth header + authHeader, err := getAuthHeader() + if err != nil { + output.Error(fmt.Sprintf("Authentication failed: %v", err), plaintext, jsonOut) + os.Exit(1) + } + + // Create API client + client := newAPIClient(authHeader) + + // Get project update + update, err := client.GetProjectUpdate(context.Background(), updateID) + if err != nil { + output.Error(fmt.Sprintf("Failed to get project update: %v", err), plaintext, jsonOut) + os.Exit(1) + } + + if jsonOut { + output.JSON(update) + return + } + + // Format output + if plaintext { + fmt.Printf("ID: %s\n", update.ID) + if update.User != nil { + fmt.Printf("Author: %s (%s)\n", update.User.Name, update.User.Email) + } + if update.Health != "" { + fmt.Printf("Health: %s\n", update.Health) + } + fmt.Printf("Created: %s\n", update.CreatedAt.Format("2006-01-02 15:04:05")) + fmt.Printf("Updated: %s\n", update.UpdatedAt.Format("2006-01-02 15:04:05")) + if update.EditedAt != nil { + fmt.Printf("Edited: %s\n", update.EditedAt.Format("2006-01-02 15:04:05")) + } + fmt.Println() + fmt.Println("Body:") + fmt.Println(update.Body) + } else { + fmt.Println() + fmt.Printf("%s %s\n", color.New(color.Bold).Sprint("ID:"), update.ID) + if update.User != nil { + fmt.Printf("%s %s (%s)\n", color.New(color.Bold).Sprint("Author:"), update.User.Name, update.User.Email) + } + if update.Health != "" { + fmt.Printf("%s %s\n", color.New(color.Bold).Sprint("Health:"), update.Health) + } + fmt.Printf("%s %s\n", color.New(color.Bold).Sprint("Created:"), update.CreatedAt.Format("2006-01-02 15:04:05")) + fmt.Printf("%s %s\n", color.New(color.Bold).Sprint("Updated:"), update.UpdatedAt.Format("2006-01-02 15:04:05")) + if update.EditedAt != nil { + fmt.Printf("%s %s\n", color.New(color.Bold).Sprint("Edited:"), update.EditedAt.Format("2006-01-02 15:04:05")) + } + fmt.Println() + fmt.Println(color.New(color.Bold).Sprint("Body:")) + fmt.Println(update.Body) + fmt.Println() + } + }, +} + func init() { rootCmd.AddCommand(projectCmd) projectCmd.AddCommand(projectListCmd) projectCmd.AddCommand(projectGetCmd) + projectCmd.AddCommand(projectCreateCmd) + projectCmd.AddCommand(projectArchiveCmd) + projectCmd.AddCommand(projectUpdateCmd) + projectCmd.AddCommand(projectUpdatePostCmd) + + // Project update-post subcommands + projectUpdatePostCmd.AddCommand(projectUpdatePostCreateCmd) + projectUpdatePostCmd.AddCommand(projectUpdatePostListCmd) + projectUpdatePostCmd.AddCommand(projectUpdatePostGetCmd) // List command flags projectListCmd.Flags().StringP("team", "t", "", "Filter by team key") @@ -594,4 +1519,37 @@ func init() { projectListCmd.Flags().BoolP("include-completed", "c", false, "Include completed and canceled projects") projectListCmd.Flags().StringP("sort", "o", "linear", "Sort order: linear (default), created, updated") projectListCmd.Flags().StringP("newer-than", "n", "", "Show projects created after this time (default: 6_months_ago, use 'all_time' for no filter)") + + // Create command flags + projectCreateCmd.Flags().String("name", "", "Project name (required)") + projectCreateCmd.Flags().String("team", "", "Team key (required)") + projectCreateCmd.Flags().String("description", "", "Project description") + projectCreateCmd.Flags().String("state", "", "Project state (planned|started|paused|completed|canceled)") + projectCreateCmd.Flags().Int("priority", 0, "Priority (0-4: None, Urgent, High, Normal, Low)") + projectCreateCmd.Flags().String("start-date", "", "Start date (YYYY-MM-DD)") + projectCreateCmd.Flags().String("target-date", "", "Target date (YYYY-MM-DD)") + projectCreateCmd.Flags().String("lead", "", "Project lead (email)") + projectCreateCmd.Flags().String("members", "", "Project members (comma-separated emails)") + projectCreateCmd.Flags().String("label", "", "Project labels (comma-separated names)") + projectCreateCmd.Flags().String("icon", "", "Project icon (emoji)") + projectCreateCmd.Flags().String("color", "", "Project color (hex code, e.g., #ff6b6b)") + projectCreateCmd.Flags().StringArray("link", []string{}, "External link URL (can be specified multiple times)") + + // Update command flags + projectUpdateCmd.Flags().String("name", "", "Project name") + projectUpdateCmd.Flags().String("description", "", "Project description") + projectUpdateCmd.Flags().String("summary", "", "Project short summary") + projectUpdateCmd.Flags().String("state", "", "Project state (planned|started|paused|completed|canceled)") + projectUpdateCmd.Flags().Int("priority", 0, "Priority (0-4: None, Urgent, High, Normal, Low)") + projectUpdateCmd.Flags().String("start-date", "", "Start date (YYYY-MM-DD)") + projectUpdateCmd.Flags().String("lead", "", "Project lead (email)") + projectUpdateCmd.Flags().String("members", "", "Project members (comma-separated emails)") + projectUpdateCmd.Flags().String("label", "", "Project labels (comma-separated names)") + projectUpdateCmd.Flags().String("icon", "", "Project icon (emoji)") + projectUpdateCmd.Flags().String("color", "", "Project color (hex code, e.g., #ff6b6b)") + projectUpdateCmd.Flags().StringArray("link", []string{}, "External link URL (can be specified multiple times)") + + // Project update-post create flags + projectUpdatePostCreateCmd.Flags().String("body", "", "Update post body (required)") + projectUpdatePostCreateCmd.Flags().String("health", "", "Project health (onTrack|atRisk|offTrack)") } diff --git a/cmd/project_cmd_test.go b/cmd/project_cmd_test.go new file mode 100644 index 0000000..889f50a --- /dev/null +++ b/cmd/project_cmd_test.go @@ -0,0 +1,165 @@ +package cmd + +import ( + "bytes" + "context" + "fmt" + "os" + "testing" + + "github.com/dorkitude/linctl/pkg/api" + "github.com/spf13/viper" +) + +type mockProjectClient struct { + created *api.Project + archived bool + projectUpdates map[string]*api.ProjectUpdate + updateCounter int +} + +func (m *mockProjectClient) GetTeam(ctx context.Context, key string) (*api.Team, error) { + return &api.Team{ID: "team-1", Key: key, Name: "Team-" + key}, nil +} + +func (m *mockProjectClient) GetProjects(ctx context.Context, filter map[string]interface{}, first int, after string, orderBy string) (*api.Projects, error) { + return &api.Projects{}, nil +} + +func (m *mockProjectClient) CreateProject(ctx context.Context, input map[string]interface{}) (*api.Project, error) { + name, _ := input["name"].(string) + m.created = &api.Project{ID: "p1", Name: name, State: fmt.Sprint(input["state"])} + return m.created, nil +} + +func (m *mockProjectClient) ArchiveProject(ctx context.Context, id string) (bool, error) { + m.archived = true + return true, nil +} + +func (m *mockProjectClient) UpdateProject(ctx context.Context, id string, input map[string]interface{}) (*api.Project, error) { + project := &api.Project{ID: id, Name: "Alpha"} + if name, ok := input["name"].(string); ok { + project.Name = name + } + if state, ok := input["state"].(string); ok { + project.State = state + } + if priority, ok := input["priority"].(int); ok { + project.Priority = priority + } + return project, nil +} + +func (m *mockProjectClient) GetProject(ctx context.Context, id string) (*api.Project, error) { + return &api.Project{ID: id, Name: "Alpha"}, nil +} + +func (m *mockProjectClient) CreateProjectUpdate(ctx context.Context, input map[string]interface{}) (*api.ProjectUpdate, error) { + if m.projectUpdates == nil { + m.projectUpdates = make(map[string]*api.ProjectUpdate) + } + m.updateCounter++ + id := fmt.Sprintf("update-%d", m.updateCounter) + update := &api.ProjectUpdate{ + ID: id, + Body: input["body"].(string), + } + if health, ok := input["health"].(string); ok { + update.Health = health + } + m.projectUpdates[id] = update + return update, nil +} + +func (m *mockProjectClient) ListProjectUpdates(ctx context.Context, projectID string) (*api.ProjectUpdates, error) { + updates := []api.ProjectUpdate{} + for _, u := range m.projectUpdates { + updates = append(updates, *u) + } + return &api.ProjectUpdates{Nodes: updates}, nil +} + +func (m *mockProjectClient) GetProjectUpdate(ctx context.Context, updateID string) (*api.ProjectUpdate, error) { + if m.projectUpdates == nil { + m.projectUpdates = make(map[string]*api.ProjectUpdate) + } + if update, ok := m.projectUpdates[updateID]; ok { + return update, nil + } + return &api.ProjectUpdate{ID: updateID, Body: "Test update body"}, nil +} + +func withInjectedProjectClient(t *testing.T, mc *mockProjectClient, fn func()) { + t.Helper() + oldNew := newAPIClient + oldAuth := getAuthHeader + newAPIClient = func(_ string) projectAPI { return mc } + getAuthHeader = func() (string, error) { return "Bearer test", nil } + defer func() { newAPIClient = oldNew; getAuthHeader = oldAuth }() + fn() +} + +func captureStdout(t *testing.T, fn func()) string { + t.Helper() + old := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + defer func() { os.Stdout = old }() + fn() + _ = w.Close() + var buf bytes.Buffer + _, _ = buf.ReadFrom(r) + return buf.String() +} + +func TestProjectCreate_Plaintext_Output(t *testing.T) { + mc := &mockProjectClient{} + withInjectedProjectClient(t, mc, func() { + viper.Set("plaintext", true) + viper.Set("json", false) + // Set flags directly on the command and call Run + _ = projectCreateCmd.Flags().Set("name", "Alpha") + _ = projectCreateCmd.Flags().Set("team", "ENG") + _ = projectCreateCmd.Flags().Set("state", "planned") + _ = projectCreateCmd.Flags().Set("target-date", "2024-12-31") + out := captureStdout(t, func() { projectCreateCmd.Run(projectCreateCmd, nil) }) + if !contains(out, "# Project Created") || !contains(out, "**Name**: Alpha") { + t.Fatalf("unexpected output:\n%s", out) + } + }) +} + +func TestProjectArchive_Plaintext_IncludesName(t *testing.T) { + mc := &mockProjectClient{} + withInjectedProjectClient(t, mc, func() { + viper.Set("plaintext", true) + viper.Set("json", false) + out := captureStdout(t, func() { projectArchiveCmd.Run(projectArchiveCmd, []string{"p1"}) }) + if !contains(out, "# Project Archived") || !contains(out, "**Name**: Alpha") { + t.Fatalf("unexpected output:\n%s", out) + } + }) +} + +func TestProjectUpdatePostCreate(t *testing.T) { + mc := &mockProjectClient{} + withInjectedProjectClient(t, mc, func() { + viper.Set("plaintext", true) + viper.Set("json", false) + _ = projectUpdatePostCreateCmd.Flags().Set("body", "Monthly progress update") + _ = projectUpdatePostCreateCmd.Flags().Set("health", "onTrack") + out := captureStdout(t, func() { + projectUpdatePostCreateCmd.Run(projectUpdatePostCreateCmd, []string{"proj-123"}) + }) + if !contains(out, "Project update created successfully") { + t.Fatalf("unexpected output:\n%s", out) + } + if mc.updateCounter != 1 { + t.Fatalf("expected 1 update created, got %d", mc.updateCounter) + } + }) +} + +// Skipping validation error tests as os.Exit() can't be easily tested +// The validation logic works but testing it requires refactoring os.Exit() calls diff --git a/cmd/project_test.go b/cmd/project_test.go new file mode 100644 index 0000000..8569f9c --- /dev/null +++ b/cmd/project_test.go @@ -0,0 +1,20 @@ +package cmd + +import "testing" + +func TestConstructProjectURL(t *testing.T) { + // Happy path: workspace URL should be preserved, slug replaced by ID + id := "1234" + original := "https://linear.app/acme/project/some-project-slug" + want := "https://linear.app/acme/project/1234" + got := constructProjectURL(id, original) + if got != want { + t.Fatalf("constructProjectURL mismatch: got %q want %q", got, want) + } + + // Empty original → empty result + if s := constructProjectURL("abc", ""); s != "" { + t.Fatalf("expected empty for empty original URL, got %q", s) + } +} + diff --git a/cmd/root.go b/cmd/root.go index f82014d..037e6dd 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -11,9 +11,9 @@ import ( ) var ( - cfgFile string - plaintext bool - jsonOut bool + cfgFile string + plaintext bool + jsonOut bool ) // version is set at build time via -ldflags @@ -66,10 +66,10 @@ func generateHeader() string { // rootCmd represents the base command when called without any subcommands var rootCmd = &cobra.Command{ - Use: "linctl", - Short: "A comprehensive Linear CLI tool", - Long: color.New(color.FgCyan).Sprintf("%s\nA comprehensive CLI tool for Linear's API featuring:\n• Issue management (create, list, update, archive)\n• Project tracking and collaboration \n• Team and user management\n• Comments and attachments\n• Webhook configuration\n• Table/plaintext/JSON output formats\n", generateHeader()), - Version: version, + Use: "linctl", + Short: "A comprehensive Linear CLI tool", + Long: color.New(color.FgCyan).Sprintf("%s\nA comprehensive CLI tool for Linear's API featuring:\n• Issue management (create, list, update, archive)\n• Project tracking and collaboration \n• Team and user management\n• Comments and attachments\n• Webhook configuration\n• Table/plaintext/JSON output formats\n", generateHeader()), + Version: version, } // Execute adds all child commands to the root command and sets flags appropriately. diff --git a/pkg/api/queries.go b/pkg/api/queries.go index f9e06c0..eec86f0 100644 --- a/pkg/api/queries.go +++ b/pkg/api/queries.go @@ -1,9 +1,10 @@ package api import ( - "context" - "encoding/json" - "time" + "context" + "encoding/json" + "fmt" + "time" ) // User represents a Linear user @@ -97,11 +98,14 @@ type Project struct { Name string `json:"name"` Description string `json:"description"` State string `json:"state"` + Priority int `json:"priority"` Progress float64 `json:"progress"` StartDate *string `json:"startDate"` TargetDate *string `json:"targetDate"` - Lead *User `json:"lead"` - Teams *Teams `json:"teams"` + Lead *User `json:"lead"` + Teams *Teams `json:"teams"` + Initiatives *Initiatives `json:"initiatives"` + Labels *Labels `json:"labels"` URL string `json:"url"` Icon *string `json:"icon"` Color string `json:"color"` @@ -230,6 +234,10 @@ type Initiative struct { Description string `json:"description"` } +type Initiatives struct { + Nodes []Initiative `json:"nodes"` +} + type PageInfo struct { HasNextPage bool `json:"hasNextPage"` EndCursor string `json:"endCursor"` @@ -428,6 +436,10 @@ func (c *Client) GetIssues(ctx context.Context, filter map[string]interface{}, f key name } + project { + id + name + } labels { nodes { id @@ -501,6 +513,10 @@ func (c *Client) IssueSearch(ctx context.Context, term string, filter map[string key name } + project { + id + name + } labels { nodes { id @@ -872,6 +888,7 @@ func (c *Client) GetProjects(ctx context.Context, filter map[string]interface{}, name description state + priority progress startDate targetDate @@ -935,6 +952,7 @@ func (c *Client) GetProject(ctx context.Context, id string) (*Project, error) { description content state + priority progress health scope @@ -951,6 +969,19 @@ func (c *Client) GetProject(ctx context.Context, id string) (*Project, error) { slackNewIssue slackIssueComments slackIssueStatuses + initiatives { + nodes { + id + name + } + } + labels { + nodes { + id + name + color + } + } lead { id name @@ -1112,6 +1143,10 @@ func (c *Client) UpdateIssue(ctx context.Context, id string, input map[string]in key name } + project { + id + name + } labels { nodes { id @@ -1174,6 +1209,10 @@ func (c *Client) CreateIssue(ctx context.Context, input map[string]interface{}) key name } + project { + id + name + } labels { nodes { id @@ -1204,35 +1243,61 @@ func (c *Client) CreateIssue(ctx context.Context, input map[string]interface{}) return &response.IssueCreate.Issue, nil } -// GetTeam returns a single team by key +// GetTeam returns a single team by key; falls back to id lookup if not found func (c *Client) GetTeam(ctx context.Context, key string) (*Team, error) { - query := ` - query Team($key: String!) { - team(id: $key) { - id - key - name - description - private - issueCount - } - } - ` - - variables := map[string]interface{}{ - "key": key, - } - - var response struct { - Team Team `json:"team"` - } - - err := c.Execute(ctx, query, variables, &response) - if err != nil { - return nil, err - } - - return &response.Team, nil + // First, attempt lookup by team key via teams connection + queryByKey := ` + query TeamByKey($key: String!) { + teams(filter: { key: { eq: $key } }, first: 1) { + nodes { + id + key + name + description + private + issueCount + } + } + } + ` + + variables := map[string]interface{}{"key": key} + + var respByKey struct { + Teams struct { + Nodes []Team `json:"nodes"` + } `json:"teams"` + } + + if err := c.Execute(ctx, queryByKey, variables, &respByKey); err == nil { + if len(respByKey.Teams.Nodes) > 0 { + t := respByKey.Teams.Nodes[0] + return &t, nil + } + } + + // Fallback: try direct id lookup (in case caller passed an ID) + queryByID := ` + query TeamByID($id: String!) { + team(id: $id) { + id + key + name + description + private + issueCount + } + } + ` + + var respByID struct { + Team *Team `json:"team"` + } + if err := c.Execute(ctx, queryByID, map[string]interface{}{"id": key}, &respByID); err == nil && respByID.Team != nil { + return respByID.Team, nil + } + + return nil, fmt.Errorf("team '%s' not found", key) } // Comment represents a Linear comment @@ -1513,3 +1578,295 @@ func (c *Client) CreateComment(ctx context.Context, issueID string, body string) return &response.CommentCreate.Comment, nil } + +// CreateProject creates a new project +func (c *Client) CreateProject(ctx context.Context, input map[string]interface{}) (*Project, error) { + query := ` + mutation CreateProject($input: ProjectCreateInput!) { + projectCreate(input: $input) { + success + project { + id + name + description + state + progress + startDate + targetDate + url + icon + color + createdAt + updatedAt + lead { + id + name + email + } + teams { + nodes { + id + key + name + } + } + } + } + } + ` + + variables := map[string]interface{}{ + "input": input, + } + + var response struct { + ProjectCreate struct { + Success bool `json:"success"` + Project Project `json:"project"` + } `json:"projectCreate"` + } + + err := c.Execute(ctx, query, variables, &response) + if err != nil { + return nil, err + } + + return &response.ProjectCreate.Project, nil +} + +// ArchiveProject archives a project by ID +func (c *Client) ArchiveProject(ctx context.Context, id string) (bool, error) { + query := ` + mutation ArchiveProject($id: String!) { + projectArchive(id: $id) { + success + project { + id + name + archivedAt + } + } + } + ` + + variables := map[string]interface{}{ + "id": id, + } + + var response struct { + ProjectArchive struct { + Success bool `json:"success"` + Project Project `json:"project"` + } `json:"projectArchive"` + } + + err := c.Execute(ctx, query, variables, &response) + if err != nil { + return false, err + } + + return response.ProjectArchive.Success, nil +} + +// UpdateProject updates a project by ID with partial field updates +func (c *Client) UpdateProject(ctx context.Context, id string, input map[string]interface{}) (*Project, error) { + query := ` + mutation UpdateProject($id: String!, $input: ProjectUpdateInput!) { + projectUpdate(id: $id, input: $input) { + success + project { + id + name + description + state + priority + progress + startDate + targetDate + url + icon + color + createdAt + updatedAt + lead { + id + name + email + } + teams { + nodes { + id + key + name + } + } + } + } + } + ` + + variables := map[string]interface{}{ + "id": id, + "input": input, + } + + var response struct { + ProjectUpdate struct { + Success bool `json:"success"` + Project Project `json:"project"` + } `json:"projectUpdate"` + } + + err := c.Execute(ctx, query, variables, &response) + if err != nil { + return nil, err + } + + return &response.ProjectUpdate.Project, nil +} + +// GetProjectLabels returns all project labels in the workspace +func (c *Client) GetProjectLabels(ctx context.Context) (*Labels, error) { + query := ` + query ProjectLabels { + projectLabels { + nodes { + id + name + color + description + } + } + } + ` + + var response struct { + ProjectLabels Labels `json:"projectLabels"` + } + + err := c.Execute(ctx, query, nil, &response) + if err != nil { + return nil, err + } + + return &response.ProjectLabels, nil +} + +// ListProjectUpdates returns all updates for a specific project +func (c *Client) ListProjectUpdates(ctx context.Context, projectID string) (*ProjectUpdates, error) { + query := ` + query ProjectUpdates($projectId: String!) { + project(id: $projectId) { + projectUpdates { + nodes { + id + body + health + createdAt + updatedAt + editedAt + user { + id + name + email + } + } + } + } + } + ` + + variables := map[string]interface{}{ + "projectId": projectID, + } + + var response struct { + Project struct { + ProjectUpdates ProjectUpdates `json:"projectUpdates"` + } `json:"project"` + } + + err := c.Execute(ctx, query, variables, &response) + if err != nil { + return nil, err + } + + return &response.Project.ProjectUpdates, nil +} + +// GetProjectUpdate returns a specific project update +func (c *Client) GetProjectUpdate(ctx context.Context, updateID string) (*ProjectUpdate, error) { + query := ` + query ProjectUpdate($id: String!) { + projectUpdate(id: $id) { + id + body + health + createdAt + updatedAt + editedAt + user { + id + name + email + } + } + } + ` + + variables := map[string]interface{}{ + "id": updateID, + } + + var response struct { + ProjectUpdate ProjectUpdate `json:"projectUpdate"` + } + + err := c.Execute(ctx, query, variables, &response) + if err != nil { + return nil, err + } + + return &response.ProjectUpdate, nil +} + +// CreateProjectUpdate creates a new project update +func (c *Client) CreateProjectUpdate(ctx context.Context, input map[string]interface{}) (*ProjectUpdate, error) { + query := ` + mutation CreateProjectUpdate($input: ProjectUpdateCreateInput!) { + projectUpdateCreate(input: $input) { + success + projectUpdate { + id + body + health + createdAt + updatedAt + user { + id + name + email + } + } + } + } + ` + + variables := map[string]interface{}{ + "input": input, + } + + var response struct { + ProjectUpdateCreate struct { + Success bool `json:"success"` + ProjectUpdate ProjectUpdate `json:"projectUpdate"` + } `json:"projectUpdateCreate"` + } + + err := c.Execute(ctx, query, variables, &response) + if err != nil { + return nil, err + } + + return &response.ProjectUpdateCreate.ProjectUpdate, nil +} diff --git a/pkg/api/queries_test.go b/pkg/api/queries_test.go new file mode 100644 index 0000000..da7616c --- /dev/null +++ b/pkg/api/queries_test.go @@ -0,0 +1,143 @@ +package api + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// helper to create a mock GraphQL server with programmable handler +func newMockGraphQLServer(t *testing.T, handler func(query string, w http.ResponseWriter)) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body struct { + Query string `json:"query"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("failed to decode request: %v", err) + return + } + handler(body.Query, w) + })) +} + +func TestGetTeamByKey(t *testing.T) { + srv := newMockGraphQLServer(t, func(query string, w http.ResponseWriter) { + if strings.Contains(query, "teams(") { + io := map[string]any{ + "data": map[string]any{ + "teams": map[string]any{ + "nodes": []any{ + map[string]any{"id": "team-1", "key": "ENG", "name": "Engineering", "issueCount": 42}, + }, + }, + }, + } + _ = json.NewEncoder(w).Encode(io) + return + } + // default empty + _ = json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{"teams": map[string]any{"nodes": []any{}}}}) + }) + defer srv.Close() + + c := NewClientWithURL(srv.URL, "Bearer test") + got, err := c.GetTeam(context.Background(), "ENG") + if err != nil { + t.Fatalf("GetTeam returned error: %v", err) + } + if got == nil || got.Key != "ENG" || got.Name != "Engineering" { + t.Fatalf("unexpected team: %+v", got) + } +} + +func TestGetTeamFallbackByID(t *testing.T) { + call := 0 + srv := newMockGraphQLServer(t, func(query string, w http.ResponseWriter) { + call++ + if strings.Contains(query, "teams(") { + // first call returns no teams by key + _ = json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{"teams": map[string]any{"nodes": []any{}}}}) + return + } + if strings.Contains(query, "team(") { + // second call returns direct lookup by id + _ = json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{"team": map[string]any{"id": "team-1", "key": "ENG", "name": "Engineering", "issueCount": 10}}}) + return + } + t.Fatalf("unexpected query: %s", query) + }) + defer srv.Close() + + c := NewClientWithURL(srv.URL, "Bearer test") + got, err := c.GetTeam(context.Background(), "team-1") + if err != nil { + t.Fatalf("GetTeam returned error: %v", err) + } + if got == nil || got.ID != "team-1" || got.Key != "ENG" { + t.Fatalf("unexpected team: %+v", got) + } + if call < 2 { + t.Fatalf("expected at least 2 calls, got %d", call) + } +} + +func TestCreateArchiveAndGetProject(t *testing.T) { + srv := newMockGraphQLServer(t, func(query string, w http.ResponseWriter) { + switch { + case strings.Contains(query, "mutation CreateProject"): + _ = json.NewEncoder(w).Encode(map[string]any{ + "data": map[string]any{ + "projectCreate": map[string]any{ + "success": true, + "project": map[string]any{"id": "p1", "name": "Alpha"}, + }, + }, + }) + case strings.Contains(query, "mutation ArchiveProject"): + _ = json.NewEncoder(w).Encode(map[string]any{ + "data": map[string]any{ + "projectArchive": map[string]any{ + "success": true, + "project": map[string]any{"id": "p1", "name": "Alpha"}, + }, + }, + }) + case strings.Contains(query, "query Project("): + _ = json.NewEncoder(w).Encode(map[string]any{ + "data": map[string]any{ + "project": map[string]any{"id": "p1", "name": "Alpha"}, + }, + }) + default: + t.Fatalf("unexpected query: %s", query) + } + }) + defer srv.Close() + + c := NewClientWithURL(srv.URL, "Bearer test") + proj, err := c.CreateProject(context.Background(), map[string]any{"name": "Alpha"}) + if err != nil { + t.Fatalf("CreateProject error: %v", err) + } + if proj == nil || proj.ID != "p1" || proj.Name != "Alpha" { + t.Fatalf("unexpected project: %+v", proj) + } + + ok, err := c.ArchiveProject(context.Background(), "p1") + if err != nil || !ok { + t.Fatalf("ArchiveProject error/ok: %v %v", err, ok) + } + + got, err := c.GetProject(context.Background(), "p1") + if err != nil { + t.Fatalf("GetProject error: %v", err) + } + if got == nil || got.Name != "Alpha" { + t.Fatalf("unexpected GetProject: %+v", got) + } +} +