diff --git a/README.md b/README.md index a029303..914d55a 100644 --- a/README.md +++ b/README.md @@ -141,9 +141,12 @@ linctl issue update LIN-123 --state "In Progress" linctl issue update LIN-123 --priority 1 # 0=None, 1=Urgent, 2=High, 3=Normal, 4=Low linctl issue update LIN-123 --due-date "2024-12-31" linctl issue update LIN-123 --due-date "" # Remove due date +linctl issue update LIN-123 --parent LIN-456 # Set parent issue +linctl issue update LIN-123 --parent none # Remove parent # Update multiple fields at once linctl issue update LIN-123 --title "Critical Bug" --assignee me --priority 1 +linctl issue update LIN-123 --parent LIN-456 --title "Sub-task" --assignee me ``` ### 3. Project Management @@ -260,6 +263,7 @@ linctl issue edit [flags] # Alias -s, --state string State name (e.g., 'Todo', 'In Progress', 'Done') --priority int Priority (0=None, 1=Urgent, 2=High, 3=Normal, 4=Low) --due-date string Due date (YYYY-MM-DD format, or empty to remove) + --parent string Parent issue ID/identifier (or 'none' to remove parent) # Archive issue (coming soon) linctl issue archive @@ -619,6 +623,13 @@ linctl team members ENG --json | jq '. | length' # Export issue comments linctl comment list LIN-123 --json > issue-comments.json + +# Set up parent-child issue relationships +linctl issue update LIN-124 --parent LIN-123 # Make LIN-124 a sub-issue of LIN-123 +linctl issue update LIN-125 --parent LIN-123 # Make LIN-125 also a sub-issue + +# Remove parent-child relationships +linctl issue update LIN-124 --parent none ``` ## 📡 Real-World Examples diff --git a/cmd/issue.go b/cmd/issue.go index f9e35ef..b64191d 100644 --- a/cmd/issue.go +++ b/cmd/issue.go @@ -925,6 +925,8 @@ Examples: linctl issue update LIN-123 --state "In Progress" linctl issue update LIN-123 --priority 1 linctl issue update LIN-123 --due-date "2024-12-31" + linctl issue update LIN-123 --parent LIN-100 # Make sub-issue of LIN-100 + linctl issue update LIN-123 --parent none # Remove parent (promote to top-level) linctl issue update LIN-123 --title "New title" --assignee me --priority 2`, Args: cobra.ExactArgs(1), Run: func(cmd *cobra.Command, args []string) { @@ -1049,6 +1051,34 @@ Examples: } } + // Handle parent update + if cmd.Flags().Changed("parent") { + parentValue, _ := cmd.Flags().GetString("parent") + normalizedValue := strings.ToLower(strings.TrimSpace(parentValue)) + + switch normalizedValue { + case "none", "null", "": + // Remove parent relationship (promote to top-level issue) + input["parentId"] = nil + default: + // Validate that the parent issue exists + parentIssue, err := client.GetIssue(context.Background(), parentValue) + if err != nil { + output.Error(fmt.Sprintf("Failed to find parent issue '%s': %v", parentValue, err), plaintext, jsonOut) + os.Exit(1) + } + + // Prevent self-referencing + currentIssueID := args[0] + if parentIssue.Identifier == currentIssueID || parentIssue.ID == currentIssueID { + output.Error("An issue cannot be its own parent", plaintext, jsonOut) + os.Exit(1) + } + + input["parentId"] = parentIssue.ID + } + } + // Check if any updates were specified if len(input) == 0 { output.Error("No updates specified. Use flags to specify what to update.", plaintext, jsonOut) @@ -1066,8 +1096,17 @@ Examples: output.JSON(issue) } else if plaintext { fmt.Printf("Updated issue %s\n", issue.Identifier) + if issue.Parent != nil { + fmt.Printf("Parent: %s - %s\n", issue.Parent.Identifier, issue.Parent.Title) + } } else { output.Success(fmt.Sprintf("Updated issue %s", issue.Identifier), plaintext, jsonOut) + if issue.Parent != nil { + fmt.Printf(" %s Parent: %s - %s\n", + color.New(color.FgBlue).Sprint("↳"), + color.New(color.FgCyan).Sprint(issue.Parent.Identifier), + issue.Parent.Title) + } } }, } @@ -1118,4 +1157,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("parent", "", "Parent issue ID or identifier (use 'none' to remove parent)") } diff --git a/cmd/project.go b/cmd/project.go index 17b7c1f..6b626d5 100644 --- a/cmd/project.go +++ b/cmd/project.go @@ -582,10 +582,395 @@ var projectGetCmd = &cobra.Command{ }, } +var projectCreateCmd = &cobra.Command{ + Use: "create", + Aliases: []string{"new"}, + Short: "Create a new project", + Long: `Create a new project in Linear. + +Examples: + linctl project create --name "Q1 Release" --team ENG + linctl project create --name "Auth Overhaul" --team ENG --description "Rewrite authentication system" + linctl project create --name "Mobile App" --team ENG --lead me --start-date 2024-01-01 --target-date 2024-06-30 + linctl project create --name "Bug Bash" --team ENG,QA --state started`, + Run: func(cmd *cobra.Command, args []string) { + plaintext := viper.GetBool("plaintext") + jsonOut := viper.GetBool("json") + + authHeader, err := auth.GetAuthHeader() + if err != nil { + output.Error("Not authenticated. Run 'linctl auth' first.", plaintext, jsonOut) + os.Exit(1) + } + + client := api.NewClient(authHeader) + + // Get required flags + name, _ := cmd.Flags().GetString("name") + teamKeys, _ := cmd.Flags().GetStringSlice("team") + + if name == "" { + output.Error("Project name is required (--name)", plaintext, jsonOut) + os.Exit(1) + } + + if len(teamKeys) == 0 { + output.Error("At least one team is required (--team)", plaintext, jsonOut) + os.Exit(1) + } + + // Build input + input := map[string]interface{}{ + "name": name, + } + + // Resolve team IDs + var teamIDs []string + for _, teamKey := range teamKeys { + team, err := client.GetTeam(context.Background(), teamKey) + if err != nil { + output.Error(fmt.Sprintf("Failed to find team '%s': %v", teamKey, err), plaintext, jsonOut) + os.Exit(1) + } + teamIDs = append(teamIDs, team.ID) + } + input["teamIds"] = teamIDs + + // Optional fields + if cmd.Flags().Changed("description") { + description, _ := cmd.Flags().GetString("description") + input["description"] = description + } + + if cmd.Flags().Changed("state") { + state, _ := cmd.Flags().GetString("state") + validStates := []string{"planned", "started", "paused", "completed", "canceled"} + isValid := false + for _, vs := range validStates { + if strings.EqualFold(state, vs) { + input["state"] = strings.ToLower(state) + isValid = true + break + } + } + if !isValid { + output.Error(fmt.Sprintf("Invalid state '%s'. Valid states: %s", state, strings.Join(validStates, ", ")), plaintext, jsonOut) + os.Exit(1) + } + } + + if cmd.Flags().Changed("lead") { + leadValue, _ := cmd.Flags().GetString("lead") + if leadValue == "me" { + viewer, err := client.GetViewer(context.Background()) + if err != nil { + output.Error(fmt.Sprintf("Failed to get current user: %v", err), plaintext, jsonOut) + os.Exit(1) + } + input["leadId"] = viewer.ID + } else { + users, err := client.GetUsers(context.Background(), 100, "", "") + if err != nil { + output.Error(fmt.Sprintf("Failed to get users: %v", err), plaintext, jsonOut) + os.Exit(1) + } + var foundUser *api.User + for _, user := range users.Nodes { + if user.Email == leadValue || user.Name == leadValue { + foundUser = &user + break + } + } + if foundUser == nil { + output.Error(fmt.Sprintf("User not found: %s", leadValue), plaintext, jsonOut) + os.Exit(1) + } + input["leadId"] = foundUser.ID + } + } + + if cmd.Flags().Changed("start-date") { + startDate, _ := cmd.Flags().GetString("start-date") + input["startDate"] = startDate + } + + if cmd.Flags().Changed("target-date") { + targetDate, _ := cmd.Flags().GetString("target-date") + input["targetDate"] = targetDate + } + + if cmd.Flags().Changed("color") { + colorValue, _ := cmd.Flags().GetString("color") + input["color"] = colorValue + } + + // 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) + } + + // Output + if jsonOut { + output.JSON(project) + } else if plaintext { + fmt.Printf("Created project: %s\n", project.Name) + fmt.Printf("ID: %s\n", project.ID) + fmt.Printf("URL: %s\n", constructProjectURL(project.ID, project.URL)) + } else { + fmt.Printf("%s Created project %s\n", + color.New(color.FgGreen).Sprint("✓"), + color.New(color.FgCyan, color.Bold).Sprint(project.Name)) + fmt.Printf(" ID: %s\n", project.ID) + fmt.Printf(" URL: %s\n", color.New(color.FgBlue, color.Underline).Sprint(constructProjectURL(project.ID, project.URL))) + } + }, +} + +var projectUpdateCmd = &cobra.Command{ + Use: "update [project-id]", + Short: "Update a project", + Long: `Update an existing project's properties. + +Examples: + linctl project update abc123 --name "New Name" + linctl project update abc123 --description "Updated description" + linctl project update abc123 --state started + linctl project update abc123 --lead john@company.com + linctl project update abc123 --target-date 2024-12-31 + linctl project update abc123 --state completed`, + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + plaintext := viper.GetBool("plaintext") + jsonOut := viper.GetBool("json") + projectID := args[0] + + authHeader, err := auth.GetAuthHeader() + if err != nil { + output.Error("Not authenticated. Run 'linctl auth' first.", plaintext, jsonOut) + os.Exit(1) + } + + client := api.NewClient(authHeader) + + // Build update input + 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("state") { + state, _ := cmd.Flags().GetString("state") + validStates := []string{"planned", "started", "paused", "completed", "canceled"} + isValid := false + for _, vs := range validStates { + if strings.EqualFold(state, vs) { + input["state"] = strings.ToLower(state) + isValid = true + break + } + } + if !isValid { + output.Error(fmt.Sprintf("Invalid state '%s'. Valid states: %s", state, strings.Join(validStates, ", ")), plaintext, jsonOut) + os.Exit(1) + } + } + + if cmd.Flags().Changed("lead") { + leadValue, _ := cmd.Flags().GetString("lead") + switch strings.ToLower(leadValue) { + case "none", "unassigned", "": + input["leadId"] = nil + case "me": + viewer, err := client.GetViewer(context.Background()) + if err != nil { + output.Error(fmt.Sprintf("Failed to get current user: %v", err), plaintext, jsonOut) + os.Exit(1) + } + input["leadId"] = viewer.ID + default: + users, err := client.GetUsers(context.Background(), 100, "", "") + if err != nil { + output.Error(fmt.Sprintf("Failed to get users: %v", err), plaintext, jsonOut) + os.Exit(1) + } + var foundUser *api.User + for _, user := range users.Nodes { + if user.Email == leadValue || user.Name == leadValue { + foundUser = &user + break + } + } + if foundUser == nil { + output.Error(fmt.Sprintf("User not found: %s", leadValue), plaintext, jsonOut) + os.Exit(1) + } + input["leadId"] = foundUser.ID + } + } + + if cmd.Flags().Changed("start-date") { + startDate, _ := cmd.Flags().GetString("start-date") + if startDate == "" { + input["startDate"] = nil + } else { + input["startDate"] = startDate + } + } + + if cmd.Flags().Changed("target-date") { + targetDate, _ := cmd.Flags().GetString("target-date") + if targetDate == "" { + input["targetDate"] = nil + } else { + input["targetDate"] = targetDate + } + } + + if cmd.Flags().Changed("color") { + colorValue, _ := cmd.Flags().GetString("color") + input["color"] = colorValue + } + + // Check if any updates were specified + if len(input) == 0 { + output.Error("No updates specified. Use flags to specify what to update.", 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) + } + + // Output + if jsonOut { + output.JSON(project) + } else if plaintext { + fmt.Printf("Updated project: %s\n", project.Name) + } else { + fmt.Printf("%s Updated project %s\n", + color.New(color.FgGreen).Sprint("✓"), + color.New(color.FgCyan, color.Bold).Sprint(project.Name)) + } + }, +} + +var projectDeleteCmd = &cobra.Command{ + Use: "delete [project-id]", + Aliases: []string{"rm", "remove"}, + Short: "Delete or archive a project", + Long: `Delete or archive a project. + +By default, this command archives the project (soft delete). +Use --permanent to permanently delete (cannot be undone). + +Examples: + linctl project delete abc123 # Archive project + linctl project delete abc123 --permanent # Permanent delete (use with caution) + linctl project delete abc123 --force # Skip confirmation prompt`, + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + plaintext := viper.GetBool("plaintext") + jsonOut := viper.GetBool("json") + projectID := args[0] + + authHeader, err := auth.GetAuthHeader() + if err != nil { + output.Error("Not authenticated. Run 'linctl auth' first.", plaintext, jsonOut) + os.Exit(1) + } + + client := api.NewClient(authHeader) + + permanent, _ := cmd.Flags().GetBool("permanent") + force, _ := cmd.Flags().GetBool("force") + + // Get project details for confirmation message + project, err := client.GetProject(context.Background(), projectID) + if err != nil { + output.Error(fmt.Sprintf("Failed to find project '%s': %v", projectID, err), plaintext, jsonOut) + os.Exit(1) + } + + // Confirmation prompt (unless --force or --json) + if !force && !jsonOut { + action := "archive" + if permanent { + action = "PERMANENTLY DELETE" + } + fmt.Printf("Are you sure you want to %s project '%s'? [y/N]: ", action, project.Name) + + var response string + fmt.Scanln(&response) + response = strings.ToLower(strings.TrimSpace(response)) + + if response != "y" && response != "yes" { + fmt.Println("Cancelled.") + return + } + } + + if permanent { + // Permanent delete + err = client.DeleteProject(context.Background(), projectID) + if err != nil { + output.Error(fmt.Sprintf("Failed to delete project: %v", err), plaintext, jsonOut) + os.Exit(1) + } + + if jsonOut { + output.JSON(map[string]interface{}{ + "success": true, + "action": "deleted", + "projectId": projectID, + "name": project.Name, + }) + } else if plaintext { + fmt.Printf("Deleted project: %s\n", project.Name) + } else { + fmt.Printf("%s Permanently deleted project %s\n", + color.New(color.FgRed).Sprint("✗"), + color.New(color.FgCyan, color.Bold).Sprint(project.Name)) + } + } else { + // Archive (soft delete) + archivedProject, err := client.ArchiveProject(context.Background(), projectID) + if err != nil { + output.Error(fmt.Sprintf("Failed to archive project: %v", err), plaintext, jsonOut) + os.Exit(1) + } + + if jsonOut { + output.JSON(archivedProject) + } else if plaintext { + fmt.Printf("Archived project: %s\n", project.Name) + } else { + fmt.Printf("%s Archived project %s\n", + color.New(color.FgYellow).Sprint("📦"), + color.New(color.FgCyan, color.Bold).Sprint(project.Name)) + } + } + }, +} + func init() { rootCmd.AddCommand(projectCmd) projectCmd.AddCommand(projectListCmd) projectCmd.AddCommand(projectGetCmd) + projectCmd.AddCommand(projectCreateCmd) + projectCmd.AddCommand(projectUpdateCmd) + projectCmd.AddCommand(projectDeleteCmd) // List command flags projectListCmd.Flags().StringP("team", "t", "", "Filter by team key") @@ -594,4 +979,29 @@ 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().StringSliceP("team", "t", []string{}, "Team key(s) (required, comma-separated for multiple)") + projectCreateCmd.Flags().StringP("description", "d", "", "Project description") + projectCreateCmd.Flags().StringP("state", "s", "", "Initial state (planned, started, paused)") + projectCreateCmd.Flags().String("lead", "", "Project lead (email, name, or 'me')") + projectCreateCmd.Flags().String("start-date", "", "Start date (YYYY-MM-DD)") + projectCreateCmd.Flags().String("target-date", "", "Target date (YYYY-MM-DD)") + projectCreateCmd.Flags().String("color", "", "Project color (hex code)") + _ = projectCreateCmd.MarkFlagRequired("name") + _ = projectCreateCmd.MarkFlagRequired("team") + + // Update command flags + projectUpdateCmd.Flags().String("name", "", "New project name") + projectUpdateCmd.Flags().StringP("description", "d", "", "New description") + projectUpdateCmd.Flags().StringP("state", "s", "", "State (planned, started, paused, completed, canceled)") + projectUpdateCmd.Flags().String("lead", "", "Project lead (email, name, 'me', or 'none' to remove)") + projectUpdateCmd.Flags().String("start-date", "", "Start date (YYYY-MM-DD, or empty to remove)") + projectUpdateCmd.Flags().String("target-date", "", "Target date (YYYY-MM-DD, or empty to remove)") + projectUpdateCmd.Flags().String("color", "", "Project color (hex code)") + + // Delete command flags + projectDeleteCmd.Flags().Bool("permanent", false, "Permanently delete (cannot be undone)") + projectDeleteCmd.Flags().BoolP("force", "f", false, "Skip confirmation prompt") } 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..39b6a6b 100644 --- a/pkg/api/queries.go +++ b/pkg/api/queries.go @@ -3,6 +3,7 @@ package api import ( "context" "encoding/json" + "fmt" "time" ) @@ -1081,6 +1082,183 @@ func (c *Client) GetProject(ctx context.Context, id string) (*Project, error) { return &response.Project, 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 +} + +// UpdateProject updates an existing project +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 + 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 +} + +// DeleteProject permanently deletes a project +func (c *Client) DeleteProject(ctx context.Context, id string) error { + query := ` + mutation DeleteProject($id: String!) { + projectDelete(id: $id) { + success + } + } + ` + + variables := map[string]interface{}{ + "id": id, + } + + var response struct { + ProjectDelete struct { + Success bool `json:"success"` + } `json:"projectDelete"` + } + + err := c.Execute(ctx, query, variables, &response) + if err != nil { + return err + } + + if !response.ProjectDelete.Success { + return fmt.Errorf("project deletion was not successful") + } + + return nil +} + +// ArchiveProject archives a project (soft delete) +func (c *Client) ArchiveProject(ctx context.Context, id string) (*Project, error) { + query := ` + mutation ArchiveProject($id: String!) { + projectArchive(id: $id) { + success + entity { + id + name + archivedAt + } + } + } + ` + + variables := map[string]interface{}{ + "id": id, + } + + var response struct { + ProjectArchive struct { + Success bool `json:"success"` + Entity Project `json:"entity"` + } `json:"projectArchive"` + } + + err := c.Execute(ctx, query, variables, &response) + if err != nil { + return nil, err + } + + return &response.ProjectArchive.Entity, nil +} + // UpdateIssue updates an issue's fields func (c *Client) UpdateIssue(ctx context.Context, id string, input map[string]interface{}) (*Issue, error) { query := ` @@ -1119,6 +1297,11 @@ func (c *Client) UpdateIssue(ctx context.Context, id string, input map[string]in color } } + parent { + id + identifier + title + } } } } diff --git a/smoke_test.sh b/smoke_test.sh index cbbd3d6..a2735e6 100755 --- a/smoke_test.sh +++ b/smoke_test.sh @@ -148,6 +148,8 @@ fi echo -e "\n${YELLOW}Testing help commands...${NC}" run_test "help" "go run main.go --help" "Usage:" run_test "issue help" "go run main.go issue --help" "Available Commands:" +run_test "issue update help" "go run main.go issue update --help" "update" +run_test "issue update --parent flag" "go run main.go issue update --help" "parent" run_test "project help" "go run main.go project --help" "Available Commands:" run_test "team help" "go run main.go team --help" "Available Commands:" run_test "user help" "go run main.go user --help" "Available Commands:"