WIP works, probably some title/body input edge cases

This commit is contained in:
vilmibm 2020-11-09 17:15:21 -08:00
parent 6fde02df1c
commit 6671106448
6 changed files with 898 additions and 779 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,65 @@ 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())
err = prShared.TitleSurvey(&tb)
if err != nil {
return fmt.Errorf("could not collect title and/or body: %w", err)
return err
}
action = tb.Action
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 +215,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

@ -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,6 +25,7 @@ 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
@ -56,6 +56,20 @@ 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 *api.Repository
HeadRepo ghrepo.Interface
BaseTrackingBranch string
BaseBranch string
HeadBranch string
HeadBranchLabel string
HeadRemote *context.Remote
IsPushEnabled bool
}
func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Command {
opts := &CreateOptions{
IO: f.IOStreams,
@ -92,10 +106,16 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co
opts.Interactive = !(opts.TitleProvided && opts.BodyProvided)
// TODO check on edge cases around title/body provision
if !opts.IO.CanPrompt() && !opts.WebMode && !opts.TitleProvided && !opts.Autofill {
return &cmdutil.FlagError{Err: errors.New("--title or --fill required when not running interactively")}
}
if !opts.IO.CanPrompt() {
opts.Interactive = false
}
if opts.IsDraft && opts.WebMode {
return errors.New("the --draft flag is not supported with --web")
}
@ -128,148 +148,20 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co
}
func createRun(opts *CreateOptions) (err error) {
cs := opts.IO.ColorScheme()
httpClient, err := opts.HttpClient()
if err != nil {
return err
}
client := api.NewClientFromHTTP(httpClient)
remotes, err := opts.Remotes()
ctx, err := NewCreateContext(opts)
if err != nil {
return err
}
repoContext, err := context.ResolveRemotesToRepos(remotes, client, opts.RepoOverride)
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
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)
defs, defaultsErr := computeDefaults(*ctx)
if defaultsErr != nil && (opts.Autofill || opts.WebMode || !opts.Interactive) {
return fmt.Errorf("could not compute title or body defaults: %w", defaultsErr)
}
var milestoneTitles []string
@ -277,64 +169,9 @@ func createRun(opts *CreateOptions) (err error) {
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{
state := shared.IssueMetadataState{
Title: defs.Title,
Body: defs.Body,
Type: shared.PRMetadata,
Reviewers: opts.Reviewers,
Assignees: opts.Assignees,
@ -343,162 +180,126 @@ func createRun(opts *CreateOptions) (err error) {
Milestones: milestoneTitles,
}
if !opts.WebMode && !opts.Autofill && opts.Interactive {
var nonLegacyTemplateFiles []string
var legacyTemplateFile *string
if opts.TitleProvided {
state.Title = opts.Title
}
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")
if opts.BodyProvided {
state.Body = opts.Body
}
if opts.WebMode {
err := handlePush(*opts, *ctx)
if err != nil {
return err
}
return previewPR(*opts, *ctx, state)
}
editorCommand, err := cmdutil.DetermineEditor(opts.Config)
if opts.Autofill || !opts.Interactive {
return submitPR(*opts, *ctx, state)
}
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 opts.IsDraft {
message = "\nCreating draft pull request for %s into %s in %s\n\n"
}
cs := opts.IO.ColorScheme()
fmt.Fprintf(opts.IO.ErrOut, message,
cs.Cyan(ctx.HeadBranchLabel),
cs.Cyan(ctx.BaseBranch),
ghrepo.FullName(ctx.BaseRepo))
if (state.Title == "" || state.Body == "") && defaultsErr != nil {
fmt.Fprintf(opts.IO.ErrOut,
"%s warning: could not compute title or body defaults: %s\n", cs.Yellow("!"), defaultsErr)
}
if !opts.TitleProvided {
err = shared.TitleSurvey(&state)
if err != nil {
return err
}
}
editorCommand, err := cmdutil.DetermineEditor(opts.Config)
if err != nil {
return err
}
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.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()
err = shared.BodySurvey(&state, templateContent, editorCommand)
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,
if state.Body == "" {
state.Body = templateContent
}
}
// 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
}
allowMetadata := ctx.BaseRepo.ViewerCanTriage()
action, err := shared.ConfirmSubmission(!state.HasMetadata(), allowMetadata)
if err != nil {
return fmt.Errorf("unable to confirm: %w", err)
}
err := pushBranch()
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
}
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) {
func computeDefaults(createCtx CreateContext) (shared.Defaults, error) {
baseRef := createCtx.BaseTrackingBranch
headRef := createCtx.HeadBranch
out := shared.Defaults{}
commits, err := git.Commits(baseRef, headRef)
@ -567,9 +368,311 @@ 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 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,
}, nil
}
func submitPR(opts CreateOptions, createCtx CreateContext, state shared.IssueMetadataState) error {
httpClient, err := opts.HttpClient()
if err != nil {
return nil
}
client := api.NewClientFromHTTP(httpClient)
params := map[string]interface{}{
"title": state.Title,
"body": state.Body,
"draft": opts.IsDraft,
"baseRefName": createCtx.BaseBranch,
"headRefName": createCtx.HeadBranchLabel,
}
if params["title"] == "" {
return errors.New("pull request title must not be blank")
}
err = shared.AddMetadataToIssueParams(client, createCtx.BaseRepo, params, &state)
if err != nil {
return err
}
pr, err := api.CreatePullRequest(client, createCtx.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)
}
return nil
}
func previewPR(opts CreateOptions, createCtx CreateContext, state shared.IssueMetadataState) error {
openURL, err := generateCompareURL(createCtx, 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
httpClient, err := opts.HttpClient()
if err != nil {
return err
}
client := api.NewClientFromHTTP(httpClient)
// 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(createCtx CreateContext, state shared.IssueMetadataState) (string, error) {
u := ghrepo.GenerateRepoURL(
createCtx.BaseRepo,
"compare/%s...%s?expand=1",
url.QueryEscape(createCtx.BaseBranch), url.QueryEscape(createCtx.HeadBranch))
url, err := shared.WithPrAndIssueQueryParams(u, state)
if err != nil {
return "", err
}

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

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

@ -0,0 +1,404 @@
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 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
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,
},
}
body := ""
err := prompt.SurveyAskOne(p, &body)
if err != nil {
return err
}
state.Body = body
return nil
}
func TitleSurvey(state *IssueMetadataState) error {
p := &survey.Input{
Message: "Title",
Default: state.Title,
}
title := ""
err := prompt.SurveyAskOne(p, &title)
if err != nil {
return err
}
state.Title = title
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)
// TODO stop using string pointer
lt := ""
if legacyTemplate != nil {
lt = *legacyTemplate
}
return templateFiles, lt
}

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

@ -54,6 +54,7 @@ mainLoop:
// FindLegacy returns the file path of the default(legacy) template
func FindLegacy(rootDir string, name string) *string {
// TODO why does this return a pointer to 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