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
54 changes: 33 additions & 21 deletions internal/cli/issues.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ import (
"strconv"

"github.com/joa23/linear-cli/internal/format"
paginationutil "github.com/joa23/linear-cli/pkg/linear/pagination"
"github.com/joa23/linear-cli/internal/service"
paginationutil "github.com/joa23/linear-cli/pkg/linear/pagination"
"github.com/spf13/cobra"
)

Expand Down Expand Up @@ -40,21 +40,21 @@ func newIssuesCmd() *cobra.Command {

func newIssuesListCmd() *cobra.Command {
var (
teamID string
project string
state string
priority string
assignee string
cycle string
labels string
teamID string
project string
state string
priority string
assignee string
cycle string
labels string
excludeLabels string
sortBy string
sortBy string
createdSince string
createdAfter string
createdBefore string
limit int
formatStr string
outputType string
limit int
formatStr string
outputType string
)

cmd := &cobra.Command{
Expand Down Expand Up @@ -243,7 +243,7 @@ Images in the description (uploads.linear.app/...) require auth — use:

# Download a private image from the issue description
linear attachments download "https://uploads.linear.app/..."`,
Args: cobra.ExactArgs(1),
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
issueID := args[0]

Expand Down Expand Up @@ -442,7 +442,7 @@ TIP: Run 'linear init' first to set default team.`,

output, err := deps.Issues.Create(input, outType)
if err != nil {
return fmt.Errorf("failed to create issue: %w", err)
return err
}

fmt.Println(output)
Expand Down Expand Up @@ -534,16 +534,18 @@ LABEL MODES:
return err
}

// Get team from flag or config (for cycle resolution)
// Get team from flag or config (for cycle resolution)
if team == "" {
team = GetDefaultTeam()
}
// Note: team can still be "" if no .linear.yaml, will fallback to issue identifier

// Check if any updates provided (description="-" means stdin)
labelsChanged := cmd.Flags().Changed("labels")
addLabelsChanged := cmd.Flags().Changed("add-labels")
removeLabelsChanged := cmd.Flags().Changed("remove-labels")
hasFlags := title != "" || description != "" || state != "" ||
priority != "" || estimate != "" || labels != "" ||
addLabels != "" || removeLabels != "" ||
priority != "" || estimate != "" || labelsChanged || addLabelsChanged || removeLabelsChanged ||
cycle != "" || project != "" || assignee != "" ||
dueDate != "" || parent != "" || dependsOn != "" || blockedBy != "" ||
len(attachFiles) > 0
Expand All @@ -553,8 +555,8 @@ LABEL MODES:
}

// 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")
if err := validateIssueLabelModeFlags(labelsChanged, addLabelsChanged, removeLabelsChanged); err != nil {
return err
}

// Get description from flag or stdin
Expand Down Expand Up @@ -597,8 +599,11 @@ LABEL MODES:
}
input.Estimate = &e
}
if labels != "" {
if labelsChanged {
input.LabelIDs = parseCommaSeparated(labels)
if input.LabelIDs == nil {
input.LabelIDs = []string{}
}
}
if addLabels != "" {
input.AddLabelIDs = parseCommaSeparated(addLabels)
Expand Down Expand Up @@ -636,7 +641,7 @@ LABEL MODES:

output, err := deps.Issues.Update(issueID, input)
if err != nil {
return fmt.Errorf("failed to update issue: %w", err)
return err
}

fmt.Println(output)
Expand Down Expand Up @@ -666,6 +671,13 @@ LABEL MODES:
return cmd
}

func validateIssueLabelModeFlags(labelsChanged, addLabelsChanged, removeLabelsChanged bool) error {
if labelsChanged && (addLabelsChanged || removeLabelsChanged) {
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")
}
return nil
}

func newIssuesCommentCmd() *cobra.Command {
var (
body string
Expand Down
18 changes: 18 additions & 0 deletions internal/cli/issues_update_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package cli

import "testing"

func TestValidateIssueLabelModeFlagsUsesFlagPresence(t *testing.T) {
if err := validateIssueLabelModeFlags(true, true, false); err == nil {
t.Fatal("expected empty --add-labels presence to conflict with --labels")
}
if err := validateIssueLabelModeFlags(true, false, true); err == nil {
t.Fatal("expected empty --remove-labels presence to conflict with --labels")
}
if err := validateIssueLabelModeFlags(false, true, true); err != nil {
t.Fatalf("add and remove modes should be compatible: %v", err)
}
if err := validateIssueLabelModeFlags(true, false, false); err != nil {
t.Fatalf("replace-only mode returned %v", err)
}
}
199 changes: 199 additions & 0 deletions internal/format/labels.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
package format

import (
"fmt"
"sort"
"strings"

"github.com/joa23/linear-cli/pkg/linear/core"
)

// LabelListOptions controls the fields included in text label listings.
type LabelListOptions struct {
// IncludeIDs includes the Linear ID on each label and group header.
IncludeIDs bool
}

// FormatLabels renders labels as a deterministic, grouped text list. Labels
// with a Parent are rendered as alternatives under one parent header, even
// when the API returns the parent after its children (or omits it entirely).
// The input labels are never modified.
func FormatLabels(labels []core.Label, options LabelListOptions) string {
if len(labels) == 0 {
return "No labels found."
}

parents := make(map[string]core.Label)
groups := make(map[string][]core.Label)
for _, label := range labels {
if label.Parent == nil {
parents[label.ID] = label
continue
}

// Parent ID is the authoritative group key. Keep a name fallback for
// incomplete fixtures/responses so children are not accidentally merged.
key := label.Parent.ID
if key == "" {
key = "name:" + label.Parent.Name
}
groups[key] = append(groups[key], label)
}

// A parent that has children is a group header, not also a standalone
// record. Other parent labels remain standalone labels.
groupParents := make(map[string]core.Label)
for key := range groups {
if parent, ok := parents[key]; ok {
groupParents[key] = parent
delete(parents, key)
}
groups[key] = sortLabels(groups[key])
}
standalone := make([]core.Label, 0, len(parents))
for _, label := range parents {
standalone = append(standalone, label)
}
standalone = sortLabels(standalone)

type entry struct {
key string
parent *core.Label
ref *core.LabelRef
labels []core.Label
}
entries := make([]entry, 0, len(groups)+len(standalone))
for key, children := range groups {
if parent, ok := groupParents[key]; ok {
parentCopy := parent
entries = append(entries, entry{key: labelSortKey(parent), parent: &parentCopy, labels: children})
continue
}
ref := groupReference(labels, key)
entries = append(entries, entry{key: labelSortKeyRef(ref), ref: ref, labels: children})
}
for _, label := range standalone {
labelCopy := label
entries = append(entries, entry{key: labelSortKey(label), parent: &labelCopy})
}
sort.SliceStable(entries, func(i, j int) bool { return entries[i].key < entries[j].key })

var b strings.Builder
b.WriteString(fmt.Sprintf("LABELS (%d)\n", len(labels)))
b.WriteString(line(40))
b.WriteByte('\n')
for _, item := range entries {
if item.parent != nil && len(item.labels) > 0 {
b.WriteString(" GROUP: ")
writeLabel(&b, *item.parent, options.IncludeIDs)
writeDescription(&b, " ", item.parent.Description)
for _, child := range item.labels {
writeLabelIndented(&b, child, " ", options.IncludeIDs)
writeDescription(&b, " ", child.Description)
}
continue
}
if item.ref != nil {
b.WriteString(" GROUP: ")
b.WriteString(labelReferenceName(*item.ref))
if options.IncludeIDs && item.ref.ID != "" {
b.WriteString(" [")
b.WriteString(item.ref.ID)
b.WriteString("]")
}
b.WriteByte('\n')
for _, child := range item.labels {
writeLabelIndented(&b, child, " ", options.IncludeIDs)
writeDescription(&b, " ", child.Description)
}
continue
}
writeLabelIndented(&b, *item.parent, " ", options.IncludeIDs)
writeDescription(&b, " ", item.parent.Description)
}
return b.String()
}

// LabelList renders the label-list text format used by services.
func (f *Formatter) LabelList(labels []core.Label, includeIDs bool) string {
return FormatLabels(labels, LabelListOptions{IncludeIDs: includeIDs})
}

// Labels renders the compact label-list text format.
// Deprecated compatibility entry point; new callers should use LabelList.
func (f *Formatter) Labels(labels []core.Label) string {
return FormatLabels(labels, LabelListOptions{})
}

func sortLabels(labels []core.Label) []core.Label {
sort.SliceStable(labels, func(i, j int) bool {
return labelSortKey(labels[i]) < labelSortKey(labels[j])
})
return labels
}

func labelSortKey(label core.Label) string {
return strings.ToLower(label.Name) + "\x00" + label.Name + "\x00" + label.ID
}

func labelSortKeyRef(ref *core.LabelRef) string {
if ref == nil {
return ""
}
return strings.ToLower(ref.Name) + "\x00" + ref.Name + "\x00" + ref.ID
}

func groupReference(labels []core.Label, key string) *core.LabelRef {
var refs []core.LabelRef
for _, label := range labels {
if label.Parent == nil {
continue
}
parentKey := label.Parent.ID
if parentKey == "" {
parentKey = "name:" + label.Parent.Name
}
if parentKey == key {
refs = append(refs, *label.Parent)
}
}
if len(refs) == 0 {
return nil
}
sort.Slice(refs, func(i, j int) bool { return labelSortKeyRef(&refs[i]) < labelSortKeyRef(&refs[j]) })
return &refs[0]
}

func labelReferenceName(ref core.LabelRef) string {
if ref.Name != "" {
return ref.Name
}
return ref.ID
}

func writeLabelIndented(b *strings.Builder, label core.Label, indent string, includeID bool) {
b.WriteString(indent)
writeLabel(b, label, includeID)
}

func writeLabel(b *strings.Builder, label core.Label, includeID bool) {
b.WriteString(label.Name)
if label.Color != "" {
b.WriteString(" [")
b.WriteString(label.Color)
b.WriteString("]")
}
if includeID && label.ID != "" {
b.WriteByte(' ')
b.WriteString(label.ID)
}
b.WriteByte('\n')
}

func writeDescription(b *strings.Builder, indent, description string) {
if description != "" {
b.WriteString(indent)
b.WriteString(description)
b.WriteByte('\n')
}
}
Loading