mirror of
https://gitea.com/gitea/tea.git
synced 2026-10-26 11:01:25 +01:00
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>
138 lines
4.2 KiB
Go
138 lines
4.2 KiB
Go
// Copyright 2026 The Gitea Authors. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package services
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
// 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 {
|
|
candidates := matches[name]
|
|
if len(candidates) == 0 {
|
|
return nil, fmt.Errorf("label %q does not exist; use tea label list", name)
|
|
}
|
|
if len(candidates) > 1 {
|
|
return nil, &AmbiguousError{Kind: "label", Key: strconv.Quote(name), Scope: scope, IDs: candidates}
|
|
}
|
|
if !seen[candidates[0]] {
|
|
ids = append(ids, candidates[0])
|
|
seen[candidates[0]] = true
|
|
}
|
|
}
|
|
return ids, nil
|
|
}
|
|
|
|
// ResolveIssueNames includes labels shared by the repository's organization.
|
|
// Duplicate names require an explicit scope to avoid choosing a different label.
|
|
func (s Labels) ResolveIssueNames(ctx context.Context, names []string) ([]int64, error) {
|
|
if len(names) == 0 {
|
|
return []int64{}, nil
|
|
}
|
|
for _, name := range names {
|
|
if strings.TrimSpace(name) == "" {
|
|
return nil, fmt.Errorf("label names cannot be empty")
|
|
}
|
|
}
|
|
scopes := []Labels{s}
|
|
owner, _, _ := strings.Cut(s.Repository, "/")
|
|
_, isOrg, err := organization(ctx, s.Client, owner)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if isOrg {
|
|
scopes = append(scopes, Labels{Client: s.Client, Organization: owner})
|
|
}
|
|
type labelKey struct{ scope, name string }
|
|
index := map[labelKey][]int64{}
|
|
for _, scope := range scopes {
|
|
scopeName := "repo"
|
|
if scope.Organization != "" {
|
|
scopeName = "org"
|
|
}
|
|
labels, err := scope.All(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, label := range labels {
|
|
for _, key := range []labelKey{{scopeName, label.Name}, {"", label.Name}} {
|
|
index[key] = append(index[key], label.ID)
|
|
}
|
|
}
|
|
}
|
|
matches := map[string][]int64{}
|
|
for _, input := range names {
|
|
scope, name := splitLabelScope(input)
|
|
matches[input] = index[labelKey{scope, name}]
|
|
}
|
|
return selectLabelNames(s.Repository, names, matches)
|
|
}
|
|
|
|
func splitLabelScope(input string) (scope, name string) {
|
|
for _, prefix := range []string{"repo:", "org:"} {
|
|
if after, ok := strings.CutPrefix(input, prefix); ok {
|
|
return strings.TrimSuffix(prefix, ":"), after
|
|
}
|
|
}
|
|
return "", input
|
|
}
|
|
|
|
// RequireFilterNames rejects names Gitea's repository issue filter cannot match: it
|
|
// matches repository labels by exact name and silently ignores every other name.
|
|
func (s Labels) RequireFilterNames(ctx context.Context, names []string) error {
|
|
labels, err := s.All(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
known := make(map[string]bool, len(labels))
|
|
for _, label := range labels {
|
|
known[label.Name] = true
|
|
}
|
|
for _, name := range names {
|
|
if !known[name] {
|
|
return fmt.Errorf("label %q does not exist in %s; issue filters match repository labels only, see tea label list", name, s.Repository)
|
|
}
|
|
}
|
|
return nil
|
|
}
|