Merge pull request #2386 from cli/create-refactor

Refactor pr/issue creation code
This commit is contained in:
Nate Smith 2020-11-13 11:37:15 -06:00 committed by GitHub
commit 9a20719ec4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 967 additions and 892 deletions

View file

@ -7,12 +7,10 @@ import (
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/api"
"github.com/cli/cli/git"
"github.com/cli/cli/internal/config"
"github.com/cli/cli/internal/ghrepo"
prShared "github.com/cli/cli/pkg/cmd/pr/shared"
"github.com/cli/cli/pkg/cmdutil"
"github.com/cli/cli/pkg/githubtemplate"
"github.com/cli/cli/pkg/iostreams"
"github.com/cli/cli/utils"
"github.com/spf13/cobra"
@ -101,14 +99,7 @@ func createRun(opts *CreateOptions) error {
return err
}
var nonLegacyTemplateFiles []string
if opts.RootDirOverride != "" {
nonLegacyTemplateFiles = githubtemplate.FindNonLegacy(opts.RootDirOverride, "ISSUE_TEMPLATE")
} else if opts.RepoOverride == "" {
if rootDir, err := git.ToplevelDir(); err == nil {
nonLegacyTemplateFiles = githubtemplate.FindNonLegacy(rootDir, "ISSUE_TEMPLATE")
}
}
templateFiles, legacyTemplate := prShared.FindTemplates(opts.RootDirOverride, "ISSUE_TEMPLATE")
isTerminal := opts.IO.IsStdoutTTY()
@ -117,14 +108,24 @@ func createRun(opts *CreateOptions) error {
milestones = []string{opts.Milestone}
}
tb := prShared.IssueMetadataState{
Type: prShared.IssueMetadata,
Assignees: opts.Assignees,
Labels: opts.Labels,
Projects: opts.Projects,
Milestones: milestones,
Title: opts.Title,
Body: opts.Body,
}
if opts.WebMode {
openURL := ghrepo.GenerateRepoURL(baseRepo, "issues/new")
if opts.Title != "" || opts.Body != "" {
openURL, err = prShared.WithPrAndIssueQueryParams(openURL, opts.Title, opts.Body, opts.Assignees, opts.Labels, opts.Projects, milestones)
openURL, err = prShared.WithPrAndIssueQueryParams(openURL, tb)
if err != nil {
return err
}
} else if len(nonLegacyTemplateFiles) > 1 {
} else if len(templateFiles) > 1 {
openURL += "/choose"
}
if isTerminal {
@ -146,59 +147,69 @@ func createRun(opts *CreateOptions) error {
}
action := prShared.SubmitAction
tb := prShared.IssueMetadataState{
Type: prShared.IssueMetadata,
Assignees: opts.Assignees,
Labels: opts.Labels,
Projects: opts.Projects,
Milestones: milestones,
}
title := opts.Title
body := opts.Body
if opts.Interactive {
var legacyTemplateFile *string
if opts.RepoOverride == "" {
if rootDir, err := git.ToplevelDir(); err == nil {
// TODO: figure out how to stub this in tests
legacyTemplateFile = githubtemplate.FindLegacy(rootDir, "ISSUE_TEMPLATE")
}
}
editorCommand, err := cmdutil.DetermineEditor(opts.Config)
if err != nil {
return err
}
err = prShared.TitleBodySurvey(opts.IO, editorCommand, &tb, apiClient, baseRepo, title, body, prShared.Defaults{}, nonLegacyTemplateFiles, legacyTemplateFile, false, repo.ViewerCanTriage())
if err != nil {
return fmt.Errorf("could not collect title and/or body: %w", err)
if tb.Title == "" {
err = prShared.TitleSurvey(&tb)
if err != nil {
return err
}
}
action = tb.Action
if tb.Body == "" {
templateContent := ""
if tb.Action == prShared.CancelAction {
templateContent, err = prShared.TemplateSurvey(templateFiles, legacyTemplate, tb)
if err != nil {
return err
}
err = prShared.BodySurvey(&tb, templateContent, editorCommand)
if err != nil {
return err
}
if tb.Body == "" {
tb.Body = templateContent
}
}
action, err := prShared.ConfirmSubmission(!tb.HasMetadata(), repo.ViewerCanTriage())
if err != nil {
return fmt.Errorf("unable to confirm: %w", err)
}
if action == prShared.MetadataAction {
err = prShared.MetadataSurvey(opts.IO, apiClient, baseRepo, &tb)
if err != nil {
return err
}
action, err = prShared.ConfirmSubmission(!tb.HasMetadata(), false)
if err != nil {
return err
}
}
if action == prShared.CancelAction {
fmt.Fprintln(opts.IO.ErrOut, "Discarding.")
return nil
}
if title == "" {
title = tb.Title
}
if body == "" {
body = tb.Body
}
} else {
if title == "" {
if tb.Title == "" {
return fmt.Errorf("title can't be blank")
}
}
if action == prShared.PreviewAction {
openURL := ghrepo.GenerateRepoURL(baseRepo, "issues/new")
openURL, err = prShared.WithPrAndIssueQueryParams(openURL, title, body, tb.Assignees, tb.Labels, tb.Projects, tb.Milestones)
openURL, err = prShared.WithPrAndIssueQueryParams(openURL, tb)
if err != nil {
return err
}
@ -208,8 +219,8 @@ func createRun(opts *CreateOptions) error {
return utils.OpenInBrowser(openURL)
} else if action == prShared.SubmitAction {
params := map[string]interface{}{
"title": title,
"body": body,
"title": tb.Title,
"body": tb.Body,
}
err = prShared.AddMetadataToIssueParams(apiClient, baseRepo, params, &tb)

View file

@ -144,18 +144,17 @@ func TestIssueCreate_nonLegacyTemplate(t *testing.T) {
as, teardown := prompt.InitAskStubber()
defer teardown()
// tmeplate
as.Stub([]*prompt.QuestionStub{
{
Name: "index",
Value: 1,
},
})
as.Stub([]*prompt.QuestionStub{
{
Name: "body",
Default: true,
},
})
// body
as.StubOneDefault()
// confirm
as.Stub([]*prompt.QuestionStub{
{
Name: "confirmation",

View file

@ -18,7 +18,6 @@ import (
"github.com/cli/cli/internal/ghrepo"
"github.com/cli/cli/pkg/cmd/pr/shared"
"github.com/cli/cli/pkg/cmdutil"
"github.com/cli/cli/pkg/githubtemplate"
"github.com/cli/cli/pkg/iostreams"
"github.com/cli/cli/pkg/prompt"
"github.com/cli/cli/utils"
@ -26,14 +25,13 @@ import (
)
type CreateOptions struct {
// This struct stores user input and factory functions
HttpClient func() (*http.Client, error)
Config func() (config.Config, error)
IO *iostreams.IOStreams
Remotes func() (context.Remotes, error)
Branch func() (string, error)
Interactive bool
TitleProvided bool
BodyProvided bool
@ -56,6 +54,21 @@ type CreateOptions struct {
Milestone string
}
type CreateContext struct {
// This struct stores contextual data about the creation process and is for building up enough
// data to create a pull request
RepoContext *context.ResolvedRemotes
BaseRepo ghrepo.Interface
HeadRepo ghrepo.Interface
BaseTrackingBranch string
BaseBranch string
HeadBranch string
HeadBranchLabel string
HeadRemote *context.Remote
IsPushEnabled bool
Client *api.Client
}
func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Command {
opts := &CreateOptions{
IO: f.IOStreams,
@ -90,8 +103,6 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co
opts.BodyProvided = cmd.Flags().Changed("body")
opts.RepoOverride, _ = cmd.Flags().GetString("repo")
opts.Interactive = !(opts.TitleProvided && opts.BodyProvided)
if !opts.IO.CanPrompt() && !opts.WebMode && !opts.TitleProvided && !opts.Autofill {
return &cmdutil.FlagError{Err: errors.New("--title or --fill required when not running interactively")}
}
@ -127,404 +138,169 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co
return cmd
}
func createRun(opts *CreateOptions) error {
func createRun(opts *CreateOptions) (err error) {
ctx, err := NewCreateContext(opts)
if err != nil {
return err
}
client := ctx.Client
state, err := NewIssueState(*ctx, *opts)
if err != nil {
return err
}
if opts.WebMode {
if !opts.Autofill {
state.Title = opts.Title
state.Body = opts.Body
}
err := handlePush(*opts, *ctx)
if err != nil {
return err
}
return previewPR(*opts, *ctx, *state)
}
if opts.TitleProvided {
state.Title = opts.Title
}
if opts.BodyProvided {
state.Body = opts.Body
}
existingPR, err := api.PullRequestForBranch(
client, ctx.BaseRepo, ctx.BaseBranch, ctx.HeadBranchLabel, []string{"OPEN"})
var notFound *api.NotFoundError
if err != nil && !errors.As(err, &notFound) {
return fmt.Errorf("error checking for existing pull request: %w", err)
}
if err == nil {
return fmt.Errorf("a pull request for branch %q into branch %q already exists:\n%s",
ctx.HeadBranchLabel, ctx.BaseBranch, existingPR.URL)
}
message := "\nCreating pull request for %s into %s in %s\n\n"
if state.Draft {
message = "\nCreating draft pull request for %s into %s in %s\n\n"
}
cs := opts.IO.ColorScheme()
httpClient, err := opts.HttpClient()
if err != nil {
return err
if opts.IO.CanPrompt() {
fmt.Fprintf(opts.IO.ErrOut, message,
cs.Cyan(ctx.HeadBranchLabel),
cs.Cyan(ctx.BaseBranch),
ghrepo.FullName(ctx.BaseRepo))
}
client := api.NewClientFromHTTP(httpClient)
remotes, err := opts.Remotes()
if opts.Autofill || (opts.TitleProvided && opts.BodyProvided) {
err = handlePush(*opts, *ctx)
if err != nil {
return err
}
return submitPR(*opts, *ctx, *state)
}
if !opts.TitleProvided {
err = shared.TitleSurvey(state)
if err != nil {
return err
}
}
editorCommand, err := cmdutil.DetermineEditor(opts.Config)
if err != nil {
return err
}
repoContext, err := context.ResolveRemotesToRepos(remotes, client, opts.RepoOverride)
templateContent := ""
if !opts.BodyProvided {
templateFiles, legacyTemplate := shared.FindTemplates(opts.RootDirOverride, "PULL_REQUEST_TEMPLATE")
templateContent, err = shared.TemplateSurvey(templateFiles, legacyTemplate, *state)
if err != nil {
return err
}
err = shared.BodySurvey(state, templateContent, editorCommand)
if err != nil {
return err
}
if state.Body == "" {
state.Body = templateContent
}
}
allowMetadata := ctx.BaseRepo.(*api.Repository).ViewerCanTriage()
action, err := shared.ConfirmSubmission(!state.HasMetadata(), allowMetadata)
if err != nil {
return fmt.Errorf("unable to confirm: %w", err)
}
if action == shared.MetadataAction {
err = shared.MetadataSurvey(opts.IO, client, ctx.BaseRepo, state)
if err != nil {
return err
}
action, err = shared.ConfirmSubmission(!state.HasMetadata(), false)
if err != nil {
return err
}
}
if action == shared.CancelAction {
fmt.Fprintln(opts.IO.ErrOut, "Discarding.")
return nil
}
err = handlePush(*opts, *ctx)
if err != nil {
return err
}
var baseRepo *api.Repository
if br, err := repoContext.BaseRepo(opts.IO); err == nil {
if r, ok := br.(*api.Repository); ok {
baseRepo = r
} else {
// TODO: if RepoNetwork is going to be requested anyway in `repoContext.HeadRepos()`,
// consider piggybacking on that result instead of performing a separate lookup
var err error
baseRepo, err = api.GitHubRepo(client, br)
if err != nil {
return err
}
}
} else {
return fmt.Errorf("could not determine base repository: %w", err)
}
isPushEnabled := false
headBranch := opts.HeadBranch
headBranchLabel := opts.HeadBranch
if headBranch == "" {
headBranch, err = opts.Branch()
if err != nil {
return fmt.Errorf("could not determine the current branch: %w", err)
}
headBranchLabel = headBranch
isPushEnabled = true
} else if idx := strings.IndexRune(headBranch, ':'); idx >= 0 {
headBranch = headBranch[idx+1:]
}
if ucc, err := git.UncommittedChangeCount(); err == nil && ucc > 0 {
fmt.Fprintf(opts.IO.ErrOut, "Warning: %s\n", utils.Pluralize(ucc, "uncommitted change"))
}
var headRepo ghrepo.Interface
var headRemote *context.Remote
if isPushEnabled {
// determine whether the head branch is already pushed to a remote
if pushedTo := determineTrackingBranch(remotes, headBranch); pushedTo != nil {
isPushEnabled = false
if r, err := remotes.FindByName(pushedTo.RemoteName); err == nil {
headRepo = r
headRemote = r
headBranchLabel = pushedTo.BranchName
if !ghrepo.IsSame(baseRepo, headRepo) {
headBranchLabel = fmt.Sprintf("%s:%s", headRepo.RepoOwner(), pushedTo.BranchName)
}
}
}
}
// otherwise, ask the user for the head repository using info obtained from the API
if headRepo == nil && isPushEnabled && opts.IO.CanPrompt() {
pushableRepos, err := repoContext.HeadRepos()
if err != nil {
return err
}
if len(pushableRepos) == 0 {
pushableRepos, err = api.RepoFindForks(client, baseRepo, 3)
if err != nil {
return err
}
}
currentLogin, err := api.CurrentLoginName(client, baseRepo.RepoHost())
if err != nil {
return err
}
hasOwnFork := false
var pushOptions []string
for _, r := range pushableRepos {
pushOptions = append(pushOptions, ghrepo.FullName(r))
if r.RepoOwner() == currentLogin {
hasOwnFork = true
}
}
if !hasOwnFork {
pushOptions = append(pushOptions, "Create a fork of "+ghrepo.FullName(baseRepo))
}
pushOptions = append(pushOptions, "Skip pushing the branch")
pushOptions = append(pushOptions, "Cancel")
var selectedOption int
err = prompt.SurveyAskOne(&survey.Select{
Message: fmt.Sprintf("Where should we push the '%s' branch?", headBranch),
Options: pushOptions,
}, &selectedOption)
if err != nil {
return err
}
if selectedOption < len(pushableRepos) {
headRepo = pushableRepos[selectedOption]
if !ghrepo.IsSame(baseRepo, headRepo) {
headBranchLabel = fmt.Sprintf("%s:%s", headRepo.RepoOwner(), headBranch)
}
} else if pushOptions[selectedOption] == "Skip pushing the branch" {
isPushEnabled = false
} else if pushOptions[selectedOption] == "Cancel" {
return cmdutil.SilentError
} else {
// "Create a fork of ..."
if baseRepo.IsPrivate {
return fmt.Errorf("cannot fork private repository %s", ghrepo.FullName(baseRepo))
}
headBranchLabel = fmt.Sprintf("%s:%s", currentLogin, headBranch)
}
}
if headRepo == nil && isPushEnabled && !opts.IO.CanPrompt() {
fmt.Fprintf(opts.IO.ErrOut, "aborted: you must first push the current branch to a remote, or use the --head flag")
return cmdutil.SilentError
}
baseBranch := opts.BaseBranch
if baseBranch == "" {
baseBranch = baseRepo.DefaultBranchRef.Name
}
if headBranch == baseBranch && headRepo != nil && ghrepo.IsSame(baseRepo, headRepo) {
return fmt.Errorf("must be on a branch named differently than %q", baseBranch)
}
var milestoneTitles []string
if opts.Milestone != "" {
milestoneTitles = []string{opts.Milestone}
}
baseTrackingBranch := baseBranch
if baseRemote, err := remotes.FindByRepo(baseRepo.RepoOwner(), baseRepo.RepoName()); err == nil {
baseTrackingBranch = fmt.Sprintf("%s/%s", baseRemote.Name, baseBranch)
}
defs, defaultsErr := computeDefaults(baseTrackingBranch, headBranch)
title := opts.Title
body := opts.Body
action := shared.SubmitAction
if opts.WebMode {
action = shared.PreviewAction
if (title == "" || body == "") && defaultsErr != nil {
return fmt.Errorf("could not compute title or body defaults: %w", defaultsErr)
}
} else if opts.Autofill {
if defaultsErr != nil && !(opts.TitleProvided || opts.BodyProvided) {
return fmt.Errorf("could not compute title or body defaults: %w", defaultsErr)
}
if !opts.TitleProvided {
title = defs.Title
}
if !opts.BodyProvided {
body = defs.Body
}
}
if !opts.WebMode {
existingPR, err := api.PullRequestForBranch(client, baseRepo, baseBranch, headBranchLabel, []string{"OPEN"})
var notFound *api.NotFoundError
if err != nil && !errors.As(err, &notFound) {
return fmt.Errorf("error checking for existing pull request: %w", err)
}
if err == nil {
return fmt.Errorf("a pull request for branch %q into branch %q already exists:\n%s", headBranchLabel, baseBranch, existingPR.URL)
}
}
isTerminal := opts.IO.IsStdinTTY() && opts.IO.IsStdoutTTY()
if !opts.WebMode && !opts.Autofill {
message := "\nCreating pull request for %s into %s in %s\n\n"
if opts.IsDraft {
message = "\nCreating draft pull request for %s into %s in %s\n\n"
}
if isTerminal {
fmt.Fprintf(opts.IO.ErrOut, message,
cs.Cyan(headBranchLabel),
cs.Cyan(baseBranch),
ghrepo.FullName(baseRepo))
if (title == "" || body == "") && defaultsErr != nil {
fmt.Fprintf(opts.IO.ErrOut, "%s warning: could not compute title or body defaults: %s\n", cs.Yellow("!"), defaultsErr)
}
}
}
tb := shared.IssueMetadataState{
Type: shared.PRMetadata,
Reviewers: opts.Reviewers,
Assignees: opts.Assignees,
Labels: opts.Labels,
Projects: opts.Projects,
Milestones: milestoneTitles,
}
if !opts.WebMode && !opts.Autofill && opts.Interactive {
var nonLegacyTemplateFiles []string
var legacyTemplateFile *string
if opts.RootDirOverride != "" {
nonLegacyTemplateFiles = githubtemplate.FindNonLegacy(opts.RootDirOverride, "PULL_REQUEST_TEMPLATE")
legacyTemplateFile = githubtemplate.FindLegacy(opts.RootDirOverride, "PULL_REQUEST_TEMPLATE")
} else if rootDir, err := git.ToplevelDir(); err == nil {
nonLegacyTemplateFiles = githubtemplate.FindNonLegacy(rootDir, "PULL_REQUEST_TEMPLATE")
legacyTemplateFile = githubtemplate.FindLegacy(rootDir, "PULL_REQUEST_TEMPLATE")
}
editorCommand, err := cmdutil.DetermineEditor(opts.Config)
if err != nil {
return err
}
err = shared.TitleBodySurvey(opts.IO, editorCommand, &tb, client, baseRepo, title, body, defs, nonLegacyTemplateFiles, legacyTemplateFile, true, baseRepo.ViewerCanTriage())
if err != nil {
return fmt.Errorf("could not collect title and/or body: %w", err)
}
action = tb.Action
if action == shared.CancelAction {
fmt.Fprintln(opts.IO.ErrOut, "Discarding.")
return nil
}
if title == "" {
title = tb.Title
}
if body == "" {
body = tb.Body
}
}
if action == shared.SubmitAction && title == "" {
return errors.New("pull request title must not be blank")
}
didForkRepo := false
// if a head repository could not be determined so far, automatically create
// one by forking the base repository
if headRepo == nil && isPushEnabled {
headRepo, err = api.ForkRepo(client, baseRepo)
if err != nil {
return fmt.Errorf("error forking repo: %w", err)
}
didForkRepo = true
}
if headRemote == nil && headRepo != nil {
headRemote, _ = repoContext.RemoteForRepo(headRepo)
}
// There are two cases when an existing remote for the head repo will be
// missing:
// 1. the head repo was just created by auto-forking;
// 2. an existing fork was discovered by querying the API.
//
// In either case, we want to add the head repo as a new git remote so we
// can push to it.
if headRemote == nil && isPushEnabled {
cfg, err := opts.Config()
if err != nil {
return err
}
cloneProtocol, _ := cfg.Get(headRepo.RepoHost(), "git_protocol")
headRepoURL := ghrepo.FormatRemoteURL(headRepo, cloneProtocol)
// TODO: prevent clashes with another remote of a same name
gitRemote, err := git.AddRemote("fork", headRepoURL)
if err != nil {
return fmt.Errorf("error adding remote: %w", err)
}
headRemote = &context.Remote{
Remote: gitRemote,
Repo: headRepo,
}
}
// automatically push the branch if it hasn't been pushed anywhere yet
if isPushEnabled {
pushBranch := func() error {
pushTries := 0
maxPushTries := 3
for {
r := NewRegexpWriter(opts.IO.ErrOut, gitPushRegexp, "")
defer r.Flush()
cmdErr := r
cmdOut := opts.IO.Out
if err := git.Push(headRemote.Name, fmt.Sprintf("HEAD:%s", headBranch), cmdOut, cmdErr); err != nil {
if didForkRepo && pushTries < maxPushTries {
pushTries++
// first wait 2 seconds after forking, then 4s, then 6s
waitSeconds := 2 * pushTries
fmt.Fprintf(opts.IO.ErrOut, "waiting %s before retrying...\n", utils.Pluralize(waitSeconds, "second"))
time.Sleep(time.Duration(waitSeconds) * time.Second)
continue
}
return err
}
break
}
return nil
}
err := pushBranch()
if err != nil {
return err
}
if action == shared.PreviewAction {
return previewPR(*opts, *ctx, *state)
}
if action == shared.SubmitAction {
params := map[string]interface{}{
"title": title,
"body": body,
"draft": opts.IsDraft,
"baseRefName": baseBranch,
"headRefName": headBranchLabel,
}
err = shared.AddMetadataToIssueParams(client, baseRepo, params, &tb)
if err != nil {
return err
}
pr, err := api.CreatePullRequest(client, baseRepo, params)
if pr != nil {
fmt.Fprintln(opts.IO.Out, pr.URL)
}
if err != nil {
if pr != nil {
return fmt.Errorf("pull request update failed: %w", err)
}
return fmt.Errorf("pull request create failed: %w", err)
}
} else if action == shared.PreviewAction {
openURL, err := generateCompareURL(baseRepo, baseBranch, headBranchLabel, title, body, tb.Assignees, tb.Labels, tb.Projects, tb.Milestones)
if err != nil {
return err
}
if isTerminal {
fmt.Fprintf(opts.IO.ErrOut, "Opening %s in your browser.\n", utils.DisplayURL(openURL))
}
return utils.OpenInBrowser(openURL)
} else {
panic("Unreachable state")
return submitPR(*opts, *ctx, *state)
}
return nil
return errors.New("expected to cancel, preview, or submit")
}
func computeDefaults(baseRef, headRef string) (shared.Defaults, error) {
out := shared.Defaults{}
func initDefaultTitleBody(ctx CreateContext, state *shared.IssueMetadataState) error {
baseRef := ctx.BaseTrackingBranch
headRef := ctx.HeadBranch
commits, err := git.Commits(baseRef, headRef)
if err != nil {
return out, err
return err
}
if len(commits) == 1 {
out.Title = commits[0].Title
state.Title = commits[0].Title
body, err := git.CommitBody(commits[0].Sha)
if err != nil {
return out, err
return err
}
out.Body = body
state.Body = body
} else {
out.Title = utils.Humanize(headRef)
state.Title = utils.Humanize(headRef)
var body strings.Builder
for i := len(commits) - 1; i >= 0; i-- {
fmt.Fprintf(&body, "- %s\n", commits[i].Title)
}
out.Body = body.String()
state.Body = body.String()
}
return out, nil
return nil
}
func determineTrackingBranch(remotes context.Remotes, headBranch string) *git.TrackingRef {
@ -568,9 +344,330 @@ func determineTrackingBranch(remotes context.Remotes, headBranch string) *git.Tr
return nil
}
func generateCompareURL(r ghrepo.Interface, base, head, title, body string, assignees, labels, projects []string, milestones []string) (string, error) {
u := ghrepo.GenerateRepoURL(r, "compare/%s...%s?expand=1", url.QueryEscape(base), url.QueryEscape(head))
url, err := shared.WithPrAndIssueQueryParams(u, title, body, assignees, labels, projects, milestones)
func NewIssueState(ctx CreateContext, opts CreateOptions) (*shared.IssueMetadataState, error) {
var milestoneTitles []string
if opts.Milestone != "" {
milestoneTitles = []string{opts.Milestone}
}
state := &shared.IssueMetadataState{
Type: shared.PRMetadata,
Reviewers: opts.Reviewers,
Assignees: opts.Assignees,
Labels: opts.Labels,
Projects: opts.Projects,
Milestones: milestoneTitles,
Draft: opts.IsDraft,
}
if opts.Autofill || !opts.TitleProvided || !opts.BodyProvided {
err := initDefaultTitleBody(ctx, state)
if err != nil {
return nil, fmt.Errorf("could not compute title or body defaults: %w", err)
}
}
return state, nil
}
func NewCreateContext(opts *CreateOptions) (*CreateContext, error) {
httpClient, err := opts.HttpClient()
if err != nil {
return nil, err
}
client := api.NewClientFromHTTP(httpClient)
remotes, err := opts.Remotes()
if err != nil {
return nil, err
}
repoContext, err := context.ResolveRemotesToRepos(remotes, client, opts.RepoOverride)
if err != nil {
return nil, err
}
var baseRepo *api.Repository
if br, err := repoContext.BaseRepo(opts.IO); err == nil {
if r, ok := br.(*api.Repository); ok {
baseRepo = r
} else {
// TODO: if RepoNetwork is going to be requested anyway in `repoContext.HeadRepos()`,
// consider piggybacking on that result instead of performing a separate lookup
baseRepo, err = api.GitHubRepo(client, br)
if err != nil {
return nil, err
}
}
} else {
return nil, fmt.Errorf("could not determine base repository: %w", err)
}
isPushEnabled := false
headBranch := opts.HeadBranch
headBranchLabel := opts.HeadBranch
if headBranch == "" {
headBranch, err = opts.Branch()
if err != nil {
return nil, fmt.Errorf("could not determine the current branch: %w", err)
}
headBranchLabel = headBranch
isPushEnabled = true
} else if idx := strings.IndexRune(headBranch, ':'); idx >= 0 {
headBranch = headBranch[idx+1:]
}
if ucc, err := git.UncommittedChangeCount(); err == nil && ucc > 0 {
fmt.Fprintf(opts.IO.ErrOut, "Warning: %s\n", utils.Pluralize(ucc, "uncommitted change"))
}
var headRepo ghrepo.Interface
var headRemote *context.Remote
if isPushEnabled {
// determine whether the head branch is already pushed to a remote
if pushedTo := determineTrackingBranch(remotes, headBranch); pushedTo != nil {
isPushEnabled = false
if r, err := remotes.FindByName(pushedTo.RemoteName); err == nil {
headRepo = r
headRemote = r
headBranchLabel = pushedTo.BranchName
if !ghrepo.IsSame(baseRepo, headRepo) {
headBranchLabel = fmt.Sprintf("%s:%s", headRepo.RepoOwner(), pushedTo.BranchName)
}
}
}
}
// otherwise, ask the user for the head repository using info obtained from the API
if headRepo == nil && isPushEnabled && opts.IO.CanPrompt() {
pushableRepos, err := repoContext.HeadRepos()
if err != nil {
return nil, err
}
if len(pushableRepos) == 0 {
pushableRepos, err = api.RepoFindForks(client, baseRepo, 3)
if err != nil {
return nil, err
}
}
currentLogin, err := api.CurrentLoginName(client, baseRepo.RepoHost())
if err != nil {
return nil, err
}
hasOwnFork := false
var pushOptions []string
for _, r := range pushableRepos {
pushOptions = append(pushOptions, ghrepo.FullName(r))
if r.RepoOwner() == currentLogin {
hasOwnFork = true
}
}
if !hasOwnFork {
pushOptions = append(pushOptions, "Create a fork of "+ghrepo.FullName(baseRepo))
}
pushOptions = append(pushOptions, "Skip pushing the branch")
pushOptions = append(pushOptions, "Cancel")
var selectedOption int
err = prompt.SurveyAskOne(&survey.Select{
Message: fmt.Sprintf("Where should we push the '%s' branch?", headBranch),
Options: pushOptions,
}, &selectedOption)
if err != nil {
return nil, err
}
if selectedOption < len(pushableRepos) {
headRepo = pushableRepos[selectedOption]
if !ghrepo.IsSame(baseRepo, headRepo) {
headBranchLabel = fmt.Sprintf("%s:%s", headRepo.RepoOwner(), headBranch)
}
} else if pushOptions[selectedOption] == "Skip pushing the branch" {
isPushEnabled = false
} else if pushOptions[selectedOption] == "Cancel" {
return nil, cmdutil.SilentError
} else {
// "Create a fork of ..."
if baseRepo.IsPrivate {
return nil, fmt.Errorf("cannot fork private repository %s", ghrepo.FullName(baseRepo))
}
headBranchLabel = fmt.Sprintf("%s:%s", currentLogin, headBranch)
}
}
if headRepo == nil && isPushEnabled && !opts.IO.CanPrompt() {
fmt.Fprintf(opts.IO.ErrOut, "aborted: you must first push the current branch to a remote, or use the --head flag")
return nil, cmdutil.SilentError
}
baseBranch := opts.BaseBranch
if baseBranch == "" {
baseBranch = baseRepo.DefaultBranchRef.Name
}
if headBranch == baseBranch && headRepo != nil && ghrepo.IsSame(baseRepo, headRepo) {
return nil, fmt.Errorf("must be on a branch named differently than %q", baseBranch)
}
baseTrackingBranch := baseBranch
if baseRemote, err := remotes.FindByRepo(baseRepo.RepoOwner(), baseRepo.RepoName()); err == nil {
baseTrackingBranch = fmt.Sprintf("%s/%s", baseRemote.Name, baseBranch)
}
return &CreateContext{
BaseRepo: baseRepo,
HeadRepo: headRepo,
BaseBranch: baseBranch,
BaseTrackingBranch: baseTrackingBranch,
HeadBranch: headBranch,
HeadBranchLabel: headBranchLabel,
HeadRemote: headRemote,
IsPushEnabled: isPushEnabled,
RepoContext: repoContext,
Client: client,
}, nil
}
func submitPR(opts CreateOptions, ctx CreateContext, state shared.IssueMetadataState) error {
client := ctx.Client
params := map[string]interface{}{
"title": state.Title,
"body": state.Body,
"draft": state.Draft,
"baseRefName": ctx.BaseBranch,
"headRefName": ctx.HeadBranchLabel,
}
if params["title"] == "" {
return errors.New("pull request title must not be blank")
}
err := shared.AddMetadataToIssueParams(client, ctx.BaseRepo, params, &state)
if err != nil {
return err
}
pr, err := api.CreatePullRequest(client, ctx.BaseRepo.(*api.Repository), params)
if pr != nil {
fmt.Fprintln(opts.IO.Out, pr.URL)
}
if err != nil {
if pr != nil {
return fmt.Errorf("pull request update failed: %w", err)
}
return fmt.Errorf("pull request create failed: %w", err)
}
return nil
}
func previewPR(opts CreateOptions, ctx CreateContext, state shared.IssueMetadataState) error {
openURL, err := generateCompareURL(ctx, state)
if err != nil {
return err
}
if opts.IO.IsStdinTTY() && opts.IO.IsStdoutTTY() {
fmt.Fprintf(opts.IO.ErrOut, "Opening %s in your browser.\n", utils.DisplayURL(openURL))
}
return utils.OpenInBrowser(openURL)
}
func handlePush(opts CreateOptions, ctx CreateContext) error {
didForkRepo := false
headRepo := ctx.HeadRepo
headRemote := ctx.HeadRemote
client := ctx.Client
var err error
// if a head repository could not be determined so far, automatically create
// one by forking the base repository
if headRepo == nil && ctx.IsPushEnabled {
headRepo, err = api.ForkRepo(client, ctx.BaseRepo)
if err != nil {
return fmt.Errorf("error forking repo: %w", err)
}
didForkRepo = true
}
if headRemote == nil && headRepo != nil {
headRemote, _ = ctx.RepoContext.RemoteForRepo(headRepo)
}
// There are two cases when an existing remote for the head repo will be
// missing:
// 1. the head repo was just created by auto-forking;
// 2. an existing fork was discovered by querying the API.
//
// In either case, we want to add the head repo as a new git remote so we
// can push to it.
if headRemote == nil && ctx.IsPushEnabled {
cfg, err := opts.Config()
if err != nil {
return err
}
cloneProtocol, _ := cfg.Get(headRepo.RepoHost(), "git_protocol")
headRepoURL := ghrepo.FormatRemoteURL(headRepo, cloneProtocol)
// TODO: prevent clashes with another remote of a same name
gitRemote, err := git.AddRemote("fork", headRepoURL)
if err != nil {
return fmt.Errorf("error adding remote: %w", err)
}
headRemote = &context.Remote{
Remote: gitRemote,
Repo: headRepo,
}
}
// automatically push the branch if it hasn't been pushed anywhere yet
if ctx.IsPushEnabled {
pushBranch := func() error {
pushTries := 0
maxPushTries := 3
for {
r := NewRegexpWriter(opts.IO.ErrOut, gitPushRegexp, "")
defer r.Flush()
cmdErr := r
cmdOut := opts.IO.Out
if err := git.Push(headRemote.Name, fmt.Sprintf("HEAD:%s", ctx.HeadBranch), cmdOut, cmdErr); err != nil {
if didForkRepo && pushTries < maxPushTries {
pushTries++
// first wait 2 seconds after forking, then 4s, then 6s
waitSeconds := 2 * pushTries
fmt.Fprintf(opts.IO.ErrOut, "waiting %s before retrying...\n", utils.Pluralize(waitSeconds, "second"))
time.Sleep(time.Duration(waitSeconds) * time.Second)
continue
}
return err
}
break
}
return nil
}
err := pushBranch()
if err != nil {
return err
}
}
return nil
}
func generateCompareURL(ctx CreateContext, state shared.IssueMetadataState) (string, error) {
u := ghrepo.GenerateRepoURL(
ctx.BaseRepo,
"compare/%s...%s?expand=1",
url.QueryEscape(ctx.BaseBranch), url.QueryEscape(ctx.HeadBranch))
url, err := shared.WithPrAndIssueQueryParams(u, state)
if err != nil {
return "", err
}

View file

@ -15,6 +15,7 @@ import (
"github.com/cli/cli/internal/config"
"github.com/cli/cli/internal/ghrepo"
"github.com/cli/cli/internal/run"
prShared "github.com/cli/cli/pkg/cmd/pr/shared"
"github.com/cli/cli/pkg/cmdutil"
"github.com/cli/cli/pkg/httpmock"
"github.com/cli/cli/pkg/iostreams"
@ -270,12 +271,11 @@ func TestPRCreate_createFork(t *testing.T) {
cs, cmdTeardown := test.InitCmdStubber()
defer cmdTeardown()
cs.Stub("") // git config --get-regexp (determineTrackingBranch)
cs.Stub("") // git show-ref --verify (determineTrackingBranch)
cs.Stub("") // git status
cs.Stub("1234567890,commit 0\n2345678901,commit 1") // git log
cs.Stub("") // git remote add
cs.Stub("") // git push
cs.Stub("") // git config --get-regexp (determineTrackingBranch)
cs.Stub("") // git show-ref --verify (determineTrackingBranch)
cs.Stub("") // git status
cs.Stub("") // git remote add
cs.Stub("") // git push
ask, cleanupAsk := prompt.InitAskStubber()
defer cleanupAsk()
@ -284,8 +284,8 @@ func TestPRCreate_createFork(t *testing.T) {
output, err := runCommand(http, nil, "feature", true, `-t title -b body`)
require.NoError(t, err)
assert.Equal(t, []string{"git", "remote", "add", "-f", "fork", "https://github.com/monalisa/REPO.git"}, cs.Calls[4].Args)
assert.Equal(t, []string{"git", "push", "--set-upstream", "fork", "HEAD:feature"}, cs.Calls[5].Args)
assert.Equal(t, []string{"git", "remote", "add", "-f", "fork", "https://github.com/monalisa/REPO.git"}, cs.Calls[3].Args)
assert.Equal(t, []string{"git", "push", "--set-upstream", "fork", "HEAD:feature"}, cs.Calls[4].Args)
assert.Equal(t, "https://github.com/OWNER/REPO/pull/12\n", output.String())
}
@ -340,9 +340,6 @@ func TestPRCreate_pushedToNonBaseRepo(t *testing.T) {
deadb00f refs/remotes/upstream/feature
deadbeef refs/remotes/origin/feature
`)) // determineTrackingBranch
cs.Register("git .+ log", 1, "", func(args []string) {
assert.Equal(t, "upstream/master...feature", args[len(args)-1])
})
_, cleanupAsk := prompt.InitAskStubber()
defer cleanupAsk()
@ -389,9 +386,6 @@ func TestPRCreate_pushedToDifferentBranchName(t *testing.T) {
deadbeef HEAD
deadbeef refs/remotes/origin/my-feat2
`)) // determineTrackingBranch
cs.Register("git .+ log", 1, "", func(args []string) {
assert.Equal(t, "origin/master...feature", args[len(args)-1])
})
_, cleanupAsk := prompt.InitAskStubber()
defer cleanupAsk()
@ -438,19 +432,14 @@ func TestPRCreate_nonLegacyTemplate(t *testing.T) {
Name: "index",
Value: 0,
},
})
as.Stub([]*prompt.QuestionStub{
{
Name: "body",
Default: true,
},
})
}) // template
as.StubOneDefault() // body
as.Stub([]*prompt.QuestionStub{
{
Name: "confirmation",
Value: 0,
},
})
}) // confirm
output, err := runCommandWithRootDirOverridden(http, nil, "feature", true, `-t "my title" -H feature`, "./fixtures/repoWithNonLegacyPRTemplates")
require.NoError(t, err)
@ -586,12 +575,11 @@ func TestPRCreate_alreadyExists(t *testing.T) {
cs, cmdTeardown := test.InitCmdStubber()
defer cmdTeardown()
cs.Stub("") // git config --get-regexp (determineTrackingBranch)
cs.Stub("") // git show-ref --verify (determineTrackingBranch)
cs.Stub("") // git status
cs.Stub("1234567890,commit 0\n2345678901,commit 1") // git log
cs.Stub("") // git config --get-regexp (determineTrackingBranch)
cs.Stub("") // git show-ref --verify (determineTrackingBranch)
cs.Stub("") // git status
_, err := runCommand(http, nil, "feature", true, `-H feature`)
_, err := runCommand(http, nil, "feature", true, `-ttitle -bbody -H feature`)
if err == nil {
t.Fatal("error expected, got nil")
}
@ -732,50 +720,42 @@ deadb00f refs/remotes/origin/feature`) // git show-ref --verify (ShowRefs)
}
func Test_generateCompareURL(t *testing.T) {
type args struct {
r ghrepo.Interface
base string
head string
title string
body string
assignees []string
labels []string
projects []string
milestones []string
}
tests := []struct {
name string
args args
ctx CreateContext
state prShared.IssueMetadataState
want string
wantErr bool
}{
{
name: "basic",
args: args{
r: ghrepo.New("OWNER", "REPO"),
base: "main",
head: "feature",
ctx: CreateContext{
BaseRepo: ghrepo.New("OWNER", "REPO"),
BaseBranch: "main",
HeadBranch: "feature",
},
want: "https://github.com/OWNER/REPO/compare/main...feature?expand=1",
wantErr: false,
},
{
name: "with labels",
args: args{
r: ghrepo.New("OWNER", "REPO"),
base: "a",
head: "b",
labels: []string{"one", "two three"},
ctx: CreateContext{
BaseRepo: ghrepo.New("OWNER", "REPO"),
BaseBranch: "a",
HeadBranch: "b",
},
state: prShared.IssueMetadataState{
Labels: []string{"one", "two three"},
},
want: "https://github.com/OWNER/REPO/compare/a...b?expand=1&labels=one%2Ctwo+three",
wantErr: false,
},
{
name: "complex branch names",
args: args{
r: ghrepo.New("OWNER", "REPO"),
base: "main/trunk",
head: "owner:feature",
ctx: CreateContext{
BaseRepo: ghrepo.New("OWNER", "REPO"),
BaseBranch: "main/trunk",
HeadBranch: "owner:feature",
},
want: "https://github.com/OWNER/REPO/compare/main%2Ftrunk...owner%3Afeature?expand=1",
wantErr: false,
@ -783,7 +763,7 @@ func Test_generateCompareURL(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := generateCompareURL(tt.args.r, tt.args.base, tt.args.head, tt.args.title, tt.args.body, tt.args.assignees, tt.args.labels, tt.args.projects, tt.args.milestones)
got, err := generateCompareURL(tt.ctx, tt.state)
if (err != nil) != tt.wantErr {
t.Errorf("generateCompareURL() error = %v, wantErr %v", err, tt.wantErr)
return

View file

@ -9,29 +9,29 @@ import (
"github.com/cli/cli/internal/ghrepo"
)
func WithPrAndIssueQueryParams(baseURL, title, body string, assignees, labels, projects []string, milestones []string) (string, error) {
func WithPrAndIssueQueryParams(baseURL string, state IssueMetadataState) (string, error) {
u, err := url.Parse(baseURL)
if err != nil {
return "", err
}
q := u.Query()
if title != "" {
q.Set("title", title)
if state.Title != "" {
q.Set("title", state.Title)
}
if body != "" {
q.Set("body", body)
if state.Body != "" {
q.Set("body", state.Body)
}
if len(assignees) > 0 {
q.Set("assignees", strings.Join(assignees, ","))
if len(state.Assignees) > 0 {
q.Set("assignees", strings.Join(state.Assignees, ","))
}
if len(labels) > 0 {
q.Set("labels", strings.Join(labels, ","))
if len(state.Labels) > 0 {
q.Set("labels", strings.Join(state.Labels, ","))
}
if len(projects) > 0 {
q.Set("projects", strings.Join(projects, ","))
if len(state.Projects) > 0 {
q.Set("projects", strings.Join(state.Projects, ","))
}
if len(milestones) > 0 {
q.Set("milestone", milestones[0])
if len(state.Milestones) > 0 {
q.Set("milestone", state.Milestones[0])
}
u.RawQuery = q.Encode()
return u.String(), nil

385
pkg/cmd/pr/shared/survey.go Normal file
View file

@ -0,0 +1,385 @@
package shared
import (
"fmt"
"strings"
"github.com/AlecAivazis/survey/v2"
"github.com/cli/cli/api"
"github.com/cli/cli/git"
"github.com/cli/cli/internal/ghrepo"
"github.com/cli/cli/pkg/githubtemplate"
"github.com/cli/cli/pkg/iostreams"
"github.com/cli/cli/pkg/prompt"
"github.com/cli/cli/pkg/surveyext"
"github.com/cli/cli/utils"
)
type Action int
type metadataStateType int
const (
IssueMetadata metadataStateType = iota
PRMetadata
)
type IssueMetadataState struct {
Type metadataStateType
Draft bool
Body string
Title string
Metadata []string
Reviewers []string
Assignees []string
Labels []string
Projects []string
Milestones []string
MetadataResult *api.RepoMetadataResult
}
func (tb *IssueMetadataState) HasMetadata() bool {
return len(tb.Reviewers) > 0 ||
len(tb.Assignees) > 0 ||
len(tb.Labels) > 0 ||
len(tb.Projects) > 0 ||
len(tb.Milestones) > 0
}
const (
SubmitAction Action = iota
PreviewAction
CancelAction
MetadataAction
noMilestone = "(none)"
)
func ConfirmSubmission(allowPreview bool, allowMetadata bool) (Action, error) {
const (
submitLabel = "Submit"
previewLabel = "Continue in browser"
metadataLabel = "Add metadata"
cancelLabel = "Cancel"
)
options := []string{submitLabel}
if allowPreview {
options = append(options, previewLabel)
}
if allowMetadata {
options = append(options, metadataLabel)
}
options = append(options, cancelLabel)
confirmAnswers := struct {
Confirmation int
}{}
confirmQs := []*survey.Question{
{
Name: "confirmation",
Prompt: &survey.Select{
Message: "What's next?",
Options: options,
},
},
}
err := prompt.SurveyAsk(confirmQs, &confirmAnswers)
if err != nil {
return -1, fmt.Errorf("could not prompt: %w", err)
}
switch options[confirmAnswers.Confirmation] {
case submitLabel:
return SubmitAction, nil
case previewLabel:
return PreviewAction, nil
case metadataLabel:
return MetadataAction, nil
case cancelLabel:
return CancelAction, nil
default:
return -1, fmt.Errorf("invalid index: %d", confirmAnswers.Confirmation)
}
}
func TemplateSurvey(templateFiles []string, legacyTemplate string, state IssueMetadataState) (templateContent string, err error) {
if len(templateFiles) == 0 && legacyTemplate == "" {
return
}
if len(templateFiles) > 0 {
templateContent, err = selectTemplate(templateFiles, legacyTemplate, state.Type)
} else {
templateContent = string(githubtemplate.ExtractContents(legacyTemplate))
}
return
}
func selectTemplate(nonLegacyTemplatePaths []string, legacyTemplatePath string, metadataType metadataStateType) (string, error) {
templateResponse := struct {
Index int
}{}
templateNames := make([]string, 0, len(nonLegacyTemplatePaths))
for _, p := range nonLegacyTemplatePaths {
templateNames = append(templateNames, githubtemplate.ExtractName(p))
}
if metadataType == IssueMetadata {
templateNames = append(templateNames, "Open a blank issue")
} else if metadataType == PRMetadata {
templateNames = append(templateNames, "Open a blank pull request")
}
selectQs := []*survey.Question{
{
Name: "index",
Prompt: &survey.Select{
Message: "Choose a template",
Options: templateNames,
},
},
}
if err := prompt.SurveyAsk(selectQs, &templateResponse); err != nil {
return "", fmt.Errorf("could not prompt: %w", err)
}
if templateResponse.Index == len(nonLegacyTemplatePaths) { // the user has selected the blank template
if legacyTemplatePath != "" {
templateContents := githubtemplate.ExtractContents(legacyTemplatePath)
return string(templateContents), nil
} else {
return "", nil
}
}
templateContents := githubtemplate.ExtractContents(nonLegacyTemplatePaths[templateResponse.Index])
return string(templateContents), nil
}
func BodySurvey(state *IssueMetadataState, templateContent, editorCommand string) error {
if templateContent != "" {
if state.Body != "" {
// prevent excessive newlines between default body and template
state.Body = strings.TrimRight(state.Body, "\n")
state.Body += "\n\n"
}
state.Body += templateContent
}
p := &surveyext.GhEditor{
BlankAllowed: true,
EditorCommand: editorCommand,
Editor: &survey.Editor{
Message: "Body",
FileName: "*.md",
Default: state.Body,
HideDefault: true,
AppendDefault: true,
},
}
err := prompt.SurveyAskOne(p, state)
if err != nil {
return err
}
return nil
}
func TitleSurvey(state *IssueMetadataState) error {
p := &survey.Input{
Message: "Title",
Default: state.Title,
}
err := prompt.SurveyAskOne(p, state)
if err != nil {
return err
}
return nil
}
func MetadataSurvey(io *iostreams.IOStreams, client *api.Client, baseRepo ghrepo.Interface, state *IssueMetadataState) error {
isChosen := func(m string) bool {
for _, c := range state.Metadata {
if m == c {
return true
}
}
return false
}
allowReviewers := state.Type == PRMetadata
extraFieldsOptions := []string{}
if allowReviewers {
extraFieldsOptions = append(extraFieldsOptions, "Reviewers")
}
extraFieldsOptions = append(extraFieldsOptions, "Assignees", "Labels", "Projects", "Milestone")
err := prompt.SurveyAsk([]*survey.Question{
{
Name: "metadata",
Prompt: &survey.MultiSelect{
Message: "What would you like to add?",
Options: extraFieldsOptions,
},
},
}, state)
if err != nil {
return fmt.Errorf("could not prompt: %w", err)
}
metadataInput := api.RepoMetadataInput{
Reviewers: isChosen("Reviewers"),
Assignees: isChosen("Assignees"),
Labels: isChosen("Labels"),
Projects: isChosen("Projects"),
Milestones: isChosen("Milestone"),
}
s := utils.Spinner(io.ErrOut)
utils.StartSpinner(s)
state.MetadataResult, err = api.RepoMetadata(client, baseRepo, metadataInput)
utils.StopSpinner(s)
if err != nil {
return fmt.Errorf("error fetching metadata options: %w", err)
}
var users []string
for _, u := range state.MetadataResult.AssignableUsers {
users = append(users, u.Login)
}
var teams []string
for _, t := range state.MetadataResult.Teams {
teams = append(teams, fmt.Sprintf("%s/%s", baseRepo.RepoOwner(), t.Slug))
}
var labels []string
for _, l := range state.MetadataResult.Labels {
labels = append(labels, l.Name)
}
var projects []string
for _, l := range state.MetadataResult.Projects {
projects = append(projects, l.Name)
}
milestones := []string{noMilestone}
for _, m := range state.MetadataResult.Milestones {
milestones = append(milestones, m.Title)
}
type metadataValues struct {
Reviewers []string
Assignees []string
Labels []string
Projects []string
Milestone string
}
var mqs []*survey.Question
if isChosen("Reviewers") {
if len(users) > 0 || len(teams) > 0 {
mqs = append(mqs, &survey.Question{
Name: "reviewers",
Prompt: &survey.MultiSelect{
Message: "Reviewers",
Options: append(users, teams...),
Default: state.Reviewers,
},
})
} else {
fmt.Fprintln(io.ErrOut, "warning: no available reviewers")
}
}
if isChosen("Assignees") {
if len(users) > 0 {
mqs = append(mqs, &survey.Question{
Name: "assignees",
Prompt: &survey.MultiSelect{
Message: "Assignees",
Options: users,
Default: state.Assignees,
},
})
} else {
fmt.Fprintln(io.ErrOut, "warning: no assignable users")
}
}
if isChosen("Labels") {
if len(labels) > 0 {
mqs = append(mqs, &survey.Question{
Name: "labels",
Prompt: &survey.MultiSelect{
Message: "Labels",
Options: labels,
Default: state.Labels,
},
})
} else {
fmt.Fprintln(io.ErrOut, "warning: no labels in the repository")
}
}
if isChosen("Projects") {
if len(projects) > 0 {
mqs = append(mqs, &survey.Question{
Name: "projects",
Prompt: &survey.MultiSelect{
Message: "Projects",
Options: projects,
Default: state.Projects,
},
})
} else {
fmt.Fprintln(io.ErrOut, "warning: no projects to choose from")
}
}
if isChosen("Milestone") {
if len(milestones) > 1 {
var milestoneDefault string
if len(state.Milestones) > 0 {
milestoneDefault = state.Milestones[0]
}
mqs = append(mqs, &survey.Question{
Name: "milestone",
Prompt: &survey.Select{
Message: "Milestone",
Options: milestones,
Default: milestoneDefault,
},
})
} else {
fmt.Fprintln(io.ErrOut, "warning: no milestones in the repository")
}
}
values := metadataValues{}
err = prompt.SurveyAsk(mqs, &values, survey.WithKeepFilter(true))
if err != nil {
return fmt.Errorf("could not prompt: %w", err)
}
state.Reviewers = values.Reviewers
state.Assignees = values.Assignees
state.Labels = values.Labels
state.Projects = values.Projects
if values.Milestone != "" && values.Milestone != noMilestone {
state.Milestones = []string{values.Milestone}
}
return nil
}
func FindTemplates(dir, path string) ([]string, string) {
if dir == "" {
rootDir, err := git.ToplevelDir()
if err != nil {
return []string{}, ""
}
dir = rootDir
}
templateFiles := githubtemplate.FindNonLegacy(dir, path)
legacyTemplate := githubtemplate.FindLegacy(dir, path)
return templateFiles, legacyTemplate
}

View file

@ -1,396 +0,0 @@
package shared
import (
"fmt"
"strings"
"github.com/AlecAivazis/survey/v2"
"github.com/cli/cli/api"
"github.com/cli/cli/internal/ghrepo"
"github.com/cli/cli/pkg/githubtemplate"
"github.com/cli/cli/pkg/iostreams"
"github.com/cli/cli/pkg/prompt"
"github.com/cli/cli/pkg/surveyext"
"github.com/cli/cli/utils"
)
type Defaults struct {
Title string
Body string
}
type Action int
type metadataStateType int
const (
IssueMetadata metadataStateType = iota
PRMetadata
)
type IssueMetadataState struct {
Type metadataStateType
Body string
Title string
Action Action
Metadata []string
Reviewers []string
Assignees []string
Labels []string
Projects []string
Milestones []string
MetadataResult *api.RepoMetadataResult
}
func (tb *IssueMetadataState) HasMetadata() bool {
return len(tb.Reviewers) > 0 ||
len(tb.Assignees) > 0 ||
len(tb.Labels) > 0 ||
len(tb.Projects) > 0 ||
len(tb.Milestones) > 0
}
const (
SubmitAction Action = iota
PreviewAction
CancelAction
MetadataAction
noMilestone = "(none)"
)
func confirmSubmission(allowPreview bool, allowMetadata bool) (Action, error) {
const (
submitLabel = "Submit"
previewLabel = "Continue in browser"
metadataLabel = "Add metadata"
cancelLabel = "Cancel"
)
options := []string{submitLabel}
if allowPreview {
options = append(options, previewLabel)
}
if allowMetadata {
options = append(options, metadataLabel)
}
options = append(options, cancelLabel)
confirmAnswers := struct {
Confirmation int
}{}
confirmQs := []*survey.Question{
{
Name: "confirmation",
Prompt: &survey.Select{
Message: "What's next?",
Options: options,
},
},
}
err := prompt.SurveyAsk(confirmQs, &confirmAnswers)
if err != nil {
return -1, fmt.Errorf("could not prompt: %w", err)
}
switch options[confirmAnswers.Confirmation] {
case submitLabel:
return SubmitAction, nil
case previewLabel:
return PreviewAction, nil
case metadataLabel:
return MetadataAction, nil
case cancelLabel:
return CancelAction, nil
default:
return -1, fmt.Errorf("invalid index: %d", confirmAnswers.Confirmation)
}
}
func selectTemplate(nonLegacyTemplatePaths []string, legacyTemplatePath *string, metadataType metadataStateType) (string, error) {
templateResponse := struct {
Index int
}{}
templateNames := make([]string, 0, len(nonLegacyTemplatePaths))
for _, p := range nonLegacyTemplatePaths {
templateNames = append(templateNames, githubtemplate.ExtractName(p))
}
if metadataType == IssueMetadata {
templateNames = append(templateNames, "Open a blank issue")
} else if metadataType == PRMetadata {
templateNames = append(templateNames, "Open a blank pull request")
}
selectQs := []*survey.Question{
{
Name: "index",
Prompt: &survey.Select{
Message: "Choose a template",
Options: templateNames,
},
},
}
if err := prompt.SurveyAsk(selectQs, &templateResponse); err != nil {
return "", fmt.Errorf("could not prompt: %w", err)
}
if templateResponse.Index == len(nonLegacyTemplatePaths) { // the user has selected the blank template
if legacyTemplatePath != nil {
templateContents := githubtemplate.ExtractContents(*legacyTemplatePath)
return string(templateContents), nil
} else {
return "", nil
}
}
templateContents := githubtemplate.ExtractContents(nonLegacyTemplatePaths[templateResponse.Index])
return string(templateContents), nil
}
// FIXME: this command has too many parameters and responsibilities
func TitleBodySurvey(io *iostreams.IOStreams, editorCommand string, issueState *IssueMetadataState, apiClient *api.Client, repo ghrepo.Interface, providedTitle, providedBody string, defs Defaults, nonLegacyTemplatePaths []string, legacyTemplatePath *string, allowReviewers, allowMetadata bool) error {
issueState.Title = defs.Title
templateContents := ""
if providedBody == "" {
issueState.Body = defs.Body
if len(nonLegacyTemplatePaths) > 0 {
var err error
templateContents, err = selectTemplate(nonLegacyTemplatePaths, legacyTemplatePath, issueState.Type)
if err != nil {
return err
}
} else if legacyTemplatePath != nil {
templateContents = string(githubtemplate.ExtractContents(*legacyTemplatePath))
}
if templateContents != "" {
if issueState.Body != "" {
// prevent excessive newlines between default body and template
issueState.Body = strings.TrimRight(issueState.Body, "\n")
issueState.Body += "\n\n"
}
issueState.Body += templateContents
}
}
titleQuestion := &survey.Question{
Name: "title",
Prompt: &survey.Input{
Message: "Title",
Default: issueState.Title,
},
}
bodyQuestion := &survey.Question{
Name: "body",
Prompt: &surveyext.GhEditor{
BlankAllowed: true,
EditorCommand: editorCommand,
Editor: &survey.Editor{
Message: "Body",
FileName: "*.md",
Default: issueState.Body,
HideDefault: true,
AppendDefault: true,
},
},
}
var qs []*survey.Question
if providedTitle == "" {
qs = append(qs, titleQuestion)
}
if providedBody == "" {
qs = append(qs, bodyQuestion)
}
err := prompt.SurveyAsk(qs, issueState)
if err != nil {
return fmt.Errorf("could not prompt: %w", err)
}
if issueState.Body == "" {
issueState.Body = templateContents
}
allowPreview := !issueState.HasMetadata()
confirmA, err := confirmSubmission(allowPreview, allowMetadata)
if err != nil {
return fmt.Errorf("unable to confirm: %w", err)
}
if confirmA == MetadataAction {
isChosen := func(m string) bool {
for _, c := range issueState.Metadata {
if m == c {
return true
}
}
return false
}
extraFieldsOptions := []string{}
if allowReviewers {
extraFieldsOptions = append(extraFieldsOptions, "Reviewers")
}
extraFieldsOptions = append(extraFieldsOptions, "Assignees", "Labels", "Projects", "Milestone")
err = prompt.SurveyAsk([]*survey.Question{
{
Name: "metadata",
Prompt: &survey.MultiSelect{
Message: "What would you like to add?",
Options: extraFieldsOptions,
},
},
}, issueState)
if err != nil {
return fmt.Errorf("could not prompt: %w", err)
}
metadataInput := api.RepoMetadataInput{
Reviewers: isChosen("Reviewers"),
Assignees: isChosen("Assignees"),
Labels: isChosen("Labels"),
Projects: isChosen("Projects"),
Milestones: isChosen("Milestone"),
}
s := utils.Spinner(io.ErrOut)
utils.StartSpinner(s)
issueState.MetadataResult, err = api.RepoMetadata(apiClient, repo, metadataInput)
utils.StopSpinner(s)
if err != nil {
return fmt.Errorf("error fetching metadata options: %w", err)
}
var users []string
for _, u := range issueState.MetadataResult.AssignableUsers {
users = append(users, u.Login)
}
var teams []string
for _, t := range issueState.MetadataResult.Teams {
teams = append(teams, fmt.Sprintf("%s/%s", repo.RepoOwner(), t.Slug))
}
var labels []string
for _, l := range issueState.MetadataResult.Labels {
labels = append(labels, l.Name)
}
var projects []string
for _, l := range issueState.MetadataResult.Projects {
projects = append(projects, l.Name)
}
milestones := []string{noMilestone}
for _, m := range issueState.MetadataResult.Milestones {
milestones = append(milestones, m.Title)
}
type metadataValues struct {
Reviewers []string
Assignees []string
Labels []string
Projects []string
Milestone string
}
var mqs []*survey.Question
if isChosen("Reviewers") {
if len(users) > 0 || len(teams) > 0 {
mqs = append(mqs, &survey.Question{
Name: "reviewers",
Prompt: &survey.MultiSelect{
Message: "Reviewers",
Options: append(users, teams...),
Default: issueState.Reviewers,
},
})
} else {
fmt.Fprintln(io.ErrOut, "warning: no available reviewers")
}
}
if isChosen("Assignees") {
if len(users) > 0 {
mqs = append(mqs, &survey.Question{
Name: "assignees",
Prompt: &survey.MultiSelect{
Message: "Assignees",
Options: users,
Default: issueState.Assignees,
},
})
} else {
fmt.Fprintln(io.ErrOut, "warning: no assignable users")
}
}
if isChosen("Labels") {
if len(labels) > 0 {
mqs = append(mqs, &survey.Question{
Name: "labels",
Prompt: &survey.MultiSelect{
Message: "Labels",
Options: labels,
Default: issueState.Labels,
},
})
} else {
fmt.Fprintln(io.ErrOut, "warning: no labels in the repository")
}
}
if isChosen("Projects") {
if len(projects) > 0 {
mqs = append(mqs, &survey.Question{
Name: "projects",
Prompt: &survey.MultiSelect{
Message: "Projects",
Options: projects,
Default: issueState.Projects,
},
})
} else {
fmt.Fprintln(io.ErrOut, "warning: no projects to choose from")
}
}
if isChosen("Milestone") {
if len(milestones) > 1 {
var milestoneDefault string
if len(issueState.Milestones) > 0 {
milestoneDefault = issueState.Milestones[0]
}
mqs = append(mqs, &survey.Question{
Name: "milestone",
Prompt: &survey.Select{
Message: "Milestone",
Options: milestones,
Default: milestoneDefault,
},
})
} else {
fmt.Fprintln(io.ErrOut, "warning: no milestones in the repository")
}
}
values := metadataValues{}
err = prompt.SurveyAsk(mqs, &values, survey.WithKeepFilter(true))
if err != nil {
return fmt.Errorf("could not prompt: %w", err)
}
issueState.Reviewers = values.Reviewers
issueState.Assignees = values.Assignees
issueState.Labels = values.Labels
issueState.Projects = values.Projects
if values.Milestone != "" && values.Milestone != noMilestone {
issueState.Milestones = []string{values.Milestone}
}
allowPreview = !issueState.HasMetadata()
allowMetadata = false
confirmA, err = confirmSubmission(allowPreview, allowMetadata)
if err != nil {
return fmt.Errorf("unable to confirm: %w", err)
}
}
issueState.Action = confirmA
return nil
}

View file

@ -53,7 +53,7 @@ mainLoop:
}
// FindLegacy returns the file path of the default(legacy) template
func FindLegacy(rootDir string, name string) *string {
func FindLegacy(rootDir string, name string) string {
namePattern := regexp.MustCompile(fmt.Sprintf(`(?i)^%s(\.|$)`, strings.ReplaceAll(name, "_", "[_-]")))
// https://help.github.com/en/github/building-a-strong-community/creating-a-pull-request-template-for-your-repository
@ -71,12 +71,11 @@ func FindLegacy(rootDir string, name string) *string {
// detect a single template file
for _, file := range files {
if namePattern.MatchString(file.Name()) && !file.IsDir() {
result := path.Join(dir, file.Name())
return &result
return path.Join(dir, file.Name())
}
}
}
return nil
return ""
}
// ExtractName returns the name of the template from YAML front-matter

View file

@ -250,10 +250,10 @@ func TestFindLegacy(t *testing.T) {
}
got := FindLegacy(tt.args.rootDir, tt.args.name)
if got == nil {
if got == "" {
t.Errorf("FindLegacy() = nil, want %v", tt.want)
} else if *got != tt.want {
t.Errorf("FindLegacy() = %v, want %v", *got, tt.want)
} else if got != tt.want {
t.Errorf("FindLegacy() = %v, want %v", got, tt.want)
}
})
os.RemoveAll(tmpdir)

View file

@ -45,7 +45,7 @@ func InitAskStubber() (*AskStubber, func()) {
count := as.Count
as.Count += 1
if count >= len(as.Stubs) {
panic(fmt.Sprintf("more asks than stubs. most recent call: %v", qs))
panic(fmt.Sprintf("more asks than stubs. most recent call: %#v", qs))
}
// actually set response