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
|
||||
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:
|
||||
- name: Set up Go 1.14
|
||||
uses: actions/setup-go@v2-beta
|
||||
uses: actions/setup-go@v2
|
||||
with:
|
||||
go-version: 1.14
|
||||
|
||||
|
|
|
|||
2
.github/workflows/lint.yml
vendored
2
.github/workflows/lint.yml
vendored
|
|
@ -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
|
||||
|
||||
|
|
|
|||
2
.github/workflows/releases.yml
vendored
2
.github/workflows/releases.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||

|
||||

|
||||
|
||||
## Availability
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}}
|
||||
}
|
||||
|
|
|
|||
106
command/alias.go
106
command/alias.go
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
|
|
|||
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