Files

557 lines
21 KiB
Go

// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package services
import (
"bytes"
"cmp"
"context"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"slices"
"strconv"
"strings"
"sync"
"time"
gitea "gitea.dev/sdk"
"gitea.dev/tea/internal/transport"
)
// Pull is the stable command representation of a pull request.
type Pull struct {
Author string `json:"author"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
Assignees []string `json:"assignees"`
Milestone *IssueMilestone `json:"milestone"`
Labels []Label `json:"labels"`
Deadline *string `json:"due_date"`
ID int64 `json:"id"`
Number int64 `json:"number"`
Title string `json:"title"`
Body string `json:"body"`
URL string `json:"url"`
State string `json:"state"`
Merged bool `json:"merged"`
MergedAt *string `json:"merged_at"`
// MergedBy, Reviewers and ReviewerTeams are only in responses for one pull request; lists
// leave them out.
MergedBy string `json:"merged_by"`
Mergeable bool `json:"mergeable"`
Draft bool `json:"draft"`
// ReviewDecision summarizes the reviews like gh: approved, changes_requested, review_required
// or "". Only commands that read the reviews fill it.
ReviewDecision string `json:"review_decision"`
Base string `json:"base"`
Head string `json:"head"`
HeadSHA string `json:"head_sha"`
HeadRepo string `json:"head_repo,omitempty"`
HeadRemoteDeleted bool `json:"head_remote_deleted"`
HeadCanPush bool `json:"-"`
HeadCloneURL string `json:"-"`
HeadSSHURL string `json:"-"`
Reviewers []string `json:"reviewers"`
ReviewerTeams []string `json:"reviewer_teams"`
Comments int64 `json:"comments"`
}
func pullHeadBranch(number int64, ref, label string) (branch string, deleted bool) {
if ref == fmt.Sprintf("refs/pull/%d/head", number) {
branch = label
if _, name, ok := strings.Cut(label, ":"); ok && name != "" {
branch = name
}
return branch, true
}
return ref, false
}
func pullFrom(pull *gitea.PullRequest) Pull {
var head, base gitea.PRBranchInfo
if pull.Head != nil {
head = *pull.Head
}
if pull.Base != nil {
base = *pull.Base
}
headBranch, remoteDeleted := pullHeadBranch(pull.Index, head.Ref, head.Name)
result := Pull{
Author: login(pull.Poster), CreatedAt: timestamp(deref(pull.Created)), UpdatedAt: timestamp(deref(pull.Updated)),
Assignees: convertAll(pull.Assignees, login), Milestone: issueMilestoneFrom(pull.Milestone), Labels: convertAll(pull.Labels, labelFrom),
Deadline: optionalTimestamp(pull.Deadline), ID: pull.ID, Number: pull.Index, Title: pull.Title, Body: pull.Body,
URL: pull.HTMLURL, State: string(pull.State), Merged: pull.HasMerged, MergedAt: optionalTimestamp(pull.Merged), MergedBy: login(pull.MergedBy),
Mergeable: pull.Mergeable, Draft: pull.Draft,
Base: base.Ref, Head: headBranch, HeadSHA: head.Sha, HeadRemoteDeleted: remoteDeleted,
Reviewers: convertAll(pull.RequestedReviewers, login), Comments: int64(pull.Comments),
ReviewerTeams: convertAll(pull.RequestedReviewersTeams, func(team *gitea.Team) string { return team.Name }),
}
repo := head.Repository
if remoteDeleted || repo == nil {
repo = base.Repository
}
if repo != nil {
result.HeadRepo, result.HeadCloneURL, result.HeadSSHURL = repo.FullName, repo.CloneURL, repo.SSHURL
if repo.Permissions != nil {
result.HeadCanPush = repo.Permissions.Push
}
}
return result
}
// PullOptions supplies validated branch and content input for creation.
type PullOptions struct {
Assignees []string `json:"assignees,omitempty"`
Labels []int64 `json:"labels,omitempty"`
Milestone int64 `json:"milestone,omitempty"`
Deadline *time.Time `json:"due_date,omitempty"`
Title string `json:"title"`
Body string `json:"body"`
Base string `json:"base"`
Head string `json:"head"`
AllowMaintainerEdit *bool `json:"allow_maintainer_edit,omitempty"`
}
// PullEdit distinguishes omitted fields from explicit empty values.
type PullEdit struct {
Labels *[]int64 `json:"-"`
Title *string `json:"title,omitempty"`
Body *string `json:"body,omitempty"`
State *string `json:"state,omitempty"`
Base *string `json:"base,omitempty"`
AllowMaintainerEdit *bool `json:"allow_maintainer_edit,omitempty"`
Assignees *[]string `json:"assignees,omitempty"`
Milestone *int64 `json:"-"`
ReviewerChanges *ReviewRequestChanges `json:"-"`
Deadline *time.Time `json:"due_date,omitempty"`
RemoveDeadline *bool `json:"unset_due_date,omitempty"`
Changes *MetadataChanges `json:"-"`
}
// Pulls implements repository pull request operations.
type Pulls struct {
Client *transport.Client
Repository string
}
// PullListOptions filters pull requests. State is open, closed, merged or all.
type PullListOptions struct {
State, Sort, Author, Base string
Limit, Page int
Milestone int64
Labels []int64
// Draft, Head (BRANCH or OWNER:BRANCH), Assignee, NoAssignee, Search (text in the title or
// body) and the merged state have no server filter; List applies them to every pull request
// it reads.
Draft, NoAssignee bool
Head, Assignee, Search string
}
// filtered reports whether a filter needs every pull request read.
func (o PullListOptions) filtered() bool {
return o.State == "merged" || o.Draft || o.NoAssignee || o.Head != "" || o.Assignee != "" || o.Search != ""
}
// matches applies the filters without a server equivalent to item.
func (o PullListOptions) matches(item Pull) bool {
owner, branch, qualified := strings.Cut(o.Head, ":")
if !qualified {
owner, branch = "", o.Head
}
headOwner, _, _ := strings.Cut(item.HeadRepo, "/")
search := strings.ToLower(o.Search)
return (o.State != "merged" || item.Merged) && (!o.Draft || item.Draft) &&
(branch == "" || (item.Head == branch && (owner == "" || strings.EqualFold(owner, headOwner)))) &&
(o.Assignee == "" || slices.ContainsFunc(item.Assignees, func(name string) bool { return strings.EqualFold(name, o.Assignee) })) &&
(!o.NoAssignee || len(item.Assignees) == 0) &&
(search == "" || strings.Contains(strings.ToLower(item.Title), search) || strings.Contains(strings.ToLower(item.Body), search))
}
// List returns a page of pull requests, even when the server caps its page size below the
// limit, and reports whether more pull requests follow.
func (s Pulls) List(ctx context.Context, options PullListOptions) ([]Pull, bool, error) {
page, err := s.ListPage(ctx, options)
return page.Items, page.More, err
}
// ListPage is List that also reports how many pull requests match, when the server counts them
// or every pull request was read for a filter without a server equivalent. Such a filter reads
// pages only until one pull request past the requested page matches.
func (s Pulls) ListPage(ctx context.Context, options PullListOptions) (Page[Pull], error) {
if !options.filtered() {
return s.listPage(ctx, options)
}
query := options
if query.State == "merged" {
query.State = "closed"
}
return filteredPage(options.Limit, options.Page, func(limit, page int) (Page[Pull], error) {
query.Limit, query.Page = limit, page
return s.listPage(ctx, query)
}, options.matches)
}
func (s Pulls) listPage(ctx context.Context, options PullListOptions) (Page[Pull], error) {
query := url.Values{"state": {options.State}}
if options.Sort != "" {
query.Set("sort", options.Sort)
}
if options.Author != "" {
query.Set("poster", options.Author)
}
if options.Base != "" {
query.Set("base_branch", options.Base)
}
if options.Milestone > 0 {
query.Set("milestone", strconv.FormatInt(options.Milestone, 10))
}
for _, label := range options.Labels {
query.Add("labels", strconv.FormatInt(label, 10))
}
// TODO(sdk): ListPullRequestsOptions lacks the poster, base_branch, and labels filters.
page, err := rawPage[*gitea.PullRequest](ctx, s.Client, "repos/"+s.Repository+"/pulls", query, options.Limit, options.Page)
if err != nil {
return Page[Pull]{}, err
}
return convertPage(page, pullFrom), nil
}
// View retrieves one pull request.
func (s Pulls) View(ctx context.Context, n int64) (Pull, error) {
pull, err := s.get(ctx, n)
if err != nil {
return Pull{}, err
}
return pullFrom(pull), nil
}
// ViewAll retrieves each of items again, a few at a time, for the fields only a single pull
// request carries, such as its reviewers.
func (s Pulls) ViewAll(ctx context.Context, items []Pull) ([]Pull, error) {
viewed := make([]Pull, len(items))
err := concurrently(len(items), func(i int) (err error) {
viewed[i], err = s.View(ctx, items[i].Number)
return err
})
return viewed, err
}
// DecideAll is ViewAll that also fills each pull request's ReviewDecision from its reviews.
func (s Pulls) DecideAll(ctx context.Context, items []Pull) ([]Pull, error) {
viewed := make([]Pull, len(items))
reviews := Reviews{Client: s.Client, Repository: s.Repository}
err := concurrently(len(items), func(i int) error {
item, err := s.View(ctx, items[i].Number)
if err != nil {
return err
}
all, err := reviews.All(ctx, item.Number)
if err != nil {
return err
}
item.ReviewDecision = ReviewDecision(all, len(item.Reviewers)+len(item.ReviewerTeams) > 0)
viewed[i] = item
return nil
})
return viewed, err
}
func (s Pulls) get(ctx context.Context, n int64) (*gitea.PullRequest, error) {
item, err := repoCall(ctx, s.Client, s.Repository, func(ctx context.Context, c *gitea.Client, owner, name string) (*gitea.PullRequest, *gitea.Response, error) {
return c.PullRequests.GetPullRequest(ctx, owner, name, n)
})
return item, Classify(err, "pull request", "#"+strconv.FormatInt(n, 10), s.Repository)
}
// ListOpen returns every open pull request in the repository.
func (s Pulls) ListOpen(ctx context.Context) ([]Pull, error) {
return listAll(func(page int) ([]Pull, error) {
items, _, err := s.List(ctx, PullListOptions{State: "open", Limit: 50, Page: page})
return items, err
})
}
// NewestClosed returns the most recently updated closed or merged pull request that keep
// accepts, and whether there is one. It reads pages newest first and stops at the first match.
func (s Pulls) NewestClosed(ctx context.Context, keep func(Pull) bool) (Pull, bool, error) {
for page := 1; page <= maxListPages; page++ {
items, _, err := s.List(ctx, PullListOptions{State: "closed", Sort: "recentupdate", Limit: 50, Page: page})
if err != nil {
return Pull{}, false, err
}
if len(items) == 0 {
return Pull{}, false, nil
}
for _, item := range items {
if keep(item) {
return item, true, nil
}
}
}
return Pull{}, false, nil
}
// Create opens a pull request.
func (s Pulls) Create(ctx context.Context, opts PullOptions) (Pull, error) {
// TODO(sdk): CreatePullRequestOption lacks allow_maintainer_edit.
var pull gitea.PullRequest
if err := sendJSON(ctx, s.Client, http.MethodPost, "repos/"+s.Repository+"/pulls", opts, &pull); err != nil {
return Pull{}, Classify(err, "pull request", "", s.Repository)
}
return pullFrom(&pull), nil
}
// Edit updates only explicitly supplied resource fields.
func (s Pulls) Edit(ctx context.Context, n int64, opts PullEdit) (Pull, error) {
if opts.ReviewerChanges != nil {
reviews, changes := Reviews{Client: s.Client, Repository: s.Repository}, opts.ReviewerChanges
opts.ReviewerChanges = nil
if opts == (PullEdit{}) {
return reviews.ChangeRequests(ctx, n, *changes)
}
if err := reviews.changeRequests(ctx, n, *changes); err != nil {
return Pull{}, err
}
item, err := s.Edit(ctx, n, opts)
if err != nil {
return Pull{}, fmt.Errorf("review requests were updated, but editing other pull request fields failed: %w", err)
}
return item, nil
}
issues := Issues{Client: s.Client, Repository: s.Repository}
if opts.Changes != nil {
resolved, err := issues.applyChanges(ctx, n, IssueEdit{Labels: opts.Labels, Assignees: opts.Assignees, Changes: opts.Changes})
if err != nil {
return Pull{}, err
}
opts.Labels, opts.Assignees, opts.Changes = resolved.Labels, resolved.Assignees, nil
}
if opts.Milestone != nil || opts.Labels != nil {
// The PR endpoint ignores zero milestones. Its issue endpoint supports
// clearing and lets common fields share the same mutation.
_, err := issues.Edit(ctx, n, IssueEdit{
Title: opts.Title, Body: opts.Body, Assignees: opts.Assignees, Milestone: opts.Milestone, Labels: opts.Labels,
Deadline: opts.Deadline, RemoveDeadline: opts.RemoveDeadline,
})
if err != nil {
return Pull{}, err
}
opts.Title, opts.Body, opts.Assignees, opts.Milestone = nil, nil, nil, nil
opts.Deadline, opts.RemoveDeadline, opts.Labels = nil, nil, nil
var result Pull
if opts == (PullEdit{}) {
result, err = s.View(ctx, n)
} else {
result, err = s.Edit(ctx, n, opts)
}
if err != nil {
return Pull{}, fmt.Errorf("issue fields were updated, but completing the pull request edit failed: %w", err)
}
return result, nil
}
item, err := repoConvert(ctx, s.Client, s.Repository, pullFrom, func(ctx context.Context, c *gitea.Client, owner, name string) (*gitea.PullRequest, *gitea.Response, error) {
return c.PullRequests.EditPullRequest(ctx, owner, name, n, editPullOption(opts))
})
return item, Classify(err, "pull request", "#"+strconv.FormatInt(n, 10), s.Repository)
}
func editPullOption(opts PullEdit) gitea.EditPullRequestOption {
option := gitea.EditPullRequestOption{
Body: opts.Body, Deadline: opts.Deadline, RemoveDeadline: opts.RemoveDeadline, AllowMaintainerEdit: opts.AllowMaintainerEdit,
}
if opts.Title != nil {
option.Title = *opts.Title
}
if opts.Base != nil {
option.Base = *opts.Base
}
if opts.Assignees != nil {
option.Assignees = *opts.Assignees
}
if opts.State != nil {
state := gitea.StateType(*opts.State)
option.State = &state
}
return option
}
// Diff returns the pull request's patch, which the caller must close.
func (s Pulls) Diff(ctx context.Context, n int64) (io.ReadCloser, error) {
diff, err := repoCall(ctx, s.Client, s.Repository, func(ctx context.Context, c *gitea.Client, owner, name string) ([]byte, *gitea.Response, error) {
return c.PullRequests.GetPullRequestDiff(ctx, owner, name, n, gitea.PullRequestDiffOptions{})
})
if err != nil {
return nil, err
}
return io.NopCloser(bytes.NewReader(diff)), nil
}
// DeleteHead deletes the head branch of a pull request in its head repository.
func (s Pulls) DeleteHead(ctx context.Context, item Pull) error {
if item.HeadRemoteDeleted || item.Head == "" {
return nil
}
repo := cmp.Or(item.HeadRepo, s.Repository)
_, err := repoCall(ctx, s.Client, repo, func(ctx context.Context, c *gitea.Client, owner, name string) (bool, *gitea.Response, error) {
return c.Repositories.DeleteRepoBranch(ctx, owner, name, item.Head)
})
return err
}
// Files lists the paths of the files a pull request changes.
func (s Pulls) Files(ctx context.Context, n int64) ([]string, error) {
files, err := listAll(func(page int) ([]*gitea.ChangedFile, error) {
return repoCall(ctx, s.Client, s.Repository, func(ctx context.Context, c *gitea.Client, owner, name string) ([]*gitea.ChangedFile, *gitea.Response, error) {
return c.PullRequests.ListPullRequestFiles(ctx, owner, name, n, gitea.ListPullRequestFilesOptions{ListOptions: listOptions(100, page)})
})
})
if err != nil {
return nil, err
}
paths := make([]string, 0, len(files))
for _, file := range files {
if file != nil {
paths = append(paths, file.Filename)
}
}
return paths, nil
}
// MergeOptions supplies merge strategy and optional commit text. Auto merges once the required
// checks succeed instead of now.
type MergeOptions struct {
Method, Title, Message string
DeleteBranch, Auto bool
}
// MergeSettings are the merge styles a repository allows, in Gitea's order, and its merge defaults.
type MergeSettings struct {
Styles []string
Default string
DeleteBranch bool
}
// MergeSettings reads the merge styles the repository allows and its default style and branch
// deletion. A default style that is not allowed falls back to the first allowed one.
func (s Pulls) MergeSettings(ctx context.Context) (MergeSettings, error) {
repo, err := repoCall(ctx, s.Client, s.Repository, func(ctx context.Context, c *gitea.Client, owner, name string) (*gitea.Repository, *gitea.Response, error) {
return c.Repositories.GetRepo(ctx, owner, name)
})
if err != nil {
return MergeSettings{}, err
}
settings := MergeSettings{Default: string(repo.DefaultMergeStyle), DeleteBranch: repo.DefaultDeleteBranchAfterMerge}
for _, style := range []struct {
name gitea.MergeStyle
allowed bool
}{
{gitea.MergeStyleMerge, repo.AllowMerge},
{gitea.MergeStyleRebase, repo.AllowRebase},
{gitea.MergeStyleRebaseMerge, repo.AllowRebaseMerge},
{gitea.MergeStyleSquash, repo.AllowSquash},
{gitea.MergeStyleFastForwardOnly, repo.AllowFastForwardOnlyMerge},
} {
if style.allowed {
settings.Styles = append(settings.Styles, string(style.name))
}
}
if !slices.Contains(settings.Styles, settings.Default) && len(settings.Styles) > 0 {
settings.Default = settings.Styles[0]
}
return settings, nil
}
// Merge merges the pull request's current head with the selected strategy; Gitea refuses the
// merge if the head moves meanwhile. When Gitea refuses the merge, the error names the reason
// derived from the pull request, since Gitea reports conflicts only as "try again later".
func (s Pulls) Merge(ctx context.Context, n int64, opts MergeOptions) error {
pull, err := s.get(ctx, n)
if err != nil {
return err
}
option := gitea.MergePullRequestOption{
Style: gitea.MergeStyle(opts.Method), Title: opts.Title, Message: opts.Message, DeleteBranchAfterMerge: &opts.DeleteBranch,
MergeWhenChecksSucceed: opts.Auto,
}
if pull.Head != nil {
option.HeadCommitId = pull.Head.Sha
}
err = repoDo(ctx, s.Client, s.Repository, func(ctx context.Context, c *gitea.Client, owner, name string) (*gitea.Response, error) {
_, resp, err := c.PullRequests.MergePullRequest(ctx, owner, name, n, option)
return resp, err
})
key := "#" + strconv.FormatInt(n, 10)
if refused, ok := errors.AsType[*transport.APIError](err); !ok || refused.Status != http.StatusMethodNotAllowed {
return Classify(err, "pull request", key, s.Repository)
}
var reason string
switch {
case pull.HasMerged:
reason = "has already been merged"
case pull.State == gitea.StateClosed:
reason = "is closed"
case pull.Draft:
reason = "is a draft; mark it ready for review first"
case !pull.Mergeable:
reason = "has conflicting files or is otherwise not mergeable"
default:
return Classify(err, "pull request", key, s.Repository)
}
return fmt.Errorf("pull request #%d %s: %w", n, reason, err)
}
// pullTemplatePaths lists the Markdown pull request templates Gitea reads, in its priority order.
var pullTemplatePaths = []string{
"PULL_REQUEST_TEMPLATE.md", "pull_request_template.md",
".gitea/PULL_REQUEST_TEMPLATE.md", ".gitea/pull_request_template.md",
".github/PULL_REQUEST_TEMPLATE.md", ".github/pull_request_template.md",
}
const maxPullTemplateBytes = 1 << 20
// Template returns the first pull request template found at ref, or "" when the repository has none.
// All paths are probed concurrently; the result is decided in priority order, so a failure is
// reported only when every higher-priority path is missing.
func (s Pulls) Template(ctx context.Context, ref string) (string, error) {
type probe struct {
content string
missing bool
err error
}
probes := make([]probe, len(pullTemplatePaths))
var wg sync.WaitGroup
for i, path := range pullTemplatePaths {
wg.Go(func() {
probes[i].content, probes[i].missing, probes[i].err = s.templateAt(ctx, ref, path)
})
}
wg.Wait()
for _, p := range probes {
if !p.missing {
return p.content, p.err
}
}
return "", nil
}
// templateAt reads one template path; missing reports a 404.
func (s Pulls) templateAt(ctx context.Context, ref, path string) (content string, missing bool, err error) {
file, err := repoCall(ctx, s.Client, s.Repository, func(ctx context.Context, c *gitea.Client, owner, name string) (io.ReadCloser, *gitea.Response, error) {
return c.Repositories.GetFileReader(ctx, owner, name, ref, path)
})
if err != nil {
if IsNotFound(err) {
return "", true, nil
}
return "", false, err
}
data, err := io.ReadAll(io.LimitReader(file, maxPullTemplateBytes))
_ = file.Close()
return string(data), false, err
}