Merge pull request #231 from github/create-flows
PR/Issue create flow tweaks
This commit is contained in:
commit
84b19a5535
4 changed files with 327 additions and 106 deletions
|
|
@ -3,6 +3,7 @@ package command
|
|||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
|
@ -308,6 +309,8 @@ func issueCreate(cmd *cobra.Command, args []string) error {
|
|||
return fmt.Errorf("the '%s/%s' repository has disabled issues", baseRepo.RepoOwner(), baseRepo.RepoName())
|
||||
}
|
||||
|
||||
action := SubmitAction
|
||||
|
||||
title, err := cmd.Flags().GetString("title")
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "could not parse title")
|
||||
|
|
@ -325,8 +328,11 @@ func issueCreate(cmd *cobra.Command, args []string) error {
|
|||
return errors.Wrap(err, "could not collect title and/or body")
|
||||
}
|
||||
|
||||
if tb == nil {
|
||||
// editing was canceled, we can just leave
|
||||
action = tb.Action
|
||||
|
||||
if tb.Action == CancelAction {
|
||||
fmt.Fprintln(cmd.ErrOrStderr(), "Discarding.")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
@ -337,17 +343,38 @@ func issueCreate(cmd *cobra.Command, args []string) error {
|
|||
body = tb.Body
|
||||
}
|
||||
}
|
||||
params := map[string]interface{}{
|
||||
"title": title,
|
||||
"body": body,
|
||||
|
||||
if action == PreviewAction {
|
||||
openURL := fmt.Sprintf(
|
||||
"https://github.com/%s/%s/issues/new/?title=%s&body=%s",
|
||||
baseRepo.RepoOwner(),
|
||||
baseRepo.RepoName(),
|
||||
url.QueryEscape(title),
|
||||
url.QueryEscape(body),
|
||||
)
|
||||
// TODO could exceed max url length for explorer
|
||||
url, err := url.Parse(openURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(cmd.ErrOrStderr(), "Opening %s%s in your browser.\n", url.Host, url.Path)
|
||||
return utils.OpenInBrowser(openURL)
|
||||
} else if action == SubmitAction {
|
||||
params := map[string]interface{}{
|
||||
"title": title,
|
||||
"body": body,
|
||||
}
|
||||
|
||||
newIssue, err := api.IssueCreate(apiClient, repo, params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Fprintln(cmd.OutOrStdout(), newIssue.URL)
|
||||
} else {
|
||||
panic("Unreachable state")
|
||||
}
|
||||
|
||||
newIssue, err := api.IssueCreate(apiClient, repo, params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Fprintln(cmd.OutOrStdout(), newIssue.URL)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,8 +2,7 @@ package command
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"runtime"
|
||||
"net/url"
|
||||
|
||||
"github.com/github/gh-cli/api"
|
||||
"github.com/github/gh-cli/context"
|
||||
|
|
@ -22,13 +21,7 @@ func prCreate(cmd *cobra.Command, _ []string) error {
|
|||
return err
|
||||
}
|
||||
if ucc > 0 {
|
||||
noun := "change"
|
||||
if ucc > 1 {
|
||||
// TODO: use pluralize helper
|
||||
noun = noun + "s"
|
||||
}
|
||||
|
||||
fmt.Fprintf(cmd.ErrOrStderr(), "Warning: %d uncommitted %s\n", ucc, noun)
|
||||
fmt.Fprintf(cmd.ErrOrStderr(), "Warning: %s\n", utils.Pluralize(ucc, "uncommitted change"))
|
||||
}
|
||||
|
||||
repo, err := ctx.BaseRepo()
|
||||
|
|
@ -51,6 +44,7 @@ func prCreate(cmd *cobra.Command, _ []string) error {
|
|||
return err
|
||||
}
|
||||
if target == "" {
|
||||
// TODO use default branch
|
||||
target = "master"
|
||||
}
|
||||
|
||||
|
|
@ -79,6 +73,8 @@ func prCreate(cmd *cobra.Command, _ []string) error {
|
|||
return errors.Wrap(err, "could not parse body")
|
||||
}
|
||||
|
||||
action := SubmitAction
|
||||
|
||||
interactive := title == "" || body == ""
|
||||
|
||||
if interactive {
|
||||
|
|
@ -93,8 +89,10 @@ func prCreate(cmd *cobra.Command, _ []string) error {
|
|||
return errors.Wrap(err, "could not collect title and/or body")
|
||||
}
|
||||
|
||||
if tb == nil {
|
||||
// editing was canceled, we can just leave
|
||||
action = tb.Action
|
||||
|
||||
if action == CancelAction {
|
||||
fmt.Fprintln(cmd.ErrOrStderr(), "Discarding.")
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
@ -125,21 +123,44 @@ func prCreate(cmd *cobra.Command, _ []string) error {
|
|||
return errors.Wrap(err, "could not parse draft")
|
||||
}
|
||||
|
||||
params := map[string]interface{}{
|
||||
"title": title,
|
||||
"body": body,
|
||||
"draft": isDraft,
|
||||
"baseRefName": base,
|
||||
"headRefName": head,
|
||||
if action == SubmitAction {
|
||||
params := map[string]interface{}{
|
||||
"title": title,
|
||||
"body": body,
|
||||
"draft": isDraft,
|
||||
"baseRefName": base,
|
||||
"headRefName": head,
|
||||
}
|
||||
|
||||
pr, err := api.CreatePullRequest(client, repo, params)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to create pull request")
|
||||
}
|
||||
|
||||
fmt.Fprintln(cmd.OutOrStdout(), pr.URL)
|
||||
} else if action == PreviewAction {
|
||||
openURL := fmt.Sprintf(
|
||||
"https://github.com/%s/%s/compare/%s...%s?expand=1&title=%s&body=%s",
|
||||
repo.RepoOwner(),
|
||||
repo.RepoName(),
|
||||
target,
|
||||
head,
|
||||
url.QueryEscape(title),
|
||||
url.QueryEscape(body),
|
||||
)
|
||||
// TODO could exceed max url length for explorer
|
||||
url, err := url.Parse(openURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(cmd.ErrOrStderr(), "Opening %s%s in your browser.\n", url.Host, url.Path)
|
||||
return utils.OpenInBrowser(openURL)
|
||||
} else {
|
||||
panic("Unreachable state")
|
||||
}
|
||||
|
||||
pr, err := api.CreatePullRequest(client, repo, params)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to create pull request")
|
||||
}
|
||||
|
||||
fmt.Fprintln(cmd.OutOrStdout(), pr.URL)
|
||||
return nil
|
||||
|
||||
}
|
||||
|
||||
func guessRemote(ctx context.Context) (string, error) {
|
||||
|
|
@ -158,19 +179,6 @@ func guessRemote(ctx context.Context) (string, error) {
|
|||
return remote.Name, nil
|
||||
}
|
||||
|
||||
func determineEditor() string {
|
||||
if runtime.GOOS == "windows" {
|
||||
return "notepad"
|
||||
}
|
||||
if v := os.Getenv("VISUAL"); v != "" {
|
||||
return v
|
||||
}
|
||||
if e := os.Getenv("EDITOR"); e != "" {
|
||||
return e
|
||||
}
|
||||
return "nano"
|
||||
}
|
||||
|
||||
var prCreateCmd = &cobra.Command{
|
||||
Use: "create",
|
||||
Short: "Create a pull request",
|
||||
|
|
|
|||
|
|
@ -1,26 +1,28 @@
|
|||
package command
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/AlecAivazis/survey/v2"
|
||||
"github.com/github/gh-cli/pkg/githubtemplate"
|
||||
"github.com/github/gh-cli/pkg/surveyext"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type Action int
|
||||
|
||||
type titleBody struct {
|
||||
Body string
|
||||
Title string
|
||||
Body string
|
||||
Title string
|
||||
Action Action
|
||||
}
|
||||
|
||||
const (
|
||||
_confirmed = iota
|
||||
_unconfirmed = iota
|
||||
_cancel = iota
|
||||
PreviewAction Action = iota
|
||||
SubmitAction
|
||||
CancelAction
|
||||
)
|
||||
|
||||
func confirm() (int, error) {
|
||||
func confirm() (Action, error) {
|
||||
confirmAnswers := struct {
|
||||
Confirmation int
|
||||
}{}
|
||||
|
|
@ -28,10 +30,10 @@ func confirm() (int, error) {
|
|||
{
|
||||
Name: "confirmation",
|
||||
Prompt: &survey.Select{
|
||||
Message: "Submit?",
|
||||
Message: "What's next?",
|
||||
Options: []string{
|
||||
"Yes",
|
||||
"Edit",
|
||||
"Preview in browser",
|
||||
"Submit",
|
||||
"Cancel",
|
||||
},
|
||||
},
|
||||
|
|
@ -43,7 +45,7 @@ func confirm() (int, error) {
|
|||
return -1, errors.Wrap(err, "could not prompt")
|
||||
}
|
||||
|
||||
return confirmAnswers.Confirmation, nil
|
||||
return Action(confirmAnswers.Confirmation), nil
|
||||
}
|
||||
|
||||
func selectTemplate(templatePaths []string) (string, error) {
|
||||
|
|
@ -85,59 +87,45 @@ func titleBodySurvey(cmd *cobra.Command, providedTitle string, providedBody stri
|
|||
inProgress.Body = templateContents
|
||||
}
|
||||
|
||||
confirmed := false
|
||||
editor := determineEditor()
|
||||
|
||||
for !confirmed {
|
||||
titleQuestion := &survey.Question{
|
||||
Name: "title",
|
||||
Prompt: &survey.Input{
|
||||
Message: "Title",
|
||||
Default: inProgress.Title,
|
||||
},
|
||||
}
|
||||
bodyQuestion := &survey.Question{
|
||||
Name: "body",
|
||||
Prompt: &survey.Editor{
|
||||
Message: fmt.Sprintf("Body (%s)", editor),
|
||||
titleQuestion := &survey.Question{
|
||||
Name: "title",
|
||||
Prompt: &survey.Input{
|
||||
Message: "Title",
|
||||
Default: inProgress.Title,
|
||||
},
|
||||
}
|
||||
bodyQuestion := &survey.Question{
|
||||
Name: "body",
|
||||
Prompt: &surveyext.GhEditor{
|
||||
Editor: &survey.Editor{
|
||||
Message: "Body",
|
||||
FileName: "*.md",
|
||||
Default: inProgress.Body,
|
||||
HideDefault: true,
|
||||
AppendDefault: true,
|
||||
Editor: editor,
|
||||
},
|
||||
}
|
||||
|
||||
qs := []*survey.Question{}
|
||||
if providedTitle == "" {
|
||||
qs = append(qs, titleQuestion)
|
||||
}
|
||||
if providedBody == "" {
|
||||
qs = append(qs, bodyQuestion)
|
||||
}
|
||||
|
||||
err := survey.Ask(qs, &inProgress)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "could not prompt")
|
||||
}
|
||||
|
||||
confirmA, err := confirm()
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "unable to confirm")
|
||||
}
|
||||
switch confirmA {
|
||||
case _confirmed:
|
||||
confirmed = true
|
||||
case _unconfirmed:
|
||||
continue
|
||||
case _cancel:
|
||||
fmt.Fprintln(cmd.ErrOrStderr(), "Discarding.")
|
||||
return nil, nil
|
||||
default:
|
||||
panic("reached unreachable case")
|
||||
}
|
||||
|
||||
},
|
||||
}
|
||||
|
||||
qs := []*survey.Question{}
|
||||
if providedTitle == "" {
|
||||
qs = append(qs, titleQuestion)
|
||||
}
|
||||
if providedBody == "" {
|
||||
qs = append(qs, bodyQuestion)
|
||||
}
|
||||
|
||||
err := survey.Ask(qs, &inProgress)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "could not prompt")
|
||||
}
|
||||
|
||||
confirmA, err := confirm()
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "unable to confirm")
|
||||
}
|
||||
|
||||
inProgress.Action = confirmA
|
||||
|
||||
return &inProgress, nil
|
||||
}
|
||||
|
|
|
|||
198
pkg/surveyext/editor.go
Normal file
198
pkg/surveyext/editor.go
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
package surveyext
|
||||
|
||||
// This file extends survey.Editor to give it more flexible behavior. For more context, read
|
||||
// https://github.com/github/gh-cli/issues/70
|
||||
// To see what we extended, search through for EXTENDED comments.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
|
||||
"github.com/AlecAivazis/survey/v2"
|
||||
"github.com/AlecAivazis/survey/v2/terminal"
|
||||
shellquote "github.com/kballard/go-shellquote"
|
||||
)
|
||||
|
||||
var (
|
||||
bom = []byte{0xef, 0xbb, 0xbf}
|
||||
editor = "nano" // EXTENDED to switch from vim as a default editor
|
||||
)
|
||||
|
||||
func init() {
|
||||
if runtime.GOOS == "windows" {
|
||||
editor = "notepad"
|
||||
}
|
||||
if v := os.Getenv("VISUAL"); v != "" {
|
||||
editor = v
|
||||
}
|
||||
if e := os.Getenv("EDITOR"); e != "" {
|
||||
editor = e
|
||||
}
|
||||
}
|
||||
|
||||
// EXTENDED to enable different prompting behavior
|
||||
type GhEditor struct {
|
||||
*survey.Editor
|
||||
}
|
||||
|
||||
// EXTENDED to change prompt text
|
||||
var EditorQuestionTemplate = `
|
||||
{{- if .ShowHelp }}{{- color .Config.Icons.Help.Format }}{{ .Config.Icons.Help.Text }} {{ .Help }}{{color "reset"}}{{"\n"}}{{end}}
|
||||
{{- color .Config.Icons.Question.Format }}{{ .Config.Icons.Question.Text }} {{color "reset"}}
|
||||
{{- color "default+hb"}}{{ .Message }} {{color "reset"}}
|
||||
{{- if .ShowAnswer}}
|
||||
{{- color "cyan"}}{{.Answer}}{{color "reset"}}{{"\n"}}
|
||||
{{- else }}
|
||||
{{- if and .Help (not .ShowHelp)}}{{color "cyan"}}[{{ .Config.HelpInput }} for help]{{color "reset"}} {{end}}
|
||||
{{- if and .Default (not .HideDefault)}}{{color "white"}}({{.Default}}) {{color "reset"}}{{end}}
|
||||
{{- color "cyan"}}[(e) to launch {{ .EditorName }}, enter to skip] {{color "reset"}}
|
||||
{{- end}}`
|
||||
|
||||
// EXTENDED to pass editor name (to use in prompt)
|
||||
type EditorTemplateData struct {
|
||||
survey.Editor
|
||||
EditorName string
|
||||
Answer string
|
||||
ShowAnswer bool
|
||||
ShowHelp bool
|
||||
Config *survey.PromptConfig
|
||||
}
|
||||
|
||||
// EXTENDED to augment prompt text and keypress handling
|
||||
func (e *GhEditor) prompt(initialValue string, config *survey.PromptConfig) (interface{}, error) {
|
||||
err := e.Render(
|
||||
EditorQuestionTemplate,
|
||||
// EXTENDED to support printing editor in prompt
|
||||
EditorTemplateData{
|
||||
Editor: *e.Editor,
|
||||
EditorName: filepath.Base(editor),
|
||||
Config: config,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// start reading runes from the standard in
|
||||
rr := e.NewRuneReader()
|
||||
rr.SetTermMode()
|
||||
defer rr.RestoreTermMode()
|
||||
|
||||
cursor := e.NewCursor()
|
||||
cursor.Hide()
|
||||
defer cursor.Show()
|
||||
|
||||
for {
|
||||
// EXTENDED to handle the e to edit / enter to skip behavior
|
||||
r, _, err := rr.ReadRune()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if r == 'e' {
|
||||
break
|
||||
}
|
||||
if r == '\r' || r == '\n' {
|
||||
return "", nil
|
||||
}
|
||||
if r == terminal.KeyInterrupt {
|
||||
return "", terminal.InterruptErr
|
||||
}
|
||||
if r == terminal.KeyEndTransmission {
|
||||
break
|
||||
}
|
||||
if string(r) == config.HelpInput && e.Help != "" {
|
||||
err = e.Render(
|
||||
EditorQuestionTemplate,
|
||||
EditorTemplateData{
|
||||
// EXTENDED to support printing editor in prompt
|
||||
Editor: *e.Editor,
|
||||
EditorName: filepath.Base(editor),
|
||||
ShowHelp: true,
|
||||
Config: config,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// prepare the temp file
|
||||
pattern := e.FileName
|
||||
if pattern == "" {
|
||||
pattern = "survey*.txt"
|
||||
}
|
||||
f, err := ioutil.TempFile("", pattern)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer os.Remove(f.Name())
|
||||
|
||||
// write utf8 BOM header
|
||||
// The reason why we do this is because notepad.exe on Windows determines the
|
||||
// encoding of an "empty" text file by the locale, for example, GBK in China,
|
||||
// while golang string only handles utf8 well. However, a text file with utf8
|
||||
// BOM header is not considered "empty" on Windows, and the encoding will then
|
||||
// be determined utf8 by notepad.exe, instead of GBK or other encodings.
|
||||
if _, err := f.Write(bom); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// write initial value
|
||||
if _, err := f.WriteString(initialValue); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// close the fd to prevent the editor unable to save file
|
||||
if err := f.Close(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
stdio := e.Stdio()
|
||||
|
||||
args, err := shellquote.Split(editor)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
args = append(args, f.Name())
|
||||
|
||||
// open the editor
|
||||
cmd := exec.Command(args[0], args[1:]...)
|
||||
cmd.Stdin = stdio.In
|
||||
cmd.Stdout = stdio.Out
|
||||
cmd.Stderr = stdio.Err
|
||||
cursor.Show()
|
||||
if err := cmd.Run(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// raw is a BOM-unstripped UTF8 byte slice
|
||||
raw, err := ioutil.ReadFile(f.Name())
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// strip BOM header
|
||||
text := string(bytes.TrimPrefix(raw, bom))
|
||||
|
||||
// check length, return default value on empty
|
||||
if len(text) == 0 && !e.AppendDefault {
|
||||
return e.Default, nil
|
||||
}
|
||||
|
||||
return text, nil
|
||||
}
|
||||
|
||||
// EXTENDED This is straight copypasta from survey to get our overriden prompt called.;
|
||||
func (e *GhEditor) Prompt(config *survey.PromptConfig) (interface{}, error) {
|
||||
initialValue := ""
|
||||
if e.Default != "" && e.AppendDefault {
|
||||
initialValue = e.Default
|
||||
}
|
||||
return e.prompt(initialValue, config)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue