Merge remote-tracking branch 'origin' into fix-incorrect-docs
This commit is contained in:
commit
4662abb312
75 changed files with 4527 additions and 1218 deletions
10
.github/PULL_REQUEST_TEMPLATE/bug_fix.md
vendored
10
.github/PULL_REQUEST_TEMPLATE/bug_fix.md
vendored
|
|
@ -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
|
||||
|
|
|
|||
2
.github/workflows/go.yml
vendored
2
.github/workflows/go.yml
vendored
|
|
@ -10,7 +10,7 @@ jobs:
|
|||
|
||||
steps:
|
||||
- name: Set up Go 1.14
|
||||
uses: actions/setup-go@v2-beta
|
||||
uses: actions/setup-go@v2
|
||||
with:
|
||||
go-version: 1.14
|
||||
|
||||
|
|
|
|||
9
.github/workflows/lint.yml
vendored
9
.github/workflows/lint.yml
vendored
|
|
@ -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
|
||||
|
||||
|
|
|
|||
2
.github/workflows/releases.yml
vendored
2
.github/workflows/releases.yml
vendored
|
|
@ -12,7 +12,7 @@ jobs:
|
|||
- name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
- name: Set up Go 1.14
|
||||
uses: actions/setup-go@v2-beta
|
||||
uses: actions/setup-go@v2
|
||||
with:
|
||||
go-version: 1.14
|
||||
- name: Generate changelog
|
||||
|
|
|
|||
3
.golangci.yml
Normal file
3
.golangci.yml
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
linters:
|
||||
enable:
|
||||
gofmt
|
||||
|
|
@ -82,6 +82,7 @@ nfpms:
|
|||
bindir: /usr/local
|
||||
dependencies:
|
||||
- git
|
||||
description: GitHub’s official command line tool.
|
||||
formats:
|
||||
- deb
|
||||
- rpm
|
||||
|
|
|
|||
8
Makefile
8
Makefile
|
|
@ -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
|
||||
|
|
|
|||
50
README.md
50
README.md
|
|
@ -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.
|
||||
|
||||

|
||||

|
||||
|
||||
## 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
|
||||
|
|
|
|||
|
|
@ -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"), ",")
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
18
api/queries_user.go
Normal 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
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
214
command/alias.go
Normal 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
321
command/alias_test.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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
122
command/help.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
||||
|
|
|
|||
356
command/pr.go
356
command/pr.go
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
109
command/pr_lookup.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
250
command/root.go
250
command/root.go
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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, ¬Found) {
|
||||
// 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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
62
docs/triage.md
Normal 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
|
||||
18
git/git.go
18
git/git.go
|
|
@ -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, "/")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
21
go.mod
|
|
@ -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
45
go.sum
|
|
@ -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=
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
60
internal/config/alias_config.go
Normal file
60
internal/config/alias_config.go
Normal 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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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, ¬Found) {
|
||||
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, ¬Found) {
|
||||
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, ¬Found) {
|
||||
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{}
|
||||
|
||||
|
|
|
|||
57
internal/config/config_type_test.go
Normal file
57
internal/config/config_type_test.go
Normal 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")
|
||||
}
|
||||
|
|
@ -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
359
pkg/cmd/api/api.go
Normal 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
536
pkg/cmd/api/api_test.go
Normal 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
131
pkg/cmd/api/http.go
Normal 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
306
pkg/cmd/api/http_test.go
Normal 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
33
pkg/cmdutil/args.go
Normal 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
19
pkg/cmdutil/errors.go
Normal 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
14
pkg/cmdutil/factory.go
Normal 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)
|
||||
}
|
||||
|
|
@ -10,8 +10,8 @@ import (
|
|||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// Find returns the list of template file paths
|
||||
func Find(rootDir string, name string) []string {
|
||||
// FindNonLegacy returns the list of template file paths from the template folder (according to the "upgraded multiple template builder")
|
||||
func FindNonLegacy(rootDir string, name string) []string {
|
||||
results := []string{}
|
||||
|
||||
// https://help.github.com/en/github/building-a-strong-community/creating-a-pull-request-template-for-your-repository
|
||||
|
|
@ -46,21 +46,34 @@ mainLoop:
|
|||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Strings(results)
|
||||
return results
|
||||
}
|
||||
|
||||
// FindLegacy returns the file path of the default(legacy) template
|
||||
func FindLegacy(rootDir string, name string) *string {
|
||||
// https://help.github.com/en/github/building-a-strong-community/creating-a-pull-request-template-for-your-repository
|
||||
candidateDirs := []string{
|
||||
path.Join(rootDir, ".github"),
|
||||
rootDir,
|
||||
path.Join(rootDir, "docs"),
|
||||
}
|
||||
for _, dir := range candidateDirs {
|
||||
files, err := ioutil.ReadDir(dir)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// detect a single template file
|
||||
for _, file := range files {
|
||||
if strings.EqualFold(file.Name(), name+".md") {
|
||||
results = append(results, path.Join(dir, file.Name()))
|
||||
break
|
||||
result := path.Join(dir, file.Name())
|
||||
return &result
|
||||
}
|
||||
}
|
||||
if len(results) > 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
sort.Strings(results)
|
||||
return results
|
||||
return nil
|
||||
}
|
||||
|
||||
// ExtractName returns the name of the template from YAML front-matter
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import (
|
|||
"testing"
|
||||
)
|
||||
|
||||
func TestFind(t *testing.T) {
|
||||
func TestFindNonLegacy(t *testing.T) {
|
||||
tmpdir, err := ioutil.TempDir("", "gh-cli")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
|
@ -25,52 +25,69 @@ func TestFind(t *testing.T) {
|
|||
want []string
|
||||
}{
|
||||
{
|
||||
name: "Template in root",
|
||||
name: "Legacy templates ignored",
|
||||
prepare: []string{
|
||||
"README.md",
|
||||
"ISSUE_TEMPLATE",
|
||||
"issue_template.md",
|
||||
"issue_template.txt",
|
||||
"pull_request_template.md",
|
||||
},
|
||||
args: args{
|
||||
rootDir: tmpdir,
|
||||
name: "ISSUE_TEMPLATE",
|
||||
},
|
||||
want: []string{
|
||||
path.Join(tmpdir, "issue_template.md"),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Template in .github takes precedence",
|
||||
prepare: []string{
|
||||
"ISSUE_TEMPLATE.md",
|
||||
".github/issue_template.md",
|
||||
},
|
||||
args: args{
|
||||
rootDir: tmpdir,
|
||||
name: "ISSUE_TEMPLATE",
|
||||
},
|
||||
want: []string{
|
||||
path.Join(tmpdir, ".github/issue_template.md"),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Template in docs",
|
||||
prepare: []string{
|
||||
"README.md",
|
||||
"docs/issue_template.md",
|
||||
},
|
||||
args: args{
|
||||
rootDir: tmpdir,
|
||||
name: "ISSUE_TEMPLATE",
|
||||
},
|
||||
want: []string{},
|
||||
},
|
||||
{
|
||||
name: "Template folder in .github takes precedence",
|
||||
prepare: []string{
|
||||
"ISSUE_TEMPLATE.md",
|
||||
"docs/ISSUE_TEMPLATE/abc.md",
|
||||
"ISSUE_TEMPLATE/abc.md",
|
||||
".github/ISSUE_TEMPLATE/abc.md",
|
||||
},
|
||||
args: args{
|
||||
rootDir: tmpdir,
|
||||
name: "ISSUE_TEMPLATE",
|
||||
},
|
||||
want: []string{
|
||||
path.Join(tmpdir, "docs/issue_template.md"),
|
||||
path.Join(tmpdir, ".github/ISSUE_TEMPLATE/abc.md"),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Multiple templates",
|
||||
name: "Template folder in root",
|
||||
prepare: []string{
|
||||
"ISSUE_TEMPLATE.md",
|
||||
"docs/ISSUE_TEMPLATE/abc.md",
|
||||
"ISSUE_TEMPLATE/abc.md",
|
||||
},
|
||||
args: args{
|
||||
rootDir: tmpdir,
|
||||
name: "ISSUE_TEMPLATE",
|
||||
},
|
||||
want: []string{
|
||||
path.Join(tmpdir, "ISSUE_TEMPLATE/abc.md"),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Template folder in docs",
|
||||
prepare: []string{
|
||||
"ISSUE_TEMPLATE.md",
|
||||
"docs/ISSUE_TEMPLATE/abc.md",
|
||||
},
|
||||
args: args{
|
||||
rootDir: tmpdir,
|
||||
name: "ISSUE_TEMPLATE",
|
||||
},
|
||||
want: []string{
|
||||
path.Join(tmpdir, "docs/ISSUE_TEMPLATE/abc.md"),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Multiple templates in template folder",
|
||||
prepare: []string{
|
||||
".github/ISSUE_TEMPLATE/nope.md",
|
||||
".github/PULL_REQUEST_TEMPLATE.md",
|
||||
|
|
@ -90,18 +107,17 @@ func TestFind(t *testing.T) {
|
|||
},
|
||||
},
|
||||
{
|
||||
name: "Empty multiple templates directory",
|
||||
name: "Empty template directories",
|
||||
prepare: []string{
|
||||
".github/issue_template.md",
|
||||
".github/issue_template/.keep",
|
||||
".github/ISSUE_TEMPLATE/.keep",
|
||||
".docs/ISSUE_TEMPLATE/.keep",
|
||||
"ISSUE_TEMPLATE/.keep",
|
||||
},
|
||||
args: args{
|
||||
rootDir: tmpdir,
|
||||
name: "ISSUE_TEMPLATE",
|
||||
},
|
||||
want: []string{
|
||||
path.Join(tmpdir, ".github/issue_template.md"),
|
||||
},
|
||||
want: []string{},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
|
|
@ -116,7 +132,99 @@ func TestFind(t *testing.T) {
|
|||
file.Close()
|
||||
}
|
||||
|
||||
if got := Find(tt.args.rootDir, tt.args.name); !reflect.DeepEqual(got, tt.want) {
|
||||
if got := FindNonLegacy(tt.args.rootDir, tt.args.name); !reflect.DeepEqual(got, tt.want) {
|
||||
t.Errorf("Find() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
os.RemoveAll(tmpdir)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindLegacy(t *testing.T) {
|
||||
tmpdir, err := ioutil.TempDir("", "gh-cli")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
type args struct {
|
||||
rootDir string
|
||||
name string
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
prepare []string
|
||||
args args
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "Template in root",
|
||||
prepare: []string{
|
||||
"README.md",
|
||||
"ISSUE_TEMPLATE",
|
||||
"issue_template.md",
|
||||
"issue_template.txt",
|
||||
"pull_request_template.md",
|
||||
"docs/issue_template.md",
|
||||
},
|
||||
args: args{
|
||||
rootDir: tmpdir,
|
||||
name: "ISSUE_TEMPLATE",
|
||||
},
|
||||
want: path.Join(tmpdir, "issue_template.md"),
|
||||
},
|
||||
{
|
||||
name: "Template in .github takes precedence",
|
||||
prepare: []string{
|
||||
"ISSUE_TEMPLATE.md",
|
||||
".github/issue_template.md",
|
||||
"docs/issue_template.md",
|
||||
},
|
||||
args: args{
|
||||
rootDir: tmpdir,
|
||||
name: "ISSUE_TEMPLATE",
|
||||
},
|
||||
want: path.Join(tmpdir, ".github/issue_template.md"),
|
||||
},
|
||||
{
|
||||
name: "Template in docs",
|
||||
prepare: []string{
|
||||
"README.md",
|
||||
"docs/issue_template.md",
|
||||
},
|
||||
args: args{
|
||||
rootDir: tmpdir,
|
||||
name: "ISSUE_TEMPLATE",
|
||||
},
|
||||
want: path.Join(tmpdir, "docs/issue_template.md"),
|
||||
},
|
||||
{
|
||||
name: "Non legacy templates ignored",
|
||||
prepare: []string{
|
||||
".github/PULL_REQUEST_TEMPLATE/abc.md",
|
||||
"PULL_REQUEST_TEMPLATE/abc.md",
|
||||
"docs/PULL_REQUEST_TEMPLATE/abc.md",
|
||||
".github/PULL_REQUEST_TEMPLATE.md",
|
||||
},
|
||||
args: args{
|
||||
rootDir: tmpdir,
|
||||
name: "PuLl_ReQuEsT_TeMpLaTe",
|
||||
},
|
||||
want: path.Join(tmpdir, ".github/PULL_REQUEST_TEMPLATE.md"),
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
for _, p := range tt.prepare {
|
||||
fp := path.Join(tmpdir, p)
|
||||
_ = os.MkdirAll(path.Dir(fp), 0700)
|
||||
file, err := os.Create(fp)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
file.Close()
|
||||
}
|
||||
|
||||
if got := FindLegacy(tt.args.rootDir, tt.args.name); *got != tt.want {
|
||||
t.Errorf("Find() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
|
|
|
|||
54
pkg/iostreams/iostreams.go
Normal file
54
pkg/iostreams/iostreams.go
Normal 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())
|
||||
}
|
||||
96
pkg/jsoncolor/jsoncolor.go
Normal file
96
pkg/jsoncolor/jsoncolor.go
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
package jsoncolor
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
colorDelim = "1;38" // bright white
|
||||
colorKey = "1;34" // bright blue
|
||||
colorNull = "1;30" // gray
|
||||
colorString = "32" // green
|
||||
colorBool = "33" // yellow
|
||||
)
|
||||
|
||||
// Write colorized JSON output parsed from reader
|
||||
func Write(w io.Writer, r io.Reader, indent string) error {
|
||||
dec := json.NewDecoder(r)
|
||||
dec.UseNumber()
|
||||
|
||||
var idx int
|
||||
var stack []json.Delim
|
||||
|
||||
for {
|
||||
t, err := dec.Token()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch tt := t.(type) {
|
||||
case json.Delim:
|
||||
switch tt {
|
||||
case '{', '[':
|
||||
stack = append(stack, tt)
|
||||
idx = 0
|
||||
fmt.Fprintf(w, "\x1b[%sm%s\x1b[m", colorDelim, tt)
|
||||
if dec.More() {
|
||||
fmt.Fprint(w, "\n", strings.Repeat(indent, len(stack)))
|
||||
}
|
||||
continue
|
||||
case '}', ']':
|
||||
stack = stack[:len(stack)-1]
|
||||
idx = 0
|
||||
fmt.Fprintf(w, "\x1b[%sm%s\x1b[m", colorDelim, tt)
|
||||
}
|
||||
default:
|
||||
b, err := json.Marshal(tt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
isKey := len(stack) > 0 && stack[len(stack)-1] == '{' && idx%2 == 0
|
||||
idx++
|
||||
|
||||
var color string
|
||||
if isKey {
|
||||
color = colorKey
|
||||
} else if tt == nil {
|
||||
color = colorNull
|
||||
} else {
|
||||
switch t.(type) {
|
||||
case string:
|
||||
color = colorString
|
||||
case bool:
|
||||
color = colorBool
|
||||
}
|
||||
}
|
||||
|
||||
if color == "" {
|
||||
_, _ = w.Write(b)
|
||||
} else {
|
||||
fmt.Fprintf(w, "\x1b[%sm%s\x1b[m", color, b)
|
||||
}
|
||||
|
||||
if isKey {
|
||||
fmt.Fprintf(w, "\x1b[%sm:\x1b[m ", colorDelim)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if dec.More() {
|
||||
fmt.Fprintf(w, "\x1b[%sm,\x1b[m\n%s", colorDelim, strings.Repeat(indent, len(stack)))
|
||||
} else if len(stack) > 0 {
|
||||
fmt.Fprint(w, "\n", strings.Repeat(indent, len(stack)-1))
|
||||
} else {
|
||||
fmt.Fprint(w, "\n")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
83
pkg/jsoncolor/jsoncolor_test.go
Normal file
83
pkg/jsoncolor/jsoncolor_test.go
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
package jsoncolor
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
)
|
||||
|
||||
func TestWrite(t *testing.T) {
|
||||
type args struct {
|
||||
r io.Reader
|
||||
indent string
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
args args
|
||||
wantW string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "blank",
|
||||
args: args{
|
||||
r: bytes.NewBufferString(``),
|
||||
indent: "",
|
||||
},
|
||||
wantW: "",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "empty object",
|
||||
args: args{
|
||||
r: bytes.NewBufferString(`{}`),
|
||||
indent: "",
|
||||
},
|
||||
wantW: "\x1b[1;38m{\x1b[m\x1b[1;38m}\x1b[m\n",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "nested object",
|
||||
args: args{
|
||||
r: bytes.NewBufferString(`{"hash":{"a":1,"b":2},"array":[3,4]}`),
|
||||
indent: "\t",
|
||||
},
|
||||
wantW: "\x1b[1;38m{\x1b[m\n\t\x1b[1;34m\"hash\"\x1b[m\x1b[1;38m:\x1b[m " +
|
||||
"\x1b[1;38m{\x1b[m\n\t\t\x1b[1;34m\"a\"\x1b[m\x1b[1;38m:\x1b[m 1\x1b[1;38m,\x1b[m\n\t\t\x1b[1;34m\"b\"\x1b[m\x1b[1;38m:\x1b[m 2\n\t\x1b[1;38m}\x1b[m\x1b[1;38m,\x1b[m" +
|
||||
"\n\t\x1b[1;34m\"array\"\x1b[m\x1b[1;38m:\x1b[m \x1b[1;38m[\x1b[m\n\t\t3\x1b[1;38m,\x1b[m\n\t\t4\n\t\x1b[1;38m]\x1b[m\n\x1b[1;38m}\x1b[m\n",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "string",
|
||||
args: args{
|
||||
r: bytes.NewBufferString(`"foo"`),
|
||||
indent: "",
|
||||
},
|
||||
wantW: "\x1b[32m\"foo\"\x1b[m\n",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "error",
|
||||
args: args{
|
||||
r: bytes.NewBufferString(`{{`),
|
||||
indent: "",
|
||||
},
|
||||
wantW: "\x1b[1;38m{\x1b[m\n",
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
w := &bytes.Buffer{}
|
||||
if err := Write(w, tt.args.r, tt.args.indent); (err != nil) != tt.wantErr {
|
||||
t.Errorf("Write() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
diff := cmp.Diff(tt.wantW, w.String())
|
||||
if diff != "" {
|
||||
t.Error(diff)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
2
wix.json
2
wix.json
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue