Files
gitea-tea/internal/app/pull_create.go
T
bircni 83924892ff refactor: reuse the existing service and rendering helpers
services.IsNotFound replaces the hand-rolled 404 checks, convertPage
the copied page conversions and lastPathNumber the comment's own
number parser. The List wrappers around ListPage of releases,
branches, wiki pages, secrets and variables go, pull request filters
resolve milestones through Milestones.FindOne like issues, and
localFlagsChanged, boolOrder, Cell.Render, stateBadge and dueText
replace their copies.

Signed-off-by: bircni <bircni@icloud.com>
2026-10-12 11:22:52 +02:00

376 lines
15 KiB
Go

// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package app
import (
"context"
"errors"
"fmt"
"path/filepath"
"strconv"
"strings"
"gitea.dev/tea/internal/clierr"
"gitea.dev/tea/internal/git"
"gitea.dev/tea/internal/services"
"gitea.dev/tea/internal/settings"
"gitea.dev/tea/internal/terminal"
"github.com/spf13/cobra"
)
func (a *application) pullCreateCommand() *cobra.Command {
var options services.PullOptions
var bodyFile, deadline, milestone, topic string
var labelNames, reviewers, reviewerTeams, attachments []string
var useEditor, draft, agit, allowMaintainerEdits, fill, web bool
create := &cobra.Command{Use: "create", Aliases: []string{"new"}, Short: "Create a pull request", Long: `Create a pull request from the current branch, or from --head.
On a terminal without flags, tea asks for the title and body, then what to do next: submit,
submit as a draft, add metadata or change the branches. --fill skips the questions and uses
the commit message of a single-commit branch, or the branch name, as the title. A branch that
is not on the remote yet is pushed first, with its upstream set.`, Args: argsExact(0), RunE: func(cmd *cobra.Command, _ []string) (runErr error) {
var editorDraft terminal.Draft
defer finishDraft(&editorDraft, &runErr)
if err := validateBodySources(cmd); err != nil {
return err
}
if agit {
if a.options.json != "" {
return clierr.Wrap(clierr.Usage, errors.New("--json/--jq cannot be combined with --agit; AGit creates the pull request through git push"))
}
for _, name := range []string{"assignee", "label", "milestone", "deadline", "reviewer", "reviewer-team", "attachment"} {
if cmd.Flags().Changed(name) {
return clierr.Wrap(clierr.Usage, fmt.Errorf("--%s cannot be combined with --agit; AGit pushes carry only a title and body", name))
}
}
} else if topic != "" {
return clierr.Wrap(clierr.Usage, errors.New("--topic requires --agit"))
}
if id, err := strconv.ParseInt(milestone, 10, 64); err == nil && id < 0 {
return clierr.Wrap(clierr.Usage, errors.New("--milestone must be a milestone title or a positive ID"))
}
if err := requireNames("label", "", labelNames); err != nil {
return err
}
if err := requireNames("assignee", "", options.Assignees); err != nil {
return err
}
trimNames(reviewers, reviewerTeams)
reviews := services.ReviewRequestChanges{Add: services.ReviewRequestInput{Users: reviewers, Teams: reviewerTeams}}
requestReviews := len(reviewers)+len(reviewerTeams) > 0
if requestReviews {
if err := reviews.Validate(); err != nil {
return clierr.Wrap(clierr.Usage, err)
}
}
files, err := openRegularFiles(attachments)
defer closeFiles(files)
if err != nil {
return err
}
if cmd.Flags().Changed("deadline") {
if options.Deadline, _, err = parseDeadline(deadline); err != nil {
return err
}
}
s, err := a.settings()
if err != nil {
return err
}
if !localFlagsChanged(cmd) && a.policy(s).CanPrompt(a.deps.Streams) && a.deps.PullForm != nil {
return a.createPullInteractively(cmd, s)
}
t, err := a.resolve(cmd.Context(), true)
if err != nil {
return err
}
options.Labels, err = (services.Labels{Client: t.client, Repository: t.Repo}).ResolveIssueNames(cmd.Context(), labelNames)
if err != nil {
return err
}
if milestone != "" && milestone != "0" {
if options.Milestone, err = milestoneID(cmd, t, milestone); err != nil {
return err
}
}
t.expandMeAll(options.Assignees)
t.expandMeAll(reviewers)
if options.Head == "" {
owner, branch := a.defaultPullHead(cmd.Context())
if branch == "" {
return clierr.Wrap(clierr.Usage, errors.New("supply --head outside a branch checkout"))
}
options.Head = branch
if baseOwner, _, _ := strings.Cut(t.Repo, "/"); !agit && owner != "" && !strings.EqualFold(owner, baseOwner) {
options.Head = owner + ":" + branch
}
}
if options.Base == "" {
repo, err := (services.Repositories{Client: t.client}).View(cmd.Context(), t.Repo)
if err != nil {
return err
}
if options.Base = repo.DefaultBranch; options.Base == "" {
return clierr.Wrap(clierr.Usage, errors.New("supply --base; repository has no default branch"))
}
}
if web {
return a.openBrowser(cmd, repositoryURL(t)+"/compare/"+options.Base+"..."+options.Head)
}
var commitBody string
if options.Title == "" {
if subject, body, ok := a.singleCommitMessage(cmd.Context(), options.Base, options.Head); ok {
options.Title, commitBody = subject, body
} else if options.Title = terminal.DefaultPullTitle(options.Head); options.Title == "" {
return clierr.Wrap(clierr.Usage, errors.New("supply --title"))
}
}
service := services.Pulls{Client: t.client, Repository: t.Repo}
if useEditor {
var seed string
if seed, err = service.Template(cmd.Context(), options.Base); err != nil {
return err
}
if seed == "" {
seed = commitBody
}
editorDraft, err = a.editDraft(cmd, seed)
options.Body = editorDraft.Body
} else if !cmd.Flags().Changed("body") && !cmd.Flags().Changed("body-file") {
options.Body = commitBody
} else {
options.Body, err = a.readBody(cmd, options.Body, bodyFile)
}
if err != nil {
return err
}
if draft {
options.Title = services.AddDraftPrefix(options.Title)
}
if agit {
return a.pushAgit(cmd, t, s, git.AgitOptions{Remote: a.options.remote, Head: options.Head, Base: options.Base, Topic: topic, Title: options.Title, Body: options.Body})
}
options.AllowMaintainerEdit = &allowMaintainerEdits
reviewService := services.Reviews{Client: t.client, Repository: t.Repo}
if len(reviewerTeams) > 0 {
if err := reviewService.CheckRequestTeams(cmd.Context(), reviewerTeams); err != nil {
return err
}
}
if err := a.ensureHeadPublished(cmd, t, s, options.Head); err != nil {
return err
}
item, err := service.Create(cmd.Context(), options)
if err != nil {
return err
}
var failures []error
if requestReviews {
if updated, err := reviewService.ChangeRequests(cmd.Context(), item.Number, reviews); err != nil {
failures = append(failures, fmt.Errorf("request reviews: %w", err))
} else {
item = updated
}
}
for _, file := range files {
if _, err := (services.Issues{Client: t.client, Repository: t.Repo}).UploadAttachment(cmd.Context(), item.Number, filepath.Base(file.Name()), file); err != nil {
failures = append(failures, fmt.Errorf("upload %s: %w", file.Name(), err))
}
}
if err := a.reportCreatedPull(cmd, t, item); err != nil {
return err
}
if len(failures) > 0 {
return fmt.Errorf("pull request #%d was created, but follow-up steps failed: %w", item.Number, errors.Join(failures...))
}
return nil
}}
create.Flags().StringSliceVarP(&options.Assignees, "assignee", "a", nil, "Assign users by username; @me for yourself")
create.Flags().StringSliceVarP(&labelNames, "label", "l", nil, "Apply labels by name; repo: or org: picks between same-named labels")
create.Flags().StringVarP(&milestone, "milestone", "m", "", "Milestone title or ID")
flagAliases(create, map[string]string{"milestone-name": "milestone"})
create.Flags().StringVarP(&options.Title, "title", "t", "", "Pull request title (default: the commit message of a single-commit branch, or the branch name)")
create.Flags().StringVar(&deadline, "deadline", "", "Due date: YYYY-MM-DD, RFC3339, or Unix timestamp")
create.Flags().StringVarP(&options.Body, "body", "b", "", "Pull request body")
create.Flags().StringVarP(&bodyFile, "body-file", "F", "", "Read the body from `file`, or - for stdin")
create.Flags().BoolVarP(&useEditor, "editor", "e", false, "Write the body in your editor, starting from the repository's template")
create.Flags().StringVarP(&options.Head, "head", "H", "", "Head branch, or OWNER:BRANCH for a fork (default: the current branch)")
create.Flags().StringVarP(&options.Base, "base", "B", "", "Base branch (default: the repository's default branch)")
create.Flags().BoolVarP(&draft, "draft", "d", false, "Create a draft pull request")
create.Flags().BoolVarP(&fill, "fill", "f", false, "Skip the questions and take the title and body from the commits")
create.Flags().BoolVarP(&web, "web", "w", false, "Open the comparison page in the browser to create the pull request there")
create.Flags().BoolVar(&allowMaintainerEdits, "allow-maintainer-edits", true, "Allow maintainers to push to the head branch")
create.Flags().BoolVar(&allowMaintainerEdits, "edits", true, "Alias of --allow-maintainer-edits")
_ = create.Flags().MarkHidden("edits")
create.Flags().BoolVar(&agit, "agit", false, "Create the pull request through AGit push (refs/for)")
create.Flags().StringSliceVarP(&reviewers, "reviewer", "r", nil, "Request reviews from users; @me for yourself")
create.Flags().StringSliceVar(&reviewerTeams, "reviewer-team", nil, "Request reviews from teams within the repository's organization")
create.Flags().StringArrayVar(&attachments, "attachment", nil, "Attach a file to the pull request (repeatable)")
create.Flags().StringVar(&topic, "topic", "", "AGit topic name (default: head branch); requires --agit")
_ = create.RegisterFlagCompletionFunc("assignee", a.completeUsers())
_ = create.RegisterFlagCompletionFunc("reviewer", a.completeUsers())
_ = create.RegisterFlagCompletionFunc("label", a.completeLabels())
_ = create.RegisterFlagCompletionFunc("milestone", a.completeMilestones())
_ = create.RegisterFlagCompletionFunc("base", a.completeBranches())
_ = create.RegisterFlagCompletionFunc("head", a.completeBranches())
create.Example = `# Create a pull request for the current branch interactively
$ tea pr create
# Take the title and body from the commits, without questions
$ tea pr create --fill
$ tea pr create -t "Add login" -b "Closes #1" -B develop -r carol -l enhancement
$ tea pr create --draft --fill`
return create
}
// pushAgit creates a pull request by pushing the head branch to refs/for/<base>/<topic>.
// Without --remote it pushes to the one remote that matches the resolved repository.
func (a *application) pushAgit(cmd *cobra.Command, t target, s settings.Snapshot, opts git.AgitOptions) error {
if _, branch, ok := strings.Cut(opts.Head, ":"); ok {
opts.Head = branch
}
if opts.Topic == "" {
opts.Topic = opts.Head
}
var err error
if opts.Remote == "" {
if opts.Remote, err = a.checkoutRemote(cmd.Context(), t, s); err != nil {
return clierr.Wrap(clierr.Usage, fmt.Errorf("AGit push: %w", err))
}
}
if opts.CredentialArgs, err = a.remoteCredentials(cmd.Context(), t, opts.Remote); err != nil {
return err
}
opts.Directory, opts.Prompt = a.options.cwd, a.policy(s).CanPrompt(a.deps.Streams)
return git.PushAgitPR(cmd.Context(), a.deps.Git, opts)
}
func (a *application) defaultPullHead(ctx context.Context) (owner, branch string) {
probe := a.gitProbe()
branch, remote := git.CurrentUpstreamRemote(ctx, probe, a.options.cwd)
if branch == "" {
return "", ""
}
if remote == "" {
return "", branch
}
raw, err := probe.Output(ctx, a.options.cwd, false, "remote", "get-url", remote)
if err != nil || raw == "" {
return "", branch
}
snapshot, err := a.settings()
if err != nil {
return "", branch
}
_, repo, err := settings.RepositoryURL(raw, snapshot.Hosts.Hosts)
if err != nil {
return "", branch
}
owner, _, _ = strings.Cut(repo, "/")
return owner, branch
}
// singleCommitMessage returns the subject and body of the only commit head has ahead of base.
// Any Git failure, or a head with zero or several commits, reports ok=false.
func (a *application) singleCommitMessage(ctx context.Context, base, head string) (subject, body string, ok bool) {
if _, branch, found := strings.Cut(head, ":"); found {
head = branch
}
if base == "" || head == "" || strings.HasPrefix(base, "-") || strings.HasPrefix(head, "-") {
return "", "", false
}
probe := a.gitProbe()
remotes := []string{"upstream", "origin"}
if a.options.remote != "" {
remotes = []string{a.options.remote}
}
count, err := "", errors.New("no base reference")
for _, ref := range append(remotes, "") {
if ref != "" {
ref += "/"
}
if count, err = probe.Output(ctx, a.options.cwd, false, "rev-list", "--count", ref+base+".."+head); err == nil {
break
}
}
if err != nil || count != "1" {
return "", "", false
}
message, err := probe.Output(ctx, a.options.cwd, false, "log", "-1", "--format=%s%n%n%b", head)
if err != nil {
return "", "", false
}
subject, body, _ = strings.Cut(message, "\n")
subject, body = strings.TrimSpace(subject), strings.TrimSpace(body)
return subject, body, subject != ""
}
func (a *application) createPullInteractively(cmd *cobra.Command, snapshot settings.Snapshot) (runErr error) {
target, err := a.resolve(cmd.Context(), true)
if err != nil {
return err
}
repo, err := (services.Repositories{Client: target.client}).View(cmd.Context(), target.Repo)
if err != nil {
return err
}
selectables, err := (services.Pulls{Client: target.client, Repository: target.Repo}).CreateSelectables(cmd.Context())
if err != nil {
return err
}
baseOwner, _, _ := strings.Cut(target.Repo, "/")
headOwner, headBranch := a.defaultPullHead(cmd.Context())
template, err := (services.Pulls{Client: target.client, Repository: target.Repo}).Template(cmd.Context(), repo.DefaultBranch)
if err != nil {
return err
}
title, body, _ := a.singleCommitMessage(cmd.Context(), repo.DefaultBranch, headBranch)
if template != "" {
body = template
}
choices := terminal.PullFormChoices{
Repo: target.Repo, BaseOwner: baseOwner, Base: repo.DefaultBranch, HeadOwner: headOwner, HeadBranch: headBranch, Title: title, Body: body,
AllowMaintainerEdits: true, CanPush: selectables.CanPush, Assignees: selectables.Assignees,
EditBody: a.bodyEditor(cmd),
}
choices.Milestones, choices.Labels = milestoneChoices(selectables.Milestones), labelChoices(selectables.Labels)
values, err := a.deps.PullForm.CreatePull(cmd.Context(), choices)
defer finishDraft(&values.Draft, &runErr)
if err != nil {
return err
}
if values.Base == "" {
return clierr.Wrap(clierr.Usage, errors.New("supply a target branch; repository has no default branch"))
}
if values.Agit && a.options.json != "" {
return clierr.Wrap(clierr.Usage, errors.New("--json/--jq cannot be combined with AGit create; AGit creates the pull request through git push"))
}
if values.AsDraft {
values.Title = services.AddDraftPrefix(values.Title)
}
if values.Agit {
return a.pushAgit(cmd, target, snapshot, git.AgitOptions{Remote: a.options.remote, Head: values.Head, Base: values.Base, Topic: values.Topic, Title: values.Title, Body: values.Body})
}
options := services.PullOptions{
Title: values.Title, Body: values.Body, Assignees: values.Assignees, Milestone: values.Milestone,
Labels: values.Labels, Deadline: values.Deadline, Base: values.Base, Head: values.Head,
AllowMaintainerEdit: &values.AllowMaintainerEdits,
}
if err := a.ensureHeadPublished(cmd, target, snapshot, options.Head); err != nil {
return err
}
item, err := (services.Pulls{Client: target.client, Repository: target.Repo}).Create(cmd.Context(), options)
if err != nil {
return err
}
return a.reportCreatedPull(cmd, target, item)
}
// reportCreatedPull reports a new pull request and prints its URL, or the pull request as JSON.
func (a *application) reportCreatedPull(cmd *cobra.Command, t target, item services.Pull) error {
a.done(cmd, "Created pull request %s", refLabel(t.Repo, item.Number, item.Title))
return a.created(cmd, item, item.URL)
}