refactor: add services layer on the Gitea Go SDK (#1125)

- `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>
This commit is contained in:
bircni committed 2026-10-11 17:15:41 +00:00
1 parent faea86cd94
commit 12e415a7a7
80 files changed
+9512

No files matched your search

+388
View File
@@ -0,0 +1,388 @@
// 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})
}