Merge remote-tracking branch 'origin' into fix-incorrect-docs

This commit is contained in:
Mislav Marohnić 2020-06-16 17:08:10 +02:00
commit 4662abb312
75 changed files with 4527 additions and 1218 deletions

View file

@ -1,7 +1,13 @@
---
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/master/.github/CONTRIBUTING.md
before opening opening a pull request. Thanks!
https://github.com/cli/cli/blob/trunk/.github/CONTRIBUTING.md
before opening a pull request. Thanks!
-->
## Summary

View file

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

View file

@ -1,12 +1,15 @@
name: Lint
on:
push: &paths
push:
paths:
- "**.go"
- go.mod
- go.sum
pull_request:
<<: *paths
paths:
- "**.go"
- go.mod
- go.sum
jobs:
lint:
@ -14,7 +17,7 @@ jobs:
steps:
- name: Set up Go 1.14
uses: actions/setup-go@v2-beta
uses: actions/setup-go@v2
with:
go-version: 1.14

View file

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

3
.golangci.yml Normal file
View file

@ -0,0 +1,3 @@
linters:
enable:
gofmt

View file

@ -82,6 +82,7 @@ nfpms:
bindir: /usr/local
dependencies:
- git
description: GitHubs official command line tool.
formats:
- deb
- rpm

View file

@ -11,8 +11,8 @@ endif
LDFLAGS := -X github.com/cli/cli/command.Version=$(GH_VERSION) $(LDFLAGS)
LDFLAGS := -X github.com/cli/cli/command.BuildDate=$(BUILD_DATE) $(LDFLAGS)
ifdef GH_OAUTH_CLIENT_SECRET
LDFLAGS := -X github.com/cli/cli/context.oauthClientID=$(GH_OAUTH_CLIENT_ID) $(LDFLAGS)
LDFLAGS := -X github.com/cli/cli/context.oauthClientSecret=$(GH_OAUTH_CLIENT_SECRET) $(LDFLAGS)
LDFLAGS := -X github.com/cli/cli/internal/config.oauthClientID=$(GH_OAUTH_CLIENT_ID) $(LDFLAGS)
LDFLAGS := -X github.com/cli/cli/internal/config.oauthClientSecret=$(GH_OAUTH_CLIENT_SECRET) $(LDFLAGS)
endif
bin/gh: $(BUILD_FILES)
@ -22,8 +22,8 @@ test:
go test ./...
.PHONY: test
site: bin/gh
bin/gh repo clone github/cli.github.com "$@"
site:
git clone https://github.com/github/cli.github.com.git "$@"
site-docs: site
git -C site pull

View file

@ -3,15 +3,15 @@
`gh` is GitHub on the command line, and it's now available in beta. It brings pull requests, issues, and other GitHub concepts to
the terminal next to where you are already working with `git` and your code.
![screenshot](https://user-images.githubusercontent.com/98482/73286699-9f922180-41bd-11ea-87c9-60a2d31fd0ac.png)
![screenshot of gh pr status](https://user-images.githubusercontent.com/98482/84171218-327e7a80-aa40-11ea-8cd1-5177fc2d0e72.png)
## Availability
While in beta, GitHub CLI is available for repos hosted on GitHub.com only. It does not currently support repositories hosted on GitHub Enterprise Server or other hosting providers. We are planning support for GitHub Enterprise Server after GitHub CLI is out of beta (likely toward the end of 2020), and we want to ensure that the API endpoints we use are more widely available for GHES versions that most GitHub customers are on.
While in beta, GitHub CLI is available for repos hosted on GitHub.com only. It currently does not support repositories hosted on GitHub Enterprise Server or other hosting providers. We are planning on adding support for GitHub Enterprise Server after GitHub CLI is out of beta (likely towards the end of 2020), and we want to ensure that the API endpoints we use are more widely available for GHES versions that most GitHub customers are on.
## We need your feedback
GitHub CLI is currently early in its development, and we're hoping to get feedback from people using it.
GitHub CLI is currently in its early development stages, and we're hoping to get feedback from people using it.
If you've installed and used `gh`, we'd love for you to take a short survey here (no more than five minutes): https://forms.gle/umxd3h31c7aMQFKG7
@ -31,9 +31,9 @@ Read the [official docs](https://cli.github.com/manual/) for more information.
## Comparison with hub
For many years, [hub][] was the unofficial GitHub CLI tool. `gh` is a new project for us to explore
For many years, [hub][] was the unofficial GitHub CLI tool. `gh` is a new project that helps us explore
what an official GitHub CLI tool can look like with a fundamentally different design. While both
tools bring GitHub to the terminal, `hub` behaves as a proxy to `git` and `gh` is a standalone
tools bring GitHub to the terminal, `hub` behaves as a proxy to `git`, and `gh` is a standalone
tool. Check out our [more detailed explanation](/docs/gh-vs-hub.md) to learn more.
@ -46,15 +46,31 @@ tool. Check out our [more detailed explanation](/docs/gh-vs-hub.md) to learn mor
#### Homebrew
Install: `brew install github/gh/gh`
Install:
Upgrade: `brew upgrade gh`
```bash
brew install github/gh/gh
```
Upgrade:
```bash
brew upgrade gh
```
#### MacPorts
Install: `sudo port install gh`
Install:
Upgrade: `sudo port selfupdate && sudo port upgrade gh`
```bash
sudo port install gh
```
Upgrade:
```bash
sudo port selfupdate && sudo port upgrade gh
```
### Windows
@ -64,24 +80,28 @@ Upgrade: `sudo port selfupdate && sudo port upgrade gh`
Install:
```
```powershell
scoop bucket add github-gh https://github.com/cli/scoop-gh.git
scoop install gh
```
Upgrade: `scoop update gh`
Upgrade:
```powershell
scoop update gh
```
#### Chocolatey
Install:
```
```powershell
choco install gh
```
Upgrade:
```
```powershell
choco upgrade gh
```
@ -122,7 +142,7 @@ Install and upgrade:
Arch Linux users can install from the AUR: https://aur.archlinux.org/packages/github-cli/
```bash
$ yay -S github-cli
yay -S github-cli
```
### Other platforms
@ -136,4 +156,4 @@ Install a prebuilt binary from the [releases page][]
[Chocolatey]: https://chocolatey.org
[releases page]: https://github.com/cli/cli/releases/latest
[hub]: https://github.com/github/hub
[contributing page]: https://github.com/cli/cli/blob/master/.github/CONTRIBUTING.md
[contributing page]: https://github.com/cli/cli/blob/trunk/.github/CONTRIBUTING.md

View file

@ -16,14 +16,18 @@ import (
// ClientOption represents an argument to NewClient
type ClientOption = func(http.RoundTripper) http.RoundTripper
// NewClient initializes a Client
func NewClient(opts ...ClientOption) *Client {
// NewHTTPClient initializes an http.Client
func NewHTTPClient(opts ...ClientOption) *http.Client {
tr := http.DefaultTransport
for _, opt := range opts {
tr = opt(tr)
}
http := &http.Client{Transport: tr}
client := &Client{http: http}
return &http.Client{Transport: tr}
}
// NewClient initializes a Client
func NewClient(opts ...ClientOption) *Client {
client := &Client{http: NewHTTPClient(opts...)}
return client
}
@ -31,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)
}}
}
@ -41,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)
}}
}
@ -161,6 +173,10 @@ func (c Client) HasScopes(wantedScopes ...string) (bool, string, error) {
}
defer res.Body.Close()
if res.StatusCode != 200 {
return false, "", handleHTTPError(res)
}
appID := res.Header.Get("X-Oauth-Client-Id")
hasScopes := strings.Split(res.Header.Get("X-Oauth-Scopes"), ",")

View file

@ -48,6 +48,7 @@ type PullRequest struct {
BaseRefName string
HeadRefName string
Body string
Mergeable string
Author struct {
Login string
@ -204,9 +205,9 @@ func (pr *PullRequest) ChecksStatus() (summary PullRequestChecksStatus) {
return
}
func (c Client) PullRequestDiff(baseRepo ghrepo.Interface, prNum int) (string, error) {
func (c Client) PullRequestDiff(baseRepo ghrepo.Interface, prNumber int) (string, error) {
url := fmt.Sprintf("https://api.github.com/repos/%s/pulls/%d",
ghrepo.FullName(baseRepo), prNum)
ghrepo.FullName(baseRepo), prNumber)
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return "", err
@ -234,7 +235,6 @@ func (c Client) PullRequestDiff(baseRepo ghrepo.Interface, prNum int) (string, e
}
return "", errors.New("pull request diff lookup failed")
}
func PullRequests(client *Client, repo ghrepo.Interface, currentPRNumber int, currentPRHeadRef, currentUsername string) (*PullRequestsPayload, error) {
@ -418,6 +418,7 @@ func PullRequestByNumber(client *Client, repo ghrepo.Interface, number int) (*Pu
state
closed
body
mergeable
author {
login
}
@ -526,6 +527,7 @@ func PullRequestForBranch(client *Client, repo ghrepo.Interface, baseBranch, hea
title
state
body
mergeable
author {
login
}
@ -991,23 +993,25 @@ func PullRequestMerge(client *Client, repo ghrepo.Interface, pr *PullRequest, m
func PullRequestReady(client *Client, repo ghrepo.Interface, pr *PullRequest) error {
var mutation struct {
MarkPullRequestReadyForReviewInput struct {
MarkPullRequestReadyForReview struct {
PullRequest struct {
ID githubv4.ID
}
} `graphql:"markPullRequestReadyForReview(input: $input)"`
}
type MarkPullRequestReadyForReviewInput struct {
PullRequestID githubv4.ID `json:"pullRequestId"`
}
input := MarkPullRequestReadyForReviewInput{PullRequestID: pr.ID}
input := githubv4.MarkPullRequestReadyForReviewInput{PullRequestID: pr.ID}
v4 := githubv4.NewClient(client.http)
err := v4.Mutate(context.Background(), &mutation, input, nil)
return v4.Mutate(context.Background(), &mutation, input, nil)
}
return err
func BranchDeleteRemote(client *Client, repo ghrepo.Interface, branch string) error {
var response struct {
NodeID string `json:"node_id"`
}
path := fmt.Sprintf("repos/%s/%s/git/refs/heads/%s", repo.RepoOwner(), repo.RepoName(), branch)
return client.REST("DELETE", path, nil, &response)
}
func min(a, b int) int {

View file

@ -84,6 +84,9 @@ func GitHubRepo(client *Client, repo ghrepo.Interface) (*Repository, error) {
hasIssuesEnabled
description
viewerPermission
defaultBranchRef {
name
}
}
}`
variables := map[string]interface{}{

18
api/queries_user.go Normal file
View file

@ -0,0 +1,18 @@
package api
import (
"context"
"github.com/shurcooL/githubv4"
)
func CurrentLoginName(client *Client) (string, error) {
var query struct {
Viewer struct {
Login string
}
}
v4 := githubv4.NewClient(client.http)
err := v4.Query(context.Background(), &query, nil)
return query.Viewer.Login, err
}

View file

@ -41,7 +41,7 @@ func (oa *OAuthFlow) ObtainAccessToken() (accessToken string, err error) {
state, _ := randomString(20)
code := ""
listener, err := net.Listen("tcp", "localhost:0")
listener, err := net.Listen("tcp4", "127.0.0.1:0")
if err != nil {
return
}
@ -66,9 +66,9 @@ func (oa *OAuthFlow) ObtainAccessToken() (accessToken string, err error) {
fmt.Fprintf(os.Stderr, "Please open the following URL manually:\n%s\n", startURL)
fmt.Fprintf(os.Stderr, "")
// TODO: Temporary workaround for https://github.com/cli/cli/issues/297
fmt.Fprintf(os.Stderr, "If you are on a server or other headless system, use this workaround instead:")
fmt.Fprintf(os.Stderr, " 1. Complete authentication on a GUI system")
fmt.Fprintf(os.Stderr, " 2. Copy the contents of ~/.config/gh/config.yml to this system")
fmt.Fprintf(os.Stderr, "If you are on a server or other headless system, use this workaround instead:\n")
fmt.Fprintf(os.Stderr, " 1. Complete authentication on a GUI system;\n")
fmt.Fprintf(os.Stderr, " 2. Copy the contents of `~/.config/gh/hosts.yml` to this system.\n")
}
_ = http.Serve(listener, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {

View file

@ -11,6 +11,7 @@ import (
"github.com/cli/cli/command"
"github.com/cli/cli/internal/config"
"github.com/cli/cli/pkg/cmdutil"
"github.com/cli/cli/update"
"github.com/cli/cli/utils"
"github.com/mgutz/ansi"
@ -29,6 +30,28 @@ func main() {
hasDebug := os.Getenv("DEBUG") != ""
stderr := utils.NewColorable(os.Stderr)
expandedArgs := []string{}
if len(os.Args) > 0 {
expandedArgs = os.Args[1:]
}
cmd, _, err := command.RootCmd.Traverse(expandedArgs)
if err != nil || cmd == command.RootCmd {
originalArgs := expandedArgs
expandedArgs, err = command.ExpandAlias(os.Args)
if err != nil {
fmt.Fprintf(stderr, "failed to process aliases: %s\n", err)
os.Exit(2)
}
if hasDebug {
fmt.Fprintf(stderr, "%v -> %v\n", originalArgs, expandedArgs)
}
}
command.RootCmd.SetArgs(expandedArgs)
if cmd, err := command.RootCmd.ExecuteC(); err != nil {
printError(os.Stderr, err, cmd, hasDebug)
os.Exit(1)
@ -48,6 +71,10 @@ func main() {
}
func printError(out io.Writer, err error, cmd *cobra.Command, debug bool) {
if err == cmdutil.SilentError {
return
}
var dnsError *net.DNSError
if errors.As(err, &dnsError) {
fmt.Fprintf(out, "error connecting to %s\n", dnsError.Name)
@ -60,7 +87,7 @@ func printError(out io.Writer, err error, cmd *cobra.Command, debug bool) {
fmt.Fprintln(out, err)
var flagError *command.FlagError
var flagError *cmdutil.FlagError
if errors.As(err, &flagError) || strings.HasPrefix(err.Error(), "unknown command ") {
if !strings.HasSuffix(err.Error(), "\n") {
fmt.Fprintln(out)

View file

@ -7,7 +7,7 @@ import (
"net"
"testing"
"github.com/cli/cli/command"
"github.com/cli/cli/pkg/cmdutil"
"github.com/spf13/cobra"
)
@ -49,7 +49,7 @@ check your internet connection or githubstatus.com
{
name: "Cobra flag error",
args: args{
err: &command.FlagError{Err: errors.New("unknown flag --foo")},
err: &cmdutil.FlagError{Err: errors.New("unknown flag --foo")},
cmd: cmd,
debug: false,
},

214
command/alias.go Normal file
View file

@ -0,0 +1,214 @@
package command
import (
"fmt"
"sort"
"strings"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/utils"
"github.com/google/shlex"
"github.com/spf13/cobra"
)
func init() {
RootCmd.AddCommand(aliasCmd)
aliasCmd.AddCommand(aliasSetCmd)
aliasCmd.AddCommand(aliasListCmd)
aliasCmd.AddCommand(aliasDeleteCmd)
}
var aliasCmd = &cobra.Command{
Use: "alias",
Short: "Create shortcuts for gh commands",
}
var aliasSetCmd = &cobra.Command{
Use: "set <alias> <expansion>",
Short: "Create a shortcut for a gh command",
Long: `Declare a word as a command alias that will expand to the specified command.
The expansion may specify additional arguments and flags. If the expansion
includes positional placeholders such as '$1', '$2', etc., any extra arguments
that follow the invocation of an alias will be inserted appropriately.`,
Example: heredoc.Doc(`
$ gh alias set pv 'pr view'
$ gh pv -w 123
#=> gh pr view -w 123
$ gh alias set bugs 'issue list --label="bugs"'
$ gh alias set epicsBy 'issue list --author="$1" --label="epic"'
$ gh epicsBy vilmibm
#=> gh issue list --author="vilmibm" --label="epic"
`),
Args: cobra.MinimumNArgs(2),
RunE: aliasSet,
// 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,
}
func aliasSet(cmd *cobra.Command, args []string) error {
ctx := contextForCommand(cmd)
cfg, err := ctx.Config()
if err != nil {
return err
}
aliasCfg, err := cfg.Aliases()
if err != nil {
return err
}
alias := args[0]
expansion := processArgs(args[1:])
expansionStr := strings.Join(expansion, " ")
out := colorableOut(cmd)
fmt.Fprintf(out, "- Adding alias for %s: %s\n", utils.Bold(alias), utils.Bold(expansionStr))
if validCommand([]string{alias}) {
return fmt.Errorf("could not create alias: %q is already a gh command", alias)
}
if !validCommand(expansion) {
return fmt.Errorf("could not create alias: %s does not correspond to a gh command", utils.Bold(expansionStr))
}
successMsg := fmt.Sprintf("%s Added alias.", utils.Green("✓"))
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(oldExpansion),
utils.Bold(expansionStr),
)
}
err = aliasCfg.Add(alias, expansionStr)
if err != nil {
return fmt.Errorf("could not create alias: %s", err)
}
fmt.Fprintln(out, successMsg)
return nil
}
func validCommand(expansion []string) bool {
cmd, _, err := RootCmd.Traverse(expansion)
return err == nil && cmd != RootCmd
}
func processArgs(args []string) []string {
if len(args) == 1 {
split, _ := shlex.Split(args[0])
return split
}
newArgs := []string{}
for _, a := range args {
if !strings.HasPrefix(a, "-") && strings.Contains(a, " ") {
a = fmt.Sprintf("%q", a)
}
newArgs = append(newArgs, a)
}
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),
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
}

321
command/alias_test.go Normal file
View file

@ -0,0 +1,321 @@
package command
import (
"bytes"
"strings"
"testing"
"github.com/cli/cli/internal/config"
"github.com/cli/cli/test"
)
func TestAliasSet_gh_command(t *testing.T) {
initBlankContext("", "OWNER/REPO", "trunk")
mainBuf := bytes.Buffer{}
hostsBuf := bytes.Buffer{}
defer config.StubWriteConfig(&mainBuf, &hostsBuf)()
_, err := RunCommand("alias set pr pr status")
if err == nil {
t.Fatal("expected error")
}
eq(t, err.Error(), `could not create alias: "pr" is already a gh command`)
}
func TestAliasSet_empty_aliases(t *testing.T) {
cfg := `---
aliases:
editor: vim
`
initBlankContext(cfg, "OWNER/REPO", "trunk")
mainBuf := bytes.Buffer{}
hostsBuf := bytes.Buffer{}
defer config.StubWriteConfig(&mainBuf, &hostsBuf)()
output, err := RunCommand("alias set co pr checkout")
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
test.ExpectLines(t, output.String(), "Added alias")
expected := `aliases:
co: pr checkout
editor: vim
`
eq(t, mainBuf.String(), expected)
}
func TestAliasSet_existing_alias(t *testing.T) {
cfg := `---
hosts:
github.com:
user: OWNER
oauth_token: token123
aliases:
co: pr checkout
`
initBlankContext(cfg, "OWNER/REPO", "trunk")
mainBuf := bytes.Buffer{}
hostsBuf := bytes.Buffer{}
defer config.StubWriteConfig(&mainBuf, &hostsBuf)()
output, err := RunCommand("alias set co pr checkout -Rcool/repo")
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
test.ExpectLines(t, output.String(), "Changed alias co from pr checkout to pr checkout -Rcool/repo")
}
func TestAliasSet_space_args(t *testing.T) {
initBlankContext("", "OWNER/REPO", "trunk")
mainBuf := bytes.Buffer{}
hostsBuf := bytes.Buffer{}
defer config.StubWriteConfig(&mainBuf, &hostsBuf)()
output, err := RunCommand(`alias set il issue list -l 'cool story'`)
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
test.ExpectLines(t, output.String(), `Adding alias for il: issue list -l "cool story"`)
test.ExpectLines(t, mainBuf.String(), `il: issue list -l "cool story"`)
}
func TestAliasSet_arg_processing(t *testing.T) {
initBlankContext("", "OWNER/REPO", "trunk")
cases := []struct {
Cmd string
ExpectedOutputLine string
ExpectedConfigLine string
}{
{"alias set co pr checkout", "- Adding alias for co: pr checkout", "co: pr checkout"},
{`alias set il "issue list"`, "- Adding alias for il: issue list", "il: issue list"},
{`alias set iz 'issue list'`, "- Adding alias for iz: issue list", "iz: issue list"},
{`alias set iy issue list --author=\$1 --label=\$2`,
`- Adding alias for iy: issue list --author=\$1 --label=\$2`,
`iy: issue list --author=\$1 --label=\$2`},
{`alias set ii 'issue list --author="$1" --label="$2"'`,
`- Adding alias for ii: issue list --author=\$1 --label=\$2`,
`ii: issue list --author=\$1 --label=\$2`},
{`alias set ix issue list --author='$1' --label='$2'`,
`- Adding alias for ix: issue list --author=\$1 --label=\$2`,
`ix: issue list --author=\$1 --label=\$2`},
}
for _, c := range cases {
mainBuf := bytes.Buffer{}
hostsBuf := bytes.Buffer{}
defer config.StubWriteConfig(&mainBuf, &hostsBuf)()
output, err := RunCommand(c.Cmd)
if err != nil {
t.Fatalf("got unexpected error running %s: %s", c.Cmd, err)
}
test.ExpectLines(t, output.String(), c.ExpectedOutputLine)
test.ExpectLines(t, mainBuf.String(), c.ExpectedConfigLine)
}
}
func TestAliasSet_init_alias_cfg(t *testing.T) {
cfg := `---
editor: vim
`
initBlankContext(cfg, "OWNER/REPO", "trunk")
mainBuf := bytes.Buffer{}
hostsBuf := bytes.Buffer{}
defer config.StubWriteConfig(&mainBuf, &hostsBuf)()
output, err := RunCommand("alias set diff pr diff")
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
expected := `editor: vim
aliases:
diff: pr diff
`
test.ExpectLines(t, output.String(), "Adding alias for diff: pr diff", "Added alias.")
eq(t, mainBuf.String(), expected)
}
func TestAliasSet_existing_aliases(t *testing.T) {
cfg := `---
aliases:
foo: bar
`
initBlankContext(cfg, "OWNER/REPO", "trunk")
mainBuf := bytes.Buffer{}
hostsBuf := bytes.Buffer{}
defer config.StubWriteConfig(&mainBuf, &hostsBuf)()
output, err := RunCommand("alias set view pr view")
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
expected := `aliases:
foo: bar
view: pr view
`
test.ExpectLines(t, output.String(), "Adding alias for view: pr view", "Added alias.")
eq(t, mainBuf.String(), expected)
}
func TestExpandAlias(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")
for _, c := range []struct {
Args string
ExpectedArgs []string
Err string
}{
{"gh co", []string{"pr", "checkout"}, ""},
{"gh il", nil, `not enough arguments for alias: issue list --author="$1" --label="$2"`},
{"gh il vilmibm", nil, `not enough arguments for alias: issue list --author="vilmibm" --label="$2"`},
{"gh co 123", []string{"pr", "checkout", "123"}, ""},
{"gh il vilmibm epic", []string{"issue", "list", `--author=vilmibm`, `--label=epic`}, ""},
{"gh ia vilmibm", []string{"issue", "list", `--author=vilmibm`, `--assignee=vilmibm`}, ""},
{"gh ia $coolmoney$", []string{"issue", "list", `--author=$coolmoney$`, `--assignee=$coolmoney$`}, ""},
{"gh pr status", []string{"pr", "status"}, ""},
{"gh il vilmibm epic -R vilmibm/testing", []string{"issue", "list", "--author=vilmibm", "--label=epic", "-R", "vilmibm/testing"}, ""},
{"gh dne", []string{"dne"}, ""},
{"gh", []string{}, ""},
{"", []string{}, ""},
} {
args := []string{}
if c.Args != "" {
args = strings.Split(c.Args, " ")
}
out, err := ExpandAlias(args)
if err == nil && c.Err != "" {
t.Errorf("expected error %s for %s", c.Err, c.Args)
continue
}
if err != nil {
eq(t, err.Error(), c.Err)
continue
}
eq(t, out, c.ExpectedArgs)
}
}
func TestAliasSet_invalid_command(t *testing.T) {
initBlankContext("", "OWNER/REPO", "trunk")
_, err := RunCommand("alias set co pe checkout")
if err == nil {
t.Fatal("expected error")
}
eq(t, err.Error(), "could not create alias: pe checkout does not correspond to a gh command")
}
func TestAliasList_empty(t *testing.T) {
initBlankContext("", "OWNER/REPO", "trunk")
output, err := RunCommand("alias list")
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
eq(t, output.String(), "")
}
func TestAliasList(t *testing.T) {
cfg := `---
aliases:
co: pr checkout
il: issue list --author=$1 --label=$2
clone: repo clone
prs: pr status
cs: config set editor 'quoted path'
`
initBlankContext(cfg, "OWNER/REPO", "trunk")
output, err := RunCommand("alias list")
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
expected := `clone repo clone
co pr checkout
cs config set editor 'quoted path'
il issue list --author=$1 --label=$2
prs pr status
`
eq(t, output.String(), expected)
}
func TestAliasDelete_nonexistent_command(t *testing.T) {
cfg := `---
aliases:
co: pr checkout
il: issue list --author="$1" --label="$2"
ia: issue list --author="$1" --assignee="$1"
`
initBlankContext(cfg, "OWNER/REPO", "trunk")
_, err := RunCommand("alias delete cool")
if err == nil {
t.Fatalf("expected error")
}
eq(t, err.Error(), "no such alias cool")
}
func TestAliasDelete(t *testing.T) {
cfg := `---
aliases:
co: pr checkout
il: issue list --author="$1" --label="$2"
ia: issue list --author="$1" --assignee="$1"
`
initBlankContext(cfg, "OWNER/REPO", "trunk")
mainBuf := bytes.Buffer{}
hostsBuf := bytes.Buffer{}
defer config.StubWriteConfig(&mainBuf, &hostsBuf)()
output, err := RunCommand("alias delete co")
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
test.ExpectLines(t, output.String(), "Deleted alias co; was pr checkout")
expected := `aliases:
il: issue list --author="$1" --label="$2"
ia: issue list --author="$1" --assignee="$1"
`
eq(t, mainBuf.String(), expected)
}

View file

@ -5,7 +5,6 @@ import (
"fmt"
"os"
"github.com/cli/cli/internal/cobrafish"
"github.com/cli/cli/utils"
"github.com/spf13/cobra"
)
@ -29,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")
@ -58,7 +57,7 @@ Homebrew, see <https://docs.brew.sh/Shell-Completion>
case "powershell":
return RootCmd.GenPowerShellCompletion(cmd.OutOrStdout())
case "fish":
return cobrafish.GenCompletion(RootCmd, cmd.OutOrStdout())
return RootCmd.GenFishCompletion(cmd.OutOrStdout(), true)
default:
return fmt.Errorf("unsupported shell type %q", shellType)
}

View file

@ -2,6 +2,8 @@ package command
import (
"fmt"
"github.com/MakeNowJust/heredoc"
"github.com/spf13/cobra"
)
@ -21,36 +23,32 @@ func init() {
var configCmd = &cobra.Command{
Use: "config",
Short: "Set and get gh settings",
Long: `Get and set key/value strings.
Short: "Manage configuration for gh",
Long: `Display or change configuration settings for gh.
Current respected settings:
- git_protocol: https or ssh. Default is https.
- git_protocol: "https" or "ssh". Default is "https".
- editor: if unset, defaults to environment variables.
`,
}
var configGetCmd = &cobra.Command{
Use: "get <key>",
Short: "Prints the value of a given configuration key",
Long: `Get the value for a given configuration key.
Examples:
$ gh config get git_protocol
https
`,
Short: "Print the value of a given configuration key",
Example: heredoc.Doc(`
$ gh config get git_protocol
https
`),
Args: cobra.ExactArgs(1),
RunE: configGet,
}
var configSetCmd = &cobra.Command{
Use: "set <key> <value>",
Short: "Updates configuration with the value of a given key",
Long: `Update the configuration by setting a key to a value.
Examples:
$ gh config set editor vim
`,
Short: "Update configuration with a value for the given key",
Example: heredoc.Doc(`
$ gh config set editor vim
`),
Args: cobra.ExactArgs(2),
RunE: configSet,
}

View file

@ -49,23 +49,31 @@ func TestConfigGet_not_found(t *testing.T) {
func TestConfigSet(t *testing.T) {
initBlankContext("", "OWNER/REPO", "master")
buf := bytes.NewBufferString("")
defer config.StubWriteConfig(buf)()
mainBuf := bytes.Buffer{}
hostsBuf := bytes.Buffer{}
defer config.StubWriteConfig(&mainBuf, &hostsBuf)()
output, err := RunCommand("config set editor ed")
if err != nil {
t.Fatalf("error running command `config set editor ed`: %v", err)
}
eq(t, output.String(), "")
if len(output.String()) > 0 {
t.Errorf("expected output to be blank: %q", output.String())
}
expected := `hosts:
github.com:
user: OWNER
oauth_token: 1234567890
editor: ed
expectedMain := "editor: ed\n"
expectedHosts := `github.com:
user: OWNER
oauth_token: "1234567890"
`
eq(t, buf.String(), expected)
if mainBuf.String() != expectedMain {
t.Errorf("expected config.yml to be %q, got %q", expectedMain, mainBuf.String())
}
if hostsBuf.String() != expectedHosts {
t.Errorf("expected hosts.yml to be %q, got %q", expectedHosts, hostsBuf.String())
}
}
func TestConfigSet_update(t *testing.T) {
@ -79,23 +87,31 @@ editor: ed
initBlankContext(cfg, "OWNER/REPO", "master")
buf := bytes.NewBufferString("")
defer config.StubWriteConfig(buf)()
mainBuf := bytes.Buffer{}
hostsBuf := bytes.Buffer{}
defer config.StubWriteConfig(&mainBuf, &hostsBuf)()
output, err := RunCommand("config set editor vim")
if err != nil {
t.Fatalf("error running command `config get editor`: %v", err)
}
eq(t, output.String(), "")
if len(output.String()) > 0 {
t.Errorf("expected output to be blank: %q", output.String())
}
expected := `hosts:
github.com:
user: OWNER
oauth_token: MUSTBEHIGHCUZIMATOKEN
editor: vim
expectedMain := "editor: vim\n"
expectedHosts := `github.com:
user: OWNER
oauth_token: MUSTBEHIGHCUZIMATOKEN
`
eq(t, buf.String(), expected)
if mainBuf.String() != expectedMain {
t.Errorf("expected config.yml to be %q, got %q", expectedMain, mainBuf.String())
}
if hostsBuf.String() != expectedHosts {
t.Errorf("expected hosts.yml to be %q, got %q", expectedHosts, hostsBuf.String())
}
}
func TestConfigGetHost(t *testing.T) {
@ -141,23 +157,32 @@ git_protocol: ssh
func TestConfigSetHost(t *testing.T) {
initBlankContext("", "OWNER/REPO", "master")
buf := bytes.NewBufferString("")
defer config.StubWriteConfig(buf)()
mainBuf := bytes.Buffer{}
hostsBuf := bytes.Buffer{}
defer config.StubWriteConfig(&mainBuf, &hostsBuf)()
output, err := RunCommand("config set -hgithub.com git_protocol ssh")
if err != nil {
t.Fatalf("error running command `config set editor ed`: %v", err)
}
eq(t, output.String(), "")
if len(output.String()) > 0 {
t.Errorf("expected output to be blank: %q", output.String())
}
expected := `hosts:
github.com:
user: OWNER
oauth_token: 1234567890
git_protocol: ssh
expectedMain := ""
expectedHosts := `github.com:
user: OWNER
oauth_token: "1234567890"
git_protocol: ssh
`
eq(t, buf.String(), expected)
if mainBuf.String() != expectedMain {
t.Errorf("expected config.yml to be %q, got %q", expectedMain, mainBuf.String())
}
if hostsBuf.String() != expectedHosts {
t.Errorf("expected hosts.yml to be %q, got %q", expectedHosts, hostsBuf.String())
}
}
func TestConfigSetHost_update(t *testing.T) {
@ -171,21 +196,30 @@ hosts:
initBlankContext(cfg, "OWNER/REPO", "master")
buf := bytes.NewBufferString("")
defer config.StubWriteConfig(buf)()
mainBuf := bytes.Buffer{}
hostsBuf := bytes.Buffer{}
defer config.StubWriteConfig(&mainBuf, &hostsBuf)()
output, err := RunCommand("config set -hgithub.com git_protocol https")
if err != nil {
t.Fatalf("error running command `config get editor`: %v", err)
}
eq(t, output.String(), "")
if len(output.String()) > 0 {
t.Errorf("expected output to be blank: %q", output.String())
}
expected := `hosts:
github.com:
git_protocol: https
user: OWNER
oauth_token: MUSTBEHIGHCUZIMATOKEN
expectedMain := ""
expectedHosts := `github.com:
git_protocol: https
user: OWNER
oauth_token: MUSTBEHIGHCUZIMATOKEN
`
eq(t, buf.String(), expected)
if mainBuf.String() != expectedMain {
t.Errorf("expected config.yml to be %q, got %q", expectedMain, mainBuf.String())
}
if hostsBuf.String() != expectedHosts {
t.Errorf("expected hosts.yml to be %q, got %q", expectedHosts, hostsBuf.String())
}
}

View file

@ -11,6 +11,7 @@ import (
"strings"
"time"
"github.com/MakeNowJust/heredoc"
"github.com/spf13/cobra"
"golang.org/x/crypto/ssh/terminal"
@ -41,19 +42,27 @@ func init() {
}
var creditsCmd = &cobra.Command{
Use: "credits [repository]",
Short: "View project's credits",
Long: `View animated credits for this or another project.
Use: "credits",
Short: "View credits for this tool",
Long: `View animated credits for gh, the tool you are currently using :)`,
Example: heredoc.Doc(`
# see a credits animation for this project
$ gh credits
# display a non-animated thank you
$ gh credits -s
# just print the contributors, one per line
$ gh credits | cat
`),
Args: cobra.ExactArgs(0),
RunE: ghCredits,
Hidden: true,
}
Examples:
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,
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 +73,18 @@ func credits(cmd *cobra.Command, args []string) error {
return err
}
owner := "cli"
repo := "cli"
if len(args) > 0 {
var owner string
var repo string
if len(args) == 0 {
baseRepo, err := determineBaseRepo(client, cmd, ctx)
if err != nil {
return err
}
owner = baseRepo.RepoOwner()
repo = baseRepo.RepoName()
} else {
parts := strings.SplitN(args[0], "/", 2)
owner = parts[0]
repo = parts[1]

View file

@ -8,6 +8,7 @@ import (
"os"
"path"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/api"
"github.com/cli/cli/utils"
"github.com/spf13/cobra"
@ -27,21 +28,30 @@ var gistCmd = &cobra.Command{
}
var gistCreateCmd = &cobra.Command{
Use: `create {<filename>|-}...`,
Use: `create [<filename>... | -]`,
Short: "Create a new gist",
Long: `gh gist create: create gists
Long: `Create a new GitHub gist with given contents.
Gists can be created from one or many files. This command can also read from STDIN. By default, gists are private; use --public to change this.
Gists can be created from one or multiple files. Alternatively, pass "-" as
file name to read from standard input.
Examples
By default, gists are private; use '--public' to make publicly listed ones.`,
Example: heredoc.Doc(`
# publish file 'hello.py' as a public gist
$ gh gist create --public hello.py
# create a gist with a description
$ gh gist create hello.py -d "my Hello-World program in Python"
gh gist create hello.py # turn file hello.py into a gist
gh gist create --public hello.py # turn file hello.py into a public gist
gh gist create -d"a file!" hello.py # turn file hello.py into a gist, with description
gh gist create hello.py world.py cool.txt # make a gist out of several files
gh gist create - # read from STDIN to create a gist
cat cool.txt | gh gist create # read the output of another command and make a gist out of it
`,
# create a gist containing several files
$ gh gist create hello.py world.py cool.txt
# read from standard input to create a gist
$ gh gist create -
# create a gist from output piped from another command
$ cat cool.txt | gh gist create
`),
RunE: gistCreate,
}

122
command/help.go Normal file
View file

@ -0,0 +1,122 @@
package command
import (
"fmt"
"regexp"
"strings"
"github.com/cli/cli/utils"
"github.com/spf13/cobra"
)
func rootHelpFunc(command *cobra.Command, args []string) {
// Display helpful error message in case subcommand name was mistyped.
// This matches Cobra's behavior for root command, which Cobra
// confusingly doesn't apply to nested commands.
if command != RootCmd {
if command.Parent() == RootCmd && len(args) >= 2 {
if command.SuggestionsMinimumDistance <= 0 {
command.SuggestionsMinimumDistance = 2
}
candidates := command.SuggestionsFor(args[1])
errOut := command.OutOrStderr()
fmt.Fprintf(errOut, "unknown command %q for %q\n", args[1], "gh "+args[0])
if len(candidates) > 0 {
fmt.Fprint(errOut, "\nDid you mean this?\n")
for _, c := range candidates {
fmt.Fprintf(errOut, "\t%s\n", c)
}
fmt.Fprint(errOut, "\n")
}
oldOut := command.OutOrStdout()
command.SetOut(errOut)
defer command.SetOut(oldOut)
}
}
coreCommands := []string{}
additionalCommands := []string{}
for _, c := range command.Commands() {
if c.Short == "" {
continue
}
if c.Hidden {
continue
}
s := rpad(c.Name()+":", c.NamePadding()) + c.Short
if _, ok := c.Annotations["IsCore"]; ok {
coreCommands = append(coreCommands, s)
} else {
additionalCommands = append(additionalCommands, s)
}
}
// If there are no core commands, assume everything is a core command
if len(coreCommands) == 0 {
coreCommands = additionalCommands
additionalCommands = []string{}
}
type helpEntry struct {
Title string
Body string
}
helpEntries := []helpEntry{}
if command.Long != "" {
helpEntries = append(helpEntries, helpEntry{"", command.Long})
} else if command.Short != "" {
helpEntries = append(helpEntries, helpEntry{"", command.Short})
}
helpEntries = append(helpEntries, helpEntry{"USAGE", command.UseLine()})
if len(coreCommands) > 0 {
helpEntries = append(helpEntries, helpEntry{"CORE COMMANDS", strings.Join(coreCommands, "\n")})
}
if len(additionalCommands) > 0 {
helpEntries = append(helpEntries, helpEntry{"ADDITIONAL COMMANDS", strings.Join(additionalCommands, "\n")})
}
flagUsages := command.LocalFlags().FlagUsages()
if flagUsages != "" {
dedent := regexp.MustCompile(`(?m)^ `)
helpEntries = append(helpEntries, helpEntry{"FLAGS", dedent.ReplaceAllString(flagUsages, "")})
}
if _, ok := command.Annotations["help:arguments"]; ok {
helpEntries = append(helpEntries, helpEntry{"ARGUMENTS", command.Annotations["help:arguments"]})
}
if command.Example != "" {
helpEntries = append(helpEntries, helpEntry{"EXAMPLES", command.Example})
}
helpEntries = append(helpEntries, helpEntry{"LEARN MORE", `
Use "gh <command> <subcommand> --help" for more information about a command.
Read the manual at https://cli.github.com/manual`})
if _, ok := command.Annotations["help:feedback"]; ok {
helpEntries = append(helpEntries, helpEntry{"FEEDBACK", command.Annotations["help:feedback"]})
}
out := colorableOut(command)
for _, e := range helpEntries {
if e.Title != "" {
// If there is a title, add indentation to each line in the body
fmt.Fprintln(out, utils.Bold(e.Title))
for _, l := range strings.Split(strings.Trim(e.Body, "\n\r"), "\n") {
fmt.Fprintln(out, " "+l)
}
} else {
// If there is no title print the body as is
fmt.Fprintln(out, e.Body)
}
fmt.Fprintln(out)
}
}
// rpad adds padding to the right of a string.
func rpad(s string, padding int) string {
template := fmt.Sprintf("%%-%ds ", padding)
return fmt.Sprintf(template, s)
}

View file

@ -10,9 +10,11 @@ import (
"strings"
"time"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/api"
"github.com/cli/cli/git"
"github.com/cli/cli/internal/ghrepo"
"github.com/cli/cli/pkg/cmdutil"
"github.com/cli/cli/pkg/githubtemplate"
"github.com/cli/cli/utils"
"github.com/spf13/cobra"
@ -49,17 +51,24 @@ func init() {
}
var issueCmd = &cobra.Command{
Use: "issue",
Use: "issue <command>",
Short: "Create and view issues",
Long: `Work with GitHub issues.
An issue can be supplied as argument in any of the following formats:
Long: `Work with GitHub issues`,
Example: heredoc.Doc(`
$ gh issue list
$ gh issue create --label bug
$ gh issue view --web
`),
Annotations: map[string]string{
"IsCore": "true",
"help:arguments": `An issue can be supplied as argument in any of the following formats:
- by number, e.g. "123"; or
- by URL, e.g. "https://github.com/OWNER/REPO/issues/123".`,
- by URL, e.g. "https://github.com/OWNER/REPO/issues/123".`},
}
var issueCreateCmd = &cobra.Command{
Use: "create",
Short: "Create a new issue",
Args: cmdutil.NoArgsQuoteReminder,
RunE: issueCreate,
Example: heredoc.Doc(`
$ gh issue create --title "I found a bug" --body "Nothing works"
@ -72,22 +81,23 @@ var issueCreateCmd = &cobra.Command{
var issueListCmd = &cobra.Command{
Use: "list",
Short: "List and filter issues in this repository",
RunE: issueList,
Example: heredoc.Doc(`
$ gh issue list -l "help wanted"
$ gh issue list -A monalisa
`),
Args: cmdutil.NoArgsQuoteReminder,
RunE: issueList,
}
var issueStatusCmd = &cobra.Command{
Use: "status",
Short: "Show status of relevant issues",
Args: cmdutil.NoArgsQuoteReminder,
RunE: issueStatus,
}
var issueViewCmd = &cobra.Command{
Use: "view {<number> | <url>}",
Args: func(cmd *cobra.Command, args []string) error {
if len(args) < 1 {
return FlagError{errors.New("issue number or URL required as argument")}
}
return nil
},
Use: "view {<number> | <url>}",
Short: "View an issue",
Args: cobra.ExactArgs(1),
Long: `Display the title, body, and other information about an issue.
With '--web', open the issue in a web browser instead.`,
@ -137,6 +147,9 @@ func issueList(cmd *cobra.Command, args []string) error {
if err != nil {
return err
}
if limit <= 0 {
return fmt.Errorf("invalid limit: %v", limit)
}
author, err := cmd.Flags().GetString("author")
if err != nil {
@ -179,7 +192,7 @@ func issueStatus(cmd *cobra.Command, args []string) error {
return err
}
currentUser, err := ctx.AuthLogin()
currentUser, err := api.CurrentLoginName(apiClient)
if err != nil {
return err
}
@ -250,11 +263,9 @@ func issueView(cmd *cobra.Command, args []string) error {
if web {
fmt.Fprintf(cmd.ErrOrStderr(), "Opening %s in your browser.\n", openURL)
return utils.OpenInBrowser(openURL)
} else {
out := colorableOut(cmd)
return printIssuePreview(out, issue)
}
out := colorableOut(cmd)
return printIssuePreview(out, issue)
}
func issueStateTitleWithColor(state string) string {
@ -363,11 +374,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")
}
}
@ -403,12 +414,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))
@ -427,6 +441,7 @@ func issueCreate(cmd *cobra.Command, args []string) error {
action := SubmitAction
tb := issueMetadataState{
Type: issueMetadata,
Assignees: assignees,
Labels: labelNames,
Projects: projectNames,
@ -436,7 +451,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)
}
@ -462,12 +484,15 @@ func issueCreate(cmd *cobra.Command, args []string) error {
}
if action == PreviewAction {
openURL := fmt.Sprintf(
"https://github.com/%s/issues/new/?title=%s&body=%s",
ghrepo.FullName(baseRepo),
url.QueryEscape(title),
url.QueryEscape(body),
)
openURL := fmt.Sprintf("https://github.com/%s/issues/new/", ghrepo.FullName(baseRepo))
milestone := ""
if len(milestoneTitles) > 0 {
milestone = milestoneTitles[0]
}
openURL, err = withPrAndIssueQueryParams(openURL, title, body, assignees, labelNames, projectNames, milestone)
if err != nil {
return err
}
// TODO could exceed max url length for explorer
fmt.Fprintf(cmd.ErrOrStderr(), "Opening %s in your browser.\n", displayURL(openURL))
return utils.OpenInBrowser(openURL)

View file

@ -20,6 +20,9 @@ func TestIssueStatus(t *testing.T) {
initBlankContext("", "OWNER/REPO", "master")
http := initFakeHTTP()
http.StubRepoResponse("OWNER", "REPO")
http.Register(
httpmock.GraphQL(`\bviewer\b`),
httpmock.StringResponse(`{"data":{"viewer":{"login":"octocat"}}}`))
jsonFile, _ := os.Open("../test/fixtures/issueStatus.json")
defer jsonFile.Close()
@ -49,6 +52,9 @@ func TestIssueStatus_blankSlate(t *testing.T) {
initBlankContext("", "OWNER/REPO", "master")
http := initFakeHTTP()
http.StubRepoResponse("OWNER", "REPO")
http.Register(
httpmock.GraphQL(`\bviewer\b`),
httpmock.StringResponse(`{"data":{"viewer":{"login":"octocat"}}}`))
http.StubResponse(200, bytes.NewBufferString(`
{ "data": { "repository": {
@ -86,6 +92,9 @@ func TestIssueStatus_disabledIssues(t *testing.T) {
initBlankContext("", "OWNER/REPO", "master")
http := initFakeHTTP()
http.StubRepoResponse("OWNER", "REPO")
http.Register(
httpmock.GraphQL(`\bviewer\b`),
httpmock.StringResponse(`{"data":{"viewer":{"login":"octocat"}}}`))
http.StubResponse(200, bytes.NewBufferString(`
{ "data": { "repository": {
@ -172,6 +181,18 @@ No issues match your search in OWNER/REPO
eq(t, reqBody.Variables.Author, "foo")
}
func TestIssueList_withInvalidLimitFlag(t *testing.T) {
initBlankContext("", "OWNER/REPO", "master")
http := initFakeHTTP()
http.StubRepoResponse("OWNER", "REPO")
_, err := RunCommand("issue list --limit=0")
if err == nil || err.Error() != "invalid limit: 0" {
t.Errorf("error running command `issue list`: %v", err)
}
}
func TestIssueList_nullAssigneeLabels(t *testing.T) {
initBlankContext("", "OWNER/REPO", "master")
http := initFakeHTTP()
@ -637,7 +658,7 @@ func TestIssueCreate_webTitleBody(t *testing.T) {
t.Fatal("expected a command to run")
}
url := strings.ReplaceAll(seenCmd.Args[len(seenCmd.Args)-1], "^", "")
eq(t, url, "https://github.com/OWNER/REPO/issues/new?title=mytitle&body=mybody")
eq(t, url, "https://github.com/OWNER/REPO/issues/new?body=mybody&title=mytitle")
eq(t, output.String(), "Opening github.com/OWNER/REPO/issues/new in your browser.\n")
}

View file

@ -1,6 +1,7 @@
package command
import (
"errors"
"fmt"
"io"
"regexp"
@ -8,10 +9,13 @@ import (
"strconv"
"strings"
"github.com/AlecAivazis/survey/v2"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/api"
"github.com/cli/cli/context"
"github.com/cli/cli/git"
"github.com/cli/cli/internal/ghrepo"
"github.com/cli/cli/pkg/cmdutil"
"github.com/cli/cli/pkg/text"
"github.com/cli/cli/utils"
"github.com/spf13/cobra"
@ -26,7 +30,8 @@ func init() {
prCmd.AddCommand(prCloseCmd)
prCmd.AddCommand(prReopenCmd)
prCmd.AddCommand(prMergeCmd)
prMergeCmd.Flags().BoolP("merge", "m", true, "Merge the commits with the base branch")
prMergeCmd.Flags().BoolP("delete-branch", "d", true, "Delete the local and remote branch after merge")
prMergeCmd.Flags().BoolP("merge", "m", false, "Merge the commits with the base branch")
prMergeCmd.Flags().BoolP("rebase", "r", false, "Rebase the commits onto the base branch")
prMergeCmd.Flags().BoolP("squash", "s", false, "Squash the commits into one commit and merge it into the base branch")
prCmd.AddCommand(prReadyCmd)
@ -43,23 +48,36 @@ func init() {
}
var prCmd = &cobra.Command{
Use: "pr",
Use: "pr <command>",
Short: "Create, view, and checkout pull requests",
Long: `Work with GitHub pull requests.
A pull request can be supplied as argument in any of the following formats:
Long: `Work with GitHub pull requests`,
Example: heredoc.Doc(`
$ gh pr checkout 353
$ gh pr create --fill
$ gh pr view --web
`),
Annotations: map[string]string{
"IsCore": "true",
"help:arguments": `A pull request can be supplied as argument in any of the following formats:
- by number, e.g. "123";
- by URL, e.g. "https://github.com/OWNER/REPO/pull/123"; or
- by the name of its head branch, e.g. "patch-1" or "OWNER:patch-1".`,
- by the name of its head branch, e.g. "patch-1" or "OWNER:patch-1".`},
}
var prListCmd = &cobra.Command{
Use: "list",
Short: "List and filter pull requests in this repository",
RunE: prList,
Args: cmdutil.NoArgsQuoteReminder,
Example: heredoc.Doc(`
$ gh pr list --limit 999
$ gh pr list --state closed
$ gh pr list --label "priority 1" --label "bug"
`),
RunE: prList,
}
var prStatusCmd = &cobra.Command{
Use: "status",
Short: "Show status of relevant pull requests",
Args: cmdutil.NoArgsQuoteReminder,
RunE: prStatus,
}
var prViewCmd = &cobra.Command{
@ -93,7 +111,7 @@ var prMergeCmd = &cobra.Command{
}
var prReadyCmd = &cobra.Command{
Use: "ready [<number> | <url> | <branch>]",
Short: "Make a pull request as ready for review",
Short: "Mark a pull request as ready for review",
Args: cobra.MaximumNArgs(1),
RunE: prReady,
}
@ -105,11 +123,6 @@ func prStatus(cmd *cobra.Command, args []string) error {
return err
}
currentUser, err := ctx.AuthLogin()
if err != nil {
return err
}
baseRepo, err := determineBaseRepo(apiClient, cmd, ctx)
if err != nil {
return err
@ -122,6 +135,8 @@ func prStatus(cmd *cobra.Command, args []string) error {
return fmt.Errorf("could not query for pull request for current branch: %w", err)
}
// the `@me` macro is available because the API lookup is ElasticSearch-based
currentUser := "@me"
prPayload, err := api.PullRequests(apiClient, baseRepo, currentPRNumber, currentPRHeadRef, currentUser)
if err != nil {
return err
@ -183,6 +198,10 @@ func prList(cmd *cobra.Command, args []string) error {
if err != nil {
return err
}
if limit <= 0 {
return fmt.Errorf("invalid limit: %v", limit)
}
state, err := cmd.Flags().GetString("state")
if err != nil {
return err
@ -302,67 +321,23 @@ func prView(cmd *cobra.Command, args []string) error {
return err
}
var baseRepo ghrepo.Interface
var prArg string
if len(args) > 0 {
prArg = args[0]
if prNum, repo := prFromURL(prArg); repo != nil {
prArg = prNum
baseRepo = repo
}
}
if baseRepo == nil {
baseRepo, err = determineBaseRepo(apiClient, cmd, ctx)
if err != nil {
return err
}
}
web, err := cmd.Flags().GetBool("web")
if err != nil {
return err
}
var openURL string
var pr *api.PullRequest
if len(args) > 0 {
pr, err = prFromArg(apiClient, baseRepo, prArg)
if err != nil {
return err
}
openURL = pr.URL
} else {
prNumber, branchWithOwner, err := prSelectorForCurrentBranch(ctx, baseRepo)
if err != nil {
return err
}
if prNumber > 0 {
openURL = fmt.Sprintf("https://github.com/%s/pull/%d", ghrepo.FullName(baseRepo), prNumber)
if !web {
pr, err = api.PullRequestByNumber(apiClient, baseRepo, prNumber)
if err != nil {
return err
}
}
} else {
pr, err = api.PullRequestForBranch(apiClient, baseRepo, "", branchWithOwner)
if err != nil {
return err
}
openURL = pr.URL
}
pr, _, err := prFromArgs(ctx, apiClient, cmd, args)
if err != nil {
return err
}
openURL := pr.URL
if web {
fmt.Fprintf(cmd.ErrOrStderr(), "Opening %s in your browser.\n", openURL)
return utils.OpenInBrowser(openURL)
} else {
out := colorableOut(cmd)
return printPrPreview(out, pr)
}
out := colorableOut(cmd)
return printPrPreview(out, pr)
}
func prClose(cmd *cobra.Command, args []string) error {
@ -372,12 +347,7 @@ func prClose(cmd *cobra.Command, args []string) error {
return err
}
baseRepo, err := determineBaseRepo(apiClient, cmd, ctx)
if err != nil {
return err
}
pr, err := prFromArg(apiClient, baseRepo, args[0])
pr, baseRepo, err := prFromArgs(ctx, apiClient, cmd, args)
if err != nil {
return err
}
@ -407,12 +377,7 @@ func prReopen(cmd *cobra.Command, args []string) error {
return err
}
baseRepo, err := determineBaseRepo(apiClient, cmd, ctx)
if err != nil {
return err
}
pr, err := prFromArg(apiClient, baseRepo, args[0])
pr, baseRepo, err := prFromArgs(ctx, apiClient, cmd, args)
if err != nil {
return err
}
@ -444,68 +409,186 @@ func prMerge(cmd *cobra.Command, args []string) error {
return err
}
baseRepo, err := determineBaseRepo(apiClient, cmd, ctx)
pr, baseRepo, err := prFromArgs(ctx, apiClient, cmd, args)
if err != nil {
return err
}
var pr *api.PullRequest
if len(args) > 0 {
pr, err = prFromArg(apiClient, baseRepo, args[0])
if err != nil {
return err
}
} else {
prNumber, branchWithOwner, err := prSelectorForCurrentBranch(ctx, baseRepo)
if err != nil {
return err
}
if prNumber != 0 {
pr, err = api.PullRequestByNumber(apiClient, baseRepo, prNumber)
} else {
pr, err = api.PullRequestForBranch(apiClient, baseRepo, "", branchWithOwner)
}
if err != nil {
return err
}
}
if pr.State == "MERGED" {
if pr.Mergeable == "CONFLICTING" {
err := fmt.Errorf("%s Pull request #%d has conflicts and isn't mergeable ", utils.Red("!"), pr.Number)
return err
} else if pr.Mergeable == "UNKNOWN" {
err := fmt.Errorf("%s Pull request #%d can't be merged right now; try again in a few seconds", utils.Red("!"), pr.Number)
return err
} else if pr.State == "MERGED" {
err := fmt.Errorf("%s Pull request #%d was already merged", utils.Red("!"), pr.Number)
return err
}
rebase, err := cmd.Flags().GetBool("rebase")
if err != nil {
return err
}
squash, err := cmd.Flags().GetBool("squash")
var mergeMethod api.PullRequestMergeMethod
deleteBranch, err := cmd.Flags().GetBool("delete-branch")
if err != nil {
return err
}
var output string
if rebase {
output = fmt.Sprintf("%s Rebased and merged pull request #%d\n", utils.Green("✔"), pr.Number)
deleteLocalBranch := !cmd.Flags().Changed("repo")
crossRepoPR := pr.HeadRepositoryOwner.Login != baseRepo.RepoOwner()
// Ensure only one merge method is specified
enabledFlagCount := 0
isInteractive := false
if b, _ := cmd.Flags().GetBool("merge"); b {
enabledFlagCount++
mergeMethod = api.PullRequestMergeMethodMerge
}
if b, _ := cmd.Flags().GetBool("rebase"); b {
enabledFlagCount++
mergeMethod = api.PullRequestMergeMethodRebase
}
if b, _ := cmd.Flags().GetBool("squash"); b {
enabledFlagCount++
mergeMethod = api.PullRequestMergeMethodSquash
}
if enabledFlagCount == 0 {
isInteractive = true
} else if enabledFlagCount > 1 {
return errors.New("expected exactly one of --merge, --rebase, or --squash to be true")
}
if isInteractive {
mergeMethod, deleteBranch, err = prInteractiveMerge(deleteLocalBranch, crossRepoPR)
if err != nil {
return nil
}
}
var action string
if mergeMethod == api.PullRequestMergeMethodRebase {
action = "Rebased and merged"
err = api.PullRequestMerge(apiClient, baseRepo, pr, api.PullRequestMergeMethodRebase)
} else if squash {
output = fmt.Sprintf("%s Squashed and merged pull request #%d\n", utils.Green("✔"), pr.Number)
} else if mergeMethod == api.PullRequestMergeMethodSquash {
action = "Squashed and merged"
err = api.PullRequestMerge(apiClient, baseRepo, pr, api.PullRequestMergeMethodSquash)
} else {
output = fmt.Sprintf("%s Merged pull request #%d\n", utils.Green("✔"), pr.Number)
} else if mergeMethod == api.PullRequestMergeMethodMerge {
action = "Merged"
err = api.PullRequestMerge(apiClient, baseRepo, pr, api.PullRequestMergeMethodMerge)
} else {
err = fmt.Errorf("unknown merge method (%d) used", mergeMethod)
return err
}
if err != nil {
return fmt.Errorf("API call failed: %w", err)
}
fmt.Fprint(colorableOut(cmd), output)
fmt.Fprintf(colorableOut(cmd), "%s %s pull request #%d\n", utils.Magenta("✔"), action, pr.Number)
if deleteBranch {
repo, err := api.GitHubRepo(apiClient, baseRepo)
if err != nil {
return err
}
currentBranch, err := ctx.Branch()
if err != nil {
return err
}
branchSwitchString := ""
if deleteLocalBranch && !crossRepoPR {
var branchToSwitchTo string
if currentBranch == pr.HeadRefName {
branchToSwitchTo = repo.DefaultBranchRef.Name
err = git.CheckoutBranch(repo.DefaultBranchRef.Name)
if err != nil {
return err
}
}
localBranchExists := git.HasLocalBranch(pr.HeadRefName)
if localBranchExists {
err = git.DeleteLocalBranch(pr.HeadRefName)
if err != nil {
err = fmt.Errorf("failed to delete local branch %s: %w", utils.Cyan(pr.HeadRefName), err)
return err
}
}
if branchToSwitchTo != "" {
branchSwitchString = fmt.Sprintf(" and switched to branch %s", utils.Cyan(branchToSwitchTo))
}
}
if !crossRepoPR {
err = api.BranchDeleteRemote(apiClient, baseRepo, pr.HeadRefName)
if err != nil {
err = fmt.Errorf("failed to delete remote branch %s: %w", utils.Cyan(pr.HeadRefName), err)
return err
}
}
fmt.Fprintf(colorableOut(cmd), "%s Deleted branch %s%s\n", utils.Red("✔"), utils.Cyan(pr.HeadRefName), branchSwitchString)
}
return nil
}
func prInteractiveMerge(deleteLocalBranch bool, crossRepoPR bool) (api.PullRequestMergeMethod, bool, error) {
mergeMethodQuestion := &survey.Question{
Name: "mergeMethod",
Prompt: &survey.Select{
Message: "What merge method would you like to use?",
Options: []string{"Create a merge commit", "Rebase and merge", "Squash and merge"},
Default: "Create a merge commit",
},
}
qs := []*survey.Question{mergeMethodQuestion}
if !crossRepoPR {
var message string
if deleteLocalBranch {
message = "Delete the branch locally and on GitHub?"
} else {
message = "Delete the branch on GitHub?"
}
deleteBranchQuestion := &survey.Question{
Name: "deleteBranch",
Prompt: &survey.Confirm{
Message: message,
Default: true,
},
}
qs = append(qs, deleteBranchQuestion)
}
answers := struct {
MergeMethod int
DeleteBranch bool
}{}
err := SurveyAsk(qs, &answers)
if err != nil {
return 0, false, fmt.Errorf("could not prompt: %w", err)
}
var mergeMethod api.PullRequestMergeMethod
switch answers.MergeMethod {
case 0:
mergeMethod = api.PullRequestMergeMethodMerge
case 1:
mergeMethod = api.PullRequestMergeMethodRebase
case 2:
mergeMethod = api.PullRequestMergeMethodSquash
}
deleteBranch := answers.DeleteBranch
return mergeMethod, deleteBranch, nil
}
func printPrPreview(out io.Writer, pr *api.PullRequest) error {
// Header (Title and State)
fmt.Fprintln(out, utils.Bold(pr.Title))
@ -564,41 +647,11 @@ func prReady(cmd *cobra.Command, args []string) error {
return err
}
baseRepo, err := determineBaseRepo(apiClient, cmd, ctx)
pr, baseRepo, err := prFromArgs(ctx, apiClient, cmd, args)
if err != nil {
return err
}
var pr *api.PullRequest
if len(args) > 0 {
var prNumber string
n, _ := prFromURL(args[0])
if n != "" {
prNumber = n
} else {
prNumber = args[0]
}
pr, err = prFromArg(apiClient, baseRepo, prNumber)
if err != nil {
return err
}
} else {
prNumber, branchWithOwner, err := prSelectorForCurrentBranch(ctx, baseRepo)
if err != nil {
return err
}
if prNumber != 0 {
pr, err = api.PullRequestByNumber(apiClient, baseRepo, prNumber)
} else {
pr, err = api.PullRequestForBranch(apiClient, baseRepo, "", branchWithOwner)
}
if err != nil {
return err
}
}
if pr.Closed {
err := fmt.Errorf("%s Pull request #%d is closed. Only draft pull requests can be marked as \"ready for review\"", utils.Red("!"), pr.Number)
return err
@ -793,23 +846,6 @@ func prProjectList(pr api.PullRequest) string {
return list
}
var prURLRE = regexp.MustCompile(`^https://github\.com/([^/]+)/([^/]+)/pull/(\d+)`)
func prFromURL(arg string) (string, ghrepo.Interface) {
if m := prURLRE.FindStringSubmatch(arg); m != nil {
return m[3], ghrepo.New(m[1], m[2])
}
return "", nil
}
func prFromArg(apiClient *api.Client, baseRepo ghrepo.Interface, arg string) (*api.PullRequest, error) {
if prNumber, err := strconv.Atoi(strings.TrimPrefix(arg, "#")); err == nil {
return api.PullRequestByNumber(apiClient, baseRepo, prNumber)
}
return api.PullRequestForBranch(apiClient, baseRepo, "", arg)
}
func prSelectorForCurrentBranch(ctx context.Context, baseRepo ghrepo.Interface) (prNumber int, prHeadRef string, err error) {
prHeadRef, err = ctx.Branch()
if err != nil {

View file

@ -26,21 +26,7 @@ func prCheckout(cmd *cobra.Command, args []string) error {
return err
}
var baseRepo ghrepo.Interface
prArg := args[0]
if prNum, repo := prFromURL(prArg); repo != nil {
prArg = prNum
baseRepo = repo
}
if baseRepo == nil {
baseRepo, err = determineBaseRepo(apiClient, cmd, ctx)
if err != nil {
return err
}
}
pr, err := prFromArg(apiClient, baseRepo, prArg)
pr, baseRepo, err := prFromArgs(ctx, apiClient, cmd, args)
if err != nil {
return err
}
@ -59,6 +45,7 @@ func prCheckout(cmd *cobra.Command, args []string) error {
var cmdQueue [][]string
newBranchName := pr.HeadRefName
if headRemote != nil {
// there is an existing git remote for PR head
remoteBranch := fmt.Sprintf("%s/%s", headRemote.Name, pr.HeadRefName)

View file

@ -76,7 +76,6 @@ func TestPRCheckout_urlArg(t *testing.T) {
return ctx
}
http := initFakeHTTP()
http.StubResponse(200, bytes.NewBufferString(`
{ "data": { "repository": { "pullRequest": {
"number": 123,
@ -125,7 +124,6 @@ func TestPRCheckout_urlArg_differentBase(t *testing.T) {
return ctx
}
http := initFakeHTTP()
http.StubResponse(200, bytes.NewBufferString(`
{ "data": { "repository": { "pullRequest": {
"number": 123,

View file

@ -12,6 +12,7 @@ import (
"github.com/cli/cli/context"
"github.com/cli/cli/git"
"github.com/cli/cli/internal/ghrepo"
"github.com/cli/cli/pkg/cmdutil"
"github.com/cli/cli/pkg/githubtemplate"
"github.com/cli/cli/utils"
"github.com/spf13/cobra"
@ -41,8 +42,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
}
@ -193,8 +194,18 @@ func prCreate(cmd *cobra.Command, _ []string) error {
}
}
isDraft, err := cmd.Flags().GetBool("draft")
if err != nil {
return fmt.Errorf("could not parse draft: %w", err)
}
if !isWeb && !autofill {
fmt.Fprintf(colorableErr(cmd), "\nCreating pull request for %s into %s in %s\n\n",
message := "\nCreating pull request for %s into %s in %s\n\n"
if isDraft {
message = "\nCreating draft pull request for %s into %s in %s\n\n"
}
fmt.Fprintf(colorableErr(cmd), message,
utils.Cyan(headBranch),
utils.Cyan(baseBranch),
ghrepo.FullName(baseRepo))
@ -204,6 +215,7 @@ func prCreate(cmd *cobra.Command, _ []string) error {
}
tb := issueMetadataState{
Type: prMetadata,
Reviewers: reviewers,
Assignees: assignees,
Labels: labelNames,
@ -214,13 +226,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)
}
@ -244,13 +257,12 @@ func prCreate(cmd *cobra.Command, _ []string) error {
return errors.New("pull request title must not be blank")
}
isDraft, err := cmd.Flags().GetBool("draft")
if err != nil {
return fmt.Errorf("could not parse draft: %w", err)
}
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
@ -338,7 +350,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)
@ -390,25 +409,52 @@ 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{
Use: "create",
Short: "Create a pull request",
Args: cmdutil.NoArgsQuoteReminder,
RunE: prCreate,
Example: heredoc.Doc(`
$ gh pr create --title "The bug is fixed" --body "Everything works again"

View file

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

View file

@ -1,13 +1,10 @@
package command
import (
"errors"
"fmt"
"os"
"strconv"
"strings"
"github.com/cli/cli/api"
"github.com/cli/cli/utils"
"github.com/spf13/cobra"
)
@ -39,43 +36,12 @@ func prDiff(cmd *cobra.Command, args []string) error {
return err
}
baseRepo, err := determineBaseRepo(apiClient, cmd, ctx)
pr, baseRepo, err := prFromArgs(ctx, apiClient, cmd, args)
if err != nil {
return fmt.Errorf("could not determine base repo: %w", err)
return fmt.Errorf("could not find pull request: %w", err)
}
// begin pr resolution boilerplate
var prNum int
branchWithOwner := ""
if len(args) == 0 {
prNum, branchWithOwner, err = prSelectorForCurrentBranch(ctx, baseRepo)
if err != nil {
return fmt.Errorf("could not query for pull request for current branch: %w", err)
}
} else {
prArg, repo := prFromURL(args[0])
if repo != nil {
baseRepo = repo
} else {
prArg = strings.TrimPrefix(args[0], "#")
}
prNum, err = strconv.Atoi(prArg)
if err != nil {
return errors.New("could not parse pull request argument")
}
}
if prNum < 1 {
pr, err := api.PullRequestForBranch(apiClient, baseRepo, "", branchWithOwner)
if err != nil {
return fmt.Errorf("could not find pull request: %w", err)
}
prNum = pr.Number
}
// end pr resolution boilerplate
diff, err := apiClient.PullRequestDiff(baseRepo, prNum)
diff, err := apiClient.PullRequestDiff(baseRepo, pr.Number)
if err != nil {
return fmt.Errorf("could not find pull request diff: %w", err)
}

View file

@ -37,6 +37,11 @@ func TestPRDiff_argument_not_found(t *testing.T) {
initBlankContext("", "OWNER/REPO", "master")
http := initFakeHTTP()
http.StubRepoResponse("OWNER", "REPO")
http.StubResponse(200, bytes.NewBufferString(`
{ "data": { "repository": {
"pullRequest": { "number": 123 }
} } }
`))
http.StubResponse(404, bytes.NewBufferString(""))
_, err := RunCommand("pr diff 123")
if err == nil {

109
command/pr_lookup.go Normal file
View file

@ -0,0 +1,109 @@
package command
import (
"fmt"
"regexp"
"strconv"
"strings"
"github.com/cli/cli/api"
"github.com/cli/cli/context"
"github.com/cli/cli/git"
"github.com/cli/cli/internal/ghrepo"
"github.com/spf13/cobra"
)
func prFromArgs(ctx context.Context, apiClient *api.Client, cmd *cobra.Command, args []string) (*api.PullRequest, ghrepo.Interface, error) {
if len(args) == 1 {
// First check to see if the prString is a url, return repo from url if found. This
// is run first because we don't need to run determineBaseRepo for this path
prString := args[0]
pr, r, err := prFromURL(ctx, apiClient, prString)
if pr != nil || err != nil {
return pr, r, err
}
}
repo, err := determineBaseRepo(apiClient, cmd, ctx)
if err != nil {
return nil, nil, fmt.Errorf("could not determine base repo: %w", err)
}
// If there are no args see if we can guess the PR from the current branch
if len(args) == 0 {
pr, err := prForCurrentBranch(ctx, apiClient, repo)
return pr, repo, err
} else {
prString := args[0]
// Next see if the prString is a number and use that to look up the url
pr, err := prFromNumberString(ctx, apiClient, repo, prString)
if pr != nil || err != nil {
return pr, repo, err
}
// Last see if it is a branch name
pr, err = api.PullRequestForBranch(apiClient, repo, "", prString)
return pr, repo, err
}
}
func prFromNumberString(ctx context.Context, apiClient *api.Client, repo ghrepo.Interface, s string) (*api.PullRequest, error) {
if prNumber, err := strconv.Atoi(strings.TrimPrefix(s, "#")); err == nil {
return api.PullRequestByNumber(apiClient, repo, prNumber)
}
return nil, nil
}
func prFromURL(ctx context.Context, apiClient *api.Client, s string) (*api.PullRequest, ghrepo.Interface, error) {
r := regexp.MustCompile(`^https://github\.com/([^/]+)/([^/]+)/pull/(\d+)`)
if m := r.FindStringSubmatch(s); m != nil {
repo := ghrepo.New(m[1], m[2])
prNumberString := m[3]
pr, err := prFromNumberString(ctx, apiClient, repo, prNumberString)
return pr, repo, err
}
return nil, nil, nil
}
func prForCurrentBranch(ctx context.Context, apiClient *api.Client, repo ghrepo.Interface) (*api.PullRequest, error) {
prHeadRef, err := ctx.Branch()
if err != nil {
return nil, err
}
branchConfig := git.ReadBranchConfig(prHeadRef)
// the branch is configured to merge a special PR head ref
prHeadRE := regexp.MustCompile(`^refs/pull/(\d+)/head$`)
if m := prHeadRE.FindStringSubmatch(branchConfig.MergeRef); m != nil {
return prFromNumberString(ctx, apiClient, repo, m[1])
}
var branchOwner string
if branchConfig.RemoteURL != nil {
// the branch merges from a remote specified by URL
if r, err := ghrepo.FromURL(branchConfig.RemoteURL); err == nil {
branchOwner = r.RepoOwner()
}
} else if branchConfig.RemoteName != "" {
// the branch merges from a remote specified by name
rem, _ := ctx.Remotes()
if r, err := rem.FindByName(branchConfig.RemoteName); err == nil {
branchOwner = r.RepoOwner()
}
}
if branchOwner != "" {
if strings.HasPrefix(branchConfig.MergeRef, "refs/heads/") {
prHeadRef = strings.TrimPrefix(branchConfig.MergeRef, "refs/heads/")
}
// prepend `OWNER:` if this branch is pushed to a fork
if !strings.EqualFold(branchOwner, repo.RepoOwner()) {
prHeadRef = fmt.Sprintf("%s:%s", branchOwner, prHeadRef)
}
}
return api.PullRequestForBranch(apiClient, repo, "", prHeadRef)
}

View file

@ -3,10 +3,9 @@ package command
import (
"errors"
"fmt"
"strconv"
"strings"
"github.com/AlecAivazis/survey/v2"
"github.com/MakeNowJust/heredoc"
"github.com/spf13/cobra"
"github.com/cli/cli/api"
@ -24,19 +23,25 @@ func init() {
}
var prReviewCmd = &cobra.Command{
Use: "review [{<number> | <url> | <branch>]",
Short: "Add a review to a pull request.",
Args: cobra.MaximumNArgs(1),
Long: `Add a review to either a specified pull request or the pull request associated with the current branch.
Use: "review [<number> | <url> | <branch>]",
Short: "Add a review to a pull request",
Long: `Add a review to a pull request.
Examples:
gh pr review # add a review for the current branch's pull request
gh pr review 123 # add a review for pull request 123
gh pr review -a # mark the current branch's pull request as approved
gh pr review -c -b "interesting" # comment on the current branch's pull request
gh pr review 123 -r -b "needs more ascii art" # request changes on pull request 123
`,
Without an argument, the pull request that belongs to the current branch is reviewed.`,
Example: heredoc.Doc(`
# approve the pull request of the current branch
$ gh pr review --approve
# leave a review comment for the current branch
$ gh pr review --comment -b "interesting"
# add a review for a specific pull request
$ gh pr review 123
# request changes on a specific pull request
$ gh pr review 123 -r -b "needs more ASCII art"
`),
Args: cobra.MaximumNArgs(1),
RunE: prReview,
}
@ -91,30 +96,9 @@ func prReview(cmd *cobra.Command, args []string) error {
return err
}
baseRepo, err := determineBaseRepo(apiClient, cmd, ctx)
pr, _, err := prFromArgs(ctx, apiClient, cmd, args)
if err != nil {
return fmt.Errorf("could not determine base repo: %w", err)
}
var prNum int
branchWithOwner := ""
if len(args) == 0 {
prNum, branchWithOwner, err = prSelectorForCurrentBranch(ctx, baseRepo)
if err != nil {
return fmt.Errorf("could not query for pull request for current branch: %w", err)
}
} else {
prArg, repo := prFromURL(args[0])
if repo != nil {
baseRepo = repo
} else {
prArg = strings.TrimPrefix(args[0], "#")
}
prNum, err = strconv.Atoi(prArg)
if err != nil {
return errors.New("could not parse pull request argument")
}
return err
}
reviewData, err := processReviewOpt(cmd)
@ -122,20 +106,6 @@ func prReview(cmd *cobra.Command, args []string) error {
return fmt.Errorf("did not understand desired review action: %w", err)
}
var pr *api.PullRequest
if prNum > 0 {
pr, err = api.PullRequestByNumber(apiClient, baseRepo, prNum)
if err != nil {
return fmt.Errorf("could not find pull request: %w", err)
}
} else {
pr, err = api.PullRequestForBranch(apiClient, baseRepo, "", branchWithOwner)
if err != nil {
return fmt.Errorf("could not find pull request: %w", err)
}
prNum = pr.Number
}
out := colorableOut(cmd)
if reviewData == nil {
@ -156,11 +126,11 @@ func prReview(cmd *cobra.Command, args []string) error {
switch reviewData.State {
case api.ReviewComment:
fmt.Fprintf(out, "%s Reviewed pull request #%d\n", utils.Gray("-"), prNum)
fmt.Fprintf(out, "%s Reviewed pull request #%d\n", utils.Gray("-"), pr.Number)
case api.ReviewApprove:
fmt.Fprintf(out, "%s Approved pull request #%d\n", utils.Green("✓"), prNum)
fmt.Fprintf(out, "%s Approved pull request #%d\n", utils.Green("✓"), pr.Number)
case api.ReviewRequestChanges:
fmt.Fprintf(out, "%s Requested changes to pull request #%d\n", utils.Red("+"), prNum)
fmt.Fprintf(out, "%s Requested changes to pull request #%d\n", utils.Red("+"), pr.Number)
}
return nil
@ -217,7 +187,7 @@ func reviewSurvey(cmd *cobra.Command) (*api.PullRequestReviewInput, error) {
}
bodyQs := []*survey.Question{
&survey.Question{
{
Name: "body",
Prompt: &surveyext.GhEditor{
BlankAllowed: blankAllowed,

View file

@ -18,6 +18,11 @@ func TestPRReview_validation(t *testing.T) {
`pr review --approve --comment -b"hey" 123`,
} {
http.StubRepoResponse("OWNER", "REPO")
http.StubResponse(200, bytes.NewBufferString(`
{ "data": { "repository": {
"pullRequest": { "number": 123 }
} } }
`))
_, err := RunCommand(cmd)
if err == nil {
t.Fatal("expected error")
@ -30,7 +35,12 @@ func TestPRReview_bad_body(t *testing.T) {
initBlankContext("", "OWNER/REPO", "master")
http := initFakeHTTP()
http.StubRepoResponse("OWNER", "REPO")
_, err := RunCommand(`pr review -b "radical"`)
http.StubResponse(200, bytes.NewBufferString(`
{ "data": { "repository": {
"pullRequest": { "number": 123 }
} } }
`))
_, err := RunCommand(`pr review 123 -b "radical"`)
if err == nil {
t.Fatal("expected error")
}
@ -40,7 +50,6 @@ func TestPRReview_bad_body(t *testing.T) {
func TestPRReview_url_arg(t *testing.T) {
initBlankContext("", "OWNER/REPO", "master")
http := initFakeHTTP()
http.StubRepoResponse("OWNER", "REPO")
http.StubResponse(200, bytes.NewBufferString(`
{ "data": { "repository": { "pullRequest": {
"id": "foobar123",
@ -67,7 +76,7 @@ func TestPRReview_url_arg(t *testing.T) {
test.ExpectLines(t, output.String(), "Approved pull request #123")
bodyBytes, _ := ioutil.ReadAll(http.Requests[2].Body)
bodyBytes, _ := ioutil.ReadAll(http.Requests[1].Body)
reqBody := struct {
Variables struct {
Input struct {
@ -173,6 +182,11 @@ func TestPRReview_blank_comment(t *testing.T) {
initBlankContext("", "OWNER/REPO", "master")
http := initFakeHTTP()
http.StubRepoResponse("OWNER", "REPO")
http.StubResponse(200, bytes.NewBufferString(`
{ "data": { "repository": {
"pullRequest": { "number": 123 }
} } }
`))
_, err := RunCommand(`pr review --comment 123`)
eq(t, err.Error(), "did not understand desired review action: body cannot be blank for comment review")
@ -182,6 +196,11 @@ func TestPRReview_blank_request_changes(t *testing.T) {
initBlankContext("", "OWNER/REPO", "master")
http := initFakeHTTP()
http.StubRepoResponse("OWNER", "REPO")
http.StubResponse(200, bytes.NewBufferString(`
{ "data": { "repository": {
"pullRequest": { "number": 123 }
} } }
`))
_, err := RunCommand(`pr review -r 123`)
eq(t, err.Error(), "did not understand desired review action: body cannot be blank for request-changes review")
@ -194,10 +213,10 @@ func TestPRReview(t *testing.T) {
ExpectedBody string
}
cases := []c{
c{`pr review --request-changes -b"bad"`, "REQUEST_CHANGES", "bad"},
c{`pr review --approve`, "APPROVE", ""},
c{`pr review --approve -b"hot damn"`, "APPROVE", "hot damn"},
c{`pr review --comment --body "i donno"`, "COMMENT", "i donno"},
{`pr review --request-changes -b"bad"`, "REQUEST_CHANGES", "bad"},
{`pr review --approve`, "APPROVE", ""},
{`pr review --approve -b"hot damn"`, "APPROVE", "hot damn"},
{`pr review --comment --body "i donno"`, "COMMENT", "i donno"},
}
for _, kase := range cases {

View file

@ -437,6 +437,17 @@ func TestPRList_filteringAssigneeLabels(t *testing.T) {
}
}
func TestPRList_withInvalidLimitFlag(t *testing.T) {
initBlankContext("", "OWNER/REPO", "master")
http := initFakeHTTP()
http.StubRepoResponse("OWNER", "REPO")
_, err := RunCommand(`pr list --limit=0`)
if err == nil && err.Error() != "invalid limit: 0" {
t.Errorf("error running command `issue list`: %v", err)
}
}
func TestPRView_Preview(t *testing.T) {
tests := map[string]struct {
ownerRepo string
@ -722,11 +733,10 @@ func TestPRView_web_numberArgWithHash(t *testing.T) {
func TestPRView_web_urlArg(t *testing.T) {
initBlankContext("", "OWNER/REPO", "master")
http := initFakeHTTP()
http.StubResponse(200, bytes.NewBufferString(`
{ "data": { "repository": { "pullRequest": {
"url": "https://github.com/OWNER/REPO/pull/23"
} } } }
{ "data": { "repository": { "pullRequest": {
"url": "https://github.com/OWNER/REPO/pull/23"
} } } }
`))
var seenCmd *exec.Cmd
@ -982,10 +992,10 @@ func initWithStubs(branch string, stubs ...stubResponse) {
initBlankContext("", "OWNER/REPO", branch)
http := initFakeHTTP()
http.StubRepoResponse("OWNER", "REPO")
for _, s := range stubs {
http.StubResponse(s.ResponseCode, s.ResponseBody)
}
http.StubRepoResponse("OWNER", "REPO")
}
func TestPrMerge(t *testing.T) {
@ -994,9 +1004,19 @@ func TestPrMerge(t *testing.T) {
"pullRequest": { "number": 1, "closed": false, "state": "OPEN"}
} } }`)},
stubResponse{200, bytes.NewBufferString(`{"id": "THE-ID"}`)},
stubResponse{200, bytes.NewBufferString(`{"node_id": "THE-ID"}`)},
)
output, err := RunCommand("pr merge 1")
cs, cmdTeardown := test.InitCmdStubber()
defer cmdTeardown()
cs.Stub("branch.blueberries.remote origin\nbranch.blueberries.merge refs/heads/blueberries") // git config --get-regexp ^branch\.master\.(remote|merge)
cs.Stub("") // git config --get-regexp ^branch\.blueberries\.(remote|merge)$
cs.Stub("") // git symbolic-ref --quiet --short HEAD
cs.Stub("") // git checkout master
cs.Stub("")
output, err := RunCommand("pr merge 1 --merge")
if err != nil {
t.Fatalf("error running command `pr merge`: %v", err)
}
@ -1008,11 +1028,96 @@ func TestPrMerge(t *testing.T) {
}
}
func TestPrMerge_withRepoFlag(t *testing.T) {
initBlankContext("", "OWNER/REPO", "master")
http := initFakeHTTP()
http.StubResponse(200, bytes.NewBufferString(`{ "data": { "repository": {
"pullRequest": { "number": 1, "closed": false, "state": "OPEN"}
} } }`))
http.StubResponse(200, bytes.NewBufferString(`{ "data": {} }`))
http.StubRepoResponse("OWNER", "REPO")
http.StubResponse(200, bytes.NewBufferString(`{"node_id": "THE-ID"}`))
cs, cmdTeardown := test.InitCmdStubber()
defer cmdTeardown()
eq(t, len(cs.Calls), 0)
output, err := RunCommand("pr merge 1 --merge -R stinky/boi")
if err != nil {
t.Fatalf("error running command `pr merge`: %v", err)
}
r := regexp.MustCompile(`Merged pull request #1`)
if !r.MatchString(output.String()) {
t.Fatalf("output did not match regexp /%s/\n> output\n%q\n", r, output.Stderr())
}
}
func TestPrMerge_deleteBranch(t *testing.T) {
initWithStubs("blueberries",
stubResponse{200, bytes.NewBufferString(`
{ "data": { "repository": { "pullRequests": { "nodes": [
{ "headRefName": "blueberries", "id": "THE-ID", "number": 3}
] } } } }`)},
stubResponse{200, bytes.NewBufferString(`{ "data": {} }`)},
stubResponse{200, bytes.NewBufferString(`{"node_id": "THE-ID"}`)},
)
cs, cmdTeardown := test.InitCmdStubber()
defer cmdTeardown()
cs.Stub("") // git config --get-regexp ^branch\.blueberries\.(remote|merge)$
cs.Stub("") // git checkout master
cs.Stub("") // git rev-parse --verify blueberries`
cs.Stub("") // git branch -d
cs.Stub("") // git push origin --delete blueberries
output, err := RunCommand(`pr merge --merge --delete-branch`)
if err != nil {
t.Fatalf("Got unexpected error running `pr merge` %s", err)
}
test.ExpectLines(t, output.String(), "Merged pull request #3", "Deleted branch blueberries")
}
func TestPrMerge_deleteNonCurrentBranch(t *testing.T) {
initBlankContext("", "OWNER/REPO", "another-branch")
http := initFakeHTTP()
http.StubRepoResponse("OWNER", "REPO")
http.StubResponse(200, bytes.NewBufferString(`
{ "data": { "repository": { "pullRequests": { "nodes": [
{ "headRefName": "blueberries", "id": "THE-ID", "number": 3}
] } } } }`))
http.StubResponse(200, bytes.NewBufferString(`{ "data": {} }`))
http.StubResponse(200, bytes.NewBufferString(`{"node_id": "THE-ID"}`))
http.StubRepoResponse("OWNER", "REPO")
cs, cmdTeardown := test.InitCmdStubber()
defer cmdTeardown()
// We don't expect the default branch to be checked out, just that blueberries is deleted
cs.Stub("") // git rev-parse --verify blueberries
cs.Stub("") // git branch -d blueberries
cs.Stub("") // git push origin --delete blueberries
output, err := RunCommand(`pr merge --merge --delete-branch blueberries`)
if err != nil {
t.Fatalf("Got unexpected error running `pr merge` %s", err)
}
test.ExpectLines(t, output.String(), "Merged pull request #3", "Deleted branch blueberries")
}
func TestPrMerge_noPrNumberGiven(t *testing.T) {
cs, cmdTeardown := test.InitCmdStubber()
defer cmdTeardown()
cs.Stub("branch.blueberries.remote origin\nbranch.blueberries.merge refs/heads/blueberries") // git config --get-regexp ^branch\.master\.(remote|merge)
cs.Stub("") // git config --get-regexp ^branch\.blueberries\.(remote|merge)$
cs.Stub("") // git symbolic-ref --quiet --short HEAD
cs.Stub("") // git checkout master
cs.Stub("") // git branch -d
jsonFile, _ := os.Open("../test/fixtures/prViewPreviewWithMetadataByBranch.json")
defer jsonFile.Close()
@ -1020,9 +1125,10 @@ func TestPrMerge_noPrNumberGiven(t *testing.T) {
initWithStubs("blueberries",
stubResponse{200, jsonFile},
stubResponse{200, bytes.NewBufferString(`{"id": "THE-ID"}`)},
stubResponse{200, bytes.NewBufferString(`{"node_id": "THE-ID"}`)},
)
output, err := RunCommand("pr merge")
output, err := RunCommand("pr merge --merge")
if err != nil {
t.Fatalf("error running command `pr merge`: %v", err)
}
@ -1040,8 +1146,17 @@ func TestPrMerge_rebase(t *testing.T) {
"pullRequest": { "number": 2, "closed": false, "state": "OPEN"}
} } }`)},
stubResponse{200, bytes.NewBufferString(`{"id": "THE-ID"}`)},
stubResponse{200, bytes.NewBufferString(`{"node_id": "THE-ID"}`)},
)
cs, cmdTeardown := test.InitCmdStubber()
defer cmdTeardown()
cs.Stub("") // git config --get-regexp ^branch\.blueberries\.(remote|merge)$
cs.Stub("") // git symbolic-ref --quiet --short HEAD
cs.Stub("") // git checkout master
cs.Stub("") // git branch -d
output, err := RunCommand("pr merge 2 --rebase")
if err != nil {
t.Fatalf("error running command `pr merge`: %v", err)
@ -1060,8 +1175,17 @@ func TestPrMerge_squash(t *testing.T) {
"pullRequest": { "number": 3, "closed": false, "state": "OPEN"}
} } }`)},
stubResponse{200, bytes.NewBufferString(`{"id": "THE-ID"}`)},
stubResponse{200, bytes.NewBufferString(`{"node_id": "THE-ID"}`)},
)
cs, cmdTeardown := test.InitCmdStubber()
defer cmdTeardown()
cs.Stub("") // git config --get-regexp ^branch\.blueberries\.(remote|merge)$
cs.Stub("") // git symbolic-ref --quiet --short HEAD
cs.Stub("") // git checkout master
cs.Stub("") // git branch -d
output, err := RunCommand("pr merge 3 --squash")
if err != nil {
t.Fatalf("error running command `pr merge`: %v", err)
@ -1080,8 +1204,17 @@ func TestPrMerge_alreadyMerged(t *testing.T) {
"pullRequest": { "number": 4, "closed": true, "state": "MERGED"}
} } }`)},
stubResponse{200, bytes.NewBufferString(`{"id": "THE-ID"}`)},
stubResponse{200, bytes.NewBufferString(`{"node_id": "THE-ID"}`)},
)
cs, cmdTeardown := test.InitCmdStubber()
defer cmdTeardown()
cs.Stub("") // git config --get-regexp ^branch\.blueberries\.(remote|merge)$
cs.Stub("") // git symbolic-ref --quiet --short HEAD
cs.Stub("") // git checkout master
cs.Stub("") // git branch -d
output, err := RunCommand("pr merge 4")
if err == nil {
t.Fatalf("expected an error running command `pr merge`: %v", err)
@ -1094,6 +1227,60 @@ func TestPrMerge_alreadyMerged(t *testing.T) {
}
}
func TestPRMerge_interactive(t *testing.T) {
initWithStubs("blueberries",
stubResponse{200, bytes.NewBufferString(`
{ "data": { "repository": { "pullRequests": { "nodes": [
{ "headRefName": "blueberries", "headRepositoryOwner": {"login": "OWNER"}, "id": "THE-ID", "number": 3}
] } } } }`)},
stubResponse{200, bytes.NewBufferString(`{"node_id": "THE-ID"}`)},
stubResponse{200, bytes.NewBufferString(`{ "data": {} }`)})
cs, cmdTeardown := test.InitCmdStubber()
defer cmdTeardown()
cs.Stub("") // git config --get-regexp ^branch\.blueberries\.(remote|merge)$
cs.Stub("") // git symbolic-ref --quiet --short HEAD
cs.Stub("") // git checkout master
cs.Stub("") // git push origin --delete blueberries
cs.Stub("") // git branch -d
as, surveyTeardown := initAskStubber()
defer surveyTeardown()
as.Stub([]*QuestionStub{
{
Name: "mergeMethod",
Value: 0,
},
{
Name: "deleteBranch",
Value: true,
},
})
output, err := RunCommand(`pr merge`)
if err != nil {
t.Fatalf("Got unexpected error running `pr merge` %s", err)
}
test.ExpectLines(t, output.String(), "Merged pull request #3", "Deleted branch blueberries")
}
func TestPrMerge_multipleMergeMethods(t *testing.T) {
initWithStubs("master",
stubResponse{200, bytes.NewBufferString(`{ "data": { "repository": {
"pullRequest": { "number": 1, "closed": false, "state": "OPEN"}
} } }`)},
stubResponse{200, bytes.NewBufferString(`{"id": "THE-ID"}`)},
)
_, err := RunCommand("pr merge 1 --merge --squash")
if err == nil {
t.Fatal("expected error running `pr merge` with multiple merge methods")
}
}
func TestPRReady(t *testing.T) {
initBlankContext("", "OWNER/REPO", "master")
http := initFakeHTTP()

View file

@ -10,6 +10,7 @@ import (
"time"
"github.com/AlecAivazis/survey/v2"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/api"
"github.com/cli/cli/git"
"github.com/cli/cli/internal/ghrepo"
@ -38,16 +39,26 @@ 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{
Use: "repo",
Use: "repo <command>",
Short: "Create, clone, fork, and view repositories",
Long: `Work with GitHub repositories.
Long: `Work with GitHub repositories`,
Example: heredoc.Doc(`
$ gh repo create
$ gh repo clone cli/cli
$ gh repo view --web
`),
Annotations: map[string]string{
"IsCore": "true",
"help:arguments": `
A repository can be supplied as an argument in any of the following formats:
- "OWNER/REPO"
- by URL, e.g. "https://github.com/OWNER/REPO"`,
- by URL, e.g. "https://github.com/OWNER/REPO"`},
}
var repoCloneCmd = &cobra.Command{
@ -56,6 +67,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,
}
@ -63,9 +77,20 @@ To pass 'git clone' flags, separate them with '--'.`,
var repoCreateCmd = &cobra.Command{
Use: "create [<name>]",
Short: "Create a new repository",
Long: `Create a new GitHub repository.
Long: `Create a new GitHub repository.`,
Example: heredoc.Doc(`
# create a repository under your account using the current directory name
$ gh repo create
Use the "ORG/NAME" syntax to create a repository within your organization.`,
# create a repository with a specific name
$ gh repo create my-project
# create a repository in an organization
$ gh repo create cli/my-project
`),
Annotations: map[string]string{"help:arguments": `A repository can be supplied as an argument in any of the following formats:
- <OWNER/REPO>
- by URL, e.g. "https://github.com/OWNER/REPO"`},
RunE: repoCreate,
}
@ -89,6 +114,27 @@ 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: heredoc.Doc(`
# view credits for the current repository
$ gh repo credits
# view credits for a specific repository
$ gh repo credits cool/repo
# print a non-animated thank you
$ gh repo credits -s
# pipe to just print the contributors, one per line
$ gh repo credits | cat
`),
Args: cobra.MaximumNArgs(1),
RunE: repoCredits,
Hidden: true,
}
func parseCloneArgs(extraArgs []string) (args []string, target string) {
args = extraArgs
@ -125,8 +171,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)
}
@ -140,12 +199,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
@ -587,3 +640,7 @@ func repoView(cmd *cobra.Command, args []string) error {
return nil
}
func repoCredits(cmd *cobra.Command, args []string) error {
return credits(cmd, args)
}

View file

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

View file

@ -4,16 +4,22 @@ import (
"errors"
"fmt"
"io"
"net/http"
"os"
"regexp"
"runtime/debug"
"strings"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/api"
"github.com/cli/cli/context"
"github.com/cli/cli/internal/config"
"github.com/cli/cli/internal/ghrepo"
apiCmd "github.com/cli/cli/pkg/cmd/api"
"github.com/cli/cli/pkg/cmdutil"
"github.com/cli/cli/pkg/iostreams"
"github.com/cli/cli/utils"
"github.com/google/shlex"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
@ -29,7 +35,6 @@ var Version = "DEV"
var BuildDate = "" // YYYY-MM-DD
var versionOutput = ""
var cobraDefaultHelpFunc func(*cobra.Command, []string)
func init() {
if Version == "DEV" {
@ -53,28 +58,42 @@ func init() {
// TODO:
// RootCmd.PersistentFlags().BoolP("verbose", "V", false, "enable verbose output")
cobraDefaultHelpFunc = RootCmd.HelpFunc()
RootCmd.SetHelpFunc(rootHelpFunc)
// This will silence the usage func on error
RootCmd.SetUsageFunc(func(_ *cobra.Command) error { return nil })
RootCmd.SetFlagErrorFunc(func(cmd *cobra.Command, err error) error {
if err == pflag.ErrHelp {
return err
}
return &FlagError{Err: err}
return &cmdutil.FlagError{Err: err}
})
}
// FlagError is the kind of error raised in flag processing
type FlagError struct {
Err error
}
func (fe FlagError) Error() string {
return fe.Err.Error()
}
func (fe FlagError) Unwrap() error {
return fe.Err
// TODO: iron out how a factory incorporates context
cmdFactory := &cmdutil.Factory{
IOStreams: iostreams.System(),
HttpClient: func() (*http.Client, error) {
token := os.Getenv("GITHUB_TOKEN")
if len(token) == 0 {
// TODO: decouple from `context`
ctx := context.New()
var err error
// TODO: pass IOStreams to this so that the auth flow knows if it's interactive or not
token, err = ctx.AuthToken()
if err != nil {
return nil, err
}
}
return httpClient(token), nil
},
BaseRepo: func() (ghrepo.Interface, error) {
// TODO: decouple from `context`
ctx := context.New()
return ctx.BaseRepo()
},
}
RootCmd.AddCommand(apiCmd.NewCmdApi(cmdFactory, nil))
}
// RootCmd is the entry point of command-line execution
@ -85,6 +104,15 @@ var RootCmd = &cobra.Command{
SilenceErrors: true,
SilenceUsage: true,
Example: heredoc.Doc(`
$ gh issue create
$ gh repo clone cli/cli
$ gh pr checkout 321
`),
Annotations: map[string]string{
"help:feedback": `
Fill out our feedback form https://forms.gle/umxd3h31c7aMQFKG7
Open an issue using gh issue create -R cli/cli`},
}
var versionCmd = &cobra.Command{
@ -101,6 +129,9 @@ var initContext = func() context.Context {
if repo := os.Getenv("GH_REPO"); repo != "" {
ctx.SetBaseRepo(repo)
}
if token := os.Getenv("GITHUB_TOKEN"); token != "" {
ctx.SetAuthToken(token)
}
return ctx
}
@ -113,11 +144,15 @@ func BasicClient() (*api.Client, error) {
}
opts = append(opts, api.AddHeader("User-Agent", fmt.Sprintf("GitHub CLI %s", Version)))
if c, err := config.ParseDefaultConfig(); err == nil {
if token, _ := c.Get(defaultHostname, "oauth_token"); token != "" {
opts = append(opts, api.AddHeader("Authorization", fmt.Sprintf("token %s", token)))
token := os.Getenv("GITHUB_TOKEN")
if token == "" {
if c, err := config.ParseDefaultConfig(); err == nil {
token, _ = c.Get(defaultHostname, "oauth_token")
}
}
if token != "" {
opts = append(opts, api.AddHeader("Authorization", fmt.Sprintf("token %s", token)))
}
return api.NewClient(opts...), nil
}
@ -129,6 +164,19 @@ func contextForCommand(cmd *cobra.Command) context.Context {
return ctx
}
// for cmdutil-powered commands
func httpClient(token string) *http.Client {
var opts []api.ClientOption
if verbose := os.Getenv("DEBUG"); verbose != "" {
opts = append(opts, apiVerboseLog())
}
opts = append(opts,
api.AddHeader("Authorization", fmt.Sprintf("token %s", token)),
api.AddHeader("User-Agent", fmt.Sprintf("GitHub CLI %s", Version)),
)
return api.NewHTTPClient(opts...)
}
// overridden in tests
var apiClientForContext = func(ctx context.Context) (*api.Client, error) {
token, err := ctx.AuthToken()
@ -145,30 +193,30 @@ var apiClientForContext = func(ctx context.Context) (*api.Client, error) {
return fmt.Sprintf("token %s", token)
}
tokenFromEnv := func() bool {
return os.Getenv("GITHUB_TOKEN") == token
}
checkScopesFunc := func(appID string) error {
if config.IsGitHubApp(appID) && utils.IsTerminal(os.Stdin) && utils.IsTerminal(os.Stderr) {
newToken, loginHandle, err := config.AuthFlow("Notice: additional authorization required")
if err != nil {
return err
}
if config.IsGitHubApp(appID) && !tokenFromEnv() && utils.IsTerminal(os.Stdin) && utils.IsTerminal(os.Stderr) {
cfg, err := ctx.Config()
if err != nil {
return err
}
_ = cfg.Set(defaultHostname, "oauth_token", newToken)
_ = cfg.Set(defaultHostname, "user", loginHandle)
// update config file on disk
err = cfg.Write()
newToken, err := config.AuthFlowWithConfig(cfg, defaultHostname, "Notice: additional authorization required")
if err != nil {
return err
}
// update configuration in memory
token = newToken
config.AuthFlowComplete()
} else {
fmt.Fprintln(os.Stderr, "Warning: gh now requires the `read:org` OAuth scope.")
fmt.Fprintln(os.Stderr, "Visit https://github.com/settings/tokens and edit your token to enable `read:org`")
fmt.Fprintln(os.Stderr, "or generate a new token and paste it via `gh config set -h github.com oauth_token MYTOKEN`")
if tokenFromEnv() {
fmt.Fprintln(os.Stderr, "or generate a new token for the GITHUB_TOKEN environment variable")
} else {
fmt.Fprintln(os.Stderr, "or generate a new token and paste it via `gh config set -h github.com oauth_token MYTOKEN`")
}
}
return nil
}
@ -194,34 +242,31 @@ var ensureScopes = func(ctx context.Context, client *api.Client, wantedScopes ..
return client, nil
}
if config.IsGitHubApp(appID) && utils.IsTerminal(os.Stdin) && utils.IsTerminal(os.Stderr) {
newToken, loginHandle, err := config.AuthFlow("Notice: additional authorization required")
if err != nil {
return client, err
}
tokenFromEnv := len(os.Getenv("GITHUB_TOKEN")) > 0
if config.IsGitHubApp(appID) && !tokenFromEnv && utils.IsTerminal(os.Stdin) && utils.IsTerminal(os.Stderr) {
cfg, err := ctx.Config()
if err != nil {
return client, err
return nil, err
}
_ = cfg.Set(defaultHostname, "oauth_token", newToken)
_ = cfg.Set(defaultHostname, "user", loginHandle)
// update config file on disk
err = cfg.Write()
_, err = config.AuthFlowWithConfig(cfg, defaultHostname, "Notice: additional authorization required")
if err != nil {
return client, err
return nil, err
}
// update configuration in memory
config.AuthFlowComplete()
reloadedClient, err := apiClientForContext(ctx)
if err != nil {
return client, err
}
return reloadedClient, nil
} else {
fmt.Fprintln(os.Stderr, fmt.Sprintf("Warning: gh now requires the `%s` OAuth scope(s).", wantedScopes))
fmt.Fprintln(os.Stderr, fmt.Sprintf("Warning: gh now requires %s OAuth scopes.", wantedScopes))
fmt.Fprintln(os.Stderr, fmt.Sprintf("Visit https://github.com/settings/tokens and edit your token to enable %s", wantedScopes))
fmt.Fprintln(os.Stderr, "or generate a new token and paste it via `gh config set -h github.com oauth_token MYTOKEN`")
if tokenFromEnv {
fmt.Fprintln(os.Stderr, "or generate a new token for the GITHUB_TOKEN environment variable")
} else {
fmt.Fprintln(os.Stderr, "or generate a new token and paste it via `gh config set -h github.com oauth_token MYTOKEN`")
}
return client, errors.New("Unable to reauthenticate")
}
@ -293,76 +338,6 @@ func determineBaseRepo(apiClient *api.Client, cmd *cobra.Command, ctx context.Co
return baseRepo, nil
}
func rootHelpFunc(command *cobra.Command, s []string) {
if command != RootCmd {
cobraDefaultHelpFunc(command, s)
return
}
type helpEntry struct {
Title string
Body string
}
coreCommandNames := []string{"issue", "pr", "repo"}
var coreCommands []string
var additionalCommands []string
for _, c := range command.Commands() {
if c.Short == "" {
continue
}
s := " " + rpad(c.Name()+":", c.NamePadding()) + c.Short
if includes(coreCommandNames, c.Name()) {
coreCommands = append(coreCommands, s)
} else if c != creditsCmd {
additionalCommands = append(additionalCommands, s)
}
}
helpEntries := []helpEntry{
{
"",
command.Long},
{"USAGE", command.Use},
{"CORE COMMANDS", strings.Join(coreCommands, "\n")},
{"ADDITIONAL COMMANDS", strings.Join(additionalCommands, "\n")},
{"FLAGS", strings.TrimRight(command.LocalFlags().FlagUsages(), "\n")},
{"EXAMPLES", `
$ gh issue create
$ gh repo clone
$ gh pr checkout 321`},
{"LEARN MORE", `
Use "gh <command> <subcommand> --help" for more information about a command.
Read the manual at <http://cli.github.com/manual>`},
{"FEEDBACK", `
Fill out our feedback form <https://forms.gle/umxd3h31c7aMQFKG7>
Open an issue using gh issue create -R cli/cli`},
}
out := colorableOut(command)
for _, e := range helpEntries {
if e.Title != "" {
fmt.Fprintln(out, utils.Bold(e.Title))
}
fmt.Fprintln(out, strings.TrimLeft(e.Body, "\n")+"\n")
}
}
// rpad adds padding to the right of a string.
func rpad(s string, padding int) string {
template := fmt.Sprintf("%%-%ds ", padding)
return fmt.Sprintf(template, s)
}
func includes(a []string, s string) bool {
for _, x := range a {
if x == s {
return true
}
}
return false
}
func formatRemoteURL(cmd *cobra.Command, fullRepoName string) string {
ctx := contextForCommand(cmd)
@ -395,3 +370,48 @@ func determineEditor(cmd *cobra.Command) (string, error) {
return editorCommand, nil
}
func ExpandAlias(args []string) ([]string, error) {
empty := []string{}
if len(args) < 2 {
// the command is lacking a subcommand
return empty, nil
}
ctx := initContext()
cfg, err := ctx.Config()
if err != nil {
return empty, err
}
aliases, err := cfg.Aliases()
if err != nil {
return empty, err
}
expansion, ok := aliases.Get(args[1])
if ok {
extraArgs := []string{}
for i, a := range args[2:] {
if !strings.Contains(expansion, "$") {
extraArgs = append(extraArgs, a)
} else {
expansion = strings.ReplaceAll(expansion, fmt.Sprintf("$%d", i+1), a)
}
}
lingeringRE := regexp.MustCompile(`\$\d`)
if lingeringRE.MatchString(expansion) {
return empty, fmt.Errorf("not enough arguments for alias: %s", expansion)
}
newArgs, err := shlex.Split(expansion)
if err != nil {
return nil, err
}
newArgs = append(newArgs, extraArgs...)
return newArgs, nil
}
return args[1:], nil
}

View file

@ -19,7 +19,7 @@ import (
const defaultTestConfig = `hosts:
github.com:
user: OWNER
oauth_token: 1234567890
oauth_token: "1234567890"
`
type askStubber struct {
@ -88,7 +88,7 @@ func initBlankContext(cfg, repo, branch string) {
// NOTE we are not restoring the original readConfig; we never want to touch the config file on
// disk during tests.
config.StubConfig(cfg)
config.StubConfig(cfg, "")
return ctx
}

View file

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

View file

@ -17,14 +17,13 @@ func NewBlank() *blankContext {
// A Context implementation that queries the filesystem
type blankContext struct {
authToken string
authLogin string
branch string
baseRepo ghrepo.Interface
remotes Remotes
}
func (c *blankContext) Config() (config.Config, error) {
cfg, err := config.ParseConfig("boom.txt")
cfg, err := config.ParseConfig("config.yml")
if err != nil {
panic(fmt.Sprintf("failed to parse config during tests. did you remember to stub? error: %s", err))
}
@ -39,14 +38,6 @@ func (c *blankContext) SetAuthToken(t string) {
c.authToken = t
}
func (c *blankContext) SetAuthLogin(login string) {
c.authLogin = login
}
func (c *blankContext) AuthLogin() (string, error) {
return c.authLogin, nil
}
func (c *blankContext) Branch() (string, error) {
if c.branch == "" {
return "", fmt.Errorf("branch was not initialized")

View file

@ -3,6 +3,7 @@ package context
import (
"errors"
"fmt"
"os"
"sort"
"github.com/cli/cli/api"
@ -18,7 +19,6 @@ const defaultHostname = "github.com"
type Context interface {
AuthToken() (string, error)
SetAuthToken(string)
AuthLogin() (string, error)
Branch() (string, error)
SetBranch(string)
Remotes() (Remotes, error)
@ -163,11 +163,13 @@ type fsContext struct {
func (c *fsContext) Config() (config.Config, error) {
if c.config == nil {
config, err := config.ParseOrSetupConfigFile(config.ConfigFile())
if err != nil {
cfg, err := config.ParseDefaultConfig()
if errors.Is(err, os.ErrNotExist) {
cfg = config.NewBlankConfig()
} else if err != nil {
return nil, err
}
c.config = config
c.config = cfg
c.authToken = ""
}
return c.config, nil
@ -183,8 +185,12 @@ func (c *fsContext) AuthToken() (string, error) {
return "", err
}
var notFound *config.NotFoundError
token, err := cfg.Get(defaultHostname, "oauth_token")
if token == "" || err != nil {
if token == "" || errors.As(err, &notFound) {
// interactive OAuth flow
return config.AuthFlowWithConfig(cfg, defaultHostname, "Notice: authentication required")
} else if err != nil {
return "", err
}
@ -195,20 +201,6 @@ func (c *fsContext) SetAuthToken(t string) {
c.authToken = t
}
func (c *fsContext) AuthLogin() (string, error) {
config, err := c.Config()
if err != nil {
return "", err
}
login, err := config.Get(defaultHostname, "user")
if login == "" || err != nil {
return "", err
}
return login, nil
}
func (c *fsContext) Branch() (string, error) {
if c.branch != "" {
return c.branch, nil

View file

@ -95,12 +95,12 @@ func Test_resolvedRemotes_triangularSetup(t *testing.T) {
},
Network: api.RepoNetworkResult{
Repositories: []*api.Repository{
&api.Repository{
{
Name: "NEWNAME",
Owner: api.RepositoryOwner{Login: "NEWOWNER"},
ViewerPermission: "READ",
},
&api.Repository{
{
Name: "REPO",
Owner: api.RepositoryOwner{Login: "MYSELF"},
ViewerPermission: "ADMIN",
@ -163,7 +163,7 @@ func Test_resolvedRemotes_forkLookup(t *testing.T) {
},
Network: api.RepoNetworkResult{
Repositories: []*api.Repository{
&api.Repository{
{
Name: "NEWNAME",
Owner: api.RepositoryOwner{Login: "NEWOWNER"},
ViewerPermission: "READ",
@ -196,7 +196,7 @@ func Test_resolvedRemotes_clonedFork(t *testing.T) {
},
Network: api.RepoNetworkResult{
Repositories: []*api.Repository{
&api.Repository{
{
Name: "REPO",
Owner: api.RepositoryOwner{Login: "OWNER"},
ViewerPermission: "ADMIN",

View file

@ -1,13 +1,20 @@
# Releasing
## Release to production
_First create a prerelease to verify the relase infrastructure_
This can all be done from your local terminal.
1. `git tag v1.2.3-pre`
2. `git push origin v1.2.3-pre`
3. Wait several minutes for the build to run <https://github.com/cli/cli/actions>
4. Verify the prerelease succeeded and has the correct artifacts at <https://github.com/cli/cli/releases>
1. `git tag v1.2.3`
2. `git push origin v1.2.3`
3. Wait a few minutes for the build to run <https://github.com/cli/cli/actions>
4. Check <https://github.com/cli/cli/releases>
_Next create a the production release_
5. `git tag v1.2.3`
6. `git push origin v1.2.3`
7. Wait several minutes for the build to run <https://github.com/cli/cli/actions>
8. Check <https://github.com/cli/cli/releases>
9. Verify the marketing site was updated https://cli.github.com/
10. Delete the prerelease on GitHub <https://github.com/cli/cli/releases>
## Release locally for debugging

View file

@ -7,6 +7,8 @@
go version go1.14
```
If `go` is not installed, follow instructions on [the Go website](https://golang.org/doc/install).
1. Clone this repository
```sh

62
docs/triage.md Normal file
View file

@ -0,0 +1,62 @@
# Triage role
As we get more issues and pull requests opened on the GitHub CLI, we've decided on a weekly rotation
triage role. The initial expectation is that the person in the role for the week spends no more than
1-2 hours a day on this work; we can refine that as needed.
# Incoming issues
just imagine a flowchart
- can this be closed outright?
- e.g. spam/junk
- close without comment
- do we not want to do it?
- e.g. have already discussed not wanting to do or duplicate issue
- comment acknowledging receipt
- close
- do we want to do it?
- e.g. bugs or things we have discussed before
- comment acknowledging it
- label appropriately (examples include `enhancement` or `bug`)
- add to project TODO column if appropriate, otherwise just leave it labeled
- is it intriguing but needs discussion?
- label `needs-design` if design input is needed, ping
- label `needs-investigation` if engineering research is required before action can be taken
- ping engineers if eng needed
- ping product if it's about future directions/roadamp/big changes
- does it need more info from the issue author?
- ask the user for that
- add `needs-user-input` label
- is it a user asking for help and you have all the info you need to help?
- try and help
# Incoming PRs
just imagine a flowchart
- can it be closed outright?
- ie spam/junk
- do we not want to do it?
- ie have already discussed not wanting to do, duplicate issue
- comment acknowledging receipt
- close
- is it intriguing but needs discussion?
- request an issue
- close
- is it something we want to include?
- add `community` label
- add to `needs review` column
# Weekly PR audit
In the interest of not letting our open PR list get out of hand (20+ total PRs _or_ multiple PRs
over a few months old), try to audit open PRs each week with the goal of getting them merged and/or
closed. It's likely too much work to deal with every PR, but even getting a few closer to done is
helpful.
For each PR, ask:
- is this too stale? close with comment
- is this really close but author is absent? push commits to finish, request review
- is this waiting on triage? go through the PR triage flow

View file

@ -203,6 +203,24 @@ func ReadBranchConfig(branch string) (cfg BranchConfig) {
return
}
func DeleteLocalBranch(branch string) error {
branchCmd := GitCommand("branch", "-D", branch)
err := run.PrepareCmd(branchCmd).Run()
return err
}
func HasLocalBranch(branch string) bool {
configCmd := GitCommand("rev-parse", "--verify", "refs/heads/"+branch)
_, err := run.PrepareCmd(configCmd).Output()
return err == nil
}
func CheckoutBranch(branch string) error {
configCmd := GitCommand("checkout", branch)
err := run.PrepareCmd(configCmd).Run()
return err
}
func isFilesystemPath(p string) bool {
return p == "." || strings.HasPrefix(p, "./") || strings.HasPrefix(p, "/")
}

View file

@ -15,9 +15,9 @@ func Test_UncommittedChangeCount(t *testing.T) {
Output string
}
cases := []c{
c{Label: "no changes", Expected: 0, Output: ""},
c{Label: "one change", Expected: 1, Output: " M poem.txt"},
c{Label: "untracked file", Expected: 2, Output: " M poem.txt\n?? new.txt"},
{Label: "no changes", Expected: 0, Output: ""},
{Label: "one change", Expected: 1, Output: " M poem.txt"},
{Label: "untracked file", Expected: 2, Output: " M poem.txt\n?? new.txt"},
}
teardown := run.SetPrepareCmd(func(*exec.Cmd) run.Runnable {

View file

@ -36,9 +36,9 @@ func Test_Translator(t *testing.T) {
tr := m.Translator()
cases := [][]string{
[]string{"ssh://gh/o/r", "ssh://github.com/o/r"},
[]string{"ssh://github.com/o/r", "ssh://github.com/o/r"},
[]string{"https://gh/o/r", "https://gh/o/r"},
{"ssh://gh/o/r", "ssh://github.com/o/r"},
{"ssh://github.com/o/r", "ssh://github.com/o/r"},
{"https://gh/o/r", "https://gh/o/r"},
}
for _, c := range cases {
u, _ := url.Parse(c[0])

21
go.mod
View file

@ -4,27 +4,28 @@ go 1.13
require (
github.com/AlecAivazis/survey/v2 v2.0.7
github.com/briandowns/spinner v1.10.1-0.20200410162419-bf6cf7ae6727
github.com/MakeNowJust/heredoc v1.0.0
github.com/briandowns/spinner v1.11.1
github.com/charmbracelet/glamour v0.1.1-0.20200320173916-301d3bcf3058
github.com/dlclark/regexp2 v1.2.0 // indirect
github.com/google/go-cmp v0.2.0
github.com/google/go-cmp v0.4.1
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510
github.com/hashicorp/go-version v1.2.0
github.com/henvic/httpretty v0.0.4
github.com/henvic/httpretty v0.0.5
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51
github.com/mattn/go-colorable v0.1.6
github.com/mattn/go-isatty v0.0.12
github.com/mattn/go-runewidth v0.0.8 // indirect
github.com/mattn/go-runewidth v0.0.9 // indirect
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b
github.com/mitchellh/go-homedir v1.1.0
github.com/shurcooL/githubv4 v0.0.0-20191127044304-8f68eb5628d0
github.com/shurcooL/githubv4 v0.0.0-20200414012201-bbc966b061dd
github.com/shurcooL/graphql v0.0.0-20181231061246-d48a9a75455f // indirect
github.com/spf13/cobra v0.0.6
github.com/spf13/cobra v1.0.0
github.com/spf13/pflag v1.0.5
github.com/stretchr/testify v1.4.0 // indirect
golang.org/x/crypto v0.0.0-20200219234226-1ad67e1f0ef4
golang.org/x/net v0.0.0-20200219183655-46282727080f // indirect
github.com/stretchr/testify v1.5.1
golang.org/x/crypto v0.0.0-20200510223506-06a226fb4e37
golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2 // indirect
golang.org/x/text v0.3.2
gopkg.in/yaml.v2 v2.2.8 // indirect
gopkg.in/yaml.v3 v3.0.0-20200121175148-a6ecf24a6d71
gopkg.in/yaml.v3 v3.0.0-20200506231410-2ff61e1afc86
)

45
go.sum
View file

@ -3,6 +3,8 @@ github.com/AlecAivazis/survey/v2 v2.0.7 h1:+f825XHLse/hWd2tE/V5df04WFGimk34Eyg/z
github.com/AlecAivazis/survey/v2 v2.0.7/go.mod h1:mlizQTaPjnR4jcpwRSaSlkbsRfYFEyKgLQvYTzxxiHA=
github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
github.com/Netflix/go-expect v0.0.0-20180615182759-c93bf25de8e8 h1:xzYJEypr/85nBpB11F9br+3HUrpgb+fcm5iADzXXYEw=
github.com/Netflix/go-expect v0.0.0-20180615182759-c93bf25de8e8/go.mod h1:oX5x61PbNXchhh0oikYAH+4Pcfw5LKv21+Jnpr6r6Pc=
github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
@ -20,8 +22,8 @@ github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRF
github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8=
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
github.com/briandowns/spinner v1.10.1-0.20200410162419-bf6cf7ae6727 h1:DOyHtIQZmwFEOt/makVyey2RMTPkpi1IQsWsWX0OcGE=
github.com/briandowns/spinner v1.10.1-0.20200410162419-bf6cf7ae6727/go.mod h1:QOuQk7x+EaDASo80FEXwlwiA+j/PPIcX3FScO+3/ZPQ=
github.com/briandowns/spinner v1.11.1 h1:OixPqDEcX3juo5AjQZAnFPbeUA0jvkp2qzB5gOZJ/L0=
github.com/briandowns/spinner v1.11.1/go.mod h1:QOuQk7x+EaDASo80FEXwlwiA+j/PPIcX3FScO+3/ZPQ=
github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
github.com/charmbracelet/glamour v0.1.1-0.20200320173916-301d3bcf3058 h1:Ks+RZ6s6UriHnL+yusm3OoaLwpV9WPvMV+FXQ6qMD7M=
github.com/charmbracelet/glamour v0.1.1-0.20200320173916-301d3bcf3058/go.mod h1:sC1EP6T+3nFnl5vwf0TYEs1inMigQxZ7n912YKoxJow=
@ -63,6 +65,8 @@ github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5y
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/go-cmp v0.2.0 h1:+dTQ8DZQJz0Mb/HjFlkptS1FeQ4cWSnN941F8aEG4SQ=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.4.1 h1:/exdXoGamhu5ONeUJH0deniYLWYvQwW66yvlfiiKTu0=
github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/goterm v0.0.0-20190703233501-fc88cf888a3f h1:5CjVwnuUcp5adK4gmY6i72gpVFVnZDP2h5TmPScB6u4=
github.com/google/goterm v0.0.0-20190703233501-fc88cf888a3f/go.mod h1:nOFQdrUlIlx6M6ODdSpBj1NVA+VgLC6kmw60mkw34H4=
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
@ -74,8 +78,8 @@ github.com/grpc-ecosystem/grpc-gateway v1.9.0/go.mod h1:vNeuVxBJEsws4ogUvrchl83t
github.com/hashicorp/go-version v1.2.0 h1:3vNe/fWF5CBgRIguda1meWhsZHy3m8gCJ5wx+dIzX/E=
github.com/hashicorp/go-version v1.2.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/henvic/httpretty v0.0.4 h1:hyMkO0HugjsmWu63Z+7chDw7+RilkKBJ1vCwlqUOvOk=
github.com/henvic/httpretty v0.0.4/go.mod h1:X38wLjWXHkXT7r2+uK8LjCMne9rsuNaBLJ+5cU2/Pmo=
github.com/henvic/httpretty v0.0.5 h1:XmOHN7HHt+ZhlLNzsMC54yncJDybipkP5NHOGVBOr1s=
github.com/henvic/httpretty v0.0.5/go.mod h1:X38wLjWXHkXT7r2+uK8LjCMne9rsuNaBLJ+5cU2/Pmo=
github.com/hinshun/vt10x v0.0.0-20180616224451-1954e6464174 h1:WlZsjVhE8Af9IcZDGgJGQpNflI3+MJSBhsgT5PCtzBQ=
github.com/hinshun/vt10x v0.0.0-20180616224451-1954e6464174/go.mod h1:DqJ97dSdRW1W22yXSB90986pcOyQ7r45iio1KN2ez1A=
github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM=
@ -109,8 +113,8 @@ github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHX
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-runewidth v0.0.7 h1:Ei8KR0497xHyKJPAv59M1dkC+rOZCMBJ+t3fZ+twI54=
github.com/mattn/go-runewidth v0.0.7/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
github.com/mattn/go-runewidth v0.0.8 h1:3tS41NlGYSmhhe/8fhGRzc+z3AYCw1Fe1WAyLuujKs0=
github.com/mattn/go-runewidth v0.0.8/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/QdE+0=
github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b h1:j7+1HpAFS1zy5+Q4qx1fWh90gTKwiN4QCGoY9TWyyO4=
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
@ -147,8 +151,8 @@ github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sergi/go-diff v1.0.0 h1:Kpca3qRNrduNnOQeazBd0ysaKrUJiIuISHxogkT9RPQ=
github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
github.com/shurcooL/githubv4 v0.0.0-20191127044304-8f68eb5628d0 h1:T9uus1QvcPgeLShS30YOnnzk3r9Vvygp45muhlrufgY=
github.com/shurcooL/githubv4 v0.0.0-20191127044304-8f68eb5628d0/go.mod h1:hAF0iLZy4td2EX+/8Tw+4nodhlMrwN3HupfaXj3zkGo=
github.com/shurcooL/githubv4 v0.0.0-20200414012201-bbc966b061dd h1:EwtC+kDj8s9OKiaStPZtTv3neldOyr98AXIxvmn3Gss=
github.com/shurcooL/githubv4 v0.0.0-20200414012201-bbc966b061dd/go.mod h1:hAF0iLZy4td2EX+/8Tw+4nodhlMrwN3HupfaXj3zkGo=
github.com/shurcooL/graphql v0.0.0-20181231061246-d48a9a75455f h1:tygelZueB1EtXkPI6mQ4o9DQ0+FKW41hTbunoXZCTqk=
github.com/shurcooL/graphql v0.0.0-20181231061246-d48a9a75455f/go.mod h1:AuYgA5Kyo4c7HfUmvRGs/6rGlMMV/6B1bVnB9JxJEEg=
github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo=
@ -158,8 +162,8 @@ github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4k
github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ=
github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
github.com/spf13/cobra v0.0.6 h1:breEStsVwemnKh2/s6gMvSdMEkwW0sK8vGStnlVBMCs=
github.com/spf13/cobra v0.0.6/go.mod h1:/6GTrnGXV9HjY+aR4k0oJ5tcvakLuG6EuKReYlHNrgE=
github.com/spf13/cobra v1.0.0 h1:6m/oheQuQ13N9ks4hubMG6BnvwOeaJrqSPLahSnczz8=
github.com/spf13/cobra v1.0.0/go.mod h1:/6GTrnGXV9HjY+aR4k0oJ5tcvakLuG6EuKReYlHNrgE=
github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo=
github.com/spf13/pflag v1.0.3 h1:zPAT6CGy6wXeQ7NtTnaTerfKOsV6V6F8agHXFiazDkg=
github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
@ -167,13 +171,14 @@ github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.4.0/go.mod h1:PTJ7Z/lr49W6bUbkmS1V3by4uWynFiR9p7+dSq/yZzE=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1 h1:2vfRuCMp5sSVIDSqO8oNnWJq7mPa6KVP3iPIwFBuy8A=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.2.1/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.5.1 h1:nOGnQDM7FYENwehXlg/kFVnos3rEvtKTjRvOWSzb6H4=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U=
github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc=
github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU=
@ -188,8 +193,8 @@ golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnf
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190530122614-20be4c3c3ed5 h1:8dUaAV7K4uHsF56JQWkprecIQKdPHtR9jCHF5nB8uzc=
golang.org/x/crypto v0.0.0-20190530122614-20be4c3c3ed5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200219234226-1ad67e1f0ef4 h1:4icQlpeqbz3WxfgP6Eq3szTj95KTrlH/CwzBzoxuFd0=
golang.org/x/crypto v0.0.0-20200219234226-1ad67e1f0ef4/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20200510223506-06a226fb4e37 h1:cg5LA/zNPRzIXIWSCxQW10Rvpy94aQh3LT/ShoCpkHw=
golang.org/x/crypto v0.0.0-20200510223506-06a226fb4e37/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@ -199,8 +204,8 @@ golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3 h1:0GoQqolDA55aaLxZyTzK/Y2ePZzZTUrRacwib7cNsYQ=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190522155817-f3200d17e092/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.0.0-20200219183655-46282727080f h1:dB42wwhNuwPvh8f+5zZWNcU+F2Xs/B9wXXwvUCOH7r8=
golang.org/x/net v0.0.0-20200219183655-46282727080f/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2 h1:eDrdRpKgkcCqKZQwyZRyeFZgfqt37SL7Kv3tok06cKE=
golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be h1:vEDujvNQGv4jgYKudGeI/+DAX4Jffq6hpD55MmoEvKs=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@ -218,6 +223,8 @@ golang.org/x/sys v0.0.0-20190530182044-ad28b68e88f1/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae h1:/WDfKMnPU+m5M4xB+6x4kaepxRw6jWvR5iDRdvjHgy8=
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd h1:xhmwyvizuTgC2qz7ZlMluP20uW+C3Rm0FD/WLDX8884=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs=
@ -227,6 +234,8 @@ golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGm
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.1.0 h1:igQkv0AAhEIvTEpD5LIpAfav2eeVO9HBTjvKHVJPRSs=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
@ -244,6 +253,6 @@ gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200121175148-a6ecf24a6d71 h1:Xe2gvTZUJpsvOWUnvmL/tmhVBZUmHSvLbMjRj6NUUKo=
gopkg.in/yaml.v3 v3.0.0-20200121175148-a6ecf24a6d71/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20200506231410-2ff61e1afc86 h1:OfFoIUYv/me30yv7XlMy4F9RJw8DEm8WQ6QG1Ph4bH0=
gopkg.in/yaml.v3 v3.0.0-20200506231410-2ff61e1afc86/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=

View file

@ -1,171 +0,0 @@
// imported from https://github.com/spf13/cobra/pull/754
// author: Tim Reddehase
package cobrafish
import (
"bytes"
"fmt"
"io"
"strings"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
func GenCompletion(c *cobra.Command, w io.Writer) error {
buf := new(bytes.Buffer)
writeFishPreamble(c, buf)
writeFishCommandCompletion(c, c, buf)
_, err := buf.WriteTo(w)
return err
}
func writeFishPreamble(cmd *cobra.Command, buf *bytes.Buffer) {
subCommandNames := []string{}
rangeCommands(cmd, func(subCmd *cobra.Command) {
subCommandNames = append(subCommandNames, subCmd.Name())
})
buf.WriteString(fmt.Sprintf(`
function __fish_%s_no_subcommand --description 'Test if %s has yet to be given the subcommand'
for i in (commandline -opc)
if contains -- $i %s
return 1
end
end
return 0
end
function __fish_%s_seen_subcommand_path --description 'Test whether the full path of subcommands is the current path'
set -l cmd (commandline -opc)
set -e cmd[1]
set -l pattern (string replace -a " " ".+" "$argv")
string match -r "$pattern" (string trim -- "$cmd")
end
# borrowed from current fish-shell master, since it is not in current 2.7.1 release
function __fish_seen_argument
argparse 's/short=+' 'l/long=+' -- $argv
set cmd (commandline -co)
set -e cmd[1]
for t in $cmd
for s in $_flag_s
if string match -qr "^-[A-z0-9]*"$s"[A-z0-9]*\$" -- $t
return 0
end
end
for l in $_flag_l
if string match -q -- "--$l" $t
return 0
end
end
end
return 1
end
`, cmd.Name(), cmd.Name(), strings.Join(subCommandNames, " "), cmd.Name()))
}
func writeFishCommandCompletion(rootCmd, cmd *cobra.Command, buf *bytes.Buffer) {
rangeCommands(cmd, func(subCmd *cobra.Command) {
condition := commandCompletionCondition(rootCmd, cmd)
escapedDescription := strings.Replace(subCmd.Short, "'", "\\'", -1)
buf.WriteString(fmt.Sprintf("complete -c %s -f %s -a %s -d '%s'\n", rootCmd.Name(), condition, subCmd.Name(), escapedDescription))
})
for _, validArg := range append(cmd.ValidArgs, cmd.ArgAliases...) {
condition := commandCompletionCondition(rootCmd, cmd)
buf.WriteString(
fmt.Sprintf("complete -c %s -f %s -a %s -d '%s'\n",
rootCmd.Name(), condition, validArg, fmt.Sprintf("Positional Argument to %s", cmd.Name())))
}
writeCommandFlagsCompletion(rootCmd, cmd, buf)
rangeCommands(cmd, func(subCmd *cobra.Command) {
writeFishCommandCompletion(rootCmd, subCmd, buf)
})
}
func writeCommandFlagsCompletion(rootCmd, cmd *cobra.Command, buf *bytes.Buffer) {
cmd.NonInheritedFlags().VisitAll(func(flag *pflag.Flag) {
if nonCompletableFlag(flag) {
return
}
writeCommandFlagCompletion(rootCmd, cmd, buf, flag)
})
cmd.InheritedFlags().VisitAll(func(flag *pflag.Flag) {
if nonCompletableFlag(flag) {
return
}
writeCommandFlagCompletion(rootCmd, cmd, buf, flag)
})
}
func writeCommandFlagCompletion(rootCmd, cmd *cobra.Command, buf *bytes.Buffer, flag *pflag.Flag) {
shortHandPortion := ""
if len(flag.Shorthand) > 0 {
shortHandPortion = fmt.Sprintf("-s %s", flag.Shorthand)
}
condition := completionCondition(rootCmd, cmd)
escapedUsage := strings.Replace(flag.Usage, "'", "\\'", -1)
buf.WriteString(fmt.Sprintf("complete -c %s -f %s %s %s -l %s -d '%s'\n",
rootCmd.Name(), condition, flagRequiresArgumentCompletion(flag), shortHandPortion, flag.Name, escapedUsage))
}
func flagRequiresArgumentCompletion(flag *pflag.Flag) string {
if flag.Value.Type() != "bool" {
return "-r"
}
return ""
}
func subCommandPath(rootCmd *cobra.Command, cmd *cobra.Command) string {
path := make([]string, 0, 1)
currentCmd := cmd
if rootCmd == cmd {
return ""
}
for {
path = append([]string{currentCmd.Name()}, path...)
if currentCmd.Parent() == rootCmd {
return strings.Join(path, " ")
}
currentCmd = currentCmd.Parent()
}
}
func rangeCommands(cmd *cobra.Command, callback func(subCmd *cobra.Command)) {
for _, subCmd := range cmd.Commands() {
if !subCmd.IsAvailableCommand() || strings.HasPrefix(subCmd.Use, "help") {
continue
}
callback(subCmd)
}
}
func commandCompletionCondition(rootCmd, cmd *cobra.Command) string {
localNonPersistentFlags := cmd.LocalNonPersistentFlags()
bareConditions := make([]string, 0, 1)
if rootCmd != cmd {
bareConditions = append(bareConditions, fmt.Sprintf("__fish_%s_seen_subcommand_path %s", rootCmd.Name(), subCommandPath(rootCmd, cmd)))
} else {
bareConditions = append(bareConditions, fmt.Sprintf("__fish_%s_no_subcommand", rootCmd.Name()))
}
localNonPersistentFlags.VisitAll(func(flag *pflag.Flag) {
flagSelector := fmt.Sprintf("-l %s", flag.Name)
if len(flag.Shorthand) > 0 {
flagSelector = fmt.Sprintf("-s %s %s", flag.Shorthand, flagSelector)
}
bareConditions = append(bareConditions, fmt.Sprintf("not __fish_seen_argument %s", flagSelector))
})
return fmt.Sprintf("-n '%s'", strings.Join(bareConditions, "; and "))
}
func completionCondition(rootCmd, cmd *cobra.Command) string {
condition := fmt.Sprintf("-n '__fish_%s_no_subcommand'", rootCmd.Name())
if rootCmd != cmd {
condition = fmt.Sprintf("-n '__fish_%s_seen_subcommand_path %s'", rootCmd.Name(), subCommandPath(rootCmd, cmd))
}
return condition
}
func nonCompletableFlag(flag *pflag.Flag) bool {
return flag.Hidden || len(flag.Deprecated) > 0
}

View file

@ -0,0 +1,60 @@
package config
import (
"fmt"
)
type AliasConfig struct {
ConfigMap
Parent Config
}
func (a *AliasConfig) Get(alias string) (string, bool) {
if a.Empty() {
return "", false
}
value, _ := a.GetStringValue(alias)
return value, value != ""
}
func (a *AliasConfig) Add(alias, expansion string) error {
err := a.SetStringValue(alias, expansion)
if err != nil {
return fmt.Errorf("failed to update config: %w", err)
}
err = a.Parent.Write()
if err != nil {
return fmt.Errorf("failed to write config: %w", err)
}
return nil
}
func (a *AliasConfig) Delete(alias string) error {
a.RemoveEntry(alias)
err := a.Parent.Write()
if err != nil {
return fmt.Errorf("failed to write config: %w", err)
}
return nil
}
func (a *AliasConfig) All() map[string]string {
out := map[string]string{}
if a.Empty() {
return out
}
for i := 0; i < len(a.Root.Content)-1; i += 2 {
key := a.Root.Content[i].Value
value := a.Root.Content[i+1].Value
out[key] = value
}
return out
}

View file

@ -21,20 +21,16 @@ func ConfigFile() string {
return path.Join(ConfigDir(), "config.yml")
}
func ParseOrSetupConfigFile(fn string) (Config, error) {
config, err := ParseConfig(fn)
if err != nil && errors.Is(err, os.ErrNotExist) {
return setupConfigFile(fn)
}
return config, err
func hostsConfigFile(filename string) string {
return path.Join(path.Dir(filename), "hosts.yml")
}
func ParseDefaultConfig() (Config, error) {
return ParseConfig(ConfigFile())
}
var ReadConfigFile = func(fn string) ([]byte, error) {
f, err := os.Open(fn)
var ReadConfigFile = func(filename string) ([]byte, error) {
f, err := os.Open(filename)
if err != nil {
return nil, err
}
@ -48,8 +44,13 @@ var ReadConfigFile = func(fn string) ([]byte, error) {
return data, nil
}
var WriteConfigFile = func(fn string, data []byte) error {
cfgFile, err := os.OpenFile(ConfigFile(), os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0600) // cargo coded from setup
var WriteConfigFile = func(filename string, data []byte) error {
err := os.MkdirAll(path.Dir(filename), 0771)
if err != nil {
return err
}
cfgFile, err := os.OpenFile(filename, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0600) // cargo coded from setup
if err != nil {
return err
}
@ -63,12 +64,12 @@ var WriteConfigFile = func(fn string, data []byte) error {
return err
}
var BackupConfigFile = func(fn string) error {
return os.Rename(fn, fn+".bak")
var BackupConfigFile = func(filename string) error {
return os.Rename(filename, filename+".bak")
}
func parseConfigFile(fn string) ([]byte, *yaml.Node, error) {
data, err := ReadConfigFile(fn)
func parseConfigFile(filename string) ([]byte, *yaml.Node, error) {
data, err := ReadConfigFile(filename)
if err != nil {
return nil, nil, err
}
@ -78,8 +79,11 @@ func parseConfigFile(fn string) ([]byte, *yaml.Node, error) {
if err != nil {
return data, nil, err
}
if len(root.Content) < 1 {
return data, &root, fmt.Errorf("malformed config")
if len(root.Content) == 0 {
return data, &yaml.Node{
Kind: yaml.DocumentNode,
Content: []*yaml.Node{{Kind: yaml.MappingNode}},
}, nil
}
if root.Content[0].Kind != yaml.MappingNode {
return data, &root, fmt.Errorf("expected a top level map")
@ -90,91 +94,76 @@ func parseConfigFile(fn string) ([]byte, *yaml.Node, error) {
func isLegacy(root *yaml.Node) bool {
for _, v := range root.Content[0].Content {
if v.Value == "hosts" {
return false
if v.Value == "github.com" {
return true
}
}
return true
return false
}
func migrateConfig(fn string, root *yaml.Node) error {
type ConfigEntry map[string]string
type ConfigHash map[string]ConfigEntry
newConfigData := map[string]ConfigHash{}
newConfigData["hosts"] = ConfigHash{}
topLevelKeys := root.Content[0].Content
for i, x := range topLevelKeys {
if x.Value == "" {
continue
}
if i+1 == len(topLevelKeys) {
break
}
hostname := x.Value
newConfigData["hosts"][hostname] = ConfigEntry{}
authKeys := topLevelKeys[i+1].Content[0].Content
for j, y := range authKeys {
if j+1 == len(authKeys) {
break
}
switch y.Value {
case "user":
newConfigData["hosts"][hostname]["user"] = authKeys[j+1].Value
case "oauth_token":
newConfigData["hosts"][hostname]["oauth_token"] = authKeys[j+1].Value
}
}
}
if _, ok := newConfigData["hosts"][defaultHostname]; !ok {
return errors.New("could not find default host configuration")
}
defaultHostConfig := newConfigData["hosts"][defaultHostname]
if _, ok := defaultHostConfig["user"]; !ok {
return errors.New("default host configuration missing user")
}
if _, ok := defaultHostConfig["oauth_token"]; !ok {
return errors.New("default host configuration missing oauth_token")
}
newConfig, err := yaml.Marshal(newConfigData)
func migrateConfig(filename string) error {
b, err := ReadConfigFile(filename)
if err != nil {
return err
}
err = BackupConfigFile(fn)
var hosts map[string][]yaml.Node
err = yaml.Unmarshal(b, &hosts)
if err != nil {
return fmt.Errorf("error decoding legacy format: %w", err)
}
cfg := NewBlankConfig()
for hostname, entries := range hosts {
if len(entries) < 1 {
continue
}
mapContent := entries[0].Content
for i := 0; i < len(mapContent)-1; i += 2 {
if err := cfg.Set(hostname, mapContent[i].Value, mapContent[i+1].Value); err != nil {
return err
}
}
}
err = BackupConfigFile(filename)
if err != nil {
return fmt.Errorf("failed to back up existing config: %w", err)
}
return WriteConfigFile(fn, newConfig)
return cfg.Write()
}
func ParseConfig(fn string) (Config, error) {
_, root, err := parseConfigFile(fn)
func ParseConfig(filename string) (Config, error) {
_, root, err := parseConfigFile(filename)
if err != nil {
return nil, err
}
if isLegacy(root) {
err = migrateConfig(fn, root)
err = migrateConfig(filename)
if err != nil {
return nil, err
return nil, fmt.Errorf("error migrating legacy config: %w", err)
}
_, root, err = parseConfigFile(fn)
_, root, err = parseConfigFile(filename)
if err != nil {
return nil, fmt.Errorf("failed to reparse migrated config: %w", err)
}
} else {
if _, hostsRoot, err := parseConfigFile(hostsConfigFile(filename)); err == nil {
if len(hostsRoot.Content[0].Content) > 0 {
newContent := []*yaml.Node{
{Value: "hosts"},
hostsRoot.Content[0],
}
restContent := root.Content[0].Content
root.Content[0].Content = append(newContent, restContent...)
}
} else if !errors.Is(err, os.ErrNotExist) {
return nil, err
}
}
return NewConfig(root), nil

View file

@ -3,9 +3,11 @@ package config
import (
"bytes"
"errors"
"fmt"
"reflect"
"testing"
"github.com/stretchr/testify/assert"
"gopkg.in/yaml.v3"
)
@ -22,8 +24,8 @@ hosts:
github.com:
user: monalisa
oauth_token: OTOKEN
`)()
config, err := ParseConfig("filename")
`, "")()
config, err := ParseConfig("config.yml")
eq(t, err, nil)
user, err := config.Get("github.com", "user")
eq(t, err, nil)
@ -42,8 +44,24 @@ hosts:
github.com:
user: monalisa
oauth_token: OTOKEN
`, "")()
config, err := ParseConfig("config.yml")
eq(t, err, nil)
user, err := config.Get("github.com", "user")
eq(t, err, nil)
eq(t, user, "monalisa")
token, err := config.Get("github.com", "oauth_token")
eq(t, err, nil)
eq(t, token, "OTOKEN")
}
func Test_parseConfig_hostsFile(t *testing.T) {
defer StubConfig("", `---
github.com:
user: monalisa
oauth_token: OTOKEN
`)()
config, err := ParseConfig("filename")
config, err := ParseConfig("config.yml")
eq(t, err, nil)
user, err := config.Get("github.com", "user")
eq(t, err, nil)
@ -59,38 +77,47 @@ hosts:
example.com:
user: wronguser
oauth_token: NOTTHIS
`)()
config, err := ParseConfig("filename")
`, "")()
config, err := ParseConfig("config.yml")
eq(t, err, nil)
_, err = config.Get("github.com", "user")
eq(t, err, errors.New(`could not find config entry for "github.com"`))
eq(t, err, &NotFoundError{errors.New(`could not find config entry for "github.com"`)})
}
func Test_migrateConfig(t *testing.T) {
oldStyle := `---
func Test_ParseConfig_migrateConfig(t *testing.T) {
defer StubConfig(`---
github.com:
- user: keiyuri
oauth_token: 123456`
var root yaml.Node
err := yaml.Unmarshal([]byte(oldStyle), &root)
if err != nil {
panic("failed to parse test yaml")
}
buf := bytes.NewBufferString("")
defer StubWriteConfig(buf)()
oauth_token: 123456
`, "")()
mainBuf := bytes.Buffer{}
hostsBuf := bytes.Buffer{}
defer StubWriteConfig(&mainBuf, &hostsBuf)()
defer StubBackupConfig()()
err = migrateConfig("boom.txt", &root)
eq(t, err, nil)
_, err := ParseConfig("config.yml")
assert.Nil(t, err)
expected := `hosts:
github.com:
oauth_token: "123456"
user: keiyuri
expectedMain := "# What protocol to use when performing git operations. Supported values: ssh, https\ngit_protocol: https\n# What editor gh should run when creating issues, pull requests, etc. If blank, will refer to environment.\neditor:\n# Aliases allow you to create nicknames for gh commands\naliases:\n co: pr checkout\n"
expectedHosts := `github.com:
user: keiyuri
oauth_token: "123456"
`
eq(t, buf.String(), expected)
assert.Equal(t, expectedMain, mainBuf.String())
assert.Equal(t, expectedHosts, hostsBuf.String())
}
func Test_parseConfigFile(t *testing.T) {
fileContents := []string{"", " ", "\n"}
for _, contents := range fileContents {
t.Run(fmt.Sprintf("contents: %q", contents), func(t *testing.T) {
defer StubConfig(contents, "")()
_, yamlRoot, err := parseConfigFile("config.yml")
eq(t, err, nil)
eq(t, yamlRoot.Content[0].Kind, yaml.MappingNode)
eq(t, len(yamlRoot.Content[0].Content), 0)
})
}
}

View file

@ -5,16 +5,10 @@ import (
"fmt"
"io"
"os"
"path/filepath"
"strings"
"github.com/cli/cli/api"
"github.com/cli/cli/auth"
"gopkg.in/yaml.v3"
)
const (
oauthHost = "github.com"
)
var (
@ -31,7 +25,31 @@ func IsGitHubApp(id string) bool {
return id == "178c6fc778ccc68e1d6a" || id == "4d747ba5675d5d66553f"
}
func AuthFlow(notice string) (string, string, error) {
func AuthFlowWithConfig(cfg Config, hostname, notice string) (string, error) {
token, userLogin, err := authFlow(hostname, notice)
if err != nil {
return "", err
}
err = cfg.Set(hostname, "user", userLogin)
if err != nil {
return "", err
}
err = cfg.Set(hostname, "oauth_token", token)
if err != nil {
return "", err
}
err = cfg.Write()
if err != nil {
return "", err
}
AuthFlowComplete()
return token, nil
}
func authFlow(oauthHost, notice string) (string, string, error) {
var verboseStream io.Writer
if strings.Contains(os.Getenv("DEBUG"), "oauth") {
verboseStream = os.Stderr
@ -69,61 +87,9 @@ func AuthFlowComplete() {
_ = waitForEnter(os.Stdin)
}
// FIXME: make testable
func setupConfigFile(filename string) (Config, error) {
token, userLogin, err := AuthFlow("Notice: authentication required")
if err != nil {
return nil, err
}
// TODO this sucks. It precludes us laying out a nice config with comments and such.
type yamlConfig struct {
Hosts map[string]map[string]string
}
yamlHosts := map[string]map[string]string{}
yamlHosts[oauthHost] = map[string]string{}
yamlHosts[oauthHost]["user"] = userLogin
yamlHosts[oauthHost]["oauth_token"] = token
defaultConfig := yamlConfig{
Hosts: yamlHosts,
}
err = os.MkdirAll(filepath.Dir(filename), 0771)
if err != nil {
return nil, err
}
cfgFile, err := os.OpenFile(filename, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0600)
if err != nil {
return nil, err
}
defer cfgFile.Close()
yamlData, err := yaml.Marshal(defaultConfig)
if err != nil {
return nil, err
}
_, err = cfgFile.Write(yamlData)
if err != nil {
return nil, err
}
// TODO cleaner error handling? this "should" always work given that we /just/ wrote the file...
return ParseConfig(filename)
}
func getViewer(token string) (string, error) {
http := api.NewClient(api.AddHeader("Authorization", fmt.Sprintf("token %s", token)))
response := struct {
Viewer struct {
Login string
}
}{}
err := http.GraphQL("{ viewer { login } }", nil, &response)
return response.Viewer.Login, err
return api.CurrentLoginName(http)
}
func waitForEnter(r io.Reader) error {

View file

@ -1,20 +1,20 @@
package config
import (
"bytes"
"errors"
"fmt"
"gopkg.in/yaml.v3"
)
const defaultHostname = "github.com"
const defaultGitProtocol = "https"
// This interface describes interacting with some persistent configuration for gh.
type Config interface {
Hosts() ([]*HostConfig, error)
Get(string, string) (string, error)
Set(string, string, string) error
Aliases() (*AliasConfig, error)
Write() error
}
@ -34,19 +34,25 @@ type ConfigMap struct {
Root *yaml.Node
}
func (cm *ConfigMap) Empty() bool {
return cm.Root == nil || len(cm.Root.Content) == 0
}
func (cm *ConfigMap) GetStringValue(key string) (string, error) {
_, valueNode, err := cm.FindEntry(key)
entry, err := cm.FindEntry(key)
if err != nil {
return "", err
}
return valueNode.Value, nil
return entry.ValueNode.Value, nil
}
func (cm *ConfigMap) SetStringValue(key, value string) error {
_, valueNode, err := cm.FindEntry(key)
entry, err := cm.FindEntry(key)
var notFound *NotFoundError
valueNode := entry.ValueNode
if err != nil && errors.As(err, &notFound) {
keyNode := &yaml.Node{
Kind: yaml.ScalarNode,
@ -54,6 +60,7 @@ func (cm *ConfigMap) SetStringValue(key, value string) error {
}
valueNode = &yaml.Node{
Kind: yaml.ScalarNode,
Tag: "!!str",
Value: "",
}
@ -67,19 +74,45 @@ func (cm *ConfigMap) SetStringValue(key, value string) error {
return nil
}
func (cm *ConfigMap) FindEntry(key string) (keyNode, valueNode *yaml.Node, err error) {
type ConfigEntry struct {
KeyNode *yaml.Node
ValueNode *yaml.Node
Index int
}
func (cm *ConfigMap) FindEntry(key string) (ce *ConfigEntry, err error) {
err = nil
ce = &ConfigEntry{}
topLevelKeys := cm.Root.Content
for i, v := range topLevelKeys {
if v.Value == key && i+1 < len(topLevelKeys) {
keyNode = v
valueNode = topLevelKeys[i+1]
if v.Value == key {
ce.KeyNode = v
ce.Index = i
if i+1 < len(topLevelKeys) {
ce.ValueNode = topLevelKeys[i+1]
}
return
}
}
return nil, nil, &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 {
@ -89,11 +122,63 @@ func NewConfig(root *yaml.Node) Config {
}
}
func NewBlankConfig() Config {
return NewConfig(&yaml.Node{
Kind: yaml.DocumentNode,
Content: []*yaml.Node{
{
Kind: yaml.MappingNode,
Content: []*yaml.Node{
{
HeadComment: "What protocol to use when performing git operations. Supported values: ssh, https",
Kind: yaml.ScalarNode,
Value: "git_protocol",
},
{
Kind: yaml.ScalarNode,
Value: "https",
},
{
HeadComment: "What editor gh should run when creating issues, pull requests, etc. If blank, will refer to environment.",
Kind: yaml.ScalarNode,
Value: "editor",
},
{
Kind: yaml.ScalarNode,
Value: "",
},
{
HeadComment: "Aliases allow you to create nicknames for gh commands",
Kind: yaml.ScalarNode,
Value: "aliases",
},
{
Kind: yaml.MappingNode,
Content: []*yaml.Node{
{
Kind: yaml.ScalarNode,
Value: "co",
},
{
Kind: yaml.ScalarNode,
Value: "pr checkout",
},
},
},
},
},
},
})
}
// This type implements a Config interface and represents a config file on disk.
type fileConfig struct {
ConfigMap
documentRoot *yaml.Node
hosts []*HostConfig
}
func (c *fileConfig) Root() *yaml.Node {
return c.ConfigMap.Root
}
func (c *fileConfig) Get(hostname, key string) (string, error) {
@ -137,7 +222,10 @@ func (c *fileConfig) Set(hostname, key, value string) error {
return c.SetStringValue(key, value)
} else {
hostCfg, err := c.configForHost(hostname)
if err != nil {
var notFound *NotFoundError
if errors.As(err, &notFound) {
hostCfg = c.makeConfigForHost(hostname)
} else if err != nil {
return err
}
return hostCfg.SetStringValue(key, value)
@ -145,7 +233,7 @@ func (c *fileConfig) Set(hostname, key, value string) error {
}
func (c *fileConfig) configForHost(hostname string) (*HostConfig, error) {
hosts, err := c.Hosts()
hosts, err := c.hostEntries()
if err != nil {
return nil, fmt.Errorf("failed to parse hosts config: %w", err)
}
@ -155,38 +243,146 @@ func (c *fileConfig) configForHost(hostname string) (*HostConfig, error) {
return hc, nil
}
}
return nil, fmt.Errorf("could not find config entry for %q", hostname)
return nil, &NotFoundError{fmt.Errorf("could not find config entry for %q", hostname)}
}
func (c *fileConfig) Write() error {
marshalled, err := yaml.Marshal(c.documentRoot)
mainData := yaml.Node{Kind: yaml.MappingNode}
hostsData := yaml.Node{Kind: yaml.MappingNode}
nodes := c.documentRoot.Content[0].Content
for i := 0; i < len(nodes)-1; i += 2 {
if nodes[i].Value == "hosts" {
hostsData.Content = append(hostsData.Content, nodes[i+1].Content...)
} else {
mainData.Content = append(mainData.Content, nodes[i], nodes[i+1])
}
}
mainBytes, err := yaml.Marshal(&mainData)
if err != nil {
return err
}
return WriteConfigFile(ConfigFile(), marshalled)
}
func (c *fileConfig) Hosts() ([]*HostConfig, error) {
if len(c.hosts) > 0 {
return c.hosts, nil
filename := ConfigFile()
err = WriteConfigFile(filename, yamlNormalize(mainBytes))
if err != nil {
return err
}
_, hostsEntry, err := c.FindEntry("hosts")
hostsBytes, err := yaml.Marshal(&hostsData)
if err != nil {
return err
}
return WriteConfigFile(hostsConfigFile(filename), yamlNormalize(hostsBytes))
}
func yamlNormalize(b []byte) []byte {
if bytes.Equal(b, []byte("{}\n")) {
return []byte{}
}
return b
}
func (c *fileConfig) Aliases() (*AliasConfig, error) {
// The complexity here is for dealing with either a missing or empty aliases key. It's something
// we'll likely want for other config sections at some point.
entry, err := c.FindEntry("aliases")
var nfe *NotFoundError
notFound := errors.As(err, &nfe)
if err != nil && !notFound {
return nil, err
}
toInsert := []*yaml.Node{}
keyNode := entry.KeyNode
valueNode := entry.ValueNode
if keyNode == nil {
keyNode = &yaml.Node{
Kind: yaml.ScalarNode,
Value: "aliases",
}
toInsert = append(toInsert, keyNode)
}
if valueNode == nil || valueNode.Kind != yaml.MappingNode {
valueNode = &yaml.Node{
Kind: yaml.MappingNode,
Value: "",
}
toInsert = append(toInsert, valueNode)
}
if len(toInsert) > 0 {
newContent := []*yaml.Node{}
if notFound {
newContent = append(c.Root().Content, keyNode, valueNode)
} else {
for i := 0; i < len(c.Root().Content); i++ {
if i == entry.Index {
newContent = append(newContent, keyNode, valueNode)
i++
} else {
newContent = append(newContent, c.Root().Content[i])
}
}
}
c.Root().Content = newContent
}
return &AliasConfig{
Parent: c,
ConfigMap: ConfigMap{Root: valueNode},
}, nil
}
func (c *fileConfig) hostEntries() ([]*HostConfig, error) {
entry, err := c.FindEntry("hosts")
if err != nil {
return nil, fmt.Errorf("could not find hosts config: %w", err)
}
hostConfigs, err := c.parseHosts(hostsEntry)
hostConfigs, err := c.parseHosts(entry.ValueNode)
if err != nil {
return nil, fmt.Errorf("could not parse hosts config: %w", err)
}
c.hosts = hostConfigs
return hostConfigs, nil
}
func (c *fileConfig) makeConfigForHost(hostname string) *HostConfig {
hostRoot := &yaml.Node{Kind: yaml.MappingNode}
hostCfg := &HostConfig{
Host: hostname,
ConfigMap: ConfigMap{Root: hostRoot},
}
var notFound *NotFoundError
hostsEntry, err := c.FindEntry("hosts")
if errors.As(err, &notFound) {
hostsEntry.KeyNode = &yaml.Node{
Kind: yaml.ScalarNode,
Value: "hosts",
}
hostsEntry.ValueNode = &yaml.Node{Kind: yaml.MappingNode}
root := c.Root()
root.Content = append(root.Content, hostsEntry.KeyNode, hostsEntry.ValueNode)
} else if err != nil {
panic(err)
}
hostsEntry.ValueNode.Content = append(hostsEntry.ValueNode.Content,
&yaml.Node{
Kind: yaml.ScalarNode,
Value: hostname,
}, hostRoot)
return hostCfg
}
func (c *fileConfig) parseHosts(hostsEntry *yaml.Node) ([]*HostConfig, error) {
hostConfigs := []*HostConfig{}

View file

@ -0,0 +1,57 @@
package config
import (
"bytes"
"testing"
"github.com/stretchr/testify/assert"
)
func Test_fileConfig_Set(t *testing.T) {
mainBuf := bytes.Buffer{}
hostsBuf := bytes.Buffer{}
defer StubWriteConfig(&mainBuf, &hostsBuf)()
c := NewBlankConfig()
assert.NoError(t, c.Set("", "editor", "nano"))
assert.NoError(t, c.Set("github.com", "git_protocol", "ssh"))
assert.NoError(t, c.Set("example.com", "editor", "vim"))
assert.NoError(t, c.Set("github.com", "user", "hubot"))
assert.NoError(t, c.Write())
expected := "# What protocol to use when performing git operations. Supported values: ssh, https\ngit_protocol: https\n# What editor gh should run when creating issues, pull requests, etc. If blank, will refer to environment.\neditor: nano\n# Aliases allow you to create nicknames for gh commands\naliases:\n co: pr checkout\n"
assert.Equal(t, expected, mainBuf.String())
assert.Equal(t, `github.com:
git_protocol: ssh
user: hubot
example.com:
editor: vim
`, hostsBuf.String())
}
func Test_defaultConfig(t *testing.T) {
mainBuf := bytes.Buffer{}
hostsBuf := bytes.Buffer{}
defer StubWriteConfig(&mainBuf, &hostsBuf)()
cfg := NewBlankConfig()
assert.NoError(t, cfg.Write())
expected := "# What protocol to use when performing git operations. Supported values: ssh, https\ngit_protocol: https\n# What editor gh should run when creating issues, pull requests, etc. If blank, will refer to environment.\neditor:\n# Aliases allow you to create nicknames for gh commands\naliases:\n co: pr checkout\n"
assert.Equal(t, expected, mainBuf.String())
assert.Equal(t, "", hostsBuf.String())
proto, err := cfg.Get("", "git_protocol")
assert.Nil(t, err)
assert.Equal(t, "https", proto)
editor, err := cfg.Get("", "editor")
assert.Nil(t, err)
assert.Equal(t, "", editor)
aliases, err := cfg.Aliases()
assert.Nil(t, err)
assert.Equal(t, len(aliases.All()), 1)
expansion, _ := aliases.Get("co")
assert.Equal(t, expansion, "pr checkout")
}

View file

@ -1,7 +1,10 @@
package config
import (
"fmt"
"io"
"os"
"path"
)
func StubBackupConfig() func() {
@ -15,21 +18,41 @@ func StubBackupConfig() func() {
}
}
func StubWriteConfig(w io.Writer) func() {
func StubWriteConfig(wc io.Writer, wh io.Writer) func() {
orig := WriteConfigFile
WriteConfigFile = func(fn string, data []byte) error {
_, err := w.Write(data)
return err
switch path.Base(fn) {
case "config.yml":
_, err := wc.Write(data)
return err
case "hosts.yml":
_, err := wh.Write(data)
return err
default:
return fmt.Errorf("write to unstubbed file: %q", fn)
}
}
return func() {
WriteConfigFile = orig
}
}
func StubConfig(content string) func() {
func StubConfig(main, hosts string) func() {
orig := ReadConfigFile
ReadConfigFile = func(fn string) ([]byte, error) {
return []byte(content), nil
switch path.Base(fn) {
case "config.yml":
return []byte(main), nil
case "hosts.yml":
if hosts == "" {
return []byte(nil), os.ErrNotExist
} else {
return []byte(hosts), nil
}
default:
return []byte(nil), fmt.Errorf("read from unstubbed file: %q", fn)
}
}
return func() {
ReadConfigFile = orig

359
pkg/cmd/api/api.go Normal file
View file

@ -0,0 +1,359 @@
package api
import (
"bytes"
"encoding/json"
"fmt"
"io"
"io/ioutil"
"net/http"
"os"
"regexp"
"sort"
"strconv"
"strings"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/internal/ghrepo"
"github.com/cli/cli/pkg/cmdutil"
"github.com/cli/cli/pkg/iostreams"
"github.com/cli/cli/pkg/jsoncolor"
"github.com/spf13/cobra"
)
type ApiOptions struct {
IO *iostreams.IOStreams
RequestMethod string
RequestMethodPassed bool
RequestPath string
RequestInputFile string
MagicFields []string
RawFields []string
RequestHeaders []string
ShowResponseHeaders bool
HttpClient func() (*http.Client, error)
BaseRepo func() (ghrepo.Interface, error)
}
func NewCmdApi(f *cmdutil.Factory, runF func(*ApiOptions) error) *cobra.Command {
opts := ApiOptions{
IO: f.IOStreams,
HttpClient: f.HttpClient,
BaseRepo: f.BaseRepo,
}
cmd := &cobra.Command{
Use: "api <endpoint>",
Short: "Make an authenticated GitHub API request",
Long: `Makes an authenticated HTTP request to the GitHub API and prints the response.
The endpoint argument should either be a path of a GitHub API v3 endpoint, or
"graphql" to access the GitHub API v4.
Placeholder values ":owner" and ":repo" in the endpoint argument will get replaced
with values from the repository of the current directory.
The default HTTP request method is "GET" normally and "POST" if any parameters
were added. Override the method with '--method'.
Pass one or more '--raw-field' values in "key=value" format to add
JSON-encoded string parameters to the POST body.
The '--field' flag behaves like '--raw-field' with magic type conversion based
on the format of the value:
- literal values "true", "false", "null", and integer numbers get converted to
appropriate JSON types;
- placeholder values ":owner" and ":repo" get populated with values from the
repository of the current directory;
- 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.
`,
Example: heredoc.Doc(`
$ gh api repos/:owner/:repo/releases
$ gh api graphql -F owner=':owner' -F name=':repo' -f query='
query($name: String!, $owner: String!) {
repository(owner: $owner, name: $name) {
releases(last: 3) {
nodes { tagName }
}
}
}
'
`),
Args: cobra.ExactArgs(1),
RunE: func(c *cobra.Command, args []string) error {
opts.RequestPath = args[0]
opts.RequestMethodPassed = c.Flags().Changed("method")
if runF != nil {
return runF(&opts)
}
return apiRun(&opts)
},
}
cmd.Flags().StringVarP(&opts.RequestMethod, "method", "X", "GET", "The HTTP method for the request")
cmd.Flags().StringArrayVarP(&opts.MagicFields, "field", "F", nil, "Add a parameter of inferred type")
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
}
func apiRun(opts *ApiOptions) error {
params, err := parseFields(opts)
if err != nil {
return err
}
requestPath, err := fillPlaceholders(opts.RequestPath, opts)
if err != nil {
return fmt.Errorf("unable to expand placeholder in path: %w", err)
}
method := opts.RequestMethod
requestHeaders := opts.RequestHeaders
var requestBody interface{} = params
if !opts.RequestMethodPassed && (len(params) > 0 || opts.RequestInputFile != "") {
method = "POST"
}
if opts.RequestInputFile != "" {
file, size, err := openUserFile(opts.RequestInputFile, opts.IO.In)
if err != nil {
return err
}
defer file.Close()
requestPath = addQuery(requestPath, params)
requestBody = file
if size >= 0 {
requestHeaders = append([]string{fmt.Sprintf("Content-Length: %d", size)}, requestHeaders...)
}
}
httpClient, err := opts.HttpClient()
if err != nil {
return err
}
resp, err := httpRequest(httpClient, method, requestPath, requestBody, requestHeaders)
if err != nil {
return err
}
if opts.ShowResponseHeaders {
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()
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
}
}
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
}
var placeholderRE = regexp.MustCompile(`\:(owner|repo)\b`)
// fillPlaceholders populates `:owner` and `:repo` placeholders with values from the current repository
func fillPlaceholders(value string, opts *ApiOptions) (string, error) {
if !placeholderRE.MatchString(value) {
return value, nil
}
baseRepo, err := opts.BaseRepo()
if err != nil {
return value, err
}
value = placeholderRE.ReplaceAllStringFunc(value, func(m string) string {
switch m {
case ":owner":
return baseRepo.RepoOwner()
case ":repo":
return baseRepo.RepoName()
default:
panic(fmt.Sprintf("invalid placeholder: %q", m))
}
})
return value, 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 {
key, value, err := parseField(f)
if err != nil {
return params, err
}
params[key] = value
}
for _, f := range opts.MagicFields {
key, strValue, err := parseField(f)
if err != nil {
return params, err
}
value, err := magicFieldValue(strValue, opts)
if err != nil {
return params, fmt.Errorf("error parsing %q value: %w", key, err)
}
params[key] = value
}
return params, nil
}
func parseField(f string) (string, string, error) {
idx := strings.IndexRune(f, '=')
if idx == -1 {
return f, "", fmt.Errorf("field %q requires a value separated by an '=' sign", f)
}
return f[0:idx], f[idx+1:], nil
}
func magicFieldValue(v string, opts *ApiOptions) (interface{}, error) {
if strings.HasPrefix(v, "@") {
return readUserFile(v[1:], opts.IO.In)
}
if n, err := strconv.Atoi(v); err == nil {
return n, nil
}
switch v {
case "true":
return true, nil
case "false":
return false, nil
case "null":
return nil, nil
default:
return fillPlaceholders(v, opts)
}
}
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
}
}
defer r.Close()
return ioutil.ReadAll(r)
}
func openUserFile(fn string, stdin io.ReadCloser) (io.ReadCloser, int64, error) {
if fn == "-" {
return stdin, -1, nil
}
r, err := os.Open(fn)
if err != nil {
return r, -1, err
}
s, err := os.Stat(fn)
if err != nil {
return r, -1, err
}
return r, s.Size(), nil
}
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
}

536
pkg/cmd/api/api_test.go Normal file
View file

@ -0,0 +1,536 @@
package api
import (
"bytes"
"fmt"
"io/ioutil"
"net/http"
"os"
"testing"
"github.com/cli/cli/internal/ghrepo"
"github.com/cli/cli/pkg/cmdutil"
"github.com/cli/cli/pkg/iostreams"
"github.com/google/shlex"
"github.com/stretchr/testify/assert"
)
func Test_NewCmdApi(t *testing.T) {
f := &cmdutil.Factory{}
tests := []struct {
name string
cli string
wants ApiOptions
wantsErr bool
}{
{
name: "no flags",
cli: "graphql",
wants: ApiOptions{
RequestMethod: "GET",
RequestMethodPassed: false,
RequestPath: "graphql",
RequestInputFile: "",
RawFields: []string(nil),
MagicFields: []string(nil),
RequestHeaders: []string(nil),
ShowResponseHeaders: false,
},
wantsErr: false,
},
{
name: "override method",
cli: "repos/octocat/Spoon-Knife -XDELETE",
wants: ApiOptions{
RequestMethod: "DELETE",
RequestMethodPassed: true,
RequestPath: "repos/octocat/Spoon-Knife",
RequestInputFile: "",
RawFields: []string(nil),
MagicFields: []string(nil),
RequestHeaders: []string(nil),
ShowResponseHeaders: false,
},
wantsErr: false,
},
{
name: "with fields",
cli: "graphql -f query=QUERY -F body=@file.txt",
wants: ApiOptions{
RequestMethod: "GET",
RequestMethodPassed: false,
RequestPath: "graphql",
RequestInputFile: "",
RawFields: []string{"query=QUERY"},
MagicFields: []string{"body=@file.txt"},
RequestHeaders: []string(nil),
ShowResponseHeaders: false,
},
wantsErr: false,
},
{
name: "with headers",
cli: "user -H 'accept: text/plain' -i",
wants: ApiOptions{
RequestMethod: "GET",
RequestMethodPassed: false,
RequestPath: "user",
RequestInputFile: "",
RawFields: []string(nil),
MagicFields: []string(nil),
RequestHeaders: []string{"accept: text/plain"},
ShowResponseHeaders: true,
},
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: "",
wantsErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmd := NewCmdApi(f, func(o *ApiOptions) error {
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)
assert.Equal(t, tt.wants.ShowResponseHeaders, o.ShowResponseHeaders)
return nil
})
argv, err := shlex.Split(tt.cli)
assert.NoError(t, err)
cmd.SetArgs(argv)
cmd.SetIn(&bytes.Buffer{})
cmd.SetOut(&bytes.Buffer{})
cmd.SetErr(&bytes.Buffer{})
_, err = cmd.ExecuteC()
if tt.wantsErr {
assert.Error(t, err)
return
}
assert.NoError(t, err)
})
}
}
func Test_apiRun(t *testing.T) {
tests := []struct {
name string
options ApiOptions
httpResponse *http.Response
err error
stdout string
stderr string
}{
{
name: "success",
httpResponse: &http.Response{
StatusCode: 200,
Body: ioutil.NopCloser(bytes.NewBufferString(`bam!`)),
},
err: nil,
stdout: `bam!`,
stderr: ``,
},
{
name: "show response headers",
options: ApiOptions{
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: "HTTP/1.1 200 Okey-dokey\nContent-Type: text/plain\r\n\r\nbody",
stderr: ``,
},
{
name: "success 204",
httpResponse: &http.Response{
StatusCode: 204,
Body: nil,
},
err: nil,
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{
StatusCode: 502,
Body: ioutil.NopCloser(bytes.NewBufferString(`gateway timeout`)),
},
err: cmdutil.SilentError,
stdout: `gateway timeout`,
stderr: "gh: HTTP 502\n",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
io, _, stdout, stderr := iostreams.Test()
tt.options.IO = io
tt.options.HttpClient = func() (*http.Client, error) {
var tr roundTripper = func(req *http.Request) (*http.Response, error) {
resp := tt.httpResponse
resp.Request = req
return resp, nil
}
return &http.Client{Transport: tr}, nil
}
err := apiRun(&tt.options)
if err != tt.err {
t.Errorf("expected error %v, got %v", tt.err, err)
}
if stdout.String() != tt.stdout {
t.Errorf("expected output %q, got %q", tt.stdout, stdout.String())
}
if stderr.String() != tt.stderr {
t.Errorf("expected error output %q, got %q", tt.stderr, stderr.String())
}
})
}
}
func Test_apiRun_inputFile(t *testing.T) {
tests := []struct {
name string
inputFile string
inputContents []byte
contentLength int64
expectedContents []byte
}{
{
name: "stdin",
inputFile: "-",
inputContents: []byte("I WORK OUT"),
contentLength: 0,
},
{
name: "from file",
inputFile: "gh-test-file",
inputContents: []byte("I WORK OUT"),
contentLength: 10,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
io, stdin, _, _ := iostreams.Test()
resp := &http.Response{StatusCode: 204}
inputFile := tt.inputFile
if tt.inputFile == "-" {
_, _ = stdin.Write(tt.inputContents)
} else {
f, err := ioutil.TempFile("", tt.inputFile)
if err != nil {
t.Fatal(err)
}
_, _ = f.Write(tt.inputContents)
f.Close()
t.Cleanup(func() { os.Remove(f.Name()) })
inputFile = f.Name()
}
var bodyBytes []byte
options := ApiOptions{
RequestPath: "hello",
RequestInputFile: inputFile,
RawFields: []string{"a=b", "c=d"},
IO: io,
HttpClient: func() (*http.Client, error) {
var tr roundTripper = func(req *http.Request) (*http.Response, error) {
var err error
if bodyBytes, err = ioutil.ReadAll(req.Body); err != nil {
return nil, err
}
resp.Request = req
return resp, nil
}
return &http.Client{Transport: tr}, nil
},
}
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, tt.contentLength, resp.Request.ContentLength)
assert.Equal(t, "", resp.Request.Header.Get("Content-Type"))
assert.Equal(t, tt.inputContents, bodyBytes)
})
}
}
func Test_parseFields(t *testing.T) {
io, stdin, _, _ := iostreams.Test()
fmt.Fprint(stdin, "pasted contents")
opts := ApiOptions{
IO: io,
RawFields: []string{
"robot=Hubot",
"destroyer=false",
"helper=true",
"location=@work",
},
MagicFields: []string{
"input=@-",
"enabled=true",
"victories=123",
},
}
params, err := parseFields(&opts)
if err != nil {
t.Fatalf("parseFields error: %v", err)
}
expect := map[string]interface{}{
"robot": "Hubot",
"destroyer": "false",
"helper": "true",
"location": "@work",
"input": []byte("pasted contents"),
"enabled": true,
"victories": 123,
}
assert.Equal(t, expect, params)
}
func Test_magicFieldValue(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()) })
io, _, _, _ := iostreams.Test()
type args struct {
v string
opts *ApiOptions
}
tests := []struct {
name string
args args
want interface{}
wantErr bool
}{
{
name: "string",
args: args{v: "hello"},
want: "hello",
wantErr: false,
},
{
name: "bool true",
args: args{v: "true"},
want: true,
wantErr: false,
},
{
name: "bool false",
args: args{v: "false"},
want: false,
wantErr: false,
},
{
name: "null",
args: args{v: "null"},
want: nil,
wantErr: false,
},
{
name: "placeholder",
args: args{
v: ":owner",
opts: &ApiOptions{
IO: io,
BaseRepo: func() (ghrepo.Interface, error) {
return ghrepo.New("hubot", "robot-uprising"), nil
},
},
},
want: "hubot",
wantErr: false,
},
{
name: "file",
args: args{
v: "@" + f.Name(),
opts: &ApiOptions{IO: io},
},
want: []byte("file contents"),
wantErr: false,
},
{
name: "file error",
args: args{
v: "@",
opts: &ApiOptions{IO: io},
},
want: nil,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := magicFieldValue(tt.args.v, tt.args.opts)
if (err != nil) != tt.wantErr {
t.Errorf("magicFieldValue() error = %v, wantErr %v", err, tt.wantErr)
return
}
if tt.wantErr {
return
}
assert.Equal(t, tt.want, got)
})
}
}
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, length, 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, int64(13), length)
assert.Equal(t, "file contents", string(fb))
}
func Test_fillPlaceholders(t *testing.T) {
type args struct {
value string
opts *ApiOptions
}
tests := []struct {
name string
args args
want string
wantErr bool
}{
{
name: "no changes",
args: args{
value: "repos/owner/repo/releases",
opts: &ApiOptions{
BaseRepo: nil,
},
},
want: "repos/owner/repo/releases",
wantErr: false,
},
{
name: "has substitutes",
args: args{
value: "repos/:owner/:repo/releases",
opts: &ApiOptions{
BaseRepo: func() (ghrepo.Interface, error) {
return ghrepo.New("hubot", "robot-uprising"), nil
},
},
},
want: "repos/hubot/robot-uprising/releases",
wantErr: false,
},
{
name: "no greedy substitutes",
args: args{
value: ":ownership/:repository",
opts: &ApiOptions{
BaseRepo: nil,
},
},
want: ":ownership/:repository",
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := fillPlaceholders(tt.args.value, tt.args.opts)
if (err != nil) != tt.wantErr {
t.Errorf("fillPlaceholders() error = %v, wantErr %v", err, tt.wantErr)
return
}
if got != tt.want {
t.Errorf("fillPlaceholders() got = %v, want %v", got, tt.want)
}
})
}
}

131
pkg/cmd/api/http.go Normal file
View file

@ -0,0 +1,131 @@
package api
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
)
func httpRequest(client *http.Client, method string, p string, params interface{}, headers []string) (*http.Response, error) {
var requestURL string
// TODO: GHE support
if strings.Contains(p, "://") {
requestURL = p
} else {
requestURL = "https://api.github.com/" + p
}
var body io.Reader
var bodyIsJSON bool
isGraphQL := p == "graphql"
switch pp := params.(type) {
case map[string]interface{}:
if strings.EqualFold(method, "GET") {
requestURL = addQuery(requestURL, pp)
} else {
for key, value := range pp {
switch vv := value.(type) {
case []byte:
pp[key] = string(vv)
}
}
if isGraphQL {
pp = groupGraphQLVariables(pp)
}
b, err := json.Marshal(pp)
if err != nil {
return nil, fmt.Errorf("error serializing parameters: %w", err)
}
body = bytes.NewBuffer(b)
bodyIsJSON = true
}
case io.Reader:
body = pp
case nil:
body = nil
default:
return nil, fmt.Errorf("unrecognized parameters type: %v", params)
}
req, err := http.NewRequest(method, requestURL, body)
if err != nil {
return nil, err
}
for _, h := range headers {
idx := strings.IndexRune(h, ':')
if idx == -1 {
return nil, fmt.Errorf("header %q requires a value separated by ':'", h)
}
name, value := h[0:idx], strings.TrimSpace(h[idx+1:])
if strings.EqualFold(name, "Content-Length") {
length, err := strconv.ParseInt(value, 10, 0)
if err != nil {
return nil, err
}
req.ContentLength = length
} else {
req.Header.Add(name, value)
}
}
if bodyIsJSON && req.Header.Get("Content-Type") == "" {
req.Header.Set("Content-Type", "application/json; charset=utf-8")
}
return client.Do(req)
}
func groupGraphQLVariables(params map[string]interface{}) map[string]interface{} {
topLevel := make(map[string]interface{})
variables := make(map[string]interface{})
for key, val := range params {
switch key {
case "query":
topLevel[key] = val
default:
variables[key] = val
}
}
if len(variables) > 0 {
topLevel["variables"] = variables
}
return topLevel
}
func addQuery(path string, params map[string]interface{}) string {
if len(params) == 0 {
return path
}
query := url.Values{}
for key, value := range params {
switch v := value.(type) {
case string:
query.Add(key, v)
case []byte:
query.Add(key, string(v))
case nil:
query.Add(key, "")
case int:
query.Add(key, fmt.Sprintf("%d", v))
case bool:
query.Add(key, fmt.Sprintf("%v", v))
default:
panic(fmt.Sprintf("unknown type %v", v))
}
}
sep := "?"
if strings.ContainsRune(path, '?') {
sep = "&"
}
return path + sep + query.Encode()
}

306
pkg/cmd/api/http_test.go Normal file
View file

@ -0,0 +1,306 @@
package api
import (
"bytes"
"io/ioutil"
"net/http"
"testing"
"github.com/stretchr/testify/assert"
)
func Test_groupGraphQLVariables(t *testing.T) {
tests := []struct {
name string
args map[string]interface{}
want map[string]interface{}
}{
{
name: "empty",
args: map[string]interface{}{},
want: map[string]interface{}{},
},
{
name: "query only",
args: map[string]interface{}{
"query": "QUERY",
},
want: map[string]interface{}{
"query": "QUERY",
},
},
{
name: "variables only",
args: map[string]interface{}{
"name": "hubot",
},
want: map[string]interface{}{
"variables": map[string]interface{}{
"name": "hubot",
},
},
},
{
name: "query + variables",
args: map[string]interface{}{
"query": "QUERY",
"name": "hubot",
"power": 9001,
},
want: map[string]interface{}{
"query": "QUERY",
"variables": map[string]interface{}{
"name": "hubot",
"power": 9001,
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := groupGraphQLVariables(tt.args)
assert.Equal(t, tt.want, got)
})
}
}
type roundTripper func(*http.Request) (*http.Response, error)
func (f roundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req)
}
func Test_httpRequest(t *testing.T) {
var tr roundTripper = func(req *http.Request) (*http.Response, error) {
return &http.Response{Request: req}, nil
}
httpClient := http.Client{Transport: tr}
type args struct {
client *http.Client
method string
p string
params interface{}
headers []string
}
type expects struct {
method string
u string
body string
headers string
}
tests := []struct {
name string
args args
want expects
wantErr bool
}{
{
name: "simple GET",
args: args{
client: &httpClient,
method: "GET",
p: "repos/octocat/spoon-knife",
params: nil,
headers: []string{},
},
wantErr: false,
want: expects{
method: "GET",
u: "https://api.github.com/repos/octocat/spoon-knife",
body: "",
headers: "",
},
},
{
name: "GET with params",
args: args{
client: &httpClient,
method: "GET",
p: "repos/octocat/spoon-knife",
params: map[string]interface{}{
"a": "b",
},
headers: []string{},
},
wantErr: false,
want: expects{
method: "GET",
u: "https://api.github.com/repos/octocat/spoon-knife?a=b",
body: "",
headers: "",
},
},
{
name: "POST with params",
args: args{
client: &httpClient,
method: "POST",
p: "repos",
params: map[string]interface{}{
"a": "b",
},
headers: []string{},
},
wantErr: false,
want: expects{
method: "POST",
u: "https://api.github.com/repos",
body: `{"a":"b"}`,
headers: "Content-Type: application/json; charset=utf-8\r\n",
},
},
{
name: "POST GraphQL",
args: args{
client: &httpClient,
method: "POST",
p: "graphql",
params: map[string]interface{}{
"a": []byte("b"),
},
headers: []string{},
},
wantErr: false,
want: expects{
method: "POST",
u: "https://api.github.com/graphql",
body: `{"variables":{"a":"b"}}`,
headers: "Content-Type: application/json; charset=utf-8\r\n",
},
},
{
name: "POST with body and type",
args: args{
client: &httpClient,
method: "POST",
p: "repos",
params: bytes.NewBufferString("CUSTOM"),
headers: []string{
"content-type: text/plain",
"accept: application/json",
},
},
wantErr: false,
want: expects{
method: "POST",
u: "https://api.github.com/repos",
body: `CUSTOM`,
headers: "Accept: application/json\r\nContent-Type: text/plain\r\n",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := httpRequest(tt.args.client, tt.args.method, tt.args.p, tt.args.params, tt.args.headers)
if (err != nil) != tt.wantErr {
t.Errorf("httpRequest() error = %v, wantErr %v", err, tt.wantErr)
return
}
req := got.Request
if req.Method != tt.want.method {
t.Errorf("Request.Method = %q, want %q", req.Method, tt.want.method)
}
if req.URL.String() != tt.want.u {
t.Errorf("Request.URL = %q, want %q", req.URL.String(), tt.want.u)
}
if tt.want.body != "" {
bb, err := ioutil.ReadAll(req.Body)
if err != nil {
t.Errorf("Request.Body ReadAll error = %v", err)
return
}
if string(bb) != tt.want.body {
t.Errorf("Request.Body = %q, want %q", string(bb), tt.want.body)
}
}
h := bytes.Buffer{}
err = req.Header.WriteSubset(&h, map[string]bool{})
if err != nil {
t.Errorf("Request.Header WriteSubset error = %v", err)
return
}
if h.String() != tt.want.headers {
t.Errorf("Request.Header = %q, want %q", h.String(), tt.want.headers)
}
})
}
}
func Test_addQuery(t *testing.T) {
type args struct {
path string
params map[string]interface{}
}
tests := []struct {
name string
args args
want string
}{
{
name: "string",
args: args{
path: "",
params: map[string]interface{}{"a": "hello"},
},
want: "?a=hello",
},
{
name: "append",
args: args{
path: "path",
params: map[string]interface{}{"a": "b"},
},
want: "path?a=b",
},
{
name: "append query",
args: args{
path: "path?foo=bar",
params: map[string]interface{}{"a": "b"},
},
want: "path?foo=bar&a=b",
},
{
name: "[]byte",
args: args{
path: "",
params: map[string]interface{}{"a": []byte("hello")},
},
want: "?a=hello",
},
{
name: "int",
args: args{
path: "",
params: map[string]interface{}{"a": 123},
},
want: "?a=123",
},
{
name: "nil",
args: args{
path: "",
params: map[string]interface{}{"a": nil},
},
want: "?a=",
},
{
name: "bool",
args: args{
path: "",
params: map[string]interface{}{"a": true, "b": false},
},
want: "?a=true&b=false",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := addQuery(tt.args.path, tt.args.params); got != tt.want {
t.Errorf("addQuery() = %v, want %v", got, tt.want)
}
})
}
}

33
pkg/cmdutil/args.go Normal file
View file

@ -0,0 +1,33 @@
package cmdutil
import (
"errors"
"fmt"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
func NoArgsQuoteReminder(cmd *cobra.Command, args []string) error {
if len(args) < 1 {
return nil
}
errMsg := fmt.Sprintf("unknown argument %q", args[0])
if len(args) > 1 {
errMsg = fmt.Sprintf("unknown arguments %q", args)
}
hasValueFlag := false
cmd.Flags().Visit(func(f *pflag.Flag) {
if f.Value.Type() != "bool" {
hasValueFlag = true
}
})
if hasValueFlag {
errMsg += "; please quote all values that have spaces"
}
return &FlagError{Err: errors.New(errMsg)}
}

19
pkg/cmdutil/errors.go Normal file
View file

@ -0,0 +1,19 @@
package cmdutil
import "errors"
// FlagError is the kind of error raised in flag processing
type FlagError struct {
Err error
}
func (fe FlagError) Error() string {
return fe.Err.Error()
}
func (fe FlagError) Unwrap() error {
return fe.Err
}
// SilentError is an error that triggers exit code 1 without any error messaging
var SilentError = errors.New("SilentError")

14
pkg/cmdutil/factory.go Normal file
View file

@ -0,0 +1,14 @@
package cmdutil
import (
"net/http"
"github.com/cli/cli/internal/ghrepo"
"github.com/cli/cli/pkg/iostreams"
)
type Factory struct {
IOStreams *iostreams.IOStreams
HttpClient func() (*http.Client, error)
BaseRepo func() (ghrepo.Interface, error)
}

View file

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

View file

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

View file

@ -0,0 +1,54 @@
package iostreams
import (
"bytes"
"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: out,
ErrOut: os.Stderr,
colorEnabled: colorEnabled,
}
}
func Test() (*IOStreams, *bytes.Buffer, *bytes.Buffer, *bytes.Buffer) {
in := &bytes.Buffer{}
out := &bytes.Buffer{}
errOut := &bytes.Buffer{}
return &IOStreams{
In: ioutil.NopCloser(in),
Out: out,
ErrOut: errOut,
}, in, out, errOut
}
func isTerminal(f *os.File) bool {
return isatty.IsTerminal(f.Fd()) || isatty.IsCygwinTerminal(f.Fd())
}

View file

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

View file

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

View file

@ -68,6 +68,7 @@ func createStubbedPrepareCmd(cs *CmdStubber) func(*exec.Cmd) run.Runnable {
if call >= len(cs.Stubs) {
panic(fmt.Sprintf("more execs than stubs. most recent call: %v", cmd))
}
// fmt.Printf("Called stub for `%v`\n", cmd) // Helpful for debugging
return cs.Stubs[call]
}
}

View file

@ -9,10 +9,20 @@ import (
"github.com/mgutz/ansi"
)
var _isColorEnabled = true
var _isStdoutTerminal = false
var checkedTerminal = false
var checkedNoColor = false
var (
_isColorEnabled bool = true
_isStdoutTerminal, checkedTerminal, checkedNoColor bool
// Outputs ANSI color if stdout is a tty
Magenta = makeColorFunc("magenta")
Cyan = makeColorFunc("cyan")
Red = makeColorFunc("red")
Yellow = makeColorFunc("yellow")
Blue = makeColorFunc("blue")
Green = makeColorFunc("green")
Gray = makeColorFunc("black+h")
Bold = makeColorFunc("default+b")
)
func isStdoutTerminal() bool {
if !checkedTerminal {
@ -49,27 +59,3 @@ func isColorEnabled() bool {
}
return _isColorEnabled
}
// Magenta outputs ANSI color if stdout is a tty
var Magenta = makeColorFunc("magenta")
// Cyan outputs ANSI color if stdout is a tty
var Cyan = makeColorFunc("cyan")
// Red outputs ANSI color if stdout is a tty
var Red = makeColorFunc("red")
// Yellow outputs ANSI color if stdout is a tty
var Yellow = makeColorFunc("yellow")
// Blue outputs ANSI color if stdout is a tty
var Blue = makeColorFunc("blue")
// Green outputs ANSI color if stdout is a tty
var Green = makeColorFunc("green")
// Gray outputs ANSI color if stdout is a tty
var Gray = makeColorFunc("black+h")
// Bold outputs ANSI color if stdout is a tty
var Bold = makeColorFunc("default+b")

View file

@ -27,6 +27,6 @@
"description": "Use GitHub from the CLI",
"project-url": "https://github.com/cli/cli",
"tags": "github cli git",
"license-url": "https://github.com/cli/cli/blob/master/LICENSE"
"license-url": "https://github.com/cli/cli/blob/trunk/LICENSE"
}
}