Merge pull request #231 from github/create-flows

PR/Issue create flow tweaks
This commit is contained in:
Nate Smith 2020-01-16 14:33:21 -06:00 committed by GitHub
commit 84b19a5535
4 changed files with 327 additions and 106 deletions

View file

@ -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
}

View file

@ -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",

View file

@ -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
View 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)
}