Merge remote-tracking branch 'origin/trunk' into h-e-l-p
This commit is contained in:
commit
56f1315d5f
28 changed files with 1183 additions and 260 deletions
6
.github/PULL_REQUEST_TEMPLATE/bug_fix.md
vendored
6
.github/PULL_REQUEST_TEMPLATE/bug_fix.md
vendored
|
|
@ -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
|
||||||
|
|
|
||||||
2
.github/workflows/go.yml
vendored
2
.github/workflows/go.yml
vendored
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
2
.github/workflows/lint.yml
vendored
2
.github/workflows/lint.yml
vendored
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
2
.github/workflows/releases.yml
vendored
2
.github/workflows/releases.yml
vendored
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
## Availability
|
## Availability
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}}
|
}}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
106
command/alias.go
106
command/alias.go
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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{
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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]},
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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())
|
||||||
|
}
|
||||||
|
|
|
||||||
96
pkg/jsoncolor/jsoncolor.go
Normal file
96
pkg/jsoncolor/jsoncolor.go
Normal 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
|
||||||
|
}
|
||||||
83
pkg/jsoncolor/jsoncolor_test.go
Normal file
83
pkg/jsoncolor/jsoncolor_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue