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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 53 additions & 17 deletions internal/cli/issues.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 <issue-id>",
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

Expand All @@ -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),
Expand All @@ -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
Expand All @@ -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 {
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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)")
Expand Down
97 changes: 74 additions & 23 deletions internal/service/issue.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down