Merge remote-tracking branch 'origin/trunk' into h-e-l-p

This commit is contained in:
Corey Johnson 2020-06-10 09:28:40 -07:00
commit 56f1315d5f
28 changed files with 1183 additions and 260 deletions

View file

@ -1,3 +1,9 @@
---
name: "\U0001F41B Bug fix"
about: Fix a bug in GitHub CLI
---
<!-- <!--
Please make sure you read our contributing guidelines at Please make sure you read our contributing guidelines at
https://github.com/cli/cli/blob/trunk/.github/CONTRIBUTING.md https://github.com/cli/cli/blob/trunk/.github/CONTRIBUTING.md

View file

@ -10,7 +10,7 @@ jobs:
steps: steps:
- name: Set up Go 1.14 - name: Set up Go 1.14
uses: actions/setup-go@v2-beta uses: actions/setup-go@v2
with: with:
go-version: 1.14 go-version: 1.14

View file

@ -17,7 +17,7 @@ jobs:
steps: steps:
- name: Set up Go 1.14 - name: Set up Go 1.14
uses: actions/setup-go@v2-beta uses: actions/setup-go@v2
with: with:
go-version: 1.14 go-version: 1.14

View file

@ -12,7 +12,7 @@ jobs:
- name: Checkout - name: Checkout
uses: actions/checkout@v2 uses: actions/checkout@v2
- name: Set up Go 1.14 - name: Set up Go 1.14
uses: actions/setup-go@v2-beta uses: actions/setup-go@v2
with: with:
go-version: 1.14 go-version: 1.14
- name: Generate changelog - name: Generate changelog

View file

@ -3,7 +3,7 @@
`gh` is GitHub on the command line, and it's now available in beta. It brings pull requests, issues, and other GitHub concepts to `gh` is GitHub on the command line, and it's now available in beta. It brings pull requests, issues, and other GitHub concepts to
the terminal next to where you are already working with `git` and your code. the terminal next to where you are already working with `git` and your code.
![screenshot](https://user-images.githubusercontent.com/98482/73286699-9f922180-41bd-11ea-87c9-60a2d31fd0ac.png) ![screenshot of gh pr status](https://user-images.githubusercontent.com/98482/84171218-327e7a80-aa40-11ea-8cd1-5177fc2d0e72.png)
## Availability ## Availability

View file

@ -35,7 +35,11 @@ func NewClient(opts ...ClientOption) *Client {
func AddHeader(name, value string) ClientOption { func AddHeader(name, value string) ClientOption {
return func(tr http.RoundTripper) http.RoundTripper { return func(tr http.RoundTripper) http.RoundTripper {
return &funcTripper{roundTrip: func(req *http.Request) (*http.Response, error) { return &funcTripper{roundTrip: func(req *http.Request) (*http.Response, error) {
req.Header.Add(name, value) // prevent the token from leaking to non-GitHub hosts
// TODO: GHE support
if !strings.EqualFold(name, "Authorization") || strings.HasSuffix(req.URL.Hostname(), ".github.com") {
req.Header.Add(name, value)
}
return tr.RoundTrip(req) return tr.RoundTrip(req)
}} }}
} }
@ -45,7 +49,11 @@ func AddHeader(name, value string) ClientOption {
func AddHeaderFunc(name string, value func() string) ClientOption { func AddHeaderFunc(name string, value func() string) ClientOption {
return func(tr http.RoundTripper) http.RoundTripper { return func(tr http.RoundTripper) http.RoundTripper {
return &funcTripper{roundTrip: func(req *http.Request) (*http.Response, error) { return &funcTripper{roundTrip: func(req *http.Request) (*http.Response, error) {
req.Header.Add(name, value()) // prevent the token from leaking to non-GitHub hosts
// TODO: GHE support
if !strings.EqualFold(name, "Authorization") || strings.HasSuffix(req.URL.Hostname(), ".github.com") {
req.Header.Add(name, value())
}
return tr.RoundTrip(req) return tr.RoundTrip(req)
}} }}
} }

View file

@ -2,6 +2,7 @@ package command
import ( import (
"fmt" "fmt"
"sort"
"strings" "strings"
"github.com/cli/cli/utils" "github.com/cli/cli/utils"
@ -12,6 +13,8 @@ import (
func init() { func init() {
RootCmd.AddCommand(aliasCmd) RootCmd.AddCommand(aliasCmd)
aliasCmd.AddCommand(aliasSetCmd) aliasCmd.AddCommand(aliasSetCmd)
aliasCmd.AddCommand(aliasListCmd)
aliasCmd.AddCommand(aliasDeleteCmd)
} }
var aliasCmd = &cobra.Command{ var aliasCmd = &cobra.Command{
@ -21,10 +24,8 @@ var aliasCmd = &cobra.Command{
var aliasSetCmd = &cobra.Command{ var aliasSetCmd = &cobra.Command{
Use: "set <alias> <expansion>", Use: "set <alias> <expansion>",
// NB: Even when inside of a single-quoted string, cobra was noticing and parsing any flags // NB: this allows a user to eschew quotes when specifiying an alias expansion. We'll have to
// used in an alias expansion string argument. Since this command needs no flags, I disabled their // revisit it if we ever want to add flags to alias set but we have no current plans for that.
// parsing. If we ever want to add flags to alias set we'll have to figure this out. I tested on
// linux in various shells against cobra 1.0; others on macos did /not/ see the same behavior.
DisableFlagParsing: true, DisableFlagParsing: true,
Short: "Create a shortcut for a gh command", Short: "Create a shortcut for a gh command",
Long: `This command lets you write your own shortcuts for running gh. They can be simple strings or accept placeholder arguments.`, Long: `This command lets you write your own shortcuts for running gh. They can be simple strings or accept placeholder arguments.`,
@ -72,11 +73,12 @@ func aliasSet(cmd *cobra.Command, args []string) error {
successMsg := fmt.Sprintf("%s Added alias.", utils.Green("✓")) successMsg := fmt.Sprintf("%s Added alias.", utils.Green("✓"))
if aliasCfg.Exists(alias) { oldExpansion, ok := aliasCfg.Get(alias)
if ok {
successMsg = fmt.Sprintf("%s Changed alias %s from %s to %s", successMsg = fmt.Sprintf("%s Changed alias %s from %s to %s",
utils.Green("✓"), utils.Green("✓"),
utils.Bold(alias), utils.Bold(alias),
utils.Bold(aliasCfg.Get(alias)), utils.Bold(oldExpansion),
utils.Bold(expansionStr), utils.Bold(expansionStr),
) )
} }
@ -112,3 +114,95 @@ func processArgs(args []string) []string {
return newArgs return newArgs
} }
var aliasListCmd = &cobra.Command{
Use: "list",
Short: "List your aliases",
Long: `This command prints out all of the aliases gh is configured to use.`,
Args: cobra.ExactArgs(0),
RunE: aliasList,
}
func aliasList(cmd *cobra.Command, args []string) error {
ctx := contextForCommand(cmd)
cfg, err := ctx.Config()
if err != nil {
return fmt.Errorf("couldn't read config: %w", err)
}
aliasCfg, err := cfg.Aliases()
if err != nil {
return fmt.Errorf("couldn't read aliases config: %w", err)
}
stderr := colorableErr(cmd)
if aliasCfg.Empty() {
fmt.Fprintf(stderr, "no aliases configured\n")
return nil
}
stdout := colorableOut(cmd)
tp := utils.NewTablePrinter(stdout)
aliasMap := aliasCfg.All()
keys := []string{}
for alias := range aliasMap {
keys = append(keys, alias)
}
sort.Strings(keys)
for _, alias := range keys {
if tp.IsTTY() {
// ensure that screen readers pause
tp.AddField(alias+":", nil, nil)
} else {
tp.AddField(alias, nil, nil)
}
tp.AddField(aliasMap[alias], nil, nil)
tp.EndRow()
}
return tp.Render()
}
var aliasDeleteCmd = &cobra.Command{
Use: "delete <alias>",
Short: "Delete an alias.",
Args: cobra.ExactArgs(1),
Example: "gh alias delete co",
RunE: aliasDelete,
}
func aliasDelete(cmd *cobra.Command, args []string) error {
alias := args[0]
ctx := contextForCommand(cmd)
cfg, err := ctx.Config()
if err != nil {
return fmt.Errorf("couldn't read config: %w", err)
}
aliasCfg, err := cfg.Aliases()
if err != nil {
return fmt.Errorf("couldn't read aliases config: %w", err)
}
expansion, ok := aliasCfg.Get(alias)
if !ok {
return fmt.Errorf("no such alias %s", alias)
}
err = aliasCfg.Delete(alias)
if err != nil {
return fmt.Errorf("failed to delete alias %s: %w", alias, err)
}
out := colorableOut(cmd)
redCheck := utils.Red("✓")
fmt.Fprintf(out, "%s Deleted alias %s; was %s\n", redCheck, alias, expansion)
return nil
}

View file

@ -183,10 +183,6 @@ aliases:
func TestExpandAlias(t *testing.T) { func TestExpandAlias(t *testing.T) {
cfg := `--- cfg := `---
hosts:
github.com:
user: OWNER
oauth_token: token123
aliases: aliases:
co: pr checkout co: pr checkout
il: issue list --author="$1" --label="$2" il: issue list --author="$1" --label="$2"
@ -241,3 +237,85 @@ func TestAliasSet_invalid_command(t *testing.T) {
eq(t, err.Error(), "could not create alias: pe checkout does not correspond to a gh command") eq(t, err.Error(), "could not create alias: pe checkout does not correspond to a gh command")
} }
func TestAliasList_empty(t *testing.T) {
initBlankContext("", "OWNER/REPO", "trunk")
output, err := RunCommand("alias list")
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
eq(t, output.String(), "")
}
func TestAliasList(t *testing.T) {
cfg := `---
aliases:
co: pr checkout
il: issue list --author=$1 --label=$2
clone: repo clone
prs: pr status
cs: config set editor 'quoted path'
`
initBlankContext(cfg, "OWNER/REPO", "trunk")
output, err := RunCommand("alias list")
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
expected := `clone repo clone
co pr checkout
cs config set editor 'quoted path'
il issue list --author=$1 --label=$2
prs pr status
`
eq(t, output.String(), expected)
}
func TestAliasDelete_nonexistent_command(t *testing.T) {
cfg := `---
aliases:
co: pr checkout
il: issue list --author="$1" --label="$2"
ia: issue list --author="$1" --assignee="$1"
`
initBlankContext(cfg, "OWNER/REPO", "trunk")
_, err := RunCommand("alias delete cool")
if err == nil {
t.Fatalf("expected error")
}
eq(t, err.Error(), "no such alias cool")
}
func TestAliasDelete(t *testing.T) {
cfg := `---
aliases:
co: pr checkout
il: issue list --author="$1" --label="$2"
ia: issue list --author="$1" --assignee="$1"
`
initBlankContext(cfg, "OWNER/REPO", "trunk")
mainBuf := bytes.Buffer{}
hostsBuf := bytes.Buffer{}
defer config.StubWriteConfig(&mainBuf, &hostsBuf)()
output, err := RunCommand("alias delete co")
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
test.ExpectLines(t, output.String(), "Deleted alias co; was pr checkout")
expected := `aliases:
il: issue list --author="$1" --label="$2"
ia: issue list --author="$1" --assignee="$1"
`
eq(t, mainBuf.String(), expected)
}

View file

@ -28,7 +28,7 @@ For example, for bash you could add this to your '~/.bash_profile':
When installing GitHub CLI through a package manager, however, it's possible that When installing GitHub CLI through a package manager, however, it's possible that
no additional shell configuration is necessary to gain completion support. For no additional shell configuration is necessary to gain completion support. For
Homebrew, see <https://docs.brew.sh/Shell-Completion> Homebrew, see https://docs.brew.sh/Shell-Completion
`, `,
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
shellType, err := cmd.Flags().GetString("shell") shellType, err := cmd.Flags().GetString("shell")

View file

@ -41,19 +41,22 @@ func init() {
} }
var creditsCmd = &cobra.Command{ var creditsCmd = &cobra.Command{
Use: "credits [repository]", Use: "credits",
Short: "View project's credits", Short: "View credits for this tool",
Long: `View animated credits for this or another project. Long: `View animated credits for gh, the tool you are currently using :)`,
Example: `gh credits # see a credits animation for this project
Examples:
gh credits # see a credits animation for this project
gh credits owner/repo # see a credits animation for owner/repo gh credits owner/repo # see a credits animation for owner/repo
gh credits -s # display a non-animated thank you gh credits -s # display a non-animated thank you
gh credits | cat # just print the contributors, one per line gh credits | cat # just print the contributors, one per line
`, `,
Args: cobra.MaximumNArgs(1), Args: cobra.ExactArgs(0),
RunE: credits, RunE: ghCredits,
Hidden: true,
}
func ghCredits(cmd *cobra.Command, _ []string) error {
args := []string{"cli/cli"}
return credits(cmd, args)
} }
func credits(cmd *cobra.Command, args []string) error { func credits(cmd *cobra.Command, args []string) error {
@ -64,9 +67,18 @@ func credits(cmd *cobra.Command, args []string) error {
return err return err
} }
owner := "cli" var owner string
repo := "cli" var repo string
if len(args) > 0 {
if len(args) == 0 {
baseRepo, err := determineBaseRepo(client, cmd, ctx)
if err != nil {
return err
}
owner = baseRepo.RepoOwner()
repo = baseRepo.RepoName()
} else {
parts := strings.SplitN(args[0], "/", 2) parts := strings.SplitN(args[0], "/", 2)
owner = parts[0] owner = parts[0]
repo = parts[1] repo = parts[1]

View file

@ -355,11 +355,11 @@ func issueCreate(cmd *cobra.Command, args []string) error {
return err return err
} }
var templateFiles []string var nonLegacyTemplateFiles []string
if baseOverride == "" { if baseOverride == "" {
if rootDir, err := git.ToplevelDir(); err == nil { if rootDir, err := git.ToplevelDir(); err == nil {
// TODO: figure out how to stub this in tests // TODO: figure out how to stub this in tests
templateFiles = githubtemplate.Find(rootDir, "ISSUE_TEMPLATE") nonLegacyTemplateFiles = githubtemplate.FindNonLegacy(rootDir, "ISSUE_TEMPLATE")
} }
} }
@ -395,12 +395,15 @@ func issueCreate(cmd *cobra.Command, args []string) error {
// TODO: move URL generation into GitHubRepository // TODO: move URL generation into GitHubRepository
openURL := fmt.Sprintf("https://github.com/%s/issues/new", ghrepo.FullName(baseRepo)) openURL := fmt.Sprintf("https://github.com/%s/issues/new", ghrepo.FullName(baseRepo))
if title != "" || body != "" { if title != "" || body != "" {
openURL += fmt.Sprintf( milestone := ""
"?title=%s&body=%s", if len(milestoneTitles) > 0 {
url.QueryEscape(title), milestone = milestoneTitles[0]
url.QueryEscape(body), }
) openURL, err = withPrAndIssueQueryParams(openURL, title, body, assignees, labelNames, projectNames, milestone)
} else if len(templateFiles) > 1 { if err != nil {
return err
}
} else if len(nonLegacyTemplateFiles) > 1 {
openURL += "/choose" openURL += "/choose"
} }
cmd.Printf("Opening %s in your browser.\n", displayURL(openURL)) cmd.Printf("Opening %s in your browser.\n", displayURL(openURL))
@ -419,6 +422,7 @@ func issueCreate(cmd *cobra.Command, args []string) error {
action := SubmitAction action := SubmitAction
tb := issueMetadataState{ tb := issueMetadataState{
Type: issueMetadata,
Assignees: assignees, Assignees: assignees,
Labels: labelNames, Labels: labelNames,
Projects: projectNames, Projects: projectNames,
@ -428,7 +432,14 @@ func issueCreate(cmd *cobra.Command, args []string) error {
interactive := !(cmd.Flags().Changed("title") && cmd.Flags().Changed("body")) interactive := !(cmd.Flags().Changed("title") && cmd.Flags().Changed("body"))
if interactive { if interactive {
err := titleBodySurvey(cmd, &tb, apiClient, baseRepo, title, body, defaults{}, templateFiles, false, repo.ViewerCanTriage()) var legacyTemplateFile *string
if baseOverride == "" {
if rootDir, err := git.ToplevelDir(); err == nil {
// TODO: figure out how to stub this in tests
legacyTemplateFile = githubtemplate.FindLegacy(rootDir, "ISSUE_TEMPLATE")
}
}
err := titleBodySurvey(cmd, &tb, apiClient, baseRepo, title, body, defaults{}, nonLegacyTemplateFiles, legacyTemplateFile, false, repo.ViewerCanTriage())
if err != nil { if err != nil {
return fmt.Errorf("could not collect title and/or body: %w", err) return fmt.Errorf("could not collect title and/or body: %w", err)
} }
@ -454,12 +465,15 @@ func issueCreate(cmd *cobra.Command, args []string) error {
} }
if action == PreviewAction { if action == PreviewAction {
openURL := fmt.Sprintf( openURL := fmt.Sprintf("https://github.com/%s/issues/new/", ghrepo.FullName(baseRepo))
"https://github.com/%s/issues/new/?title=%s&body=%s", milestone := ""
ghrepo.FullName(baseRepo), if len(milestoneTitles) > 0 {
url.QueryEscape(title), milestone = milestoneTitles[0]
url.QueryEscape(body), }
) openURL, err = withPrAndIssueQueryParams(openURL, title, body, assignees, labelNames, projectNames, milestone)
if err != nil {
return err
}
// TODO could exceed max url length for explorer // TODO could exceed max url length for explorer
fmt.Fprintf(cmd.ErrOrStderr(), "Opening %s in your browser.\n", displayURL(openURL)) fmt.Fprintf(cmd.ErrOrStderr(), "Opening %s in your browser.\n", displayURL(openURL))
return utils.OpenInBrowser(openURL) return utils.OpenInBrowser(openURL)

View file

@ -646,7 +646,7 @@ func TestIssueCreate_webTitleBody(t *testing.T) {
t.Fatal("expected a command to run") t.Fatal("expected a command to run")
} }
url := strings.ReplaceAll(seenCmd.Args[len(seenCmd.Args)-1], "^", "") url := strings.ReplaceAll(seenCmd.Args[len(seenCmd.Args)-1], "^", "")
eq(t, url, "https://github.com/OWNER/REPO/issues/new?title=mytitle&body=mybody") eq(t, url, "https://github.com/OWNER/REPO/issues/new?body=mybody&title=mytitle")
eq(t, output.String(), "Opening github.com/OWNER/REPO/issues/new in your browser.\n") eq(t, output.String(), "Opening github.com/OWNER/REPO/issues/new in your browser.\n")
} }

View file

@ -40,8 +40,8 @@ func computeDefaults(baseRef, headRef string) (defaults, error) {
out.Title = utils.Humanize(headRef) out.Title = utils.Humanize(headRef)
body := "" body := ""
for _, c := range commits { for i := len(commits) - 1; i >= 0; i-- {
body += fmt.Sprintf("- %s\n", c.Title) body += fmt.Sprintf("- %s\n", commits[i].Title)
} }
out.Body = body out.Body = body
} }
@ -203,6 +203,7 @@ func prCreate(cmd *cobra.Command, _ []string) error {
} }
tb := issueMetadataState{ tb := issueMetadataState{
Type: prMetadata,
Reviewers: reviewers, Reviewers: reviewers,
Assignees: assignees, Assignees: assignees,
Labels: labelNames, Labels: labelNames,
@ -213,13 +214,14 @@ func prCreate(cmd *cobra.Command, _ []string) error {
interactive := !(cmd.Flags().Changed("title") && cmd.Flags().Changed("body")) interactive := !(cmd.Flags().Changed("title") && cmd.Flags().Changed("body"))
if !isWeb && !autofill && interactive { if !isWeb && !autofill && interactive {
var templateFiles []string var nonLegacyTemplateFiles []string
var legacyTemplateFile *string
if rootDir, err := git.ToplevelDir(); err == nil { if rootDir, err := git.ToplevelDir(); err == nil {
// TODO: figure out how to stub this in tests // TODO: figure out how to stub this in tests
templateFiles = githubtemplate.Find(rootDir, "PULL_REQUEST_TEMPLATE") nonLegacyTemplateFiles = githubtemplate.FindNonLegacy(rootDir, "PULL_REQUEST_TEMPLATE")
legacyTemplateFile = githubtemplate.FindLegacy(rootDir, "PULL_REQUEST_TEMPLATE")
} }
err := titleBodySurvey(cmd, &tb, client, baseRepo, title, body, defs, nonLegacyTemplateFiles, legacyTemplateFile, true, baseRepo.ViewerCanTriage())
err := titleBodySurvey(cmd, &tb, client, baseRepo, title, body, defs, templateFiles, true, baseRepo.ViewerCanTriage())
if err != nil { if err != nil {
return fmt.Errorf("could not collect title and/or body: %w", err) return fmt.Errorf("could not collect title and/or body: %w", err)
} }
@ -250,6 +252,9 @@ func prCreate(cmd *cobra.Command, _ []string) error {
if isDraft && isWeb { if isDraft && isWeb {
return errors.New("the --draft flag is not supported with --web") return errors.New("the --draft flag is not supported with --web")
} }
if len(reviewers) > 0 && isWeb {
return errors.New("the --reviewer flag is not supported with --web")
}
didForkRepo := false didForkRepo := false
// if a head repository could not be determined so far, automatically create // if a head repository could not be determined so far, automatically create
@ -337,7 +342,14 @@ func prCreate(cmd *cobra.Command, _ []string) error {
fmt.Fprintln(cmd.OutOrStdout(), pr.URL) fmt.Fprintln(cmd.OutOrStdout(), pr.URL)
} else if action == PreviewAction { } else if action == PreviewAction {
openURL := generateCompareURL(baseRepo, baseBranch, headBranchLabel, title, body) milestone := ""
if len(milestoneTitles) > 0 {
milestone = milestoneTitles[0]
}
openURL, err := generateCompareURL(baseRepo, baseBranch, headBranchLabel, title, body, assignees, labelNames, projectNames, milestone)
if err != nil {
return err
}
// TODO could exceed max url length for explorer // TODO could exceed max url length for explorer
fmt.Fprintf(cmd.ErrOrStderr(), "Opening %s in your browser.\n", displayURL(openURL)) fmt.Fprintf(cmd.ErrOrStderr(), "Opening %s in your browser.\n", displayURL(openURL))
return utils.OpenInBrowser(openURL) return utils.OpenInBrowser(openURL)
@ -389,20 +401,46 @@ func determineTrackingBranch(remotes context.Remotes, headBranch string) *git.Tr
return nil return nil
} }
func generateCompareURL(r ghrepo.Interface, base, head, title, body string) string { func withPrAndIssueQueryParams(baseURL, title, body string, assignees, labels, projects []string, milestone string) (string, error) {
u, err := url.Parse(baseURL)
if err != nil {
return "", nil
}
q := u.Query()
if title != "" {
q.Set("title", title)
}
if body != "" {
q.Set("body", body)
}
if len(assignees) > 0 {
q.Set("assignees", strings.Join(assignees, ","))
}
if len(labels) > 0 {
q.Set("labels", strings.Join(labels, ","))
}
if len(projects) > 0 {
q.Set("projects", strings.Join(projects, ","))
}
if milestone != "" {
q.Set("milestone", milestone)
}
u.RawQuery = q.Encode()
return u.String(), nil
}
func generateCompareURL(r ghrepo.Interface, base, head, title, body string, assignees, labels, projects []string, milestone string) (string, error) {
u := fmt.Sprintf( u := fmt.Sprintf(
"https://github.com/%s/compare/%s...%s?expand=1", "https://github.com/%s/compare/%s...%s?expand=1",
ghrepo.FullName(r), ghrepo.FullName(r),
base, base,
head, head,
) )
if title != "" { url, err := withPrAndIssueQueryParams(u, title, body, assignees, labels, projects, milestone)
u += "&title=" + url.QueryEscape(title) if err != nil {
return "", err
} }
if body != "" { return url, nil
u += "&body=" + url.QueryEscape(body)
}
return u
} }
var prCreateCmd = &cobra.Command{ var prCreateCmd = &cobra.Command{

View file

@ -519,7 +519,7 @@ func TestPRCreate_survey_defaults_multicommit(t *testing.T) {
}{} }{}
_ = json.Unmarshal(bodyBytes, &reqBody) _ = json.Unmarshal(bodyBytes, &reqBody)
expectedBody := "- commit 0\n- commit 1\n" expectedBody := "- commit 1\n- commit 0\n"
eq(t, reqBody.Variables.Input.RepositoryID, "REPOID") eq(t, reqBody.Variables.Input.RepositoryID, "REPOID")
eq(t, reqBody.Variables.Input.Title, "cool bug fixes") eq(t, reqBody.Variables.Input.Title, "cool bug fixes")

View file

@ -38,6 +38,9 @@ func init() {
repoCmd.AddCommand(repoViewCmd) repoCmd.AddCommand(repoViewCmd)
repoViewCmd.Flags().BoolP("web", "w", false, "Open a repository in the browser") repoViewCmd.Flags().BoolP("web", "w", false, "Open a repository in the browser")
repoCmd.AddCommand(repoCreditsCmd)
repoCreditsCmd.Flags().BoolP("static", "s", false, "Print a static version of the credits")
} }
var repoCmd = &cobra.Command{ var repoCmd = &cobra.Command{
@ -62,6 +65,9 @@ var repoCloneCmd = &cobra.Command{
Short: "Clone a repository locally", Short: "Clone a repository locally",
Long: `Clone a GitHub repository locally. Long: `Clone a GitHub repository locally.
If the "OWNER/" portion of the "OWNER/REPO" repository argument is omitted, it
defaults to the name of the authenticating user.
To pass 'git clone' flags, separate them with '--'.`, To pass 'git clone' flags, separate them with '--'.`,
RunE: repoClone, RunE: repoClone,
} }
@ -104,6 +110,19 @@ With '--web', open the repository in a web browser instead.`,
RunE: repoView, RunE: repoView,
} }
var repoCreditsCmd = &cobra.Command{
Use: "credits [<repository>]",
Short: "View credits for a repository",
Example: `$ gh repo credits # view credits for the current repository
$ gh repo credits cool/repo # view credits for cool/repo
$ gh repo credits -s # print a non-animated thank you
$ gh repo credits | cat # pipe to just print the contributors, one per line
`,
Args: cobra.MaximumNArgs(1),
RunE: repoCredits,
Hidden: true,
}
func parseCloneArgs(extraArgs []string) (args []string, target string) { func parseCloneArgs(extraArgs []string) (args []string, target string) {
args = extraArgs args = extraArgs
@ -140,8 +159,21 @@ func runClone(cloneURL string, args []string) (target string, err error) {
} }
func repoClone(cmd *cobra.Command, args []string) error { func repoClone(cmd *cobra.Command, args []string) error {
ctx := contextForCommand(cmd)
apiClient, err := apiClientForContext(ctx)
if err != nil {
return err
}
cloneURL := args[0] cloneURL := args[0]
if !strings.Contains(cloneURL, ":") { if !strings.Contains(cloneURL, ":") {
if !strings.Contains(cloneURL, "/") {
currentUser, err := api.CurrentLoginName(apiClient)
if err != nil {
return err
}
cloneURL = currentUser + "/" + cloneURL
}
cloneURL = formatRemoteURL(cmd, cloneURL) cloneURL = formatRemoteURL(cmd, cloneURL)
} }
@ -155,12 +187,6 @@ func repoClone(cmd *cobra.Command, args []string) error {
} }
if repo != nil { if repo != nil {
ctx := contextForCommand(cmd)
apiClient, err := apiClientForContext(ctx)
if err != nil {
return err
}
parentRepo, err = api.RepoParent(apiClient, repo) parentRepo, err = api.RepoParent(apiClient, repo)
if err != nil { if err != nil {
return err return err
@ -602,3 +628,7 @@ func repoView(cmd *cobra.Command, args []string) error {
return nil return nil
} }
func repoCredits(cmd *cobra.Command, args []string) error {
return credits(cmd, args)
}

View file

@ -1,7 +1,6 @@
package command package command
import ( import (
"bytes"
"encoding/json" "encoding/json"
"io/ioutil" "io/ioutil"
"os/exec" "os/exec"
@ -15,6 +14,7 @@ import (
"github.com/cli/cli/context" "github.com/cli/cli/context"
"github.com/cli/cli/internal/run" "github.com/cli/cli/internal/run"
"github.com/cli/cli/pkg/httpmock"
"github.com/cli/cli/test" "github.com/cli/cli/test"
"github.com/cli/cli/utils" "github.com/cli/cli/utils"
) )
@ -458,11 +458,13 @@ func TestRepoClone(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
http := initFakeHTTP() http := initFakeHTTP()
http.StubResponse(200, bytes.NewBufferString(` http.Register(
{ "data": { "repository": { httpmock.GraphQL(`\brepository\(`),
"parent": null httpmock.StringResponse(`
} } } { "data": { "repository": {
`)) "parent": null
} } }
`))
cs, restore := test.InitCmdStubber() cs, restore := test.InitCmdStubber()
defer restore() defer restore()
@ -484,14 +486,16 @@ func TestRepoClone(t *testing.T) {
func TestRepoClone_hasParent(t *testing.T) { func TestRepoClone_hasParent(t *testing.T) {
http := initFakeHTTP() http := initFakeHTTP()
http.StubResponse(200, bytes.NewBufferString(` http.Register(
{ "data": { "repository": { httpmock.GraphQL(`\brepository\(`),
"parent": { httpmock.StringResponse(`
"owner": {"login": "hubot"}, { "data": { "repository": {
"name": "ORIG" "parent": {
} "owner": {"login": "hubot"},
} } } "name": "ORIG"
`)) }
} } }
`))
cs, restore := test.InitCmdStubber() cs, restore := test.InitCmdStubber()
defer restore() defer restore()
@ -508,6 +512,37 @@ func TestRepoClone_hasParent(t *testing.T) {
eq(t, strings.Join(cs.Calls[1].Args, " "), "git -C REPO remote add -f upstream https://github.com/hubot/ORIG.git") eq(t, strings.Join(cs.Calls[1].Args, " "), "git -C REPO remote add -f upstream https://github.com/hubot/ORIG.git")
} }
func TestRepo_withoutUsername(t *testing.T) {
http := initFakeHTTP()
http.Register(
httpmock.GraphQL(`\bviewer\b`),
httpmock.StringResponse(`
{ "data": { "viewer": {
"login": "OWNER"
}}}`))
http.Register(
httpmock.GraphQL(`\brepository\(`),
httpmock.StringResponse(`
{ "data": { "repository": {
"parent": null
} } }`))
cs, restore := test.InitCmdStubber()
defer restore()
cs.Stub("") // git clone
output, err := RunCommand("repo clone REPO")
if err != nil {
t.Fatalf("error running command `repo clone`: %v", err)
}
eq(t, output.String(), "")
eq(t, output.Stderr(), "")
eq(t, cs.Count, 1)
eq(t, strings.Join(cs.Calls[0].Args, " "), "git clone https://github.com/OWNER/REPO.git")
}
func TestRepoCreate(t *testing.T) { func TestRepoCreate(t *testing.T) {
ctx := context.NewBlank() ctx := context.NewBlank()
ctx.SetBranch("master") ctx.SetBranch("master")
@ -516,18 +551,19 @@ func TestRepoCreate(t *testing.T) {
} }
http := initFakeHTTP() http := initFakeHTTP()
http.StubResponse(200, bytes.NewBufferString(` http.Register(
{ "data": { "createRepository": { httpmock.GraphQL(`\bcreateRepository\(`),
"repository": { httpmock.StringResponse(`
"id": "REPOID", { "data": { "createRepository": {
"url": "https://github.com/OWNER/REPO", "repository": {
"name": "REPO", "id": "REPOID",
"owner": { "url": "https://github.com/OWNER/REPO",
"login": "OWNER" "name": "REPO",
"owner": {
"login": "OWNER"
}
} }
} } } }`))
} } }
`))
var seenCmd *exec.Cmd var seenCmd *exec.Cmd
restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable { restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable {
@ -581,22 +617,24 @@ func TestRepoCreate_org(t *testing.T) {
} }
http := initFakeHTTP() http := initFakeHTTP()
http.StubResponse(200, bytes.NewBufferString(` http.Register(
{ "node_id": "ORGID" httpmock.MatchAny,
} httpmock.StringResponse(`
`)) { "node_id": "ORGID"
http.StubResponse(200, bytes.NewBufferString(` }`))
{ "data": { "createRepository": { http.Register(
"repository": { httpmock.GraphQL(`\bcreateRepository\(`),
"id": "REPOID", httpmock.StringResponse(`
"url": "https://github.com/ORG/REPO", { "data": { "createRepository": {
"name": "REPO", "repository": {
"owner": { "id": "REPOID",
"login": "ORG" "url": "https://github.com/ORG/REPO",
"name": "REPO",
"owner": {
"login": "ORG"
}
} }
} } } }`))
} } }
`))
var seenCmd *exec.Cmd var seenCmd *exec.Cmd
restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable { restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable {
@ -649,23 +687,25 @@ func TestRepoCreate_orgWithTeam(t *testing.T) {
} }
http := initFakeHTTP() http := initFakeHTTP()
http.StubResponse(200, bytes.NewBufferString(` http.Register(
{ "node_id": "TEAMID", httpmock.MatchAny,
"organization": { "node_id": "ORGID" } httpmock.StringResponse(`
} { "node_id": "TEAMID",
`)) "organization": { "node_id": "ORGID" }
http.StubResponse(200, bytes.NewBufferString(` }`))
{ "data": { "createRepository": { http.Register(
"repository": { httpmock.GraphQL(`\bcreateRepository\(`),
"id": "REPOID", httpmock.StringResponse(`
"url": "https://github.com/ORG/REPO", { "data": { "createRepository": {
"name": "REPO", "repository": {
"owner": { "id": "REPOID",
"login": "ORG" "url": "https://github.com/ORG/REPO",
"name": "REPO",
"owner": {
"login": "ORG"
}
} }
} } } }`))
} } }
`))
var seenCmd *exec.Cmd var seenCmd *exec.Cmd
restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable { restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable {
@ -714,9 +754,10 @@ func TestRepoView_web(t *testing.T) {
initBlankContext("", "OWNER/REPO", "master") initBlankContext("", "OWNER/REPO", "master")
http := initFakeHTTP() http := initFakeHTTP()
http.StubRepoResponse("OWNER", "REPO") http.StubRepoResponse("OWNER", "REPO")
http.StubResponse(200, bytes.NewBufferString(` http.Register(
{ } httpmock.MatchAny,
`)) httpmock.StringResponse(`
{ }`))
var seenCmd *exec.Cmd var seenCmd *exec.Cmd
restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable { restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable {
@ -747,9 +788,10 @@ func TestRepoView_web_ownerRepo(t *testing.T) {
return ctx return ctx
} }
http := initFakeHTTP() http := initFakeHTTP()
http.StubResponse(200, bytes.NewBufferString(` http.Register(
{ } httpmock.MatchAny,
`)) httpmock.StringResponse(`
{ }`))
var seenCmd *exec.Cmd var seenCmd *exec.Cmd
restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable { restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable {
@ -780,9 +822,10 @@ func TestRepoView_web_fullURL(t *testing.T) {
return ctx return ctx
} }
http := initFakeHTTP() http := initFakeHTTP()
http.StubResponse(200, bytes.NewBufferString(` http.Register(
{ } httpmock.MatchAny,
`)) httpmock.StringResponse(`
{ }`))
var seenCmd *exec.Cmd var seenCmd *exec.Cmd
restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable { restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable {
seenCmd = cmd seenCmd = cmd
@ -809,16 +852,18 @@ func TestRepoView(t *testing.T) {
initBlankContext("", "OWNER/REPO", "master") initBlankContext("", "OWNER/REPO", "master")
http := initFakeHTTP() http := initFakeHTTP()
http.StubRepoResponse("OWNER", "REPO") http.StubRepoResponse("OWNER", "REPO")
http.StubResponse(200, bytes.NewBufferString(` http.Register(
httpmock.GraphQL(`\brepository\(`),
httpmock.StringResponse(`
{ "data": { { "data": {
"repository": { "repository": {
"description": "social distancing" "description": "social distancing"
}}} } } }`))
`)) http.Register(
http.StubResponse(200, bytes.NewBufferString(` httpmock.MatchAny,
httpmock.StringResponse(`
{ "name": "readme.md", { "name": "readme.md",
"content": "IyB0cnVseSBjb29sIHJlYWRtZSBjaGVjayBpdCBvdXQ="} "content": "IyB0cnVseSBjb29sIHJlYWRtZSBjaGVjayBpdCBvdXQ="}`))
`))
output, err := RunCommand("repo view") output, err := RunCommand("repo view")
if err != nil { if err != nil {
@ -837,16 +882,18 @@ func TestRepoView_nonmarkdown_readme(t *testing.T) {
initBlankContext("", "OWNER/REPO", "master") initBlankContext("", "OWNER/REPO", "master")
http := initFakeHTTP() http := initFakeHTTP()
http.StubRepoResponse("OWNER", "REPO") http.StubRepoResponse("OWNER", "REPO")
http.StubResponse(200, bytes.NewBufferString(` http.Register(
httpmock.GraphQL(`\brepository\(`),
httpmock.StringResponse(`
{ "data": { { "data": {
"repository": { "repository": {
"description": "social distancing" "description": "social distancing"
}}} } } }`))
`)) http.Register(
http.StubResponse(200, bytes.NewBufferString(` httpmock.MatchAny,
httpmock.StringResponse(`
{ "name": "readme.org", { "name": "readme.org",
"content": "IyB0cnVseSBjb29sIHJlYWRtZSBjaGVjayBpdCBvdXQ="} "content": "IyB0cnVseSBjb29sIHJlYWRtZSBjaGVjayBpdCBvdXQ="}`))
`))
output, err := RunCommand("repo view") output, err := RunCommand("repo view")
if err != nil { if err != nil {
@ -864,8 +911,8 @@ func TestRepoView_blanks(t *testing.T) {
initBlankContext("", "OWNER/REPO", "master") initBlankContext("", "OWNER/REPO", "master")
http := initFakeHTTP() http := initFakeHTTP()
http.StubRepoResponse("OWNER", "REPO") http.StubRepoResponse("OWNER", "REPO")
http.StubResponse(200, bytes.NewBufferString("{}")) http.Register(httpmock.MatchAny, httpmock.StringResponse("{}"))
http.StubResponse(200, bytes.NewBufferString("{}")) http.Register(httpmock.MatchAny, httpmock.StringResponse("{}"))
output, err := RunCommand("repo view") output, err := RunCommand("repo view")
if err != nil { if err != nil {

View file

@ -375,9 +375,9 @@ func ExpandAlias(args []string) ([]string, error) {
return empty, err return empty, err
} }
if aliases.Exists(args[1]) { expansion, ok := aliases.Get(args[1])
if ok {
extraArgs := []string{} extraArgs := []string{}
expansion := aliases.Get(args[1])
for i, a := range args[2:] { for i, a := range args[2:] {
if !strings.Contains(expansion, "$") { if !strings.Contains(expansion, "$") {
extraArgs = append(extraArgs, a) extraArgs = append(extraArgs, a)

View file

@ -13,8 +13,16 @@ import (
) )
type Action int type Action int
type metadataStateType int
const (
issueMetadata metadataStateType = iota
prMetadata
)
type issueMetadataState struct { type issueMetadataState struct {
Type metadataStateType
Body string Body string
Title string Title string
Action Action Action Action
@ -99,35 +107,46 @@ func confirmSubmission(allowPreview bool, allowMetadata bool) (Action, error) {
} }
} }
func selectTemplate(templatePaths []string) (string, error) { func selectTemplate(nonLegacyTemplatePaths []string, legacyTemplatePath *string, metadataType metadataStateType) (string, error) {
templateResponse := struct { templateResponse := struct {
Index int Index int
}{} }{}
if len(templatePaths) > 1 { templateNames := make([]string, 0, len(nonLegacyTemplatePaths))
templateNames := make([]string, 0, len(templatePaths)) for _, p := range nonLegacyTemplatePaths {
for _, p := range templatePaths { templateNames = append(templateNames, githubtemplate.ExtractName(p))
templateNames = append(templateNames, githubtemplate.ExtractName(p)) }
} if metadataType == issueMetadata {
templateNames = append(templateNames, "Open a blank issue")
selectQs := []*survey.Question{ } else if metadataType == prMetadata {
{ templateNames = append(templateNames, "Open a blank pull request")
Name: "index",
Prompt: &survey.Select{
Message: "Choose a template",
Options: templateNames,
},
},
}
if err := SurveyAsk(selectQs, &templateResponse); err != nil {
return "", fmt.Errorf("could not prompt: %w", err)
}
} }
templateContents := githubtemplate.ExtractContents(templatePaths[templateResponse.Index]) selectQs := []*survey.Question{
{
Name: "index",
Prompt: &survey.Select{
Message: "Choose a template",
Options: templateNames,
},
},
}
if err := SurveyAsk(selectQs, &templateResponse); err != nil {
return "", fmt.Errorf("could not prompt: %w", err)
}
if templateResponse.Index == len(nonLegacyTemplatePaths) { // the user has selected the blank template
if legacyTemplatePath != nil {
templateContents := githubtemplate.ExtractContents(*legacyTemplatePath)
return string(templateContents), nil
} else {
return "", nil
}
}
templateContents := githubtemplate.ExtractContents(nonLegacyTemplatePaths[templateResponse.Index])
return string(templateContents), nil return string(templateContents), nil
} }
func titleBodySurvey(cmd *cobra.Command, issueState *issueMetadataState, apiClient *api.Client, repo ghrepo.Interface, providedTitle, providedBody string, defs defaults, templatePaths []string, allowReviewers, allowMetadata bool) error { func titleBodySurvey(cmd *cobra.Command, issueState *issueMetadataState, apiClient *api.Client, repo ghrepo.Interface, providedTitle, providedBody string, defs defaults, nonLegacyTemplatePaths []string, legacyTemplatePath *string, allowReviewers, allowMetadata bool) error {
editorCommand, err := determineEditor(cmd) editorCommand, err := determineEditor(cmd)
if err != nil { if err != nil {
return err return err
@ -137,13 +156,15 @@ func titleBodySurvey(cmd *cobra.Command, issueState *issueMetadataState, apiClie
templateContents := "" templateContents := ""
if providedBody == "" { if providedBody == "" {
if len(templatePaths) > 0 { if len(nonLegacyTemplatePaths) > 0 {
var err error var err error
templateContents, err = selectTemplate(templatePaths) templateContents, err = selectTemplate(nonLegacyTemplatePaths, legacyTemplatePath, issueState.Type)
if err != nil { if err != nil {
return err return err
} }
issueState.Body = templateContents issueState.Body = templateContents
} else if legacyTemplatePath != nil {
issueState.Body = string(githubtemplate.ExtractContents(*legacyTemplatePath))
} else { } else {
issueState.Body = defs.Body issueState.Body = defs.Body
} }
@ -259,6 +280,13 @@ func titleBodySurvey(cmd *cobra.Command, issueState *issueMetadataState, apiClie
milestones = append(milestones, m.Title) milestones = append(milestones, m.Title)
} }
type metadataValues struct {
Reviewers []string
Assignees []string
Labels []string
Projects []string
Milestone string
}
var mqs []*survey.Question var mqs []*survey.Question
if isChosen("Reviewers") { if isChosen("Reviewers") {
if len(users) > 0 || len(teams) > 0 { if len(users) > 0 || len(teams) > 0 {
@ -318,7 +346,7 @@ func titleBodySurvey(cmd *cobra.Command, issueState *issueMetadataState, apiClie
} }
if isChosen("Milestone") { if isChosen("Milestone") {
if len(milestones) > 1 { if len(milestones) > 1 {
var milestoneDefault interface{} var milestoneDefault string
if len(issueState.Milestones) > 0 { if len(issueState.Milestones) > 0 {
milestoneDefault = issueState.Milestones[0] milestoneDefault = issueState.Milestones[0]
} }
@ -334,11 +362,16 @@ func titleBodySurvey(cmd *cobra.Command, issueState *issueMetadataState, apiClie
cmd.PrintErrln("warning: no milestones in the repository") cmd.PrintErrln("warning: no milestones in the repository")
} }
} }
values := metadataValues{}
err = SurveyAsk(mqs, issueState, survey.WithKeepFilter(true)) err = SurveyAsk(mqs, &values, survey.WithKeepFilter(true))
if err != nil { if err != nil {
return fmt.Errorf("could not prompt: %w", err) return fmt.Errorf("could not prompt: %w", err)
} }
issueState.Reviewers = values.Reviewers
issueState.Assignees = values.Assignees
issueState.Labels = values.Labels
issueState.Projects = values.Projects
issueState.Milestones = []string{values.Milestone}
if len(issueState.Milestones) > 0 && issueState.Milestones[0] == noMilestone { if len(issueState.Milestones) > 0 && issueState.Milestones[0] == noMilestone {
issueState.Milestones = issueState.Milestones[0:0] issueState.Milestones = issueState.Milestones[0:0]

View file

@ -9,19 +9,13 @@ type AliasConfig struct {
Parent Config Parent Config
} }
func (a *AliasConfig) Exists(alias string) bool { func (a *AliasConfig) Get(alias string) (string, bool) {
if a.Empty() { if a.Empty() {
return false return "", false
} }
value, _ := a.GetStringValue(alias) value, _ := a.GetStringValue(alias)
return value != "" return value, value != ""
}
func (a *AliasConfig) Get(alias string) string {
value, _ := a.GetStringValue(alias)
return value
} }
func (a *AliasConfig) Add(alias, expansion string) error { func (a *AliasConfig) Add(alias, expansion string) error {
@ -39,6 +33,28 @@ func (a *AliasConfig) Add(alias, expansion string) error {
} }
func (a *AliasConfig) Delete(alias string) error { func (a *AliasConfig) Delete(alias string) error {
// TODO when we get to gh alias delete a.RemoveEntry(alias)
err := a.Parent.Write()
if err != nil {
return fmt.Errorf("failed to write config: %w", err)
}
return nil return nil
} }
func (a *AliasConfig) All() map[string]string {
out := map[string]string{}
if a.Empty() {
return out
}
for i := 0; i < len(a.Root.Content)-1; i += 2 {
key := a.Root.Content[i].Value
value := a.Root.Content[i+1].Value
out[key] = value
}
return out
}

View file

@ -100,6 +100,21 @@ func (cm *ConfigMap) FindEntry(key string) (ce *ConfigEntry, err error) {
return ce, &NotFoundError{errors.New("not found")} return ce, &NotFoundError{errors.New("not found")}
} }
func (cm *ConfigMap) RemoveEntry(key string) {
newContent := []*yaml.Node{}
content := cm.Root.Content
for i := 0; i < len(content); i++ {
if content[i].Value == key {
i++ // skip the next node which is this key's value
} else {
newContent = append(newContent, content[i])
}
}
cm.Root.Content = newContent
}
func NewConfig(root *yaml.Node) Config { func NewConfig(root *yaml.Node) Config {
return &fileConfig{ return &fileConfig{
ConfigMap: ConfigMap{Root: root.Content[0]}, ConfigMap: ConfigMap{Root: root.Content[0]},

View file

@ -1,16 +1,21 @@
package api package api
import ( import (
"bytes"
"encoding/json"
"fmt" "fmt"
"io" "io"
"io/ioutil" "io/ioutil"
"net/http" "net/http"
"os" "os"
"regexp"
"sort"
"strconv" "strconv"
"strings" "strings"
"github.com/cli/cli/pkg/cmdutil" "github.com/cli/cli/pkg/cmdutil"
"github.com/cli/cli/pkg/iostreams" "github.com/cli/cli/pkg/iostreams"
"github.com/cli/cli/pkg/jsoncolor"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@ -20,6 +25,7 @@ type ApiOptions struct {
RequestMethod string RequestMethod string
RequestMethodPassed bool RequestMethodPassed bool
RequestPath string RequestPath string
RequestInputFile string
MagicFields []string MagicFields []string
RawFields []string RawFields []string
RequestHeaders []string RequestHeaders []string
@ -55,6 +61,10 @@ on the format of the value:
appropriate JSON types; appropriate JSON types;
- if the value starts with "@", the rest of the value is interpreted as a - if the value starts with "@", the rest of the value is interpreted as a
filename to read the value from. Pass "-" to read from standard input. filename to read the value from. Pass "-" to read from standard input.
Raw request body may be passed from the outside via a file specified by '--input'.
Pass "-" to read from standard input. In this mode, parameters specified via
'--field' flags are serialized into URL query parameters.
`, `,
Args: cobra.ExactArgs(1), Args: cobra.ExactArgs(1),
RunE: func(c *cobra.Command, args []string) error { RunE: func(c *cobra.Command, args []string) error {
@ -73,6 +83,7 @@ on the format of the value:
cmd.Flags().StringArrayVarP(&opts.RawFields, "raw-field", "f", nil, "Add a string parameter") cmd.Flags().StringArrayVarP(&opts.RawFields, "raw-field", "f", nil, "Add a string parameter")
cmd.Flags().StringArrayVarP(&opts.RequestHeaders, "header", "H", nil, "Add an additional HTTP request header") cmd.Flags().StringArrayVarP(&opts.RequestHeaders, "header", "H", nil, "Add an additional HTTP request header")
cmd.Flags().BoolVarP(&opts.ShowResponseHeaders, "include", "i", false, "Include HTTP response headers in the output") cmd.Flags().BoolVarP(&opts.ShowResponseHeaders, "include", "i", false, "Include HTTP response headers in the output")
cmd.Flags().StringVar(&opts.RequestInputFile, "input", "", "The file to use as body for the HTTP request")
return cmd return cmd
} }
@ -83,45 +94,99 @@ func apiRun(opts *ApiOptions) error {
} }
method := opts.RequestMethod method := opts.RequestMethod
if len(params) > 0 && !opts.RequestMethodPassed { requestPath := opts.RequestPath
requestHeaders := opts.RequestHeaders
var requestBody interface{} = params
if !opts.RequestMethodPassed && (len(params) > 0 || opts.RequestInputFile != "") {
method = "POST" method = "POST"
} }
if opts.RequestInputFile != "" {
file, err := openUserFile(opts.RequestInputFile, opts.IO.In)
if err != nil {
return err
}
defer file.Close()
requestPath = addQuery(requestPath, params)
requestBody = file
}
httpClient, err := opts.HttpClient() httpClient, err := opts.HttpClient()
if err != nil { if err != nil {
return err return err
} }
resp, err := httpRequest(httpClient, method, opts.RequestPath, params, opts.RequestHeaders) resp, err := httpRequest(httpClient, method, requestPath, requestBody, requestHeaders)
if err != nil { if err != nil {
return err return err
} }
if opts.ShowResponseHeaders { if opts.ShowResponseHeaders {
for name, vals := range resp.Header { fmt.Fprintln(opts.IO.Out, resp.Proto, resp.Status)
fmt.Fprintf(opts.IO.Out, "%s: %s\r\n", name, strings.Join(vals, ", ")) printHeaders(opts.IO.Out, resp.Header, opts.IO.ColorEnabled())
}
fmt.Fprint(opts.IO.Out, "\r\n") fmt.Fprint(opts.IO.Out, "\r\n")
} }
if resp.StatusCode == 204 { if resp.StatusCode == 204 {
return nil return nil
} }
var responseBody io.Reader = resp.Body
defer resp.Body.Close() defer resp.Body.Close()
_, err = io.Copy(opts.IO.Out, resp.Body) isJSON, _ := regexp.MatchString(`[/+]json(;|$)`, resp.Header.Get("Content-Type"))
if err != nil {
return err var serverError string
if isJSON && (opts.RequestPath == "graphql" || resp.StatusCode >= 400) {
responseBody, serverError, err = parseErrorResponse(responseBody, resp.StatusCode)
if err != nil {
return err
}
} }
// TODO: detect GraphQL errors if isJSON && opts.IO.ColorEnabled() {
if resp.StatusCode > 299 { err = jsoncolor.Write(opts.IO.Out, responseBody, " ")
if err != nil {
return err
}
} else {
_, err = io.Copy(opts.IO.Out, responseBody)
if err != nil {
return err
}
}
if serverError != "" {
fmt.Fprintf(opts.IO.ErrOut, "gh: %s\n", serverError)
return cmdutil.SilentError
} else if resp.StatusCode > 299 {
fmt.Fprintf(opts.IO.ErrOut, "gh: HTTP %d\n", resp.StatusCode)
return cmdutil.SilentError return cmdutil.SilentError
} }
return nil return nil
} }
func printHeaders(w io.Writer, headers http.Header, colorize bool) {
var names []string
for name := range headers {
if name == "Status" {
continue
}
names = append(names, name)
}
sort.Strings(names)
var headerColor, headerColorReset string
if colorize {
headerColor = "\x1b[1;34m" // bright blue
headerColorReset = "\x1b[m"
}
for _, name := range names {
fmt.Fprintf(w, "%s%s%s: %s\r\n", headerColor, name, headerColorReset, strings.Join(headers[name], ", "))
}
}
func parseFields(opts *ApiOptions) (map[string]interface{}, error) { func parseFields(opts *ApiOptions) (map[string]interface{}, error) {
params := make(map[string]interface{}) params := make(map[string]interface{})
for _, f := range opts.RawFields { for _, f := range opts.RawFields {
@ -175,16 +240,48 @@ func magicFieldValue(v string, stdin io.ReadCloser) (interface{}, error) {
} }
func readUserFile(fn string, stdin io.ReadCloser) ([]byte, error) { func readUserFile(fn string, stdin io.ReadCloser) ([]byte, error) {
var r io.ReadCloser r, err := openUserFile(fn, stdin)
if fn == "-" { if err != nil {
r = stdin return nil, err
} else {
var err error
r, err = os.Open(fn)
if err != nil {
return nil, err
}
} }
defer r.Close() defer r.Close()
return ioutil.ReadAll(r) return ioutil.ReadAll(r)
} }
func openUserFile(fn string, stdin io.ReadCloser) (io.ReadCloser, error) {
if fn == "-" {
return stdin, nil
}
return os.Open(fn)
}
func parseErrorResponse(r io.Reader, statusCode int) (io.Reader, string, error) {
bodyCopy := &bytes.Buffer{}
b, err := ioutil.ReadAll(io.TeeReader(r, bodyCopy))
if err != nil {
return r, "", err
}
var parsedBody struct {
Message string
Errors []struct {
Message string
}
}
err = json.Unmarshal(b, &parsedBody)
if err != nil {
return r, "", err
}
if parsedBody.Message != "" {
return bodyCopy, fmt.Sprintf("%s (HTTP %d)", parsedBody.Message, statusCode), nil
} else if len(parsedBody.Errors) > 0 {
msgs := make([]string, len(parsedBody.Errors))
for i, e := range parsedBody.Errors {
msgs[i] = e.Message
}
return bodyCopy, strings.Join(msgs, "\n"), nil
}
return bodyCopy, "", nil
}

View file

@ -31,6 +31,7 @@ func Test_NewCmdApi(t *testing.T) {
RequestMethod: "GET", RequestMethod: "GET",
RequestMethodPassed: false, RequestMethodPassed: false,
RequestPath: "graphql", RequestPath: "graphql",
RequestInputFile: "",
RawFields: []string(nil), RawFields: []string(nil),
MagicFields: []string(nil), MagicFields: []string(nil),
RequestHeaders: []string(nil), RequestHeaders: []string(nil),
@ -45,6 +46,7 @@ func Test_NewCmdApi(t *testing.T) {
RequestMethod: "DELETE", RequestMethod: "DELETE",
RequestMethodPassed: true, RequestMethodPassed: true,
RequestPath: "repos/octocat/Spoon-Knife", RequestPath: "repos/octocat/Spoon-Knife",
RequestInputFile: "",
RawFields: []string(nil), RawFields: []string(nil),
MagicFields: []string(nil), MagicFields: []string(nil),
RequestHeaders: []string(nil), RequestHeaders: []string(nil),
@ -59,6 +61,7 @@ func Test_NewCmdApi(t *testing.T) {
RequestMethod: "GET", RequestMethod: "GET",
RequestMethodPassed: false, RequestMethodPassed: false,
RequestPath: "graphql", RequestPath: "graphql",
RequestInputFile: "",
RawFields: []string{"query=QUERY"}, RawFields: []string{"query=QUERY"},
MagicFields: []string{"body=@file.txt"}, MagicFields: []string{"body=@file.txt"},
RequestHeaders: []string(nil), RequestHeaders: []string(nil),
@ -73,6 +76,7 @@ func Test_NewCmdApi(t *testing.T) {
RequestMethod: "GET", RequestMethod: "GET",
RequestMethodPassed: false, RequestMethodPassed: false,
RequestPath: "user", RequestPath: "user",
RequestInputFile: "",
RawFields: []string(nil), RawFields: []string(nil),
MagicFields: []string(nil), MagicFields: []string(nil),
RequestHeaders: []string{"accept: text/plain"}, RequestHeaders: []string{"accept: text/plain"},
@ -80,6 +84,21 @@ func Test_NewCmdApi(t *testing.T) {
}, },
wantsErr: false, wantsErr: false,
}, },
{
name: "with request body from file",
cli: "user --input myfile",
wants: ApiOptions{
RequestMethod: "GET",
RequestMethodPassed: false,
RequestPath: "user",
RequestInputFile: "myfile",
RawFields: []string(nil),
MagicFields: []string(nil),
RequestHeaders: []string(nil),
ShowResponseHeaders: false,
},
wantsErr: false,
},
{ {
name: "no arguments", name: "no arguments",
cli: "", cli: "",
@ -92,6 +111,7 @@ func Test_NewCmdApi(t *testing.T) {
assert.Equal(t, tt.wants.RequestMethod, o.RequestMethod) assert.Equal(t, tt.wants.RequestMethod, o.RequestMethod)
assert.Equal(t, tt.wants.RequestMethodPassed, o.RequestMethodPassed) assert.Equal(t, tt.wants.RequestMethodPassed, o.RequestMethodPassed)
assert.Equal(t, tt.wants.RequestPath, o.RequestPath) assert.Equal(t, tt.wants.RequestPath, o.RequestPath)
assert.Equal(t, tt.wants.RequestInputFile, o.RequestInputFile)
assert.Equal(t, tt.wants.RawFields, o.RawFields) assert.Equal(t, tt.wants.RawFields, o.RawFields)
assert.Equal(t, tt.wants.MagicFields, o.MagicFields) assert.Equal(t, tt.wants.MagicFields, o.MagicFields)
assert.Equal(t, tt.wants.RequestHeaders, o.RequestHeaders) assert.Equal(t, tt.wants.RequestHeaders, o.RequestHeaders)
@ -140,12 +160,14 @@ func Test_apiRun(t *testing.T) {
ShowResponseHeaders: true, ShowResponseHeaders: true,
}, },
httpResponse: &http.Response{ httpResponse: &http.Response{
Proto: "HTTP/1.1",
Status: "200 Okey-dokey",
StatusCode: 200, StatusCode: 200,
Body: ioutil.NopCloser(bytes.NewBufferString(`body`)), Body: ioutil.NopCloser(bytes.NewBufferString(`body`)),
Header: http.Header{"Content-Type": []string{"text/plain"}}, Header: http.Header{"Content-Type": []string{"text/plain"}},
}, },
err: nil, err: nil,
stdout: "Content-Type: text/plain\r\n\r\nbody", stdout: "HTTP/1.1 200 Okey-dokey\nContent-Type: text/plain\r\n\r\nbody",
stderr: ``, stderr: ``,
}, },
{ {
@ -158,6 +180,31 @@ func Test_apiRun(t *testing.T) {
stdout: ``, stdout: ``,
stderr: ``, stderr: ``,
}, },
{
name: "REST error",
httpResponse: &http.Response{
StatusCode: 400,
Body: ioutil.NopCloser(bytes.NewBufferString(`{"message": "THIS IS FINE"}`)),
Header: http.Header{"Content-Type": []string{"application/json; charset=utf-8"}},
},
err: cmdutil.SilentError,
stdout: `{"message": "THIS IS FINE"}`,
stderr: "gh: THIS IS FINE (HTTP 400)\n",
},
{
name: "GraphQL error",
options: ApiOptions{
RequestPath: "graphql",
},
httpResponse: &http.Response{
StatusCode: 200,
Body: ioutil.NopCloser(bytes.NewBufferString(`{"errors": [{"message":"AGAIN"}, {"message":"FINE"}]}`)),
Header: http.Header{"Content-Type": []string{"application/json; charset=utf-8"}},
},
err: cmdutil.SilentError,
stdout: `{"errors": [{"message":"AGAIN"}, {"message":"FINE"}]}`,
stderr: "gh: AGAIN\nFINE\n",
},
{ {
name: "failure", name: "failure",
httpResponse: &http.Response{ httpResponse: &http.Response{
@ -166,7 +213,7 @@ func Test_apiRun(t *testing.T) {
}, },
err: cmdutil.SilentError, err: cmdutil.SilentError,
stdout: `gateway timeout`, stdout: `gateway timeout`,
stderr: ``, stderr: "gh: HTTP 502\n",
}, },
} }
@ -199,6 +246,44 @@ func Test_apiRun(t *testing.T) {
} }
} }
func Test_apiRun_inputFile(t *testing.T) {
io, stdin, _, _ := iostreams.Test()
resp := &http.Response{StatusCode: 204}
options := ApiOptions{
RequestPath: "hello",
RequestInputFile: "-",
RawFields: []string{"a=b", "c=d"},
IO: io,
HttpClient: func() (*http.Client, error) {
var tr roundTripper = func(req *http.Request) (*http.Response, error) {
resp.Request = req
return resp, nil
}
return &http.Client{Transport: tr}, nil
},
}
fmt.Fprintln(stdin, "I WORK OUT")
err := apiRun(&options)
if err != nil {
t.Errorf("got error %v", err)
}
assert.Equal(t, "POST", resp.Request.Method)
assert.Equal(t, "/hello?a=b&c=d", resp.Request.URL.RequestURI())
assert.Equal(t, "", resp.Request.Header.Get("Content-Length"))
assert.Equal(t, "", resp.Request.Header.Get("Content-Type"))
bb, err := ioutil.ReadAll(resp.Request.Body)
if err != nil {
t.Errorf("got error %v", err)
}
assert.Equal(t, "I WORK OUT\n", string(bb))
}
func Test_parseFields(t *testing.T) { func Test_parseFields(t *testing.T) {
io, stdin, _, _ := iostreams.Test() io, stdin, _, _ := iostreams.Test()
fmt.Fprint(stdin, "pasted contents") fmt.Fprint(stdin, "pasted contents")
@ -305,3 +390,26 @@ func Test_magicFieldValue(t *testing.T) {
}) })
} }
} }
func Test_openUserFile(t *testing.T) {
f, err := ioutil.TempFile("", "gh-test")
if err != nil {
t.Fatal(err)
}
fmt.Fprint(f, "file contents")
f.Close()
t.Cleanup(func() { os.Remove(f.Name()) })
file, err := openUserFile(f.Name(), nil)
if err != nil {
t.Fatal(err)
}
defer file.Close()
fb, err := ioutil.ReadAll(file)
if err != nil {
t.Fatal(err)
}
assert.Equal(t, "file contents", string(fb))
}

View file

@ -11,8 +11,14 @@ import (
) )
func httpRequest(client *http.Client, method string, p string, params interface{}, headers []string) (*http.Response, error) { func httpRequest(client *http.Client, method string, p string, params interface{}, headers []string) (*http.Response, error) {
var requestURL string
// TODO: GHE support // TODO: GHE support
url := "https://api.github.com/" + p if strings.Contains(p, "://") {
requestURL = p
} else {
requestURL = "https://api.github.com/" + p
}
var body io.Reader var body io.Reader
var bodyIsJSON bool var bodyIsJSON bool
isGraphQL := p == "graphql" isGraphQL := p == "graphql"
@ -20,7 +26,7 @@ func httpRequest(client *http.Client, method string, p string, params interface{
switch pp := params.(type) { switch pp := params.(type) {
case map[string]interface{}: case map[string]interface{}:
if strings.EqualFold(method, "GET") { if strings.EqualFold(method, "GET") {
url = addQuery(url, pp) requestURL = addQuery(requestURL, pp)
} else { } else {
for key, value := range pp { for key, value := range pp {
switch vv := value.(type) { switch vv := value.(type) {
@ -46,7 +52,7 @@ func httpRequest(client *http.Client, method string, p string, params interface{
return nil, fmt.Errorf("unrecognized parameters type: %v", params) return nil, fmt.Errorf("unrecognized parameters type: %v", params)
} }
req, err := http.NewRequest(method, url, body) req, err := http.NewRequest(method, requestURL, body)
if err != nil { if err != nil {
return nil, err return nil, err
} }

View file

@ -10,8 +10,8 @@ import (
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
) )
// Find returns the list of template file paths // FindNonLegacy returns the list of template file paths from the template folder (according to the "upgraded multiple template builder")
func Find(rootDir string, name string) []string { func FindNonLegacy(rootDir string, name string) []string {
results := []string{} results := []string{}
// https://help.github.com/en/github/building-a-strong-community/creating-a-pull-request-template-for-your-repository // https://help.github.com/en/github/building-a-strong-community/creating-a-pull-request-template-for-your-repository
@ -46,21 +46,34 @@ mainLoop:
break break
} }
} }
}
sort.Strings(results)
return results
}
// FindLegacy returns the file path of the default(legacy) template
func FindLegacy(rootDir string, name string) *string {
// https://help.github.com/en/github/building-a-strong-community/creating-a-pull-request-template-for-your-repository
candidateDirs := []string{
path.Join(rootDir, ".github"),
rootDir,
path.Join(rootDir, "docs"),
}
for _, dir := range candidateDirs {
files, err := ioutil.ReadDir(dir)
if err != nil {
continue
}
// detect a single template file // detect a single template file
for _, file := range files { for _, file := range files {
if strings.EqualFold(file.Name(), name+".md") { if strings.EqualFold(file.Name(), name+".md") {
results = append(results, path.Join(dir, file.Name())) result := path.Join(dir, file.Name())
break return &result
} }
} }
if len(results) > 0 {
break
}
} }
return nil
sort.Strings(results)
return results
} }
// ExtractName returns the name of the template from YAML front-matter // ExtractName returns the name of the template from YAML front-matter

View file

@ -8,7 +8,7 @@ import (
"testing" "testing"
) )
func TestFind(t *testing.T) { func TestFindNonLegacy(t *testing.T) {
tmpdir, err := ioutil.TempDir("", "gh-cli") tmpdir, err := ioutil.TempDir("", "gh-cli")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@ -25,52 +25,69 @@ func TestFind(t *testing.T) {
want []string want []string
}{ }{
{ {
name: "Template in root", name: "Legacy templates ignored",
prepare: []string{ prepare: []string{
"README.md", "README.md",
"ISSUE_TEMPLATE", "ISSUE_TEMPLATE",
"issue_template.md", "issue_template.md",
"issue_template.txt", "issue_template.txt",
"pull_request_template.md", "pull_request_template.md",
},
args: args{
rootDir: tmpdir,
name: "ISSUE_TEMPLATE",
},
want: []string{
path.Join(tmpdir, "issue_template.md"),
},
},
{
name: "Template in .github takes precedence",
prepare: []string{
"ISSUE_TEMPLATE.md",
".github/issue_template.md", ".github/issue_template.md",
},
args: args{
rootDir: tmpdir,
name: "ISSUE_TEMPLATE",
},
want: []string{
path.Join(tmpdir, ".github/issue_template.md"),
},
},
{
name: "Template in docs",
prepare: []string{
"README.md",
"docs/issue_template.md", "docs/issue_template.md",
}, },
args: args{ args: args{
rootDir: tmpdir, rootDir: tmpdir,
name: "ISSUE_TEMPLATE", name: "ISSUE_TEMPLATE",
}, },
want: []string{},
},
{
name: "Template folder in .github takes precedence",
prepare: []string{
"ISSUE_TEMPLATE.md",
"docs/ISSUE_TEMPLATE/abc.md",
"ISSUE_TEMPLATE/abc.md",
".github/ISSUE_TEMPLATE/abc.md",
},
args: args{
rootDir: tmpdir,
name: "ISSUE_TEMPLATE",
},
want: []string{ want: []string{
path.Join(tmpdir, "docs/issue_template.md"), path.Join(tmpdir, ".github/ISSUE_TEMPLATE/abc.md"),
}, },
}, },
{ {
name: "Multiple templates", name: "Template folder in root",
prepare: []string{
"ISSUE_TEMPLATE.md",
"docs/ISSUE_TEMPLATE/abc.md",
"ISSUE_TEMPLATE/abc.md",
},
args: args{
rootDir: tmpdir,
name: "ISSUE_TEMPLATE",
},
want: []string{
path.Join(tmpdir, "ISSUE_TEMPLATE/abc.md"),
},
},
{
name: "Template folder in docs",
prepare: []string{
"ISSUE_TEMPLATE.md",
"docs/ISSUE_TEMPLATE/abc.md",
},
args: args{
rootDir: tmpdir,
name: "ISSUE_TEMPLATE",
},
want: []string{
path.Join(tmpdir, "docs/ISSUE_TEMPLATE/abc.md"),
},
},
{
name: "Multiple templates in template folder",
prepare: []string{ prepare: []string{
".github/ISSUE_TEMPLATE/nope.md", ".github/ISSUE_TEMPLATE/nope.md",
".github/PULL_REQUEST_TEMPLATE.md", ".github/PULL_REQUEST_TEMPLATE.md",
@ -90,18 +107,17 @@ func TestFind(t *testing.T) {
}, },
}, },
{ {
name: "Empty multiple templates directory", name: "Empty template directories",
prepare: []string{ prepare: []string{
".github/issue_template.md", ".github/ISSUE_TEMPLATE/.keep",
".github/issue_template/.keep", ".docs/ISSUE_TEMPLATE/.keep",
"ISSUE_TEMPLATE/.keep",
}, },
args: args{ args: args{
rootDir: tmpdir, rootDir: tmpdir,
name: "ISSUE_TEMPLATE", name: "ISSUE_TEMPLATE",
}, },
want: []string{ want: []string{},
path.Join(tmpdir, ".github/issue_template.md"),
},
}, },
} }
for _, tt := range tests { for _, tt := range tests {
@ -116,7 +132,99 @@ func TestFind(t *testing.T) {
file.Close() file.Close()
} }
if got := Find(tt.args.rootDir, tt.args.name); !reflect.DeepEqual(got, tt.want) { if got := FindNonLegacy(tt.args.rootDir, tt.args.name); !reflect.DeepEqual(got, tt.want) {
t.Errorf("Find() = %v, want %v", got, tt.want)
}
})
os.RemoveAll(tmpdir)
}
}
func TestFindLegacy(t *testing.T) {
tmpdir, err := ioutil.TempDir("", "gh-cli")
if err != nil {
t.Fatal(err)
}
type args struct {
rootDir string
name string
}
tests := []struct {
name string
prepare []string
args args
want string
}{
{
name: "Template in root",
prepare: []string{
"README.md",
"ISSUE_TEMPLATE",
"issue_template.md",
"issue_template.txt",
"pull_request_template.md",
"docs/issue_template.md",
},
args: args{
rootDir: tmpdir,
name: "ISSUE_TEMPLATE",
},
want: path.Join(tmpdir, "issue_template.md"),
},
{
name: "Template in .github takes precedence",
prepare: []string{
"ISSUE_TEMPLATE.md",
".github/issue_template.md",
"docs/issue_template.md",
},
args: args{
rootDir: tmpdir,
name: "ISSUE_TEMPLATE",
},
want: path.Join(tmpdir, ".github/issue_template.md"),
},
{
name: "Template in docs",
prepare: []string{
"README.md",
"docs/issue_template.md",
},
args: args{
rootDir: tmpdir,
name: "ISSUE_TEMPLATE",
},
want: path.Join(tmpdir, "docs/issue_template.md"),
},
{
name: "Non legacy templates ignored",
prepare: []string{
".github/PULL_REQUEST_TEMPLATE/abc.md",
"PULL_REQUEST_TEMPLATE/abc.md",
"docs/PULL_REQUEST_TEMPLATE/abc.md",
".github/PULL_REQUEST_TEMPLATE.md",
},
args: args{
rootDir: tmpdir,
name: "PuLl_ReQuEsT_TeMpLaTe",
},
want: path.Join(tmpdir, ".github/PULL_REQUEST_TEMPLATE.md"),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
for _, p := range tt.prepare {
fp := path.Join(tmpdir, p)
_ = os.MkdirAll(path.Dir(fp), 0700)
file, err := os.Create(fp)
if err != nil {
t.Fatal(err)
}
file.Close()
}
if got := FindLegacy(tt.args.rootDir, tt.args.name); *got != tt.want {
t.Errorf("Find() = %v, want %v", got, tt.want) t.Errorf("Find() = %v, want %v", got, tt.want)
} }
}) })

View file

@ -5,19 +5,36 @@ import (
"io" "io"
"io/ioutil" "io/ioutil"
"os" "os"
"github.com/mattn/go-colorable"
"github.com/mattn/go-isatty"
) )
type IOStreams struct { type IOStreams struct {
In io.ReadCloser In io.ReadCloser
Out io.Writer Out io.Writer
ErrOut io.Writer ErrOut io.Writer
colorEnabled bool
}
func (s *IOStreams) ColorEnabled() bool {
return s.colorEnabled
} }
func System() *IOStreams { func System() *IOStreams {
var out io.Writer = os.Stdout
var colorEnabled bool
if os.Getenv("NO_COLOR") == "" && isTerminal(os.Stdout) {
out = colorable.NewColorable(os.Stdout)
colorEnabled = true
}
return &IOStreams{ return &IOStreams{
In: os.Stdin, In: os.Stdin,
Out: os.Stdout, Out: out,
ErrOut: os.Stderr, ErrOut: os.Stderr,
colorEnabled: colorEnabled,
} }
} }
@ -31,3 +48,7 @@ func Test() (*IOStreams, *bytes.Buffer, *bytes.Buffer, *bytes.Buffer) {
ErrOut: errOut, ErrOut: errOut,
}, in, out, errOut }, in, out, errOut
} }
func isTerminal(f *os.File) bool {
return isatty.IsTerminal(f.Fd()) || isatty.IsCygwinTerminal(f.Fd())
}

View file

@ -0,0 +1,96 @@
package jsoncolor
import (
"encoding/json"
"fmt"
"io"
"strings"
)
const (
colorDelim = "1;38" // bright white
colorKey = "1;34" // bright blue
colorNull = "1;30" // gray
colorString = "32" // green
colorBool = "33" // yellow
)
// Write colorized JSON output parsed from reader
func Write(w io.Writer, r io.Reader, indent string) error {
dec := json.NewDecoder(r)
dec.UseNumber()
var idx int
var stack []json.Delim
for {
t, err := dec.Token()
if err == io.EOF {
break
}
if err != nil {
return err
}
switch tt := t.(type) {
case json.Delim:
switch tt {
case '{', '[':
stack = append(stack, tt)
idx = 0
fmt.Fprintf(w, "\x1b[%sm%s\x1b[m", colorDelim, tt)
if dec.More() {
fmt.Fprint(w, "\n", strings.Repeat(indent, len(stack)))
}
continue
case '}', ']':
stack = stack[:len(stack)-1]
idx = 0
fmt.Fprintf(w, "\x1b[%sm%s\x1b[m", colorDelim, tt)
}
default:
b, err := json.Marshal(tt)
if err != nil {
return err
}
isKey := len(stack) > 0 && stack[len(stack)-1] == '{' && idx%2 == 0
idx++
var color string
if isKey {
color = colorKey
} else if tt == nil {
color = colorNull
} else {
switch t.(type) {
case string:
color = colorString
case bool:
color = colorBool
}
}
if color == "" {
_, _ = w.Write(b)
} else {
fmt.Fprintf(w, "\x1b[%sm%s\x1b[m", color, b)
}
if isKey {
fmt.Fprintf(w, "\x1b[%sm:\x1b[m ", colorDelim)
continue
}
}
if dec.More() {
fmt.Fprintf(w, "\x1b[%sm,\x1b[m\n%s", colorDelim, strings.Repeat(indent, len(stack)))
} else if len(stack) > 0 {
fmt.Fprint(w, "\n", strings.Repeat(indent, len(stack)-1))
} else {
fmt.Fprint(w, "\n")
}
}
return nil
}

View file

@ -0,0 +1,83 @@
package jsoncolor
import (
"bytes"
"io"
"testing"
"github.com/google/go-cmp/cmp"
)
func TestWrite(t *testing.T) {
type args struct {
r io.Reader
indent string
}
tests := []struct {
name string
args args
wantW string
wantErr bool
}{
{
name: "blank",
args: args{
r: bytes.NewBufferString(``),
indent: "",
},
wantW: "",
wantErr: false,
},
{
name: "empty object",
args: args{
r: bytes.NewBufferString(`{}`),
indent: "",
},
wantW: "\x1b[1;38m{\x1b[m\x1b[1;38m}\x1b[m\n",
wantErr: false,
},
{
name: "nested object",
args: args{
r: bytes.NewBufferString(`{"hash":{"a":1,"b":2},"array":[3,4]}`),
indent: "\t",
},
wantW: "\x1b[1;38m{\x1b[m\n\t\x1b[1;34m\"hash\"\x1b[m\x1b[1;38m:\x1b[m " +
"\x1b[1;38m{\x1b[m\n\t\t\x1b[1;34m\"a\"\x1b[m\x1b[1;38m:\x1b[m 1\x1b[1;38m,\x1b[m\n\t\t\x1b[1;34m\"b\"\x1b[m\x1b[1;38m:\x1b[m 2\n\t\x1b[1;38m}\x1b[m\x1b[1;38m,\x1b[m" +
"\n\t\x1b[1;34m\"array\"\x1b[m\x1b[1;38m:\x1b[m \x1b[1;38m[\x1b[m\n\t\t3\x1b[1;38m,\x1b[m\n\t\t4\n\t\x1b[1;38m]\x1b[m\n\x1b[1;38m}\x1b[m\n",
wantErr: false,
},
{
name: "string",
args: args{
r: bytes.NewBufferString(`"foo"`),
indent: "",
},
wantW: "\x1b[32m\"foo\"\x1b[m\n",
wantErr: false,
},
{
name: "error",
args: args{
r: bytes.NewBufferString(`{{`),
indent: "",
},
wantW: "\x1b[1;38m{\x1b[m\n",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
w := &bytes.Buffer{}
if err := Write(w, tt.args.r, tt.args.indent); (err != nil) != tt.wantErr {
t.Errorf("Write() error = %v, wantErr %v", err, tt.wantErr)
return
}
diff := cmp.Diff(tt.wantW, w.String())
if diff != "" {
t.Error(diff)
}
})
}
}