diff --git a/internal/cli/issues.go b/internal/cli/issues.go index 0436079..f894fab 100644 --- a/internal/cli/issues.go +++ b/internal/cli/issues.go @@ -440,27 +440,37 @@ TIP: Run 'linear init' first to set default team.`, func newIssuesUpdateCmd() *cobra.Command { var ( - team string - title string - description string - state string - priority string - estimate string - labels string - cycle string - project string - assignee string - dueDate string - parent string - dependsOn string - blockedBy string - attachFiles []string + team string + title string + description string + state string + priority string + estimate string + labels string + addLabels string + removeLabels string + cycle string + project string + assignee string + dueDate string + parent string + dependsOn string + blockedBy string + attachFiles []string ) cmd := &cobra.Command{ Use: "update ", Short: "Update an existing issue", - Long: `Update an existing issue. Only provided flags are changed.`, + Long: `Update an existing issue. Only provided flags are changed. + +LABEL MODES: + --labels Replace ALL labels (removes existing, sets new ones) + --add-labels Add labels without removing existing ones + --remove-labels Remove specific labels without affecting others + + --add-labels and --remove-labels can be used together. + --labels cannot be combined with --add-labels or --remove-labels.`, Example: ` # Update state and priority linear issues update CEN-123 --state Done --priority 0 @@ -470,6 +480,18 @@ func newIssuesUpdateCmd() *cobra.Command { # Assign to yourself linear issues update CEN-123 --assignee me + # Replace all labels (existing labels are removed) + linear issues update CEN-123 --labels "backend,security" + + # Add labels without removing existing ones + linear issues update CEN-123 --add-labels "bug,urgent" + + # Remove specific labels + linear issues update CEN-123 --remove-labels "wontfix" + + # Add and remove labels in one command + linear issues update CEN-123 --add-labels "in-progress" --remove-labels "backlog" + # Update description from file (use - for stdin) cat updated-spec.md | linear issues update CEN-123 -d -`, Args: cobra.ExactArgs(1), @@ -489,6 +511,7 @@ func newIssuesUpdateCmd() *cobra.Command { // Check if any updates provided (description="-" means stdin) hasFlags := title != "" || description != "" || state != "" || priority != "" || estimate != "" || labels != "" || + addLabels != "" || removeLabels != "" || cycle != "" || project != "" || assignee != "" || dueDate != "" || parent != "" || dependsOn != "" || blockedBy != "" || len(attachFiles) > 0 @@ -497,6 +520,11 @@ func newIssuesUpdateCmd() *cobra.Command { return fmt.Errorf("no updates specified. Use flags like --state, --priority, etc") } + // Validate mutual exclusivity: --labels cannot be used with --add-labels or --remove-labels + if labels != "" && (addLabels != "" || removeLabels != "") { + return fmt.Errorf("--labels cannot be combined with --add-labels or --remove-labels. Use --labels to replace all labels, or --add-labels/--remove-labels for incremental changes") + } + // Get description from flag or stdin desc, err := getDescriptionFromFlagOrStdin(description) if err != nil { @@ -540,6 +568,12 @@ func newIssuesUpdateCmd() *cobra.Command { if labels != "" { input.LabelIDs = parseCommaSeparated(labels) } + if addLabels != "" { + input.AddLabelIDs = parseCommaSeparated(addLabels) + } + if removeLabels != "" { + input.RemoveLabelIDs = parseCommaSeparated(removeLabels) + } if cycle != "" { input.CycleID = &cycle } @@ -584,7 +618,9 @@ func newIssuesUpdateCmd() *cobra.Command { cmd.Flags().StringVarP(&state, "state", "s", "", "Update workflow state name (e.g., 'In Progress', 'Backlog')") cmd.Flags().StringVarP(&priority, "priority", "p", "", "Priority: 0-4 or none/urgent/high/normal/low") cmd.Flags().StringVarP(&estimate, "estimate", "e", "", "Update story points estimate") - cmd.Flags().StringVarP(&labels, "labels", "l", "", "Update labels (comma-separated)") + cmd.Flags().StringVarP(&labels, "labels", "l", "", "Replace ALL labels (comma-separated); removes existing labels") + cmd.Flags().StringVar(&addLabels, "add-labels", "", "Add labels without removing existing ones (comma-separated)") + cmd.Flags().StringVar(&removeLabels, "remove-labels", "", "Remove specific labels without affecting others (comma-separated)") cmd.Flags().StringVarP(&cycle, "cycle", "c", "", "Update cycle number or name") cmd.Flags().StringVarP(&project, "project", "P", "", ProjectFlagDescription) cmd.Flags().StringVarP(&assignee, "assignee", "a", "", "Update assignee name or email (use 'me' for yourself)") diff --git a/internal/service/issue.go b/internal/service/issue.go index ebab9a4..91fb110 100644 --- a/internal/service/issue.go +++ b/internal/service/issue.go @@ -549,20 +549,22 @@ func (s *IssueService) Create(input *CreateIssueInput) (string, error) { // UpdateIssueInput represents input for updating an issue type UpdateIssueInput struct { - Title *string - Description *string - StateID *string - AssigneeID *string - ProjectID *string - ParentID *string - TeamID *string - CycleID *string - Priority *int - Estimate *float64 - DueDate *string - LabelIDs []string - DependsOn []string // Issue identifiers this issue depends on (stored in metadata) - BlockedBy []string // Issue identifiers that block this issue (stored in metadata) + Title *string + Description *string + StateID *string + AssigneeID *string + ProjectID *string + ParentID *string + TeamID *string + CycleID *string + Priority *int + Estimate *float64 + DueDate *string + LabelIDs []string // Replace mode: replaces all labels + AddLabelIDs []string // Additive mode: labels to add (names, resolved later) + RemoveLabelIDs []string // Subtractive mode: labels to remove (names, resolved later) + DependsOn []string // Issue identifiers this issue depends on (stored in metadata) + BlockedBy []string // Issue identifiers that block this issue (stored in metadata) } // Update updates an existing issue @@ -679,7 +681,8 @@ func (s *IssueService) Update(identifier string, input *UpdateIssueInput) (strin } linearInput.CycleID = &cycleID } - if len(input.LabelIDs) > 0 { + hasLabelChanges := len(input.LabelIDs) > 0 || len(input.AddLabelIDs) > 0 || len(input.RemoveLabelIDs) > 0 + if hasLabelChanges { // Resolve team ID for label resolution var teamIDForLabels string var err error @@ -701,16 +704,52 @@ func (s *IssueService) Update(identifier string, input *UpdateIssueInput) (strin } } - // Resolve label names to IDs - resolvedLabelIDs := make([]string, 0, len(input.LabelIDs)) - for _, labelName := range input.LabelIDs { - labelID, err := s.client.ResolveLabelIdentifier(labelName, teamIDForLabels) - if err != nil { - return "", fmt.Errorf("failed to resolve label '%s': %w", labelName, err) + if len(input.LabelIDs) > 0 { + // Replace mode: resolve label names to IDs and set directly + resolvedLabelIDs := make([]string, 0, len(input.LabelIDs)) + for _, labelName := range input.LabelIDs { + labelID, err := s.client.ResolveLabelIdentifier(labelName, teamIDForLabels) + if err != nil { + return "", fmt.Errorf("failed to resolve label '%s': %w", labelName, err) + } + resolvedLabelIDs = append(resolvedLabelIDs, labelID) } - resolvedLabelIDs = append(resolvedLabelIDs, labelID) + linearInput.LabelIDs = resolvedLabelIDs + } else { + // Additive/subtractive mode: fetch current labels, merge/remove, then set + currentLabelIDs := s.extractCurrentLabelIDs(issue) + + // Build a set from current labels for efficient merge/remove + labelSet := make(map[string]bool, len(currentLabelIDs)) + for _, id := range currentLabelIDs { + labelSet[id] = true + } + + // Add new labels + for _, labelName := range input.AddLabelIDs { + labelID, err := s.client.ResolveLabelIdentifier(labelName, teamIDForLabels) + if err != nil { + return "", fmt.Errorf("failed to resolve label '%s': %w", labelName, err) + } + labelSet[labelID] = true + } + + // Remove labels + for _, labelName := range input.RemoveLabelIDs { + labelID, err := s.client.ResolveLabelIdentifier(labelName, teamIDForLabels) + if err != nil { + return "", fmt.Errorf("failed to resolve label '%s': %w", labelName, err) + } + delete(labelSet, labelID) + } + + // Convert set back to slice + finalLabelIDs := make([]string, 0, len(labelSet)) + for id := range labelSet { + finalLabelIDs = append(finalLabelIDs, id) + } + linearInput.LabelIDs = finalLabelIDs } - linearInput.LabelIDs = resolvedLabelIDs } // Perform update only if there are GraphQL fields to update @@ -819,6 +858,18 @@ func hasServiceFieldsToUpdate(input core.UpdateIssueInput) bool { len(input.LabelIDs) > 0 } +// extractCurrentLabelIDs extracts the current label IDs from an issue +func (s *IssueService) extractCurrentLabelIDs(issue *core.Issue) []string { + if issue.Labels == nil || len(issue.Labels.Nodes) == 0 { + return nil + } + ids := make([]string, len(issue.Labels.Nodes)) + for i, label := range issue.Labels.Nodes { + ids[i] = label.ID + } + return ids +} + // resolveStateID resolves a state name to a valid state ID func (s *IssueService) resolveStateID(stateName, teamID string) (string, error) { // Always resolve by name - no UUID support