diff --git a/cmd/pulls/create.go b/cmd/pulls/create.go index b1dd6a3..bdeb879 100644 --- a/cmd/pulls/create.go +++ b/cmd/pulls/create.go @@ -37,6 +37,14 @@ var CmdPullsCreate = cli.Command{ Usage: "Enable maintainers to push to the base branch of created pull", Value: true, }, + &cli.BoolFlag{ + Name: "agit", + Usage: "Create an agit flow pull request", + }, + &cli.StringFlag{ + Name: "topic", + Usage: "Topic name for agit flow pull request", + }, }, flags.IssuePRCreateFlags...), } @@ -61,6 +69,18 @@ func runPullsCreate(_ stdctx.Context, cmd *cli.Command) error { return err } + if ctx.Bool("agit") { + return task.CreateAgitFlowPull( + ctx, + ctx.String("remote"), + ctx.String("head"), + ctx.String("base"), + ctx.String("topic"), + opts, + interact.PromptPassword, + ) + } + var allowMaintainerEdits *bool if ctx.IsSet("allow-maintainer-edits") { allowMaintainerEdits = gitea.OptionalBool(ctx.Bool("allow-maintainer-edits")) diff --git a/docs/CLI.md b/docs/CLI.md index 59467dc..2bca4e0 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -345,6 +345,8 @@ Deletes local & remote feature-branches for a closed pull request Create a pull-request +**--agit**: Create an agit flow pull request + **--allow-maintainer-edits, --edits**: Enable maintainers to push to the base branch of created pull **--assignees, -a**="": Comma-separated list of usernames to assign @@ -371,6 +373,8 @@ Create a pull-request **--title, -t**="": +**--topic**="": Topic name for agit flow pull request + ### close Change state of one or more pull requests to 'closed' diff --git a/modules/git/branch.go b/modules/git/branch.go index 2f2bc79..7963c2e 100644 --- a/modules/git/branch.go +++ b/modules/git/branch.go @@ -4,8 +4,10 @@ package git import ( + "encoding/base64" "fmt" "strings" + "unicode" "github.com/go-git/go-git/v5" git_config "github.com/go-git/go-git/v5/config" @@ -247,3 +249,47 @@ func (r TeaRepo) TeaGetCurrentBranchNameAndSHA() (string, string, error) { return localHead.Name().Short(), localHead.Hash().String(), nil } + +// PushToCreatAgitFlowPR pushes the given head to the refs/for// ref on the remote to create an agit flow PR. +func (r TeaRepo) PushToCreatAgitFlowPR(remoteName, head, base, topic, title, description string, auth git_transport.AuthMethod) error { + if !strings.HasPrefix(head, "refs/") { + head = "refs/heads/" + head + } + + ref := fmt.Sprintf("%s:refs/for/%s/%s", head, base, topic) + + pushOptions := make(map[string]string) + if len(title) > 0 { + pushOptions["title"] = b64Encode(title) + } + if len(description) > 0 { + pushOptions["description"] = b64Encode(description) + } + + opts := &git.PushOptions{ + RemoteName: remoteName, + RefSpecs: []git_config.RefSpec{git_config.RefSpec(ref)}, + Options: pushOptions, + Auth: auth, + } + + return r.Push(opts) +} + +// b64Encode implements base64 encode for string if necessary. +func b64Encode(s string) string { + if strings.Contains(s, "\n") || !isASCII(s) { + return "{base64}" + base64.StdEncoding.EncodeToString([]byte(s)) + } + return s +} + +// isASCII indicates string contains only ASCII. +func isASCII(s string) bool { + for i := 0; i < len(s); i++ { + if s[i] > unicode.MaxASCII { + return false + } + } + return true +} diff --git a/modules/git/repo.go b/modules/git/repo.go index 8baec1f..938e4e6 100644 --- a/modules/git/repo.go +++ b/modules/git/repo.go @@ -4,6 +4,8 @@ package git import ( + "net/url" + "github.com/go-git/go-git/v5" ) @@ -33,3 +35,13 @@ func RepoFromPath(path string) (*TeaRepo, error) { return &TeaRepo{repo}, nil } + +// RemoteURL returns the URL of the given remote +func (r TeaRepo) RemoteURL(remoteName string) (*url.URL, error) { + remote, err := r.Remote(remoteName) + if err != nil { + return nil, err + } + + return url.Parse(remote.Config().URLs[0]) +} diff --git a/modules/interact/pull_create.go b/modules/interact/pull_create.go index 6233405..802d6e6 100644 --- a/modules/interact/pull_create.go +++ b/modules/interact/pull_create.go @@ -7,6 +7,7 @@ import ( "code.gitea.io/sdk/gitea" "code.gitea.io/tea/modules/context" "code.gitea.io/tea/modules/task" + "code.gitea.io/tea/modules/theme" "github.com/charmbracelet/huh" ) @@ -16,6 +17,8 @@ func CreatePull(ctx *context.TeaContext) (err error) { var ( base, head string allowMaintainerEdits = true + + agit bool ) // owner, repo @@ -37,6 +40,66 @@ func CreatePull(ctx *context.TeaContext) (err error) { } } + if err := huh.NewConfirm(). + Title("Do you want to create an agit flow pull request?"). + Value(&agit). + WithTheme(theme.GetTheme()). + Run(); err != nil { + return err + } + + if agit { + var ( + topic string + baseRemote string + ) + + topic = headBranch + + head = "HEAD" + baseRemote = "origin" + + if err := huh.NewForm( + huh.NewGroup( + huh.NewInput(). + Title("Target branch:"). + Value(&base). + Validate(huh.ValidateNotEmpty()), + + huh.NewInput(). + Title("Source repo remote:"). + Value(&baseRemote), + + huh.NewInput(). + Title("Topic branch:"). + Value(&topic). + Validate(validator), + + huh.NewInput(). + Title("Head branch:"). + Value(&head). + Validate(validator), + ), + ).Run(); err != nil { + return err + } + + opts := gitea.CreateIssueOption{Title: task.GetDefaultPRTitle(head)} + if err = promptIssueProperties(ctx.Login, ctx.Owner, ctx.Repo, &opts); err != nil { + return err + } + + return task.CreateAgitFlowPull( + ctx, + baseRemote, + head, + base, + topic, + &opts, + PromptPassword, + ) + } + if err := huh.NewForm( huh.NewGroup( huh.NewInput(). diff --git a/modules/task/pull_create.go b/modules/task/pull_create.go index b07a28a..424854d 100644 --- a/modules/task/pull_create.go +++ b/modules/task/pull_create.go @@ -153,3 +153,67 @@ func GetDefaultPRTitle(header string) string { return title } + +// CreateAgitFlowPull creates a agit flow PR in the given repo and prints the result +func CreateAgitFlowPull(ctx *context.TeaContext, remote, head, base, topic string, + opts *gitea.CreateIssueOption, + callback func(string) (string, error)) (err error) { + // default is default branch + if len(base) == 0 { + base, err = GetDefaultPRBase(ctx.Login, ctx.Owner, ctx.Repo) + if err != nil { + return err + } + } + + // default is current one + if len(head) == 0 { + if ctx.LocalRepo == nil { + return fmt.Errorf("no local git repo detected, please specify topic branch") + } + headOwner, headBranch, err := GetDefaultPRHead(ctx.LocalRepo) + if err != nil { + return err + } + + head = GetHeadSpec(headOwner, headBranch, ctx.Owner) + } + + if len(remote) == 0 { + return fmt.Errorf("remote is required for agit flow PR") + } + + if len(topic) == 0 { + topic = head + } + + if head == base || topic == base { + return fmt.Errorf("can't create PR from %s to %s", topic, base) + } + + // default is head branch name + if len(opts.Title) == 0 { + opts.Title = GetDefaultPRTitle(head) + } + // title is required + if len(opts.Title) == 0 { + return fmt.Errorf("title is required") + } + + localRepo, err := local_git.RepoForWorkdir() + if err != nil { + return err + } + + url, err := localRepo.RemoteURL(remote) + if err != nil { + return err + } + + auth, err := local_git.GetAuthForURL(url, ctx.Login.Token, ctx.Login.SSHKey, callback) + if err != nil { + return err + } + + return localRepo.PushToCreatAgitFlowPR(remote, head, base, topic, opts.Title, opts.Body, auth) +}