Merge remote-tracking branch 'origin/master' into pr-current-branch

This commit is contained in:
Mislav Marohnić 2019-12-02 18:56:11 +01:00
commit db8c2d4e01
22 changed files with 713 additions and 654 deletions

View file

@ -2,51 +2,43 @@ package command
import (
"fmt"
"io/ioutil"
"io"
"os"
"strconv"
"strings"
"github.com/github/gh-cli/api"
"github.com/github/gh-cli/utils"
"github.com/pkg/errors"
"github.com/spf13/cobra"
"golang.org/x/crypto/ssh/terminal"
)
func init() {
RootCmd.AddCommand(issueCmd)
issueCmd.AddCommand(
&cobra.Command{
Use: "status",
Short: "Show status of relevant issues",
RunE: issueStatus,
},
&cobra.Command{
Use: "view <issue-number>",
Args: cobra.MinimumNArgs(1),
Short: "View an issue in the browser",
RunE: issueView,
},
)
issueCmd.AddCommand(issueCreateCmd)
issueCreateCmd.Flags().StringArrayP("message", "m", nil, "set title and body")
issueCreateCmd.Flags().BoolP("web", "w", false, "open the web browser to create an issue")
issueCmd.AddCommand(issueStatusCmd)
issueCmd.AddCommand(issueViewCmd)
issueCreateCmd.Flags().StringP("title", "t", "",
"Supply a title. Will prompt for one otherwise.")
issueCreateCmd.Flags().StringP("body", "b", "",
"Supply a body. Will prompt for one otherwise.")
issueCreateCmd.Flags().BoolP("web", "w", false, "Open the web browser to create an issue")
issueListCmd := &cobra.Command{
Use: "list",
Short: "List and filter issues in this repository",
RunE: issueList,
}
issueListCmd.Flags().StringP("assignee", "a", "", "filter by assignee")
issueListCmd.Flags().StringSliceP("label", "l", nil, "filter by label")
issueListCmd.Flags().StringP("state", "s", "", "filter by state (open|closed|all)")
issueListCmd.Flags().IntP("limit", "L", 30, "maximum number of issues to fetch")
issueListCmd.Flags().StringP("assignee", "a", "", "Filter by assignee")
issueListCmd.Flags().StringSliceP("label", "l", nil, "Filter by label")
issueListCmd.Flags().StringP("state", "s", "", "Filter by state (open|closed|all)")
issueListCmd.Flags().IntP("limit", "L", 30, "Maximum number of issues to fetch")
issueCmd.AddCommand((issueListCmd))
}
var issueCmd = &cobra.Command{
Use: "issue",
Short: "Work with issues",
Short: "Create and view issues",
Long: `Work with GitHub issues`,
}
var issueCreateCmd = &cobra.Command{
@ -54,6 +46,17 @@ var issueCreateCmd = &cobra.Command{
Short: "Create a new issue",
RunE: issueCreate,
}
var issueStatusCmd = &cobra.Command{
Use: "status",
Short: "Show status of relevant issues",
RunE: issueStatus,
}
var issueViewCmd = &cobra.Command{
Use: "view <issue-number>",
Args: cobra.MinimumNArgs(1),
Short: "View an issue in the browser",
RunE: issueView,
}
func issueList(cmd *cobra.Command, args []string) error {
ctx := contextForCommand(cmd)
@ -92,12 +95,31 @@ func issueList(cmd *cobra.Command, args []string) error {
return err
}
if len(issues) > 0 {
printIssues("", issues...)
} else {
message := fmt.Sprintf("There are no open issues")
printMessage(message)
out := cmd.OutOrStdout()
colorOut := colorableOut(cmd)
if len(issues) == 0 {
printMessage(colorOut, "There are no open issues")
return nil
}
table := utils.NewTablePrinter(out)
for _, issue := range issues {
issueNum := strconv.Itoa(issue.Number)
if table.IsTTY() {
issueNum = "#" + issueNum
}
labels := labelList(issue)
if labels != "" && table.IsTTY() {
labels = fmt.Sprintf("(%s)", labels)
}
table.AddField(issueNum, nil, colorFuncForState(issue.State))
table.AddField(issue.Title, nil, nil)
table.AddField(labels, nil, utils.Gray)
table.EndRow()
}
table.Render()
return nil
}
@ -123,30 +145,32 @@ func issueStatus(cmd *cobra.Command, args []string) error {
return err
}
printHeader("Issues assigned to you")
out := colorableOut(cmd)
printHeader(out, "Issues assigned to you")
if issuePayload.Assigned != nil {
printIssues(" ", issuePayload.Assigned...)
printIssues(out, " ", issuePayload.Assigned...)
} else {
message := fmt.Sprintf(" There are no issues assgined to you")
printMessage(message)
printMessage(out, message)
}
fmt.Println()
fmt.Fprintln(out)
printHeader("Issues mentioning you")
printHeader(out, "Issues mentioning you")
if len(issuePayload.Mentioned) > 0 {
printIssues(" ", issuePayload.Mentioned...)
printIssues(out, " ", issuePayload.Mentioned...)
} else {
printMessage(" There are no issues mentioning you")
printMessage(out, " There are no issues mentioning you")
}
fmt.Println()
fmt.Fprintln(out)
printHeader("Recent issues")
if len(issuePayload.Recent) > 0 {
printIssues(" ", issuePayload.Recent...)
printHeader(out, "Issues opened by you")
if len(issuePayload.Authored) > 0 {
printIssues(out, " ", issuePayload.Authored...)
} else {
printMessage(" There are no recent issues")
printMessage(out, " There are no issues opened by you")
}
fmt.Println()
fmt.Fprintln(out)
return nil
}
@ -189,44 +213,39 @@ func issueCreate(cmd *cobra.Command, args []string) error {
return utils.OpenInBrowser(openURL)
}
var title string
var body string
message, err := cmd.Flags().GetStringArray("message")
if err != nil {
return err
}
apiClient, err := apiClientForContext(ctx)
if err != nil {
return err
}
if len(message) > 0 {
title = message[0]
body = strings.Join(message[1:], "\n\n")
} else {
// TODO: open the text editor for issue title & body
input := os.Stdin
if terminal.IsTerminal(int(input.Fd())) {
cmd.Println("Enter the issue title and body; press Enter + Ctrl-D when done:")
}
inputBytes, err := ioutil.ReadAll(input)
if err != nil {
return err
}
parts := strings.SplitN(string(inputBytes), "\n\n", 2)
if len(parts) > 0 {
title = parts[0]
}
if len(parts) > 1 {
body = parts[1]
}
title, err := cmd.Flags().GetString("title")
if err != nil {
return errors.Wrap(err, "could not parse title")
}
body, err := cmd.Flags().GetString("body")
if err != nil {
return errors.Wrap(err, "could not parse body")
}
if title == "" {
return fmt.Errorf("aborting due to empty title")
interactive := title == "" || body == ""
if interactive {
tb, err := titleBodySurvey(cmd, title, body)
if err != nil {
return errors.Wrap(err, "could not collect title and/or body")
}
if tb == nil {
// editing was canceled, we can just leave
return nil
}
if title == "" {
title = tb.Title
}
if body == "" {
body = tb.Body
}
}
params := map[string]interface{}{
"title": title,
@ -242,17 +261,30 @@ func issueCreate(cmd *cobra.Command, args []string) error {
return nil
}
func printIssues(prefix string, issues ...api.Issue) {
func printIssues(w io.Writer, prefix string, issues ...api.Issue) {
for _, issue := range issues {
number := utils.Green("#" + strconv.Itoa(issue.Number))
var coloredLabels string
if len(issue.Labels) > 0 {
var ellipse string
if issue.TotalLabelCount > len(issue.Labels) {
ellipse = "…"
}
coloredLabels = utils.Gray(fmt.Sprintf(" (%s%s)", strings.Join(issue.Labels, ", "), ellipse))
coloredLabels := labelList(issue)
if coloredLabels != "" {
coloredLabels = utils.Gray(fmt.Sprintf(" (%s)", coloredLabels))
}
fmt.Printf("%s%s %s %s\n", prefix, number, truncate(70, issue.Title), coloredLabels)
fmt.Fprintf(w, "%s%s %s%s\n", prefix, number, truncate(70, issue.Title), coloredLabels)
}
}
func labelList(issue api.Issue) string {
if len(issue.Labels.Nodes) == 0 {
return ""
}
labelNames := []string{}
for _, label := range issue.Labels.Nodes {
labelNames = append(labelNames, label.Name)
}
list := strings.Join(labelNames, ", ")
if issue.Labels.TotalCount > len(issue.Labels.Nodes) {
list += ", …"
}
return list
}

View file

@ -55,9 +55,9 @@ func TestIssueList(t *testing.T) {
}
expectedIssues := []*regexp.Regexp{
regexp.MustCompile(`#1.*won`),
regexp.MustCompile(`#2.*too`),
regexp.MustCompile(`#4.*fore`),
regexp.MustCompile(`(?m)^1\t.*won`),
regexp.MustCompile(`(?m)^2\t.*too`),
regexp.MustCompile(`(?m)^4\t.*fore`),
}
for _, r := range expectedIssues {
@ -144,7 +144,7 @@ func TestIssueCreate(t *testing.T) {
out := bytes.Buffer{}
issueCreateCmd.SetOut(&out)
RootCmd.SetArgs([]string{"issue", "create", "-m", "hello", "-m", "ab", "-m", "cd"})
RootCmd.SetArgs([]string{"issue", "create", "-t", "hello", "-b", "cash rules everything around me"})
_, err := RootCmd.ExecuteC()
if err != nil {
t.Errorf("error running command `issue create`: %v", err)
@ -164,7 +164,7 @@ func TestIssueCreate(t *testing.T) {
eq(t, reqBody.Variables.Input.RepositoryID, "REPOID")
eq(t, reqBody.Variables.Input.Title, "hello")
eq(t, reqBody.Variables.Input.Body, "ab\n\ncd")
eq(t, reqBody.Variables.Input.Body, "cash rules everything around me")
eq(t, out.String(), "https://github.com/OWNER/REPO/issues/12\n")
}

View file

@ -2,6 +2,7 @@ package command
import (
"fmt"
"io"
"os"
"os/exec"
"regexp"
@ -13,7 +14,6 @@ import (
"github.com/github/gh-cli/git"
"github.com/github/gh-cli/utils"
"github.com/spf13/cobra"
"golang.org/x/crypto/ssh/terminal"
)
func init() {
@ -24,15 +24,15 @@ func init() {
prCmd.AddCommand(prStatusCmd)
prCmd.AddCommand(prViewCmd)
prListCmd.Flags().IntP("limit", "L", 30, "maximum number of items to fetch")
prListCmd.Flags().StringP("state", "s", "open", "filter by state")
prListCmd.Flags().StringP("base", "b", "", "filter by base branch")
prListCmd.Flags().StringArrayP("label", "l", nil, "filter by label")
prListCmd.Flags().IntP("limit", "L", 30, "Maximum number of items to fetch")
prListCmd.Flags().StringP("state", "s", "open", "Filter by state")
prListCmd.Flags().StringP("base", "B", "", "Filter by base branch")
prListCmd.Flags().StringArrayP("label", "l", nil, "Filter by label")
}
var prCmd = &cobra.Command{
Use: "pr",
Short: "Work with pull requests",
Short: "Create, view, and checkout pull requests",
Long: `Work with GitHub pull requests.`,
}
var prCheckoutCmd = &cobra.Command{
@ -82,30 +82,32 @@ func prStatus(cmd *cobra.Command, args []string) error {
return err
}
printHeader("Current branch")
out := colorableOut(cmd)
printHeader(out, "Current branch")
if prPayload.CurrentPR != nil {
printPrs(*prPayload.CurrentPR)
printPrs(out, *prPayload.CurrentPR)
} else {
message := fmt.Sprintf(" There is no pull request associated with %s", utils.Cyan("["+currentBranch+"]"))
printMessage(message)
printMessage(out, message)
}
fmt.Println()
fmt.Fprintln(out)
printHeader("Created by you")
printHeader(out, "Created by you")
if len(prPayload.ViewerCreated) > 0 {
printPrs(prPayload.ViewerCreated...)
printPrs(out, prPayload.ViewerCreated...)
} else {
printMessage(" You have no open pull requests")
printMessage(out, " You have no open pull requests")
}
fmt.Println()
fmt.Fprintln(out)
printHeader("Requesting a code review from you")
printHeader(out, "Requesting a code review from you")
if len(prPayload.ReviewRequested) > 0 {
printPrs(prPayload.ReviewRequested...)
printPrs(out, prPayload.ReviewRequested...)
} else {
printMessage(" You have no pull requests to review")
printMessage(out, " You have no pull requests to review")
}
fmt.Println()
fmt.Fprintln(out)
return nil
}
@ -170,57 +172,38 @@ func prList(cmd *cobra.Command, args []string) error {
return err
}
tty := false
ttyWidth := 80
out := cmd.OutOrStdout()
if outFile, isFile := out.(*os.File); isFile {
fd := int(outFile.Fd())
tty = terminal.IsTerminal(fd)
if w, _, err := terminal.GetSize(fd); err == nil {
ttyWidth = w
}
}
numWidth := 0
maxTitleWidth := 0
table := utils.NewTablePrinter(cmd.OutOrStdout())
for _, pr := range prs {
numLen := len(strconv.Itoa(pr.Number)) + 1
if numLen > numWidth {
numWidth = numLen
}
if len(pr.Title) > maxTitleWidth {
maxTitleWidth = len(pr.Title)
prNum := strconv.Itoa(pr.Number)
if table.IsTTY() {
prNum = "#" + prNum
}
table.AddField(prNum, nil, colorFuncForState(pr.State))
table.AddField(pr.Title, nil, nil)
table.AddField(pr.HeadLabel(), nil, utils.Cyan)
table.EndRow()
}
err = table.Render()
if err != nil {
return err
}
branchWidth := 40
titleWidth := ttyWidth - branchWidth - 2 - numWidth - 2
if maxTitleWidth < titleWidth {
branchWidth += titleWidth - maxTitleWidth
titleWidth = maxTitleWidth
}
for _, pr := range prs {
if tty {
prNum := fmt.Sprintf("% *s", numWidth, fmt.Sprintf("#%d", pr.Number))
switch pr.State {
case "OPEN":
prNum = utils.Green(prNum)
case "CLOSED":
prNum = utils.Red(prNum)
case "MERGED":
prNum = utils.Magenta(prNum)
}
prBranch := utils.Cyan(truncate(branchWidth, pr.HeadLabel()))
fmt.Fprintf(out, "%s %-*s %s\n", prNum, titleWidth, truncate(titleWidth, pr.Title), prBranch)
} else {
fmt.Fprintf(out, "%d\t%s\t%s\n", pr.Number, pr.Title, pr.HeadLabel())
}
}
return nil
}
func colorFuncForState(state string) func(string) string {
switch state {
case "OPEN":
return utils.Green
case "CLOSED":
return utils.Red
case "MERGED":
return utils.Magenta
default:
return nil
}
}
func prView(cmd *cobra.Command, args []string) error {
ctx := contextForCommand(cmd)
baseRepo, err := ctx.BaseRepo()
@ -380,50 +363,51 @@ func prCheckout(cmd *cobra.Command, args []string) error {
return nil
}
func printPrs(prs ...api.PullRequest) {
func printPrs(w io.Writer, prs ...api.PullRequest) {
for _, pr := range prs {
prNumber := fmt.Sprintf("#%d", pr.Number)
fmt.Printf(" %s %s %s", utils.Yellow(prNumber), truncate(50, pr.Title), utils.Cyan("["+pr.HeadLabel()+"]"))
fmt.Fprintf(w, " %s %s %s", utils.Yellow(prNumber), truncate(50, pr.Title), utils.Cyan("["+pr.HeadLabel()+"]"))
checks := pr.ChecksStatus()
reviews := pr.ReviewStatus()
if checks.Total > 0 || reviews.ChangesRequested || reviews.Approved {
fmt.Printf("\n ")
fmt.Fprintf(w, "\n ")
}
if checks.Total > 0 {
var ratio string
var summary string
if checks.Failing > 0 {
ratio = fmt.Sprintf("%d/%d", checks.Passing, checks.Total)
ratio = utils.Red(ratio)
if checks.Failing == checks.Total {
summary = utils.Red("All checks failing")
} else {
summary = utils.Red(fmt.Sprintf("%d/%d checks failing", checks.Failing, checks.Total))
}
} else if checks.Pending > 0 {
ratio = fmt.Sprintf("%d/%d", checks.Passing, checks.Total)
ratio = utils.Yellow(ratio)
summary = utils.Yellow("Checks pending")
} else if checks.Passing == checks.Total {
ratio = fmt.Sprintf("%d", checks.Total)
ratio = utils.Green(ratio)
summary = utils.Green("Checks passing")
}
fmt.Printf(" - checks: %s", ratio)
fmt.Fprintf(w, " - %s", summary)
}
if reviews.ChangesRequested {
fmt.Printf(" - %s", utils.Red("changes requested"))
fmt.Fprintf(w, " - %s", utils.Red("changes requested"))
} else if reviews.ReviewRequired {
fmt.Printf(" - %s", utils.Yellow("review required"))
fmt.Fprintf(w, " - %s", utils.Yellow("review required"))
} else if reviews.Approved {
fmt.Printf(" - %s", utils.Green("approved"))
fmt.Fprintf(w, " - %s", utils.Green("approved"))
}
fmt.Printf("\n")
fmt.Fprint(w, "\n")
}
}
func printHeader(s string) {
fmt.Println(utils.Bold(s))
func printHeader(w io.Writer, s string) {
fmt.Fprintln(w, utils.Bold(s))
}
func printMessage(s string) {
fmt.Println(utils.Gray(s))
func printMessage(w io.Writer, s string) {
fmt.Fprintln(w, utils.Gray(s))
}
func truncate(maxLength int, title string) string {

View file

@ -5,7 +5,6 @@ import (
"os"
"runtime"
"github.com/AlecAivazis/survey/v2"
"github.com/github/gh-cli/api"
"github.com/github/gh-cli/context"
"github.com/github/gh-cli/git"
@ -55,86 +54,25 @@ func prCreate(cmd *cobra.Command, _ []string) error {
interactive := title == "" || body == ""
inProgress := struct {
Body string
Title string
}{}
if interactive {
confirmed := false
editor := determineEditor()
tb, err := titleBodySurvey(cmd, title, body)
if err != nil {
return errors.Wrap(err, "could not collect title and/or body")
}
for !confirmed {
titleQuestion := &survey.Question{
Name: "title",
Prompt: &survey.Input{
Message: "Pull request title",
Default: inProgress.Title,
},
}
bodyQuestion := &survey.Question{
Name: "body",
Prompt: &survey.Editor{
Message: fmt.Sprintf("Pull request body (%s)", editor),
FileName: "*.md",
Default: inProgress.Body,
AppendDefault: true,
Editor: editor,
},
}
if tb == nil {
// editing was canceled, we can just leave
return nil
}
qs := []*survey.Question{}
if title == "" {
qs = append(qs, titleQuestion)
}
if body == "" {
qs = append(qs, bodyQuestion)
}
err := survey.Ask(qs, &inProgress)
if err != nil {
return errors.Wrap(err, "could not prompt")
}
confirmAnswers := struct {
Confirmation string
}{}
confirmQs := []*survey.Question{
{
Name: "confirmation",
Prompt: &survey.Select{
Message: "Submit?",
Options: []string{
"Yes",
"Edit",
"Cancel",
},
},
},
}
err = survey.Ask(confirmQs, &confirmAnswers)
if err != nil {
return errors.Wrap(err, "could not prompt")
}
switch confirmAnswers.Confirmation {
case "Yes":
confirmed = true
case "Edit":
continue
case "Cancel":
cmd.Println("Discarding pull request")
return nil
}
if title == "" {
title = tb.Title
}
if body == "" {
body = tb.Body
}
}
if title == "" {
title = inProgress.Title
}
if body == "" {
body = inProgress.Body
}
base, err := cmd.Flags().GetString("base")
if err != nil {
return errors.Wrap(err, "could not parse base")

View file

@ -2,10 +2,13 @@ package command
import (
"fmt"
"io"
"os"
"strings"
"github.com/github/gh-cli/api"
"github.com/github/gh-cli/context"
"github.com/github/gh-cli/utils"
"github.com/spf13/cobra"
)
@ -17,9 +20,12 @@ var Version = "DEV"
var BuildDate = "YYYY-MM-DD"
func init() {
RootCmd.Version = fmt.Sprintf("%s (%s)", Version, BuildDate)
RootCmd.PersistentFlags().StringP("repo", "R", "", "current GitHub repository")
RootCmd.PersistentFlags().StringP("current-branch", "B", "", "current git branch")
RootCmd.Version = fmt.Sprintf("%s (%s)", strings.TrimPrefix(Version, "v"), BuildDate)
RootCmd.AddCommand(versionCmd)
RootCmd.PersistentFlags().StringP("repo", "R", "", "Current GitHub repository")
RootCmd.PersistentFlags().Bool("help", false, "Show help for command")
RootCmd.Flags().Bool("version", false, "Print gh version")
// TODO:
// RootCmd.PersistentFlags().BoolP("verbose", "V", false, "enable verbose output")
@ -37,12 +43,20 @@ type FlagError struct {
var RootCmd = &cobra.Command{
Use: "gh",
Short: "GitHub CLI",
Long: `Work with GitHub from your terminal`,
Long: `Work seamlessly with GitHub from the command line`,
SilenceErrors: true,
SilenceUsage: true,
}
var versionCmd = &cobra.Command{
Use: "version",
Hidden: true,
Run: func(cmd *cobra.Command, args []string) {
fmt.Printf("gh version %s\n", RootCmd.Version)
},
}
// overriden in tests
var initContext = func() context.Context {
ctx := context.New()
@ -56,9 +70,7 @@ func contextForCommand(cmd *cobra.Command) context.Context {
ctx := initContext()
if repo, err := cmd.Flags().GetString("repo"); err == nil && repo != "" {
ctx.SetBaseRepo(repo)
}
if branch, err := cmd.Flags().GetString("current-branch"); err == nil && branch != "" {
ctx.SetBranch(branch)
ctx.SetBranch("master")
}
return ctx
}
@ -82,3 +94,11 @@ var apiClientForContext = func(ctx context.Context) (*api.Client, error) {
}
return api.NewClient(opts...), nil
}
func colorableOut(cmd *cobra.Command) io.Writer {
out := cmd.OutOrStdout()
if outFile, isFile := out.(*os.File); isFile {
return utils.NewColorable(outFile)
}
return out
}

View file

@ -0,0 +1,104 @@
package command
import (
"fmt"
"github.com/AlecAivazis/survey/v2"
"github.com/pkg/errors"
"github.com/spf13/cobra"
)
type titleBody struct {
Body string
Title string
}
const (
_confirmed = iota
_unconfirmed = iota
_cancel = iota
)
func confirm() (int, error) {
confirmAnswers := struct {
Confirmation int
}{}
confirmQs := []*survey.Question{
{
Name: "confirmation",
Prompt: &survey.Select{
Message: "Submit?",
Options: []string{
"Yes",
"Edit",
"Cancel",
},
},
},
}
err := survey.Ask(confirmQs, &confirmAnswers)
if err != nil {
return -1, errors.Wrap(err, "could not prompt")
}
return confirmAnswers.Confirmation, nil
}
func titleBodySurvey(cmd *cobra.Command, providedTitle string, providedBody string) (*titleBody, error) {
inProgress := titleBody{}
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),
FileName: "*.md",
Default: inProgress.Body,
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:
cmd.Println("Discarding.")
return nil, nil
default:
panic("reached unreachable case")
}
}
return &inProgress, nil
}