Files
gitea-tea/internal/services/metadata.go
T
bircni 2ba02cf915 feat(label): address labels by name and add import and clone
label edit bug and label delete kind/feature failed with "invalid resource
number", and label create took its name as --name and required
--color. Like gh, label create takes the name as an argument and picks
a random color without -c; label edit and delete take names or IDs
(delete several at once, after a confirmation that names them), and
-c/-d/-n/-f are the gh short flags. Without a name on a terminal, label
create asks for the name, color and description.

label list shows ID NAME COLOR DESCRIPTION FLAGS with a colored swatch
and exclusive and archived labels flagged. Mutations report ✓ lines.
label import reads a label file or stdin (label create --file still
works), label clone copies another repository's labels, skipping those
that exist unless --force, and label export writes to stdout by default
or to -O/--output.

Signed-off-by: bircni <bircni@icloud.com>
2026-10-12 00:16:09 +02:00

402 lines
14 KiB
Go

// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package services
import (
"context"
"fmt"
"net/http"
"net/url"
"slices"
"strings"
"time"
gitea "gitea.dev/sdk"
"gitea.dev/tea/internal/transport"
)
// Label is a repository or organization label.
type Label struct {
ID int64 `json:"id"`
Name string `json:"name"`
Color string `json:"color"`
Description string `json:"description"`
Exclusive bool `json:"exclusive"`
Archived bool `json:"is_archived"`
}
// LabelOptions distinguishes omitted label fields during edits.
type LabelOptions struct {
Name *string `json:"name,omitempty"`
Color *string `json:"color,omitempty"`
Description *string `json:"description,omitempty"`
Exclusive *bool `json:"exclusive,omitempty"`
Archived *bool `json:"is_archived,omitempty"`
}
// Labels implements explicitly scoped label operations.
type Labels struct {
Client *transport.Client
Repository, Organization string
}
func (s Labels) orgEndpoint() string { return "orgs/" + url.PathEscape(s.Organization) + "/labels" }
// List returns scoped labels without merging unrelated organization labels.
func (s Labels) List(ctx context.Context, limit, page int) ([]Label, error) {
labels, _, err := s.list(ctx, limit, page)
if err != nil {
return nil, err
}
return convertAll(labels, labelFrom), nil
}
// ListPage is List with the number of labels in the scope, as the server counts them.
func (s Labels) ListPage(ctx context.Context, limit, page int) (Page[Label], error) {
labels, err := listPage(limit, page, func(limit, page int) ([]*gitea.Label, http.Header, error) {
labels, resp, err := s.list(ctx, limit, page)
return labels, headerOf(resp), err
})
return Page[Label]{Items: convertAll(labels.Items, labelFrom), More: labels.More, Total: labels.Total, Counted: labels.Counted}, err
}
func (s Labels) list(ctx context.Context, limit, page int) ([]*gitea.Label, *gitea.Response, error) {
return call(ctx, s.Client, func(ctx context.Context, c *gitea.Client) ([]*gitea.Label, *gitea.Response, error) {
if s.Organization != "" {
return c.Organizations.ListOrgLabels(ctx, s.Organization, gitea.ListOrgLabelsOptions{ListOptions: listOptions(limit, page)})
}
owner, repo, err := splitRepository(s.Repository)
if err != nil {
return nil, nil, err
}
return c.Repositories.ListRepoLabels(ctx, owner, repo, gitea.ListLabelsOptions{ListOptions: listOptions(limit, page)})
})
}
// All returns every label in the scope.
func (s Labels) All(ctx context.Context) ([]Label, error) {
return listAll(func(page int) ([]Label, error) { return s.List(ctx, 100, page) })
}
// Create creates one label.
func (s Labels) Create(ctx context.Context, opts LabelOptions) (Label, error) {
if s.Organization != "" && opts.Archived != nil && *opts.Archived {
// TODO(sdk): CreateOrgLabelOption.IsArchived
var result Label
err := sendJSON(ctx, s.Client, http.MethodPost, s.orgEndpoint(), opts, &result)
return result, err
}
name, color, description := deref(opts.Name), deref(opts.Color), deref(opts.Description)
exclusive, archived := deref(opts.Exclusive), deref(opts.Archived)
label, _, err := call(ctx, s.Client, func(ctx context.Context, c *gitea.Client) (*gitea.Label, *gitea.Response, error) {
if s.Organization != "" {
return c.Organizations.CreateOrgLabel(ctx, s.Organization, gitea.CreateOrgLabelOption{
Name: name, Color: color, Description: description, Exclusive: exclusive,
})
}
owner, repo, err := splitRepository(s.Repository)
if err != nil {
return nil, nil, err
}
return c.Repositories.CreateLabel(ctx, owner, repo, gitea.CreateLabelOption{
Name: name, Color: color, Description: description, Exclusive: exclusive, IsArchived: archived,
})
})
if err != nil {
return Label{}, err
}
return labelFrom(label), nil
}
// Edit updates only explicitly supplied fields.
func (s Labels) Edit(ctx context.Context, id int64, opts LabelOptions) (Label, error) {
if s.Organization != "" && opts.Archived != nil {
// TODO(sdk): EditOrgLabelOption.IsArchived
var result Label
err := sendJSON(ctx, s.Client, http.MethodPatch, fmt.Sprintf("%s/%d", s.orgEndpoint(), id), opts, &result)
return result, err
}
label, _, err := call(ctx, s.Client, func(ctx context.Context, c *gitea.Client) (*gitea.Label, *gitea.Response, error) {
if s.Organization != "" {
return c.Organizations.EditOrgLabel(ctx, s.Organization, id, gitea.EditOrgLabelOption{
Name: opts.Name, Color: opts.Color, Description: opts.Description, Exclusive: opts.Exclusive,
})
}
owner, repo, err := splitRepository(s.Repository)
if err != nil {
return nil, nil, err
}
return c.Repositories.EditLabel(ctx, owner, repo, id, gitea.EditLabelOption{
Name: opts.Name, Color: opts.Color, Description: opts.Description, Exclusive: opts.Exclusive, IsArchived: opts.Archived,
})
})
if err != nil {
return Label{}, err
}
return labelFrom(label), nil
}
// Delete deletes one scoped label.
func (s Labels) Delete(ctx context.Context, id int64) error {
return do(ctx, s.Client, func(ctx context.Context, c *gitea.Client) (*gitea.Response, error) {
if s.Organization != "" {
return c.Organizations.DeleteOrgLabel(ctx, s.Organization, id)
}
owner, repo, err := splitRepository(s.Repository)
if err != nil {
return nil, err
}
return c.Repositories.DeleteLabel(ctx, owner, repo, id)
})
}
// Milestone is a stable milestone result.
type Milestone struct {
ID int64 `json:"id"`
Title string `json:"title"`
Description string `json:"description"`
State string `json:"state"`
Deadline *string `json:"due_on"`
OpenIssues int `json:"open_issues"`
ClosedIssues int `json:"closed_issues"`
}
func milestoneFrom(milestone *gitea.Milestone) Milestone {
return Milestone{
ID: milestone.ID, Title: milestone.Title, Description: milestone.Description, State: string(milestone.State),
Deadline: optionalTimestamp(milestone.Deadline), OpenIssues: milestone.OpenIssues, ClosedIssues: milestone.ClosedIssues,
}
}
// MilestoneOptions allows explicit state, description and deadline updates.
type MilestoneOptions struct {
Title *string `json:"title,omitempty"`
Description *string `json:"description,omitempty"`
State *string `json:"state,omitempty"`
Deadline *string `json:"due_on,omitempty"`
}
// deadline parses the RFC 3339 deadline; the zero time clears it.
func (o MilestoneOptions) deadline() (*time.Time, error) {
if o.Deadline == nil {
return nil, nil
}
deadline, err := time.Parse(time.RFC3339, *o.Deadline)
if err != nil {
return nil, fmt.Errorf("invalid milestone deadline %q: %w", *o.Deadline, err)
}
return &deadline, nil
}
// Milestones implements repository milestone operations.
type Milestones struct {
Client *transport.Client
Repository string
}
// List returns a non-nil page of milestones.
func (s Milestones) List(ctx context.Context, state string, limit, page int) ([]Milestone, error) {
milestones, err := repoCall(ctx, s.Client, s.Repository, func(ctx context.Context, c *gitea.Client, owner, repo string) ([]*gitea.Milestone, *gitea.Response, error) {
return c.Repositories.ListMilestones(ctx, owner, repo, gitea.ListMilestoneOption{ListOptions: listOptions(limit, page), State: gitea.StateType(state)})
})
if err != nil {
return nil, err
}
return convertAll(milestones, milestoneFrom), nil
}
// Create creates one milestone.
func (s Milestones) Create(ctx context.Context, opts MilestoneOptions) (Milestone, error) {
owner, repo, err := splitRepository(s.Repository)
if err != nil {
return Milestone{}, err
}
deadline, err := opts.deadline()
if err != nil {
return Milestone{}, err
}
milestone, _, err := call(ctx, s.Client, func(ctx context.Context, c *gitea.Client) (*gitea.Milestone, *gitea.Response, error) {
return c.Repositories.CreateMilestone(ctx, owner, repo, gitea.CreateMilestoneOption{
Title: deref(opts.Title), Description: deref(opts.Description), State: gitea.StateType(deref(opts.State)), Deadline: deadline,
})
})
if err != nil {
return Milestone{}, err
}
return milestoneFrom(milestone), nil
}
// Edit updates a milestone.
func (s Milestones) Edit(ctx context.Context, id int64, opts MilestoneOptions) (Milestone, error) {
owner, repo, err := splitRepository(s.Repository)
if err != nil {
return Milestone{}, err
}
deadline, err := opts.deadline()
if err != nil {
return Milestone{}, err
}
// Gitea clears omitted due_on values, so unrelated edits must resend the date.
if opts.Deadline == nil {
current, err := s.get(ctx, owner, repo, id)
if err != nil {
return Milestone{}, err
}
deadline = current.Deadline
}
edit := gitea.EditMilestoneOption{Title: deref(opts.Title), Description: opts.Description, Deadline: deadline}
if opts.State != nil {
state := gitea.StateType(*opts.State)
edit.State = &state
}
milestone, _, err := call(ctx, s.Client, func(ctx context.Context, c *gitea.Client) (*gitea.Milestone, *gitea.Response, error) {
return c.Repositories.EditMilestone(ctx, owner, repo, id, edit)
})
if err != nil {
return Milestone{}, err
}
return milestoneFrom(milestone), nil
}
// Delete deletes one milestone.
func (s Milestones) Delete(ctx context.Context, id int64) error {
return repoDo(ctx, s.Client, s.Repository, func(ctx context.Context, c *gitea.Client, owner, repo string) (*gitea.Response, error) {
return c.Repositories.DeleteMilestone(ctx, owner, repo, id)
})
}
// View retrieves one repository milestone.
func (s Milestones) View(ctx context.Context, id int64) (Milestone, error) {
owner, repo, err := splitRepository(s.Repository)
if err != nil {
return Milestone{}, err
}
milestone, err := s.get(ctx, owner, repo, id)
if err != nil {
return Milestone{}, err
}
return milestoneFrom(milestone), nil
}
func (s Milestones) get(ctx context.Context, owner, repo string, id int64) (*gitea.Milestone, error) {
milestone, _, err := call(ctx, s.Client, func(ctx context.Context, c *gitea.Client) (*gitea.Milestone, *gitea.Response, error) {
return c.Repositories.GetMilestone(ctx, owner, repo, id)
})
return milestone, err
}
// IssueEdit distinguishes omitted fields from explicit clears.
type IssueEdit struct {
Labels *[]int64 `json:"-"`
Ref *string `json:"ref,omitempty"`
Title *string `json:"title,omitempty"`
Body *string `json:"body,omitempty"`
Assignees *[]string `json:"assignees,omitempty"`
Milestone *int64 `json:"milestone,omitempty"`
Deadline *time.Time `json:"due_date,omitempty"`
RemoveDeadline *bool `json:"unset_due_date,omitempty"`
Changes *MetadataChanges `json:"-"`
}
// MetadataChanges adds and removes labels and assignees relative to an issue's current state.
type MetadataChanges struct {
AddLabels, RemoveLabels []int64
AddAssignees, RemoveAssignees []string
}
// Edit updates only supplied issue fields.
func (s Issues) Edit(ctx context.Context, n int64, opts IssueEdit) (Issue, error) {
if opts.Changes != nil {
var err error
if opts, err = s.applyChanges(ctx, n, opts); err != nil {
return Issue{}, err
}
}
if opts.Assignees != nil {
names := mergeUnique(*opts.Assignees, nil, nil, strings.EqualFold)
opts.Assignees = &names
}
if opts.Labels != nil {
if err := s.replaceLabels(ctx, n, append([]int64{}, (*opts.Labels)...)); err != nil {
return Issue{}, err
}
opts.Labels = nil
var item Issue
var err error
if opts == (IssueEdit{}) {
item, err = s.View(ctx, n)
} else {
item, err = s.Edit(ctx, n, opts)
}
if err != nil {
return Issue{}, fmt.Errorf("labels were updated, but completing the issue edit failed: %w", err)
}
return item, nil
}
edit := gitea.EditIssueOption{
Title: deref(opts.Title), Body: opts.Body, Ref: opts.Ref, Milestone: opts.Milestone,
Deadline: opts.Deadline, RemoveDeadline: opts.RemoveDeadline,
}
if opts.Assignees != nil {
edit.Assignees = *opts.Assignees
}
return s.edit(ctx, n, edit)
}
func (s Issues) replaceLabels(ctx context.Context, n int64, ids []int64) error {
return repoDo(ctx, s.Client, s.Repository, func(ctx context.Context, c *gitea.Client, owner, repo string) (*gitea.Response, error) {
_, resp, err := c.Issues.ReplaceIssueLabels(ctx, owner, repo, n, gitea.IssueLabelsOption{Labels: ids})
return resp, err
})
}
// applyChanges replaces incremental changes with the full label and assignee sets Gitea
// expects, read from the issue's current state; Gitea cannot add or remove single assignees.
func (s Issues) applyChanges(ctx context.Context, n int64, opts IssueEdit) (IssueEdit, error) {
changes := *opts.Changes
opts.Changes = nil
current, err := s.View(ctx, n)
if err != nil {
return opts, err
}
if len(changes.AddLabels)+len(changes.RemoveLabels) > 0 {
ids := make([]int64, len(current.Labels))
for i, label := range current.Labels {
ids[i] = label.ID
}
ids = mergeUnique(ids, changes.AddLabels, changes.RemoveLabels, func(a, b int64) bool { return a == b })
opts.Labels = &ids
}
if len(changes.AddAssignees)+len(changes.RemoveAssignees) > 0 {
names := mergeUnique(current.Assignees, changes.AddAssignees, changes.RemoveAssignees, strings.EqualFold)
opts.Assignees = &names
}
return opts, nil
}
// mergeUnique applies additions and removals to current values, preserving order.
func mergeUnique[T any](current, add, remove []T, equal func(T, T) bool) []T {
result := []T{}
for _, item := range slices.Concat(current, add) {
matches := func(other T) bool { return equal(item, other) }
if !slices.ContainsFunc(remove, matches) && !slices.ContainsFunc(result, matches) {
result = append(result, item)
}
}
return result
}
// RemoveMilestone refuses to clear an issue belonging to another milestone.
func (s Issues) RemoveMilestone(ctx context.Context, n, id int64) (Issue, error) {
current, err := s.View(ctx, n)
if err != nil {
return Issue{}, err
}
if current.Milestone == nil || current.Milestone.ID != id {
return Issue{}, fmt.Errorf("issue is not assigned to milestone %d", id)
}
cleared := int64(0)
return s.Edit(ctx, n, IssueEdit{Milestone: &cleared})
}