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
https://github.com/cli/cli/blob/trunk/.github/CONTRIBUTING.md

View file

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

View file

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

View file

@ -12,7 +12,7 @@ jobs:
- name: Checkout
uses: actions/checkout@v2
- name: Set up Go 1.14
uses: actions/setup-go@v2-beta
uses: actions/setup-go@v2
with:
go-version: 1.14
- 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
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

View file

@ -35,7 +35,11 @@ func NewClient(opts ...ClientOption) *Client {
func AddHeader(name, value string) ClientOption {
return func(tr http.RoundTripper) http.RoundTripper {
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)
}}
}
@ -45,7 +49,11 @@ func AddHeader(name, value string) ClientOption {
func AddHeaderFunc(name string, value func() string) ClientOption {
return func(tr http.RoundTripper) http.RoundTripper {
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)
}}
}

View file

@ -2,6 +2,7 @@ package command
import (
"fmt"
"sort"
"strings"
"github.com/cli/cli/utils"
@ -12,6 +13,8 @@ import (
func init() {
RootCmd.AddCommand(aliasCmd)
aliasCmd.AddCommand(aliasSetCmd)
aliasCmd.AddCommand(aliasListCmd)
aliasCmd.AddCommand(aliasDeleteCmd)
}
var aliasCmd = &cobra.Command{
@ -21,10 +24,8 @@ var aliasCmd = &cobra.Command{
var aliasSetCmd = &cobra.Command{
Use: "set <alias> <expansion>",
// NB: Even when inside of a single-quoted string, cobra was noticing and parsing any flags
// used in an alias expansion string argument. Since this command needs no flags, I disabled their
// 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.
// NB: this allows a user to eschew quotes when specifiying an alias expansion. We'll have to
// revisit it if we ever want to add flags to alias set but we have no current plans for that.
DisableFlagParsing: true,
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.`,
@ -72,11 +73,12 @@ func aliasSet(cmd *cobra.Command, args []string) error {
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",
utils.Green("✓"),
utils.Bold(alias),
utils.Bold(aliasCfg.Get(alias)),
utils.Bold(oldExpansion),
utils.Bold(expansionStr),
)
}
@ -112,3 +114,95 @@ func processArgs(args []string) []string {
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) {
cfg := `---
hosts:
github.com:
user: OWNER
oauth_token: token123
aliases:
co: pr checkout
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")
}
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
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 {
shellType, err := cmd.Flags().GetString("shell")

View file

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

View file

@ -355,11 +355,11 @@ func issueCreate(cmd *cobra.Command, args []string) error {
return err
}
var templateFiles []string
var nonLegacyTemplateFiles []string
if baseOverride == "" {
if rootDir, err := git.ToplevelDir(); err == nil {
// 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
openURL := fmt.Sprintf("https://github.com/%s/issues/new", ghrepo.FullName(baseRepo))
if title != "" || body != "" {
openURL += fmt.Sprintf(
"?title=%s&body=%s",
url.QueryEscape(title),
url.QueryEscape(body),
)
} else if len(templateFiles) > 1 {
milestone := ""
if len(milestoneTitles) > 0 {
milestone = milestoneTitles[0]
}
openURL, err = withPrAndIssueQueryParams(openURL, title, body, assignees, labelNames, projectNames, milestone)
if err != nil {
return err
}
} else if len(nonLegacyTemplateFiles) > 1 {
openURL += "/choose"
}
cmd.Printf("Opening %s in your browser.\n", displayURL(openURL))
@ -419,6 +422,7 @@ func issueCreate(cmd *cobra.Command, args []string) error {
action := SubmitAction
tb := issueMetadataState{
Type: issueMetadata,
Assignees: assignees,
Labels: labelNames,
Projects: projectNames,
@ -428,7 +432,14 @@ func issueCreate(cmd *cobra.Command, args []string) error {
interactive := !(cmd.Flags().Changed("title") && cmd.Flags().Changed("body"))
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 {
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 {
openURL := fmt.Sprintf(
"https://github.com/%s/issues/new/?title=%s&body=%s",
ghrepo.FullName(baseRepo),
url.QueryEscape(title),
url.QueryEscape(body),
)
openURL := fmt.Sprintf("https://github.com/%s/issues/new/", ghrepo.FullName(baseRepo))
milestone := ""
if len(milestoneTitles) > 0 {
milestone = milestoneTitles[0]
}
openURL, err = withPrAndIssueQueryParams(openURL, title, body, assignees, labelNames, projectNames, milestone)
if err != nil {
return err
}
// TODO could exceed max url length for explorer
fmt.Fprintf(cmd.ErrOrStderr(), "Opening %s in your browser.\n", displayURL(openURL))
return utils.OpenInBrowser(openURL)

View file

@ -646,7 +646,7 @@ func TestIssueCreate_webTitleBody(t *testing.T) {
t.Fatal("expected a command to run")
}
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")
}

View file

@ -40,8 +40,8 @@ func computeDefaults(baseRef, headRef string) (defaults, error) {
out.Title = utils.Humanize(headRef)
body := ""
for _, c := range commits {
body += fmt.Sprintf("- %s\n", c.Title)
for i := len(commits) - 1; i >= 0; i-- {
body += fmt.Sprintf("- %s\n", commits[i].Title)
}
out.Body = body
}
@ -203,6 +203,7 @@ func prCreate(cmd *cobra.Command, _ []string) error {
}
tb := issueMetadataState{
Type: prMetadata,
Reviewers: reviewers,
Assignees: assignees,
Labels: labelNames,
@ -213,13 +214,14 @@ func prCreate(cmd *cobra.Command, _ []string) error {
interactive := !(cmd.Flags().Changed("title") && cmd.Flags().Changed("body"))
if !isWeb && !autofill && interactive {
var templateFiles []string
var nonLegacyTemplateFiles []string
var legacyTemplateFile *string
if rootDir, err := git.ToplevelDir(); err == nil {
// 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, templateFiles, true, baseRepo.ViewerCanTriage())
err := titleBodySurvey(cmd, &tb, client, baseRepo, title, body, defs, nonLegacyTemplateFiles, legacyTemplateFile, true, baseRepo.ViewerCanTriage())
if err != nil {
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 {
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
// 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)
} 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
fmt.Fprintf(cmd.ErrOrStderr(), "Opening %s in your browser.\n", displayURL(openURL))
return utils.OpenInBrowser(openURL)
@ -389,20 +401,46 @@ func determineTrackingBranch(remotes context.Remotes, headBranch string) *git.Tr
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(
"https://github.com/%s/compare/%s...%s?expand=1",
ghrepo.FullName(r),
base,
head,
)
if title != "" {
u += "&title=" + url.QueryEscape(title)
url, err := withPrAndIssueQueryParams(u, title, body, assignees, labels, projects, milestone)
if err != nil {
return "", err
}
if body != "" {
u += "&body=" + url.QueryEscape(body)
}
return u
return url, nil
}
var prCreateCmd = &cobra.Command{

View file

@ -519,7 +519,7 @@ func TestPRCreate_survey_defaults_multicommit(t *testing.T) {
}{}
_ = 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.Title, "cool bug fixes")

View file

@ -38,6 +38,9 @@ func init() {
repoCmd.AddCommand(repoViewCmd)
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{
@ -62,6 +65,9 @@ var repoCloneCmd = &cobra.Command{
Short: "Clone a 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 '--'.`,
RunE: repoClone,
}
@ -104,6 +110,19 @@ With '--web', open the repository in a web browser instead.`,
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) {
args = extraArgs
@ -140,8 +159,21 @@ func runClone(cloneURL string, args []string) (target string, err error) {
}
func repoClone(cmd *cobra.Command, args []string) error {
ctx := contextForCommand(cmd)
apiClient, err := apiClientForContext(ctx)
if err != nil {
return err
}
cloneURL := args[0]
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)
}
@ -155,12 +187,6 @@ func repoClone(cmd *cobra.Command, args []string) error {
}
if repo != nil {
ctx := contextForCommand(cmd)
apiClient, err := apiClientForContext(ctx)
if err != nil {
return err
}
parentRepo, err = api.RepoParent(apiClient, repo)
if err != nil {
return err
@ -602,3 +628,7 @@ func repoView(cmd *cobra.Command, args []string) error {
return nil
}
func repoCredits(cmd *cobra.Command, args []string) error {
return credits(cmd, args)
}

View file

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

View file

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

View file

@ -13,8 +13,16 @@ import (
)
type Action int
type metadataStateType int
const (
issueMetadata metadataStateType = iota
prMetadata
)
type issueMetadataState struct {
Type metadataStateType
Body string
Title string
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 {
Index int
}{}
if len(templatePaths) > 1 {
templateNames := make([]string, 0, len(templatePaths))
for _, p := range templatePaths {
templateNames = append(templateNames, githubtemplate.ExtractName(p))
}
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)
}
templateNames := make([]string, 0, len(nonLegacyTemplatePaths))
for _, p := range nonLegacyTemplatePaths {
templateNames = append(templateNames, githubtemplate.ExtractName(p))
}
if metadataType == issueMetadata {
templateNames = append(templateNames, "Open a blank issue")
} else if metadataType == prMetadata {
templateNames = append(templateNames, "Open a blank pull request")
}
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
}
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)
if err != nil {
return err
@ -137,13 +156,15 @@ func titleBodySurvey(cmd *cobra.Command, issueState *issueMetadataState, apiClie
templateContents := ""
if providedBody == "" {
if len(templatePaths) > 0 {
if len(nonLegacyTemplatePaths) > 0 {
var err error
templateContents, err = selectTemplate(templatePaths)
templateContents, err = selectTemplate(nonLegacyTemplatePaths, legacyTemplatePath, issueState.Type)
if err != nil {
return err
}
issueState.Body = templateContents
} else if legacyTemplatePath != nil {
issueState.Body = string(githubtemplate.ExtractContents(*legacyTemplatePath))
} else {
issueState.Body = defs.Body
}
@ -259,6 +280,13 @@ func titleBodySurvey(cmd *cobra.Command, issueState *issueMetadataState, apiClie
milestones = append(milestones, m.Title)
}
type metadataValues struct {
Reviewers []string
Assignees []string
Labels []string
Projects []string
Milestone string
}
var mqs []*survey.Question
if isChosen("Reviewers") {
if len(users) > 0 || len(teams) > 0 {
@ -318,7 +346,7 @@ func titleBodySurvey(cmd *cobra.Command, issueState *issueMetadataState, apiClie
}
if isChosen("Milestone") {
if len(milestones) > 1 {
var milestoneDefault interface{}
var milestoneDefault string
if len(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")
}
}
err = SurveyAsk(mqs, issueState, survey.WithKeepFilter(true))
values := metadataValues{}
err = SurveyAsk(mqs, &values, survey.WithKeepFilter(true))
if err != nil {
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 {
issueState.Milestones = issueState.Milestones[0:0]

View file

@ -9,19 +9,13 @@ type AliasConfig struct {
Parent Config
}
func (a *AliasConfig) Exists(alias string) bool {
func (a *AliasConfig) Get(alias string) (string, bool) {
if a.Empty() {
return false
return "", false
}
value, _ := a.GetStringValue(alias)
return value != ""
}
func (a *AliasConfig) Get(alias string) string {
value, _ := a.GetStringValue(alias)
return value
return value, value != ""
}
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 {
// 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
}
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")}
}
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 {
return &fileConfig{
ConfigMap: ConfigMap{Root: root.Content[0]},

View file

@ -1,16 +1,21 @@
package api
import (
"bytes"
"encoding/json"
"fmt"
"io"
"io/ioutil"
"net/http"
"os"
"regexp"
"sort"
"strconv"
"strings"
"github.com/cli/cli/pkg/cmdutil"
"github.com/cli/cli/pkg/iostreams"
"github.com/cli/cli/pkg/jsoncolor"
"github.com/spf13/cobra"
)
@ -20,6 +25,7 @@ type ApiOptions struct {
RequestMethod string
RequestMethodPassed bool
RequestPath string
RequestInputFile string
MagicFields []string
RawFields []string
RequestHeaders []string
@ -55,6 +61,10 @@ on the format of the value:
appropriate JSON types;
- 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.
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),
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.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().StringVar(&opts.RequestInputFile, "input", "", "The file to use as body for the HTTP request")
return cmd
}
@ -83,45 +94,99 @@ func apiRun(opts *ApiOptions) error {
}
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"
}
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()
if err != nil {
return err
}
resp, err := httpRequest(httpClient, method, opts.RequestPath, params, opts.RequestHeaders)
resp, err := httpRequest(httpClient, method, requestPath, requestBody, requestHeaders)
if err != nil {
return err
}
if opts.ShowResponseHeaders {
for name, vals := range resp.Header {
fmt.Fprintf(opts.IO.Out, "%s: %s\r\n", name, strings.Join(vals, ", "))
}
fmt.Fprintln(opts.IO.Out, resp.Proto, resp.Status)
printHeaders(opts.IO.Out, resp.Header, opts.IO.ColorEnabled())
fmt.Fprint(opts.IO.Out, "\r\n")
}
if resp.StatusCode == 204 {
return nil
}
var responseBody io.Reader = resp.Body
defer resp.Body.Close()
_, err = io.Copy(opts.IO.Out, resp.Body)
if err != nil {
return err
isJSON, _ := regexp.MatchString(`[/+]json(;|$)`, resp.Header.Get("Content-Type"))
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 resp.StatusCode > 299 {
if isJSON && opts.IO.ColorEnabled() {
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 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) {
params := make(map[string]interface{})
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) {
var r io.ReadCloser
if fn == "-" {
r = stdin
} else {
var err error
r, err = os.Open(fn)
if err != nil {
return nil, err
}
r, err := openUserFile(fn, stdin)
if err != nil {
return nil, err
}
defer r.Close()
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",
RequestMethodPassed: false,
RequestPath: "graphql",
RequestInputFile: "",
RawFields: []string(nil),
MagicFields: []string(nil),
RequestHeaders: []string(nil),
@ -45,6 +46,7 @@ func Test_NewCmdApi(t *testing.T) {
RequestMethod: "DELETE",
RequestMethodPassed: true,
RequestPath: "repos/octocat/Spoon-Knife",
RequestInputFile: "",
RawFields: []string(nil),
MagicFields: []string(nil),
RequestHeaders: []string(nil),
@ -59,6 +61,7 @@ func Test_NewCmdApi(t *testing.T) {
RequestMethod: "GET",
RequestMethodPassed: false,
RequestPath: "graphql",
RequestInputFile: "",
RawFields: []string{"query=QUERY"},
MagicFields: []string{"body=@file.txt"},
RequestHeaders: []string(nil),
@ -73,6 +76,7 @@ func Test_NewCmdApi(t *testing.T) {
RequestMethod: "GET",
RequestMethodPassed: false,
RequestPath: "user",
RequestInputFile: "",
RawFields: []string(nil),
MagicFields: []string(nil),
RequestHeaders: []string{"accept: text/plain"},
@ -80,6 +84,21 @@ func Test_NewCmdApi(t *testing.T) {
},
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",
cli: "",
@ -92,6 +111,7 @@ func Test_NewCmdApi(t *testing.T) {
assert.Equal(t, tt.wants.RequestMethod, o.RequestMethod)
assert.Equal(t, tt.wants.RequestMethodPassed, o.RequestMethodPassed)
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.MagicFields, o.MagicFields)
assert.Equal(t, tt.wants.RequestHeaders, o.RequestHeaders)
@ -140,12 +160,14 @@ func Test_apiRun(t *testing.T) {
ShowResponseHeaders: true,
},
httpResponse: &http.Response{
Proto: "HTTP/1.1",
Status: "200 Okey-dokey",
StatusCode: 200,
Body: ioutil.NopCloser(bytes.NewBufferString(`body`)),
Header: http.Header{"Content-Type": []string{"text/plain"}},
},
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: ``,
},
{
@ -158,6 +180,31 @@ func Test_apiRun(t *testing.T) {
stdout: ``,
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",
httpResponse: &http.Response{
@ -166,7 +213,7 @@ func Test_apiRun(t *testing.T) {
},
err: cmdutil.SilentError,
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) {
io, stdin, _, _ := iostreams.Test()
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) {
var requestURL string
// 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 bodyIsJSON bool
isGraphQL := p == "graphql"
@ -20,7 +26,7 @@ func httpRequest(client *http.Client, method string, p string, params interface{
switch pp := params.(type) {
case map[string]interface{}:
if strings.EqualFold(method, "GET") {
url = addQuery(url, pp)
requestURL = addQuery(requestURL, pp)
} else {
for key, value := range pp {
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)
}
req, err := http.NewRequest(method, url, body)
req, err := http.NewRequest(method, requestURL, body)
if err != nil {
return nil, err
}

View file

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

View file

@ -8,7 +8,7 @@ import (
"testing"
)
func TestFind(t *testing.T) {
func TestFindNonLegacy(t *testing.T) {
tmpdir, err := ioutil.TempDir("", "gh-cli")
if err != nil {
t.Fatal(err)
@ -25,52 +25,69 @@ func TestFind(t *testing.T) {
want []string
}{
{
name: "Template in root",
name: "Legacy templates ignored",
prepare: []string{
"README.md",
"ISSUE_TEMPLATE",
"issue_template.md",
"issue_template.txt",
"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",
},
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",
},
args: args{
rootDir: tmpdir,
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{
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{
".github/ISSUE_TEMPLATE/nope.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{
".github/issue_template.md",
".github/issue_template/.keep",
".github/ISSUE_TEMPLATE/.keep",
".docs/ISSUE_TEMPLATE/.keep",
"ISSUE_TEMPLATE/.keep",
},
args: args{
rootDir: tmpdir,
name: "ISSUE_TEMPLATE",
},
want: []string{
path.Join(tmpdir, ".github/issue_template.md"),
},
want: []string{},
},
}
for _, tt := range tests {
@ -116,7 +132,99 @@ func TestFind(t *testing.T) {
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)
}
})

View file

@ -5,19 +5,36 @@ import (
"io"
"io/ioutil"
"os"
"github.com/mattn/go-colorable"
"github.com/mattn/go-isatty"
)
type IOStreams struct {
In io.ReadCloser
Out io.Writer
ErrOut io.Writer
colorEnabled bool
}
func (s *IOStreams) ColorEnabled() bool {
return s.colorEnabled
}
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{
In: os.Stdin,
Out: os.Stdout,
ErrOut: os.Stderr,
In: os.Stdin,
Out: out,
ErrOut: os.Stderr,
colorEnabled: colorEnabled,
}
}
@ -31,3 +48,7 @@ func Test() (*IOStreams, *bytes.Buffer, *bytes.Buffer, *bytes.Buffer) {
ErrOut: 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)
}
})
}
}