diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 000000000..19e727fdf --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,23 @@ +--- +name: "\U0001F41B Bug report" +about: Report a bug or unexpected behavior while using GitHub CLI + +--- + +### Describe the bug + +A clear and concise description of what the bug is. Include version by typing `gh --version`. + +### Steps to reproduce the behavior + +1. Type this '...' +2. View the output '....' +3. See error + +### Expected vs actual behavior + +A clear and concise description of what you expected to happen and what actually happened. + +### Logs + +Paste the activity from your command line. Redact if needed. diff --git a/.github/ISSUE_TEMPLATE/submit-a-request.md b/.github/ISSUE_TEMPLATE/submit-a-request.md new file mode 100644 index 000000000..585afa21e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/submit-a-request.md @@ -0,0 +1,17 @@ +--- +name: "\U00002B50 Submit a request" +about: Surface a feature or problem that you think should be solved + +--- + +### Describe the feature or problem you’d like to solve + +A clear and concise description of what the feature or problem is. + +### Proposed solution + +How will it benefit CLI and its users? + +### Additional context + +Add any other context like screenshots or mockups are helpful, if applicable. diff --git a/.goreleaser.yml b/.goreleaser.yml index eef85417e..dbddbb888 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -24,6 +24,18 @@ archives: format_overrides: - goos: windows format: zip + +nfpms: + - license: MIT + maintainer: GitHub + homepage: https://github.com/github/gh-cli + bindir: /usr/local + dependencies: + - git + formats: + - deb + - rpm + changelog: sort: asc filters: diff --git a/LICENSE b/LICENSE new file mode 100644 index 000000000..b6a58a957 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2019 GitHub Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile index 120d44cad..0aa490445 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ BUILD_FILES = $(shell go list -f '{{range .GoFiles}}{{$$.Dir}}/{{.}}\ {{end}}' ./...) -GH_VERSION = $(shell go describe --tags 2>/dev/null || git rev-parse --short HEAD) +GH_VERSION = $(shell git describe --tags 2>/dev/null || git rev-parse --short HEAD) LDFLAGS := -X github.com/github/gh-cli/command.Version=$(GH_VERSION) $(LDFLAGS) LDFLAGS := -X github.com/github/gh-cli/command.BuildDate=$(shell date +%Y-%m-%d) $(LDFLAGS) ifdef GH_OAUTH_CLIENT_SECRET diff --git a/README.md b/README.md index 47f80bd8b..036c37b90 100644 --- a/README.md +++ b/README.md @@ -8,9 +8,32 @@ This tool is an endeavor separate from [github/hub](https://github.com/github/hu _warning, gh is in a very alpha phase_ +## macOS + `brew install github/gh/gh` -That's it. You are now ready to use `gh` on the command line. 🥳 +## Debian/Ubuntu Linux + +1. `sudo apt install git` if you don't already have git +2. Download the `.deb` file from the [releases page](https://github.com/github/gh-cli/releases/latest) +3. `sudo dpkg -i gh_*_linux_amd64.deb` install the downloaded file + +_(Uninstall with `sudo apt remove gh`)_ + +## Fedora/Centos Linux + +1. Download the `.rpm` file from the [releases page](https://github.com/github/gh-cli/releases/latest) +2. `sudo yum localinstall gh_*_linux_amd64.rpm` install the downloaded file + +_(Uninstall with `sudo yum remove gh`)_ + +## Other Linux + +1. Download the `_linux_amd64.tar.gz` file from the [releases page](https://github.com/github/gh-cli/releases/latest) +2. `tar -xf gh_*_linux_amd64.tar.gz` +3. Copy the uncompressed `gh` somewhere on your `$PATH` (e.g. `sudo cp gh_*_linux_amd64/bin/gh /usr/local/bin/`) + +_(Uninstall with `rm`)_ # Process @@ -25,4 +48,13 @@ This can all be done from your local terminal. 1. `git tag 'vVERSION_NUMBER' # example git tag 'v0.0.1'` 2. `git push origin vVERSION_NUMBER` 3. Wait a few minutes for the build to run and CI to pass. Look at the [actions tab](https://github.com/github/gh-cli/actions) to check the progress. -4. Go to https://github.com/github/homebrew-gh/releases and look at the release +4. Go to and look at the release + +# Test a release + +A local release can be created for testing without creating anything official on the release page. + +1. `git tag 'v6.6.6' # some throwaway version number` +2. `env GH_OAUTH_CLIENT_SECRET=foobar GH_OAUTH_CLIENT_ID=1234 goreleaser --skip-publish --rm-dist` +3. Check and test files in `dist/` +4. `git tag -d v6.6.6 # delete the throwaway tag` diff --git a/api/queries_issue.go b/api/queries_issue.go index 31793e608..401920e1b 100644 --- a/api/queries_issue.go +++ b/api/queries_issue.go @@ -2,40 +2,33 @@ package api import ( "fmt" - "time" ) type IssuesPayload struct { Assigned []Issue Mentioned []Issue - Recent []Issue + Authored []Issue } type Issue struct { - Number int - Title string - URL string - Labels []string - TotalLabelCount int + Number int + Title string + URL string + State string + + Labels struct { + Nodes []IssueLabel + TotalCount int + } +} + +type IssueLabel struct { + Name string } type apiIssues struct { Issues struct { - Edges []struct { - Node struct { - Number int - Title string - URL string - Labels struct { - Edges []struct { - Node struct { - Name string - } - } - TotalCount int - } - } - } + Nodes []Issue } } @@ -43,11 +36,11 @@ const fragments = ` fragment issue on Issue { number title + url + state labels(first: 3) { - edges { - node { - name - } + nodes { + name } totalCount } @@ -97,35 +90,29 @@ func IssueStatus(client *Client, ghRepo Repo, currentUsername string) (*IssuesPa type response struct { Assigned apiIssues Mentioned apiIssues - Recent apiIssues + Authored apiIssues } query := fragments + ` - query($owner: String!, $repo: String!, $since: DateTime!, $viewer: String!, $per_page: Int = 10) { + query($owner: String!, $repo: String!, $viewer: String!, $per_page: Int = 10) { assigned: repository(owner: $owner, name: $repo) { issues(filterBy: {assignee: $viewer, states: OPEN}, first: $per_page, orderBy: {field: CREATED_AT, direction: DESC}) { - edges { - node { - ...issue - } + nodes { + ...issue } } } mentioned: repository(owner: $owner, name: $repo) { issues(filterBy: {mentioned: $viewer, states: OPEN}, first: $per_page, orderBy: {field: CREATED_AT, direction: DESC}) { - edges { - node { - ...issue - } + nodes { + ...issue } } } - recent: repository(owner: $owner, name: $repo) { - issues(filterBy: {since: $since, states: OPEN}, first: $per_page, orderBy: {field: CREATED_AT, direction: DESC}) { - edges { - node { - ...issue - } + authored: repository(owner: $owner, name: $repo) { + issues(filterBy: {createdBy: $viewer, states: OPEN}, first: $per_page, orderBy: {field: CREATED_AT, direction: DESC}) { + nodes { + ...issue } } } @@ -134,12 +121,10 @@ func IssueStatus(client *Client, ghRepo Repo, currentUsername string) (*IssuesPa owner := ghRepo.RepoOwner() repo := ghRepo.RepoName() - since := time.Now().UTC().Add(time.Hour * -24).Format("2006-01-02T15:04:05-0700") variables := map[string]interface{}{ "owner": owner, "repo": repo, "viewer": currentUsername, - "since": since, } var resp response @@ -148,14 +133,10 @@ func IssueStatus(client *Client, ghRepo Repo, currentUsername string) (*IssuesPa return nil, err } - assigned := convertAPIToIssues(resp.Assigned) - mentioned := convertAPIToIssues(resp.Mentioned) - recent := convertAPIToIssues(resp.Recent) - payload := IssuesPayload{ - assigned, - mentioned, - recent, + Assigned: resp.Assigned.Issues.Nodes, + Mentioned: resp.Mentioned.Issues.Nodes, + Authored: resp.Authored.Issues.Nodes, } return &payload, nil @@ -191,10 +172,8 @@ func IssueList(client *Client, ghRepo Repo, state string, labels []string, assig query($owner: String!, $repo: String!, $limit: Int, $states: [IssueState!] = OPEN, $labels: [String!], $assignee: String) { repository(owner: $owner, name: $repo) { issues(first: $limit, orderBy: {field: CREATED_AT, direction: DESC}, states: $states, labels: $labels, filterBy: {assignee: $assignee}) { - edges { - node { - ...issue - } + nodes { + ...issue } } } @@ -221,27 +200,5 @@ func IssueList(client *Client, ghRepo Repo, state string, labels []string, assig return nil, err } - issues := convertAPIToIssues(resp.Repository) - return issues, nil -} - -func convertAPIToIssues(i apiIssues) []Issue { - var issues []Issue - for _, edge := range i.Issues.Edges { - var labels []string - for _, labelEdge := range edge.Node.Labels.Edges { - labels = append(labels, labelEdge.Node.Name) - } - - issue := Issue{ - Number: edge.Node.Number, - Title: edge.Node.Title, - URL: edge.Node.URL, - Labels: labels, - TotalLabelCount: edge.Node.Labels.TotalCount, - } - issues = append(issues, issue) - } - - return issues + return resp.Repository.Issues.Nodes, nil } diff --git a/auth/oauth.go b/auth/oauth.go index dba975f41..8325519fd 100644 --- a/auth/oauth.go +++ b/auth/oauth.go @@ -11,6 +11,7 @@ import ( "os" "os/exec" "runtime" + "strings" ) func randomString(length int) (string, error) { @@ -101,11 +102,14 @@ func openInBrowser(url string) error { args = []string{"open"} case "windows": args = []string{"cmd", "/c", "start"} + r := strings.NewReplacer("&", "^&") + url = r.Replace(url) default: args = []string{"xdg-open"} } args = append(args, url) cmd := exec.Command(args[0], args[1:]...) + cmd.Stderr = os.Stderr return cmd.Run() } diff --git a/command/issue.go b/command/issue.go index 00ece68ef..80b0a980d 100644 --- a/command/issue.go +++ b/command/issue.go @@ -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 ", - 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 ", + 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 assigned to you") - printMessage(message) + message := fmt.Sprintf(" There are no issues assgined to you") + 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 +} diff --git a/command/issue_test.go b/command/issue_test.go index 09a55609f..37db69b10 100644 --- a/command/issue_test.go +++ b/command/issue_test.go @@ -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") } diff --git a/command/pr.go b/command/pr.go index ab1ad4ad5..e2222083d 100644 --- a/command/pr.go +++ b/command/pr.go @@ -2,6 +2,7 @@ package command import ( "fmt" + "io" "os" "os/exec" "strconv" @@ -10,7 +11,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() { @@ -21,15 +21,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{ @@ -79,30 +79,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 } @@ -167,57 +169,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() @@ -350,50 +333,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 { diff --git a/command/pr_create.go b/command/pr_create.go index eebd7258d..8f635e306 100644 --- a/command/pr_create.go +++ b/command/pr_create.go @@ -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") diff --git a/command/pr_test.go b/command/pr_test.go index 47851a5e8..701874f13 100644 --- a/command/pr_test.go +++ b/command/pr_test.go @@ -147,7 +147,7 @@ func TestPRView_NoActiveBranch(t *testing.T) { }) defer restoreCmd() - output, err := test.RunCommand(RootCmd, "pr view") + _, err := test.RunCommand(RootCmd, "pr view") if err == nil || err.Error() != "the 'master' branch has no open pull requests" { t.Errorf("error running command `pr view`: %v", err) } @@ -157,7 +157,7 @@ func TestPRView_NoActiveBranch(t *testing.T) { } // Now run again but provide a PR number - output, err = test.RunCommand(RootCmd, "pr view 23") + output, err := test.RunCommand(RootCmd, "pr view 23") if err != nil { t.Errorf("error running command `pr view`: %v", err) } diff --git a/command/root.go b/command/root.go index f2a73430d..085d1758b 100644 --- a/command/root.go +++ b/command/root.go @@ -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 +} diff --git a/command/title_body_survey.go b/command/title_body_survey.go new file mode 100644 index 000000000..af4dc34e1 --- /dev/null +++ b/command/title_body_survey.go @@ -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 +} diff --git a/context/config_setup.go b/context/config_setup.go index bb7addbce..48a037a24 100644 --- a/context/config_setup.go +++ b/context/config_setup.go @@ -66,6 +66,9 @@ func setupConfigFile(filename string) (*configEntry, error) { defer config.Close() yamlData, err := yaml.Marshal(data) + if err != nil { + return nil, err + } n, err := config.Write(yamlData) if err == nil && n < len(yamlData) { err = io.ErrShortWrite diff --git a/git/git.go b/git/git.go index 8824872a4..7732751bd 100644 --- a/git/git.go +++ b/git/git.go @@ -4,7 +4,6 @@ import ( "bytes" "fmt" "io/ioutil" - "os" "os/exec" "path/filepath" "strings" @@ -31,16 +30,6 @@ func Dir() (string, error) { return gitDir, nil } -func WorkdirName() (string, error) { - toplevelCmd := exec.Command("git", "rev-parse", "--show-toplevel") - output, err := utils.PrepareCmd(toplevelCmd).Output() - dir := firstLine(output) - if dir == "" { - return "", fmt.Errorf("unable to determine git working directory") - } - return dir, err -} - func VerifyRef(ref string) bool { showRef := exec.Command("git", "show-ref", "--verify", "--quiet", ref) err := utils.PrepareCmd(showRef).Run() @@ -73,72 +62,10 @@ func BranchAtRef(paths ...string) (name string, err error) { return } -func Editor() (string, error) { - varCmd := exec.Command("git", "var", "GIT_EDITOR") - output, err := utils.PrepareCmd(varCmd).Output() - if err != nil { - return "", fmt.Errorf("Can't load git var: GIT_EDITOR") - } - - return os.ExpandEnv(firstLine(output)), nil -} - func Head() (string, error) { return BranchAtRef("HEAD") } -func SymbolicFullName(name string) (string, error) { - parseCmd := exec.Command("git", "rev-parse", "--symbolic-full-name", name) - output, err := utils.PrepareCmd(parseCmd).Output() - if err != nil { - return "", fmt.Errorf("Unknown revision or path not in the working tree: %s", name) - } - - return firstLine(output), nil -} - -func CommentChar(text string) (string, error) { - char, err := Config("core.commentchar") - if err != nil { - return "#", nil - } else if char == "auto" { - lines := strings.Split(text, "\n") - commentCharCandidates := strings.Split("#;@!$%^&|:", "") - candidateLoop: - for _, candidate := range commentCharCandidates { - for _, line := range lines { - if strings.HasPrefix(line, candidate) { - continue candidateLoop - } - } - return candidate, nil - } - return "", fmt.Errorf("unable to select a comment character that is not used in the current message") - } else { - return char, nil - } -} - -func Show(sha string) (string, error) { - cmd := exec.Command("git", "-c", "log.showSignature=false", "show", "-s", "--format=%s%n%+b", sha) - output, err := utils.PrepareCmd(cmd).Output() - return strings.TrimSpace(string(output)), err -} - -func Log(sha1, sha2 string) (string, error) { - shaRange := fmt.Sprintf("%s...%s", sha1, sha2) - cmd := exec.Command( - "-c", "log.showSignature=false", "log", "--no-color", - "--format=%h (%aN, %ar)%n%w(78,3,3)%s%n%+b", - "--cherry", shaRange) - outputs, err := utils.PrepareCmd(cmd).Output() - if err != nil { - return "", fmt.Errorf("Can't load git log %s..%s", sha1, sha2) - } - - return string(outputs), nil -} - func listRemotes() ([]string, error) { remoteCmd := exec.Command("git", "remote", "-v") output, err := utils.PrepareCmd(remoteCmd).Output() @@ -156,34 +83,6 @@ func Config(name string) (string, error) { } -func ConfigAll(name string) ([]string, error) { - mode := "--get-all" - if strings.Contains(name, "*") { - mode = "--get-regexp" - } - - configCmd := exec.Command("git", "config", mode, name) - output, err := utils.PrepareCmd(configCmd).Output() - if err != nil { - return nil, fmt.Errorf("Unknown config %s", name) - } - return outputLines(output), nil -} - -func LocalBranches() ([]string, error) { - branchesCmd := exec.Command("git", "branch", "--list") - output, err := utils.PrepareCmd(branchesCmd).Output() - if err != nil { - return nil, err - } - - branches := []string{} - for _, branch := range outputLines(output) { - branches = append(branches, branch[2:]) - } - return branches, nil -} - var GitCommand = func(args ...string) *exec.Cmd { return exec.Command("git", args...) } diff --git a/test/fixtures/issueList.json b/test/fixtures/issueList.json index 17e92c743..b22899cee 100644 --- a/test/fixtures/issueList.json +++ b/test/fixtures/issueList.json @@ -2,57 +2,45 @@ "data": { "repository": { "issues": { - "edges": [ + "nodes": [ { - "node": { "number": 1, "title": "number won", "url": "https://wow.com", "labels": { - "edges": [ + "nodes": [ { - "node": { "name": "label" - } } ], "totalCount": 1 } - } }, { - "node": { "number": 2, "title": "number too", "url": "https://wow.com", "labels": { - "edges": [ + "nodes": [ { - "node": { "name": "label" - } } ], "totalCount": 1 } - } }, { - "node": { "number": 4, "title": "number fore", "url": "https://wow.com", "labels": { - "edges": [ + "nodes": [ { - "node": { "name": "label" - } } ], "totalCount": 1 } - } } ] } diff --git a/test/fixtures/issueStatus.json b/test/fixtures/issueStatus.json index 784c0cc8f..f9e831c7a 100644 --- a/test/fixtures/issueStatus.json +++ b/test/fixtures/issueStatus.json @@ -2,43 +2,35 @@ "data": { "assigned": { "issues": { - "edges": [ + "nodes": [ { - "node": { "number": 9, "title": "corey thinks squash tastes bad" - } }, { - "node": { "number": 10, "title": "broccoli is a superfood" - } } ] } }, "mentioned": { "issues": { - "edges": [ + "nodes": [ { - "node": { "number": 8, "title": "rabbits eat carrots" - } }, { - "node": { "number": 11, "title": "swiss chard is neutral" - } } ] } }, - "recent": { + "authored": { "issues": { - "edges": [] + "nodes": [] } }, diff --git a/ui/ui.go b/ui/ui.go deleted file mode 100644 index 7ea7240fe..000000000 --- a/ui/ui.go +++ /dev/null @@ -1,98 +0,0 @@ -package ui - -import ( - "fmt" - "io" - "os" - - "github.com/mattn/go-colorable" - "golang.org/x/crypto/ssh/terminal" -) - -type UI interface { - Print(a ...interface{}) (n int, err error) - Printf(format string, a ...interface{}) (n int, err error) - Println(a ...interface{}) (n int, err error) - Errorf(format string, a ...interface{}) (n int, err error) - Errorln(a ...interface{}) (n int, err error) -} - -var ( - Stdout = colorable.NewColorableStdout() - Stderr = colorable.NewColorableStderr() - Default UI = Console{Stdout: Stdout, Stderr: Stderr} -) - -func Print(a ...interface{}) (n int) { - n, err := Default.Print(a...) - if err != nil { - // If something as basic as printing to stdout fails, just panic and exit - os.Exit(1) - } - return -} - -func Printf(format string, a ...interface{}) (n int) { - n, err := Default.Printf(format, a...) - if err != nil { - // If something as basic as printing to stdout fails, just panic and exit - os.Exit(1) - } - return -} - -func Println(a ...interface{}) (n int) { - n, err := Default.Println(a...) - if err != nil { - // If something as basic as printing to stdout fails, just panic and exit - os.Exit(1) - } - return -} - -func Errorf(format string, a ...interface{}) (n int) { - n, err := Default.Errorf(format, a...) - if err != nil { - // If something as basic as printing to stderr fails, just panic and exit - os.Exit(1) - } - return -} - -func Errorln(a ...interface{}) (n int) { - n, err := Default.Errorln(a...) - if err != nil { - // If something as basic as printing to stderr fails, just panic and exit - os.Exit(1) - } - return -} - -func IsTerminal(f *os.File) bool { - return terminal.IsTerminal(int(f.Fd())) -} - -type Console struct { - Stdout io.Writer - Stderr io.Writer -} - -func (c Console) Print(a ...interface{}) (n int, err error) { - return fmt.Fprint(c.Stdout, a...) -} - -func (c Console) Printf(format string, a ...interface{}) (n int, err error) { - return fmt.Fprintf(c.Stdout, format, a...) -} - -func (c Console) Println(a ...interface{}) (n int, err error) { - return fmt.Fprintln(c.Stdout, a...) -} - -func (c Console) Errorf(format string, a ...interface{}) (n int, err error) { - return fmt.Fprintf(c.Stderr, format, a...) -} - -func (c Console) Errorln(a ...interface{}) (n int, err error) { - return fmt.Fprintln(c.Stderr, a...) -} diff --git a/utils/color.go b/utils/color.go index 3fbcad4e3..ed22bc3e9 100644 --- a/utils/color.go +++ b/utils/color.go @@ -1,40 +1,61 @@ package utils import ( + "io" "os" + "github.com/mattn/go-colorable" + "github.com/mattn/go-isatty" "github.com/mgutz/ansi" - "golang.org/x/crypto/ssh/terminal" ) +var _isStdoutTerminal = false +var checkedTerminal = false + +func isStdoutTerminal() bool { + if !checkedTerminal { + fd := os.Stdout.Fd() + _isStdoutTerminal = isatty.IsTerminal(fd) || isatty.IsCygwinTerminal(fd) + checkedTerminal = true + } + return _isStdoutTerminal +} + +// NewColorable returns an output stream that handles ANSI color sequences on Windows +func NewColorable(f *os.File) io.Writer { + return colorable.NewColorable(f) +} + func makeColorFunc(color string) func(string) string { + cf := ansi.ColorFunc(color) return func(arg string) string { - output := arg - if terminal.IsTerminal(int(os.Stdout.Fd())) { - output = ansi.Color(color+arg+ansi.Reset, "") + if isStdoutTerminal() { + return cf(arg) } - - return output + return arg } } -var Black = makeColorFunc(ansi.Black) -var White = makeColorFunc(ansi.White) -var Magenta = makeColorFunc(ansi.Magenta) -var Cyan = makeColorFunc(ansi.Cyan) -var Red = makeColorFunc(ansi.Red) -var Yellow = makeColorFunc(ansi.Yellow) -var Blue = makeColorFunc(ansi.Blue) -var Green = makeColorFunc(ansi.Green) -var Gray = makeColorFunc(ansi.LightBlack) +// Magenta outputs ANSI color if stdout is a tty +var Magenta = makeColorFunc("magenta") -func Bold(arg string) string { - output := arg - if terminal.IsTerminal(int(os.Stdout.Fd())) { - // This is really annoying. If you just define Bold as ColorFunc("+b") it will properly bold but - // will not use the default color, resulting in black and probably unreadable text. This forces - // the default color before bolding. - output = ansi.Color(ansi.DefaultFG+arg+ansi.Reset, "+b") - } - return output -} +// Cyan outputs ANSI color if stdout is a tty +var Cyan = makeColorFunc("cyan") + +// Red outputs ANSI color if stdout is a tty +var Red = makeColorFunc("red") + +// Yellow outputs ANSI color if stdout is a tty +var Yellow = makeColorFunc("yellow") + +// Blue outputs ANSI color if stdout is a tty +var Blue = makeColorFunc("blue") + +// Green outputs ANSI color if stdout is a tty +var Green = makeColorFunc("green") + +// Gray outputs ANSI color if stdout is a tty +var Gray = makeColorFunc("black+h") + +// Bold outputs ANSI color if stdout is a tty +var Bold = makeColorFunc("default+b") diff --git a/utils/table_printer.go b/utils/table_printer.go new file mode 100644 index 000000000..649e246e1 --- /dev/null +++ b/utils/table_printer.go @@ -0,0 +1,182 @@ +package utils + +import ( + "fmt" + "io" + "os" + "os/exec" + "strconv" + "strings" + + "github.com/mattn/go-isatty" + "golang.org/x/crypto/ssh/terminal" +) + +type TablePrinter interface { + IsTTY() bool + AddField(string, func(int, string) string, func(string) string) + EndRow() + Render() error +} + +func NewTablePrinter(w io.Writer) TablePrinter { + if outFile, isFile := w.(*os.File); isFile { + isCygwin := isatty.IsCygwinTerminal(outFile.Fd()) + if isatty.IsTerminal(outFile.Fd()) || isCygwin { + ttyWidth := 80 + if w, _, err := terminal.GetSize(int(outFile.Fd())); err == nil { + ttyWidth = w + } else if isCygwin { + tputCmd := exec.Command("tput", "cols") + tputCmd.Stdin = os.Stdin + if out, err := tputCmd.Output(); err == nil { + if w, err := strconv.Atoi(strings.TrimSpace(string(out))); err == nil { + ttyWidth = w + } + } + } + return &ttyTablePrinter{ + out: NewColorable(outFile), + maxWidth: ttyWidth, + } + } + } + return &tsvTablePrinter{ + out: w, + } +} + +type tableField struct { + Text string + TruncateFunc func(int, string) string + ColorFunc func(string) string +} + +type ttyTablePrinter struct { + out io.Writer + maxWidth int + rows [][]tableField +} + +func (t ttyTablePrinter) IsTTY() bool { + return true +} + +func (t *ttyTablePrinter) AddField(text string, truncateFunc func(int, string) string, colorFunc func(string) string) { + if truncateFunc == nil { + truncateFunc = truncate + } + if t.rows == nil { + t.rows = [][]tableField{[]tableField{}} + } + rowI := len(t.rows) - 1 + field := tableField{ + Text: text, + TruncateFunc: truncateFunc, + ColorFunc: colorFunc, + } + t.rows[rowI] = append(t.rows[rowI], field) +} + +func (t *ttyTablePrinter) EndRow() { + t.rows = append(t.rows, []tableField{}) +} + +func (t *ttyTablePrinter) Render() error { + if len(t.rows) == 0 { + return nil + } + + numCols := len(t.rows[0]) + colWidths := make([]int, numCols) + // measure maximum content width per column + for _, row := range t.rows { + for col, field := range row { + textLen := len(field.Text) + if textLen > colWidths[col] { + colWidths[col] = textLen + } + } + } + + delim := " " + availWidth := t.maxWidth - colWidths[0] - ((numCols - 1) * len(delim)) + // add extra space from columns that are already narrower than threshold + for col := 1; col < numCols; col++ { + availColWidth := availWidth / (numCols - 1) + if extra := availColWidth - colWidths[col]; extra > 0 { + availWidth += extra + } + } + // cap all but first column to fit available terminal width + // TODO: support weighted instead of even redistribution + for col := 1; col < numCols; col++ { + availColWidth := availWidth / (numCols - 1) + if colWidths[col] > availColWidth { + colWidths[col] = availColWidth + } + } + + for _, row := range t.rows { + for col, field := range row { + if col > 0 { + _, err := fmt.Fprint(t.out, delim) + if err != nil { + return err + } + } + truncVal := field.TruncateFunc(colWidths[col], field.Text) + if col < numCols-1 { + // pad value with spaces on the right + truncVal = fmt.Sprintf("%-*s", colWidths[col], truncVal) + } + if field.ColorFunc != nil { + truncVal = field.ColorFunc(truncVal) + } + _, err := fmt.Fprint(t.out, truncVal) + if err != nil { + return err + } + } + if len(row) > 0 { + _, err := fmt.Fprint(t.out, "\n") + if err != nil { + return err + } + } + } + return nil +} + +type tsvTablePrinter struct { + out io.Writer + currentCol int +} + +func (t tsvTablePrinter) IsTTY() bool { + return false +} + +func (t *tsvTablePrinter) AddField(text string, _ func(int, string) string, _ func(string) string) { + if t.currentCol > 0 { + fmt.Fprint(t.out, "\t") + } + fmt.Fprint(t.out, text) + t.currentCol++ +} + +func (t *tsvTablePrinter) EndRow() { + fmt.Fprint(t.out, "\n") + t.currentCol = 0 +} + +func (t *tsvTablePrinter) Render() error { + return nil +} + +func truncate(maxLength int, title string) string { + if len(title) > maxLength { + return title[0:maxLength-3] + "..." + } + return title +} diff --git a/utils/utils.go b/utils/utils.go index b034a18d9..6a1730cb8 100644 --- a/utils/utils.go +++ b/utils/utils.go @@ -2,31 +2,13 @@ package utils import ( "errors" - "fmt" "os" "os/exec" - "path/filepath" "runtime" - "strings" - "time" - "github.com/github/gh-cli/ui" "github.com/kballard/go-shellquote" ) -var timeNow = time.Now - -func Check(err error) { - if err != nil { - ui.Errorln(err) - os.Exit(1) - } -} - -func ConcatPaths(paths ...string) string { - return strings.Join(paths, "/") -} - func OpenInBrowser(url string) error { browser := os.Getenv("BROWSER") if browser == "" { @@ -69,58 +51,3 @@ func searchBrowserLauncher(goos string) (browser string) { return browser } - -func CommandPath(cmd string) (string, error) { - if runtime.GOOS == "windows" { - cmd = cmd + ".exe" - } - - path, err := exec.LookPath(cmd) - if err != nil { - return "", err - } - - path, err = filepath.Abs(path) - if err != nil { - return "", err - } - - return filepath.EvalSymlinks(path) -} - -func TimeAgo(t time.Time) string { - duration := timeNow().Sub(t) - minutes := duration.Minutes() - hours := duration.Hours() - days := hours / 24 - months := days / 30 - years := months / 12 - - var val int - var unit string - - if minutes < 1 { - return "now" - } else if hours < 1 { - val = int(minutes) - unit = "minute" - } else if days < 1 { - val = int(hours) - unit = "hour" - } else if months < 1 { - val = int(days) - unit = "day" - } else if years < 1 { - val = int(months) - unit = "month" - } else { - val = int(years) - unit = "year" - } - - var plural string - if val > 1 { - plural = "s" - } - return fmt.Sprintf("%d %s%s ago", val, unit, plural) -}