Merge remote-tracking branch 'origin/master' into upgrade-gh-reminder

This commit is contained in:
Corey Johnson 2019-12-02 10:01:04 -08:00
commit 5ce2b7ea18
23 changed files with 715 additions and 657 deletions

23
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View file

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

View file

@ -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 youd 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.

View file

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

21
LICENSE Normal file
View file

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

View file

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

View file

@ -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 <https://github.com/github/homebrew-gh/releases> 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`

View file

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

View file

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

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

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"
"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 {

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

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

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
}

View file

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

View file

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

View file

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

View file

@ -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": []
}
},

View file

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

View file

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

182
utils/table_printer.go Normal file
View file

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

View file

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