mirror of
https://gitea.com/gitea/tea.git
synced 2026-10-26 11:01:25 +01:00
381 lines
15 KiB
Go
381 lines
15 KiB
Go
// Copyright 2026 The Gitea Authors. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package services
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
|
|
gitea "gitea.dev/sdk"
|
|
"gitea.dev/tea/internal/transport"
|
|
)
|
|
|
|
// Repositories implements Gitea repository operations.
|
|
type Repositories struct{ Client *transport.Client }
|
|
|
|
// Repository is the stable command representation of a repository.
|
|
type Repository struct {
|
|
ActionsEnabled bool `json:"actions_enabled"`
|
|
Website string `json:"website"`
|
|
ID int64 `json:"id"`
|
|
Name string `json:"name"`
|
|
FullName string `json:"full_name"`
|
|
Description string `json:"description"`
|
|
URL string `json:"url"`
|
|
Private bool `json:"private"`
|
|
DefaultBranch string `json:"default_branch"`
|
|
CloneURL string `json:"clone_url"`
|
|
SSHURL string `json:"ssh_url"`
|
|
Archived bool `json:"archived"`
|
|
Template bool `json:"template"`
|
|
Fork bool `json:"fork"`
|
|
Mirror bool `json:"mirror"`
|
|
Empty bool `json:"empty"`
|
|
Stars int `json:"stars_count"`
|
|
Forks int `json:"forks_count"`
|
|
OpenIssues int `json:"open_issues_count"`
|
|
OpenPulls int `json:"open_pr_count"`
|
|
Topics []string `json:"topics"`
|
|
UpdatedAt string `json:"updated_at"`
|
|
Parent *Repository `json:"parent,omitempty"`
|
|
// OriginalURL stays out of JSON output: migration sources may embed credentials.
|
|
OriginalURL string `json:"-"`
|
|
}
|
|
|
|
// RepositoryOptions supplies repository creation input.
|
|
type RepositoryOptions struct {
|
|
Name string `json:"name"`
|
|
Description string `json:"description,omitempty"`
|
|
Private bool `json:"private"`
|
|
AutoInit bool `json:"auto_init"`
|
|
DefaultBranch string `json:"default_branch,omitempty"`
|
|
Labels string `json:"issue_labels,omitempty"`
|
|
Gitignores string `json:"gitignores,omitempty"`
|
|
License string `json:"license,omitempty"`
|
|
Readme string `json:"readme,omitempty"`
|
|
Template bool `json:"template"`
|
|
TrustModel string `json:"trust_model,omitempty"`
|
|
ObjectFormat string `json:"object_format_name,omitempty"`
|
|
}
|
|
|
|
// RepositoryEdit distinguishes omitted settings from explicit false values.
|
|
type RepositoryEdit struct {
|
|
ActionsEnabled *bool `json:"has_actions,omitempty"`
|
|
Name *string `json:"name,omitempty"`
|
|
Website *string `json:"website,omitempty"`
|
|
DefaultBranch *string `json:"default_branch,omitempty"`
|
|
Description *string `json:"description,omitempty"`
|
|
Private *bool `json:"private,omitempty"`
|
|
Archived *bool `json:"archived,omitempty"`
|
|
Template *bool `json:"template,omitempty"`
|
|
Issues *bool `json:"has_issues,omitempty"`
|
|
Wiki *bool `json:"has_wiki,omitempty"`
|
|
PullRequests *bool `json:"has_pull_requests,omitempty"`
|
|
Projects *bool `json:"has_projects,omitempty"`
|
|
MergeCommits *bool `json:"allow_merge_commits,omitempty"`
|
|
Squash *bool `json:"allow_squash_merge,omitempty"`
|
|
Rebase *bool `json:"allow_rebase,omitempty"`
|
|
MergeStyle *string `json:"default_merge_style,omitempty"`
|
|
DeleteBranch *bool `json:"default_delete_branch_after_merge,omitempty"`
|
|
}
|
|
|
|
// View retrieves one repository.
|
|
func (s Repositories) View(ctx context.Context, repo string) (Repository, error) {
|
|
item, err := s.get(ctx, repo)
|
|
if err != nil {
|
|
return Repository{}, err
|
|
}
|
|
return repositoryFrom(item), nil
|
|
}
|
|
|
|
func (s Repositories) get(ctx context.Context, repo string) (*gitea.Repository, error) {
|
|
item, err := repoCall(ctx, s.Client, repo, func(ctx context.Context, c *gitea.Client, owner, name string) (*gitea.Repository, *gitea.Response, error) {
|
|
return c.Repositories.GetRepo(ctx, owner, name)
|
|
})
|
|
return item, Classify(err, "repository", repo, "")
|
|
}
|
|
|
|
// readmeDirs are the directories Gitea searches for a README, in order; "" is the repository root.
|
|
var readmeDirs = []string{".gitea", ".github", "", "docs"}
|
|
|
|
// readmeRank orders README file names as Gitea does: Markdown first, then text, then any other
|
|
// README.* such as README.rst. It returns -1 for names that are not a README.
|
|
func readmeRank(name string) int {
|
|
switch lower := strings.ToLower(name); {
|
|
case lower == "readme.md":
|
|
return 0
|
|
case lower == "readme.markdown":
|
|
return 1
|
|
case lower == "readme.txt":
|
|
return 2
|
|
case lower == "readme":
|
|
return 3
|
|
case strings.HasPrefix(lower, "readme."):
|
|
return 4
|
|
}
|
|
return -1
|
|
}
|
|
|
|
// Readme returns the README of the repository at ref as Gitea's web view finds it, searching .gitea,
|
|
// .github, the root and docs: its path and its content, or "" when there is none. An explicit ref
|
|
// that does not exist is an error.
|
|
func (s Repositories) Readme(ctx context.Context, repo, ref string) (path, content string, err error) {
|
|
list := func(dir string) ([]*gitea.ContentsResponse, error) {
|
|
return repoCall(ctx, s.Client, repo, func(ctx context.Context, c *gitea.Client, owner, name string) ([]*gitea.ContentsResponse, *gitea.Response, error) {
|
|
return c.Repositories.ListContents(ctx, owner, name, ref, dir)
|
|
})
|
|
}
|
|
root, err := list("")
|
|
if err != nil {
|
|
if IsNotFound(err) && ref != "" {
|
|
return "", "", NotFound("branch", strconv.Quote(ref), repo)
|
|
}
|
|
// A reader without code access sees the repository but not its files.
|
|
if IsNotFound(err) || IsForbidden(err) {
|
|
return "", "", nil
|
|
}
|
|
return "", "", err
|
|
}
|
|
for _, dir := range readmeDirs {
|
|
entries := root
|
|
if dir != "" {
|
|
i := slices.IndexFunc(root, func(e *gitea.ContentsResponse) bool { return e.Type == "dir" && e.Name == dir })
|
|
if i < 0 {
|
|
continue
|
|
}
|
|
if entries, err = list(dir); err != nil {
|
|
return "", "", err
|
|
}
|
|
}
|
|
rank := -1
|
|
for _, entry := range entries {
|
|
if r := readmeRank(entry.Name); entry.Type == "file" && r >= 0 && (rank < 0 || r < rank) {
|
|
path, rank = strings.TrimPrefix(dir+"/"+entry.Name, "/"), r
|
|
}
|
|
}
|
|
if path != "" {
|
|
break
|
|
}
|
|
}
|
|
if path == "" {
|
|
return "", "", nil
|
|
}
|
|
data, err := repoCall(ctx, s.Client, repo, func(ctx context.Context, c *gitea.Client, owner, name string) ([]byte, *gitea.Response, error) {
|
|
return c.Repositories.GetFile(ctx, owner, name, ref, path)
|
|
})
|
|
return path, string(data), err
|
|
}
|
|
|
|
// RepositoryPermissions holds server-side access flags used only for local decisions.
|
|
// It is intentionally omitted from the stable Repository JSON schema.
|
|
type RepositoryPermissions struct {
|
|
Admin bool `json:"admin"`
|
|
Push bool `json:"push"`
|
|
Pull bool `json:"pull"`
|
|
}
|
|
|
|
// viewPermissions reads repository access flags without exposing them on Repository results.
|
|
func (s Repositories) viewPermissions(ctx context.Context, repo string) (RepositoryPermissions, error) {
|
|
item, err := s.get(ctx, repo)
|
|
if err != nil {
|
|
return RepositoryPermissions{}, err
|
|
}
|
|
return permissionsFrom(item), nil
|
|
}
|
|
|
|
func permissionsFrom(item *gitea.Repository) RepositoryPermissions {
|
|
if item.Permissions == nil {
|
|
return RepositoryPermissions{}
|
|
}
|
|
return RepositoryPermissions{Admin: item.Permissions.Admin, Push: item.Permissions.Push, Pull: item.Permissions.Pull}
|
|
}
|
|
|
|
// Assignee is a user that can be assigned on repository issues.
|
|
type Assignee struct {
|
|
Username string `json:"username"`
|
|
FullName string `json:"full_name"`
|
|
}
|
|
|
|
// Assignees lists the users that can be assigned on repository issues.
|
|
func (s Repositories) Assignees(ctx context.Context, repo string) ([]Assignee, error) {
|
|
users, err := repoCall(ctx, s.Client, repo, func(ctx context.Context, c *gitea.Client, owner, name string) ([]*gitea.User, *gitea.Response, error) {
|
|
return c.Repositories.GetAssignees(ctx, owner, name)
|
|
})
|
|
return convertAll(users, func(user *gitea.User) Assignee { return Assignee{Username: user.UserName, FullName: user.FullName} }), err
|
|
}
|
|
|
|
// assignees lists usernames that can be assigned on repository issues.
|
|
func (s Repositories) assignees(ctx context.Context, repo string) ([]string, error) {
|
|
users, err := s.Assignees(ctx, repo)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
names := make([]string, len(users))
|
|
for i, user := range users {
|
|
names[i] = user.Username
|
|
}
|
|
return names, nil
|
|
}
|
|
|
|
// Tags lists a page of the repository's tag names.
|
|
func (s Repositories) Tags(ctx context.Context, repo string, limit, page int) ([]string, error) {
|
|
tags, err := repoCall(ctx, s.Client, repo, func(ctx context.Context, c *gitea.Client, owner, name string) ([]*gitea.Tag, *gitea.Response, error) {
|
|
return c.Repositories.ListRepoTags(ctx, owner, name, gitea.ListRepoTagsOptions{ListOptions: listOptions(limit, page)})
|
|
})
|
|
return convertAll(tags, func(tag *gitea.Tag) string { return tag.Name }), err
|
|
}
|
|
|
|
// Create creates a repository for the account or the named organization.
|
|
func (s Repositories) Create(ctx context.Context, org string, opts RepositoryOptions) (Repository, error) {
|
|
options := gitea.CreateRepoOption{
|
|
Name: opts.Name, Description: opts.Description, Private: opts.Private, IssueLabels: opts.Labels,
|
|
AutoInit: opts.AutoInit, Template: opts.Template, Gitignores: opts.Gitignores, License: opts.License,
|
|
Readme: opts.Readme, DefaultBranch: opts.DefaultBranch, TrustModel: gitea.TrustModel(opts.TrustModel),
|
|
ObjectFormatName: opts.ObjectFormat,
|
|
}
|
|
// TODO(sdk): CreateRepoOption.Validate counts the description in bytes, but Gitea allows 2048
|
|
// characters; a longer one is set by an edit once the repository exists.
|
|
description := opts.Description
|
|
later := len(description) > sdkDescriptionLimit
|
|
if later {
|
|
options.Description = ""
|
|
}
|
|
item, _, err := call(ctx, s.Client, func(ctx context.Context, c *gitea.Client) (*gitea.Repository, *gitea.Response, error) {
|
|
if org != "" {
|
|
return c.Repositories.CreateOrgRepo(ctx, org, options)
|
|
}
|
|
return c.Repositories.CreateRepo(ctx, options)
|
|
})
|
|
if err != nil {
|
|
if org != "" && IsNotFound(err) {
|
|
// The repository cannot be missing before it exists: the organization is.
|
|
return Repository{}, &NotFoundError{Kind: "organization", Key: strconv.Quote(org), Err: err}
|
|
}
|
|
return Repository{}, Classify(err, "repository", "", "")
|
|
}
|
|
created := repositoryFrom(item)
|
|
if later {
|
|
edited, err := s.Edit(ctx, created.FullName, RepositoryEdit{Description: &description})
|
|
if err != nil {
|
|
return created, fmt.Errorf("repository %s was created, but setting its description failed: %w", created.FullName, err)
|
|
}
|
|
return edited, nil
|
|
}
|
|
return created, nil
|
|
}
|
|
|
|
// sdkDescriptionLimit is the length in bytes that the SDK accepts for a new repository's description.
|
|
const sdkDescriptionLimit = 2048
|
|
|
|
// Edit updates only explicitly supplied resource fields.
|
|
func (s Repositories) Edit(ctx context.Context, repo string, opts RepositoryEdit) (Repository, error) {
|
|
var mergeStyle *gitea.MergeStyle
|
|
if opts.MergeStyle != nil {
|
|
style := gitea.MergeStyle(*opts.MergeStyle)
|
|
mergeStyle = &style
|
|
}
|
|
item, err := repoConvert(ctx, s.Client, repo, repositoryFrom, func(ctx context.Context, c *gitea.Client, owner, name string) (*gitea.Repository, *gitea.Response, error) {
|
|
return c.Repositories.EditRepo(ctx, owner, name, gitea.EditRepoOption{
|
|
HasActions: opts.ActionsEnabled, Name: opts.Name, Website: opts.Website, DefaultBranch: opts.DefaultBranch,
|
|
Description: opts.Description, Private: opts.Private, Archived: opts.Archived, Template: opts.Template,
|
|
HasIssues: opts.Issues, HasWiki: opts.Wiki, HasPullRequests: opts.PullRequests, HasProjects: opts.Projects,
|
|
AllowMerge: opts.MergeCommits, AllowSquash: opts.Squash, AllowRebase: opts.Rebase, DefaultMergeStyle: mergeStyle,
|
|
DefaultDeleteBranchAfterMerge: opts.DeleteBranch,
|
|
})
|
|
})
|
|
return item, Classify(err, "repository", repo, "")
|
|
}
|
|
|
|
// Topics lists the topics of the repository. Gitea allows at most 25, so one page holds them all.
|
|
func (s Repositories) Topics(ctx context.Context, repo string) ([]string, error) {
|
|
return repoCall(ctx, s.Client, repo, func(ctx context.Context, c *gitea.Client, owner, name string) ([]string, *gitea.Response, error) {
|
|
return c.Repositories.ListRepoTopics(ctx, owner, name, gitea.ListRepoTopicsOptions{ListOptions: gitea.ListOptions{Page: 1, PageSize: 50}})
|
|
})
|
|
}
|
|
|
|
// SetTopics replaces the topics of the repository.
|
|
func (s Repositories) SetTopics(ctx context.Context, repo string, topics []string) error {
|
|
return repoDo(ctx, s.Client, repo, func(ctx context.Context, c *gitea.Client, owner, name string) (*gitea.Response, error) {
|
|
return c.Repositories.SetRepoTopics(ctx, owner, name, topics)
|
|
})
|
|
}
|
|
|
|
// Delete removes the selected repository.
|
|
func (s Repositories) Delete(ctx context.Context, repo string) error {
|
|
err := repoDo(ctx, s.Client, repo, func(ctx context.Context, c *gitea.Client, owner, name string) (*gitea.Response, error) {
|
|
return c.Repositories.DeleteRepo(ctx, owner, name)
|
|
})
|
|
return Classify(err, "repository", repo, "")
|
|
}
|
|
|
|
// ForkOptions places a fork: under Organization instead of the account, and named Name instead
|
|
// of the forked repository's name.
|
|
type ForkOptions struct{ Organization, Name string }
|
|
|
|
// Fork forks repo for the account or the organization. When that owner already has a fork of
|
|
// repo, Fork returns it and reports that it existed.
|
|
func (s Repositories) Fork(ctx context.Context, repo string, opts ForkOptions) (Repository, bool, error) {
|
|
var options gitea.CreateForkOption
|
|
if opts.Organization != "" {
|
|
options.Organization = &opts.Organization
|
|
}
|
|
if opts.Name != "" {
|
|
options.Name = &opts.Name
|
|
}
|
|
fork, err := repoConvert(ctx, s.Client, repo, repositoryFrom, func(ctx context.Context, c *gitea.Client, owner, name string) (*gitea.Repository, *gitea.Response, error) {
|
|
return c.Repositories.CreateFork(ctx, owner, name, options)
|
|
})
|
|
apiErr, ok := errors.AsType[*transport.APIError](err)
|
|
if !ok || apiErr.Status != http.StatusConflict || !strings.Contains(apiErr.Message, "already forked") {
|
|
return fork, false, err
|
|
}
|
|
owner := opts.Organization
|
|
if owner == "" {
|
|
if owner, err = CurrentAccount(ctx, s.Client); err != nil {
|
|
return Repository{}, false, err
|
|
}
|
|
}
|
|
forks, listErr := listAll(func(page int) ([]*gitea.Repository, error) {
|
|
return repoCall(ctx, s.Client, repo, func(ctx context.Context, c *gitea.Client, owner, name string) ([]*gitea.Repository, *gitea.Response, error) {
|
|
return c.Repositories.ListForks(ctx, owner, name, gitea.ListForksOptions{ListOptions: listOptions(50, page)})
|
|
})
|
|
})
|
|
if listErr != nil {
|
|
return Repository{}, false, listErr
|
|
}
|
|
for _, existing := range forks {
|
|
if existing.Owner != nil && strings.EqualFold(existing.Owner.UserName, owner) {
|
|
// The fork list omits the parent that a fork's own record has.
|
|
fork, err := s.View(ctx, existing.FullName)
|
|
return fork, err == nil, err
|
|
}
|
|
}
|
|
return Repository{}, false, apiErr
|
|
}
|
|
|
|
// MergeUpstream fast-forwards branch of the fork repo to its parent's branch on the server. It
|
|
// returns how Gitea updated the branch: "fast-forward", or "up-to-date" when there was nothing to do.
|
|
func (s Repositories) MergeUpstream(ctx context.Context, repo, branch string) (string, error) {
|
|
result, err := repoCall(ctx, s.Client, repo, func(ctx context.Context, c *gitea.Client, owner, name string) (*gitea.MergeUpstreamResponse, *gitea.Response, error) {
|
|
return c.Repositories.MergeUpstream(ctx, owner, name, gitea.MergeUpstreamRequest{Branch: branch, FfOnly: true})
|
|
})
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return result.MergeStyle, nil
|
|
}
|
|
|
|
// MirrorSync asks the server to update the pull mirror repo from its source; the update runs in
|
|
// the background.
|
|
func (s Repositories) MirrorSync(ctx context.Context, repo string) error {
|
|
return repoDo(ctx, s.Client, repo, func(ctx context.Context, c *gitea.Client, owner, name string) (*gitea.Response, error) {
|
|
return c.Repositories.MirrorSync(ctx, owner, name)
|
|
})
|
|
}
|