From 5bcb3855e2de2cd555fafe9e7fa6532c8ada910f Mon Sep 17 00:00:00 2001 From: michael-ford Date: Sun, 8 Mar 2026 14:14:20 -0700 Subject: [PATCH] feat: add --add-labels and --remove-labels flags to issues update The existing --labels flag uses replace semantics, sending a full labelIds array to the Linear API and wiping any existing labels. This is problematic for workflows that need to incrementally add or remove labels without affecting the rest. New flags: - --add-labels: adds labels to the issue without removing existing ones - --remove-labels: removes specific labels without affecting others Both can be used together in a single command. They are mutually exclusive with --labels (which remains as the replace-all mode). Implementation: - CLI layer: new flags with mutual exclusivity validation - Service layer: fetch current labels from issue, resolve names to IDs, merge/subtract using a set, then send combined labelIds - No new GraphQL queries needed: GetIssue already returns labels --- internal/cli/issues.go | 70 +++++++++++++++++++++------- internal/service/issue.go | 97 +++++++++++++++++++++++++++++---------- 2 files changed, 127 insertions(+), 40 deletions(-) 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