fix(label): refuse duplicate label names unless --force

tea label create --name bug twice created a second "bug" label, after
which issue edit --add-labels bug failed as ambiguous and issue list
--label bug matched nothing. label create (and --file imports) now fail
with "label "bug" already exists in alice/project; pass --force to
update it", ignoring case; --force updates the existing label. The
ambiguity error for labels that already share a name lists their IDs.

Signed-off-by: bircni <bircni@icloud.com>
This commit is contained in:
bircni committed 2026-10-12 00:14:30 +02:00
1 parent c1d51c386d
commit 0d013746b5
5 files changed
+92 -10

No files matched your search

+38 -3
View File
@@ -5,11 +5,46 @@ package services
import (
"context"
"errors"
"fmt"
"strconv"
"strings"
)
func selectLabelNames(names []string, matches map[string][]int64) ([]int64, error) {
// Scope names the repository or organization the labels belong to.
func (s Labels) Scope() string {
if s.Organization != "" {
return s.Organization
}
return s.Repository
}
// finder addresses the labels of the scope by ID or name (D10); byName matches numeric values as
// names only.
func (s Labels) finder(byName bool) NameOrID[Label] {
return NameOrID[Label]{Kind: "label", Scope: s.Scope(), ID: func(l Label) int64 { return l.ID }, Name: func(l Label) string { return l.Name }, ByName: byName}
}
// CreateUnique creates a label unless existing, the labels of the scope, has one with its name,
// ignoring case. Then update edits that label with the supplied fields, keeping its name, and
// otherwise CreateUnique fails: Gitea would create a second label that name filters cannot tell
// apart.
func (s Labels) CreateUnique(ctx context.Context, existing []Label, opts LabelOptions, update bool) (Label, error) {
current, err := s.finder(true).Find(existing, deref(opts.Name))
if errors.As(err, new(*NotFoundError)) {
return s.Create(ctx, opts)
}
if err != nil {
return Label{}, err
}
if !update {
return Label{}, &ConflictError{Reason: ReasonExists, Kind: "label", Key: strconv.Quote(current.Name), Scope: s.Scope(), Hint: "pass --force to update it"}
}
opts.Name = nil
return s.Edit(ctx, current.ID, opts)
}
func selectLabelNames(scope string, names []string, matches map[string][]int64) ([]int64, error) {
ids := make([]int64, 0, len(names))
seen := map[int64]bool{}
for _, name := range names {
@@ -18,7 +53,7 @@ func selectLabelNames(names []string, matches map[string][]int64) ([]int64, erro
return nil, fmt.Errorf("label %q does not exist; use tea label list", name)
}
if len(candidates) > 1 {
return nil, fmt.Errorf("label %q is ambiguous", name)
return nil, &AmbiguousError{Kind: "label", Key: strconv.Quote(name), Scope: scope, IDs: candidates}
}
if !seen[candidates[0]] {
ids = append(ids, candidates[0])
@@ -70,7 +105,7 @@ func (s Labels) ResolveIssueNames(ctx context.Context, names []string) ([]int64,
scope, name := splitLabelScope(input)
matches[input] = index[labelKey{scope, name}]
}
return selectLabelNames(names, matches)
return selectLabelNames(s.Repository, names, matches)
}
func splitLabelScope(input string) (scope, name string) {
+1 -1
View File
@@ -66,7 +66,7 @@ func TestIssueLabelsCombineScopesWithoutSilentlyChoosingDuplicates(t *testing.T)
}))
service := Labels{Client: client, Repository: "company/project"}
_, err := service.ResolveIssueNames(context.Background(), []string{"bug"})
require.ErrorContains(t, err, "ambiguous")
require.EqualError(t, err, `label "bug" is ambiguous in company/project (IDs 1, 2); pass an ID`)
ids, err := service.ResolveIssueNames(context.Background(), []string{"org:bug", "repo:bug", "shared", "shared"})
require.NoError(t, err)
require.Equal(t, []int64{2, 1, 3}, ids)