mirror of
https://gitea.com/gitea/tea.git
synced 2026-10-26 11:01:25 +01:00
- `internal/services`: typed Gitea workflows built on `gitea.dev/sdk`, independent of CLI flags and terminal I/O: issues, pull requests, reviews, comments, labels, milestones, releases and assets, repositories, branches, wiki, Actions (workflows, runs, jobs, logs, artifacts, secrets, variables), organizations, teams, webhooks, notifications, SSH keys, tracked time, admin users, search and status. - SDK types are mapped to tea's own output types, so `--json` stays a stable contract. Requests the SDK cannot express yet (e.g. pull list filters, multi-status run lists, org label archiving) go through small raw helpers, each marked `TODO(sdk)` with the missing SDK piece. - Targets Gitea 28 or newer: no version or API-schema probing. List helpers fill pages capped by `MAX_RESPONSE_ITEMS`. Nothing imports this package yet. Reviewed-on: https://gitea.com/gitea/tea/pulls/1125 Reviewed-by: TheFox0x7 <95654+thefox0x7@noreply.gitea.com>
389 lines
13 KiB
Go
389 lines
13 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 := 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)})
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return convertAll(labels, labelFrom), nil
|
|
}
|
|
|
|
// 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})
|
|
}
|