Merge remote-tracking branch 'origin/trunk' into prclose-delete
This commit is contained in:
commit
e44eb665a1
179 changed files with 12538 additions and 8277 deletions
4
.github/workflows/go.yml
vendored
4
.github/workflows/go.yml
vendored
|
|
@ -9,10 +9,10 @@ jobs:
|
||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Set up Go 1.14
|
- name: Set up Go 1.15
|
||||||
uses: actions/setup-go@v2
|
uses: actions/setup-go@v2
|
||||||
with:
|
with:
|
||||||
go-version: 1.14
|
go-version: 1.15
|
||||||
|
|
||||||
- name: Check out code
|
- name: Check out code
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v2
|
||||||
|
|
|
||||||
4
.github/workflows/lint.yml
vendored
4
.github/workflows/lint.yml
vendored
|
|
@ -16,10 +16,10 @@ jobs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Set up Go 1.14
|
- name: Set up Go 1.15
|
||||||
uses: actions/setup-go@v2
|
uses: actions/setup-go@v2
|
||||||
with:
|
with:
|
||||||
go-version: 1.14
|
go-version: 1.15
|
||||||
|
|
||||||
- name: Check out code
|
- name: Check out code
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v2
|
||||||
|
|
|
||||||
5
.github/workflows/releases.yml
vendored
5
.github/workflows/releases.yml
vendored
|
|
@ -11,10 +11,10 @@ jobs:
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v2
|
||||||
- name: Set up Go 1.14
|
- name: Set up Go 1.15
|
||||||
uses: actions/setup-go@v2
|
uses: actions/setup-go@v2
|
||||||
with:
|
with:
|
||||||
go-version: 1.14
|
go-version: 1.15
|
||||||
- name: Generate changelog
|
- name: Generate changelog
|
||||||
run: |
|
run: |
|
||||||
echo ::set-env name=GORELEASER_CURRENT_TAG::${GITHUB_REF#refs/tags/}
|
echo ::set-env name=GORELEASER_CURRENT_TAG::${GITHUB_REF#refs/tags/}
|
||||||
|
|
@ -32,6 +32,7 @@ jobs:
|
||||||
if: "!contains(github.ref, '-')" # skip prereleases
|
if: "!contains(github.ref, '-')" # skip prereleases
|
||||||
with:
|
with:
|
||||||
formula-name: gh
|
formula-name: gh
|
||||||
|
download-url: https://github.com/cli/cli.git
|
||||||
env:
|
env:
|
||||||
COMMITTER_TOKEN: ${{ secrets.UPLOAD_GITHUB_TOKEN }}
|
COMMITTER_TOKEN: ${{ secrets.UPLOAD_GITHUB_TOKEN }}
|
||||||
- name: Checkout documentation site
|
- name: Checkout documentation site
|
||||||
|
|
|
||||||
32
README.md
32
README.md
|
|
@ -113,43 +113,53 @@ MSI installers are available for download on the [releases page][].
|
||||||
|
|
||||||
Install and upgrade:
|
Install and upgrade:
|
||||||
|
|
||||||
1. Download the `.deb` file from the [releases page][]
|
1. Download the `.deb` file from the [releases page][];
|
||||||
2. `sudo apt install ./gh_*_linux_amd64.deb` install the downloaded file
|
2. Install the downloaded file: `sudo apt install ./gh_*_linux_amd64.deb`
|
||||||
|
|
||||||
### Fedora Linux
|
### Fedora Linux
|
||||||
|
|
||||||
Install and upgrade:
|
Install and upgrade:
|
||||||
|
|
||||||
1. Download the `.rpm` file from the [releases page][]
|
1. Download the `.rpm` file from the [releases page][];
|
||||||
2. `sudo dnf install gh_*_linux_amd64.rpm` install the downloaded file
|
2. Install the downloaded file: `sudo dnf install gh_*_linux_amd64.rpm`
|
||||||
|
|
||||||
### Centos Linux
|
### Centos Linux
|
||||||
|
|
||||||
Install and upgrade:
|
Install and upgrade:
|
||||||
|
|
||||||
1. Download the `.rpm` file from the [releases page][]
|
1. Download the `.rpm` file from the [releases page][];
|
||||||
2. `sudo yum localinstall gh_*_linux_amd64.rpm` install the downloaded file
|
2. Install the downloaded file: `sudo yum localinstall gh_*_linux_amd64.rpm`
|
||||||
|
|
||||||
### openSUSE/SUSE Linux
|
### openSUSE/SUSE Linux
|
||||||
|
|
||||||
Install and upgrade:
|
Install and upgrade:
|
||||||
|
|
||||||
1. Download the `.rpm` file from the [releases page][]
|
1. Download the `.rpm` file from the [releases page][];
|
||||||
2. `sudo zypper in gh_*_linux_amd64.rpm` install the downloaded file
|
2. Install the downloaded file: `sudo zypper in gh_*_linux_amd64.rpm`
|
||||||
|
|
||||||
### Arch Linux
|
### Arch Linux
|
||||||
|
|
||||||
Arch Linux users can install from the community repo: https://www.archlinux.org/packages/community/x86_64/github-cli/
|
Arch Linux users can install from the [community repo](https://www.archlinux.org/packages/community/x86_64/github-cli/):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pacman -S github-cli
|
pacman -S github-cli
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Android
|
||||||
|
|
||||||
|
Android users can install via Termux:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pkg install gh
|
||||||
|
```
|
||||||
|
|
||||||
### Other platforms
|
### Other platforms
|
||||||
|
|
||||||
Install a prebuilt binary from the [releases page][]
|
Download packaged binaries from the [releases page][].
|
||||||
|
|
||||||
### [Build from source](/docs/source.md)
|
### Build from source
|
||||||
|
|
||||||
|
See here on how to [build GitHub CLI from source](/docs/source.md).
|
||||||
|
|
||||||
[docs]: https://cli.github.com/manual
|
[docs]: https://cli.github.com/manual
|
||||||
[scoop]: https://scoop.sh
|
[scoop]: https://scoop.sh
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package api
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"io/ioutil"
|
"io/ioutil"
|
||||||
|
|
@ -11,6 +12,7 @@ import (
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/cli/cli/internal/ghinstance"
|
||||||
"github.com/henvic/httpretty"
|
"github.com/henvic/httpretty"
|
||||||
"github.com/shurcooL/graphql"
|
"github.com/shurcooL/graphql"
|
||||||
)
|
)
|
||||||
|
|
@ -43,25 +45,21 @@ func NewClientFromHTTP(httpClient *http.Client) *Client {
|
||||||
func AddHeader(name, value string) ClientOption {
|
func AddHeader(name, value string) ClientOption {
|
||||||
return func(tr http.RoundTripper) http.RoundTripper {
|
return func(tr http.RoundTripper) http.RoundTripper {
|
||||||
return &funcTripper{roundTrip: func(req *http.Request) (*http.Response, error) {
|
return &funcTripper{roundTrip: func(req *http.Request) (*http.Response, error) {
|
||||||
// prevent the token from leaking to non-GitHub hosts
|
req.Header.Add(name, value)
|
||||||
// TODO: GHE support
|
|
||||||
if !strings.EqualFold(name, "Authorization") || strings.HasSuffix(req.URL.Hostname(), ".github.com") {
|
|
||||||
req.Header.Add(name, value)
|
|
||||||
}
|
|
||||||
return tr.RoundTrip(req)
|
return tr.RoundTrip(req)
|
||||||
}}
|
}}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// AddHeaderFunc is an AddHeader that gets the string value from a function
|
// AddHeaderFunc is an AddHeader that gets the string value from a function
|
||||||
func AddHeaderFunc(name string, value func() string) ClientOption {
|
func AddHeaderFunc(name string, getValue func(*http.Request) (string, error)) ClientOption {
|
||||||
return func(tr http.RoundTripper) http.RoundTripper {
|
return func(tr http.RoundTripper) http.RoundTripper {
|
||||||
return &funcTripper{roundTrip: func(req *http.Request) (*http.Response, error) {
|
return &funcTripper{roundTrip: func(req *http.Request) (*http.Response, error) {
|
||||||
// prevent the token from leaking to non-GitHub hosts
|
value, err := getValue(req)
|
||||||
// TODO: GHE support
|
if err != nil {
|
||||||
if !strings.EqualFold(name, "Authorization") || strings.HasSuffix(req.URL.Hostname(), ".github.com") {
|
return nil, err
|
||||||
req.Header.Add(name, value())
|
|
||||||
}
|
}
|
||||||
|
req.Header.Add(name, value)
|
||||||
return tr.RoundTrip(req)
|
return tr.RoundTrip(req)
|
||||||
}}
|
}}
|
||||||
}
|
}
|
||||||
|
|
@ -198,19 +196,22 @@ func (err HTTPError) Error() string {
|
||||||
return fmt.Sprintf("HTTP %d (%s)", err.StatusCode, err.RequestURL)
|
return fmt.Sprintf("HTTP %d (%s)", err.StatusCode, err.RequestURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Returns whether or not scopes are present, appID, and error
|
type MissingScopesError struct {
|
||||||
func (c Client) HasScopes(wantedScopes ...string) (bool, string, error) {
|
error
|
||||||
url := "https://api.github.com/user"
|
}
|
||||||
req, err := http.NewRequest("GET", url, nil)
|
|
||||||
|
func (c Client) HasMinimumScopes(hostname string) error {
|
||||||
|
apiEndpoint := ghinstance.RESTPrefix(hostname)
|
||||||
|
|
||||||
|
req, err := http.NewRequest("GET", apiEndpoint, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, "", err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
req.Header.Set("Content-Type", "application/json; charset=utf-8")
|
req.Header.Set("Content-Type", "application/json; charset=utf-8")
|
||||||
|
|
||||||
res, err := c.http.Do(req)
|
res, err := c.http.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, "", err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
defer func() {
|
defer func() {
|
||||||
|
|
@ -221,37 +222,45 @@ func (c Client) HasScopes(wantedScopes ...string) (bool, string, error) {
|
||||||
}()
|
}()
|
||||||
|
|
||||||
if res.StatusCode != 200 {
|
if res.StatusCode != 200 {
|
||||||
return false, "", handleHTTPError(res)
|
return handleHTTPError(res)
|
||||||
}
|
}
|
||||||
|
|
||||||
appID := res.Header.Get("X-Oauth-Client-Id")
|
|
||||||
hasScopes := strings.Split(res.Header.Get("X-Oauth-Scopes"), ",")
|
hasScopes := strings.Split(res.Header.Get("X-Oauth-Scopes"), ",")
|
||||||
|
|
||||||
found := 0
|
search := map[string]bool{
|
||||||
|
"repo": false,
|
||||||
|
"read:org": false,
|
||||||
|
"admin:org": false,
|
||||||
|
}
|
||||||
|
|
||||||
for _, s := range hasScopes {
|
for _, s := range hasScopes {
|
||||||
for _, w := range wantedScopes {
|
search[strings.TrimSpace(s)] = true
|
||||||
if w == strings.TrimSpace(s) {
|
|
||||||
found++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if found == len(wantedScopes) {
|
errorMsgs := []string{}
|
||||||
return true, appID, nil
|
if !search["repo"] {
|
||||||
|
errorMsgs = append(errorMsgs, "missing required scope 'repo'")
|
||||||
}
|
}
|
||||||
|
|
||||||
return false, appID, nil
|
if !search["read:org"] && !search["admin:org"] {
|
||||||
|
errorMsgs = append(errorMsgs, "missing required scope 'read:org'")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(errorMsgs) > 0 {
|
||||||
|
return &MissingScopesError{error: errors.New(strings.Join(errorMsgs, ";"))}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GraphQL performs a GraphQL request and parses the response
|
// GraphQL performs a GraphQL request and parses the response
|
||||||
func (c Client) GraphQL(query string, variables map[string]interface{}, data interface{}) error {
|
func (c Client) GraphQL(hostname string, query string, variables map[string]interface{}, data interface{}) error {
|
||||||
url := "https://api.github.com/graphql"
|
|
||||||
reqBody, err := json.Marshal(map[string]interface{}{"query": query, "variables": variables})
|
reqBody, err := json.Marshal(map[string]interface{}{"query": query, "variables": variables})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
req, err := http.NewRequest("POST", url, bytes.NewBuffer(reqBody))
|
req, err := http.NewRequest("POST", ghinstance.GraphQLEndpoint(hostname), bytes.NewBuffer(reqBody))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -267,13 +276,13 @@ func (c Client) GraphQL(query string, variables map[string]interface{}, data int
|
||||||
return handleResponse(resp, data)
|
return handleResponse(resp, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
func graphQLClient(h *http.Client) *graphql.Client {
|
func graphQLClient(h *http.Client, hostname string) *graphql.Client {
|
||||||
return graphql.NewClient("https://api.github.com/graphql", h)
|
return graphql.NewClient(ghinstance.GraphQLEndpoint(hostname), h)
|
||||||
}
|
}
|
||||||
|
|
||||||
// REST performs a REST request and parses the response.
|
// REST performs a REST request and parses the response.
|
||||||
func (c Client) REST(method string, p string, body io.Reader, data interface{}) error {
|
func (c Client) REST(hostname string, method string, p string, body io.Reader, data interface{}) error {
|
||||||
url := "https://api.github.com/" + p
|
url := ghinstance.RESTPrefix(hostname) + p
|
||||||
req, err := http.NewRequest(method, url, body)
|
req, err := http.NewRequest(method, url, body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@ func TestGraphQL(t *testing.T) {
|
||||||
}{}
|
}{}
|
||||||
|
|
||||||
http.StubResponse(200, bytes.NewBufferString(`{"data":{"viewer":{"login":"hubot"}}}`))
|
http.StubResponse(200, bytes.NewBufferString(`{"data":{"viewer":{"login":"hubot"}}}`))
|
||||||
err := client.GraphQL("QUERY", vars, &response)
|
err := client.GraphQL("github.com", "QUERY", vars, &response)
|
||||||
eq(t, err, nil)
|
eq(t, err, nil)
|
||||||
eq(t, response.Viewer.Login, "hubot")
|
eq(t, response.Viewer.Login, "hubot")
|
||||||
|
|
||||||
|
|
@ -55,7 +55,7 @@ func TestGraphQLError(t *testing.T) {
|
||||||
]
|
]
|
||||||
}`))
|
}`))
|
||||||
|
|
||||||
err := client.GraphQL("", nil, &response)
|
err := client.GraphQL("github.com", "", nil, &response)
|
||||||
if err == nil || err.Error() != "GraphQL error: OH NO\nthis is fine" {
|
if err == nil || err.Error() != "GraphQL error: OH NO\nthis is fine" {
|
||||||
t.Fatalf("got %q", err.Error())
|
t.Fatalf("got %q", err.Error())
|
||||||
}
|
}
|
||||||
|
|
@ -71,7 +71,7 @@ func TestRESTGetDelete(t *testing.T) {
|
||||||
http.StubResponse(204, bytes.NewBuffer([]byte{}))
|
http.StubResponse(204, bytes.NewBuffer([]byte{}))
|
||||||
|
|
||||||
r := bytes.NewReader([]byte(`{}`))
|
r := bytes.NewReader([]byte(`{}`))
|
||||||
err := client.REST("DELETE", "applications/CLIENTID/grant", r, nil)
|
err := client.REST("github.com", "DELETE", "applications/CLIENTID/grant", r, nil)
|
||||||
eq(t, err, nil)
|
eq(t, err, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -82,7 +82,7 @@ func TestRESTError(t *testing.T) {
|
||||||
http.StubResponse(422, bytes.NewBufferString(`{"message": "OH NO"}`))
|
http.StubResponse(422, bytes.NewBufferString(`{"message": "OH NO"}`))
|
||||||
|
|
||||||
var httpErr HTTPError
|
var httpErr HTTPError
|
||||||
err := client.REST("DELETE", "repos/branch", nil, nil)
|
err := client.REST("github.com", "DELETE", "repos/branch", nil, nil)
|
||||||
if err == nil || !errors.As(err, &httpErr) {
|
if err == nil || !errors.As(err, &httpErr) {
|
||||||
t.Fatalf("got %v", err)
|
t.Fatalf("got %v", err)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,52 +0,0 @@
|
||||||
package api
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"encoding/json"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Gist represents a GitHub's gist.
|
|
||||||
type Gist struct {
|
|
||||||
Description string `json:"description,omitempty"`
|
|
||||||
Public bool `json:"public,omitempty"`
|
|
||||||
Files map[GistFilename]GistFile `json:"files,omitempty"`
|
|
||||||
HTMLURL string `json:"html_url,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type GistFilename string
|
|
||||||
|
|
||||||
type GistFile struct {
|
|
||||||
Content string `json:"content,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a gist for authenticated user.
|
|
||||||
//
|
|
||||||
// GitHub API docs: https://developer.github.com/v3/gists/#create-a-gist
|
|
||||||
func GistCreate(client *Client, description string, public bool, files map[string]string) (*Gist, error) {
|
|
||||||
gistFiles := map[GistFilename]GistFile{}
|
|
||||||
|
|
||||||
for filename, content := range files {
|
|
||||||
gistFiles[GistFilename(filename)] = GistFile{content}
|
|
||||||
}
|
|
||||||
|
|
||||||
path := "gists"
|
|
||||||
body := &Gist{
|
|
||||||
Description: description,
|
|
||||||
Public: public,
|
|
||||||
Files: gistFiles,
|
|
||||||
}
|
|
||||||
result := Gist{}
|
|
||||||
|
|
||||||
requestByte, err := json.Marshal(body)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
requestBody := bytes.NewReader(requestByte)
|
|
||||||
|
|
||||||
err = client.REST("POST", path, requestBody, &result)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return &result, nil
|
|
||||||
}
|
|
||||||
|
|
@ -112,7 +112,7 @@ func IssueCreate(client *Client, repo *Repository, params map[string]interface{}
|
||||||
}
|
}
|
||||||
}{}
|
}{}
|
||||||
|
|
||||||
err := client.GraphQL(query, variables, &result)
|
err := client.GraphQL(repo.RepoHost(), query, variables, &result)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -171,7 +171,7 @@ func IssueStatus(client *Client, repo ghrepo.Interface, currentUsername string)
|
||||||
}
|
}
|
||||||
|
|
||||||
var resp response
|
var resp response
|
||||||
err := client.GraphQL(query, variables, &resp)
|
err := client.GraphQL(repo.RepoHost(), query, variables, &resp)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -270,7 +270,7 @@ func IssueList(client *Client, repo ghrepo.Interface, state string, labels []str
|
||||||
loop:
|
loop:
|
||||||
for {
|
for {
|
||||||
variables["limit"] = pageLimit
|
variables["limit"] = pageLimit
|
||||||
err := client.GraphQL(query, variables, &response)
|
err := client.GraphQL(repo.RepoHost(), query, variables, &response)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -361,7 +361,7 @@ func IssueByNumber(client *Client, repo ghrepo.Interface, number int) (*Issue, e
|
||||||
}
|
}
|
||||||
|
|
||||||
var resp response
|
var resp response
|
||||||
err := client.GraphQL(query, variables, &resp)
|
err := client.GraphQL(repo.RepoHost(), query, variables, &resp)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -389,7 +389,7 @@ func IssueClose(client *Client, repo ghrepo.Interface, issue Issue) error {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
gql := graphQLClient(client.http)
|
gql := graphQLClient(client.http, repo.RepoHost())
|
||||||
err := gql.MutateNamed(context.Background(), "IssueClose", &mutation, variables)
|
err := gql.MutateNamed(context.Background(), "IssueClose", &mutation, variables)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -414,7 +414,7 @@ func IssueReopen(client *Client, repo ghrepo.Interface, issue Issue) error {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
gql := graphQLClient(client.http)
|
gql := graphQLClient(client.http, repo.RepoHost())
|
||||||
err := gql.MutateNamed(context.Background(), "IssueReopen", &mutation, variables)
|
err := gql.MutateNamed(context.Background(), "IssueReopen", &mutation, variables)
|
||||||
|
|
||||||
return err
|
return err
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,12 @@ package api
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
|
"github.com/cli/cli/internal/ghrepo"
|
||||||
"github.com/shurcooL/githubv4"
|
"github.com/shurcooL/githubv4"
|
||||||
)
|
)
|
||||||
|
|
||||||
// OrganizationProjects fetches all open projects for an organization
|
// OrganizationProjects fetches all open projects for an organization
|
||||||
func OrganizationProjects(client *Client, owner string) ([]RepoProject, error) {
|
func OrganizationProjects(client *Client, repo ghrepo.Interface) ([]RepoProject, error) {
|
||||||
var query struct {
|
var query struct {
|
||||||
Organization struct {
|
Organization struct {
|
||||||
Projects struct {
|
Projects struct {
|
||||||
|
|
@ -21,11 +22,11 @@ func OrganizationProjects(client *Client, owner string) ([]RepoProject, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
variables := map[string]interface{}{
|
variables := map[string]interface{}{
|
||||||
"owner": githubv4.String(owner),
|
"owner": githubv4.String(repo.RepoOwner()),
|
||||||
"endCursor": (*githubv4.String)(nil),
|
"endCursor": (*githubv4.String)(nil),
|
||||||
}
|
}
|
||||||
|
|
||||||
gql := graphQLClient(client.http)
|
gql := graphQLClient(client.http, repo.RepoHost())
|
||||||
|
|
||||||
var projects []RepoProject
|
var projects []RepoProject
|
||||||
for {
|
for {
|
||||||
|
|
@ -50,7 +51,7 @@ type OrgTeam struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// OrganizationTeams fetches all the teams in an organization
|
// OrganizationTeams fetches all the teams in an organization
|
||||||
func OrganizationTeams(client *Client, owner string) ([]OrgTeam, error) {
|
func OrganizationTeams(client *Client, repo ghrepo.Interface) ([]OrgTeam, error) {
|
||||||
var query struct {
|
var query struct {
|
||||||
Organization struct {
|
Organization struct {
|
||||||
Teams struct {
|
Teams struct {
|
||||||
|
|
@ -64,11 +65,11 @@ func OrganizationTeams(client *Client, owner string) ([]OrgTeam, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
variables := map[string]interface{}{
|
variables := map[string]interface{}{
|
||||||
"owner": githubv4.String(owner),
|
"owner": githubv4.String(repo.RepoOwner()),
|
||||||
"endCursor": (*githubv4.String)(nil),
|
"endCursor": (*githubv4.String)(nil),
|
||||||
}
|
}
|
||||||
|
|
||||||
gql := graphQLClient(client.http)
|
gql := graphQLClient(client.http, repo.RepoHost())
|
||||||
|
|
||||||
var teams []OrgTeam
|
var teams []OrgTeam
|
||||||
for {
|
for {
|
||||||
|
|
|
||||||
|
|
@ -4,13 +4,13 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/ioutil"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/shurcooL/githubv4"
|
"github.com/cli/cli/internal/ghinstance"
|
||||||
|
|
||||||
"github.com/cli/cli/internal/ghrepo"
|
"github.com/cli/cli/internal/ghrepo"
|
||||||
|
"github.com/shurcooL/githubv4"
|
||||||
)
|
)
|
||||||
|
|
||||||
type PullRequestReviewState int
|
type PullRequestReviewState int
|
||||||
|
|
@ -209,36 +209,28 @@ func (pr *PullRequest) ChecksStatus() (summary PullRequestChecksStatus) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c Client) PullRequestDiff(baseRepo ghrepo.Interface, prNumber int) (string, error) {
|
func (c Client) PullRequestDiff(baseRepo ghrepo.Interface, prNumber int) (io.ReadCloser, error) {
|
||||||
url := fmt.Sprintf("https://api.github.com/repos/%s/pulls/%d",
|
url := fmt.Sprintf("%srepos/%s/pulls/%d",
|
||||||
ghrepo.FullName(baseRepo), prNumber)
|
ghinstance.RESTPrefix(baseRepo.RepoHost()), ghrepo.FullName(baseRepo), prNumber)
|
||||||
req, err := http.NewRequest("GET", url, nil)
|
req, err := http.NewRequest("GET", url, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
req.Header.Set("Accept", "application/vnd.github.v3.diff; charset=utf-8")
|
req.Header.Set("Accept", "application/vnd.github.v3.diff; charset=utf-8")
|
||||||
|
|
||||||
resp, err := c.http.Do(req)
|
resp, err := c.http.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return nil, err
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
b, err := ioutil.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
if resp.StatusCode == 200 {
|
|
||||||
return string(b), nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if resp.StatusCode == 404 {
|
if resp.StatusCode == 404 {
|
||||||
return "", &NotFoundError{errors.New("pull request not found")}
|
return nil, &NotFoundError{errors.New("pull request not found")}
|
||||||
|
} else if resp.StatusCode != 200 {
|
||||||
|
return nil, handleHTTPError(resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
return "", errors.New("pull request diff lookup failed")
|
return resp.Body, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func PullRequests(client *Client, repo ghrepo.Interface, currentPRNumber int, currentPRHeadRef, currentUsername string) (*PullRequestsPayload, error) {
|
func PullRequests(client *Client, repo ghrepo.Interface, currentPRNumber int, currentPRHeadRef, currentUsername string) (*PullRequestsPayload, error) {
|
||||||
|
|
@ -363,7 +355,7 @@ func PullRequests(client *Client, repo ghrepo.Interface, currentPRNumber int, cu
|
||||||
}
|
}
|
||||||
|
|
||||||
var resp response
|
var resp response
|
||||||
err := client.GraphQL(query, variables, &resp)
|
err := client.GraphQL(repo.RepoHost(), query, variables, &resp)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -500,7 +492,7 @@ func PullRequestByNumber(client *Client, repo ghrepo.Interface, number int) (*Pu
|
||||||
}
|
}
|
||||||
|
|
||||||
var resp response
|
var resp response
|
||||||
err := client.GraphQL(query, variables, &resp)
|
err := client.GraphQL(repo.RepoHost(), query, variables, &resp)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -613,7 +605,7 @@ func PullRequestForBranch(client *Client, repo ghrepo.Interface, baseBranch, hea
|
||||||
}
|
}
|
||||||
|
|
||||||
var resp response
|
var resp response
|
||||||
err := client.GraphQL(query, variables, &resp)
|
err := client.GraphQL(repo.RepoHost(), query, variables, &resp)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -663,7 +655,7 @@ func CreatePullRequest(client *Client, repo *Repository, params map[string]inter
|
||||||
}
|
}
|
||||||
}{}
|
}{}
|
||||||
|
|
||||||
err := client.GraphQL(query, variables, &result)
|
err := client.GraphQL(repo.RepoHost(), query, variables, &result)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -689,7 +681,7 @@ func CreatePullRequest(client *Client, repo *Repository, params map[string]inter
|
||||||
variables := map[string]interface{}{
|
variables := map[string]interface{}{
|
||||||
"input": updateParams,
|
"input": updateParams,
|
||||||
}
|
}
|
||||||
err := client.GraphQL(updateQuery, variables, &result)
|
err := client.GraphQL(repo.RepoHost(), updateQuery, variables, &result)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -714,7 +706,7 @@ func CreatePullRequest(client *Client, repo *Repository, params map[string]inter
|
||||||
variables := map[string]interface{}{
|
variables := map[string]interface{}{
|
||||||
"input": reviewParams,
|
"input": reviewParams,
|
||||||
}
|
}
|
||||||
err := client.GraphQL(reviewQuery, variables, &result)
|
err := client.GraphQL(repo.RepoHost(), reviewQuery, variables, &result)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -734,7 +726,7 @@ func isBlank(v interface{}) bool {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func AddReview(client *Client, pr *PullRequest, input *PullRequestReviewInput) error {
|
func AddReview(client *Client, repo ghrepo.Interface, pr *PullRequest, input *PullRequestReviewInput) error {
|
||||||
var mutation struct {
|
var mutation struct {
|
||||||
AddPullRequestReview struct {
|
AddPullRequestReview struct {
|
||||||
ClientMutationID string
|
ClientMutationID string
|
||||||
|
|
@ -758,11 +750,11 @@ func AddReview(client *Client, pr *PullRequest, input *PullRequestReviewInput) e
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
gql := graphQLClient(client.http)
|
gql := graphQLClient(client.http, repo.RepoHost())
|
||||||
return gql.MutateNamed(context.Background(), "PullRequestReviewAdd", &mutation, variables)
|
return gql.MutateNamed(context.Background(), "PullRequestReviewAdd", &mutation, variables)
|
||||||
}
|
}
|
||||||
|
|
||||||
func PullRequestList(client *Client, vars map[string]interface{}, limit int) (*PullRequestAndTotalCount, error) {
|
func PullRequestList(client *Client, repo ghrepo.Interface, vars map[string]interface{}, limit int) (*PullRequestAndTotalCount, error) {
|
||||||
type prBlock struct {
|
type prBlock struct {
|
||||||
Edges []struct {
|
Edges []struct {
|
||||||
Node PullRequest
|
Node PullRequest
|
||||||
|
|
@ -859,10 +851,8 @@ func PullRequestList(client *Client, vars map[string]interface{}, limit int) (*P
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}`
|
}`
|
||||||
owner := vars["owner"].(string)
|
|
||||||
repo := vars["repo"].(string)
|
|
||||||
search := []string{
|
search := []string{
|
||||||
fmt.Sprintf("repo:%s/%s", owner, repo),
|
fmt.Sprintf("repo:%s/%s", repo.RepoOwner(), repo.RepoName()),
|
||||||
fmt.Sprintf("assignee:%s", assignee),
|
fmt.Sprintf("assignee:%s", assignee),
|
||||||
"is:pr",
|
"is:pr",
|
||||||
"sort:created-desc",
|
"sort:created-desc",
|
||||||
|
|
@ -888,6 +878,8 @@ func PullRequestList(client *Client, vars map[string]interface{}, limit int) (*P
|
||||||
}
|
}
|
||||||
variables["q"] = strings.Join(search, " ")
|
variables["q"] = strings.Join(search, " ")
|
||||||
} else {
|
} else {
|
||||||
|
variables["owner"] = repo.RepoOwner()
|
||||||
|
variables["repo"] = repo.RepoName()
|
||||||
for name, val := range vars {
|
for name, val := range vars {
|
||||||
variables[name] = val
|
variables[name] = val
|
||||||
}
|
}
|
||||||
|
|
@ -896,7 +888,7 @@ loop:
|
||||||
for {
|
for {
|
||||||
variables["limit"] = pageLimit
|
variables["limit"] = pageLimit
|
||||||
var data response
|
var data response
|
||||||
err := client.GraphQL(query, variables, &data)
|
err := client.GraphQL(repo.RepoHost(), query, variables, &data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -945,7 +937,7 @@ func PullRequestClose(client *Client, repo ghrepo.Interface, pr *PullRequest) er
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
gql := graphQLClient(client.http)
|
gql := graphQLClient(client.http, repo.RepoHost())
|
||||||
err := gql.MutateNamed(context.Background(), "PullRequestClose", &mutation, variables)
|
err := gql.MutateNamed(context.Background(), "PullRequestClose", &mutation, variables)
|
||||||
|
|
||||||
return err
|
return err
|
||||||
|
|
@ -966,7 +958,7 @@ func PullRequestReopen(client *Client, repo ghrepo.Interface, pr *PullRequest) e
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
gql := graphQLClient(client.http)
|
gql := graphQLClient(client.http, repo.RepoHost())
|
||||||
err := gql.MutateNamed(context.Background(), "PullRequestReopen", &mutation, variables)
|
err := gql.MutateNamed(context.Background(), "PullRequestReopen", &mutation, variables)
|
||||||
|
|
||||||
return err
|
return err
|
||||||
|
|
@ -996,7 +988,7 @@ func PullRequestMerge(client *Client, repo ghrepo.Interface, pr *PullRequest, m
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
gql := graphQLClient(client.http)
|
gql := graphQLClient(client.http, repo.RepoHost())
|
||||||
err := gql.MutateNamed(context.Background(), "PullRequestMerge", &mutation, variables)
|
err := gql.MutateNamed(context.Background(), "PullRequestMerge", &mutation, variables)
|
||||||
|
|
||||||
return err
|
return err
|
||||||
|
|
@ -1017,13 +1009,13 @@ func PullRequestReady(client *Client, repo ghrepo.Interface, pr *PullRequest) er
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
gql := graphQLClient(client.http)
|
gql := graphQLClient(client.http, repo.RepoHost())
|
||||||
return gql.MutateNamed(context.Background(), "PullRequestReadyForReview", &mutation, variables)
|
return gql.MutateNamed(context.Background(), "PullRequestReadyForReview", &mutation, variables)
|
||||||
}
|
}
|
||||||
|
|
||||||
func BranchDeleteRemote(client *Client, repo ghrepo.Interface, branch string) error {
|
func BranchDeleteRemote(client *Client, repo ghrepo.Interface, branch string) error {
|
||||||
path := fmt.Sprintf("repos/%s/%s/git/refs/heads/%s", repo.RepoOwner(), repo.RepoName(), branch)
|
path := fmt.Sprintf("repos/%s/%s/git/refs/heads/%s", repo.RepoOwner(), repo.RepoName(), branch)
|
||||||
return client.REST("DELETE", path, nil, nil)
|
return client.REST(repo.RepoHost(), "DELETE", path, nil, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func min(a, b int) int {
|
func min(a, b int) int {
|
||||||
|
|
|
||||||
|
|
@ -27,9 +27,7 @@ type Repository struct {
|
||||||
IsPrivate bool
|
IsPrivate bool
|
||||||
HasIssuesEnabled bool
|
HasIssuesEnabled bool
|
||||||
ViewerPermission string
|
ViewerPermission string
|
||||||
DefaultBranchRef struct {
|
DefaultBranchRef BranchRef
|
||||||
Name string
|
|
||||||
}
|
|
||||||
|
|
||||||
Parent *Repository
|
Parent *Repository
|
||||||
|
|
||||||
|
|
@ -42,6 +40,11 @@ type RepositoryOwner struct {
|
||||||
Login string
|
Login string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BranchRef is the branch name in a GitHub repository
|
||||||
|
type BranchRef struct {
|
||||||
|
Name string
|
||||||
|
}
|
||||||
|
|
||||||
// RepoOwner is the login name of the owner
|
// RepoOwner is the login name of the owner
|
||||||
func (r Repository) RepoOwner() string {
|
func (r Repository) RepoOwner() string {
|
||||||
return r.Owner.Login
|
return r.Owner.Login
|
||||||
|
|
@ -103,7 +106,7 @@ func GitHubRepo(client *Client, repo ghrepo.Interface) (*Repository, error) {
|
||||||
result := struct {
|
result := struct {
|
||||||
Repository Repository
|
Repository Repository
|
||||||
}{}
|
}{}
|
||||||
err := client.GraphQL(query, variables, &result)
|
err := client.GraphQL(repo.RepoHost(), query, variables, &result)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|
@ -142,7 +145,7 @@ func RepoParent(client *Client, repo ghrepo.Interface) (ghrepo.Interface, error)
|
||||||
"name": githubv4.String(repo.RepoName()),
|
"name": githubv4.String(repo.RepoName()),
|
||||||
}
|
}
|
||||||
|
|
||||||
gql := graphQLClient(client.http)
|
gql := graphQLClient(client.http, repo.RepoHost())
|
||||||
err := gql.QueryNamed(context.Background(), "RepositoryFindParent", &query, variables)
|
err := gql.QueryNamed(context.Background(), "RepositoryFindParent", &query, variables)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|
@ -186,7 +189,7 @@ func RepoNetwork(client *Client, repos []ghrepo.Interface) (RepoNetworkResult, e
|
||||||
graphqlResult := make(map[string]*json.RawMessage)
|
graphqlResult := make(map[string]*json.RawMessage)
|
||||||
var result RepoNetworkResult
|
var result RepoNetworkResult
|
||||||
|
|
||||||
err := client.GraphQL(fmt.Sprintf(`
|
err := client.GraphQL(hostname, fmt.Sprintf(`
|
||||||
fragment repo on Repository {
|
fragment repo on Repository {
|
||||||
id
|
id
|
||||||
name
|
name
|
||||||
|
|
@ -282,7 +285,7 @@ func ForkRepo(client *Client, repo ghrepo.Interface) (*Repository, error) {
|
||||||
path := fmt.Sprintf("repos/%s/forks", ghrepo.FullName(repo))
|
path := fmt.Sprintf("repos/%s/forks", ghrepo.FullName(repo))
|
||||||
body := bytes.NewBufferString(`{}`)
|
body := bytes.NewBufferString(`{}`)
|
||||||
result := repositoryV3{}
|
result := repositoryV3{}
|
||||||
err := client.REST("POST", path, body, &result)
|
err := client.REST(repo.RepoHost(), "POST", path, body, &result)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -315,7 +318,7 @@ func RepoFindFork(client *Client, repo ghrepo.Interface) (*Repository, error) {
|
||||||
"repo": repo.RepoName(),
|
"repo": repo.RepoName(),
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := client.GraphQL(`
|
if err := client.GraphQL(repo.RepoHost(), `
|
||||||
query RepositoryFindFork($owner: String!, $repo: String!) {
|
query RepositoryFindFork($owner: String!, $repo: String!) {
|
||||||
repository(owner: $owner, name: $repo) {
|
repository(owner: $owner, name: $repo) {
|
||||||
forks(first: 1, affiliations: [OWNER, COLLABORATOR]) {
|
forks(first: 1, affiliations: [OWNER, COLLABORATOR]) {
|
||||||
|
|
@ -461,7 +464,7 @@ func RepoMetadata(client *Client, repo ghrepo.Interface, input RepoMetadataInput
|
||||||
if input.Reviewers {
|
if input.Reviewers {
|
||||||
count++
|
count++
|
||||||
go func() {
|
go func() {
|
||||||
teams, err := OrganizationTeams(client, repo.RepoOwner())
|
teams, err := OrganizationTeams(client, repo)
|
||||||
// TODO: better detection of non-org repos
|
// TODO: better detection of non-org repos
|
||||||
if err != nil && !strings.HasPrefix(err.Error(), "Could not resolve to an Organization") {
|
if err != nil && !strings.HasPrefix(err.Error(), "Could not resolve to an Organization") {
|
||||||
errc <- fmt.Errorf("error fetching organization teams: %w", err)
|
errc <- fmt.Errorf("error fetching organization teams: %w", err)
|
||||||
|
|
@ -492,7 +495,7 @@ func RepoMetadata(client *Client, repo ghrepo.Interface, input RepoMetadataInput
|
||||||
}
|
}
|
||||||
result.Projects = projects
|
result.Projects = projects
|
||||||
|
|
||||||
orgProjects, err := OrganizationProjects(client, repo.RepoOwner())
|
orgProjects, err := OrganizationProjects(client, repo)
|
||||||
// TODO: better detection of non-org repos
|
// TODO: better detection of non-org repos
|
||||||
if err != nil && !strings.HasPrefix(err.Error(), "Could not resolve to an Organization") {
|
if err != nil && !strings.HasPrefix(err.Error(), "Could not resolve to an Organization") {
|
||||||
errc <- fmt.Errorf("error fetching organization projects: %w", err)
|
errc <- fmt.Errorf("error fetching organization projects: %w", err)
|
||||||
|
|
@ -588,7 +591,7 @@ func RepoResolveMetadataIDs(client *Client, repo ghrepo.Interface, input RepoRes
|
||||||
fmt.Fprint(query, "}\n")
|
fmt.Fprint(query, "}\n")
|
||||||
|
|
||||||
response := make(map[string]json.RawMessage)
|
response := make(map[string]json.RawMessage)
|
||||||
err = client.GraphQL(query.String(), nil, &response)
|
err = client.GraphQL(repo.RepoHost(), query.String(), nil, &response)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return result, err
|
return result, err
|
||||||
}
|
}
|
||||||
|
|
@ -651,7 +654,7 @@ func RepoProjects(client *Client, repo ghrepo.Interface) ([]RepoProject, error)
|
||||||
"endCursor": (*githubv4.String)(nil),
|
"endCursor": (*githubv4.String)(nil),
|
||||||
}
|
}
|
||||||
|
|
||||||
gql := graphQLClient(client.http)
|
gql := graphQLClient(client.http, repo.RepoHost())
|
||||||
|
|
||||||
var projects []RepoProject
|
var projects []RepoProject
|
||||||
for {
|
for {
|
||||||
|
|
@ -695,7 +698,7 @@ func RepoAssignableUsers(client *Client, repo ghrepo.Interface) ([]RepoAssignee,
|
||||||
"endCursor": (*githubv4.String)(nil),
|
"endCursor": (*githubv4.String)(nil),
|
||||||
}
|
}
|
||||||
|
|
||||||
gql := graphQLClient(client.http)
|
gql := graphQLClient(client.http, repo.RepoHost())
|
||||||
|
|
||||||
var users []RepoAssignee
|
var users []RepoAssignee
|
||||||
for {
|
for {
|
||||||
|
|
@ -739,7 +742,7 @@ func RepoLabels(client *Client, repo ghrepo.Interface) ([]RepoLabel, error) {
|
||||||
"endCursor": (*githubv4.String)(nil),
|
"endCursor": (*githubv4.String)(nil),
|
||||||
}
|
}
|
||||||
|
|
||||||
gql := graphQLClient(client.http)
|
gql := graphQLClient(client.http, repo.RepoHost())
|
||||||
|
|
||||||
var labels []RepoLabel
|
var labels []RepoLabel
|
||||||
for {
|
for {
|
||||||
|
|
@ -783,7 +786,7 @@ func RepoMilestones(client *Client, repo ghrepo.Interface) ([]RepoMilestone, err
|
||||||
"endCursor": (*githubv4.String)(nil),
|
"endCursor": (*githubv4.String)(nil),
|
||||||
}
|
}
|
||||||
|
|
||||||
gql := graphQLClient(client.http)
|
gql := graphQLClient(client.http, repo.RepoHost())
|
||||||
|
|
||||||
var milestones []RepoMilestone
|
var milestones []RepoMilestone
|
||||||
for {
|
for {
|
||||||
|
|
|
||||||
|
|
@ -4,13 +4,13 @@ import (
|
||||||
"context"
|
"context"
|
||||||
)
|
)
|
||||||
|
|
||||||
func CurrentLoginName(client *Client) (string, error) {
|
func CurrentLoginName(client *Client, hostname string) (string, error) {
|
||||||
var query struct {
|
var query struct {
|
||||||
Viewer struct {
|
Viewer struct {
|
||||||
Login string
|
Login string
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
gql := graphQLClient(client.http)
|
gql := graphQLClient(client.http, hostname)
|
||||||
err := gql.QueryNamed(context.Background(), "UserCurrent", &query, nil)
|
err := gql.QueryNamed(context.Background(), "UserCurrent", &query, nil)
|
||||||
return query.Viewer.Login, err
|
return query.Viewer.Login, err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import (
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/cli/cli/internal/ghinstance"
|
||||||
"github.com/cli/cli/pkg/browser"
|
"github.com/cli/cli/pkg/browser"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -52,9 +53,18 @@ func (oa *OAuthFlow) ObtainAccessToken() (accessToken string, err error) {
|
||||||
scopes = strings.Join(oa.Scopes, " ")
|
scopes = strings.Join(oa.Scopes, " ")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
localhost := "127.0.0.1"
|
||||||
|
callbackPath := "/callback"
|
||||||
|
if ghinstance.IsEnterprise(oa.Hostname) {
|
||||||
|
// the OAuth app on Enterprise hosts is still registered with a legacy callback URL
|
||||||
|
// see https://github.com/cli/cli/pull/222, https://github.com/cli/cli/pull/650
|
||||||
|
localhost = "localhost"
|
||||||
|
callbackPath = "/"
|
||||||
|
}
|
||||||
|
|
||||||
q := url.Values{}
|
q := url.Values{}
|
||||||
q.Set("client_id", oa.ClientID)
|
q.Set("client_id", oa.ClientID)
|
||||||
q.Set("redirect_uri", fmt.Sprintf("http://127.0.0.1:%d/callback", port))
|
q.Set("redirect_uri", fmt.Sprintf("http://%s:%d%s", localhost, port, callbackPath))
|
||||||
q.Set("scope", scopes)
|
q.Set("scope", scopes)
|
||||||
q.Set("state", state)
|
q.Set("state", state)
|
||||||
|
|
||||||
|
|
@ -73,7 +83,7 @@ func (oa *OAuthFlow) ObtainAccessToken() (accessToken string, err error) {
|
||||||
|
|
||||||
_ = http.Serve(listener, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
_ = http.Serve(listener, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
oa.logf("server handler: %s\n", r.URL.Path)
|
oa.logf("server handler: %s\n", r.URL.Path)
|
||||||
if r.URL.Path != "/callback" {
|
if r.URL.Path != callbackPath {
|
||||||
w.WriteHeader(404)
|
w.WriteHeader(404)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,9 @@ import (
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/cli/cli/command"
|
"github.com/cli/cli/pkg/cmd/root"
|
||||||
|
"github.com/cli/cli/pkg/cmdutil"
|
||||||
|
"github.com/cli/cli/pkg/iostreams"
|
||||||
"github.com/spf13/cobra/doc"
|
"github.com/spf13/cobra/doc"
|
||||||
"github.com/spf13/pflag"
|
"github.com/spf13/pflag"
|
||||||
)
|
)
|
||||||
|
|
@ -35,13 +37,16 @@ func main() {
|
||||||
fatal("no dir set")
|
fatal("no dir set")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
io, _, _, _ := iostreams.Test()
|
||||||
|
rootCmd := root.NewCmdRoot(&cmdutil.Factory{IOStreams: io}, "", "")
|
||||||
|
|
||||||
err := os.MkdirAll(*dir, 0755)
|
err := os.MkdirAll(*dir, 0755)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fatal(err)
|
fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if *website {
|
if *website {
|
||||||
err = doc.GenMarkdownTreeCustom(command.RootCmd, *dir, filePrepender, linkHandler)
|
err = doc.GenMarkdownTreeCustom(rootCmd, *dir, filePrepender, linkHandler)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fatal(err)
|
fatal(err)
|
||||||
}
|
}
|
||||||
|
|
@ -54,7 +59,7 @@ func main() {
|
||||||
Source: "", //source and manual are just put at the top of the manpage, before name
|
Source: "", //source and manual are just put at the top of the manpage, before name
|
||||||
Manual: "", //if source is an empty string, it's set to "Auto generated by spf13/cobra"
|
Manual: "", //if source is an empty string, it's set to "Auto generated by spf13/cobra"
|
||||||
}
|
}
|
||||||
err = doc.GenManTree(command.RootCmd, header, *dir)
|
err = doc.GenManTree(rootCmd, header, *dir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fatal(err)
|
fatal(err)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,11 @@ import (
|
||||||
|
|
||||||
"github.com/cli/cli/command"
|
"github.com/cli/cli/command"
|
||||||
"github.com/cli/cli/internal/config"
|
"github.com/cli/cli/internal/config"
|
||||||
|
"github.com/cli/cli/internal/ghinstance"
|
||||||
|
"github.com/cli/cli/internal/run"
|
||||||
|
"github.com/cli/cli/pkg/cmd/alias/expand"
|
||||||
|
"github.com/cli/cli/pkg/cmd/factory"
|
||||||
|
"github.com/cli/cli/pkg/cmd/root"
|
||||||
"github.com/cli/cli/pkg/cmdutil"
|
"github.com/cli/cli/pkg/cmdutil"
|
||||||
"github.com/cli/cli/update"
|
"github.com/cli/cli/update"
|
||||||
"github.com/cli/cli/utils"
|
"github.com/cli/cli/utils"
|
||||||
|
|
@ -31,25 +36,48 @@ func main() {
|
||||||
|
|
||||||
hasDebug := os.Getenv("DEBUG") != ""
|
hasDebug := os.Getenv("DEBUG") != ""
|
||||||
|
|
||||||
stderr := utils.NewColorable(os.Stderr)
|
if hostFromEnv := os.Getenv("GH_HOST"); hostFromEnv != "" {
|
||||||
|
ghinstance.OverrideDefault(hostFromEnv)
|
||||||
|
}
|
||||||
|
|
||||||
|
cmdFactory := factory.New(command.Version)
|
||||||
|
stderr := cmdFactory.IOStreams.ErrOut
|
||||||
|
rootCmd := root.NewCmdRoot(cmdFactory, command.Version, command.BuildDate)
|
||||||
|
|
||||||
expandedArgs := []string{}
|
expandedArgs := []string{}
|
||||||
if len(os.Args) > 0 {
|
if len(os.Args) > 0 {
|
||||||
expandedArgs = os.Args[1:]
|
expandedArgs = os.Args[1:]
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd, _, err := command.RootCmd.Traverse(expandedArgs)
|
cmd, _, err := rootCmd.Traverse(expandedArgs)
|
||||||
if err != nil || cmd == command.RootCmd {
|
if err != nil || cmd == rootCmd {
|
||||||
originalArgs := expandedArgs
|
originalArgs := expandedArgs
|
||||||
isShell := false
|
isShell := false
|
||||||
expandedArgs, isShell, err = command.ExpandAlias(os.Args)
|
|
||||||
|
cfg, err := cmdFactory.Config()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(stderr, "failed to read configuration: %s\n", err)
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
expandedArgs, isShell, err = expand.ExpandAlias(cfg, os.Args, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(stderr, "failed to process aliases: %s\n", err)
|
fmt.Fprintf(stderr, "failed to process aliases: %s\n", err)
|
||||||
os.Exit(2)
|
os.Exit(2)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if hasDebug {
|
||||||
|
fmt.Fprintf(stderr, "%v -> %v\n", originalArgs, expandedArgs)
|
||||||
|
}
|
||||||
|
|
||||||
if isShell {
|
if isShell {
|
||||||
err = command.ExecuteShellAlias(expandedArgs)
|
externalCmd := exec.Command(expandedArgs[0], expandedArgs[1:]...)
|
||||||
|
externalCmd.Stderr = os.Stderr
|
||||||
|
externalCmd.Stdout = os.Stdout
|
||||||
|
externalCmd.Stdin = os.Stdin
|
||||||
|
preparedCmd := run.PrepareCmd(externalCmd)
|
||||||
|
|
||||||
|
err = preparedCmd.Run()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if ee, ok := err.(*exec.ExitError); ok {
|
if ee, ok := err.(*exec.ExitError); ok {
|
||||||
os.Exit(ee.ExitCode())
|
os.Exit(ee.ExitCode())
|
||||||
|
|
@ -61,19 +89,15 @@ func main() {
|
||||||
|
|
||||||
os.Exit(0)
|
os.Exit(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
if hasDebug {
|
|
||||||
fmt.Fprintf(stderr, "%v -> %v\n", originalArgs, expandedArgs)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
command.RootCmd.SetArgs(expandedArgs)
|
rootCmd.SetArgs(expandedArgs)
|
||||||
|
|
||||||
if cmd, err := command.RootCmd.ExecuteC(); err != nil {
|
if cmd, err := rootCmd.ExecuteC(); err != nil {
|
||||||
printError(os.Stderr, err, cmd, hasDebug)
|
printError(stderr, err, cmd, hasDebug)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
if command.HasFailed() {
|
if root.HasFailed() {
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -85,7 +109,6 @@ func main() {
|
||||||
ansi.Color(newRelease.Version, "cyan"),
|
ansi.Color(newRelease.Version, "cyan"),
|
||||||
ansi.Color(newRelease.URL, "yellow"))
|
ansi.Color(newRelease.URL, "yellow"))
|
||||||
|
|
||||||
stderr := utils.NewColorable(os.Stderr)
|
|
||||||
fmt.Fprintf(stderr, "\n\n%s\n\n", msg)
|
fmt.Fprintf(stderr, "\n\n%s\n\n", msg)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
233
command/alias.go
233
command/alias.go
|
|
@ -1,233 +0,0 @@
|
||||||
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)
|
|
||||||
|
|
||||||
aliasSetCmd.Flags().BoolP("shell", "s", false, "Declare an alias to be passed through a shell interpreter")
|
|
||||||
}
|
|
||||||
|
|
||||||
var aliasCmd = &cobra.Command{
|
|
||||||
Use: "alias",
|
|
||||||
Short: "Create command shortcuts",
|
|
||||||
Long: heredoc.Doc(`
|
|
||||||
Aliases can be used to make shortcuts for gh commands or to compose multiple commands.
|
|
||||||
|
|
||||||
Run "gh help alias set" to learn more.
|
|
||||||
`),
|
|
||||||
}
|
|
||||||
|
|
||||||
var aliasSetCmd = &cobra.Command{
|
|
||||||
Use: "set <alias> <expansion>",
|
|
||||||
Short: "Create a shortcut for a gh command",
|
|
||||||
Long: heredoc.Doc(`
|
|
||||||
Declare a word as a command alias that will expand to the specified command(s).
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
If '--shell' is specified, the alias will be run through a shell interpreter (sh). This allows you
|
|
||||||
to compose commands with "|" or redirect with ">". Note that extra arguments following the alias
|
|
||||||
will not be automatically passed to the expanded expression. To have a shell alias receive
|
|
||||||
arguments, you must explicitly accept them using "$1", "$2", etc., or "$@" to accept all of them.
|
|
||||||
|
|
||||||
Platform note: on Windows, shell aliases are executed via "sh" as installed by Git For Windows. If
|
|
||||||
you have installed git on Windows in some other way, shell aliases may not work for you.
|
|
||||||
|
|
||||||
Quotes must always be used when defining a command as in the examples.`),
|
|
||||||
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"
|
|
||||||
|
|
||||||
$ gh alias set --shell igrep 'gh issue list --label="$1" | grep $2'
|
|
||||||
$ gh igrep epic foo
|
|
||||||
#=> gh issue list --label="epic" | grep "foo"`),
|
|
||||||
Args: cobra.ExactArgs(2),
|
|
||||||
RunE: aliasSet,
|
|
||||||
}
|
|
||||||
|
|
||||||
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 := args[1]
|
|
||||||
|
|
||||||
stderr := colorableErr(cmd)
|
|
||||||
if connectedToTerminal(cmd) {
|
|
||||||
fmt.Fprintf(stderr, "- Adding alias for %s: %s\n", utils.Bold(alias), utils.Bold(expansion))
|
|
||||||
}
|
|
||||||
|
|
||||||
shell, err := cmd.Flags().GetBool("shell")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if shell && !strings.HasPrefix(expansion, "!") {
|
|
||||||
expansion = "!" + expansion
|
|
||||||
}
|
|
||||||
isExternal := strings.HasPrefix(expansion, "!")
|
|
||||||
|
|
||||||
if validCommand(alias) {
|
|
||||||
return fmt.Errorf("could not create alias: %q is already a gh command", alias)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !isExternal && !validCommand(expansion) {
|
|
||||||
return fmt.Errorf("could not create alias: %s does not correspond to a gh command", expansion)
|
|
||||||
}
|
|
||||||
|
|
||||||
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(expansion),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = aliasCfg.Add(alias, expansion)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("could not create alias: %s", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if connectedToTerminal(cmd) {
|
|
||||||
fmt.Fprintln(stderr, successMsg)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func validCommand(expansion string) bool {
|
|
||||||
split, err := shlex.Split(expansion)
|
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
cmd, _, err := RootCmd.Traverse(split)
|
|
||||||
return err == nil && cmd != RootCmd
|
|
||||||
}
|
|
||||||
|
|
||||||
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() {
|
|
||||||
if connectedToTerminal(cmd) {
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
if connectedToTerminal(cmd) {
|
|
||||||
stderr := colorableErr(cmd)
|
|
||||||
redCheck := utils.Red("✓")
|
|
||||||
fmt.Fprintf(stderr, "%s Deleted alias %s; was %s\n", redCheck, alias, expansion)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
@ -1,423 +0,0 @@
|
||||||
package command
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/cli/cli/internal/config"
|
|
||||||
"github.com/cli/cli/test"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
)
|
|
||||||
|
|
||||||
func stubSh(value string) func() {
|
|
||||||
orig := findSh
|
|
||||||
findSh = func() (string, error) {
|
|
||||||
return value, nil
|
|
||||||
}
|
|
||||||
return func() {
|
|
||||||
findSh = orig
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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")
|
|
||||||
|
|
||||||
defer stubTerminal(true)()
|
|
||||||
|
|
||||||
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.Stderr(), "Added alias")
|
|
||||||
test.ExpectLines(t, output.String(), "")
|
|
||||||
|
|
||||||
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")
|
|
||||||
defer stubTerminal(true)()
|
|
||||||
|
|
||||||
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.Stderr(), "Changed alias.*co.*from.*pr checkout.*to.*pr checkout -Rcool/repo")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAliasSet_space_args(t *testing.T) {
|
|
||||||
initBlankContext("", "OWNER/REPO", "trunk")
|
|
||||||
defer stubTerminal(true)()
|
|
||||||
|
|
||||||
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.Stderr(), `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")
|
|
||||||
defer stubTerminal(true)()
|
|
||||||
cases := []struct {
|
|
||||||
Cmd string
|
|
||||||
ExpectedOutputLine string
|
|
||||||
ExpectedConfigLine string
|
|
||||||
}{
|
|
||||||
{`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 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.Stderr(), 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")
|
|
||||||
defer stubTerminal(true)()
|
|
||||||
|
|
||||||
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.Stderr(), "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")
|
|
||||||
defer stubTerminal(true)()
|
|
||||||
|
|
||||||
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.Stderr(), "Adding alias for.*view.*pr view", "Added alias.")
|
|
||||||
eq(t, mainBuf.String(), expected)
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExpandAlias_shell(t *testing.T) {
|
|
||||||
defer stubSh("sh")()
|
|
||||||
cfg := `---
|
|
||||||
aliases:
|
|
||||||
ig: '!gh issue list | grep cool'
|
|
||||||
`
|
|
||||||
initBlankContext(cfg, "OWNER/REPO", "trunk")
|
|
||||||
|
|
||||||
expanded, isShell, err := ExpandAlias([]string{"gh", "ig"})
|
|
||||||
|
|
||||||
assert.True(t, isShell)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("unexpected error: %s", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
expected := []string{"sh", "-c", "gh issue list | grep cool"}
|
|
||||||
|
|
||||||
assert.Equal(t, expected, expanded)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExpandAlias_shell_extra_args(t *testing.T) {
|
|
||||||
defer stubSh("sh")()
|
|
||||||
cfg := `---
|
|
||||||
aliases:
|
|
||||||
ig: '!gh issue list --label=$1 | grep'
|
|
||||||
`
|
|
||||||
initBlankContext(cfg, "OWNER/REPO", "trunk")
|
|
||||||
|
|
||||||
expanded, isShell, err := ExpandAlias([]string{"gh", "ig", "bug", "foo"})
|
|
||||||
|
|
||||||
assert.True(t, isShell)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("unexpected error: %s", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
expected := []string{"sh", "-c", "gh issue list --label=$1 | grep", "--", "bug", "foo"}
|
|
||||||
|
|
||||||
assert.Equal(t, expected, expanded)
|
|
||||||
}
|
|
||||||
|
|
||||||
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, " ")
|
|
||||||
}
|
|
||||||
|
|
||||||
expanded, isShell, err := ExpandAlias(args)
|
|
||||||
|
|
||||||
assert.False(t, isShell)
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.Equal(t, c.ExpectedArgs, expanded)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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")
|
|
||||||
defer stubTerminal(true)()
|
|
||||||
|
|
||||||
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.Stderr(), "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)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestShellAlias_flag(t *testing.T) {
|
|
||||||
initBlankContext("", "OWNER/REPO", "trunk")
|
|
||||||
|
|
||||||
defer stubTerminal(true)()
|
|
||||||
|
|
||||||
mainBuf := bytes.Buffer{}
|
|
||||||
hostsBuf := bytes.Buffer{}
|
|
||||||
defer config.StubWriteConfig(&mainBuf, &hostsBuf)()
|
|
||||||
|
|
||||||
output, err := RunCommand("alias set --shell igrep 'gh issue list | grep'")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("unexpected error: %s", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
test.ExpectLines(t, output.Stderr(), "Adding alias for.*igrep")
|
|
||||||
expected := `aliases:
|
|
||||||
igrep: '!gh issue list | grep'
|
|
||||||
`
|
|
||||||
|
|
||||||
eq(t, mainBuf.String(), expected)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestShellAlias_bang(t *testing.T) {
|
|
||||||
initBlankContext("", "OWNER/REPO", "trunk")
|
|
||||||
|
|
||||||
defer stubTerminal(true)()
|
|
||||||
|
|
||||||
mainBuf := bytes.Buffer{}
|
|
||||||
hostsBuf := bytes.Buffer{}
|
|
||||||
defer config.StubWriteConfig(&mainBuf, &hostsBuf)()
|
|
||||||
|
|
||||||
output, err := RunCommand("alias set igrep '!gh issue list | grep'")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("unexpected error: %s", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
test.ExpectLines(t, output.Stderr(), "Adding alias for.*igrep")
|
|
||||||
expected := `aliases:
|
|
||||||
igrep: '!gh issue list | grep'
|
|
||||||
`
|
|
||||||
|
|
||||||
eq(t, mainBuf.String(), expected)
|
|
||||||
}
|
|
||||||
|
|
@ -1,65 +0,0 @@
|
||||||
package command
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
|
|
||||||
"github.com/cli/cli/utils"
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
)
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
RootCmd.AddCommand(completionCmd)
|
|
||||||
completionCmd.Flags().StringP("shell", "s", "", "Shell type: {bash|zsh|fish|powershell}")
|
|
||||||
}
|
|
||||||
|
|
||||||
var completionCmd = &cobra.Command{
|
|
||||||
Use: "completion",
|
|
||||||
Short: "Generate shell completion scripts",
|
|
||||||
Long: `Generate shell completion scripts for GitHub CLI commands.
|
|
||||||
|
|
||||||
The output of this command will be computer code and is meant to be saved to a
|
|
||||||
file or immediately evaluated by an interactive shell.
|
|
||||||
|
|
||||||
For example, for bash you could add this to your '~/.bash_profile':
|
|
||||||
|
|
||||||
eval "$(gh completion -s bash)"
|
|
||||||
|
|
||||||
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
|
|
||||||
`,
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
shellType, err := cmd.Flags().GetString("shell")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if shellType == "" {
|
|
||||||
out := cmd.OutOrStdout()
|
|
||||||
isTTY := false
|
|
||||||
if outFile, isFile := out.(*os.File); isFile {
|
|
||||||
isTTY = utils.IsTerminal(outFile)
|
|
||||||
}
|
|
||||||
|
|
||||||
if isTTY {
|
|
||||||
return errors.New("error: the value for `--shell` is required\nsee `gh help completion` for more information")
|
|
||||||
}
|
|
||||||
shellType = "bash"
|
|
||||||
}
|
|
||||||
|
|
||||||
switch shellType {
|
|
||||||
case "bash":
|
|
||||||
return RootCmd.GenBashCompletion(cmd.OutOrStdout())
|
|
||||||
case "zsh":
|
|
||||||
return RootCmd.GenZshCompletion(cmd.OutOrStdout())
|
|
||||||
case "powershell":
|
|
||||||
return RootCmd.GenPowerShellCompletion(cmd.OutOrStdout())
|
|
||||||
case "fish":
|
|
||||||
return RootCmd.GenFishCompletion(cmd.OutOrStdout(), true)
|
|
||||||
default:
|
|
||||||
return fmt.Errorf("unsupported shell type %q", shellType)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
@ -1,57 +0,0 @@
|
||||||
package command
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestCompletion_bash(t *testing.T) {
|
|
||||||
output, err := RunCommand(`completion`)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !strings.Contains(output.String(), "complete -o default -F __start_gh gh") {
|
|
||||||
t.Errorf("problem in bash completion:\n%s", output)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCompletion_zsh(t *testing.T) {
|
|
||||||
output, err := RunCommand(`completion -s zsh`)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !strings.Contains(output.String(), "#compdef _gh gh") {
|
|
||||||
t.Errorf("problem in zsh completion:\n%s", output)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCompletion_fish(t *testing.T) {
|
|
||||||
output, err := RunCommand(`completion -s fish`)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !strings.Contains(output.String(), "complete -c gh ") {
|
|
||||||
t.Errorf("problem in fish completion:\n%s", output)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCompletion_powerShell(t *testing.T) {
|
|
||||||
output, err := RunCommand(`completion -s powershell`)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !strings.Contains(output.String(), "Register-ArgumentCompleter") {
|
|
||||||
t.Errorf("problem in powershell completion:\n%s", output)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCompletion_unsupported(t *testing.T) {
|
|
||||||
_, err := RunCommand(`completion -s csh`)
|
|
||||||
if err == nil || err.Error() != `unsupported shell type "csh"` {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,111 +0,0 @@
|
||||||
package command
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/MakeNowJust/heredoc"
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
)
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
RootCmd.AddCommand(configCmd)
|
|
||||||
configCmd.AddCommand(configGetCmd)
|
|
||||||
configCmd.AddCommand(configSetCmd)
|
|
||||||
|
|
||||||
configGetCmd.Flags().StringP("host", "h", "", "Get per-host setting")
|
|
||||||
configSetCmd.Flags().StringP("host", "h", "", "Set per-host setting")
|
|
||||||
|
|
||||||
// TODO reveal and add usage once we properly support multiple hosts
|
|
||||||
_ = configGetCmd.Flags().MarkHidden("host")
|
|
||||||
// TODO reveal and add usage once we properly support multiple hosts
|
|
||||||
_ = configSetCmd.Flags().MarkHidden("host")
|
|
||||||
}
|
|
||||||
|
|
||||||
var configCmd = &cobra.Command{
|
|
||||||
Use: "config",
|
|
||||||
Short: "Manage configuration for gh",
|
|
||||||
Long: `Display or change configuration settings for gh.
|
|
||||||
|
|
||||||
Current respected settings:
|
|
||||||
- git_protocol: "https" or "ssh". Default is "https".
|
|
||||||
- editor: if unset, defaults to environment variables.
|
|
||||||
`,
|
|
||||||
}
|
|
||||||
|
|
||||||
var configGetCmd = &cobra.Command{
|
|
||||||
Use: "get <key>",
|
|
||||||
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: "Update configuration with a value for the given key",
|
|
||||||
Example: heredoc.Doc(`
|
|
||||||
$ gh config set editor vim
|
|
||||||
$ gh config set editor "code --wait"
|
|
||||||
`),
|
|
||||||
Args: cobra.ExactArgs(2),
|
|
||||||
RunE: configSet,
|
|
||||||
}
|
|
||||||
|
|
||||||
func configGet(cmd *cobra.Command, args []string) error {
|
|
||||||
key := args[0]
|
|
||||||
hostname, err := cmd.Flags().GetString("host")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := contextForCommand(cmd)
|
|
||||||
|
|
||||||
cfg, err := ctx.Config()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
val, err := cfg.Get(hostname, key)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if val != "" {
|
|
||||||
out := colorableOut(cmd)
|
|
||||||
fmt.Fprintf(out, "%s\n", val)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func configSet(cmd *cobra.Command, args []string) error {
|
|
||||||
key := args[0]
|
|
||||||
value := args[1]
|
|
||||||
|
|
||||||
hostname, err := cmd.Flags().GetString("host")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := contextForCommand(cmd)
|
|
||||||
|
|
||||||
cfg, err := ctx.Config()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
err = cfg.Set(hostname, key, value)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to set %q to %q: %w", key, value, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = cfg.Write()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to write config to disk: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
@ -1,225 +0,0 @@
|
||||||
package command
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/cli/cli/internal/config"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestConfigGet(t *testing.T) {
|
|
||||||
cfg := `---
|
|
||||||
hosts:
|
|
||||||
github.com:
|
|
||||||
user: OWNER
|
|
||||||
oauth_token: MUSTBEHIGHCUZIMATOKEN
|
|
||||||
editor: ed
|
|
||||||
`
|
|
||||||
initBlankContext(cfg, "OWNER/REPO", "master")
|
|
||||||
|
|
||||||
output, err := RunCommand("config get editor")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("error running command `config get editor`: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
eq(t, output.String(), "ed\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestConfigGet_default(t *testing.T) {
|
|
||||||
initBlankContext("", "OWNER/REPO", "master")
|
|
||||||
output, err := RunCommand("config get git_protocol")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("error running command `config get git_protocol`: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
eq(t, output.String(), "https\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestConfigGet_not_found(t *testing.T) {
|
|
||||||
initBlankContext("", "OWNER/REPO", "master")
|
|
||||||
|
|
||||||
output, err := RunCommand("config get missing")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("error running command `config get missing`: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
eq(t, output.String(), "")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestConfigSet(t *testing.T) {
|
|
||||||
initBlankContext("", "OWNER/REPO", "master")
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(output.String()) > 0 {
|
|
||||||
t.Errorf("expected output to be blank: %q", output.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
expectedMain := "editor: ed\n"
|
|
||||||
expectedHosts := `github.com:
|
|
||||||
user: OWNER
|
|
||||||
oauth_token: "1234567890"
|
|
||||||
`
|
|
||||||
|
|
||||||
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) {
|
|
||||||
cfg := `---
|
|
||||||
hosts:
|
|
||||||
github.com:
|
|
||||||
user: OWNER
|
|
||||||
oauth_token: MUSTBEHIGHCUZIMATOKEN
|
|
||||||
editor: ed
|
|
||||||
`
|
|
||||||
|
|
||||||
initBlankContext(cfg, "OWNER/REPO", "master")
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(output.String()) > 0 {
|
|
||||||
t.Errorf("expected output to be blank: %q", output.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
expectedMain := "editor: vim\n"
|
|
||||||
expectedHosts := `github.com:
|
|
||||||
user: OWNER
|
|
||||||
oauth_token: MUSTBEHIGHCUZIMATOKEN
|
|
||||||
`
|
|
||||||
|
|
||||||
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) {
|
|
||||||
cfg := `---
|
|
||||||
hosts:
|
|
||||||
github.com:
|
|
||||||
git_protocol: ssh
|
|
||||||
user: OWNER
|
|
||||||
oauth_token: MUSTBEHIGHCUZIMATOKEN
|
|
||||||
editor: ed
|
|
||||||
git_protocol: https
|
|
||||||
`
|
|
||||||
initBlankContext(cfg, "OWNER/REPO", "master")
|
|
||||||
|
|
||||||
output, err := RunCommand("config get -hgithub.com git_protocol")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("error running command `config get editor`: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
eq(t, output.String(), "ssh\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestConfigGetHost_unset(t *testing.T) {
|
|
||||||
cfg := `---
|
|
||||||
hosts:
|
|
||||||
github.com:
|
|
||||||
user: OWNER
|
|
||||||
oauth_token: MUSTBEHIGHCUZIMATOKEN
|
|
||||||
|
|
||||||
editor: ed
|
|
||||||
git_protocol: ssh
|
|
||||||
`
|
|
||||||
initBlankContext(cfg, "OWNER/REPO", "master")
|
|
||||||
|
|
||||||
output, err := RunCommand("config get -hgithub.com git_protocol")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("error running command `config get -hgithub.com git_protocol`: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
eq(t, output.String(), "ssh\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestConfigSetHost(t *testing.T) {
|
|
||||||
initBlankContext("", "OWNER/REPO", "master")
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(output.String()) > 0 {
|
|
||||||
t.Errorf("expected output to be blank: %q", output.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
expectedMain := ""
|
|
||||||
expectedHosts := `github.com:
|
|
||||||
user: OWNER
|
|
||||||
oauth_token: "1234567890"
|
|
||||||
git_protocol: ssh
|
|
||||||
`
|
|
||||||
|
|
||||||
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) {
|
|
||||||
cfg := `---
|
|
||||||
hosts:
|
|
||||||
github.com:
|
|
||||||
git_protocol: ssh
|
|
||||||
user: OWNER
|
|
||||||
oauth_token: MUSTBEHIGHCUZIMATOKEN
|
|
||||||
`
|
|
||||||
|
|
||||||
initBlankContext(cfg, "OWNER/REPO", "master")
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(output.String()) > 0 {
|
|
||||||
t.Errorf("expected output to be blank: %q", output.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
expectedMain := ""
|
|
||||||
expectedHosts := `github.com:
|
|
||||||
git_protocol: https
|
|
||||||
user: OWNER
|
|
||||||
oauth_token: MUSTBEHIGHCUZIMATOKEN
|
|
||||||
`
|
|
||||||
|
|
||||||
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())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
838
command/issue.go
838
command/issue.go
|
|
@ -1,838 +0,0 @@
|
||||||
package command
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/url"
|
|
||||||
"strconv"
|
|
||||||
"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"
|
|
||||||
"github.com/spf13/pflag"
|
|
||||||
)
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
issueCmd.PersistentFlags().StringP("repo", "R", "", "Select another repository using the `OWNER/REPO` format")
|
|
||||||
|
|
||||||
RootCmd.AddCommand(issueCmd)
|
|
||||||
issueCmd.AddCommand(issueStatusCmd)
|
|
||||||
|
|
||||||
issueCmd.AddCommand(issueCreateCmd)
|
|
||||||
issueCreateCmd.Flags().StringP("title", "t", "",
|
|
||||||
"Supply a title. Will prompt for one otherwise.")
|
|
||||||
issueCreateCmd.Flags().StringP("body", "b", "",
|
|
||||||
"Supply a body. Will prompt for one otherwise.")
|
|
||||||
issueCreateCmd.Flags().BoolP("web", "w", false, "Open the browser to create an issue")
|
|
||||||
issueCreateCmd.Flags().StringSliceP("assignee", "a", nil, "Assign people by their `login`")
|
|
||||||
issueCreateCmd.Flags().StringSliceP("label", "l", nil, "Add labels by `name`")
|
|
||||||
issueCreateCmd.Flags().StringSliceP("project", "p", nil, "Add the issue to projects by `name`")
|
|
||||||
issueCreateCmd.Flags().StringP("milestone", "m", "", "Add the issue to a milestone by `name`")
|
|
||||||
|
|
||||||
issueCmd.AddCommand(issueListCmd)
|
|
||||||
issueListCmd.Flags().BoolP("web", "w", false, "Open the browser to list the issue(s)")
|
|
||||||
issueListCmd.Flags().StringP("assignee", "a", "", "Filter by assignee")
|
|
||||||
issueListCmd.Flags().StringSliceP("label", "l", nil, "Filter by labels")
|
|
||||||
issueListCmd.Flags().StringP("state", "s", "open", "Filter by state: {open|closed|all}")
|
|
||||||
issueListCmd.Flags().IntP("limit", "L", 30, "Maximum number of issues to fetch")
|
|
||||||
issueListCmd.Flags().StringP("author", "A", "", "Filter by author")
|
|
||||||
issueListCmd.Flags().String("mention", "", "Filter by mention")
|
|
||||||
issueListCmd.Flags().StringP("milestone", "m", "", "Filter by milestone `name`")
|
|
||||||
|
|
||||||
issueCmd.AddCommand(issueViewCmd)
|
|
||||||
issueViewCmd.Flags().BoolP("web", "w", false, "Open an issue in the browser")
|
|
||||||
|
|
||||||
issueCmd.AddCommand(issueCloseCmd)
|
|
||||||
issueCmd.AddCommand(issueReopenCmd)
|
|
||||||
}
|
|
||||||
|
|
||||||
var issueCmd = &cobra.Command{
|
|
||||||
Use: "issue <command>",
|
|
||||||
Short: "Create and view issues",
|
|
||||||
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".`},
|
|
||||||
}
|
|
||||||
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"
|
|
||||||
$ gh issue create --label "bug,help wanted"
|
|
||||||
$ gh issue create --label bug --label "help wanted"
|
|
||||||
$ gh issue create --assignee monalisa,hubot
|
|
||||||
$ gh issue create --project "Roadmap"
|
|
||||||
`),
|
|
||||||
}
|
|
||||||
var issueListCmd = &cobra.Command{
|
|
||||||
Use: "list",
|
|
||||||
Short: "List and filter issues in this repository",
|
|
||||||
Example: heredoc.Doc(`
|
|
||||||
$ gh issue list -l "help wanted"
|
|
||||||
$ gh issue list -A monalisa
|
|
||||||
$ gh issue list --web
|
|
||||||
`),
|
|
||||||
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>}",
|
|
||||||
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.`,
|
|
||||||
RunE: issueView,
|
|
||||||
}
|
|
||||||
var issueCloseCmd = &cobra.Command{
|
|
||||||
Use: "close {<number> | <url>}",
|
|
||||||
Short: "Close issue",
|
|
||||||
Args: cobra.ExactArgs(1),
|
|
||||||
RunE: issueClose,
|
|
||||||
}
|
|
||||||
var issueReopenCmd = &cobra.Command{
|
|
||||||
Use: "reopen {<number> | <url>}",
|
|
||||||
Short: "Reopen issue",
|
|
||||||
Args: cobra.ExactArgs(1),
|
|
||||||
RunE: issueReopen,
|
|
||||||
}
|
|
||||||
|
|
||||||
type filterOptions struct {
|
|
||||||
entity string
|
|
||||||
state string
|
|
||||||
assignee string
|
|
||||||
labels []string
|
|
||||||
author string
|
|
||||||
baseBranch string
|
|
||||||
mention string
|
|
||||||
milestone string
|
|
||||||
}
|
|
||||||
|
|
||||||
func listURLWithQuery(listURL string, options filterOptions) (string, error) {
|
|
||||||
u, err := url.Parse(listURL)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
query := fmt.Sprintf("is:%s ", options.entity)
|
|
||||||
if options.state != "all" {
|
|
||||||
query += fmt.Sprintf("is:%s ", options.state)
|
|
||||||
}
|
|
||||||
if options.assignee != "" {
|
|
||||||
query += fmt.Sprintf("assignee:%s ", options.assignee)
|
|
||||||
}
|
|
||||||
for _, label := range options.labels {
|
|
||||||
query += fmt.Sprintf("label:%s ", quoteValueForQuery(label))
|
|
||||||
}
|
|
||||||
if options.author != "" {
|
|
||||||
query += fmt.Sprintf("author:%s ", options.author)
|
|
||||||
}
|
|
||||||
if options.baseBranch != "" {
|
|
||||||
query += fmt.Sprintf("base:%s ", options.baseBranch)
|
|
||||||
}
|
|
||||||
if options.mention != "" {
|
|
||||||
query += fmt.Sprintf("mentions:%s ", options.mention)
|
|
||||||
}
|
|
||||||
if options.milestone != "" {
|
|
||||||
query += fmt.Sprintf("milestone:%s ", quoteValueForQuery(options.milestone))
|
|
||||||
}
|
|
||||||
q := u.Query()
|
|
||||||
q.Set("q", strings.TrimSuffix(query, " "))
|
|
||||||
u.RawQuery = q.Encode()
|
|
||||||
return u.String(), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func quoteValueForQuery(v string) string {
|
|
||||||
if strings.ContainsAny(v, " \"\t\r\n") {
|
|
||||||
return fmt.Sprintf("%q", v)
|
|
||||||
}
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
|
|
||||||
func issueList(cmd *cobra.Command, args []string) error {
|
|
||||||
ctx := contextForCommand(cmd)
|
|
||||||
apiClient, err := apiClientForContext(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
baseRepo, err := determineBaseRepo(apiClient, cmd, ctx)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
web, err := cmd.Flags().GetBool("web")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
state, err := cmd.Flags().GetString("state")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
labels, err := cmd.Flags().GetStringSlice("label")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
assignee, err := cmd.Flags().GetString("assignee")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
limit, err := cmd.Flags().GetInt("limit")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if limit <= 0 {
|
|
||||||
return fmt.Errorf("invalid limit: %v", limit)
|
|
||||||
}
|
|
||||||
|
|
||||||
author, err := cmd.Flags().GetString("author")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
mention, err := cmd.Flags().GetString("mention")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
milestone, err := cmd.Flags().GetString("milestone")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if web {
|
|
||||||
issueListURL := ghrepo.GenerateRepoURL(baseRepo, "issues")
|
|
||||||
openURL, err := listURLWithQuery(issueListURL, filterOptions{
|
|
||||||
entity: "issue",
|
|
||||||
state: state,
|
|
||||||
assignee: assignee,
|
|
||||||
labels: labels,
|
|
||||||
author: author,
|
|
||||||
mention: mention,
|
|
||||||
milestone: milestone,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
fmt.Fprintf(cmd.ErrOrStderr(), "Opening %s in your browser.\n", utils.DisplayURL(openURL))
|
|
||||||
return utils.OpenInBrowser(openURL)
|
|
||||||
}
|
|
||||||
|
|
||||||
listResult, err := api.IssueList(apiClient, baseRepo, state, labels, assignee, limit, author, mention, milestone)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
hasFilters := false
|
|
||||||
cmd.Flags().Visit(func(f *pflag.Flag) {
|
|
||||||
switch f.Name {
|
|
||||||
case "state", "label", "assignee", "author", "mention", "milestone":
|
|
||||||
hasFilters = true
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
title := listHeader(ghrepo.FullName(baseRepo), "issue", len(listResult.Issues), listResult.TotalCount, hasFilters)
|
|
||||||
if connectedToTerminal(cmd) {
|
|
||||||
fmt.Fprintf(colorableErr(cmd), "\n%s\n\n", title)
|
|
||||||
}
|
|
||||||
|
|
||||||
out := cmd.OutOrStdout()
|
|
||||||
|
|
||||||
printIssues(out, "", len(listResult.Issues), listResult.Issues)
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func issueStatus(cmd *cobra.Command, args []string) error {
|
|
||||||
ctx := contextForCommand(cmd)
|
|
||||||
apiClient, err := apiClientForContext(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
baseRepo, err := determineBaseRepo(apiClient, cmd, ctx)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
currentUser, err := api.CurrentLoginName(apiClient)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
issuePayload, err := api.IssueStatus(apiClient, baseRepo, currentUser)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
out := colorableOut(cmd)
|
|
||||||
|
|
||||||
fmt.Fprintln(out, "")
|
|
||||||
fmt.Fprintf(out, "Relevant issues in %s\n", ghrepo.FullName(baseRepo))
|
|
||||||
fmt.Fprintln(out, "")
|
|
||||||
|
|
||||||
printHeader(out, "Issues assigned to you")
|
|
||||||
if issuePayload.Assigned.TotalCount > 0 {
|
|
||||||
printIssues(out, " ", issuePayload.Assigned.TotalCount, issuePayload.Assigned.Issues)
|
|
||||||
} else {
|
|
||||||
message := " There are no issues assigned to you"
|
|
||||||
printMessage(out, message)
|
|
||||||
}
|
|
||||||
fmt.Fprintln(out)
|
|
||||||
|
|
||||||
printHeader(out, "Issues mentioning you")
|
|
||||||
if issuePayload.Mentioned.TotalCount > 0 {
|
|
||||||
printIssues(out, " ", issuePayload.Mentioned.TotalCount, issuePayload.Mentioned.Issues)
|
|
||||||
} else {
|
|
||||||
printMessage(out, " There are no issues mentioning you")
|
|
||||||
}
|
|
||||||
fmt.Fprintln(out)
|
|
||||||
|
|
||||||
printHeader(out, "Issues opened by you")
|
|
||||||
if issuePayload.Authored.TotalCount > 0 {
|
|
||||||
printIssues(out, " ", issuePayload.Authored.TotalCount, issuePayload.Authored.Issues)
|
|
||||||
} else {
|
|
||||||
printMessage(out, " There are no issues opened by you")
|
|
||||||
}
|
|
||||||
fmt.Fprintln(out)
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func issueView(cmd *cobra.Command, args []string) error {
|
|
||||||
ctx := contextForCommand(cmd)
|
|
||||||
|
|
||||||
apiClient, err := apiClientForContext(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
issue, _, err := issueFromArg(ctx, apiClient, cmd, args[0])
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
openURL := issue.URL
|
|
||||||
|
|
||||||
web, err := cmd.Flags().GetBool("web")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if web {
|
|
||||||
fmt.Fprintf(cmd.ErrOrStderr(), "Opening %s in your browser.\n", openURL)
|
|
||||||
return utils.OpenInBrowser(openURL)
|
|
||||||
}
|
|
||||||
if connectedToTerminal(cmd) {
|
|
||||||
return printHumanIssuePreview(colorableOut(cmd), issue)
|
|
||||||
}
|
|
||||||
|
|
||||||
return printRawIssuePreview(cmd.OutOrStdout(), issue)
|
|
||||||
}
|
|
||||||
|
|
||||||
func issueStateTitleWithColor(state string) string {
|
|
||||||
colorFunc := colorFuncForState(state)
|
|
||||||
return colorFunc(strings.Title(strings.ToLower(state)))
|
|
||||||
}
|
|
||||||
|
|
||||||
func listHeader(repoName string, itemName string, matchCount int, totalMatchCount int, hasFilters bool) string {
|
|
||||||
if totalMatchCount == 0 {
|
|
||||||
if hasFilters {
|
|
||||||
return fmt.Sprintf("No %ss match your search in %s", itemName, repoName)
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("There are no open %ss in %s", itemName, repoName)
|
|
||||||
}
|
|
||||||
|
|
||||||
if hasFilters {
|
|
||||||
matchVerb := "match"
|
|
||||||
if totalMatchCount == 1 {
|
|
||||||
matchVerb = "matches"
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("Showing %d of %s in %s that %s your search", matchCount, utils.Pluralize(totalMatchCount, itemName), repoName, matchVerb)
|
|
||||||
}
|
|
||||||
|
|
||||||
return fmt.Sprintf("Showing %d of %s in %s", matchCount, utils.Pluralize(totalMatchCount, fmt.Sprintf("open %s", itemName)), repoName)
|
|
||||||
}
|
|
||||||
|
|
||||||
func printRawIssuePreview(out io.Writer, issue *api.Issue) error {
|
|
||||||
assignees := issueAssigneeList(*issue)
|
|
||||||
labels := issueLabelList(*issue)
|
|
||||||
projects := issueProjectList(*issue)
|
|
||||||
|
|
||||||
// Print empty strings for empty values so the number of metadata lines is consistent when
|
|
||||||
// processing many issues with head and grep.
|
|
||||||
fmt.Fprintf(out, "title:\t%s\n", issue.Title)
|
|
||||||
fmt.Fprintf(out, "state:\t%s\n", issue.State)
|
|
||||||
fmt.Fprintf(out, "author:\t%s\n", issue.Author.Login)
|
|
||||||
fmt.Fprintf(out, "labels:\t%s\n", labels)
|
|
||||||
fmt.Fprintf(out, "comments:\t%d\n", issue.Comments.TotalCount)
|
|
||||||
fmt.Fprintf(out, "assignees:\t%s\n", assignees)
|
|
||||||
fmt.Fprintf(out, "projects:\t%s\n", projects)
|
|
||||||
fmt.Fprintf(out, "milestone:\t%s\n", issue.Milestone.Title)
|
|
||||||
|
|
||||||
fmt.Fprintln(out, "--")
|
|
||||||
fmt.Fprintln(out, issue.Body)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func printHumanIssuePreview(out io.Writer, issue *api.Issue) error {
|
|
||||||
now := time.Now()
|
|
||||||
ago := now.Sub(issue.CreatedAt)
|
|
||||||
|
|
||||||
// Header (Title and State)
|
|
||||||
fmt.Fprintln(out, utils.Bold(issue.Title))
|
|
||||||
fmt.Fprint(out, issueStateTitleWithColor(issue.State))
|
|
||||||
fmt.Fprintln(out, utils.Gray(fmt.Sprintf(
|
|
||||||
" • %s opened %s • %s",
|
|
||||||
issue.Author.Login,
|
|
||||||
utils.FuzzyAgo(ago),
|
|
||||||
utils.Pluralize(issue.Comments.TotalCount, "comment"),
|
|
||||||
)))
|
|
||||||
|
|
||||||
// Metadata
|
|
||||||
fmt.Fprintln(out)
|
|
||||||
if assignees := issueAssigneeList(*issue); assignees != "" {
|
|
||||||
fmt.Fprint(out, utils.Bold("Assignees: "))
|
|
||||||
fmt.Fprintln(out, assignees)
|
|
||||||
}
|
|
||||||
if labels := issueLabelList(*issue); labels != "" {
|
|
||||||
fmt.Fprint(out, utils.Bold("Labels: "))
|
|
||||||
fmt.Fprintln(out, labels)
|
|
||||||
}
|
|
||||||
if projects := issueProjectList(*issue); projects != "" {
|
|
||||||
fmt.Fprint(out, utils.Bold("Projects: "))
|
|
||||||
fmt.Fprintln(out, projects)
|
|
||||||
}
|
|
||||||
if issue.Milestone.Title != "" {
|
|
||||||
fmt.Fprint(out, utils.Bold("Milestone: "))
|
|
||||||
fmt.Fprintln(out, issue.Milestone.Title)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Body
|
|
||||||
if issue.Body != "" {
|
|
||||||
fmt.Fprintln(out)
|
|
||||||
md, err := utils.RenderMarkdown(issue.Body)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
fmt.Fprintln(out, md)
|
|
||||||
}
|
|
||||||
fmt.Fprintln(out)
|
|
||||||
|
|
||||||
// Footer
|
|
||||||
fmt.Fprintf(out, utils.Gray("View this issue on GitHub: %s\n"), issue.URL)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func issueCreate(cmd *cobra.Command, args []string) error {
|
|
||||||
ctx := contextForCommand(cmd)
|
|
||||||
apiClient, err := apiClientForContext(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// NB no auto forking like over in pr create
|
|
||||||
baseRepo, err := determineBaseRepo(apiClient, cmd, ctx)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
baseOverride, err := cmd.Flags().GetString("repo")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
var nonLegacyTemplateFiles []string
|
|
||||||
if baseOverride == "" {
|
|
||||||
if rootDir, err := git.ToplevelDir(); err == nil {
|
|
||||||
// TODO: figure out how to stub this in tests
|
|
||||||
nonLegacyTemplateFiles = githubtemplate.FindNonLegacy(rootDir, "ISSUE_TEMPLATE")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
title, err := cmd.Flags().GetString("title")
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("could not parse title: %w", err)
|
|
||||||
}
|
|
||||||
body, err := cmd.Flags().GetString("body")
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("could not parse body: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
assignees, err := cmd.Flags().GetStringSlice("assignee")
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("could not parse assignees: %w", err)
|
|
||||||
}
|
|
||||||
labelNames, err := cmd.Flags().GetStringSlice("label")
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("could not parse labels: %w", err)
|
|
||||||
}
|
|
||||||
projectNames, err := cmd.Flags().GetStringSlice("project")
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("could not parse projects: %w", err)
|
|
||||||
}
|
|
||||||
var milestoneTitles []string
|
|
||||||
if milestoneTitle, err := cmd.Flags().GetString("milestone"); err != nil {
|
|
||||||
return fmt.Errorf("could not parse milestone: %w", err)
|
|
||||||
} else if milestoneTitle != "" {
|
|
||||||
milestoneTitles = append(milestoneTitles, milestoneTitle)
|
|
||||||
}
|
|
||||||
|
|
||||||
if isWeb, err := cmd.Flags().GetBool("web"); err == nil && isWeb {
|
|
||||||
openURL := ghrepo.GenerateRepoURL(baseRepo, "issues/new")
|
|
||||||
if title != "" || body != "" {
|
|
||||||
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"
|
|
||||||
}
|
|
||||||
if connectedToTerminal(cmd) {
|
|
||||||
cmd.Printf("Opening %s in your browser.\n", utils.DisplayURL(openURL))
|
|
||||||
}
|
|
||||||
return utils.OpenInBrowser(openURL)
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Fprintf(colorableErr(cmd), "\nCreating issue in %s\n\n", ghrepo.FullName(baseRepo))
|
|
||||||
|
|
||||||
repo, err := api.GitHubRepo(apiClient, baseRepo)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if !repo.HasIssuesEnabled {
|
|
||||||
return fmt.Errorf("the '%s' repository has disabled issues", ghrepo.FullName(baseRepo))
|
|
||||||
}
|
|
||||||
|
|
||||||
action := SubmitAction
|
|
||||||
tb := issueMetadataState{
|
|
||||||
Type: issueMetadata,
|
|
||||||
Assignees: assignees,
|
|
||||||
Labels: labelNames,
|
|
||||||
Projects: projectNames,
|
|
||||||
Milestones: milestoneTitles,
|
|
||||||
}
|
|
||||||
|
|
||||||
interactive := !(cmd.Flags().Changed("title") && cmd.Flags().Changed("body"))
|
|
||||||
|
|
||||||
if interactive && !connectedToTerminal(cmd) {
|
|
||||||
return fmt.Errorf("must provide --title and --body when not attached to a terminal")
|
|
||||||
}
|
|
||||||
|
|
||||||
if interactive {
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
action = tb.Action
|
|
||||||
|
|
||||||
if tb.Action == CancelAction {
|
|
||||||
fmt.Fprintln(cmd.ErrOrStderr(), "Discarding.")
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if title == "" {
|
|
||||||
title = tb.Title
|
|
||||||
}
|
|
||||||
if body == "" {
|
|
||||||
body = tb.Body
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if title == "" {
|
|
||||||
return fmt.Errorf("title can't be blank")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if action == PreviewAction {
|
|
||||||
openURL := ghrepo.GenerateRepoURL(baseRepo, "issues/new")
|
|
||||||
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", utils.DisplayURL(openURL))
|
|
||||||
return utils.OpenInBrowser(openURL)
|
|
||||||
} else if action == SubmitAction {
|
|
||||||
params := map[string]interface{}{
|
|
||||||
"title": title,
|
|
||||||
"body": body,
|
|
||||||
}
|
|
||||||
|
|
||||||
err = addMetadataToIssueParams(apiClient, baseRepo, params, &tb)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
newIssue, err := api.IssueCreate(apiClient, repo, params)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Fprintln(cmd.OutOrStdout(), newIssue.URL)
|
|
||||||
} else {
|
|
||||||
panic("Unreachable state")
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func addMetadataToIssueParams(client *api.Client, baseRepo ghrepo.Interface, params map[string]interface{}, tb *issueMetadataState) error {
|
|
||||||
if !tb.HasMetadata() {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if tb.MetadataResult == nil {
|
|
||||||
resolveInput := api.RepoResolveInput{
|
|
||||||
Reviewers: tb.Reviewers,
|
|
||||||
Assignees: tb.Assignees,
|
|
||||||
Labels: tb.Labels,
|
|
||||||
Projects: tb.Projects,
|
|
||||||
Milestones: tb.Milestones,
|
|
||||||
}
|
|
||||||
|
|
||||||
var err error
|
|
||||||
tb.MetadataResult, err = api.RepoResolveMetadataIDs(client, baseRepo, resolveInput)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
assigneeIDs, err := tb.MetadataResult.MembersToIDs(tb.Assignees)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("could not assign user: %w", err)
|
|
||||||
}
|
|
||||||
params["assigneeIds"] = assigneeIDs
|
|
||||||
|
|
||||||
labelIDs, err := tb.MetadataResult.LabelsToIDs(tb.Labels)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("could not add label: %w", err)
|
|
||||||
}
|
|
||||||
params["labelIds"] = labelIDs
|
|
||||||
|
|
||||||
projectIDs, err := tb.MetadataResult.ProjectsToIDs(tb.Projects)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("could not add to project: %w", err)
|
|
||||||
}
|
|
||||||
params["projectIds"] = projectIDs
|
|
||||||
|
|
||||||
if len(tb.Milestones) > 0 {
|
|
||||||
milestoneID, err := tb.MetadataResult.MilestoneToID(tb.Milestones[0])
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("could not add to milestone '%s': %w", tb.Milestones[0], err)
|
|
||||||
}
|
|
||||||
params["milestoneId"] = milestoneID
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(tb.Reviewers) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var userReviewers []string
|
|
||||||
var teamReviewers []string
|
|
||||||
for _, r := range tb.Reviewers {
|
|
||||||
if strings.ContainsRune(r, '/') {
|
|
||||||
teamReviewers = append(teamReviewers, r)
|
|
||||||
} else {
|
|
||||||
userReviewers = append(userReviewers, r)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
userReviewerIDs, err := tb.MetadataResult.MembersToIDs(userReviewers)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("could not request reviewer: %w", err)
|
|
||||||
}
|
|
||||||
params["userReviewerIds"] = userReviewerIDs
|
|
||||||
|
|
||||||
teamReviewerIDs, err := tb.MetadataResult.TeamsToIDs(teamReviewers)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("could not request reviewer: %w", err)
|
|
||||||
}
|
|
||||||
params["teamReviewerIds"] = teamReviewerIDs
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func printIssues(w io.Writer, prefix string, totalCount int, issues []api.Issue) {
|
|
||||||
table := utils.NewTablePrinter(w)
|
|
||||||
for _, issue := range issues {
|
|
||||||
issueNum := strconv.Itoa(issue.Number)
|
|
||||||
if table.IsTTY() {
|
|
||||||
issueNum = "#" + issueNum
|
|
||||||
}
|
|
||||||
issueNum = prefix + issueNum
|
|
||||||
labels := issueLabelList(issue)
|
|
||||||
if labels != "" && table.IsTTY() {
|
|
||||||
labels = fmt.Sprintf("(%s)", labels)
|
|
||||||
}
|
|
||||||
now := time.Now()
|
|
||||||
ago := now.Sub(issue.UpdatedAt)
|
|
||||||
table.AddField(issueNum, nil, colorFuncForState(issue.State))
|
|
||||||
if !table.IsTTY() {
|
|
||||||
table.AddField(issue.State, nil, nil)
|
|
||||||
}
|
|
||||||
table.AddField(replaceExcessiveWhitespace(issue.Title), nil, nil)
|
|
||||||
table.AddField(labels, nil, utils.Gray)
|
|
||||||
if table.IsTTY() {
|
|
||||||
table.AddField(utils.FuzzyAgo(ago), nil, utils.Gray)
|
|
||||||
} else {
|
|
||||||
table.AddField(issue.UpdatedAt.String(), nil, nil)
|
|
||||||
}
|
|
||||||
table.EndRow()
|
|
||||||
}
|
|
||||||
_ = table.Render()
|
|
||||||
remaining := totalCount - len(issues)
|
|
||||||
if remaining > 0 {
|
|
||||||
fmt.Fprintf(w, utils.Gray("%sAnd %d more\n"), prefix, remaining)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func issueAssigneeList(issue api.Issue) string {
|
|
||||||
if len(issue.Assignees.Nodes) == 0 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
AssigneeNames := make([]string, 0, len(issue.Assignees.Nodes))
|
|
||||||
for _, assignee := range issue.Assignees.Nodes {
|
|
||||||
AssigneeNames = append(AssigneeNames, assignee.Login)
|
|
||||||
}
|
|
||||||
|
|
||||||
list := strings.Join(AssigneeNames, ", ")
|
|
||||||
if issue.Assignees.TotalCount > len(issue.Assignees.Nodes) {
|
|
||||||
list += ", …"
|
|
||||||
}
|
|
||||||
return list
|
|
||||||
}
|
|
||||||
|
|
||||||
func issueLabelList(issue api.Issue) string {
|
|
||||||
if len(issue.Labels.Nodes) == 0 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
labelNames := make([]string, 0, len(issue.Labels.Nodes))
|
|
||||||
for _, label := range issue.Labels.Nodes {
|
|
||||||
labelNames = append(labelNames, label.Name)
|
|
||||||
}
|
|
||||||
|
|
||||||
list := strings.Join(labelNames, ", ")
|
|
||||||
if issue.Labels.TotalCount > len(issue.Labels.Nodes) {
|
|
||||||
list += ", …"
|
|
||||||
}
|
|
||||||
return list
|
|
||||||
}
|
|
||||||
|
|
||||||
func issueProjectList(issue api.Issue) string {
|
|
||||||
if len(issue.ProjectCards.Nodes) == 0 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
projectNames := make([]string, 0, len(issue.ProjectCards.Nodes))
|
|
||||||
for _, project := range issue.ProjectCards.Nodes {
|
|
||||||
colName := project.Column.Name
|
|
||||||
if colName == "" {
|
|
||||||
colName = "Awaiting triage"
|
|
||||||
}
|
|
||||||
projectNames = append(projectNames, fmt.Sprintf("%s (%s)", project.Project.Name, colName))
|
|
||||||
}
|
|
||||||
|
|
||||||
list := strings.Join(projectNames, ", ")
|
|
||||||
if issue.ProjectCards.TotalCount > len(issue.ProjectCards.Nodes) {
|
|
||||||
list += ", …"
|
|
||||||
}
|
|
||||||
return list
|
|
||||||
}
|
|
||||||
|
|
||||||
func issueClose(cmd *cobra.Command, args []string) error {
|
|
||||||
ctx := contextForCommand(cmd)
|
|
||||||
apiClient, err := apiClientForContext(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
issue, baseRepo, err := issueFromArg(ctx, apiClient, cmd, args[0])
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if issue.Closed {
|
|
||||||
fmt.Fprintf(colorableErr(cmd), "%s Issue #%d (%s) is already closed\n", utils.Yellow("!"), issue.Number, issue.Title)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
err = api.IssueClose(apiClient, baseRepo, *issue)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Fprintf(colorableErr(cmd), "%s Closed issue #%d (%s)\n", utils.Red("✔"), issue.Number, issue.Title)
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func issueReopen(cmd *cobra.Command, args []string) error {
|
|
||||||
ctx := contextForCommand(cmd)
|
|
||||||
apiClient, err := apiClientForContext(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
issue, baseRepo, err := issueFromArg(ctx, apiClient, cmd, args[0])
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if !issue.Closed {
|
|
||||||
fmt.Fprintf(colorableErr(cmd), "%s Issue #%d (%s) is already open\n", utils.Yellow("!"), issue.Number, issue.Title)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
err = api.IssueReopen(apiClient, baseRepo, *issue)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Fprintf(colorableErr(cmd), "%s Reopened issue #%d (%s)\n", utils.Green("✔"), issue.Number, issue.Title)
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load diff
1107
command/pr.go
1107
command/pr.go
File diff suppressed because it is too large
Load diff
|
|
@ -1,104 +0,0 @@
|
||||||
package command
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/cli/cli/utils"
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
)
|
|
||||||
|
|
||||||
var prDiffCmd = &cobra.Command{
|
|
||||||
Use: "diff {<number> | <url>}",
|
|
||||||
Short: "View a pull request's changes.",
|
|
||||||
RunE: prDiff,
|
|
||||||
}
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
prDiffCmd.Flags().StringP("color", "c", "auto", "Whether or not to output color: {always|never|auto}")
|
|
||||||
|
|
||||||
prCmd.AddCommand(prDiffCmd)
|
|
||||||
}
|
|
||||||
|
|
||||||
func prDiff(cmd *cobra.Command, args []string) error {
|
|
||||||
color, err := cmd.Flags().GetString("color")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if !validColorFlag(color) {
|
|
||||||
return fmt.Errorf("did not understand color: %q. Expected one of always, never, or auto", color)
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := contextForCommand(cmd)
|
|
||||||
apiClient, err := apiClientForContext(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
pr, baseRepo, err := prFromArgs(ctx, apiClient, cmd, args)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("could not find pull request: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
diff, err := apiClient.PullRequestDiff(baseRepo, pr.Number)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("could not find pull request diff: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
out := cmd.OutOrStdout()
|
|
||||||
if color == "auto" {
|
|
||||||
color = "never"
|
|
||||||
isTTY := false
|
|
||||||
if outFile, isFile := out.(*os.File); isFile {
|
|
||||||
isTTY = utils.IsTerminal(outFile)
|
|
||||||
if isTTY {
|
|
||||||
color = "always"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if color == "never" {
|
|
||||||
fmt.Fprint(out, diff)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
out = colorableOut(cmd)
|
|
||||||
for _, diffLine := range strings.Split(diff, "\n") {
|
|
||||||
output := diffLine
|
|
||||||
switch {
|
|
||||||
case isHeaderLine(diffLine):
|
|
||||||
output = utils.Bold(diffLine)
|
|
||||||
case isAdditionLine(diffLine):
|
|
||||||
output = utils.Green(diffLine)
|
|
||||||
case isRemovalLine(diffLine):
|
|
||||||
output = utils.Red(diffLine)
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Fprintln(out, output)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func isHeaderLine(dl string) bool {
|
|
||||||
prefixes := []string{"+++", "---", "diff", "index"}
|
|
||||||
for _, p := range prefixes {
|
|
||||||
if strings.HasPrefix(dl, p) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func isAdditionLine(dl string) bool {
|
|
||||||
return strings.HasPrefix(dl, "+")
|
|
||||||
}
|
|
||||||
|
|
||||||
func isRemovalLine(dl string) bool {
|
|
||||||
return strings.HasPrefix(dl, "-")
|
|
||||||
}
|
|
||||||
|
|
||||||
func validColorFlag(c string) bool {
|
|
||||||
return c == "auto" || c == "always" || c == "never"
|
|
||||||
}
|
|
||||||
|
|
@ -1,104 +0,0 @@
|
||||||
package command
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestPRDiff_validation(t *testing.T) {
|
|
||||||
_, err := RunCommand("pr diff --color=doublerainbow")
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected error")
|
|
||||||
}
|
|
||||||
eq(t, err.Error(), `did not understand color: "doublerainbow". Expected one of always, never, or auto`)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPRDiff_no_current_pr(t *testing.T) {
|
|
||||||
initBlankContext("", "OWNER/REPO", "master")
|
|
||||||
http := initFakeHTTP()
|
|
||||||
http.StubRepoResponse("OWNER", "REPO")
|
|
||||||
http.StubResponse(200, bytes.NewBufferString(`
|
|
||||||
{ "data": { "repository": { "pullRequests": { "nodes": [
|
|
||||||
{ "url": "https://github.com/OWNER/REPO/pull/123",
|
|
||||||
"number": 123,
|
|
||||||
"id": "foobar123",
|
|
||||||
"headRefName": "feature",
|
|
||||||
"baseRefName": "master" }
|
|
||||||
] } } } }`))
|
|
||||||
http.StubResponse(200, bytes.NewBufferString(testDiff))
|
|
||||||
_, err := RunCommand("pr diff")
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected error", err)
|
|
||||||
}
|
|
||||||
eq(t, err.Error(), `could not find pull request: no open pull requests found for branch "master"`)
|
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
|
||||||
t.Fatal("expected error", err)
|
|
||||||
}
|
|
||||||
eq(t, err.Error(), `could not find pull request diff: pull request not found`)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPRDiff(t *testing.T) {
|
|
||||||
initBlankContext("", "OWNER/REPO", "feature")
|
|
||||||
http := initFakeHTTP()
|
|
||||||
http.StubRepoResponse("OWNER", "REPO")
|
|
||||||
http.StubResponse(200, bytes.NewBufferString(`
|
|
||||||
{ "data": { "repository": { "pullRequests": { "nodes": [
|
|
||||||
{ "url": "https://github.com/OWNER/REPO/pull/123",
|
|
||||||
"number": 123,
|
|
||||||
"id": "foobar123",
|
|
||||||
"headRefName": "feature",
|
|
||||||
"baseRefName": "master" }
|
|
||||||
] } } } }`))
|
|
||||||
http.StubResponse(200, bytes.NewBufferString(testDiff))
|
|
||||||
output, err := RunCommand("pr diff")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("unexpected error: %s", err)
|
|
||||||
}
|
|
||||||
eq(t, output.String(), testDiff)
|
|
||||||
}
|
|
||||||
|
|
||||||
const testDiff = `diff --git a/.github/workflows/releases.yml b/.github/workflows/releases.yml
|
|
||||||
index 73974448..b7fc0154 100644
|
|
||||||
--- a/.github/workflows/releases.yml
|
|
||||||
+++ b/.github/workflows/releases.yml
|
|
||||||
@@ -44,6 +44,11 @@ jobs:
|
|
||||||
token: ${{secrets.SITE_GITHUB_TOKEN}}
|
|
||||||
- name: Publish documentation site
|
|
||||||
if: "!contains(github.ref, '-')" # skip prereleases
|
|
||||||
+ env:
|
|
||||||
+ GIT_COMMITTER_NAME: cli automation
|
|
||||||
+ GIT_AUTHOR_NAME: cli automation
|
|
||||||
+ GIT_COMMITTER_EMAIL: noreply@github.com
|
|
||||||
+ GIT_AUTHOR_EMAIL: noreply@github.com
|
|
||||||
run: make site-publish
|
|
||||||
- name: Move project cards
|
|
||||||
if: "!contains(github.ref, '-')" # skip prereleases
|
|
||||||
diff --git a/Makefile b/Makefile
|
|
||||||
index f2b4805c..3d7bd0f9 100644
|
|
||||||
--- a/Makefile
|
|
||||||
+++ b/Makefile
|
|
||||||
@@ -22,8 +22,8 @@ test:
|
|
||||||
go test ./...
|
|
||||||
.PHONY: test
|
|
||||||
|
|
||||||
-site:
|
|
||||||
- git clone https://github.com/github/cli.github.com.git "$@"
|
|
||||||
+site: bin/gh
|
|
||||||
+ bin/gh repo clone github/cli.github.com "$@"
|
|
||||||
|
|
||||||
site-docs: site
|
|
||||||
git -C site pull
|
|
||||||
`
|
|
||||||
|
|
@ -1,254 +0,0 @@
|
||||||
package command
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/AlecAivazis/survey/v2"
|
|
||||||
"github.com/MakeNowJust/heredoc"
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
|
|
||||||
"github.com/cli/cli/api"
|
|
||||||
"github.com/cli/cli/pkg/prompt"
|
|
||||||
"github.com/cli/cli/pkg/surveyext"
|
|
||||||
"github.com/cli/cli/utils"
|
|
||||||
)
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
prCmd.AddCommand(prReviewCmd)
|
|
||||||
|
|
||||||
prReviewCmd.Flags().BoolP("approve", "a", false, "Approve pull request")
|
|
||||||
prReviewCmd.Flags().BoolP("request-changes", "r", false, "Request changes on a pull request")
|
|
||||||
prReviewCmd.Flags().BoolP("comment", "c", false, "Comment on a pull request")
|
|
||||||
prReviewCmd.Flags().StringP("body", "b", "", "Specify the body of a review")
|
|
||||||
}
|
|
||||||
|
|
||||||
var prReviewCmd = &cobra.Command{
|
|
||||||
Use: "review [<number> | <url> | <branch>]",
|
|
||||||
Short: "Add a review to a pull request",
|
|
||||||
Long: `Add a review to a pull request.
|
|
||||||
|
|
||||||
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,
|
|
||||||
}
|
|
||||||
|
|
||||||
func processReviewOpt(cmd *cobra.Command) (*api.PullRequestReviewInput, error) {
|
|
||||||
found := 0
|
|
||||||
flag := ""
|
|
||||||
var state api.PullRequestReviewState
|
|
||||||
|
|
||||||
if cmd.Flags().Changed("approve") {
|
|
||||||
found++
|
|
||||||
flag = "approve"
|
|
||||||
state = api.ReviewApprove
|
|
||||||
}
|
|
||||||
if cmd.Flags().Changed("request-changes") {
|
|
||||||
found++
|
|
||||||
flag = "request-changes"
|
|
||||||
state = api.ReviewRequestChanges
|
|
||||||
}
|
|
||||||
if cmd.Flags().Changed("comment") {
|
|
||||||
found++
|
|
||||||
flag = "comment"
|
|
||||||
state = api.ReviewComment
|
|
||||||
}
|
|
||||||
|
|
||||||
body, err := cmd.Flags().GetString("body")
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if found == 0 && body == "" {
|
|
||||||
if connectedToTerminal(cmd) {
|
|
||||||
return nil, nil // signal interactive mode
|
|
||||||
}
|
|
||||||
return nil, errors.New("--approve, --request-changes, or --comment required when not attached to a tty")
|
|
||||||
} else if found == 0 && body != "" {
|
|
||||||
return nil, errors.New("--body unsupported without --approve, --request-changes, or --comment")
|
|
||||||
} else if found > 1 {
|
|
||||||
return nil, errors.New("need exactly one of --approve, --request-changes, or --comment")
|
|
||||||
}
|
|
||||||
|
|
||||||
if (flag == "request-changes" || flag == "comment") && body == "" {
|
|
||||||
return nil, fmt.Errorf("body cannot be blank for %s review", flag)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &api.PullRequestReviewInput{
|
|
||||||
Body: body,
|
|
||||||
State: state,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func prReview(cmd *cobra.Command, args []string) error {
|
|
||||||
ctx := contextForCommand(cmd)
|
|
||||||
apiClient, err := apiClientForContext(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
pr, _, err := prFromArgs(ctx, apiClient, cmd, args)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
reviewData, err := processReviewOpt(cmd)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("did not understand desired review action: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
stderr := colorableErr(cmd)
|
|
||||||
|
|
||||||
if reviewData == nil {
|
|
||||||
reviewData, err = reviewSurvey(cmd)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if reviewData == nil && err == nil {
|
|
||||||
fmt.Fprint(stderr, "Discarding.\n")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
err = api.AddReview(apiClient, pr, reviewData)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to create review: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !connectedToTerminal(cmd) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
switch reviewData.State {
|
|
||||||
case api.ReviewComment:
|
|
||||||
fmt.Fprintf(stderr, "%s Reviewed pull request #%d\n", utils.Gray("-"), pr.Number)
|
|
||||||
case api.ReviewApprove:
|
|
||||||
fmt.Fprintf(stderr, "%s Approved pull request #%d\n", utils.Green("✓"), pr.Number)
|
|
||||||
case api.ReviewRequestChanges:
|
|
||||||
fmt.Fprintf(stderr, "%s Requested changes to pull request #%d\n", utils.Red("+"), pr.Number)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func reviewSurvey(cmd *cobra.Command) (*api.PullRequestReviewInput, error) {
|
|
||||||
editorCommand, err := determineEditor(cmd)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
typeAnswers := struct {
|
|
||||||
ReviewType string
|
|
||||||
}{}
|
|
||||||
typeQs := []*survey.Question{
|
|
||||||
{
|
|
||||||
Name: "reviewType",
|
|
||||||
Prompt: &survey.Select{
|
|
||||||
Message: "What kind of review do you want to give?",
|
|
||||||
Options: []string{
|
|
||||||
"Comment",
|
|
||||||
"Approve",
|
|
||||||
"Request changes",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
err = prompt.SurveyAsk(typeQs, &typeAnswers)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
var reviewState api.PullRequestReviewState
|
|
||||||
|
|
||||||
switch typeAnswers.ReviewType {
|
|
||||||
case "Approve":
|
|
||||||
reviewState = api.ReviewApprove
|
|
||||||
case "Request changes":
|
|
||||||
reviewState = api.ReviewRequestChanges
|
|
||||||
case "Comment":
|
|
||||||
reviewState = api.ReviewComment
|
|
||||||
default:
|
|
||||||
panic("unreachable state")
|
|
||||||
}
|
|
||||||
|
|
||||||
bodyAnswers := struct {
|
|
||||||
Body string
|
|
||||||
}{}
|
|
||||||
|
|
||||||
blankAllowed := false
|
|
||||||
if reviewState == api.ReviewApprove {
|
|
||||||
blankAllowed = true
|
|
||||||
}
|
|
||||||
|
|
||||||
bodyQs := []*survey.Question{
|
|
||||||
{
|
|
||||||
Name: "body",
|
|
||||||
Prompt: &surveyext.GhEditor{
|
|
||||||
BlankAllowed: blankAllowed,
|
|
||||||
EditorCommand: editorCommand,
|
|
||||||
Editor: &survey.Editor{
|
|
||||||
Message: "Review body",
|
|
||||||
FileName: "*.md",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
err = prompt.SurveyAsk(bodyQs, &bodyAnswers)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if bodyAnswers.Body == "" && (reviewState == api.ReviewComment || reviewState == api.ReviewRequestChanges) {
|
|
||||||
return nil, errors.New("this type of review cannot be blank")
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(bodyAnswers.Body) > 0 {
|
|
||||||
out := colorableOut(cmd)
|
|
||||||
renderedBody, err := utils.RenderMarkdown(bodyAnswers.Body)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Fprintf(out, "Got:\n%s", renderedBody)
|
|
||||||
}
|
|
||||||
|
|
||||||
confirm := false
|
|
||||||
confirmQs := []*survey.Question{
|
|
||||||
{
|
|
||||||
Name: "confirm",
|
|
||||||
Prompt: &survey.Confirm{
|
|
||||||
Message: "Submit?",
|
|
||||||
Default: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
err = prompt.SurveyAsk(confirmQs, &confirm)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if !confirm {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return &api.PullRequestReviewInput{
|
|
||||||
Body: bodyAnswers.Body,
|
|
||||||
State: reviewState,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
1731
command/pr_test.go
1731
command/pr_test.go
File diff suppressed because it is too large
Load diff
481
command/root.go
481
command/root.go
|
|
@ -1,231 +1,33 @@
|
||||||
package command
|
package command
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
|
||||||
"path/filepath"
|
|
||||||
"regexp"
|
|
||||||
"runtime"
|
|
||||||
"runtime/debug"
|
"runtime/debug"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/MakeNowJust/heredoc"
|
|
||||||
"github.com/cli/cli/api"
|
"github.com/cli/cli/api"
|
||||||
"github.com/cli/cli/context"
|
|
||||||
"github.com/cli/cli/internal/config"
|
"github.com/cli/cli/internal/config"
|
||||||
"github.com/cli/cli/internal/ghrepo"
|
"github.com/cli/cli/internal/ghinstance"
|
||||||
"github.com/cli/cli/internal/run"
|
|
||||||
apiCmd "github.com/cli/cli/pkg/cmd/api"
|
|
||||||
gistCreateCmd "github.com/cli/cli/pkg/cmd/gist/create"
|
|
||||||
repoCmd "github.com/cli/cli/pkg/cmd/repo"
|
|
||||||
repoCloneCmd "github.com/cli/cli/pkg/cmd/repo/clone"
|
|
||||||
repoCreateCmd "github.com/cli/cli/pkg/cmd/repo/create"
|
|
||||||
creditsCmd "github.com/cli/cli/pkg/cmd/repo/credits"
|
|
||||||
repoForkCmd "github.com/cli/cli/pkg/cmd/repo/fork"
|
|
||||||
repoViewCmd "github.com/cli/cli/pkg/cmd/repo/view"
|
|
||||||
"github.com/cli/cli/pkg/cmdutil"
|
|
||||||
"github.com/cli/cli/pkg/iostreams"
|
|
||||||
"github.com/cli/cli/utils"
|
"github.com/cli/cli/utils"
|
||||||
"github.com/google/shlex"
|
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
"github.com/spf13/pflag"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// TODO these are sprinkled across command, context, config, and ghrepo
|
|
||||||
const defaultHostname = "github.com"
|
|
||||||
|
|
||||||
// Version is dynamically set by the toolchain or overridden by the Makefile.
|
// Version is dynamically set by the toolchain or overridden by the Makefile.
|
||||||
var Version = "DEV"
|
var Version = "DEV"
|
||||||
|
|
||||||
// BuildDate is dynamically set at build time in the Makefile.
|
// BuildDate is dynamically set at build time in the Makefile.
|
||||||
var BuildDate = "" // YYYY-MM-DD
|
var BuildDate = "" // YYYY-MM-DD
|
||||||
|
|
||||||
var versionOutput = ""
|
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
if Version == "DEV" {
|
if Version == "DEV" {
|
||||||
if info, ok := debug.ReadBuildInfo(); ok && info.Main.Version != "(devel)" {
|
if info, ok := debug.ReadBuildInfo(); ok && info.Main.Version != "(devel)" {
|
||||||
Version = info.Main.Version
|
Version = info.Main.Version
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Version = strings.TrimPrefix(Version, "v")
|
|
||||||
if BuildDate == "" {
|
|
||||||
RootCmd.Version = Version
|
|
||||||
} else {
|
|
||||||
RootCmd.Version = fmt.Sprintf("%s (%s)", Version, BuildDate)
|
|
||||||
}
|
|
||||||
versionOutput = fmt.Sprintf("gh version %s\n%s\n", RootCmd.Version, changelogURL(Version))
|
|
||||||
RootCmd.AddCommand(versionCmd)
|
|
||||||
RootCmd.SetVersionTemplate(versionOutput)
|
|
||||||
|
|
||||||
RootCmd.PersistentFlags().Bool("help", false, "Show help for command")
|
|
||||||
RootCmd.Flags().Bool("version", false, "Show gh version")
|
|
||||||
// TODO:
|
|
||||||
// RootCmd.PersistentFlags().BoolP("verbose", "V", false, "enable verbose output")
|
|
||||||
|
|
||||||
RootCmd.SetHelpFunc(rootHelpFunc)
|
|
||||||
RootCmd.SetUsageFunc(rootUsageFunc)
|
|
||||||
|
|
||||||
RootCmd.SetFlagErrorFunc(func(cmd *cobra.Command, err error) error {
|
|
||||||
if err == pflag.ErrHelp {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return &cmdutil.FlagError{Err: 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()
|
|
||||||
},
|
|
||||||
Remotes: func() (context.Remotes, error) {
|
|
||||||
ctx := context.New()
|
|
||||||
return ctx.Remotes()
|
|
||||||
},
|
|
||||||
Config: func() (config.Config, error) {
|
|
||||||
cfg, err := config.ParseDefaultConfig()
|
|
||||||
if errors.Is(err, os.ErrNotExist) {
|
|
||||||
cfg = config.NewBlankConfig()
|
|
||||||
} else if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return cfg, nil
|
|
||||||
},
|
|
||||||
}
|
|
||||||
RootCmd.AddCommand(apiCmd.NewCmdApi(cmdFactory, nil))
|
|
||||||
|
|
||||||
gistCmd := &cobra.Command{
|
|
||||||
Use: "gist",
|
|
||||||
Short: "Create gists",
|
|
||||||
Long: `Work with GitHub gists.`,
|
|
||||||
}
|
|
||||||
RootCmd.AddCommand(gistCmd)
|
|
||||||
gistCmd.AddCommand(gistCreateCmd.NewCmdCreate(cmdFactory, nil))
|
|
||||||
|
|
||||||
resolvedBaseRepo := func() (ghrepo.Interface, error) {
|
|
||||||
httpClient, err := cmdFactory.HttpClient()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
apiClient := api.NewClientFromHTTP(httpClient)
|
|
||||||
|
|
||||||
ctx := context.New()
|
|
||||||
remotes, err := ctx.Remotes()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
repoContext, err := context.ResolveRemotesToRepos(remotes, apiClient, "")
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
baseRepo, err := repoContext.BaseRepo()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return baseRepo, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
repoResolvingCmdFactory := *cmdFactory
|
|
||||||
|
|
||||||
repoResolvingCmdFactory.BaseRepo = resolvedBaseRepo
|
|
||||||
|
|
||||||
RootCmd.AddCommand(repoCmd.Cmd)
|
|
||||||
repoCmd.Cmd.AddCommand(repoViewCmd.NewCmdView(&repoResolvingCmdFactory, nil))
|
|
||||||
repoCmd.Cmd.AddCommand(repoForkCmd.NewCmdFork(&repoResolvingCmdFactory, nil))
|
|
||||||
repoCmd.Cmd.AddCommand(repoCloneCmd.NewCmdClone(cmdFactory, nil))
|
|
||||||
repoCmd.Cmd.AddCommand(repoCreateCmd.NewCmdCreate(cmdFactory, nil))
|
|
||||||
repoCmd.Cmd.AddCommand(creditsCmd.NewCmdRepoCredits(&repoResolvingCmdFactory, nil))
|
|
||||||
|
|
||||||
RootCmd.AddCommand(creditsCmd.NewCmdCredits(cmdFactory, nil))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// RootCmd is the entry point of command-line execution
|
// BasicClient returns an API client for github.com only that borrows from but
|
||||||
var RootCmd = &cobra.Command{
|
// does not depend on user configuration
|
||||||
Use: "gh <command> <subcommand> [flags]",
|
|
||||||
Short: "GitHub CLI",
|
|
||||||
Long: `Work seamlessly with GitHub from the command line.`,
|
|
||||||
|
|
||||||
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": heredoc.Doc(`
|
|
||||||
Fill out our feedback form https://forms.gle/umxd3h31c7aMQFKG7
|
|
||||||
Open an issue using “gh issue create -R cli/cli”
|
|
||||||
`),
|
|
||||||
"help:environment": heredoc.Doc(`
|
|
||||||
GITHUB_TOKEN: an authentication token for API requests. Setting this avoids being
|
|
||||||
prompted to authenticate and overrides any previously stored credentials.
|
|
||||||
|
|
||||||
GH_REPO: specify the GitHub repository in "OWNER/REPO" format for commands that
|
|
||||||
otherwise operate on a local repository.
|
|
||||||
|
|
||||||
GH_EDITOR, GIT_EDITOR, VISUAL, EDITOR (in order of precedence): the editor tool to use
|
|
||||||
for authoring text.
|
|
||||||
|
|
||||||
BROWSER: the web browser to use for opening links.
|
|
||||||
|
|
||||||
DEBUG: set to any value to enable verbose output to standard error. Include values "api"
|
|
||||||
or "oauth" to print detailed information about HTTP requests or authentication flow.
|
|
||||||
|
|
||||||
GLAMOUR_STYLE: the style to use for rendering Markdown. See
|
|
||||||
https://github.com/charmbracelet/glamour#styles
|
|
||||||
|
|
||||||
NO_COLOR: avoid printing ANSI escape sequences for color output.
|
|
||||||
`),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
var versionCmd = &cobra.Command{
|
|
||||||
Use: "version",
|
|
||||||
Hidden: true,
|
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
|
||||||
fmt.Print(versionOutput)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// overridden in tests
|
|
||||||
var initContext = func() context.Context {
|
|
||||||
ctx := context.New()
|
|
||||||
if repo := os.Getenv("GH_REPO"); repo != "" {
|
|
||||||
ctx.SetBaseRepo(repo)
|
|
||||||
}
|
|
||||||
if token := os.Getenv("GITHUB_TOKEN"); token != "" {
|
|
||||||
ctx.SetAuthToken(token)
|
|
||||||
}
|
|
||||||
return ctx
|
|
||||||
}
|
|
||||||
|
|
||||||
// BasicClient returns an API client that borrows from but does not depend on
|
|
||||||
// user configuration
|
|
||||||
func BasicClient() (*api.Client, error) {
|
func BasicClient() (*api.Client, error) {
|
||||||
var opts []api.ClientOption
|
var opts []api.ClientOption
|
||||||
if verbose := os.Getenv("DEBUG"); verbose != "" {
|
if verbose := os.Getenv("DEBUG"); verbose != "" {
|
||||||
|
|
@ -236,7 +38,7 @@ func BasicClient() (*api.Client, error) {
|
||||||
token := os.Getenv("GITHUB_TOKEN")
|
token := os.Getenv("GITHUB_TOKEN")
|
||||||
if token == "" {
|
if token == "" {
|
||||||
if c, err := config.ParseDefaultConfig(); err == nil {
|
if c, err := config.ParseDefaultConfig(); err == nil {
|
||||||
token, _ = c.Get(defaultHostname, "oauth_token")
|
token, _ = c.Get(ghinstance.Default(), "oauth_token")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if token != "" {
|
if token != "" {
|
||||||
|
|
@ -245,283 +47,8 @@ func BasicClient() (*api.Client, error) {
|
||||||
return api.NewClient(opts...), nil
|
return api.NewClient(opts...), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func contextForCommand(cmd *cobra.Command) context.Context {
|
|
||||||
ctx := initContext()
|
|
||||||
if repo, err := cmd.Flags().GetString("repo"); err == nil && repo != "" {
|
|
||||||
ctx.SetBaseRepo(repo)
|
|
||||||
}
|
|
||||||
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()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
var opts []api.ClientOption
|
|
||||||
if verbose := os.Getenv("DEBUG"); verbose != "" {
|
|
||||||
opts = append(opts, apiVerboseLog())
|
|
||||||
}
|
|
||||||
|
|
||||||
getAuthValue := func() string {
|
|
||||||
return fmt.Sprintf("token %s", token)
|
|
||||||
}
|
|
||||||
|
|
||||||
tokenFromEnv := func() bool {
|
|
||||||
return os.Getenv("GITHUB_TOKEN") == token
|
|
||||||
}
|
|
||||||
|
|
||||||
checkScopesFunc := func(appID string) error {
|
|
||||||
if config.IsGitHubApp(appID) && !tokenFromEnv() && utils.IsTerminal(os.Stdin) && utils.IsTerminal(os.Stderr) {
|
|
||||||
cfg, err := ctx.Config()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
newToken, err := config.AuthFlowWithConfig(cfg, defaultHostname, "Notice: additional authorization required")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
// update configuration in memory
|
|
||||||
token = newToken
|
|
||||||
} 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`")
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
opts = append(opts,
|
|
||||||
api.CheckScopes("read:org", checkScopesFunc),
|
|
||||||
api.AddHeaderFunc("Authorization", getAuthValue),
|
|
||||||
api.AddHeader("User-Agent", fmt.Sprintf("GitHub CLI %s", Version)),
|
|
||||||
// antiope-preview: Checks
|
|
||||||
api.AddHeader("Accept", "application/vnd.github.antiope-preview+json"),
|
|
||||||
)
|
|
||||||
|
|
||||||
return api.NewClient(opts...), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func apiVerboseLog() api.ClientOption {
|
func apiVerboseLog() api.ClientOption {
|
||||||
logTraffic := strings.Contains(os.Getenv("DEBUG"), "api")
|
logTraffic := strings.Contains(os.Getenv("DEBUG"), "api")
|
||||||
colorize := utils.IsTerminal(os.Stderr)
|
colorize := utils.IsTerminal(os.Stderr)
|
||||||
return api.VerboseLog(utils.NewColorable(os.Stderr), logTraffic, colorize)
|
return api.VerboseLog(utils.NewColorable(os.Stderr), logTraffic, colorize)
|
||||||
}
|
}
|
||||||
|
|
||||||
func colorableOut(cmd *cobra.Command) io.Writer {
|
|
||||||
out := cmd.OutOrStdout()
|
|
||||||
if outFile, isFile := out.(*os.File); isFile {
|
|
||||||
return utils.NewColorable(outFile)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func colorableErr(cmd *cobra.Command) io.Writer {
|
|
||||||
err := cmd.ErrOrStderr()
|
|
||||||
if outFile, isFile := err.(*os.File); isFile {
|
|
||||||
return utils.NewColorable(outFile)
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func changelogURL(version string) string {
|
|
||||||
path := "https://github.com/cli/cli"
|
|
||||||
r := regexp.MustCompile(`^v?\d+\.\d+\.\d+(-[\w.]+)?$`)
|
|
||||||
if !r.MatchString(version) {
|
|
||||||
return fmt.Sprintf("%s/releases/latest", path)
|
|
||||||
}
|
|
||||||
|
|
||||||
url := fmt.Sprintf("%s/releases/tag/v%s", path, strings.TrimPrefix(version, "v"))
|
|
||||||
return url
|
|
||||||
}
|
|
||||||
|
|
||||||
func determineBaseRepo(apiClient *api.Client, cmd *cobra.Command, ctx context.Context) (ghrepo.Interface, error) {
|
|
||||||
repo, _ := cmd.Flags().GetString("repo")
|
|
||||||
if repo != "" {
|
|
||||||
baseRepo, err := ghrepo.FromFullName(repo)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("argument error: %w", err)
|
|
||||||
}
|
|
||||||
return baseRepo, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
remotes, err := ctx.Remotes()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
repoContext, err := context.ResolveRemotesToRepos(remotes, apiClient, "")
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
baseRepo, err := repoContext.BaseRepo()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return baseRepo, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO there is a parallel implementation for isolated commands
|
|
||||||
func formatRemoteURL(cmd *cobra.Command, repo ghrepo.Interface) string {
|
|
||||||
ctx := contextForCommand(cmd)
|
|
||||||
|
|
||||||
var protocol string
|
|
||||||
cfg, err := ctx.Config()
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(colorableErr(cmd), "%s failed to load config: %s. using defaults\n", utils.Yellow("!"), err)
|
|
||||||
} else {
|
|
||||||
protocol, _ = cfg.Get(repo.RepoHost(), "git_protocol")
|
|
||||||
}
|
|
||||||
|
|
||||||
if protocol == "ssh" {
|
|
||||||
return fmt.Sprintf("git@%s:%s/%s.git", repo.RepoHost(), repo.RepoOwner(), repo.RepoName())
|
|
||||||
}
|
|
||||||
|
|
||||||
return fmt.Sprintf("https://%s/%s/%s.git", repo.RepoHost(), repo.RepoOwner(), repo.RepoName())
|
|
||||||
}
|
|
||||||
|
|
||||||
func determineEditor(cmd *cobra.Command) (string, error) {
|
|
||||||
editorCommand := os.Getenv("GH_EDITOR")
|
|
||||||
if editorCommand == "" {
|
|
||||||
ctx := contextForCommand(cmd)
|
|
||||||
cfg, err := ctx.Config()
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("could not read config: %w", err)
|
|
||||||
}
|
|
||||||
editorCommand, _ = cfg.Get(defaultHostname, "editor")
|
|
||||||
}
|
|
||||||
|
|
||||||
return editorCommand, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func ExecuteShellAlias(args []string) error {
|
|
||||||
externalCmd := exec.Command(args[0], args[1:]...)
|
|
||||||
externalCmd.Stderr = os.Stderr
|
|
||||||
externalCmd.Stdout = os.Stdout
|
|
||||||
externalCmd.Stdin = os.Stdin
|
|
||||||
preparedCmd := run.PrepareCmd(externalCmd)
|
|
||||||
|
|
||||||
return preparedCmd.Run()
|
|
||||||
}
|
|
||||||
|
|
||||||
var findSh = func() (string, error) {
|
|
||||||
shPath, err := exec.LookPath("sh")
|
|
||||||
if err == nil {
|
|
||||||
return shPath, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
winNotFoundErr := errors.New("unable to locate sh to execute the shell alias with. The sh.exe interpreter is typically distributed with Git for Windows.")
|
|
||||||
// We can try and find a sh executable in a Git for Windows install
|
|
||||||
gitPath, err := exec.LookPath("git")
|
|
||||||
if err != nil {
|
|
||||||
return "", winNotFoundErr
|
|
||||||
}
|
|
||||||
|
|
||||||
shPath = filepath.Join(filepath.Dir(gitPath), "..", "bin", "sh.exe")
|
|
||||||
_, err = os.Stat(shPath)
|
|
||||||
if err != nil {
|
|
||||||
return "", winNotFoundErr
|
|
||||||
}
|
|
||||||
|
|
||||||
return shPath, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return "", errors.New("unable to locate sh to execute shell alias with")
|
|
||||||
}
|
|
||||||
|
|
||||||
// ExpandAlias processes argv to see if it should be rewritten according to a user's aliases. The
|
|
||||||
// second return value indicates whether the alias should be executed in a new shell process instead
|
|
||||||
// of running gh itself.
|
|
||||||
func ExpandAlias(args []string) (expanded []string, isShell bool, err error) {
|
|
||||||
err = nil
|
|
||||||
isShell = false
|
|
||||||
expanded = []string{}
|
|
||||||
|
|
||||||
if len(args) < 2 {
|
|
||||||
// the command is lacking a subcommand
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := initContext()
|
|
||||||
cfg, err := ctx.Config()
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
aliases, err := cfg.Aliases()
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
expansion, ok := aliases.Get(args[1])
|
|
||||||
if ok {
|
|
||||||
if strings.HasPrefix(expansion, "!") {
|
|
||||||
isShell = true
|
|
||||||
shPath, shErr := findSh()
|
|
||||||
if shErr != nil {
|
|
||||||
err = shErr
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
expanded = []string{shPath, "-c", expansion[1:]}
|
|
||||||
|
|
||||||
if len(args[2:]) > 0 {
|
|
||||||
expanded = append(expanded, "--")
|
|
||||||
expanded = append(expanded, args[2:]...)
|
|
||||||
}
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
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) {
|
|
||||||
err = fmt.Errorf("not enough arguments for alias: %s", expansion)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var newArgs []string
|
|
||||||
newArgs, err = shlex.Split(expansion)
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
expanded = append(newArgs, extraArgs...)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
expanded = args[1:]
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
func connectedToTerminal(cmd *cobra.Command) bool {
|
|
||||||
return utils.IsTerminal(cmd.InOrStdin()) && utils.IsTerminal(cmd.OutOrStdout())
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,138 +0,0 @@
|
||||||
package command
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/cli/cli/api"
|
|
||||||
"github.com/cli/cli/context"
|
|
||||||
"github.com/cli/cli/internal/config"
|
|
||||||
"github.com/cli/cli/pkg/httpmock"
|
|
||||||
"github.com/cli/cli/utils"
|
|
||||||
"github.com/google/shlex"
|
|
||||||
"github.com/spf13/pflag"
|
|
||||||
)
|
|
||||||
|
|
||||||
const defaultTestConfig = `hosts:
|
|
||||||
github.com:
|
|
||||||
user: OWNER
|
|
||||||
oauth_token: "1234567890"
|
|
||||||
`
|
|
||||||
|
|
||||||
func initBlankContext(cfg, repo, branch string) {
|
|
||||||
initContext = func() context.Context {
|
|
||||||
ctx := context.NewBlank()
|
|
||||||
ctx.SetBaseRepo(repo)
|
|
||||||
ctx.SetBranch(branch)
|
|
||||||
ctx.SetRemotes(map[string]string{
|
|
||||||
"origin": "OWNER/REPO",
|
|
||||||
})
|
|
||||||
|
|
||||||
if cfg == "" {
|
|
||||||
cfg = defaultTestConfig
|
|
||||||
}
|
|
||||||
|
|
||||||
// NOTE we are not restoring the original readConfig; we never want to touch the config file on
|
|
||||||
// disk during tests.
|
|
||||||
config.StubConfig(cfg, "")
|
|
||||||
|
|
||||||
return ctx
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func initFakeHTTP() *httpmock.Registry {
|
|
||||||
http := &httpmock.Registry{}
|
|
||||||
apiClientForContext = func(context.Context) (*api.Client, error) {
|
|
||||||
return api.NewClient(api.ReplaceTripper(http)), nil
|
|
||||||
}
|
|
||||||
return http
|
|
||||||
}
|
|
||||||
|
|
||||||
type cmdOut struct {
|
|
||||||
outBuf, errBuf *bytes.Buffer
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c cmdOut) String() string {
|
|
||||||
return c.outBuf.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c cmdOut) Stderr() string {
|
|
||||||
return c.errBuf.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
func RunCommand(args string) (*cmdOut, error) {
|
|
||||||
rootCmd := RootCmd
|
|
||||||
rootArgv, err := shlex.Split(args)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd, _, err := rootCmd.Traverse(rootArgv)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
rootCmd.SetArgs(rootArgv)
|
|
||||||
|
|
||||||
outBuf := bytes.Buffer{}
|
|
||||||
cmd.SetOut(&outBuf)
|
|
||||||
errBuf := bytes.Buffer{}
|
|
||||||
cmd.SetErr(&errBuf)
|
|
||||||
|
|
||||||
// Reset flag values so they don't leak between tests
|
|
||||||
// FIXME: change how we initialize Cobra commands to render this hack unnecessary
|
|
||||||
cmd.Flags().VisitAll(func(f *pflag.Flag) {
|
|
||||||
f.Changed = false
|
|
||||||
switch v := f.Value.(type) {
|
|
||||||
case pflag.SliceValue:
|
|
||||||
_ = v.Replace([]string{})
|
|
||||||
default:
|
|
||||||
switch v.Type() {
|
|
||||||
case "bool", "string", "int":
|
|
||||||
_ = v.Set(f.DefValue)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
_, err = rootCmd.ExecuteC()
|
|
||||||
cmd.SetOut(nil)
|
|
||||||
cmd.SetErr(nil)
|
|
||||||
|
|
||||||
return &cmdOut{&outBuf, &errBuf}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
type errorStub struct {
|
|
||||||
message string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s errorStub) Output() ([]byte, error) {
|
|
||||||
return nil, errors.New(s.message)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s errorStub) Run() error {
|
|
||||||
return errors.New(s.message)
|
|
||||||
}
|
|
||||||
|
|
||||||
func stubTerminal(connected bool) func() {
|
|
||||||
isTerminal := utils.IsTerminal
|
|
||||||
utils.IsTerminal = func(_ interface{}) bool {
|
|
||||||
return connected
|
|
||||||
}
|
|
||||||
|
|
||||||
terminalSize := utils.TerminalSize
|
|
||||||
if connected {
|
|
||||||
utils.TerminalSize = func(_ interface{}) (int, int, error) {
|
|
||||||
return 80, 20, nil
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
utils.TerminalSize = func(_ interface{}) (int, int, error) {
|
|
||||||
return 0, 0, fmt.Errorf("terminal connection stubbed to false")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return func() {
|
|
||||||
utils.IsTerminal = isTerminal
|
|
||||||
utils.TerminalSize = terminalSize
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -2,11 +2,8 @@ package context
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/cli/cli/git"
|
|
||||||
"github.com/cli/cli/internal/config"
|
"github.com/cli/cli/internal/config"
|
||||||
"github.com/cli/cli/internal/ghrepo"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// NewBlank initializes a blank Context suitable for testing
|
// NewBlank initializes a blank Context suitable for testing
|
||||||
|
|
@ -16,10 +13,6 @@ func NewBlank() *blankContext {
|
||||||
|
|
||||||
// A Context implementation that queries the filesystem
|
// A Context implementation that queries the filesystem
|
||||||
type blankContext struct {
|
type blankContext struct {
|
||||||
authToken string
|
|
||||||
branch string
|
|
||||||
baseRepo ghrepo.Interface
|
|
||||||
remotes Remotes
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *blankContext) Config() (config.Config, error) {
|
func (c *blankContext) Config() (config.Config, error) {
|
||||||
|
|
@ -29,59 +22,3 @@ func (c *blankContext) Config() (config.Config, error) {
|
||||||
}
|
}
|
||||||
return cfg, nil
|
return cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *blankContext) AuthToken() (string, error) {
|
|
||||||
return c.authToken, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *blankContext) SetAuthToken(t string) {
|
|
||||||
c.authToken = t
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *blankContext) Branch() (string, error) {
|
|
||||||
if c.branch == "" {
|
|
||||||
return "", fmt.Errorf("branch was not initialized: %w", git.ErrNotOnAnyBranch)
|
|
||||||
}
|
|
||||||
return c.branch, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *blankContext) SetBranch(b string) {
|
|
||||||
c.branch = b
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *blankContext) Remotes() (Remotes, error) {
|
|
||||||
if c.remotes == nil {
|
|
||||||
return nil, fmt.Errorf("remotes were not initialized")
|
|
||||||
}
|
|
||||||
return c.remotes, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *blankContext) SetRemotes(stubs map[string]string) {
|
|
||||||
c.remotes = make([]*Remote, 0, len(stubs))
|
|
||||||
for remoteName, repo := range stubs {
|
|
||||||
ownerWithName := strings.SplitN(repo, "/", 2)
|
|
||||||
c.remotes = append(c.remotes, &Remote{
|
|
||||||
Remote: &git.Remote{Name: remoteName},
|
|
||||||
Repo: ghrepo.New(ownerWithName[0], ownerWithName[1]),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *blankContext) BaseRepo() (ghrepo.Interface, error) {
|
|
||||||
if c.baseRepo != nil {
|
|
||||||
return c.baseRepo, nil
|
|
||||||
}
|
|
||||||
remotes, err := c.Remotes()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if len(remotes) < 1 {
|
|
||||||
return nil, fmt.Errorf("remotes are empty")
|
|
||||||
}
|
|
||||||
return remotes[0], nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *blankContext) SetBaseRepo(nwo string) {
|
|
||||||
repo, _ := ghrepo.FromFullName(nwo)
|
|
||||||
c.baseRepo = repo
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -8,23 +8,12 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/cli/cli/api"
|
"github.com/cli/cli/api"
|
||||||
"github.com/cli/cli/git"
|
|
||||||
"github.com/cli/cli/internal/config"
|
"github.com/cli/cli/internal/config"
|
||||||
"github.com/cli/cli/internal/ghrepo"
|
"github.com/cli/cli/internal/ghrepo"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TODO these are sprinkled across command, context, config, and ghrepo
|
|
||||||
const defaultHostname = "github.com"
|
|
||||||
|
|
||||||
// Context represents the interface for querying information about the current environment
|
// Context represents the interface for querying information about the current environment
|
||||||
type Context interface {
|
type Context interface {
|
||||||
AuthToken() (string, error)
|
|
||||||
SetAuthToken(string)
|
|
||||||
Branch() (string, error)
|
|
||||||
SetBranch(string)
|
|
||||||
Remotes() (Remotes, error)
|
|
||||||
BaseRepo() (ghrepo.Interface, error)
|
|
||||||
SetBaseRepo(string)
|
|
||||||
Config() (config.Config, error)
|
Config() (config.Config, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -51,7 +40,7 @@ func ResolveRemotesToRepos(remotes Remotes, client *api.Client, base string) (Re
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
repos = append(repos, r)
|
repos = append(repos, r)
|
||||||
if ghrepo.IsSame(r, baseOverride) {
|
if baseOverride != nil && ghrepo.IsSame(r, baseOverride) {
|
||||||
foundBaseOverride = true
|
foundBaseOverride = true
|
||||||
}
|
}
|
||||||
if len(repos) == maxRemotesForLookup {
|
if len(repos) == maxRemotesForLookup {
|
||||||
|
|
@ -164,11 +153,7 @@ func New() Context {
|
||||||
|
|
||||||
// A Context implementation that queries the filesystem
|
// A Context implementation that queries the filesystem
|
||||||
type fsContext struct {
|
type fsContext struct {
|
||||||
config config.Config
|
config config.Config
|
||||||
remotes Remotes
|
|
||||||
branch string
|
|
||||||
baseRepo ghrepo.Interface
|
|
||||||
authToken string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *fsContext) Config() (config.Config, error) {
|
func (c *fsContext) Config() (config.Config, error) {
|
||||||
|
|
@ -180,105 +165,6 @@ func (c *fsContext) Config() (config.Config, error) {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
c.config = cfg
|
c.config = cfg
|
||||||
c.authToken = ""
|
|
||||||
}
|
}
|
||||||
return c.config, nil
|
return c.config, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *fsContext) AuthToken() (string, error) {
|
|
||||||
if c.authToken != "" {
|
|
||||||
return c.authToken, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg, err := c.Config()
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
var notFound *config.NotFoundError
|
|
||||||
token, err := cfg.Get(defaultHostname, "oauth_token")
|
|
||||||
if token == "" || errors.As(err, ¬Found) {
|
|
||||||
// interactive OAuth flow
|
|
||||||
return config.AuthFlowWithConfig(cfg, defaultHostname, "Notice: authentication required")
|
|
||||||
} else if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
return token, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *fsContext) SetAuthToken(t string) {
|
|
||||||
c.authToken = t
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *fsContext) Branch() (string, error) {
|
|
||||||
if c.branch != "" {
|
|
||||||
return c.branch, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
currentBranch, err := git.CurrentBranch()
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("could not determine current branch: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
c.branch = currentBranch
|
|
||||||
return c.branch, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *fsContext) SetBranch(b string) {
|
|
||||||
c.branch = b
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *fsContext) Remotes() (Remotes, error) {
|
|
||||||
if c.remotes == nil {
|
|
||||||
gitRemotes, err := git.Remotes()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if len(gitRemotes) == 0 {
|
|
||||||
return nil, errors.New("no git remotes found")
|
|
||||||
}
|
|
||||||
|
|
||||||
sshTranslate := git.ParseSSHConfig().Translator()
|
|
||||||
resolvedRemotes := translateRemotes(gitRemotes, sshTranslate)
|
|
||||||
|
|
||||||
// ignore non-github.com remotes
|
|
||||||
// TODO: GHE compatibility
|
|
||||||
filteredRemotes := Remotes{}
|
|
||||||
for _, r := range resolvedRemotes {
|
|
||||||
if r.RepoHost() != defaultHostname {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
filteredRemotes = append(filteredRemotes, r)
|
|
||||||
}
|
|
||||||
c.remotes = filteredRemotes
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(c.remotes) == 0 {
|
|
||||||
// TODO: GHE compatibility
|
|
||||||
return nil, errors.New("no git remote found for a github.com repository")
|
|
||||||
}
|
|
||||||
return c.remotes, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *fsContext) BaseRepo() (ghrepo.Interface, error) {
|
|
||||||
if c.baseRepo != nil {
|
|
||||||
return c.baseRepo, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
remotes, err := c.Remotes()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
rem, err := remotes.FindByName("upstream", "github", "origin", "*")
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
c.baseRepo = rem
|
|
||||||
return c.baseRepo, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *fsContext) SetBaseRepo(nwo string) {
|
|
||||||
c.baseRepo, _ = ghrepo.FromFullName(nwo)
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -76,7 +76,7 @@ func (r Remote) RepoHost() string {
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: accept an interface instead of git.RemoteSet
|
// TODO: accept an interface instead of git.RemoteSet
|
||||||
func translateRemotes(gitRemotes git.RemoteSet, urlTranslate func(*url.URL) *url.URL) (remotes Remotes) {
|
func TranslateRemotes(gitRemotes git.RemoteSet, urlTranslate func(*url.URL) *url.URL) (remotes Remotes) {
|
||||||
for _, r := range gitRemotes {
|
for _, r := range gitRemotes {
|
||||||
var repo ghrepo.Interface
|
var repo ghrepo.Interface
|
||||||
if r.FetchURL != nil {
|
if r.FetchURL != nil {
|
||||||
|
|
|
||||||
|
|
@ -57,7 +57,7 @@ func Test_translateRemotes(t *testing.T) {
|
||||||
identityURL := func(u *url.URL) *url.URL {
|
identityURL := func(u *url.URL) *url.URL {
|
||||||
return u
|
return u
|
||||||
}
|
}
|
||||||
result := translateRemotes(gitRemotes, identityURL)
|
result := TranslateRemotes(gitRemotes, identityURL)
|
||||||
|
|
||||||
if len(result) != 1 {
|
if len(result) != 1 {
|
||||||
t.Errorf("got %d results", len(result))
|
t.Errorf("got %d results", len(result))
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# Installation from source
|
# Installation from source
|
||||||
|
|
||||||
0. Verify that you have Go 1.13.8+ installed
|
0. Verify that you have Go 1.15+ installed
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
$ go version
|
$ go version
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,15 @@ var remoteRE = regexp.MustCompile(`(.+)\s+(.+)\s+\((push|fetch)\)`)
|
||||||
// RemoteSet is a slice of git remotes
|
// RemoteSet is a slice of git remotes
|
||||||
type RemoteSet []*Remote
|
type RemoteSet []*Remote
|
||||||
|
|
||||||
|
func NewRemote(name string, u string) *Remote {
|
||||||
|
pu, _ := url.Parse(u)
|
||||||
|
return &Remote{
|
||||||
|
Name: name,
|
||||||
|
FetchURL: pu,
|
||||||
|
PushURL: pu,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Remote is a parsed git remote
|
// Remote is a parsed git remote
|
||||||
type Remote struct {
|
type Remote struct {
|
||||||
Name string
|
Name string
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,10 @@ var (
|
||||||
protocolRe = regexp.MustCompile("^[a-zA-Z_+-]+://")
|
protocolRe = regexp.MustCompile("^[a-zA-Z_+-]+://")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func IsURL(u string) bool {
|
||||||
|
return strings.HasPrefix(u, "git@") || protocolRe.MatchString(u)
|
||||||
|
}
|
||||||
|
|
||||||
// ParseURL normalizes git remote urls
|
// ParseURL normalizes git remote urls
|
||||||
func ParseURL(rawURL string) (u *url.URL, err error) {
|
func ParseURL(rawURL string) (u *url.URL, err error) {
|
||||||
if !protocolRe.MatchString(rawURL) &&
|
if !protocolRe.MatchString(rawURL) &&
|
||||||
|
|
|
||||||
|
|
@ -75,22 +75,30 @@ func parseConfigFile(filename string) ([]byte, *yaml.Node, error) {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
var root yaml.Node
|
root, err := parseConfigData(data)
|
||||||
err = yaml.Unmarshal(data, &root)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return data, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
|
return data, root, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseConfigData(data []byte) (*yaml.Node, error) {
|
||||||
|
var root yaml.Node
|
||||||
|
err := yaml.Unmarshal(data, &root)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
if len(root.Content) == 0 {
|
if len(root.Content) == 0 {
|
||||||
return data, &yaml.Node{
|
return &yaml.Node{
|
||||||
Kind: yaml.DocumentNode,
|
Kind: yaml.DocumentNode,
|
||||||
Content: []*yaml.Node{{Kind: yaml.MappingNode}},
|
Content: []*yaml.Node{{Kind: yaml.MappingNode}},
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
if root.Content[0].Kind != yaml.MappingNode {
|
if root.Content[0].Kind != yaml.MappingNode {
|
||||||
return data, &root, fmt.Errorf("expected a top level map")
|
return &root, fmt.Errorf("expected a top level map")
|
||||||
}
|
}
|
||||||
|
return &root, nil
|
||||||
return data, &root, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func isLegacy(root *yaml.Node) bool {
|
func isLegacy(root *yaml.Node) bool {
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"reflect"
|
"reflect"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
@ -71,17 +70,29 @@ github.com:
|
||||||
eq(t, token, "OTOKEN")
|
eq(t, token, "OTOKEN")
|
||||||
}
|
}
|
||||||
|
|
||||||
func Test_parseConfig_notFound(t *testing.T) {
|
func Test_parseConfig_hostFallback(t *testing.T) {
|
||||||
defer StubConfig(`---
|
defer StubConfig(`---
|
||||||
hosts:
|
git_protocol: ssh
|
||||||
example.com:
|
`, `---
|
||||||
|
github.com:
|
||||||
|
user: monalisa
|
||||||
|
oauth_token: OTOKEN
|
||||||
|
example.com:
|
||||||
user: wronguser
|
user: wronguser
|
||||||
oauth_token: NOTTHIS
|
oauth_token: NOTTHIS
|
||||||
`, "")()
|
git_protocol: https
|
||||||
|
`)()
|
||||||
config, err := ParseConfig("config.yml")
|
config, err := ParseConfig("config.yml")
|
||||||
eq(t, err, nil)
|
eq(t, err, nil)
|
||||||
_, err = config.Get("github.com", "user")
|
val, err := config.Get("example.com", "git_protocol")
|
||||||
eq(t, err, &NotFoundError{errors.New(`could not find config entry for "github.com"`)})
|
eq(t, err, nil)
|
||||||
|
eq(t, val, "https")
|
||||||
|
val, err = config.Get("github.com", "git_protocol")
|
||||||
|
eq(t, err, nil)
|
||||||
|
eq(t, val, "ssh")
|
||||||
|
val, err = config.Get("nonexist.io", "git_protocol")
|
||||||
|
eq(t, err, nil)
|
||||||
|
eq(t, val, "ssh")
|
||||||
}
|
}
|
||||||
|
|
||||||
func Test_ParseConfig_migrateConfig(t *testing.T) {
|
func Test_ParseConfig_migrateConfig(t *testing.T) {
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import (
|
||||||
|
|
||||||
"github.com/cli/cli/api"
|
"github.com/cli/cli/api"
|
||||||
"github.com/cli/cli/auth"
|
"github.com/cli/cli/auth"
|
||||||
|
"github.com/cli/cli/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
|
@ -25,8 +26,8 @@ func IsGitHubApp(id string) bool {
|
||||||
return id == "178c6fc778ccc68e1d6a" || id == "4d747ba5675d5d66553f"
|
return id == "178c6fc778ccc68e1d6a" || id == "4d747ba5675d5d66553f"
|
||||||
}
|
}
|
||||||
|
|
||||||
func AuthFlowWithConfig(cfg Config, hostname, notice string) (string, error) {
|
func AuthFlowWithConfig(cfg Config, hostname, notice string, additionalScopes []string) (string, error) {
|
||||||
token, userLogin, err := authFlow(hostname, notice)
|
token, userLogin, err := authFlow(hostname, notice, additionalScopes)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
@ -49,17 +50,20 @@ func AuthFlowWithConfig(cfg Config, hostname, notice string) (string, error) {
|
||||||
return token, nil
|
return token, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func authFlow(oauthHost, notice string) (string, string, error) {
|
func authFlow(oauthHost, notice string, additionalScopes []string) (string, string, error) {
|
||||||
var verboseStream io.Writer
|
var verboseStream io.Writer
|
||||||
if strings.Contains(os.Getenv("DEBUG"), "oauth") {
|
if strings.Contains(os.Getenv("DEBUG"), "oauth") {
|
||||||
verboseStream = os.Stderr
|
verboseStream = os.Stderr
|
||||||
}
|
}
|
||||||
|
|
||||||
|
minimumScopes := []string{"repo", "read:org", "gist"}
|
||||||
|
scopes := append(minimumScopes, additionalScopes...)
|
||||||
|
|
||||||
flow := &auth.OAuthFlow{
|
flow := &auth.OAuthFlow{
|
||||||
Hostname: oauthHost,
|
Hostname: oauthHost,
|
||||||
ClientID: oauthClientID,
|
ClientID: oauthClientID,
|
||||||
ClientSecret: oauthClientSecret,
|
ClientSecret: oauthClientSecret,
|
||||||
Scopes: []string{"repo", "read:org", "gist"},
|
Scopes: scopes,
|
||||||
WriteSuccessHTML: func(w io.Writer) {
|
WriteSuccessHTML: func(w io.Writer) {
|
||||||
fmt.Fprintln(w, oauthSuccessPage)
|
fmt.Fprintln(w, oauthSuccessPage)
|
||||||
},
|
},
|
||||||
|
|
@ -67,14 +71,14 @@ func authFlow(oauthHost, notice string) (string, string, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Fprintln(os.Stderr, notice)
|
fmt.Fprintln(os.Stderr, notice)
|
||||||
fmt.Fprintf(os.Stderr, "Press Enter to open %s in your browser... ", flow.Hostname)
|
fmt.Fprintf(os.Stderr, "- %s to open %s in your browser... ", utils.Bold("Press Enter"), flow.Hostname)
|
||||||
_ = waitForEnter(os.Stdin)
|
_ = waitForEnter(os.Stdin)
|
||||||
token, err := flow.ObtainAccessToken()
|
token, err := flow.ObtainAccessToken()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", err
|
return "", "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
userLogin, err := getViewer(token)
|
userLogin, err := getViewer(oauthHost, token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", err
|
return "", "", err
|
||||||
}
|
}
|
||||||
|
|
@ -83,13 +87,14 @@ func authFlow(oauthHost, notice string) (string, string, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func AuthFlowComplete() {
|
func AuthFlowComplete() {
|
||||||
fmt.Fprintln(os.Stderr, "Authentication complete. Press Enter to continue... ")
|
fmt.Fprintf(os.Stderr, "%s Authentication complete. %s to continue...\n",
|
||||||
|
utils.GreenCheck(), utils.Bold("Press Enter"))
|
||||||
_ = waitForEnter(os.Stdin)
|
_ = waitForEnter(os.Stdin)
|
||||||
}
|
}
|
||||||
|
|
||||||
func getViewer(token string) (string, error) {
|
func getViewer(hostname, token string) (string, error) {
|
||||||
http := api.NewClient(api.AddHeader("Authorization", fmt.Sprintf("token %s", token)))
|
http := api.NewClient(api.AddHeader("Authorization", fmt.Sprintf("token %s", token)))
|
||||||
return api.CurrentLoginName(http)
|
return api.CurrentLoginName(http, hostname)
|
||||||
}
|
}
|
||||||
|
|
||||||
func waitForEnter(r io.Reader) error {
|
func waitForEnter(r io.Reader) error {
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,9 @@ import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
|
||||||
|
"github.com/cli/cli/internal/ghinstance"
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -14,6 +16,8 @@ const defaultGitProtocol = "https"
|
||||||
type Config interface {
|
type Config interface {
|
||||||
Get(string, string) (string, error)
|
Get(string, string) (string, error)
|
||||||
Set(string, string, string) error
|
Set(string, string, string) error
|
||||||
|
UnsetHost(string)
|
||||||
|
Hosts() ([]string, error)
|
||||||
Aliases() (*AliasConfig, error)
|
Aliases() (*AliasConfig, error)
|
||||||
Write() error
|
Write() error
|
||||||
}
|
}
|
||||||
|
|
@ -29,7 +33,7 @@ type HostConfig struct {
|
||||||
|
|
||||||
// This type implements a low-level get/set config that is backed by an in-memory tree of Yaml
|
// This type implements a low-level get/set config that is backed by an in-memory tree of Yaml
|
||||||
// nodes. It allows us to interact with a yaml-based config programmatically, preserving any
|
// nodes. It allows us to interact with a yaml-based config programmatically, preserving any
|
||||||
// comments that were present when the yaml waas parsed.
|
// comments that were present when the yaml was parsed.
|
||||||
type ConfigMap struct {
|
type ConfigMap struct {
|
||||||
Root *yaml.Node
|
Root *yaml.Node
|
||||||
}
|
}
|
||||||
|
|
@ -122,6 +126,16 @@ func NewConfig(root *yaml.Node) Config {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NewFromString initializes a Config from a yaml string
|
||||||
|
func NewFromString(str string) Config {
|
||||||
|
root, err := parseConfigData([]byte(str))
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return NewConfig(root)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewBlankConfig initializes a config file pre-populated with comments and default values
|
||||||
func NewBlankConfig() Config {
|
func NewBlankConfig() Config {
|
||||||
return NewConfig(NewBlankRoot())
|
return NewConfig(NewBlankRoot())
|
||||||
}
|
}
|
||||||
|
|
@ -187,16 +201,19 @@ func (c *fileConfig) Root() *yaml.Node {
|
||||||
|
|
||||||
func (c *fileConfig) Get(hostname, key string) (string, error) {
|
func (c *fileConfig) Get(hostname, key string) (string, error) {
|
||||||
if hostname != "" {
|
if hostname != "" {
|
||||||
|
var notFound *NotFoundError
|
||||||
|
|
||||||
hostCfg, err := c.configForHost(hostname)
|
hostCfg, err := c.configForHost(hostname)
|
||||||
if err != nil {
|
if err != nil && !errors.As(err, ¬Found) {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
hostValue, err := hostCfg.GetStringValue(key)
|
var hostValue string
|
||||||
var notFound *NotFoundError
|
if hostCfg != nil {
|
||||||
|
hostValue, err = hostCfg.GetStringValue(key)
|
||||||
if err != nil && !errors.As(err, ¬Found) {
|
if err != nil && !errors.As(err, ¬Found) {
|
||||||
return "", err
|
return "", err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if hostValue != "" {
|
if hostValue != "" {
|
||||||
|
|
@ -236,6 +253,20 @@ func (c *fileConfig) Set(hostname, key, value string) error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *fileConfig) UnsetHost(hostname string) {
|
||||||
|
if hostname == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
hostsEntry, err := c.FindEntry("hosts")
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cm := ConfigMap{hostsEntry.ValueNode}
|
||||||
|
cm.RemoveEntry(hostname)
|
||||||
|
}
|
||||||
|
|
||||||
func (c *fileConfig) configForHost(hostname string) (*HostConfig, error) {
|
func (c *fileConfig) configForHost(hostname string) (*HostConfig, error) {
|
||||||
hosts, err := c.hostEntries()
|
hosts, err := c.hostEntries()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -357,6 +388,23 @@ func (c *fileConfig) hostEntries() ([]*HostConfig, error) {
|
||||||
return hostConfigs, nil
|
return hostConfigs, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Hosts returns a list of all known hostnames configured in hosts.yml
|
||||||
|
func (c *fileConfig) Hosts() ([]string, error) {
|
||||||
|
entries, err := c.hostEntries()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
hostnames := []string{}
|
||||||
|
for _, entry := range entries {
|
||||||
|
hostnames = append(hostnames, entry.Host)
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.SliceStable(hostnames, func(i, j int) bool { return hostnames[i] == ghinstance.Default() })
|
||||||
|
|
||||||
|
return hostnames, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (c *fileConfig) makeConfigForHost(hostname string) *HostConfig {
|
func (c *fileConfig) makeConfigForHost(hostname string) *HostConfig {
|
||||||
hostRoot := &yaml.Node{Kind: yaml.MappingNode}
|
hostRoot := &yaml.Node{Kind: yaml.MappingNode}
|
||||||
hostCfg := &HostConfig{
|
hostCfg := &HostConfig{
|
||||||
|
|
|
||||||
57
internal/ghinstance/host.go
Normal file
57
internal/ghinstance/host.go
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
package ghinstance
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
const defaultHostname = "github.com"
|
||||||
|
|
||||||
|
var hostnameOverride string
|
||||||
|
|
||||||
|
// Default returns the host name of the default GitHub instance
|
||||||
|
func Default() string {
|
||||||
|
return defaultHostname
|
||||||
|
}
|
||||||
|
|
||||||
|
// OverridableDefault is like Default, except it is overridable by the GH_HOST environment variable
|
||||||
|
func OverridableDefault() string {
|
||||||
|
if hostnameOverride != "" {
|
||||||
|
return hostnameOverride
|
||||||
|
}
|
||||||
|
return defaultHostname
|
||||||
|
}
|
||||||
|
|
||||||
|
// OverrideDefault overrides the value returned from OverridableDefault. This should only ever be
|
||||||
|
// called from the main runtime path, not tests.
|
||||||
|
func OverrideDefault(newhost string) {
|
||||||
|
hostnameOverride = newhost
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsEnterprise reports whether a non-normalized host name looks like a GHE instance
|
||||||
|
func IsEnterprise(h string) bool {
|
||||||
|
return NormalizeHostname(h) != defaultHostname
|
||||||
|
}
|
||||||
|
|
||||||
|
// NormalizeHostname returns the canonical host name of a GitHub instance
|
||||||
|
func NormalizeHostname(h string) string {
|
||||||
|
hostname := strings.ToLower(h)
|
||||||
|
if strings.HasSuffix(hostname, "."+defaultHostname) {
|
||||||
|
return defaultHostname
|
||||||
|
}
|
||||||
|
return hostname
|
||||||
|
}
|
||||||
|
|
||||||
|
func GraphQLEndpoint(hostname string) string {
|
||||||
|
if IsEnterprise(hostname) {
|
||||||
|
return fmt.Sprintf("https://%s/api/graphql", hostname)
|
||||||
|
}
|
||||||
|
return "https://api.github.com/graphql"
|
||||||
|
}
|
||||||
|
|
||||||
|
func RESTPrefix(hostname string) string {
|
||||||
|
if IsEnterprise(hostname) {
|
||||||
|
return fmt.Sprintf("https://%s/api/v3/", hostname)
|
||||||
|
}
|
||||||
|
return "https://api.github.com/"
|
||||||
|
}
|
||||||
144
internal/ghinstance/host_test.go
Normal file
144
internal/ghinstance/host_test.go
Normal file
|
|
@ -0,0 +1,144 @@
|
||||||
|
package ghinstance
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestOverridableDefault(t *testing.T) {
|
||||||
|
oldOverride := hostnameOverride
|
||||||
|
t.Cleanup(func() {
|
||||||
|
hostnameOverride = oldOverride
|
||||||
|
})
|
||||||
|
|
||||||
|
host := OverridableDefault()
|
||||||
|
if host != "github.com" {
|
||||||
|
t.Errorf("expected github.com, got %q", host)
|
||||||
|
}
|
||||||
|
|
||||||
|
OverrideDefault("example.org")
|
||||||
|
|
||||||
|
host = OverridableDefault()
|
||||||
|
if host != "example.org" {
|
||||||
|
t.Errorf("expected example.org, got %q", host)
|
||||||
|
}
|
||||||
|
host = Default()
|
||||||
|
if host != "github.com" {
|
||||||
|
t.Errorf("expected github.com, got %q", host)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsEnterprise(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
host string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
host: "github.com",
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
host: "api.github.com",
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
host: "ghe.io",
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
host: "example.com",
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.host, func(t *testing.T) {
|
||||||
|
if got := IsEnterprise(tt.host); got != tt.want {
|
||||||
|
t.Errorf("IsEnterprise() = %v, want %v", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeHostname(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
host string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
host: "GitHub.com",
|
||||||
|
want: "github.com",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
host: "api.github.com",
|
||||||
|
want: "github.com",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
host: "ssh.github.com",
|
||||||
|
want: "github.com",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
host: "upload.github.com",
|
||||||
|
want: "github.com",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
host: "GHE.IO",
|
||||||
|
want: "ghe.io",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
host: "git.my.org",
|
||||||
|
want: "git.my.org",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.host, func(t *testing.T) {
|
||||||
|
if got := NormalizeHostname(tt.host); got != tt.want {
|
||||||
|
t.Errorf("NormalizeHostname() = %v, want %v", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGraphQLEndpoint(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
host string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
host: "github.com",
|
||||||
|
want: "https://api.github.com/graphql",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
host: "ghe.io",
|
||||||
|
want: "https://ghe.io/api/graphql",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.host, func(t *testing.T) {
|
||||||
|
if got := GraphQLEndpoint(tt.host); got != tt.want {
|
||||||
|
t.Errorf("GraphQLEndpoint() = %v, want %v", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRESTPrefix(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
host string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
host: "github.com",
|
||||||
|
want: "https://api.github.com/",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
host: "ghe.io",
|
||||||
|
want: "https://ghe.io/api/v3/",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.host, func(t *testing.T) {
|
||||||
|
if got := RESTPrefix(tt.host); got != tt.want {
|
||||||
|
t.Errorf("RESTPrefix() = %v, want %v", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,9 +4,10 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
|
||||||
|
|
||||||
const defaultHostname = "github.com"
|
"github.com/cli/cli/git"
|
||||||
|
"github.com/cli/cli/internal/ghinstance"
|
||||||
|
)
|
||||||
|
|
||||||
// Interface describes an object that represents a GitHub repository
|
// Interface describes an object that represents a GitHub repository
|
||||||
type Interface interface {
|
type Interface interface {
|
||||||
|
|
@ -17,10 +18,7 @@ type Interface interface {
|
||||||
|
|
||||||
// New instantiates a GitHub repository from owner and name arguments
|
// New instantiates a GitHub repository from owner and name arguments
|
||||||
func New(owner, repo string) Interface {
|
func New(owner, repo string) Interface {
|
||||||
return &ghRepo{
|
return NewWithHost(owner, repo, ghinstance.OverridableDefault())
|
||||||
owner: owner,
|
|
||||||
name: repo,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewWithHost is like New with an explicit host name
|
// NewWithHost is like New with an explicit host name
|
||||||
|
|
@ -28,7 +26,7 @@ func NewWithHost(owner, repo, hostname string) Interface {
|
||||||
return &ghRepo{
|
return &ghRepo{
|
||||||
owner: owner,
|
owner: owner,
|
||||||
name: repo,
|
name: repo,
|
||||||
hostname: hostname,
|
hostname: normalizeHostname(hostname),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -37,15 +35,31 @@ func FullName(r Interface) string {
|
||||||
return fmt.Sprintf("%s/%s", r.RepoOwner(), r.RepoName())
|
return fmt.Sprintf("%s/%s", r.RepoOwner(), r.RepoName())
|
||||||
}
|
}
|
||||||
|
|
||||||
// FromFullName extracts the GitHub repository information from an "OWNER/REPO" string
|
// FromFullName extracts the GitHub repository information from the following
|
||||||
|
// formats: "OWNER/REPO", "HOST/OWNER/REPO", and a full URL.
|
||||||
func FromFullName(nwo string) (Interface, error) {
|
func FromFullName(nwo string) (Interface, error) {
|
||||||
var r ghRepo
|
if git.IsURL(nwo) {
|
||||||
parts := strings.SplitN(nwo, "/", 2)
|
u, err := git.ParseURL(nwo)
|
||||||
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
|
if err != nil {
|
||||||
return &r, fmt.Errorf("expected OWNER/REPO format, got %q", nwo)
|
return nil, err
|
||||||
|
}
|
||||||
|
return FromURL(u)
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := strings.SplitN(nwo, "/", 4)
|
||||||
|
for _, p := range parts {
|
||||||
|
if len(p) == 0 {
|
||||||
|
return nil, fmt.Errorf(`expected the "[HOST/]OWNER/REPO" format, got %q`, nwo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch len(parts) {
|
||||||
|
case 3:
|
||||||
|
return NewWithHost(parts[1], parts[2], normalizeHostname(parts[0])), nil
|
||||||
|
case 2:
|
||||||
|
return New(parts[0], parts[1]), nil
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf(`expected the "[HOST/]OWNER/REPO" format, got %q`, nwo)
|
||||||
}
|
}
|
||||||
r.owner, r.name = parts[0], parts[1]
|
|
||||||
return &r, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// FromURL extracts the GitHub repository information from a git remote URL
|
// FromURL extracts the GitHub repository information from a git remote URL
|
||||||
|
|
@ -59,11 +73,7 @@ func FromURL(u *url.URL) (Interface, error) {
|
||||||
return nil, fmt.Errorf("invalid path: %s", u.Path)
|
return nil, fmt.Errorf("invalid path: %s", u.Path)
|
||||||
}
|
}
|
||||||
|
|
||||||
return &ghRepo{
|
return NewWithHost(parts[0], strings.TrimSuffix(parts[1], ".git"), u.Hostname()), nil
|
||||||
owner: parts[0],
|
|
||||||
name: strings.TrimSuffix(parts[1], ".git"),
|
|
||||||
hostname: normalizeHostname(u.Hostname()),
|
|
||||||
}, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func normalizeHostname(h string) string {
|
func normalizeHostname(h string) string {
|
||||||
|
|
@ -109,8 +119,5 @@ func (r ghRepo) RepoName() string {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r ghRepo) RepoHost() string {
|
func (r ghRepo) RepoHost() string {
|
||||||
if r.hostname != "" {
|
return r.hostname
|
||||||
return r.hostname
|
|
||||||
}
|
|
||||||
return defaultHostname
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -114,3 +114,87 @@ func Test_repoFromURL(t *testing.T) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestFromFullName(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
wantOwner string
|
||||||
|
wantName string
|
||||||
|
wantHost string
|
||||||
|
wantErr error
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "OWNER/REPO combo",
|
||||||
|
input: "OWNER/REPO",
|
||||||
|
wantHost: "github.com",
|
||||||
|
wantOwner: "OWNER",
|
||||||
|
wantName: "REPO",
|
||||||
|
wantErr: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "too few elements",
|
||||||
|
input: "OWNER",
|
||||||
|
wantErr: errors.New(`expected the "[HOST/]OWNER/REPO" format, got "OWNER"`),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "too many elements",
|
||||||
|
input: "a/b/c/d",
|
||||||
|
wantErr: errors.New(`expected the "[HOST/]OWNER/REPO" format, got "a/b/c/d"`),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "blank value",
|
||||||
|
input: "a/",
|
||||||
|
wantErr: errors.New(`expected the "[HOST/]OWNER/REPO" format, got "a/"`),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "with hostname",
|
||||||
|
input: "example.org/OWNER/REPO",
|
||||||
|
wantHost: "example.org",
|
||||||
|
wantOwner: "OWNER",
|
||||||
|
wantName: "REPO",
|
||||||
|
wantErr: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "full URL",
|
||||||
|
input: "https://example.org/OWNER/REPO.git",
|
||||||
|
wantHost: "example.org",
|
||||||
|
wantOwner: "OWNER",
|
||||||
|
wantName: "REPO",
|
||||||
|
wantErr: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "SSH URL",
|
||||||
|
input: "git@example.org:OWNER/REPO.git",
|
||||||
|
wantHost: "example.org",
|
||||||
|
wantOwner: "OWNER",
|
||||||
|
wantName: "REPO",
|
||||||
|
wantErr: nil,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
r, err := FromFullName(tt.input)
|
||||||
|
if tt.wantErr != nil {
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("no error in result, expected %v", tt.wantErr)
|
||||||
|
} else if err.Error() != tt.wantErr.Error() {
|
||||||
|
t.Fatalf("expected error %q, got %q", tt.wantErr.Error(), err.Error())
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("got error %v", err)
|
||||||
|
}
|
||||||
|
if r.RepoHost() != tt.wantHost {
|
||||||
|
t.Errorf("expected host %q, got %q", tt.wantHost, r.RepoHost())
|
||||||
|
}
|
||||||
|
if r.RepoOwner() != tt.wantOwner {
|
||||||
|
t.Errorf("expected owner %q, got %q", tt.wantOwner, r.RepoOwner())
|
||||||
|
}
|
||||||
|
if r.RepoName() != tt.wantName {
|
||||||
|
t.Errorf("expected name %q, got %q", tt.wantName, r.RepoName())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,20 +20,21 @@ func Command(url string) (*exec.Cmd, error) {
|
||||||
|
|
||||||
// ForOS produces an exec.Cmd to open the web browser for different OS
|
// ForOS produces an exec.Cmd to open the web browser for different OS
|
||||||
func ForOS(goos, url string) *exec.Cmd {
|
func ForOS(goos, url string) *exec.Cmd {
|
||||||
|
exe := "open"
|
||||||
var args []string
|
var args []string
|
||||||
switch goos {
|
switch goos {
|
||||||
case "darwin":
|
case "darwin":
|
||||||
args = []string{"open"}
|
args = append(args, url)
|
||||||
case "windows":
|
case "windows":
|
||||||
args = []string{"cmd", "/c", "start"}
|
exe = "cmd"
|
||||||
r := strings.NewReplacer("&", "^&")
|
r := strings.NewReplacer("&", "^&")
|
||||||
url = r.Replace(url)
|
args = append(args, "/c", "start", r.Replace(url))
|
||||||
default:
|
default:
|
||||||
args = []string{"xdg-open"}
|
exe = "xdg-open"
|
||||||
|
args = append(args, url)
|
||||||
}
|
}
|
||||||
|
|
||||||
args = append(args, url)
|
cmd := exec.Command(exe, args...)
|
||||||
cmd := exec.Command(args[0], args[1:]...)
|
|
||||||
cmd.Stderr = os.Stderr
|
cmd.Stderr = os.Stderr
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
|
||||||
28
pkg/cmd/alias/alias.go
Normal file
28
pkg/cmd/alias/alias.go
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
package alias
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/MakeNowJust/heredoc"
|
||||||
|
deleteCmd "github.com/cli/cli/pkg/cmd/alias/delete"
|
||||||
|
listCmd "github.com/cli/cli/pkg/cmd/alias/list"
|
||||||
|
setCmd "github.com/cli/cli/pkg/cmd/alias/set"
|
||||||
|
"github.com/cli/cli/pkg/cmdutil"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewCmdAlias(f *cmdutil.Factory) *cobra.Command {
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "alias",
|
||||||
|
Short: "Create command shortcuts",
|
||||||
|
Long: heredoc.Doc(`
|
||||||
|
Aliases can be used to make shortcuts for gh commands or to compose multiple commands.
|
||||||
|
|
||||||
|
Run "gh help alias set" to learn more.
|
||||||
|
`),
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.AddCommand(deleteCmd.NewCmdDelete(f, nil))
|
||||||
|
cmd.AddCommand(listCmd.NewCmdList(f, nil))
|
||||||
|
cmd.AddCommand(setCmd.NewCmdSet(f, nil))
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
71
pkg/cmd/alias/delete/delete.go
Normal file
71
pkg/cmd/alias/delete/delete.go
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
package delete
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/cli/cli/internal/config"
|
||||||
|
"github.com/cli/cli/pkg/cmdutil"
|
||||||
|
"github.com/cli/cli/pkg/iostreams"
|
||||||
|
"github.com/cli/cli/utils"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DeleteOptions struct {
|
||||||
|
Config func() (config.Config, error)
|
||||||
|
IO *iostreams.IOStreams
|
||||||
|
|
||||||
|
Name string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCmdDelete(f *cmdutil.Factory, runF func(*DeleteOptions) error) *cobra.Command {
|
||||||
|
opts := &DeleteOptions{
|
||||||
|
IO: f.IOStreams,
|
||||||
|
Config: f.Config,
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "delete <alias>",
|
||||||
|
Short: "Delete an alias",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
opts.Name = args[0]
|
||||||
|
|
||||||
|
if runF != nil {
|
||||||
|
return runF(opts)
|
||||||
|
}
|
||||||
|
return deleteRun(opts)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteRun(opts *DeleteOptions) error {
|
||||||
|
cfg, err := opts.Config()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
aliasCfg, err := cfg.Aliases()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("couldn't read aliases config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
expansion, ok := aliasCfg.Get(opts.Name)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("no such alias %s", opts.Name)
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
err = aliasCfg.Delete(opts.Name)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to delete alias %s: %w", opts.Name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if opts.IO.IsStdoutTTY() {
|
||||||
|
redCheck := utils.Red("✓")
|
||||||
|
fmt.Fprintf(opts.IO.ErrOut, "%s Deleted alias %s; was %s\n", redCheck, opts.Name, expansion)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
90
pkg/cmd/alias/delete/delete_test.go
Normal file
90
pkg/cmd/alias/delete/delete_test.go
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
package delete
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"io/ioutil"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/MakeNowJust/heredoc"
|
||||||
|
"github.com/cli/cli/internal/config"
|
||||||
|
"github.com/cli/cli/pkg/cmdutil"
|
||||||
|
"github.com/cli/cli/pkg/iostreams"
|
||||||
|
"github.com/google/shlex"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAliasDelete(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
config string
|
||||||
|
cli string
|
||||||
|
isTTY bool
|
||||||
|
wantStdout string
|
||||||
|
wantStderr string
|
||||||
|
wantErr string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "no aliases",
|
||||||
|
config: "",
|
||||||
|
cli: "co",
|
||||||
|
isTTY: true,
|
||||||
|
wantStdout: "",
|
||||||
|
wantStderr: "",
|
||||||
|
wantErr: "no such alias co",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "delete one",
|
||||||
|
config: heredoc.Doc(`
|
||||||
|
aliases:
|
||||||
|
il: issue list
|
||||||
|
co: pr checkout
|
||||||
|
`),
|
||||||
|
cli: "co",
|
||||||
|
isTTY: true,
|
||||||
|
wantStdout: "",
|
||||||
|
wantStderr: "✓ Deleted alias co; was pr checkout\n",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
defer config.StubWriteConfig(ioutil.Discard, ioutil.Discard)()
|
||||||
|
|
||||||
|
cfg := config.NewFromString(tt.config)
|
||||||
|
|
||||||
|
io, _, stdout, stderr := iostreams.Test()
|
||||||
|
io.SetStdoutTTY(tt.isTTY)
|
||||||
|
io.SetStdinTTY(tt.isTTY)
|
||||||
|
io.SetStderrTTY(tt.isTTY)
|
||||||
|
|
||||||
|
factory := &cmdutil.Factory{
|
||||||
|
IOStreams: io,
|
||||||
|
Config: func() (config.Config, error) {
|
||||||
|
return cfg, nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := NewCmdDelete(factory, nil)
|
||||||
|
|
||||||
|
argv, err := shlex.Split(tt.cli)
|
||||||
|
require.NoError(t, err)
|
||||||
|
cmd.SetArgs(argv)
|
||||||
|
|
||||||
|
cmd.SetIn(&bytes.Buffer{})
|
||||||
|
cmd.SetOut(ioutil.Discard)
|
||||||
|
cmd.SetErr(ioutil.Discard)
|
||||||
|
|
||||||
|
_, err = cmd.ExecuteC()
|
||||||
|
if tt.wantErr != "" {
|
||||||
|
if assert.Error(t, err) {
|
||||||
|
assert.Equal(t, tt.wantErr, err.Error())
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, tt.wantStdout, stdout.String())
|
||||||
|
assert.Equal(t, tt.wantStderr, stderr.String())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
106
pkg/cmd/alias/expand/expand.go
Normal file
106
pkg/cmd/alias/expand/expand.go
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
package expand
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/cli/cli/internal/config"
|
||||||
|
"github.com/google/shlex"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ExpandAlias processes argv to see if it should be rewritten according to a user's aliases. The
|
||||||
|
// second return value indicates whether the alias should be executed in a new shell process instead
|
||||||
|
// of running gh itself.
|
||||||
|
func ExpandAlias(cfg config.Config, args []string, findShFunc func() (string, error)) (expanded []string, isShell bool, err error) {
|
||||||
|
if len(args) < 2 {
|
||||||
|
// the command is lacking a subcommand
|
||||||
|
return
|
||||||
|
}
|
||||||
|
expanded = args[1:]
|
||||||
|
|
||||||
|
aliases, err := cfg.Aliases()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
expansion, ok := aliases.Get(args[1])
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(expansion, "!") {
|
||||||
|
isShell = true
|
||||||
|
if findShFunc == nil {
|
||||||
|
findShFunc = findSh
|
||||||
|
}
|
||||||
|
shPath, shErr := findShFunc()
|
||||||
|
if shErr != nil {
|
||||||
|
err = shErr
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
expanded = []string{shPath, "-c", expansion[1:]}
|
||||||
|
|
||||||
|
if len(args[2:]) > 0 {
|
||||||
|
expanded = append(expanded, "--")
|
||||||
|
expanded = append(expanded, args[2:]...)
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
err = fmt.Errorf("not enough arguments for alias: %s", expansion)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var newArgs []string
|
||||||
|
newArgs, err = shlex.Split(expansion)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
expanded = append(newArgs, extraArgs...)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func findSh() (string, error) {
|
||||||
|
shPath, err := exec.LookPath("sh")
|
||||||
|
if err == nil {
|
||||||
|
return shPath, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
winNotFoundErr := errors.New("unable to locate sh to execute the shell alias with. The sh.exe interpreter is typically distributed with Git for Windows.")
|
||||||
|
// We can try and find a sh executable in a Git for Windows install
|
||||||
|
gitPath, err := exec.LookPath("git")
|
||||||
|
if err != nil {
|
||||||
|
return "", winNotFoundErr
|
||||||
|
}
|
||||||
|
|
||||||
|
shPath = filepath.Join(filepath.Dir(gitPath), "..", "bin", "sh.exe")
|
||||||
|
_, err = os.Stat(shPath)
|
||||||
|
if err != nil {
|
||||||
|
return "", winNotFoundErr
|
||||||
|
}
|
||||||
|
|
||||||
|
return shPath, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", errors.New("unable to locate sh to execute shell alias with")
|
||||||
|
}
|
||||||
185
pkg/cmd/alias/expand/expand_test.go
Normal file
185
pkg/cmd/alias/expand/expand_test.go
Normal file
|
|
@ -0,0 +1,185 @@
|
||||||
|
package expand
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/MakeNowJust/heredoc"
|
||||||
|
"github.com/cli/cli/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestExpandAlias(t *testing.T) {
|
||||||
|
findShFunc := func() (string, error) {
|
||||||
|
return "/usr/bin/sh", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := config.NewFromString(heredoc.Doc(`
|
||||||
|
aliases:
|
||||||
|
co: pr checkout
|
||||||
|
il: issue list --author="$1" --label="$2"
|
||||||
|
ia: issue list --author="$1" --assignee="$1"
|
||||||
|
`))
|
||||||
|
|
||||||
|
type args struct {
|
||||||
|
config config.Config
|
||||||
|
argv []string
|
||||||
|
}
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
args args
|
||||||
|
wantExpanded []string
|
||||||
|
wantIsShell bool
|
||||||
|
wantErr error
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "no arguments",
|
||||||
|
args: args{
|
||||||
|
config: cfg,
|
||||||
|
argv: []string{},
|
||||||
|
},
|
||||||
|
wantExpanded: []string(nil),
|
||||||
|
wantIsShell: false,
|
||||||
|
wantErr: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "too few arguments",
|
||||||
|
args: args{
|
||||||
|
config: cfg,
|
||||||
|
argv: []string{"gh"},
|
||||||
|
},
|
||||||
|
wantExpanded: []string(nil),
|
||||||
|
wantIsShell: false,
|
||||||
|
wantErr: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no expansion",
|
||||||
|
args: args{
|
||||||
|
config: cfg,
|
||||||
|
argv: []string{"gh", "pr", "status"},
|
||||||
|
},
|
||||||
|
wantExpanded: []string{"pr", "status"},
|
||||||
|
wantIsShell: false,
|
||||||
|
wantErr: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "simple expansion",
|
||||||
|
args: args{
|
||||||
|
config: cfg,
|
||||||
|
argv: []string{"gh", "co"},
|
||||||
|
},
|
||||||
|
wantExpanded: []string{"pr", "checkout"},
|
||||||
|
wantIsShell: false,
|
||||||
|
wantErr: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "adding arguments after expansion",
|
||||||
|
args: args{
|
||||||
|
config: cfg,
|
||||||
|
argv: []string{"gh", "co", "123"},
|
||||||
|
},
|
||||||
|
wantExpanded: []string{"pr", "checkout", "123"},
|
||||||
|
wantIsShell: false,
|
||||||
|
wantErr: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "not enough arguments for expansion",
|
||||||
|
args: args{
|
||||||
|
config: cfg,
|
||||||
|
argv: []string{"gh", "il"},
|
||||||
|
},
|
||||||
|
wantExpanded: []string{},
|
||||||
|
wantIsShell: false,
|
||||||
|
wantErr: errors.New(`not enough arguments for alias: issue list --author="$1" --label="$2"`),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "not enough arguments for expansion 2",
|
||||||
|
args: args{
|
||||||
|
config: cfg,
|
||||||
|
argv: []string{"gh", "il", "vilmibm"},
|
||||||
|
},
|
||||||
|
wantExpanded: []string{},
|
||||||
|
wantIsShell: false,
|
||||||
|
wantErr: errors.New(`not enough arguments for alias: issue list --author="vilmibm" --label="$2"`),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "satisfy expansion arguments",
|
||||||
|
args: args{
|
||||||
|
config: cfg,
|
||||||
|
argv: []string{"gh", "il", "vilmibm", "help wanted"},
|
||||||
|
},
|
||||||
|
wantExpanded: []string{"issue", "list", "--author=vilmibm", "--label=help wanted"},
|
||||||
|
wantIsShell: false,
|
||||||
|
wantErr: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mixed positional and non-positional arguments",
|
||||||
|
args: args{
|
||||||
|
config: cfg,
|
||||||
|
argv: []string{"gh", "il", "vilmibm", "epic", "-R", "monalisa/testing"},
|
||||||
|
},
|
||||||
|
wantExpanded: []string{"issue", "list", "--author=vilmibm", "--label=epic", "-R", "monalisa/testing"},
|
||||||
|
wantIsShell: false,
|
||||||
|
wantErr: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "dollar in expansion",
|
||||||
|
args: args{
|
||||||
|
config: cfg,
|
||||||
|
argv: []string{"gh", "ia", "$coolmoney$"},
|
||||||
|
},
|
||||||
|
wantExpanded: []string{"issue", "list", "--author=$coolmoney$", "--assignee=$coolmoney$"},
|
||||||
|
wantIsShell: false,
|
||||||
|
wantErr: nil,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
gotExpanded, gotIsShell, err := ExpandAlias(tt.args.config, tt.args.argv, findShFunc)
|
||||||
|
if tt.wantErr != nil {
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error")
|
||||||
|
}
|
||||||
|
if tt.wantErr.Error() != err.Error() {
|
||||||
|
t.Fatalf("expected error %q, got %q", tt.wantErr, err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("got error: %v", err)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(gotExpanded, tt.wantExpanded) {
|
||||||
|
t.Errorf("ExpandAlias() gotExpanded = %v, want %v", gotExpanded, tt.wantExpanded)
|
||||||
|
}
|
||||||
|
if gotIsShell != tt.wantIsShell {
|
||||||
|
t.Errorf("ExpandAlias() gotIsShell = %v, want %v", gotIsShell, tt.wantIsShell)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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{}, ""},
|
||||||
|
// } {
|
||||||
83
pkg/cmd/alias/list/list.go
Normal file
83
pkg/cmd/alias/list/list.go
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
package list
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
|
||||||
|
"github.com/MakeNowJust/heredoc"
|
||||||
|
"github.com/cli/cli/internal/config"
|
||||||
|
"github.com/cli/cli/pkg/cmdutil"
|
||||||
|
"github.com/cli/cli/pkg/iostreams"
|
||||||
|
"github.com/cli/cli/utils"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ListOptions struct {
|
||||||
|
Config func() (config.Config, error)
|
||||||
|
IO *iostreams.IOStreams
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Command {
|
||||||
|
opts := &ListOptions{
|
||||||
|
IO: f.IOStreams,
|
||||||
|
Config: f.Config,
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "list",
|
||||||
|
Short: "List your aliases",
|
||||||
|
Long: heredoc.Doc(`
|
||||||
|
This command prints out all of the aliases gh is configured to use.
|
||||||
|
`),
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
if runF != nil {
|
||||||
|
return runF(opts)
|
||||||
|
}
|
||||||
|
return listRun(opts)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func listRun(opts *ListOptions) error {
|
||||||
|
cfg, err := opts.Config()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
aliasCfg, err := cfg.Aliases()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("couldn't read aliases config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if aliasCfg.Empty() {
|
||||||
|
if opts.IO.IsStdoutTTY() {
|
||||||
|
fmt.Fprintf(opts.IO.ErrOut, "no aliases configured\n")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
tp := utils.NewTablePrinter(opts.IO)
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
77
pkg/cmd/alias/list/list_test.go
Normal file
77
pkg/cmd/alias/list/list_test.go
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
package list
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"io/ioutil"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/MakeNowJust/heredoc"
|
||||||
|
"github.com/cli/cli/internal/config"
|
||||||
|
"github.com/cli/cli/pkg/cmdutil"
|
||||||
|
"github.com/cli/cli/pkg/iostreams"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAliasList(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
config string
|
||||||
|
isTTY bool
|
||||||
|
wantStdout string
|
||||||
|
wantStderr string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "empty",
|
||||||
|
config: "",
|
||||||
|
isTTY: true,
|
||||||
|
wantStdout: "",
|
||||||
|
wantStderr: "no aliases configured\n",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "some",
|
||||||
|
config: heredoc.Doc(`
|
||||||
|
aliases:
|
||||||
|
co: pr checkout
|
||||||
|
gc: "!gh gist create \"$@\" | pbcopy"
|
||||||
|
`),
|
||||||
|
isTTY: true,
|
||||||
|
wantStdout: "co: pr checkout\ngc: !gh gist create \"$@\" | pbcopy\n",
|
||||||
|
wantStderr: "",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
// TODO: change underlying config implementation so Write is not
|
||||||
|
// automatically called when editing aliases in-memory
|
||||||
|
defer config.StubWriteConfig(ioutil.Discard, ioutil.Discard)()
|
||||||
|
|
||||||
|
cfg := config.NewFromString(tt.config)
|
||||||
|
|
||||||
|
io, _, stdout, stderr := iostreams.Test()
|
||||||
|
io.SetStdoutTTY(tt.isTTY)
|
||||||
|
io.SetStdinTTY(tt.isTTY)
|
||||||
|
io.SetStderrTTY(tt.isTTY)
|
||||||
|
|
||||||
|
factory := &cmdutil.Factory{
|
||||||
|
IOStreams: io,
|
||||||
|
Config: func() (config.Config, error) {
|
||||||
|
return cfg, nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := NewCmdList(factory, nil)
|
||||||
|
cmd.SetArgs([]string{})
|
||||||
|
|
||||||
|
cmd.SetIn(&bytes.Buffer{})
|
||||||
|
cmd.SetOut(ioutil.Discard)
|
||||||
|
cmd.SetErr(ioutil.Discard)
|
||||||
|
|
||||||
|
_, err := cmd.ExecuteC()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, tt.wantStdout, stdout.String())
|
||||||
|
assert.Equal(t, tt.wantStderr, stderr.String())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
147
pkg/cmd/alias/set/set.go
Normal file
147
pkg/cmd/alias/set/set.go
Normal file
|
|
@ -0,0 +1,147 @@
|
||||||
|
package set
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/MakeNowJust/heredoc"
|
||||||
|
"github.com/cli/cli/internal/config"
|
||||||
|
"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"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SetOptions struct {
|
||||||
|
Config func() (config.Config, error)
|
||||||
|
IO *iostreams.IOStreams
|
||||||
|
|
||||||
|
Name string
|
||||||
|
Expansion string
|
||||||
|
IsShell bool
|
||||||
|
RootCmd *cobra.Command
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCmdSet(f *cmdutil.Factory, runF func(*SetOptions) error) *cobra.Command {
|
||||||
|
opts := &SetOptions{
|
||||||
|
IO: f.IOStreams,
|
||||||
|
Config: f.Config,
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "set <alias> <expansion>",
|
||||||
|
Short: "Create a shortcut for a gh command",
|
||||||
|
Long: heredoc.Doc(`
|
||||||
|
Declare a word as a command alias that will expand to the specified command(s).
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
If '--shell' is specified, the alias will be run through a shell interpreter (sh). This allows you
|
||||||
|
to compose commands with "|" or redirect with ">". Note that extra arguments following the alias
|
||||||
|
will not be automatically passed to the expanded expression. To have a shell alias receive
|
||||||
|
arguments, you must explicitly accept them using "$1", "$2", etc., or "$@" to accept all of them.
|
||||||
|
|
||||||
|
Platform note: on Windows, shell aliases are executed via "sh" as installed by Git For Windows. If
|
||||||
|
you have installed git on Windows in some other way, shell aliases may not work for you.
|
||||||
|
|
||||||
|
Quotes must always be used when defining a command as in the examples.
|
||||||
|
`),
|
||||||
|
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"
|
||||||
|
|
||||||
|
$ gh alias set --shell igrep 'gh issue list --label="$1" | grep $2'
|
||||||
|
$ gh igrep epic foo
|
||||||
|
#=> gh issue list --label="epic" | grep "foo"
|
||||||
|
`),
|
||||||
|
Args: cobra.ExactArgs(2),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
opts.RootCmd = cmd.Root()
|
||||||
|
|
||||||
|
opts.Name = args[0]
|
||||||
|
opts.Expansion = args[1]
|
||||||
|
|
||||||
|
if runF != nil {
|
||||||
|
return runF(opts)
|
||||||
|
}
|
||||||
|
return setRun(opts)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Flags().BoolVarP(&opts.IsShell, "shell", "s", false, "Declare an alias to be passed through a shell interpreter")
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func setRun(opts *SetOptions) error {
|
||||||
|
cfg, err := opts.Config()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
aliasCfg, err := cfg.Aliases()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
isTerminal := opts.IO.IsStdoutTTY()
|
||||||
|
if isTerminal {
|
||||||
|
fmt.Fprintf(opts.IO.ErrOut, "- Adding alias for %s: %s\n", utils.Bold(opts.Name), utils.Bold(opts.Expansion))
|
||||||
|
}
|
||||||
|
|
||||||
|
expansion := opts.Expansion
|
||||||
|
isShell := opts.IsShell
|
||||||
|
if isShell && !strings.HasPrefix(expansion, "!") {
|
||||||
|
expansion = "!" + expansion
|
||||||
|
}
|
||||||
|
isShell = strings.HasPrefix(expansion, "!")
|
||||||
|
|
||||||
|
if validCommand(opts.RootCmd, opts.Name) {
|
||||||
|
return fmt.Errorf("could not create alias: %q is already a gh command", opts.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !isShell && !validCommand(opts.RootCmd, expansion) {
|
||||||
|
return fmt.Errorf("could not create alias: %s does not correspond to a gh command", expansion)
|
||||||
|
}
|
||||||
|
|
||||||
|
successMsg := fmt.Sprintf("%s Added alias.", utils.Green("✓"))
|
||||||
|
if oldExpansion, ok := aliasCfg.Get(opts.Name); ok {
|
||||||
|
successMsg = fmt.Sprintf("%s Changed alias %s from %s to %s",
|
||||||
|
utils.Green("✓"),
|
||||||
|
utils.Bold(opts.Name),
|
||||||
|
utils.Bold(oldExpansion),
|
||||||
|
utils.Bold(expansion),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = aliasCfg.Add(opts.Name, expansion)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("could not create alias: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if isTerminal {
|
||||||
|
fmt.Fprintln(opts.IO.ErrOut, successMsg)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validCommand(rootCmd *cobra.Command, expansion string) bool {
|
||||||
|
split, err := shlex.Split(expansion)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd, _, err := rootCmd.Traverse(split)
|
||||||
|
return err == nil && cmd != rootCmd
|
||||||
|
}
|
||||||
252
pkg/cmd/alias/set/set_test.go
Normal file
252
pkg/cmd/alias/set/set_test.go
Normal file
|
|
@ -0,0 +1,252 @@
|
||||||
|
package set
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"io/ioutil"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/MakeNowJust/heredoc"
|
||||||
|
"github.com/cli/cli/internal/config"
|
||||||
|
"github.com/cli/cli/pkg/cmdutil"
|
||||||
|
"github.com/cli/cli/pkg/iostreams"
|
||||||
|
"github.com/cli/cli/test"
|
||||||
|
"github.com/google/shlex"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func runCommand(cfg config.Config, isTTY bool, cli string) (*test.CmdOut, error) {
|
||||||
|
io, _, stdout, stderr := iostreams.Test()
|
||||||
|
io.SetStdoutTTY(isTTY)
|
||||||
|
io.SetStdinTTY(isTTY)
|
||||||
|
io.SetStderrTTY(isTTY)
|
||||||
|
|
||||||
|
factory := &cmdutil.Factory{
|
||||||
|
IOStreams: io,
|
||||||
|
Config: func() (config.Config, error) {
|
||||||
|
return cfg, nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := NewCmdSet(factory, nil)
|
||||||
|
|
||||||
|
// fake command nesting structure needed for validCommand
|
||||||
|
rootCmd := &cobra.Command{}
|
||||||
|
rootCmd.AddCommand(cmd)
|
||||||
|
prCmd := &cobra.Command{Use: "pr"}
|
||||||
|
prCmd.AddCommand(&cobra.Command{Use: "checkout"})
|
||||||
|
prCmd.AddCommand(&cobra.Command{Use: "status"})
|
||||||
|
rootCmd.AddCommand(prCmd)
|
||||||
|
issueCmd := &cobra.Command{Use: "issue"}
|
||||||
|
issueCmd.AddCommand(&cobra.Command{Use: "list"})
|
||||||
|
rootCmd.AddCommand(issueCmd)
|
||||||
|
|
||||||
|
argv, err := shlex.Split("set " + cli)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rootCmd.SetArgs(argv)
|
||||||
|
|
||||||
|
rootCmd.SetIn(&bytes.Buffer{})
|
||||||
|
rootCmd.SetOut(ioutil.Discard)
|
||||||
|
rootCmd.SetErr(ioutil.Discard)
|
||||||
|
|
||||||
|
_, err = rootCmd.ExecuteC()
|
||||||
|
return &test.CmdOut{
|
||||||
|
OutBuf: stdout,
|
||||||
|
ErrBuf: stderr,
|
||||||
|
}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAliasSet_gh_command(t *testing.T) {
|
||||||
|
defer config.StubWriteConfig(ioutil.Discard, ioutil.Discard)()
|
||||||
|
|
||||||
|
cfg := config.NewFromString(``)
|
||||||
|
|
||||||
|
_, err := runCommand(cfg, true, "pr 'pr status'")
|
||||||
|
|
||||||
|
if assert.Error(t, err) {
|
||||||
|
assert.Equal(t, `could not create alias: "pr" is already a gh command`, err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAliasSet_empty_aliases(t *testing.T) {
|
||||||
|
mainBuf := bytes.Buffer{}
|
||||||
|
defer config.StubWriteConfig(&mainBuf, ioutil.Discard)()
|
||||||
|
|
||||||
|
cfg := config.NewFromString(heredoc.Doc(`
|
||||||
|
aliases:
|
||||||
|
editor: vim
|
||||||
|
`))
|
||||||
|
|
||||||
|
output, err := runCommand(cfg, true, "co 'pr checkout'")
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
test.ExpectLines(t, output.Stderr(), "Added alias")
|
||||||
|
test.ExpectLines(t, output.String(), "")
|
||||||
|
|
||||||
|
expected := `aliases:
|
||||||
|
co: pr checkout
|
||||||
|
editor: vim
|
||||||
|
`
|
||||||
|
assert.Equal(t, expected, mainBuf.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAliasSet_existing_alias(t *testing.T) {
|
||||||
|
mainBuf := bytes.Buffer{}
|
||||||
|
defer config.StubWriteConfig(&mainBuf, ioutil.Discard)()
|
||||||
|
|
||||||
|
cfg := config.NewFromString(heredoc.Doc(`
|
||||||
|
aliases:
|
||||||
|
co: pr checkout
|
||||||
|
`))
|
||||||
|
|
||||||
|
output, err := runCommand(cfg, true, "co 'pr checkout -Rcool/repo'")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
test.ExpectLines(t, output.Stderr(), "Changed alias.*co.*from.*pr checkout.*to.*pr checkout -Rcool/repo")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAliasSet_space_args(t *testing.T) {
|
||||||
|
mainBuf := bytes.Buffer{}
|
||||||
|
defer config.StubWriteConfig(&mainBuf, ioutil.Discard)()
|
||||||
|
|
||||||
|
cfg := config.NewFromString(``)
|
||||||
|
|
||||||
|
output, err := runCommand(cfg, true, `il 'issue list -l "cool story"'`)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
test.ExpectLines(t, output.Stderr(), `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) {
|
||||||
|
cases := []struct {
|
||||||
|
Cmd string
|
||||||
|
ExpectedOutputLine string
|
||||||
|
ExpectedConfigLine string
|
||||||
|
}{
|
||||||
|
{`il "issue list"`, "- Adding alias for.*il.*issue list", "il: issue list"},
|
||||||
|
|
||||||
|
{`iz 'issue list'`, "- Adding alias for.*iz.*issue list", "iz: issue list"},
|
||||||
|
|
||||||
|
{`ii 'issue list --author="$1" --label="$2"'`,
|
||||||
|
`- Adding alias for.*ii.*issue list --author="\$1" --label="\$2"`,
|
||||||
|
`ii: issue list --author="\$1" --label="\$2"`},
|
||||||
|
|
||||||
|
{`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 {
|
||||||
|
t.Run(c.Cmd, func(t *testing.T) {
|
||||||
|
mainBuf := bytes.Buffer{}
|
||||||
|
defer config.StubWriteConfig(&mainBuf, ioutil.Discard)()
|
||||||
|
|
||||||
|
cfg := config.NewFromString(``)
|
||||||
|
|
||||||
|
output, err := runCommand(cfg, true, c.Cmd)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("got unexpected error running %s: %s", c.Cmd, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
test.ExpectLines(t, output.Stderr(), c.ExpectedOutputLine)
|
||||||
|
test.ExpectLines(t, mainBuf.String(), c.ExpectedConfigLine)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAliasSet_init_alias_cfg(t *testing.T) {
|
||||||
|
mainBuf := bytes.Buffer{}
|
||||||
|
defer config.StubWriteConfig(&mainBuf, ioutil.Discard)()
|
||||||
|
|
||||||
|
cfg := config.NewFromString(heredoc.Doc(`
|
||||||
|
editor: vim
|
||||||
|
`))
|
||||||
|
|
||||||
|
output, err := runCommand(cfg, true, "diff 'pr diff'")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
expected := `editor: vim
|
||||||
|
aliases:
|
||||||
|
diff: pr diff
|
||||||
|
`
|
||||||
|
|
||||||
|
test.ExpectLines(t, output.Stderr(), "Adding alias for.*diff.*pr diff", "Added alias.")
|
||||||
|
assert.Equal(t, expected, mainBuf.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAliasSet_existing_aliases(t *testing.T) {
|
||||||
|
mainBuf := bytes.Buffer{}
|
||||||
|
defer config.StubWriteConfig(&mainBuf, ioutil.Discard)()
|
||||||
|
|
||||||
|
cfg := config.NewFromString(heredoc.Doc(`
|
||||||
|
aliases:
|
||||||
|
foo: bar
|
||||||
|
`))
|
||||||
|
|
||||||
|
output, err := runCommand(cfg, true, "view 'pr view'")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
expected := `aliases:
|
||||||
|
foo: bar
|
||||||
|
view: pr view
|
||||||
|
`
|
||||||
|
|
||||||
|
test.ExpectLines(t, output.Stderr(), "Adding alias for.*view.*pr view", "Added alias.")
|
||||||
|
assert.Equal(t, expected, mainBuf.String())
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAliasSet_invalid_command(t *testing.T) {
|
||||||
|
defer config.StubWriteConfig(ioutil.Discard, ioutil.Discard)()
|
||||||
|
|
||||||
|
cfg := config.NewFromString(``)
|
||||||
|
|
||||||
|
_, err := runCommand(cfg, true, "co 'pe checkout'")
|
||||||
|
if assert.Error(t, err) {
|
||||||
|
assert.Equal(t, "could not create alias: pe checkout does not correspond to a gh command", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestShellAlias_flag(t *testing.T) {
|
||||||
|
mainBuf := bytes.Buffer{}
|
||||||
|
defer config.StubWriteConfig(&mainBuf, ioutil.Discard)()
|
||||||
|
|
||||||
|
cfg := config.NewFromString(``)
|
||||||
|
|
||||||
|
output, err := runCommand(cfg, true, "--shell igrep 'gh issue list | grep'")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
test.ExpectLines(t, output.Stderr(), "Adding alias for.*igrep")
|
||||||
|
|
||||||
|
expected := `aliases:
|
||||||
|
igrep: '!gh issue list | grep'
|
||||||
|
`
|
||||||
|
assert.Equal(t, expected, mainBuf.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestShellAlias_bang(t *testing.T) {
|
||||||
|
mainBuf := bytes.Buffer{}
|
||||||
|
defer config.StubWriteConfig(&mainBuf, ioutil.Discard)()
|
||||||
|
|
||||||
|
cfg := config.NewFromString(``)
|
||||||
|
|
||||||
|
output, err := runCommand(cfg, true, "igrep '!gh issue list | grep'")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
test.ExpectLines(t, output.Stderr(), "Adding alias for.*igrep")
|
||||||
|
|
||||||
|
expected := `aliases:
|
||||||
|
igrep: '!gh issue list | grep'
|
||||||
|
`
|
||||||
|
assert.Equal(t, expected, mainBuf.String())
|
||||||
|
}
|
||||||
|
|
@ -15,6 +15,7 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/MakeNowJust/heredoc"
|
"github.com/MakeNowJust/heredoc"
|
||||||
|
"github.com/cli/cli/internal/ghinstance"
|
||||||
"github.com/cli/cli/internal/ghrepo"
|
"github.com/cli/cli/internal/ghrepo"
|
||||||
"github.com/cli/cli/pkg/cmdutil"
|
"github.com/cli/cli/pkg/cmdutil"
|
||||||
"github.com/cli/cli/pkg/iostreams"
|
"github.com/cli/cli/pkg/iostreams"
|
||||||
|
|
@ -38,6 +39,7 @@ type ApiOptions struct {
|
||||||
|
|
||||||
HttpClient func() (*http.Client, error)
|
HttpClient func() (*http.Client, error)
|
||||||
BaseRepo func() (ghrepo.Interface, error)
|
BaseRepo func() (ghrepo.Interface, error)
|
||||||
|
Branch func() (string, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewCmdApi(f *cmdutil.Factory, runF func(*ApiOptions) error) *cobra.Command {
|
func NewCmdApi(f *cmdutil.Factory, runF func(*ApiOptions) error) *cobra.Command {
|
||||||
|
|
@ -45,6 +47,7 @@ func NewCmdApi(f *cmdutil.Factory, runF func(*ApiOptions) error) *cobra.Command
|
||||||
IO: f.IOStreams,
|
IO: f.IOStreams,
|
||||||
HttpClient: f.HttpClient,
|
HttpClient: f.HttpClient,
|
||||||
BaseRepo: f.BaseRepo,
|
BaseRepo: f.BaseRepo,
|
||||||
|
Branch: f.Branch,
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
|
|
@ -55,8 +58,8 @@ func NewCmdApi(f *cmdutil.Factory, runF func(*ApiOptions) error) *cobra.Command
|
||||||
The endpoint argument should either be a path of a GitHub API v3 endpoint, or
|
The endpoint argument should either be a path of a GitHub API v3 endpoint, or
|
||||||
"graphql" to access the GitHub API v4.
|
"graphql" to access the GitHub API v4.
|
||||||
|
|
||||||
Placeholder values ":owner" and ":repo" in the endpoint argument will get replaced
|
Placeholder values ":owner", ":repo", and ":branch" in the endpoint argument will
|
||||||
with values from the repository of the current directory.
|
get replaced with values from the repository of the current directory.
|
||||||
|
|
||||||
The default HTTP request method is "GET" normally and "POST" if any parameters
|
The default HTTP request method is "GET" normally and "POST" if any parameters
|
||||||
were added. Override the method with '--method'.
|
were added. Override the method with '--method'.
|
||||||
|
|
@ -69,8 +72,8 @@ on the format of the value:
|
||||||
|
|
||||||
- literal values "true", "false", "null", and integer numbers get converted to
|
- literal values "true", "false", "null", and integer numbers get converted to
|
||||||
appropriate JSON types;
|
appropriate JSON types;
|
||||||
- placeholder values ":owner" and ":repo" get populated with values from the
|
- placeholder values ":owner", ":repo", and ":branch" get populated with values
|
||||||
repository of the current directory;
|
from the repository of the current directory;
|
||||||
- if the value starts with "@", the rest of the value is interpreted as a
|
- if the value starts with "@", the rest of the value is interpreted as a
|
||||||
filename to read the value from. Pass "-" to read from standard input.
|
filename to read the value from. Pass "-" to read from standard input.
|
||||||
|
|
||||||
|
|
@ -193,9 +196,11 @@ func apiRun(opts *ApiOptions) error {
|
||||||
opts.IO.Out = ioutil.Discard
|
opts.IO.Out = ioutil.Discard
|
||||||
}
|
}
|
||||||
|
|
||||||
|
host := ghinstance.OverridableDefault()
|
||||||
|
|
||||||
hasNextPage := true
|
hasNextPage := true
|
||||||
for hasNextPage {
|
for hasNextPage {
|
||||||
resp, err := httpRequest(httpClient, method, requestPath, requestBody, requestHeaders)
|
resp, err := httpRequest(httpClient, host, method, requestPath, requestBody, requestHeaders)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -285,7 +290,7 @@ func processResponse(resp *http.Response, opts *ApiOptions, headersOutputStream
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var placeholderRE = regexp.MustCompile(`\:(owner|repo)\b`)
|
var placeholderRE = regexp.MustCompile(`\:(owner|repo|branch)\b`)
|
||||||
|
|
||||||
// fillPlaceholders populates `:owner` and `:repo` placeholders with values from the current repository
|
// fillPlaceholders populates `:owner` and `:repo` placeholders with values from the current repository
|
||||||
func fillPlaceholders(value string, opts *ApiOptions) (string, error) {
|
func fillPlaceholders(value string, opts *ApiOptions) (string, error) {
|
||||||
|
|
@ -298,18 +303,28 @@ func fillPlaceholders(value string, opts *ApiOptions) (string, error) {
|
||||||
return value, err
|
return value, err
|
||||||
}
|
}
|
||||||
|
|
||||||
value = placeholderRE.ReplaceAllStringFunc(value, func(m string) string {
|
filled := placeholderRE.ReplaceAllStringFunc(value, func(m string) string {
|
||||||
switch m {
|
switch m {
|
||||||
case ":owner":
|
case ":owner":
|
||||||
return baseRepo.RepoOwner()
|
return baseRepo.RepoOwner()
|
||||||
case ":repo":
|
case ":repo":
|
||||||
return baseRepo.RepoName()
|
return baseRepo.RepoName()
|
||||||
|
case ":branch":
|
||||||
|
branch, e := opts.Branch()
|
||||||
|
if e != nil {
|
||||||
|
err = e
|
||||||
|
}
|
||||||
|
return branch
|
||||||
default:
|
default:
|
||||||
panic(fmt.Sprintf("invalid placeholder: %q", m))
|
panic(fmt.Sprintf("invalid placeholder: %q", m))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
return value, nil
|
if err != nil {
|
||||||
|
return value, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return filled, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func printHeaders(w io.Writer, headers http.Header, colorize bool) {
|
func printHeaders(w io.Writer, headers http.Header, colorize bool) {
|
||||||
|
|
@ -426,23 +441,43 @@ func parseErrorResponse(r io.Reader, statusCode int) (io.Reader, string, error)
|
||||||
|
|
||||||
var parsedBody struct {
|
var parsedBody struct {
|
||||||
Message string
|
Message string
|
||||||
Errors []struct {
|
Errors []json.RawMessage
|
||||||
Message string
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
err = json.Unmarshal(b, &parsedBody)
|
err = json.Unmarshal(b, &parsedBody)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return r, "", err
|
return r, "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
if parsedBody.Message != "" {
|
if parsedBody.Message != "" {
|
||||||
return bodyCopy, fmt.Sprintf("%s (HTTP %d)", parsedBody.Message, statusCode), nil
|
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 {
|
type errorMessage struct {
|
||||||
msgs[i] = e.Message
|
Message string
|
||||||
|
}
|
||||||
|
var errors []string
|
||||||
|
for _, rawErr := range parsedBody.Errors {
|
||||||
|
if len(rawErr) == 0 {
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
return bodyCopy, strings.Join(msgs, "\n"), nil
|
if rawErr[0] == '{' {
|
||||||
|
var objectError errorMessage
|
||||||
|
err := json.Unmarshal(rawErr, &objectError)
|
||||||
|
if err != nil {
|
||||||
|
return r, "", err
|
||||||
|
}
|
||||||
|
errors = append(errors, objectError.Message)
|
||||||
|
} else if rawErr[0] == '"' {
|
||||||
|
var stringError string
|
||||||
|
err := json.Unmarshal(rawErr, &stringError)
|
||||||
|
if err != nil {
|
||||||
|
return r, "", err
|
||||||
|
}
|
||||||
|
errors = append(errors, stringError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(errors) > 0 {
|
||||||
|
return bodyCopy, strings.Join(errors, "\n"), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return bodyCopy, "", nil
|
return bodyCopy, "", nil
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import (
|
||||||
"os"
|
"os"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/cli/cli/git"
|
||||||
"github.com/cli/cli/internal/ghrepo"
|
"github.com/cli/cli/internal/ghrepo"
|
||||||
"github.com/cli/cli/pkg/cmdutil"
|
"github.com/cli/cli/pkg/cmdutil"
|
||||||
"github.com/cli/cli/pkg/iostreams"
|
"github.com/cli/cli/pkg/iostreams"
|
||||||
|
|
@ -264,6 +265,17 @@ func Test_apiRun(t *testing.T) {
|
||||||
stdout: `{"message": "THIS IS FINE"}`,
|
stdout: `{"message": "THIS IS FINE"}`,
|
||||||
stderr: "gh: THIS IS FINE (HTTP 400)\n",
|
stderr: "gh: THIS IS FINE (HTTP 400)\n",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "REST string errors",
|
||||||
|
httpResponse: &http.Response{
|
||||||
|
StatusCode: 400,
|
||||||
|
Body: ioutil.NopCloser(bytes.NewBufferString(`{"errors": ["ALSO", "FINE"]}`)),
|
||||||
|
Header: http.Header{"Content-Type": []string{"application/json; charset=utf-8"}},
|
||||||
|
},
|
||||||
|
err: cmdutil.SilentError,
|
||||||
|
stdout: `{"errors": ["ALSO", "FINE"]}`,
|
||||||
|
stderr: "gh: ALSO\nFINE\n",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "GraphQL error",
|
name: "GraphQL error",
|
||||||
options: ApiOptions{
|
options: ApiOptions{
|
||||||
|
|
@ -742,6 +754,38 @@ func Test_fillPlaceholders(t *testing.T) {
|
||||||
want: "repos/hubot/robot-uprising/releases",
|
want: "repos/hubot/robot-uprising/releases",
|
||||||
wantErr: false,
|
wantErr: false,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "has branch placeholder",
|
||||||
|
args: args{
|
||||||
|
value: "repos/cli/cli/branches/:branch/protection/required_status_checks",
|
||||||
|
opts: &ApiOptions{
|
||||||
|
BaseRepo: func() (ghrepo.Interface, error) {
|
||||||
|
return ghrepo.New("cli", "cli"), nil
|
||||||
|
},
|
||||||
|
Branch: func() (string, error) {
|
||||||
|
return "trunk", nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
want: "repos/cli/cli/branches/trunk/protection/required_status_checks",
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "has branch placeholder and git is in detached head",
|
||||||
|
args: args{
|
||||||
|
value: "repos/:owner/:repo/branches/:branch",
|
||||||
|
opts: &ApiOptions{
|
||||||
|
BaseRepo: func() (ghrepo.Interface, error) {
|
||||||
|
return ghrepo.New("cli", "cli"), nil
|
||||||
|
},
|
||||||
|
Branch: func() (string, error) {
|
||||||
|
return "", git.ErrNotOnAnyBranch
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
want: "repos/:owner/:repo/branches/:branch",
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "no greedy substitutes",
|
name: "no greedy substitutes",
|
||||||
args: args{
|
args: args{
|
||||||
|
|
|
||||||
|
|
@ -9,20 +9,23 @@ import (
|
||||||
"net/url"
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/cli/cli/internal/ghinstance"
|
||||||
)
|
)
|
||||||
|
|
||||||
func httpRequest(client *http.Client, method string, p string, params interface{}, headers []string) (*http.Response, error) {
|
func httpRequest(client *http.Client, hostname string, method string, p string, params interface{}, headers []string) (*http.Response, error) {
|
||||||
|
isGraphQL := p == "graphql"
|
||||||
var requestURL string
|
var requestURL string
|
||||||
// TODO: GHE support
|
|
||||||
if strings.Contains(p, "://") {
|
if strings.Contains(p, "://") {
|
||||||
requestURL = p
|
requestURL = p
|
||||||
|
} else if isGraphQL {
|
||||||
|
requestURL = ghinstance.GraphQLEndpoint(hostname)
|
||||||
} else {
|
} else {
|
||||||
requestURL = "https://api.github.com/" + p
|
requestURL = ghinstance.RESTPrefix(hostname) + strings.TrimPrefix(p, "/")
|
||||||
}
|
}
|
||||||
|
|
||||||
var body io.Reader
|
var body io.Reader
|
||||||
var bodyIsJSON bool
|
var bodyIsJSON bool
|
||||||
isGraphQL := p == "graphql"
|
|
||||||
|
|
||||||
switch pp := params.(type) {
|
switch pp := params.(type) {
|
||||||
case map[string]interface{}:
|
case map[string]interface{}:
|
||||||
|
|
|
||||||
|
|
@ -93,6 +93,7 @@ func Test_httpRequest(t *testing.T) {
|
||||||
|
|
||||||
type args struct {
|
type args struct {
|
||||||
client *http.Client
|
client *http.Client
|
||||||
|
host string
|
||||||
method string
|
method string
|
||||||
p string
|
p string
|
||||||
params interface{}
|
params interface{}
|
||||||
|
|
@ -114,6 +115,7 @@ func Test_httpRequest(t *testing.T) {
|
||||||
name: "simple GET",
|
name: "simple GET",
|
||||||
args: args{
|
args: args{
|
||||||
client: &httpClient,
|
client: &httpClient,
|
||||||
|
host: "github.com",
|
||||||
method: "GET",
|
method: "GET",
|
||||||
p: "repos/octocat/spoon-knife",
|
p: "repos/octocat/spoon-knife",
|
||||||
params: nil,
|
params: nil,
|
||||||
|
|
@ -127,10 +129,47 @@ func Test_httpRequest(t *testing.T) {
|
||||||
headers: "",
|
headers: "",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "GET with leading slash",
|
||||||
|
args: args{
|
||||||
|
client: &httpClient,
|
||||||
|
host: "github.com",
|
||||||
|
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: "Enterprise REST",
|
||||||
|
args: args{
|
||||||
|
client: &httpClient,
|
||||||
|
host: "example.org",
|
||||||
|
method: "GET",
|
||||||
|
p: "repos/octocat/spoon-knife",
|
||||||
|
params: nil,
|
||||||
|
headers: []string{},
|
||||||
|
},
|
||||||
|
wantErr: false,
|
||||||
|
want: expects{
|
||||||
|
method: "GET",
|
||||||
|
u: "https://example.org/api/v3/repos/octocat/spoon-knife",
|
||||||
|
body: "",
|
||||||
|
headers: "",
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "GET with params",
|
name: "GET with params",
|
||||||
args: args{
|
args: args{
|
||||||
client: &httpClient,
|
client: &httpClient,
|
||||||
|
host: "github.com",
|
||||||
method: "GET",
|
method: "GET",
|
||||||
p: "repos/octocat/spoon-knife",
|
p: "repos/octocat/spoon-knife",
|
||||||
params: map[string]interface{}{
|
params: map[string]interface{}{
|
||||||
|
|
@ -150,6 +189,7 @@ func Test_httpRequest(t *testing.T) {
|
||||||
name: "POST with params",
|
name: "POST with params",
|
||||||
args: args{
|
args: args{
|
||||||
client: &httpClient,
|
client: &httpClient,
|
||||||
|
host: "github.com",
|
||||||
method: "POST",
|
method: "POST",
|
||||||
p: "repos",
|
p: "repos",
|
||||||
params: map[string]interface{}{
|
params: map[string]interface{}{
|
||||||
|
|
@ -169,6 +209,7 @@ func Test_httpRequest(t *testing.T) {
|
||||||
name: "POST GraphQL",
|
name: "POST GraphQL",
|
||||||
args: args{
|
args: args{
|
||||||
client: &httpClient,
|
client: &httpClient,
|
||||||
|
host: "github.com",
|
||||||
method: "POST",
|
method: "POST",
|
||||||
p: "graphql",
|
p: "graphql",
|
||||||
params: map[string]interface{}{
|
params: map[string]interface{}{
|
||||||
|
|
@ -184,10 +225,29 @@ func Test_httpRequest(t *testing.T) {
|
||||||
headers: "Content-Type: application/json; charset=utf-8\r\n",
|
headers: "Content-Type: application/json; charset=utf-8\r\n",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "Enterprise GraphQL",
|
||||||
|
args: args{
|
||||||
|
client: &httpClient,
|
||||||
|
host: "example.org",
|
||||||
|
method: "POST",
|
||||||
|
p: "graphql",
|
||||||
|
params: map[string]interface{}{},
|
||||||
|
headers: []string{},
|
||||||
|
},
|
||||||
|
wantErr: false,
|
||||||
|
want: expects{
|
||||||
|
method: "POST",
|
||||||
|
u: "https://example.org/api/graphql",
|
||||||
|
body: `{}`,
|
||||||
|
headers: "Content-Type: application/json; charset=utf-8\r\n",
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "POST with body and type",
|
name: "POST with body and type",
|
||||||
args: args{
|
args: args{
|
||||||
client: &httpClient,
|
client: &httpClient,
|
||||||
|
host: "github.com",
|
||||||
method: "POST",
|
method: "POST",
|
||||||
p: "repos",
|
p: "repos",
|
||||||
params: bytes.NewBufferString("CUSTOM"),
|
params: bytes.NewBufferString("CUSTOM"),
|
||||||
|
|
@ -207,7 +267,7 @@ func Test_httpRequest(t *testing.T) {
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
got, err := httpRequest(tt.args.client, tt.args.method, tt.args.p, tt.args.params, tt.args.headers)
|
got, err := httpRequest(tt.args.client, tt.args.host, tt.args.method, tt.args.p, tt.args.params, tt.args.headers)
|
||||||
if (err != nil) != tt.wantErr {
|
if (err != nil) != tt.wantErr {
|
||||||
t.Errorf("httpRequest() error = %v, wantErr %v", err, tt.wantErr)
|
t.Errorf("httpRequest() error = %v, wantErr %v", err, tt.wantErr)
|
||||||
return
|
return
|
||||||
|
|
|
||||||
25
pkg/cmd/auth/auth.go
Normal file
25
pkg/cmd/auth/auth.go
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
authLoginCmd "github.com/cli/cli/pkg/cmd/auth/login"
|
||||||
|
authLogoutCmd "github.com/cli/cli/pkg/cmd/auth/logout"
|
||||||
|
authRefreshCmd "github.com/cli/cli/pkg/cmd/auth/refresh"
|
||||||
|
authStatusCmd "github.com/cli/cli/pkg/cmd/auth/status"
|
||||||
|
"github.com/cli/cli/pkg/cmdutil"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewCmdAuth(f *cmdutil.Factory) *cobra.Command {
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "auth <command>",
|
||||||
|
Short: "Login, logout, and refresh your authentication",
|
||||||
|
Long: `Manage gh's authentication state.`,
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.AddCommand(authLoginCmd.NewCmdLogin(f, nil))
|
||||||
|
cmd.AddCommand(authLogoutCmd.NewCmdLogout(f, nil))
|
||||||
|
cmd.AddCommand(authStatusCmd.NewCmdStatus(f, nil))
|
||||||
|
cmd.AddCommand(authRefreshCmd.NewCmdRefresh(f, nil))
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
48
pkg/cmd/auth/client/client.go
Normal file
48
pkg/cmd/auth/client/client.go
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
package client
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/cli/cli/api"
|
||||||
|
"github.com/cli/cli/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ValidateHostCfg(hostname string, cfg config.Config) error {
|
||||||
|
apiClient, err := ClientFromCfg(hostname, cfg)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = apiClient.HasMinimumScopes(hostname)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("could not validate token: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var ClientFromCfg = func(hostname string, cfg config.Config) (*api.Client, error) {
|
||||||
|
var opts []api.ClientOption
|
||||||
|
|
||||||
|
token, err := cfg.Get(hostname, "oauth_token")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if token == "" {
|
||||||
|
return nil, fmt.Errorf("no token found in config for %s", hostname)
|
||||||
|
}
|
||||||
|
|
||||||
|
opts = append(opts,
|
||||||
|
// no access to Version so the user agent is more generic here.
|
||||||
|
api.AddHeader("User-Agent", "GitHub CLI"),
|
||||||
|
api.AddHeaderFunc("Authorization", func(req *http.Request) (string, error) {
|
||||||
|
return fmt.Sprintf("token %s", token), nil
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
httpClient := api.NewHTTPClient(opts...)
|
||||||
|
|
||||||
|
return api.NewClientFromHTTP(httpClient), nil
|
||||||
|
}
|
||||||
290
pkg/cmd/auth/login/login.go
Normal file
290
pkg/cmd/auth/login/login.go
Normal file
|
|
@ -0,0 +1,290 @@
|
||||||
|
package login
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io/ioutil"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/AlecAivazis/survey/v2"
|
||||||
|
"github.com/MakeNowJust/heredoc"
|
||||||
|
"github.com/cli/cli/api"
|
||||||
|
"github.com/cli/cli/internal/config"
|
||||||
|
"github.com/cli/cli/internal/ghinstance"
|
||||||
|
"github.com/cli/cli/pkg/cmd/auth/client"
|
||||||
|
"github.com/cli/cli/pkg/cmdutil"
|
||||||
|
"github.com/cli/cli/pkg/iostreams"
|
||||||
|
"github.com/cli/cli/pkg/prompt"
|
||||||
|
"github.com/cli/cli/utils"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
type LoginOptions struct {
|
||||||
|
IO *iostreams.IOStreams
|
||||||
|
Config func() (config.Config, error)
|
||||||
|
|
||||||
|
Hostname string
|
||||||
|
Token string
|
||||||
|
OnlyValidate bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCmdLogin(f *cmdutil.Factory, runF func(*LoginOptions) error) *cobra.Command {
|
||||||
|
opts := &LoginOptions{
|
||||||
|
IO: f.IOStreams,
|
||||||
|
Config: f.Config,
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "login",
|
||||||
|
Args: cobra.ExactArgs(0),
|
||||||
|
Short: "Authenticate with a GitHub host",
|
||||||
|
Long: heredoc.Doc(`Authenticate with a GitHub host.
|
||||||
|
|
||||||
|
This interactive command initializes your authentication state either by helping you log into
|
||||||
|
GitHub via browser-based OAuth or by accepting a Personal Access Token.
|
||||||
|
|
||||||
|
The interactivity can be avoided by specifying --with-token and passing a token on STDIN.
|
||||||
|
`),
|
||||||
|
Example: heredoc.Doc(`
|
||||||
|
$ gh auth login
|
||||||
|
# => do an interactive setup
|
||||||
|
|
||||||
|
$ gh auth login --with-token < mytoken.txt
|
||||||
|
# => read token from mytoken.txt and authenticate against github.com
|
||||||
|
|
||||||
|
$ gh auth login --hostname enterprise.internal --with-token < mytoken.txt
|
||||||
|
# => read token from mytoken.txt and authenticate against a GitHub Enterprise instance
|
||||||
|
`),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
isTTY := opts.IO.IsStdinTTY()
|
||||||
|
|
||||||
|
// TODO support other ways of naming
|
||||||
|
ghToken := os.Getenv("GITHUB_TOKEN")
|
||||||
|
|
||||||
|
if !isTTY && (!cmd.Flags().Changed("with-token") && ghToken == "") {
|
||||||
|
return &cmdutil.FlagError{Err: errors.New("no terminal detected; please use '--with-token' or set GITHUB_TOKEN")}
|
||||||
|
}
|
||||||
|
|
||||||
|
wt, _ := cmd.Flags().GetBool("with-token")
|
||||||
|
if wt {
|
||||||
|
defer opts.IO.In.Close()
|
||||||
|
token, err := ioutil.ReadAll(opts.IO.In)
|
||||||
|
if err != nil {
|
||||||
|
return &cmdutil.FlagError{Err: fmt.Errorf("failed to read token from STDIN: %w", err)}
|
||||||
|
}
|
||||||
|
|
||||||
|
opts.Token = strings.TrimSpace(string(token))
|
||||||
|
} else if ghToken != "" {
|
||||||
|
opts.OnlyValidate = true
|
||||||
|
opts.Token = ghToken
|
||||||
|
}
|
||||||
|
|
||||||
|
if opts.Token != "" {
|
||||||
|
// Assume non-interactive if a token is specified
|
||||||
|
if opts.Hostname == "" {
|
||||||
|
opts.Hostname = ghinstance.Default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if runF != nil {
|
||||||
|
return runF(opts)
|
||||||
|
}
|
||||||
|
|
||||||
|
return loginRun(opts)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Flags().StringVarP(&opts.Hostname, "hostname", "h", "", "The hostname of the GitHub instance to authenticate with")
|
||||||
|
cmd.Flags().Bool("with-token", false, "Read token from standard input")
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func loginRun(opts *LoginOptions) error {
|
||||||
|
cfg, err := opts.Config()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if opts.Token != "" {
|
||||||
|
// I chose to not error on existing host here; my thinking is that for --with-token the user
|
||||||
|
// probably doesn't care if a token is overwritten since they have a token in hand they
|
||||||
|
// explicitly want to use.
|
||||||
|
if opts.Hostname == "" {
|
||||||
|
return errors.New("empty hostname would leak oauth_token")
|
||||||
|
}
|
||||||
|
|
||||||
|
err := cfg.Set(opts.Hostname, "oauth_token", opts.Token)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = client.ValidateHostCfg(opts.Hostname, cfg)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if opts.OnlyValidate {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return cfg.Write()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO consider explicitly telling survey what io to use since it's implicit right now
|
||||||
|
|
||||||
|
hostname := opts.Hostname
|
||||||
|
|
||||||
|
if hostname == "" {
|
||||||
|
var hostType int
|
||||||
|
err := prompt.SurveyAskOne(&survey.Select{
|
||||||
|
Message: "What account do you want to log into?",
|
||||||
|
Options: []string{
|
||||||
|
"GitHub.com",
|
||||||
|
"GitHub Enterprise",
|
||||||
|
},
|
||||||
|
}, &hostType)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("could not prompt: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
isEnterprise := hostType == 1
|
||||||
|
|
||||||
|
hostname = ghinstance.Default()
|
||||||
|
if isEnterprise {
|
||||||
|
err := prompt.SurveyAskOne(&survey.Input{
|
||||||
|
Message: "GHE hostname:",
|
||||||
|
}, &hostname, survey.WithValidator(survey.Required))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("could not prompt: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintf(opts.IO.ErrOut, "- Logging into %s\n", hostname)
|
||||||
|
|
||||||
|
existingToken, _ := cfg.Get(hostname, "oauth_token")
|
||||||
|
|
||||||
|
if existingToken != "" {
|
||||||
|
err := client.ValidateHostCfg(hostname, cfg)
|
||||||
|
if err == nil {
|
||||||
|
apiClient, err := client.ClientFromCfg(hostname, cfg)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
username, err := api.CurrentLoginName(apiClient, hostname)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error using api: %w", err)
|
||||||
|
}
|
||||||
|
var keepGoing bool
|
||||||
|
err = prompt.SurveyAskOne(&survey.Confirm{
|
||||||
|
Message: fmt.Sprintf(
|
||||||
|
"You're already logged into %s as %s. Do you want to re-authenticate?",
|
||||||
|
hostname,
|
||||||
|
username),
|
||||||
|
Default: false,
|
||||||
|
}, &keepGoing)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("could not prompt: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !keepGoing {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var authMode int
|
||||||
|
err = prompt.SurveyAskOne(&survey.Select{
|
||||||
|
Message: "How would you like to authenticate?",
|
||||||
|
Options: []string{
|
||||||
|
"Login with a web browser",
|
||||||
|
"Paste an authentication token",
|
||||||
|
},
|
||||||
|
}, &authMode)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("could not prompt: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if authMode == 0 {
|
||||||
|
_, err := config.AuthFlowWithConfig(cfg, hostname, "", []string{})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to authenticate via web browser: %w", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fmt.Fprintln(opts.IO.ErrOut)
|
||||||
|
fmt.Fprintln(opts.IO.ErrOut, heredoc.Doc(`
|
||||||
|
Tip: you can generate a Personal Access Token here https://github.com/settings/tokens
|
||||||
|
The minimum required scopes are 'repo' and 'read:org'.`))
|
||||||
|
var token string
|
||||||
|
err := prompt.SurveyAskOne(&survey.Password{
|
||||||
|
Message: "Paste your authentication token:",
|
||||||
|
}, &token, survey.WithValidator(survey.Required))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("could not prompt: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if hostname == "" {
|
||||||
|
return errors.New("empty hostname would leak oauth_token")
|
||||||
|
}
|
||||||
|
|
||||||
|
err = cfg.Set(hostname, "oauth_token", token)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = client.ValidateHostCfg(hostname, cfg)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var gitProtocol string
|
||||||
|
err = prompt.SurveyAskOne(&survey.Select{
|
||||||
|
Message: "Choose default git protocol",
|
||||||
|
Options: []string{
|
||||||
|
"HTTPS",
|
||||||
|
"SSH",
|
||||||
|
},
|
||||||
|
}, &gitProtocol)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("could not prompt: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
gitProtocol = strings.ToLower(gitProtocol)
|
||||||
|
|
||||||
|
fmt.Fprintf(opts.IO.ErrOut, "- gh config set -h%s git_protocol %s\n", hostname, gitProtocol)
|
||||||
|
err = cfg.Set(hostname, "git_protocol", gitProtocol)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintf(opts.IO.ErrOut, "%s Configured git protocol\n", utils.GreenCheck())
|
||||||
|
|
||||||
|
apiClient, err := client.ClientFromCfg(hostname, cfg)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
username, err := api.CurrentLoginName(apiClient, hostname)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error using api: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = cfg.Set(hostname, "user", username)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = cfg.Write()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintf(opts.IO.ErrOut, "%s Logged in as %s\n", utils.GreenCheck(), utils.Bold(username))
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
423
pkg/cmd/auth/login/login_test.go
Normal file
423
pkg/cmd/auth/login/login_test.go
Normal file
|
|
@ -0,0 +1,423 @@
|
||||||
|
package login
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"regexp"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/cli/cli/api"
|
||||||
|
"github.com/cli/cli/internal/config"
|
||||||
|
"github.com/cli/cli/pkg/cmd/auth/client"
|
||||||
|
"github.com/cli/cli/pkg/cmdutil"
|
||||||
|
"github.com/cli/cli/pkg/httpmock"
|
||||||
|
"github.com/cli/cli/pkg/iostreams"
|
||||||
|
"github.com/cli/cli/pkg/prompt"
|
||||||
|
"github.com/google/shlex"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Test_NewCmdLogin(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
cli string
|
||||||
|
stdin string
|
||||||
|
stdinTTY bool
|
||||||
|
wants LoginOptions
|
||||||
|
wantsErr bool
|
||||||
|
ghtoken string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "nontty, with-token",
|
||||||
|
stdin: "abc123\n",
|
||||||
|
cli: "--with-token",
|
||||||
|
wants: LoginOptions{
|
||||||
|
Hostname: "github.com",
|
||||||
|
Token: "abc123",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "tty, with-token",
|
||||||
|
stdinTTY: true,
|
||||||
|
stdin: "def456",
|
||||||
|
cli: "--with-token",
|
||||||
|
wants: LoginOptions{
|
||||||
|
Hostname: "github.com",
|
||||||
|
Token: "def456",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "nontty, hostname",
|
||||||
|
cli: "--hostname claire.redfield",
|
||||||
|
wantsErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "nontty",
|
||||||
|
cli: "",
|
||||||
|
wantsErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "nontty, with-token, hostname",
|
||||||
|
cli: "--hostname claire.redfield --with-token",
|
||||||
|
stdin: "abc123\n",
|
||||||
|
wants: LoginOptions{
|
||||||
|
Hostname: "claire.redfield",
|
||||||
|
Token: "abc123",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "tty, with-token, hostname",
|
||||||
|
stdinTTY: true,
|
||||||
|
stdin: "ghi789",
|
||||||
|
cli: "--with-token --hostname brad.vickers",
|
||||||
|
wants: LoginOptions{
|
||||||
|
Hostname: "brad.vickers",
|
||||||
|
Token: "ghi789",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "tty, hostname",
|
||||||
|
stdinTTY: true,
|
||||||
|
cli: "--hostname barry.burton",
|
||||||
|
wants: LoginOptions{
|
||||||
|
Hostname: "barry.burton",
|
||||||
|
Token: "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "tty",
|
||||||
|
stdinTTY: true,
|
||||||
|
cli: "",
|
||||||
|
wants: LoginOptions{
|
||||||
|
Hostname: "",
|
||||||
|
Token: "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "tty, GITHUB_TOKEN",
|
||||||
|
stdinTTY: true,
|
||||||
|
cli: "",
|
||||||
|
ghtoken: "abc123",
|
||||||
|
wants: LoginOptions{
|
||||||
|
Hostname: "github.com",
|
||||||
|
Token: "abc123",
|
||||||
|
OnlyValidate: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "nontty, GITHUB_TOKEN",
|
||||||
|
stdinTTY: false,
|
||||||
|
cli: "",
|
||||||
|
ghtoken: "abc123",
|
||||||
|
wants: LoginOptions{
|
||||||
|
Hostname: "github.com",
|
||||||
|
Token: "abc123",
|
||||||
|
OnlyValidate: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
ghtoken := os.Getenv("GITHUB_TOKEN")
|
||||||
|
defer func() {
|
||||||
|
os.Setenv("GITHUB_TOKEN", ghtoken)
|
||||||
|
}()
|
||||||
|
os.Setenv("GITHUB_TOKEN", tt.ghtoken)
|
||||||
|
io, stdin, _, _ := iostreams.Test()
|
||||||
|
f := &cmdutil.Factory{
|
||||||
|
IOStreams: io,
|
||||||
|
}
|
||||||
|
|
||||||
|
io.SetStdinTTY(tt.stdinTTY)
|
||||||
|
if tt.stdin != "" {
|
||||||
|
stdin.WriteString(tt.stdin)
|
||||||
|
}
|
||||||
|
|
||||||
|
argv, err := shlex.Split(tt.cli)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
var gotOpts *LoginOptions
|
||||||
|
cmd := NewCmdLogin(f, func(opts *LoginOptions) error {
|
||||||
|
gotOpts = opts
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
// TODO cobra hack-around
|
||||||
|
cmd.Flags().BoolP("help", "x", false, "")
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
assert.Equal(t, tt.wants.Token, gotOpts.Token)
|
||||||
|
assert.Equal(t, tt.wants.Hostname, gotOpts.Hostname)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_loginRun_nontty(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
opts *LoginOptions
|
||||||
|
httpStubs func(*httpmock.Registry)
|
||||||
|
wantHosts string
|
||||||
|
wantErr *regexp.Regexp
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "with token",
|
||||||
|
opts: &LoginOptions{
|
||||||
|
Hostname: "github.com",
|
||||||
|
Token: "abc123",
|
||||||
|
},
|
||||||
|
wantHosts: "github.com:\n oauth_token: abc123\n",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "with token and non-default host",
|
||||||
|
opts: &LoginOptions{
|
||||||
|
Hostname: "albert.wesker",
|
||||||
|
Token: "abc123",
|
||||||
|
},
|
||||||
|
httpStubs: func(reg *httpmock.Registry) {
|
||||||
|
reg.Register(httpmock.REST("GET", "api/v3/"), httpmock.ScopesResponder("repo,read:org"))
|
||||||
|
},
|
||||||
|
wantHosts: "albert.wesker:\n oauth_token: abc123\n",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing repo scope",
|
||||||
|
opts: &LoginOptions{
|
||||||
|
Hostname: "github.com",
|
||||||
|
Token: "abc456",
|
||||||
|
},
|
||||||
|
httpStubs: func(reg *httpmock.Registry) {
|
||||||
|
reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("read:org"))
|
||||||
|
},
|
||||||
|
wantErr: regexp.MustCompile(`missing required scope 'repo'`),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing read scope",
|
||||||
|
opts: &LoginOptions{
|
||||||
|
Hostname: "github.com",
|
||||||
|
Token: "abc456",
|
||||||
|
},
|
||||||
|
httpStubs: func(reg *httpmock.Registry) {
|
||||||
|
reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo"))
|
||||||
|
},
|
||||||
|
wantErr: regexp.MustCompile(`missing required scope 'read:org'`),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "has admin scope",
|
||||||
|
opts: &LoginOptions{
|
||||||
|
Hostname: "github.com",
|
||||||
|
Token: "abc456",
|
||||||
|
},
|
||||||
|
httpStubs: func(reg *httpmock.Registry) {
|
||||||
|
reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,admin:org"))
|
||||||
|
},
|
||||||
|
wantHosts: "github.com:\n oauth_token: abc456\n",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
io, _, stdout, stderr := iostreams.Test()
|
||||||
|
|
||||||
|
io.SetStdinTTY(false)
|
||||||
|
io.SetStdoutTTY(false)
|
||||||
|
|
||||||
|
tt.opts.Config = func() (config.Config, error) {
|
||||||
|
return config.NewBlankConfig(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
tt.opts.IO = io
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
reg := &httpmock.Registry{}
|
||||||
|
origClientFromCfg := client.ClientFromCfg
|
||||||
|
defer func() {
|
||||||
|
client.ClientFromCfg = origClientFromCfg
|
||||||
|
}()
|
||||||
|
client.ClientFromCfg = func(_ string, _ config.Config) (*api.Client, error) {
|
||||||
|
httpClient := &http.Client{Transport: reg}
|
||||||
|
return api.NewClientFromHTTP(httpClient), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if tt.httpStubs != nil {
|
||||||
|
tt.httpStubs(reg)
|
||||||
|
} else {
|
||||||
|
reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,read:org"))
|
||||||
|
}
|
||||||
|
|
||||||
|
mainBuf := bytes.Buffer{}
|
||||||
|
hostsBuf := bytes.Buffer{}
|
||||||
|
defer config.StubWriteConfig(&mainBuf, &hostsBuf)()
|
||||||
|
|
||||||
|
err := loginRun(tt.opts)
|
||||||
|
assert.Equal(t, tt.wantErr == nil, err == nil)
|
||||||
|
if err != nil {
|
||||||
|
if tt.wantErr != nil {
|
||||||
|
assert.True(t, tt.wantErr.MatchString(err.Error()))
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
t.Fatalf("unexpected error: %s", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(t, "", stdout.String())
|
||||||
|
assert.Equal(t, "", stderr.String())
|
||||||
|
assert.Equal(t, tt.wantHosts, hostsBuf.String())
|
||||||
|
reg.Verify(t)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_loginRun_Survey(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
opts *LoginOptions
|
||||||
|
httpStubs func(*httpmock.Registry)
|
||||||
|
askStubs func(*prompt.AskStubber)
|
||||||
|
wantHosts string
|
||||||
|
cfg func(config.Config)
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "already authenticated",
|
||||||
|
cfg: func(cfg config.Config) {
|
||||||
|
_ = cfg.Set("github.com", "oauth_token", "ghi789")
|
||||||
|
},
|
||||||
|
httpStubs: func(reg *httpmock.Registry) {
|
||||||
|
reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,read:org,"))
|
||||||
|
reg.Register(
|
||||||
|
httpmock.GraphQL(`query UserCurrent\b`),
|
||||||
|
httpmock.StringResponse(`{"data":{"viewer":{"login":"jillv"}}}`))
|
||||||
|
},
|
||||||
|
askStubs: func(as *prompt.AskStubber) {
|
||||||
|
as.StubOne(0) // host type github.com
|
||||||
|
as.StubOne(false) // do not continue
|
||||||
|
},
|
||||||
|
wantHosts: "", // nothing should have been written to hosts
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "hostname set",
|
||||||
|
opts: &LoginOptions{
|
||||||
|
Hostname: "rebecca.chambers",
|
||||||
|
},
|
||||||
|
wantHosts: "rebecca.chambers:\n oauth_token: def456\n git_protocol: https\n user: jillv\n",
|
||||||
|
askStubs: func(as *prompt.AskStubber) {
|
||||||
|
as.StubOne(1) // auth mode: token
|
||||||
|
as.StubOne("def456") // auth token
|
||||||
|
as.StubOne("HTTPS") // git_protocol
|
||||||
|
},
|
||||||
|
httpStubs: func(reg *httpmock.Registry) {
|
||||||
|
reg.Register(httpmock.REST("GET", "api/v3/"), httpmock.ScopesResponder("repo,read:org,"))
|
||||||
|
reg.Register(
|
||||||
|
httpmock.GraphQL(`query UserCurrent\b`),
|
||||||
|
httpmock.StringResponse(`{"data":{"viewer":{"login":"jillv"}}}`))
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "choose enterprise",
|
||||||
|
wantHosts: "brad.vickers:\n oauth_token: def456\n git_protocol: https\n user: jillv\n",
|
||||||
|
askStubs: func(as *prompt.AskStubber) {
|
||||||
|
as.StubOne(1) // host type enterprise
|
||||||
|
as.StubOne("brad.vickers") // hostname
|
||||||
|
as.StubOne(1) // auth mode: token
|
||||||
|
as.StubOne("def456") // auth token
|
||||||
|
as.StubOne("HTTPS") // git_protocol
|
||||||
|
},
|
||||||
|
httpStubs: func(reg *httpmock.Registry) {
|
||||||
|
reg.Register(httpmock.REST("GET", "api/v3/"), httpmock.ScopesResponder("repo,read:org,"))
|
||||||
|
reg.Register(
|
||||||
|
httpmock.GraphQL(`query UserCurrent\b`),
|
||||||
|
httpmock.StringResponse(`{"data":{"viewer":{"login":"jillv"}}}`))
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "choose github.com",
|
||||||
|
wantHosts: "github.com:\n oauth_token: def456\n git_protocol: https\n user: jillv\n",
|
||||||
|
askStubs: func(as *prompt.AskStubber) {
|
||||||
|
as.StubOne(0) // host type github.com
|
||||||
|
as.StubOne(1) // auth mode: token
|
||||||
|
as.StubOne("def456") // auth token
|
||||||
|
as.StubOne("HTTPS") // git_protocol
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "sets git_protocol",
|
||||||
|
wantHosts: "github.com:\n oauth_token: def456\n git_protocol: ssh\n user: jillv\n",
|
||||||
|
askStubs: func(as *prompt.AskStubber) {
|
||||||
|
as.StubOne(0) // host type github.com
|
||||||
|
as.StubOne(1) // auth mode: token
|
||||||
|
as.StubOne("def456") // auth token
|
||||||
|
as.StubOne("SSH") // git_protocol
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// TODO how to test browser auth?
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
if tt.opts == nil {
|
||||||
|
tt.opts = &LoginOptions{}
|
||||||
|
}
|
||||||
|
io, _, _, _ := iostreams.Test()
|
||||||
|
|
||||||
|
io.SetStdinTTY(true)
|
||||||
|
io.SetStderrTTY(true)
|
||||||
|
io.SetStdoutTTY(true)
|
||||||
|
|
||||||
|
tt.opts.IO = io
|
||||||
|
|
||||||
|
cfg := config.NewBlankConfig()
|
||||||
|
|
||||||
|
if tt.cfg != nil {
|
||||||
|
tt.cfg(cfg)
|
||||||
|
}
|
||||||
|
tt.opts.Config = func() (config.Config, error) {
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
reg := &httpmock.Registry{}
|
||||||
|
origClientFromCfg := client.ClientFromCfg
|
||||||
|
defer func() {
|
||||||
|
client.ClientFromCfg = origClientFromCfg
|
||||||
|
}()
|
||||||
|
client.ClientFromCfg = func(_ string, _ config.Config) (*api.Client, error) {
|
||||||
|
httpClient := &http.Client{Transport: reg}
|
||||||
|
return api.NewClientFromHTTP(httpClient), nil
|
||||||
|
}
|
||||||
|
if tt.httpStubs != nil {
|
||||||
|
tt.httpStubs(reg)
|
||||||
|
} else {
|
||||||
|
reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,read:org,"))
|
||||||
|
reg.Register(
|
||||||
|
httpmock.GraphQL(`query UserCurrent\b`),
|
||||||
|
httpmock.StringResponse(`{"data":{"viewer":{"login":"jillv"}}}`))
|
||||||
|
}
|
||||||
|
|
||||||
|
mainBuf := bytes.Buffer{}
|
||||||
|
hostsBuf := bytes.Buffer{}
|
||||||
|
defer config.StubWriteConfig(&mainBuf, &hostsBuf)()
|
||||||
|
|
||||||
|
as, teardown := prompt.InitAskStubber()
|
||||||
|
defer teardown()
|
||||||
|
if tt.askStubs != nil {
|
||||||
|
tt.askStubs(as)
|
||||||
|
}
|
||||||
|
|
||||||
|
err := loginRun(tt.opts)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(t, tt.wantHosts, hostsBuf.String())
|
||||||
|
reg.Verify(t)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
162
pkg/cmd/auth/logout/logout.go
Normal file
162
pkg/cmd/auth/logout/logout.go
Normal file
|
|
@ -0,0 +1,162 @@
|
||||||
|
package logout
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/AlecAivazis/survey/v2"
|
||||||
|
"github.com/MakeNowJust/heredoc"
|
||||||
|
"github.com/cli/cli/api"
|
||||||
|
"github.com/cli/cli/internal/config"
|
||||||
|
"github.com/cli/cli/pkg/cmdutil"
|
||||||
|
"github.com/cli/cli/pkg/iostreams"
|
||||||
|
"github.com/cli/cli/pkg/prompt"
|
||||||
|
"github.com/cli/cli/utils"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
type LogoutOptions struct {
|
||||||
|
HttpClient func() (*http.Client, error)
|
||||||
|
IO *iostreams.IOStreams
|
||||||
|
Config func() (config.Config, error)
|
||||||
|
|
||||||
|
Hostname string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCmdLogout(f *cmdutil.Factory, runF func(*LogoutOptions) error) *cobra.Command {
|
||||||
|
opts := &LogoutOptions{
|
||||||
|
HttpClient: f.HttpClient,
|
||||||
|
IO: f.IOStreams,
|
||||||
|
Config: f.Config,
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "logout",
|
||||||
|
Args: cobra.ExactArgs(0),
|
||||||
|
Short: "Log out of a GitHub host",
|
||||||
|
Long: heredoc.Doc(`Remove authentication for a GitHub host.
|
||||||
|
|
||||||
|
This command removes the authentication configuration for a host either specified
|
||||||
|
interactively or via --hostname.
|
||||||
|
`),
|
||||||
|
Example: heredoc.Doc(`
|
||||||
|
$ gh auth logout
|
||||||
|
# => select what host to log out of via a prompt
|
||||||
|
|
||||||
|
$ gh auth logout --hostname enterprise.internal
|
||||||
|
# => log out of specified host
|
||||||
|
`),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
if runF != nil {
|
||||||
|
return runF(opts)
|
||||||
|
}
|
||||||
|
|
||||||
|
return logoutRun(opts)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Flags().StringVarP(&opts.Hostname, "hostname", "h", "", "The hostname of the GitHub instance to log out of")
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func logoutRun(opts *LogoutOptions) error {
|
||||||
|
if os.Getenv("GITHUB_TOKEN") != "" {
|
||||||
|
return errors.New("GITHUB_TOKEN is set in your environment. If you no longer want to use it with gh, please unset it.")
|
||||||
|
}
|
||||||
|
|
||||||
|
isTTY := opts.IO.IsStdinTTY() && opts.IO.IsStdoutTTY()
|
||||||
|
|
||||||
|
hostname := opts.Hostname
|
||||||
|
|
||||||
|
if !isTTY && hostname == "" {
|
||||||
|
return errors.New("--hostname required when not attached to a terminal")
|
||||||
|
}
|
||||||
|
|
||||||
|
showConfirm := isTTY && hostname == ""
|
||||||
|
|
||||||
|
cfg, err := opts.Config()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
candidates, err := cfg.Hosts()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("not logged in to any hosts")
|
||||||
|
}
|
||||||
|
|
||||||
|
if hostname == "" {
|
||||||
|
if len(candidates) == 1 {
|
||||||
|
hostname = candidates[0]
|
||||||
|
} else {
|
||||||
|
err = prompt.SurveyAskOne(&survey.Select{
|
||||||
|
Message: "What account do you want to log out of?",
|
||||||
|
Options: candidates,
|
||||||
|
}, &hostname)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("could not prompt: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
var found bool
|
||||||
|
for _, c := range candidates {
|
||||||
|
if c == hostname {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !found {
|
||||||
|
return fmt.Errorf("not logged into %s", hostname)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
httpClient, err := opts.HttpClient()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
apiClient := api.NewClientFromHTTP(httpClient)
|
||||||
|
|
||||||
|
username, err := api.CurrentLoginName(apiClient, hostname)
|
||||||
|
if err != nil {
|
||||||
|
// suppressing; the user is trying to delete this token and it might be bad.
|
||||||
|
// we'll see if the username is in the config and fall back to that.
|
||||||
|
username, _ = cfg.Get(hostname, "user")
|
||||||
|
}
|
||||||
|
|
||||||
|
usernameStr := ""
|
||||||
|
if username != "" {
|
||||||
|
usernameStr = fmt.Sprintf(" account '%s'", username)
|
||||||
|
}
|
||||||
|
|
||||||
|
if showConfirm {
|
||||||
|
var keepGoing bool
|
||||||
|
err := prompt.SurveyAskOne(&survey.Confirm{
|
||||||
|
Message: fmt.Sprintf("Are you sure you want to log out of %s%s?", hostname, usernameStr),
|
||||||
|
Default: true,
|
||||||
|
}, &keepGoing)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("could not prompt: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !keepGoing {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg.UnsetHost(hostname)
|
||||||
|
err = cfg.Write()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to write config, authentication configuration not updated: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if isTTY {
|
||||||
|
fmt.Fprintf(opts.IO.ErrOut, "%s Logged out of %s%s\n",
|
||||||
|
utils.GreenCheck(), utils.Bold(hostname), usernameStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
272
pkg/cmd/auth/logout/logout_test.go
Normal file
272
pkg/cmd/auth/logout/logout_test.go
Normal file
|
|
@ -0,0 +1,272 @@
|
||||||
|
package logout
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"regexp"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/cli/cli/internal/config"
|
||||||
|
"github.com/cli/cli/pkg/cmdutil"
|
||||||
|
"github.com/cli/cli/pkg/httpmock"
|
||||||
|
"github.com/cli/cli/pkg/iostreams"
|
||||||
|
"github.com/cli/cli/pkg/prompt"
|
||||||
|
"github.com/google/shlex"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Test_NewCmdLogout(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
cli string
|
||||||
|
wants LogoutOptions
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "with hostname",
|
||||||
|
cli: "--hostname harry.mason",
|
||||||
|
wants: LogoutOptions{
|
||||||
|
Hostname: "harry.mason",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no arguments",
|
||||||
|
cli: "",
|
||||||
|
wants: LogoutOptions{
|
||||||
|
Hostname: "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
io, _, _, _ := iostreams.Test()
|
||||||
|
f := &cmdutil.Factory{
|
||||||
|
IOStreams: io,
|
||||||
|
}
|
||||||
|
|
||||||
|
argv, err := shlex.Split(tt.cli)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
var gotOpts *LogoutOptions
|
||||||
|
cmd := NewCmdLogout(f, func(opts *LogoutOptions) error {
|
||||||
|
gotOpts = opts
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
// TODO cobra hack-around
|
||||||
|
cmd.Flags().BoolP("help", "x", false, "")
|
||||||
|
|
||||||
|
cmd.SetArgs(argv)
|
||||||
|
cmd.SetIn(&bytes.Buffer{})
|
||||||
|
cmd.SetOut(&bytes.Buffer{})
|
||||||
|
cmd.SetErr(&bytes.Buffer{})
|
||||||
|
|
||||||
|
_, err = cmd.ExecuteC()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, tt.wants.Hostname, gotOpts.Hostname)
|
||||||
|
})
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_logoutRun_tty(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
opts *LogoutOptions
|
||||||
|
askStubs func(*prompt.AskStubber)
|
||||||
|
cfgHosts []string
|
||||||
|
wantHosts string
|
||||||
|
wantErrOut *regexp.Regexp
|
||||||
|
wantErr *regexp.Regexp
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "no arguments, multiple hosts",
|
||||||
|
opts: &LogoutOptions{},
|
||||||
|
cfgHosts: []string{"cheryl.mason", "github.com"},
|
||||||
|
wantHosts: "cheryl.mason:\n oauth_token: abc123\n",
|
||||||
|
askStubs: func(as *prompt.AskStubber) {
|
||||||
|
as.StubOne("github.com")
|
||||||
|
as.StubOne(true)
|
||||||
|
},
|
||||||
|
wantErrOut: regexp.MustCompile(`Logged out of github.com account 'cybilb'`),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no arguments, one host",
|
||||||
|
opts: &LogoutOptions{},
|
||||||
|
cfgHosts: []string{"github.com"},
|
||||||
|
askStubs: func(as *prompt.AskStubber) {
|
||||||
|
as.StubOne(true)
|
||||||
|
},
|
||||||
|
wantErrOut: regexp.MustCompile(`Logged out of github.com account 'cybilb'`),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no arguments, no hosts",
|
||||||
|
opts: &LogoutOptions{},
|
||||||
|
wantErr: regexp.MustCompile(`not logged in to any hosts`),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "hostname",
|
||||||
|
opts: &LogoutOptions{
|
||||||
|
Hostname: "cheryl.mason",
|
||||||
|
},
|
||||||
|
cfgHosts: []string{"cheryl.mason", "github.com"},
|
||||||
|
wantHosts: "github.com:\n oauth_token: abc123\n",
|
||||||
|
askStubs: func(as *prompt.AskStubber) {
|
||||||
|
as.StubOne(true)
|
||||||
|
},
|
||||||
|
wantErrOut: regexp.MustCompile(`Logged out of cheryl.mason account 'cybilb'`),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
io, _, _, stderr := iostreams.Test()
|
||||||
|
|
||||||
|
io.SetStdinTTY(true)
|
||||||
|
io.SetStdoutTTY(true)
|
||||||
|
|
||||||
|
tt.opts.IO = io
|
||||||
|
cfg := config.NewBlankConfig()
|
||||||
|
tt.opts.Config = func() (config.Config, error) {
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, hostname := range tt.cfgHosts {
|
||||||
|
_ = cfg.Set(hostname, "oauth_token", "abc123")
|
||||||
|
}
|
||||||
|
|
||||||
|
reg := &httpmock.Registry{}
|
||||||
|
reg.Register(
|
||||||
|
httpmock.GraphQL(`query UserCurrent\b`),
|
||||||
|
httpmock.StringResponse(`{"data":{"viewer":{"login":"cybilb"}}}`))
|
||||||
|
|
||||||
|
tt.opts.HttpClient = func() (*http.Client, error) {
|
||||||
|
return &http.Client{Transport: reg}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
mainBuf := bytes.Buffer{}
|
||||||
|
hostsBuf := bytes.Buffer{}
|
||||||
|
defer config.StubWriteConfig(&mainBuf, &hostsBuf)()
|
||||||
|
|
||||||
|
as, teardown := prompt.InitAskStubber()
|
||||||
|
defer teardown()
|
||||||
|
if tt.askStubs != nil {
|
||||||
|
tt.askStubs(as)
|
||||||
|
}
|
||||||
|
|
||||||
|
err := logoutRun(tt.opts)
|
||||||
|
assert.Equal(t, tt.wantErr == nil, err == nil)
|
||||||
|
if err != nil {
|
||||||
|
if tt.wantErr != nil {
|
||||||
|
assert.True(t, tt.wantErr.MatchString(err.Error()))
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
t.Fatalf("unexpected error: %s", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if tt.wantErrOut == nil {
|
||||||
|
assert.Equal(t, "", stderr.String())
|
||||||
|
} else {
|
||||||
|
assert.True(t, tt.wantErrOut.MatchString(stderr.String()))
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(t, tt.wantHosts, hostsBuf.String())
|
||||||
|
reg.Verify(t)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_logoutRun_nontty(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
opts *LogoutOptions
|
||||||
|
cfgHosts []string
|
||||||
|
wantHosts string
|
||||||
|
wantErr *regexp.Regexp
|
||||||
|
ghtoken string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "no arguments",
|
||||||
|
wantErr: regexp.MustCompile(`hostname required when not`),
|
||||||
|
opts: &LogoutOptions{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "hostname, one host",
|
||||||
|
opts: &LogoutOptions{
|
||||||
|
Hostname: "harry.mason",
|
||||||
|
},
|
||||||
|
cfgHosts: []string{"harry.mason"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "hostname, multiple hosts",
|
||||||
|
opts: &LogoutOptions{
|
||||||
|
Hostname: "harry.mason",
|
||||||
|
},
|
||||||
|
cfgHosts: []string{"harry.mason", "cheryl.mason"},
|
||||||
|
wantHosts: "cheryl.mason:\n oauth_token: abc123\n",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "hostname, no hosts",
|
||||||
|
opts: &LogoutOptions{
|
||||||
|
Hostname: "harry.mason",
|
||||||
|
},
|
||||||
|
wantErr: regexp.MustCompile(`not logged in to any hosts`),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "gh token is set",
|
||||||
|
opts: &LogoutOptions{},
|
||||||
|
ghtoken: "abc123",
|
||||||
|
wantErr: regexp.MustCompile(`GITHUB_TOKEN is set in your environment`),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
ghtoken := os.Getenv("GITHUB_TOKEN")
|
||||||
|
defer func() {
|
||||||
|
os.Setenv("GITHUB_TOKEN", ghtoken)
|
||||||
|
}()
|
||||||
|
os.Setenv("GITHUB_TOKEN", tt.ghtoken)
|
||||||
|
io, _, _, stderr := iostreams.Test()
|
||||||
|
|
||||||
|
io.SetStdinTTY(false)
|
||||||
|
io.SetStdoutTTY(false)
|
||||||
|
|
||||||
|
tt.opts.IO = io
|
||||||
|
cfg := config.NewBlankConfig()
|
||||||
|
tt.opts.Config = func() (config.Config, error) {
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, hostname := range tt.cfgHosts {
|
||||||
|
_ = cfg.Set(hostname, "oauth_token", "abc123")
|
||||||
|
}
|
||||||
|
|
||||||
|
reg := &httpmock.Registry{}
|
||||||
|
tt.opts.HttpClient = func() (*http.Client, error) {
|
||||||
|
return &http.Client{Transport: reg}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
mainBuf := bytes.Buffer{}
|
||||||
|
hostsBuf := bytes.Buffer{}
|
||||||
|
defer config.StubWriteConfig(&mainBuf, &hostsBuf)()
|
||||||
|
|
||||||
|
err := logoutRun(tt.opts)
|
||||||
|
assert.Equal(t, tt.wantErr == nil, err == nil)
|
||||||
|
if err != nil {
|
||||||
|
if tt.wantErr != nil {
|
||||||
|
assert.True(t, tt.wantErr.MatchString(err.Error()))
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
t.Fatalf("unexpected error: %s", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(t, "", stderr.String())
|
||||||
|
|
||||||
|
assert.Equal(t, tt.wantHosts, hostsBuf.String())
|
||||||
|
reg.Verify(t)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
116
pkg/cmd/auth/refresh/refresh.go
Normal file
116
pkg/cmd/auth/refresh/refresh.go
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
package refresh
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/AlecAivazis/survey/v2"
|
||||||
|
"github.com/MakeNowJust/heredoc"
|
||||||
|
"github.com/cli/cli/internal/config"
|
||||||
|
"github.com/cli/cli/pkg/cmdutil"
|
||||||
|
"github.com/cli/cli/pkg/iostreams"
|
||||||
|
"github.com/cli/cli/pkg/prompt"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
type RefreshOptions struct {
|
||||||
|
IO *iostreams.IOStreams
|
||||||
|
Config func() (config.Config, error)
|
||||||
|
|
||||||
|
Hostname string
|
||||||
|
Scopes []string
|
||||||
|
AuthFlow func(config.Config, string, []string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCmdRefresh(f *cmdutil.Factory, runF func(*RefreshOptions) error) *cobra.Command {
|
||||||
|
opts := &RefreshOptions{
|
||||||
|
IO: f.IOStreams,
|
||||||
|
Config: f.Config,
|
||||||
|
AuthFlow: func(cfg config.Config, hostname string, scopes []string) error {
|
||||||
|
_, err := config.AuthFlowWithConfig(cfg, hostname, "", scopes)
|
||||||
|
return err
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "refresh",
|
||||||
|
Args: cobra.ExactArgs(0),
|
||||||
|
Short: "Refresh stored authentication credentials",
|
||||||
|
Long: heredoc.Doc(`Expand or fix the permission scopes for stored credentials
|
||||||
|
|
||||||
|
The --scopes flag accepts a comma separated list of scopes you want your gh credentials to have. If
|
||||||
|
absent, this command ensures that gh has access to a minimum set of scopes.
|
||||||
|
`),
|
||||||
|
Example: heredoc.Doc(`
|
||||||
|
$ gh auth refresh --scopes write:org,read:public_key
|
||||||
|
# => open a browser to add write:org and read:public_key scopes for use with gh api
|
||||||
|
|
||||||
|
$ gh auth refresh
|
||||||
|
# => open a browser to ensure your authentication credentials have the correct minimum scopes
|
||||||
|
`),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
if runF != nil {
|
||||||
|
return runF(opts)
|
||||||
|
}
|
||||||
|
|
||||||
|
return refreshRun(opts)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Flags().StringVarP(&opts.Hostname, "hostname", "h", "", "The GitHub host to use for authentication")
|
||||||
|
cmd.Flags().StringSliceVarP(&opts.Scopes, "scopes", "s", nil, "Additional authentication scopes for gh to have")
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func refreshRun(opts *RefreshOptions) error {
|
||||||
|
if os.Getenv("GITHUB_TOKEN") != "" {
|
||||||
|
return fmt.Errorf("GITHUB_TOKEN is present in your environment and is incompatible with this command. If you'd like to modify a personal access token, see https://github.com/settings/tokens")
|
||||||
|
}
|
||||||
|
|
||||||
|
isTTY := opts.IO.IsStdinTTY() && opts.IO.IsStdoutTTY()
|
||||||
|
|
||||||
|
if !isTTY {
|
||||||
|
return fmt.Errorf("not attached to a terminal; in headless environments, GITHUB_TOKEN is recommended")
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := opts.Config()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
candidates, err := cfg.Hosts()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("not logged in to any hosts. Use 'gh auth login' to authenticate with a host")
|
||||||
|
}
|
||||||
|
|
||||||
|
hostname := opts.Hostname
|
||||||
|
if hostname == "" {
|
||||||
|
if len(candidates) == 1 {
|
||||||
|
hostname = candidates[0]
|
||||||
|
} else {
|
||||||
|
err := prompt.SurveyAskOne(&survey.Select{
|
||||||
|
Message: "What account do you want to refresh auth for?",
|
||||||
|
Options: candidates,
|
||||||
|
}, &hostname)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("could not prompt: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
var found bool
|
||||||
|
for _, c := range candidates {
|
||||||
|
if c == hostname {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !found {
|
||||||
|
return fmt.Errorf("not logged in to %s. use 'gh auth login' to authenticate with this host", hostname)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return opts.AuthFlow(cfg, hostname, opts.Scopes)
|
||||||
|
}
|
||||||
244
pkg/cmd/auth/refresh/refresh_test.go
Normal file
244
pkg/cmd/auth/refresh/refresh_test.go
Normal file
|
|
@ -0,0 +1,244 @@
|
||||||
|
package refresh
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"os"
|
||||||
|
"regexp"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/cli/cli/internal/config"
|
||||||
|
"github.com/cli/cli/pkg/cmdutil"
|
||||||
|
"github.com/cli/cli/pkg/httpmock"
|
||||||
|
"github.com/cli/cli/pkg/iostreams"
|
||||||
|
"github.com/cli/cli/pkg/prompt"
|
||||||
|
"github.com/google/shlex"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Test_NewCmdRefresh(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
cli string
|
||||||
|
wants RefreshOptions
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "no arguments",
|
||||||
|
wants: RefreshOptions{
|
||||||
|
Hostname: "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "hostname",
|
||||||
|
cli: "-h aline.cedrac",
|
||||||
|
wants: RefreshOptions{
|
||||||
|
Hostname: "aline.cedrac",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "one scope",
|
||||||
|
cli: "--scopes repo:invite",
|
||||||
|
wants: RefreshOptions{
|
||||||
|
Scopes: []string{"repo:invite"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "scopes",
|
||||||
|
cli: "--scopes repo:invite,read:public_key",
|
||||||
|
wants: RefreshOptions{
|
||||||
|
Scopes: []string{"repo:invite", "read:public_key"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
io, _, _, _ := iostreams.Test()
|
||||||
|
f := &cmdutil.Factory{
|
||||||
|
IOStreams: io,
|
||||||
|
}
|
||||||
|
|
||||||
|
argv, err := shlex.Split(tt.cli)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
var gotOpts *RefreshOptions
|
||||||
|
cmd := NewCmdRefresh(f, func(opts *RefreshOptions) error {
|
||||||
|
gotOpts = opts
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
// TODO cobra hack-around
|
||||||
|
cmd.Flags().BoolP("help", "x", false, "")
|
||||||
|
|
||||||
|
cmd.SetArgs(argv)
|
||||||
|
cmd.SetIn(&bytes.Buffer{})
|
||||||
|
cmd.SetOut(&bytes.Buffer{})
|
||||||
|
cmd.SetErr(&bytes.Buffer{})
|
||||||
|
|
||||||
|
_, err = cmd.ExecuteC()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, tt.wants.Hostname, gotOpts.Hostname)
|
||||||
|
assert.Equal(t, tt.wants.Scopes, gotOpts.Scopes)
|
||||||
|
})
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type authArgs struct {
|
||||||
|
hostname string
|
||||||
|
scopes []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_refreshRun(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
opts *RefreshOptions
|
||||||
|
askStubs func(*prompt.AskStubber)
|
||||||
|
cfgHosts []string
|
||||||
|
wantErr *regexp.Regexp
|
||||||
|
ghtoken string
|
||||||
|
nontty bool
|
||||||
|
wantAuthArgs authArgs
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "GITHUB_TOKEN set",
|
||||||
|
opts: &RefreshOptions{},
|
||||||
|
ghtoken: "abc123",
|
||||||
|
wantErr: regexp.MustCompile(`GITHUB_TOKEN is present in your environment`),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "non tty",
|
||||||
|
opts: &RefreshOptions{},
|
||||||
|
nontty: true,
|
||||||
|
wantErr: regexp.MustCompile(`not attached to a terminal;`),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no hosts configured",
|
||||||
|
opts: &RefreshOptions{},
|
||||||
|
wantErr: regexp.MustCompile(`not logged in to any hosts`),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "hostname given but dne",
|
||||||
|
cfgHosts: []string{
|
||||||
|
"github.com",
|
||||||
|
"aline.cedrac",
|
||||||
|
},
|
||||||
|
opts: &RefreshOptions{
|
||||||
|
Hostname: "obed.morton",
|
||||||
|
},
|
||||||
|
wantErr: regexp.MustCompile(`not logged in to obed.morton`),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "hostname provided and is configured",
|
||||||
|
cfgHosts: []string{
|
||||||
|
"obed.morton",
|
||||||
|
"github.com",
|
||||||
|
},
|
||||||
|
opts: &RefreshOptions{
|
||||||
|
Hostname: "obed.morton",
|
||||||
|
},
|
||||||
|
wantAuthArgs: authArgs{
|
||||||
|
hostname: "obed.morton",
|
||||||
|
scopes: nil,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no hostname, one host configured",
|
||||||
|
cfgHosts: []string{
|
||||||
|
"github.com",
|
||||||
|
},
|
||||||
|
opts: &RefreshOptions{
|
||||||
|
Hostname: "",
|
||||||
|
},
|
||||||
|
wantAuthArgs: authArgs{
|
||||||
|
hostname: "github.com",
|
||||||
|
scopes: nil,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no hostname, multiple hosts configured",
|
||||||
|
cfgHosts: []string{
|
||||||
|
"github.com",
|
||||||
|
"aline.cedrac",
|
||||||
|
},
|
||||||
|
opts: &RefreshOptions{
|
||||||
|
Hostname: "",
|
||||||
|
},
|
||||||
|
askStubs: func(as *prompt.AskStubber) {
|
||||||
|
as.StubOne("github.com")
|
||||||
|
},
|
||||||
|
wantAuthArgs: authArgs{
|
||||||
|
hostname: "github.com",
|
||||||
|
scopes: nil,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "scopes provided",
|
||||||
|
cfgHosts: []string{
|
||||||
|
"github.com",
|
||||||
|
},
|
||||||
|
opts: &RefreshOptions{
|
||||||
|
Scopes: []string{"repo:invite", "public_key:read"},
|
||||||
|
},
|
||||||
|
wantAuthArgs: authArgs{
|
||||||
|
hostname: "github.com",
|
||||||
|
scopes: []string{"repo:invite", "public_key:read"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
aa := authArgs{}
|
||||||
|
tt.opts.AuthFlow = func(_ config.Config, hostname string, scopes []string) error {
|
||||||
|
aa.hostname = hostname
|
||||||
|
aa.scopes = scopes
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
ghtoken := os.Getenv("GITHUB_TOKEN")
|
||||||
|
defer func() {
|
||||||
|
os.Setenv("GITHUB_TOKEN", ghtoken)
|
||||||
|
}()
|
||||||
|
os.Setenv("GITHUB_TOKEN", tt.ghtoken)
|
||||||
|
io, _, _, _ := iostreams.Test()
|
||||||
|
|
||||||
|
io.SetStdinTTY(!tt.nontty)
|
||||||
|
io.SetStdoutTTY(!tt.nontty)
|
||||||
|
|
||||||
|
tt.opts.IO = io
|
||||||
|
cfg := config.NewBlankConfig()
|
||||||
|
tt.opts.Config = func() (config.Config, error) {
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
for _, hostname := range tt.cfgHosts {
|
||||||
|
_ = cfg.Set(hostname, "oauth_token", "abc123")
|
||||||
|
}
|
||||||
|
reg := &httpmock.Registry{}
|
||||||
|
reg.Register(
|
||||||
|
httpmock.GraphQL(`query UserCurrent\b`),
|
||||||
|
httpmock.StringResponse(`{"data":{"viewer":{"login":"cybilb"}}}`))
|
||||||
|
|
||||||
|
mainBuf := bytes.Buffer{}
|
||||||
|
hostsBuf := bytes.Buffer{}
|
||||||
|
defer config.StubWriteConfig(&mainBuf, &hostsBuf)()
|
||||||
|
|
||||||
|
as, teardown := prompt.InitAskStubber()
|
||||||
|
defer teardown()
|
||||||
|
if tt.askStubs != nil {
|
||||||
|
tt.askStubs(as)
|
||||||
|
}
|
||||||
|
|
||||||
|
err := refreshRun(tt.opts)
|
||||||
|
assert.Equal(t, tt.wantErr == nil, err == nil)
|
||||||
|
if err != nil {
|
||||||
|
if tt.wantErr != nil {
|
||||||
|
assert.True(t, tt.wantErr.MatchString(err.Error()))
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
t.Fatalf("unexpected error: %s", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(t, aa.hostname, tt.wantAuthArgs.hostname)
|
||||||
|
assert.Equal(t, aa.scopes, tt.wantAuthArgs.scopes)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
199
pkg/cmd/auth/status/status.go
Normal file
199
pkg/cmd/auth/status/status.go
Normal file
|
|
@ -0,0 +1,199 @@
|
||||||
|
package status
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/MakeNowJust/heredoc"
|
||||||
|
"github.com/cli/cli/api"
|
||||||
|
"github.com/cli/cli/internal/config"
|
||||||
|
"github.com/cli/cli/internal/ghinstance"
|
||||||
|
"github.com/cli/cli/pkg/cmd/auth/client"
|
||||||
|
"github.com/cli/cli/pkg/cmdutil"
|
||||||
|
"github.com/cli/cli/pkg/iostreams"
|
||||||
|
"github.com/cli/cli/utils"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
type StatusOptions struct {
|
||||||
|
HttpClient func() (*http.Client, error)
|
||||||
|
IO *iostreams.IOStreams
|
||||||
|
Config func() (config.Config, error)
|
||||||
|
Token string
|
||||||
|
Hostname string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCmdStatus(f *cmdutil.Factory, runF func(*StatusOptions) error) *cobra.Command {
|
||||||
|
opts := &StatusOptions{
|
||||||
|
HttpClient: f.HttpClient,
|
||||||
|
IO: f.IOStreams,
|
||||||
|
Config: f.Config,
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "status",
|
||||||
|
Args: cobra.ExactArgs(0),
|
||||||
|
Short: "View authentication status",
|
||||||
|
Long: heredoc.Doc(`Verifies and displays information about your authentication state.
|
||||||
|
|
||||||
|
This command will test your authentication state for each GitHub host that gh knows about and
|
||||||
|
report on any issues.
|
||||||
|
`),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
// TODO support other names
|
||||||
|
opts.Token = os.Getenv("GITHUB_TOKEN")
|
||||||
|
|
||||||
|
if opts.Token != "" && opts.Hostname == "" {
|
||||||
|
opts.Hostname = ghinstance.Default()
|
||||||
|
}
|
||||||
|
|
||||||
|
if runF != nil {
|
||||||
|
return runF(opts)
|
||||||
|
}
|
||||||
|
|
||||||
|
return statusRun(opts)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Flags().StringVarP(&opts.Hostname, "hostname", "h", "", "Check a specific hostname's auth status")
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func statusRun(opts *StatusOptions) error {
|
||||||
|
cfg, err := opts.Config()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO check tty
|
||||||
|
|
||||||
|
stderr := opts.IO.ErrOut
|
||||||
|
|
||||||
|
if opts.Token != "" {
|
||||||
|
hostname := opts.Hostname
|
||||||
|
err := cfg.Set(opts.Hostname, "oauth_token", opts.Token)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
apiClient, err := client.ClientFromCfg(hostname, cfg)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = apiClient.HasMinimumScopes(hostname)
|
||||||
|
if err != nil {
|
||||||
|
var missingScopes *api.MissingScopesError
|
||||||
|
if errors.As(err, &missingScopes) {
|
||||||
|
fmt.Fprintf(stderr, "%s %s: %s\n", utils.Red("X"), hostname, err)
|
||||||
|
fmt.Fprintln(stderr,
|
||||||
|
"The token in GITHUB_TOKEN is valid but missing scopes that gh requires to function.")
|
||||||
|
} else {
|
||||||
|
fmt.Fprintf(stderr, "%s %s: authentication failed\n", utils.Red("X"), hostname)
|
||||||
|
fmt.Fprintln(stderr)
|
||||||
|
fmt.Fprintf(stderr,
|
||||||
|
"The token in GITHUB_TOKEN is invalid.\n")
|
||||||
|
}
|
||||||
|
fmt.Fprintf(stderr,
|
||||||
|
"Please visit https://%s/settings/tokens and create a new token with 'repo', 'read:org', and 'gist' scopes.\n", hostname)
|
||||||
|
return cmdutil.SilentError
|
||||||
|
} else {
|
||||||
|
username, err := api.CurrentLoginName(apiClient, hostname)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%s %s: api call failed: %s\n", utils.Red("X"), hostname, err)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(stderr,
|
||||||
|
"%s token valid for %s as %s\n", utils.GreenCheck(), hostname, utils.Bold(username))
|
||||||
|
proto, _ := cfg.Get(hostname, "git_protocol")
|
||||||
|
if proto != "" {
|
||||||
|
fmt.Fprintln(stderr)
|
||||||
|
fmt.Fprintf(stderr,
|
||||||
|
"Git operations for %s configured to use %s protocol.\n", hostname, utils.Bold(proto))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
statusInfo := map[string][]string{}
|
||||||
|
|
||||||
|
hostnames, err := cfg.Hosts()
|
||||||
|
if len(hostnames) == 0 || err != nil {
|
||||||
|
fmt.Fprintf(stderr,
|
||||||
|
"You are not logged into any GitHub hosts. Run %s to authenticate.\n", utils.Bold("gh auth login"))
|
||||||
|
return cmdutil.SilentError
|
||||||
|
}
|
||||||
|
|
||||||
|
httpClient, err := opts.HttpClient()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
apiClient := api.NewClientFromHTTP(httpClient)
|
||||||
|
|
||||||
|
var failed bool
|
||||||
|
|
||||||
|
for _, hostname := range hostnames {
|
||||||
|
if opts.Hostname != "" && opts.Hostname != hostname {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
statusInfo[hostname] = []string{}
|
||||||
|
addMsg := func(x string, ys ...interface{}) {
|
||||||
|
statusInfo[hostname] = append(statusInfo[hostname], fmt.Sprintf(x, ys...))
|
||||||
|
}
|
||||||
|
|
||||||
|
err = apiClient.HasMinimumScopes(hostname)
|
||||||
|
if err != nil {
|
||||||
|
var missingScopes *api.MissingScopesError
|
||||||
|
if errors.As(err, &missingScopes) {
|
||||||
|
addMsg("%s %s: %s\n", utils.Red("X"), hostname, err)
|
||||||
|
addMsg("- To enable the missing scopes, please run %s %s\n",
|
||||||
|
utils.Bold("gh auth refresh -h"),
|
||||||
|
utils.Bold(hostname))
|
||||||
|
} else {
|
||||||
|
addMsg("%s %s: authentication failed\n", utils.Red("X"), hostname)
|
||||||
|
addMsg("- The configured token for %s is no longer valid.", utils.Bold(hostname))
|
||||||
|
addMsg("- To re-authenticate, please run %s %s",
|
||||||
|
utils.Bold("gh auth login -h"), utils.Bold(hostname))
|
||||||
|
addMsg("- To forget about this host, please run %s %s",
|
||||||
|
utils.Bold("gh auth logout -h"), utils.Bold(hostname))
|
||||||
|
}
|
||||||
|
failed = true
|
||||||
|
} else {
|
||||||
|
username, err := api.CurrentLoginName(apiClient, hostname)
|
||||||
|
if err != nil {
|
||||||
|
addMsg("%s %s: api call failed: %s\n", utils.Red("X"), hostname, err)
|
||||||
|
}
|
||||||
|
addMsg("%s Logged in to %s as %s", utils.GreenCheck(), hostname, utils.Bold(username))
|
||||||
|
proto, _ := cfg.Get(hostname, "git_protocol")
|
||||||
|
if proto != "" {
|
||||||
|
addMsg("%s Git operations for %s configured to use %s protocol.",
|
||||||
|
utils.GreenCheck(), hostname, utils.Bold(proto))
|
||||||
|
}
|
||||||
|
addMsg("")
|
||||||
|
}
|
||||||
|
|
||||||
|
// NB we could take this opportunity to add or fix the "user" key in the hosts config. I chose
|
||||||
|
// not to since I wanted this command to be read-only.
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, hostname := range hostnames {
|
||||||
|
lines, ok := statusInfo[hostname]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fmt.Fprintf(stderr, "%s\n", utils.Bold(hostname))
|
||||||
|
for _, line := range lines {
|
||||||
|
fmt.Fprintf(stderr, " %s\n", line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if failed {
|
||||||
|
return cmdutil.SilentError
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
299
pkg/cmd/auth/status/status_test.go
Normal file
299
pkg/cmd/auth/status/status_test.go
Normal file
|
|
@ -0,0 +1,299 @@
|
||||||
|
package status
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"regexp"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/cli/cli/api"
|
||||||
|
"github.com/cli/cli/internal/config"
|
||||||
|
"github.com/cli/cli/pkg/cmd/auth/client"
|
||||||
|
"github.com/cli/cli/pkg/cmdutil"
|
||||||
|
"github.com/cli/cli/pkg/httpmock"
|
||||||
|
"github.com/cli/cli/pkg/iostreams"
|
||||||
|
"github.com/google/shlex"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Test_NewCmdStatus(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
cli string
|
||||||
|
wants StatusOptions
|
||||||
|
ghtoken string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "ghtoken set",
|
||||||
|
cli: "",
|
||||||
|
wants: StatusOptions{
|
||||||
|
Token: "abc123",
|
||||||
|
Hostname: "github.com",
|
||||||
|
},
|
||||||
|
ghtoken: "abc123",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "ghtoken set",
|
||||||
|
cli: "--hostname joel.miller",
|
||||||
|
wants: StatusOptions{
|
||||||
|
Token: "def456",
|
||||||
|
Hostname: "joel.miller",
|
||||||
|
},
|
||||||
|
ghtoken: "def456",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no arguments",
|
||||||
|
cli: "",
|
||||||
|
wants: StatusOptions{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "hostname set",
|
||||||
|
cli: "--hostname ellie.williams",
|
||||||
|
wants: StatusOptions{
|
||||||
|
Hostname: "ellie.williams",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
ghtoken := os.Getenv("GITHUB_TOKEN")
|
||||||
|
defer func() {
|
||||||
|
os.Setenv("GITHUB_TOKEN", ghtoken)
|
||||||
|
}()
|
||||||
|
os.Setenv("GITHUB_TOKEN", tt.ghtoken)
|
||||||
|
|
||||||
|
f := &cmdutil.Factory{}
|
||||||
|
|
||||||
|
argv, err := shlex.Split(tt.cli)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
var gotOpts *StatusOptions
|
||||||
|
cmd := NewCmdStatus(f, func(opts *StatusOptions) error {
|
||||||
|
gotOpts = opts
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
// TODO cobra hack-around
|
||||||
|
cmd.Flags().BoolP("help", "x", false, "")
|
||||||
|
|
||||||
|
cmd.SetArgs(argv)
|
||||||
|
cmd.SetIn(&bytes.Buffer{})
|
||||||
|
cmd.SetOut(&bytes.Buffer{})
|
||||||
|
cmd.SetErr(&bytes.Buffer{})
|
||||||
|
|
||||||
|
_, err = cmd.ExecuteC()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, tt.wants.Token, gotOpts.Token)
|
||||||
|
assert.Equal(t, tt.wants.Hostname, gotOpts.Hostname)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_statusRun(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
opts *StatusOptions
|
||||||
|
httpStubs func(*httpmock.Registry)
|
||||||
|
cfg func(config.Config)
|
||||||
|
wantErr *regexp.Regexp
|
||||||
|
wantErrOut *regexp.Regexp
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "token set, bad token",
|
||||||
|
opts: &StatusOptions{
|
||||||
|
Token: "abc123",
|
||||||
|
Hostname: "github.com",
|
||||||
|
},
|
||||||
|
httpStubs: func(reg *httpmock.Registry) {
|
||||||
|
reg.Register(
|
||||||
|
httpmock.REST("GET", ""),
|
||||||
|
httpmock.StatusStringResponse(400, "no bueno"),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
wantErr: regexp.MustCompile(``),
|
||||||
|
wantErrOut: regexp.MustCompile(`authentication failed`),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "token set, missing scope",
|
||||||
|
opts: &StatusOptions{
|
||||||
|
Token: "abc123",
|
||||||
|
Hostname: "github.com",
|
||||||
|
},
|
||||||
|
httpStubs: func(reg *httpmock.Registry) {
|
||||||
|
reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,"))
|
||||||
|
},
|
||||||
|
wantErr: regexp.MustCompile(``),
|
||||||
|
wantErrOut: regexp.MustCompile(`missing required scope 'read:org'`),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "token set, good token",
|
||||||
|
opts: &StatusOptions{
|
||||||
|
Token: "abc123",
|
||||||
|
Hostname: "github.com",
|
||||||
|
},
|
||||||
|
httpStubs: func(reg *httpmock.Registry) {
|
||||||
|
reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,read:org,"))
|
||||||
|
reg.Register(
|
||||||
|
httpmock.GraphQL(`query UserCurrent\b`),
|
||||||
|
httpmock.StringResponse(`{"data":{"viewer":{"login":"tess"}}}`))
|
||||||
|
},
|
||||||
|
wantErrOut: regexp.MustCompile(`token valid for github.com as.*tess`),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "hostname set",
|
||||||
|
opts: &StatusOptions{
|
||||||
|
Hostname: "joel.miller",
|
||||||
|
},
|
||||||
|
cfg: func(c config.Config) {
|
||||||
|
_ = c.Set("joel.miller", "oauth_token", "abc123")
|
||||||
|
_ = c.Set("github.com", "oauth_token", "abc123")
|
||||||
|
},
|
||||||
|
httpStubs: func(reg *httpmock.Registry) {
|
||||||
|
reg.Register(httpmock.REST("GET", "api/v3/"), httpmock.ScopesResponder("repo,read:org,"))
|
||||||
|
reg.Register(
|
||||||
|
httpmock.GraphQL(`query UserCurrent\b`),
|
||||||
|
httpmock.StringResponse(`{"data":{"viewer":{"login":"tess"}}}`))
|
||||||
|
},
|
||||||
|
wantErrOut: regexp.MustCompile(`Logged in to joel.miller as.*tess`),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "hostname set",
|
||||||
|
opts: &StatusOptions{
|
||||||
|
Hostname: "joel.miller",
|
||||||
|
},
|
||||||
|
cfg: func(c config.Config) {
|
||||||
|
_ = c.Set("joel.miller", "oauth_token", "abc123")
|
||||||
|
_ = c.Set("github.com", "oauth_token", "abc123")
|
||||||
|
},
|
||||||
|
httpStubs: func(reg *httpmock.Registry) {
|
||||||
|
reg.Register(httpmock.REST("GET", "api/v3/"), httpmock.ScopesResponder("repo,read:org,"))
|
||||||
|
reg.Register(
|
||||||
|
httpmock.GraphQL(`query UserCurrent\b`),
|
||||||
|
httpmock.StringResponse(`{"data":{"viewer":{"login":"tess"}}}`))
|
||||||
|
},
|
||||||
|
wantErrOut: regexp.MustCompile(`Logged in to joel.miller as.*tess`),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing scope",
|
||||||
|
opts: &StatusOptions{},
|
||||||
|
cfg: func(c config.Config) {
|
||||||
|
_ = c.Set("joel.miller", "oauth_token", "abc123")
|
||||||
|
_ = c.Set("github.com", "oauth_token", "abc123")
|
||||||
|
},
|
||||||
|
httpStubs: func(reg *httpmock.Registry) {
|
||||||
|
reg.Register(httpmock.REST("GET", "api/v3/"), httpmock.ScopesResponder("repo,"))
|
||||||
|
reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,read:org,"))
|
||||||
|
reg.Register(
|
||||||
|
httpmock.GraphQL(`query UserCurrent\b`),
|
||||||
|
httpmock.StringResponse(`{"data":{"viewer":{"login":"tess"}}}`))
|
||||||
|
},
|
||||||
|
wantErrOut: regexp.MustCompile(`joel.miller: missing required.*Logged in to github.com as.*tess`),
|
||||||
|
wantErr: regexp.MustCompile(``),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "bad token",
|
||||||
|
opts: &StatusOptions{},
|
||||||
|
cfg: func(c config.Config) {
|
||||||
|
_ = c.Set("joel.miller", "oauth_token", "abc123")
|
||||||
|
_ = c.Set("github.com", "oauth_token", "abc123")
|
||||||
|
},
|
||||||
|
httpStubs: func(reg *httpmock.Registry) {
|
||||||
|
reg.Register(httpmock.REST("GET", "api/v3/"), httpmock.StatusStringResponse(400, "no bueno"))
|
||||||
|
reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,read:org,"))
|
||||||
|
reg.Register(
|
||||||
|
httpmock.GraphQL(`query UserCurrent\b`),
|
||||||
|
httpmock.StringResponse(`{"data":{"viewer":{"login":"tess"}}}`))
|
||||||
|
},
|
||||||
|
wantErrOut: regexp.MustCompile(`joel.miller: authentication failed.*Logged in to github.com as.*tess`),
|
||||||
|
wantErr: regexp.MustCompile(``),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "all good",
|
||||||
|
opts: &StatusOptions{},
|
||||||
|
cfg: func(c config.Config) {
|
||||||
|
_ = c.Set("joel.miller", "oauth_token", "abc123")
|
||||||
|
_ = c.Set("github.com", "oauth_token", "abc123")
|
||||||
|
},
|
||||||
|
httpStubs: func(reg *httpmock.Registry) {
|
||||||
|
reg.Register(httpmock.REST("GET", "api/v3/"), httpmock.ScopesResponder("repo,read:org,"))
|
||||||
|
reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,read:org,"))
|
||||||
|
reg.Register(
|
||||||
|
httpmock.GraphQL(`query UserCurrent\b`),
|
||||||
|
httpmock.StringResponse(`{"data":{"viewer":{"login":"tess"}}}`))
|
||||||
|
reg.Register(
|
||||||
|
httpmock.GraphQL(`query UserCurrent\b`),
|
||||||
|
httpmock.StringResponse(`{"data":{"viewer":{"login":"tess"}}}`))
|
||||||
|
},
|
||||||
|
wantErrOut: regexp.MustCompile(`(?s)Logged in to github.com as.*tess.*Logged in to joel.miller as.*tess`),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if tt.opts == nil {
|
||||||
|
tt.opts = &StatusOptions{}
|
||||||
|
}
|
||||||
|
|
||||||
|
io, _, _, stderr := iostreams.Test()
|
||||||
|
|
||||||
|
io.SetStdinTTY(true)
|
||||||
|
io.SetStderrTTY(true)
|
||||||
|
io.SetStdoutTTY(true)
|
||||||
|
|
||||||
|
tt.opts.IO = io
|
||||||
|
|
||||||
|
cfg := config.NewBlankConfig()
|
||||||
|
|
||||||
|
if tt.cfg != nil {
|
||||||
|
tt.cfg(cfg)
|
||||||
|
}
|
||||||
|
tt.opts.Config = func() (config.Config, error) {
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
reg := &httpmock.Registry{}
|
||||||
|
origClientFromCfg := client.ClientFromCfg
|
||||||
|
defer func() {
|
||||||
|
client.ClientFromCfg = origClientFromCfg
|
||||||
|
}()
|
||||||
|
client.ClientFromCfg = func(_ string, _ config.Config) (*api.Client, error) {
|
||||||
|
httpClient := &http.Client{Transport: reg}
|
||||||
|
return api.NewClientFromHTTP(httpClient), nil
|
||||||
|
}
|
||||||
|
tt.opts.HttpClient = func() (*http.Client, error) {
|
||||||
|
return &http.Client{Transport: reg}, nil
|
||||||
|
}
|
||||||
|
if tt.httpStubs != nil {
|
||||||
|
tt.httpStubs(reg)
|
||||||
|
}
|
||||||
|
mainBuf := bytes.Buffer{}
|
||||||
|
hostsBuf := bytes.Buffer{}
|
||||||
|
defer config.StubWriteConfig(&mainBuf, &hostsBuf)()
|
||||||
|
|
||||||
|
err := statusRun(tt.opts)
|
||||||
|
assert.Equal(t, tt.wantErr == nil, err == nil)
|
||||||
|
if err != nil {
|
||||||
|
if tt.wantErr != nil {
|
||||||
|
assert.True(t, tt.wantErr.MatchString(err.Error()))
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
t.Fatalf("unexpected error: %s", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if tt.wantErrOut == nil {
|
||||||
|
assert.Equal(t, "", stderr.String())
|
||||||
|
} else {
|
||||||
|
assert.True(t, tt.wantErrOut.MatchString(stderr.String()))
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(t, "", mainBuf.String())
|
||||||
|
assert.Equal(t, "", hostsBuf.String())
|
||||||
|
|
||||||
|
reg.Verify(t)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
98
pkg/cmd/config/config.go
Normal file
98
pkg/cmd/config/config.go
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/MakeNowJust/heredoc"
|
||||||
|
"github.com/cli/cli/pkg/cmdutil"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewCmdConfig(f *cmdutil.Factory) *cobra.Command {
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "config",
|
||||||
|
Short: "Manage configuration for gh",
|
||||||
|
Long: heredoc.Doc(`
|
||||||
|
Display or change configuration settings for gh.
|
||||||
|
|
||||||
|
Current respected settings:
|
||||||
|
- git_protocol: "https" or "ssh". Default is "https".
|
||||||
|
- editor: if unset, defaults to environment variables.
|
||||||
|
`),
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.AddCommand(NewCmdConfigGet(f))
|
||||||
|
cmd.AddCommand(NewCmdConfigSet(f))
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCmdConfigGet(f *cmdutil.Factory) *cobra.Command {
|
||||||
|
var hostname string
|
||||||
|
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "get <key>",
|
||||||
|
Short: "Print the value of a given configuration key",
|
||||||
|
Example: heredoc.Doc(`
|
||||||
|
$ gh config get git_protocol
|
||||||
|
https
|
||||||
|
`),
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
cfg, err := f.Config()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
val, err := cfg.Get(hostname, args[0])
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if val != "" {
|
||||||
|
fmt.Fprintf(f.IOStreams.Out, "%s\n", val)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Flags().StringVarP(&hostname, "host", "h", "", "Get per-host setting")
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCmdConfigSet(f *cmdutil.Factory) *cobra.Command {
|
||||||
|
var hostname string
|
||||||
|
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "set <key> <value>",
|
||||||
|
Short: "Update configuration with a value for the given key",
|
||||||
|
Example: heredoc.Doc(`
|
||||||
|
$ gh config set editor vim
|
||||||
|
$ gh config set editor "code --wait"
|
||||||
|
`),
|
||||||
|
Args: cobra.ExactArgs(2),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
cfg, err := f.Config()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
key, value := args[0], args[1]
|
||||||
|
err = cfg.Set(hostname, key, value)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to set %q to %q: %w", key, value, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = cfg.Write()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to write config to disk: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Flags().StringVarP(&hostname, "host", "h", "", "Set per-host setting")
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
156
pkg/cmd/config/config_test.go
Normal file
156
pkg/cmd/config/config_test.go
Normal file
|
|
@ -0,0 +1,156 @@
|
||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/cli/cli/internal/config"
|
||||||
|
"github.com/cli/cli/pkg/cmdutil"
|
||||||
|
"github.com/cli/cli/pkg/iostreams"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
type configStub map[string]string
|
||||||
|
|
||||||
|
func genKey(host, key string) string {
|
||||||
|
if host != "" {
|
||||||
|
return host + ":" + key
|
||||||
|
}
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c configStub) Get(host, key string) (string, error) {
|
||||||
|
if v, found := c[genKey(host, key)]; found {
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
|
return "", errors.New("not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c configStub) Set(host, key, value string) error {
|
||||||
|
c[genKey(host, key)] = value
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c configStub) Aliases() (*config.AliasConfig, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c configStub) Hosts() ([]string, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c configStub) UnsetHost(hostname string) {
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c configStub) Write() error {
|
||||||
|
c["_written"] = "true"
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfigGet(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
config configStub
|
||||||
|
args []string
|
||||||
|
stdout string
|
||||||
|
stderr string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "get key",
|
||||||
|
config: configStub{
|
||||||
|
"editor": "ed",
|
||||||
|
},
|
||||||
|
args: []string{"editor"},
|
||||||
|
stdout: "ed\n",
|
||||||
|
stderr: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "get key scoped by host",
|
||||||
|
config: configStub{
|
||||||
|
"editor": "ed",
|
||||||
|
"github.com:editor": "vim",
|
||||||
|
},
|
||||||
|
args: []string{"editor", "-h", "github.com"},
|
||||||
|
stdout: "vim\n",
|
||||||
|
stderr: "",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
io, _, stdout, stderr := iostreams.Test()
|
||||||
|
f := &cmdutil.Factory{
|
||||||
|
IOStreams: io,
|
||||||
|
Config: func() (config.Config, error) {
|
||||||
|
return tt.config, nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := NewCmdConfigGet(f)
|
||||||
|
cmd.Flags().BoolP("help", "x", false, "")
|
||||||
|
cmd.SetArgs(tt.args)
|
||||||
|
cmd.SetOut(stdout)
|
||||||
|
cmd.SetErr(stderr)
|
||||||
|
|
||||||
|
_, err := cmd.ExecuteC()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, tt.stdout, stdout.String())
|
||||||
|
assert.Equal(t, tt.stderr, stderr.String())
|
||||||
|
assert.Equal(t, "", tt.config["_written"])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfigSet(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
config configStub
|
||||||
|
args []string
|
||||||
|
expectKey string
|
||||||
|
stdout string
|
||||||
|
stderr string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "set key",
|
||||||
|
config: configStub{},
|
||||||
|
args: []string{"editor", "vim"},
|
||||||
|
expectKey: "editor",
|
||||||
|
stdout: "",
|
||||||
|
stderr: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "set key scoped by host",
|
||||||
|
config: configStub{},
|
||||||
|
args: []string{"editor", "vim", "-h", "github.com"},
|
||||||
|
expectKey: "github.com:editor",
|
||||||
|
stdout: "",
|
||||||
|
stderr: "",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
io, _, stdout, stderr := iostreams.Test()
|
||||||
|
f := &cmdutil.Factory{
|
||||||
|
IOStreams: io,
|
||||||
|
Config: func() (config.Config, error) {
|
||||||
|
return tt.config, nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := NewCmdConfigSet(f)
|
||||||
|
cmd.Flags().BoolP("help", "x", false, "")
|
||||||
|
cmd.SetArgs(tt.args)
|
||||||
|
cmd.SetOut(stdout)
|
||||||
|
cmd.SetErr(stderr)
|
||||||
|
|
||||||
|
_, err := cmd.ExecuteC()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, tt.stdout, stdout.String())
|
||||||
|
assert.Equal(t, tt.stderr, stderr.String())
|
||||||
|
assert.Equal(t, "vim", tt.config[tt.expectKey])
|
||||||
|
assert.Equal(t, "true", tt.config["_written"])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
67
pkg/cmd/factory/default.go
Normal file
67
pkg/cmd/factory/default.go
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
package factory
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/cli/cli/git"
|
||||||
|
"github.com/cli/cli/internal/config"
|
||||||
|
"github.com/cli/cli/internal/ghrepo"
|
||||||
|
"github.com/cli/cli/pkg/cmdutil"
|
||||||
|
"github.com/cli/cli/pkg/iostreams"
|
||||||
|
)
|
||||||
|
|
||||||
|
func New(appVersion string) *cmdutil.Factory {
|
||||||
|
io := iostreams.System()
|
||||||
|
|
||||||
|
var cachedConfig config.Config
|
||||||
|
var configError error
|
||||||
|
configFunc := func() (config.Config, error) {
|
||||||
|
if cachedConfig != nil || configError != nil {
|
||||||
|
return cachedConfig, configError
|
||||||
|
}
|
||||||
|
cachedConfig, configError = config.ParseDefaultConfig()
|
||||||
|
if errors.Is(configError, os.ErrNotExist) {
|
||||||
|
cachedConfig = config.NewBlankConfig()
|
||||||
|
configError = nil
|
||||||
|
}
|
||||||
|
return cachedConfig, configError
|
||||||
|
}
|
||||||
|
|
||||||
|
rr := &remoteResolver{
|
||||||
|
readRemotes: git.Remotes,
|
||||||
|
getConfig: configFunc,
|
||||||
|
}
|
||||||
|
remotesFunc := rr.Resolver()
|
||||||
|
|
||||||
|
return &cmdutil.Factory{
|
||||||
|
IOStreams: io,
|
||||||
|
Config: configFunc,
|
||||||
|
Remotes: remotesFunc,
|
||||||
|
HttpClient: func() (*http.Client, error) {
|
||||||
|
cfg, err := configFunc()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: avoid setting Accept header for `api` command
|
||||||
|
return httpClient(io, cfg, appVersion, true), nil
|
||||||
|
},
|
||||||
|
BaseRepo: func() (ghrepo.Interface, error) {
|
||||||
|
remotes, err := remotesFunc()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return remotes[0], nil
|
||||||
|
},
|
||||||
|
Branch: func() (string, error) {
|
||||||
|
currentBranch, err := git.CurrentBranch()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("could not determine current branch: %w", err)
|
||||||
|
}
|
||||||
|
return currentBranch, nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
68
pkg/cmd/factory/http.go
Normal file
68
pkg/cmd/factory/http.go
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
package factory
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/cli/cli/api"
|
||||||
|
"github.com/cli/cli/internal/config"
|
||||||
|
"github.com/cli/cli/internal/ghinstance"
|
||||||
|
"github.com/cli/cli/pkg/iostreams"
|
||||||
|
)
|
||||||
|
|
||||||
|
// generic authenticated HTTP client for commands
|
||||||
|
func httpClient(io *iostreams.IOStreams, cfg config.Config, appVersion string, setAccept bool) *http.Client {
|
||||||
|
var opts []api.ClientOption
|
||||||
|
if verbose := os.Getenv("DEBUG"); verbose != "" {
|
||||||
|
logTraffic := strings.Contains(verbose, "api")
|
||||||
|
opts = append(opts, api.VerboseLog(io.ErrOut, logTraffic, io.IsStderrTTY()))
|
||||||
|
}
|
||||||
|
|
||||||
|
opts = append(opts,
|
||||||
|
api.AddHeader("User-Agent", fmt.Sprintf("GitHub CLI %s", appVersion)),
|
||||||
|
api.AddHeaderFunc("Authorization", func(req *http.Request) (string, error) {
|
||||||
|
if token := os.Getenv("GITHUB_TOKEN"); token != "" {
|
||||||
|
return fmt.Sprintf("token %s", token), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
hostname := ghinstance.NormalizeHostname(req.URL.Hostname())
|
||||||
|
token, err := cfg.Get(hostname, "oauth_token")
|
||||||
|
if token == "" {
|
||||||
|
var notFound *config.NotFoundError
|
||||||
|
// TODO: check if stdout is TTY too
|
||||||
|
if errors.As(err, ¬Found) && io.IsStdinTTY() {
|
||||||
|
// interactive OAuth flow
|
||||||
|
token, err = config.AuthFlowWithConfig(cfg, hostname, "Notice: authentication required", nil)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if token == "" {
|
||||||
|
// TODO: instruct user how to manually authenticate
|
||||||
|
return "", fmt.Errorf("authentication required for %s", hostname)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("token %s", token), nil
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
if setAccept {
|
||||||
|
opts = append(opts,
|
||||||
|
api.AddHeaderFunc("Accept", func(req *http.Request) (string, error) {
|
||||||
|
// antiope-preview: Checks
|
||||||
|
accept := "application/vnd.github.antiope-preview+json"
|
||||||
|
if ghinstance.IsEnterprise(req.URL.Hostname()) {
|
||||||
|
// shadow-cat-preview: Draft pull requests
|
||||||
|
accept += ", application/vnd.github.shadow-cat-preview"
|
||||||
|
}
|
||||||
|
return accept, nil
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return api.NewHTTPClient(opts...)
|
||||||
|
}
|
||||||
80
pkg/cmd/factory/remote_resolver.go
Normal file
80
pkg/cmd/factory/remote_resolver.go
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
package factory
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/url"
|
||||||
|
"sort"
|
||||||
|
|
||||||
|
"github.com/cli/cli/context"
|
||||||
|
"github.com/cli/cli/git"
|
||||||
|
"github.com/cli/cli/internal/config"
|
||||||
|
"github.com/cli/cli/internal/ghinstance"
|
||||||
|
)
|
||||||
|
|
||||||
|
type remoteResolver struct {
|
||||||
|
readRemotes func() (git.RemoteSet, error)
|
||||||
|
getConfig func() (config.Config, error)
|
||||||
|
urlTranslator func(*url.URL) *url.URL
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rr *remoteResolver) Resolver() func() (context.Remotes, error) {
|
||||||
|
var cachedRemotes context.Remotes
|
||||||
|
var remotesError error
|
||||||
|
|
||||||
|
return func() (context.Remotes, error) {
|
||||||
|
if cachedRemotes != nil || remotesError != nil {
|
||||||
|
return cachedRemotes, remotesError
|
||||||
|
}
|
||||||
|
|
||||||
|
gitRemotes, err := rr.readRemotes()
|
||||||
|
if err != nil {
|
||||||
|
remotesError = err
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(gitRemotes) == 0 {
|
||||||
|
remotesError = errors.New("no git remotes found")
|
||||||
|
return nil, remotesError
|
||||||
|
}
|
||||||
|
|
||||||
|
sshTranslate := rr.urlTranslator
|
||||||
|
if sshTranslate == nil {
|
||||||
|
sshTranslate = git.ParseSSHConfig().Translator()
|
||||||
|
}
|
||||||
|
resolvedRemotes := context.TranslateRemotes(gitRemotes, sshTranslate)
|
||||||
|
|
||||||
|
cfg, err := rr.getConfig()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
knownHosts := map[string]bool{}
|
||||||
|
knownHosts[ghinstance.Default()] = true
|
||||||
|
if authenticatedHosts, err := cfg.Hosts(); err == nil {
|
||||||
|
for _, h := range authenticatedHosts {
|
||||||
|
knownHosts[h] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// filter remotes to only those sharing a single, known hostname
|
||||||
|
var hostname string
|
||||||
|
cachedRemotes = context.Remotes{}
|
||||||
|
sort.Sort(resolvedRemotes)
|
||||||
|
for _, r := range resolvedRemotes {
|
||||||
|
if hostname == "" {
|
||||||
|
if !knownHosts[r.RepoHost()] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
hostname = r.RepoHost()
|
||||||
|
} else if r.RepoHost() != hostname {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cachedRemotes = append(cachedRemotes, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(cachedRemotes) == 0 {
|
||||||
|
remotesError = errors.New("none of the git remotes point to a known GitHub host")
|
||||||
|
return nil, remotesError
|
||||||
|
}
|
||||||
|
return cachedRemotes, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
42
pkg/cmd/factory/remote_resolver_test.go
Normal file
42
pkg/cmd/factory/remote_resolver_test.go
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
package factory
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/url"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/MakeNowJust/heredoc"
|
||||||
|
"github.com/cli/cli/git"
|
||||||
|
"github.com/cli/cli/internal/config"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Test_remoteResolver(t *testing.T) {
|
||||||
|
rr := &remoteResolver{
|
||||||
|
readRemotes: func() (git.RemoteSet, error) {
|
||||||
|
return git.RemoteSet{
|
||||||
|
git.NewRemote("fork", "https://example.org/ghe-owner/ghe-fork.git"),
|
||||||
|
git.NewRemote("origin", "https://github.com/owner/repo.git"),
|
||||||
|
git.NewRemote("upstream", "https://example.org/ghe-owner/ghe-repo.git"),
|
||||||
|
}, nil
|
||||||
|
},
|
||||||
|
getConfig: func() (config.Config, error) {
|
||||||
|
return config.NewFromString(heredoc.Doc(`
|
||||||
|
hosts:
|
||||||
|
example.org:
|
||||||
|
oauth_token: GHETOKEN
|
||||||
|
`)), nil
|
||||||
|
},
|
||||||
|
urlTranslator: func(u *url.URL) *url.URL {
|
||||||
|
return u
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
resolver := rr.Resolver()
|
||||||
|
remotes, err := resolver()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, 2, len(remotes))
|
||||||
|
|
||||||
|
assert.Equal(t, "upstream", remotes[0].Name)
|
||||||
|
assert.Equal(t, "fork", remotes[1].Name)
|
||||||
|
}
|
||||||
|
|
@ -7,10 +7,13 @@ import (
|
||||||
"io/ioutil"
|
"io/ioutil"
|
||||||
"net/http"
|
"net/http"
|
||||||
"path"
|
"path"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/MakeNowJust/heredoc"
|
"github.com/MakeNowJust/heredoc"
|
||||||
"github.com/cli/cli/api"
|
"github.com/cli/cli/api"
|
||||||
|
"github.com/cli/cli/internal/ghinstance"
|
||||||
"github.com/cli/cli/pkg/cmdutil"
|
"github.com/cli/cli/pkg/cmdutil"
|
||||||
"github.com/cli/cli/pkg/iostreams"
|
"github.com/cli/cli/pkg/iostreams"
|
||||||
"github.com/cli/cli/utils"
|
"github.com/cli/cli/utils"
|
||||||
|
|
@ -95,15 +98,28 @@ func createRun(opts *CreateOptions) error {
|
||||||
return fmt.Errorf("failed to collect files for posting: %w", err)
|
return fmt.Errorf("failed to collect files for posting: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
gistName := guessGistName(files)
|
||||||
|
|
||||||
|
processMessage := "Creating gist..."
|
||||||
|
completionMessage := "Created gist"
|
||||||
|
if gistName != "" {
|
||||||
|
if len(files) > 1 {
|
||||||
|
processMessage = "Creating gist with multiple files"
|
||||||
|
} else {
|
||||||
|
processMessage = fmt.Sprintf("Creating gist %s", gistName)
|
||||||
|
}
|
||||||
|
completionMessage = fmt.Sprintf("Created gist %s", gistName)
|
||||||
|
}
|
||||||
|
|
||||||
errOut := opts.IO.ErrOut
|
errOut := opts.IO.ErrOut
|
||||||
fmt.Fprintf(errOut, "%s Creating gist...\n", utils.Gray("-"))
|
fmt.Fprintf(errOut, "%s %s\n", utils.Gray("-"), processMessage)
|
||||||
|
|
||||||
httpClient, err := opts.HttpClient()
|
httpClient, err := opts.HttpClient()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
gist, err := apiCreate(httpClient, opts.Description, opts.Public, files)
|
gist, err := apiCreate(httpClient, ghinstance.OverridableDefault(), opts.Description, opts.Public, files)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
var httpError api.HTTPError
|
var httpError api.HTTPError
|
||||||
if errors.As(err, &httpError) {
|
if errors.As(err, &httpError) {
|
||||||
|
|
@ -114,7 +130,7 @@ func createRun(opts *CreateOptions) error {
|
||||||
return fmt.Errorf("%s Failed to create gist: %w", utils.Red("X"), err)
|
return fmt.Errorf("%s Failed to create gist: %w", utils.Red("X"), err)
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Fprintf(errOut, "%s Created gist\n", utils.Green("✓"))
|
fmt.Fprintf(errOut, "%s %s\n", utils.Green("✓"), completionMessage)
|
||||||
|
|
||||||
fmt.Fprintln(opts.IO.Out, gist.HTMLURL)
|
fmt.Fprintln(opts.IO.Out, gist.HTMLURL)
|
||||||
|
|
||||||
|
|
@ -152,3 +168,22 @@ func processFiles(stdin io.ReadCloser, filenames []string) (map[string]string, e
|
||||||
|
|
||||||
return fs, nil
|
return fs, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func guessGistName(files map[string]string) string {
|
||||||
|
filenames := make([]string, 0, len(files))
|
||||||
|
gistName := ""
|
||||||
|
|
||||||
|
re := regexp.MustCompile(`^gistfile\d+\.txt$`)
|
||||||
|
for k := range files {
|
||||||
|
if !re.MatchString(k) {
|
||||||
|
filenames = append(filenames, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(filenames) > 0 {
|
||||||
|
sort.Strings(filenames)
|
||||||
|
gistName = filenames[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
return gistName
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,24 @@ func Test_processFiles(t *testing.T) {
|
||||||
assert.Equal(t, "hey cool how is it going", files["gistfile0.txt"])
|
assert.Equal(t, "hey cool how is it going", files["gistfile0.txt"])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func Test_guessGistName_stdin(t *testing.T) {
|
||||||
|
files := map[string]string{"gistfile0.txt": "sample content"}
|
||||||
|
|
||||||
|
gistName := guessGistName(files)
|
||||||
|
assert.Equal(t, "", gistName)
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_guessGistName_userFiles(t *testing.T) {
|
||||||
|
files := map[string]string{
|
||||||
|
"fig.txt": "I am a fig.",
|
||||||
|
"apple.txt": "I am an apple.",
|
||||||
|
"gistfile0.txt": "sample content",
|
||||||
|
}
|
||||||
|
|
||||||
|
gistName := guessGistName(files)
|
||||||
|
assert.Equal(t, "apple.txt", gistName)
|
||||||
|
}
|
||||||
|
|
||||||
func TestNewCmdCreate(t *testing.T) {
|
func TestNewCmdCreate(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
|
|
@ -157,7 +175,7 @@ func Test_createRun(t *testing.T) {
|
||||||
Filenames: []string{fixtureFile},
|
Filenames: []string{fixtureFile},
|
||||||
},
|
},
|
||||||
wantOut: "https://gist.github.com/aa5a315d61ae9438b18d\n",
|
wantOut: "https://gist.github.com/aa5a315d61ae9438b18d\n",
|
||||||
wantStderr: "- Creating gist...\n✓ Created gist\n",
|
wantStderr: "- Creating gist fixture.txt\n✓ Created gist fixture.txt\n",
|
||||||
wantErr: false,
|
wantErr: false,
|
||||||
wantParams: map[string]interface{}{
|
wantParams: map[string]interface{}{
|
||||||
"public": true,
|
"public": true,
|
||||||
|
|
@ -175,7 +193,7 @@ func Test_createRun(t *testing.T) {
|
||||||
Filenames: []string{fixtureFile},
|
Filenames: []string{fixtureFile},
|
||||||
},
|
},
|
||||||
wantOut: "https://gist.github.com/aa5a315d61ae9438b18d\n",
|
wantOut: "https://gist.github.com/aa5a315d61ae9438b18d\n",
|
||||||
wantStderr: "- Creating gist...\n✓ Created gist\n",
|
wantStderr: "- Creating gist fixture.txt\n✓ Created gist fixture.txt\n",
|
||||||
wantErr: false,
|
wantErr: false,
|
||||||
wantParams: map[string]interface{}{
|
wantParams: map[string]interface{}{
|
||||||
"description": "an incredibly interesting gist",
|
"description": "an incredibly interesting gist",
|
||||||
|
|
@ -193,7 +211,7 @@ func Test_createRun(t *testing.T) {
|
||||||
},
|
},
|
||||||
stdin: "cool stdin content",
|
stdin: "cool stdin content",
|
||||||
wantOut: "https://gist.github.com/aa5a315d61ae9438b18d\n",
|
wantOut: "https://gist.github.com/aa5a315d61ae9438b18d\n",
|
||||||
wantStderr: "- Creating gist...\n✓ Created gist\n",
|
wantStderr: "- Creating gist with multiple files\n✓ Created gist fixture.txt\n",
|
||||||
wantErr: false,
|
wantErr: false,
|
||||||
wantParams: map[string]interface{}{
|
wantParams: map[string]interface{}{
|
||||||
"files": map[string]interface{}{
|
"files": map[string]interface{}{
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ type GistFile struct {
|
||||||
Content string `json:"content,omitempty"`
|
Content string `json:"content,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func apiCreate(httpClient *http.Client, description string, public bool, files map[string]string) (*Gist, error) {
|
func apiCreate(httpClient *http.Client, hostname string, description string, public bool, files map[string]string) (*Gist, error) {
|
||||||
gistFiles := map[GistFilename]GistFile{}
|
gistFiles := map[GistFilename]GistFile{}
|
||||||
|
|
||||||
for filename, content := range files {
|
for filename, content := range files {
|
||||||
|
|
@ -44,7 +44,7 @@ func apiCreate(httpClient *http.Client, description string, public bool, files m
|
||||||
requestBody := bytes.NewReader(requestByte)
|
requestBody := bytes.NewReader(requestByte)
|
||||||
|
|
||||||
apiClient := api.NewClientFromHTTP(httpClient)
|
apiClient := api.NewClientFromHTTP(httpClient)
|
||||||
err = apiClient.REST("POST", path, requestBody, &result)
|
err = apiClient.REST(hostname, "POST", path, requestBody, &result)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
19
pkg/cmd/gist/gist.go
Normal file
19
pkg/cmd/gist/gist.go
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
package gist
|
||||||
|
|
||||||
|
import (
|
||||||
|
gistCreateCmd "github.com/cli/cli/pkg/cmd/gist/create"
|
||||||
|
"github.com/cli/cli/pkg/cmdutil"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewCmdGist(f *cmdutil.Factory) *cobra.Command {
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "gist",
|
||||||
|
Short: "Create gists",
|
||||||
|
Long: `Work with GitHub gists.`,
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.AddCommand(gistCreateCmd.NewCmdCreate(f, nil))
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
80
pkg/cmd/issue/close/close.go
Normal file
80
pkg/cmd/issue/close/close.go
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
package close
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/cli/cli/api"
|
||||||
|
"github.com/cli/cli/internal/config"
|
||||||
|
"github.com/cli/cli/internal/ghrepo"
|
||||||
|
"github.com/cli/cli/pkg/cmd/issue/shared"
|
||||||
|
"github.com/cli/cli/pkg/cmdutil"
|
||||||
|
"github.com/cli/cli/pkg/iostreams"
|
||||||
|
"github.com/cli/cli/utils"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CloseOptions struct {
|
||||||
|
HttpClient func() (*http.Client, error)
|
||||||
|
Config func() (config.Config, error)
|
||||||
|
IO *iostreams.IOStreams
|
||||||
|
BaseRepo func() (ghrepo.Interface, error)
|
||||||
|
|
||||||
|
SelectorArg string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCmdClose(f *cmdutil.Factory, runF func(*CloseOptions) error) *cobra.Command {
|
||||||
|
opts := &CloseOptions{
|
||||||
|
IO: f.IOStreams,
|
||||||
|
HttpClient: f.HttpClient,
|
||||||
|
Config: f.Config,
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "close {<number> | <url>}",
|
||||||
|
Short: "Close issue",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
// support `-R, --repo` override
|
||||||
|
opts.BaseRepo = f.BaseRepo
|
||||||
|
|
||||||
|
if len(args) > 0 {
|
||||||
|
opts.SelectorArg = args[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
if runF != nil {
|
||||||
|
return runF(opts)
|
||||||
|
}
|
||||||
|
return closeRun(opts)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func closeRun(opts *CloseOptions) error {
|
||||||
|
httpClient, err := opts.HttpClient()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
apiClient := api.NewClientFromHTTP(httpClient)
|
||||||
|
|
||||||
|
issue, baseRepo, err := shared.IssueFromArg(apiClient, opts.BaseRepo, opts.SelectorArg)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if issue.Closed {
|
||||||
|
fmt.Fprintf(opts.IO.ErrOut, "%s Issue #%d (%s) is already closed\n", utils.Yellow("!"), issue.Number, issue.Title)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
err = api.IssueClose(apiClient, baseRepo, *issue)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintf(opts.IO.ErrOut, "%s Closed issue #%d (%s)\n", utils.Red("✔"), issue.Number, issue.Title)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
119
pkg/cmd/issue/close/close_test.go
Normal file
119
pkg/cmd/issue/close/close_test.go
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
package close
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"io/ioutil"
|
||||||
|
"net/http"
|
||||||
|
"regexp"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/cli/cli/internal/config"
|
||||||
|
"github.com/cli/cli/internal/ghrepo"
|
||||||
|
"github.com/cli/cli/pkg/cmdutil"
|
||||||
|
"github.com/cli/cli/pkg/httpmock"
|
||||||
|
"github.com/cli/cli/pkg/iostreams"
|
||||||
|
"github.com/cli/cli/test"
|
||||||
|
"github.com/google/shlex"
|
||||||
|
)
|
||||||
|
|
||||||
|
func runCommand(rt http.RoundTripper, isTTY bool, cli string) (*test.CmdOut, error) {
|
||||||
|
io, _, stdout, stderr := iostreams.Test()
|
||||||
|
io.SetStdoutTTY(isTTY)
|
||||||
|
io.SetStdinTTY(isTTY)
|
||||||
|
io.SetStderrTTY(isTTY)
|
||||||
|
|
||||||
|
factory := &cmdutil.Factory{
|
||||||
|
IOStreams: io,
|
||||||
|
HttpClient: func() (*http.Client, error) {
|
||||||
|
return &http.Client{Transport: rt}, nil
|
||||||
|
},
|
||||||
|
Config: func() (config.Config, error) {
|
||||||
|
return config.NewBlankConfig(), nil
|
||||||
|
},
|
||||||
|
BaseRepo: func() (ghrepo.Interface, error) {
|
||||||
|
return ghrepo.New("OWNER", "REPO"), nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := NewCmdClose(factory, nil)
|
||||||
|
|
||||||
|
argv, err := shlex.Split(cli)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cmd.SetArgs(argv)
|
||||||
|
|
||||||
|
cmd.SetIn(&bytes.Buffer{})
|
||||||
|
cmd.SetOut(ioutil.Discard)
|
||||||
|
cmd.SetErr(ioutil.Discard)
|
||||||
|
|
||||||
|
_, err = cmd.ExecuteC()
|
||||||
|
return &test.CmdOut{
|
||||||
|
OutBuf: stdout,
|
||||||
|
ErrBuf: stderr,
|
||||||
|
}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIssueClose(t *testing.T) {
|
||||||
|
http := &httpmock.Registry{}
|
||||||
|
defer http.Verify(t)
|
||||||
|
|
||||||
|
http.StubResponse(200, bytes.NewBufferString(`
|
||||||
|
{ "data": { "repository": {
|
||||||
|
"hasIssuesEnabled": true,
|
||||||
|
"issue": { "number": 13, "title": "The title of the issue"}
|
||||||
|
} } }
|
||||||
|
`))
|
||||||
|
|
||||||
|
http.StubResponse(200, bytes.NewBufferString(`{"id": "THE-ID"}`))
|
||||||
|
|
||||||
|
output, err := runCommand(http, true, "13")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("error running command `issue close`: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
r := regexp.MustCompile(`Closed issue #13 \(The title of the issue\)`)
|
||||||
|
|
||||||
|
if !r.MatchString(output.Stderr()) {
|
||||||
|
t.Fatalf("output did not match regexp /%s/\n> output\n%q\n", r, output.Stderr())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIssueClose_alreadyClosed(t *testing.T) {
|
||||||
|
http := &httpmock.Registry{}
|
||||||
|
defer http.Verify(t)
|
||||||
|
|
||||||
|
http.StubResponse(200, bytes.NewBufferString(`
|
||||||
|
{ "data": { "repository": {
|
||||||
|
"hasIssuesEnabled": true,
|
||||||
|
"issue": { "number": 13, "title": "The title of the issue", "closed": true}
|
||||||
|
} } }
|
||||||
|
`))
|
||||||
|
|
||||||
|
output, err := runCommand(http, true, "13")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("error running command `issue close`: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
r := regexp.MustCompile(`Issue #13 \(The title of the issue\) is already closed`)
|
||||||
|
|
||||||
|
if !r.MatchString(output.Stderr()) {
|
||||||
|
t.Fatalf("output did not match regexp /%s/\n> output\n%q\n", r, output.Stderr())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIssueClose_issuesDisabled(t *testing.T) {
|
||||||
|
http := &httpmock.Registry{}
|
||||||
|
defer http.Verify(t)
|
||||||
|
|
||||||
|
http.StubResponse(200, bytes.NewBufferString(`
|
||||||
|
{ "data": { "repository": {
|
||||||
|
"hasIssuesEnabled": false
|
||||||
|
} } }
|
||||||
|
`))
|
||||||
|
|
||||||
|
_, err := runCommand(http, true, "13")
|
||||||
|
if err == nil || err.Error() != "the 'OWNER/REPO' repository has disabled issues" {
|
||||||
|
t.Fatalf("got error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
228
pkg/cmd/issue/create/create.go
Normal file
228
pkg/cmd/issue/create/create.go
Normal file
|
|
@ -0,0 +1,228 @@
|
||||||
|
package create
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/MakeNowJust/heredoc"
|
||||||
|
"github.com/cli/cli/api"
|
||||||
|
"github.com/cli/cli/git"
|
||||||
|
"github.com/cli/cli/internal/config"
|
||||||
|
"github.com/cli/cli/internal/ghrepo"
|
||||||
|
prShared "github.com/cli/cli/pkg/cmd/pr/shared"
|
||||||
|
"github.com/cli/cli/pkg/cmdutil"
|
||||||
|
"github.com/cli/cli/pkg/githubtemplate"
|
||||||
|
"github.com/cli/cli/pkg/iostreams"
|
||||||
|
"github.com/cli/cli/utils"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CreateOptions struct {
|
||||||
|
HttpClient func() (*http.Client, error)
|
||||||
|
Config func() (config.Config, error)
|
||||||
|
IO *iostreams.IOStreams
|
||||||
|
BaseRepo func() (ghrepo.Interface, error)
|
||||||
|
|
||||||
|
RepoOverride string
|
||||||
|
WebMode bool
|
||||||
|
|
||||||
|
Title string
|
||||||
|
TitleProvided bool
|
||||||
|
Body string
|
||||||
|
BodyProvided bool
|
||||||
|
|
||||||
|
Assignees []string
|
||||||
|
Labels []string
|
||||||
|
Projects []string
|
||||||
|
Milestone string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Command {
|
||||||
|
opts := &CreateOptions{
|
||||||
|
IO: f.IOStreams,
|
||||||
|
HttpClient: f.HttpClient,
|
||||||
|
Config: f.Config,
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "create",
|
||||||
|
Short: "Create a new issue",
|
||||||
|
Example: heredoc.Doc(`
|
||||||
|
$ gh issue create --title "I found a bug" --body "Nothing works"
|
||||||
|
$ gh issue create --label "bug,help wanted"
|
||||||
|
$ gh issue create --label bug --label "help wanted"
|
||||||
|
$ gh issue create --assignee monalisa,hubot
|
||||||
|
$ gh issue create --project "Roadmap"
|
||||||
|
`),
|
||||||
|
Args: cmdutil.NoArgsQuoteReminder,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
// support `-R, --repo` override
|
||||||
|
opts.BaseRepo = f.BaseRepo
|
||||||
|
|
||||||
|
opts.TitleProvided = cmd.Flags().Changed("title")
|
||||||
|
opts.BodyProvided = cmd.Flags().Changed("body")
|
||||||
|
opts.RepoOverride, _ = cmd.Flags().GetString("repo")
|
||||||
|
|
||||||
|
if runF != nil {
|
||||||
|
return runF(opts)
|
||||||
|
}
|
||||||
|
return createRun(opts)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Flags().StringVarP(&opts.Title, "title", "t", "", "Supply a title. Will prompt for one otherwise.")
|
||||||
|
cmd.Flags().StringVarP(&opts.Body, "body", "b", "", "Supply a body. Will prompt for one otherwise.")
|
||||||
|
cmd.Flags().BoolVarP(&opts.WebMode, "web", "w", false, "Open the browser to create an issue")
|
||||||
|
cmd.Flags().StringSliceVarP(&opts.Assignees, "assignee", "a", nil, "Assign people by their `login`")
|
||||||
|
cmd.Flags().StringSliceVarP(&opts.Labels, "label", "l", nil, "Add labels by `name`")
|
||||||
|
cmd.Flags().StringSliceVarP(&opts.Projects, "project", "p", nil, "Add the issue to projects by `name`")
|
||||||
|
cmd.Flags().StringVarP(&opts.Milestone, "milestone", "m", "", "Add the issue to a milestone by `name`")
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func createRun(opts *CreateOptions) error {
|
||||||
|
httpClient, err := opts.HttpClient()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
apiClient := api.NewClientFromHTTP(httpClient)
|
||||||
|
|
||||||
|
baseRepo, err := opts.BaseRepo()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var nonLegacyTemplateFiles []string
|
||||||
|
if opts.RepoOverride == "" {
|
||||||
|
if rootDir, err := git.ToplevelDir(); err == nil {
|
||||||
|
// TODO: figure out how to stub this in tests
|
||||||
|
nonLegacyTemplateFiles = githubtemplate.FindNonLegacy(rootDir, "ISSUE_TEMPLATE")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isTerminal := opts.IO.IsStdoutTTY()
|
||||||
|
|
||||||
|
var milestones []string
|
||||||
|
if opts.Milestone != "" {
|
||||||
|
milestones = []string{opts.Milestone}
|
||||||
|
}
|
||||||
|
|
||||||
|
if opts.WebMode {
|
||||||
|
openURL := ghrepo.GenerateRepoURL(baseRepo, "issues/new")
|
||||||
|
if opts.Title != "" || opts.Body != "" {
|
||||||
|
openURL, err = prShared.WithPrAndIssueQueryParams(openURL, opts.Title, opts.Body, opts.Assignees, opts.Labels, opts.Projects, milestones)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else if len(nonLegacyTemplateFiles) > 1 {
|
||||||
|
openURL += "/choose"
|
||||||
|
}
|
||||||
|
if isTerminal {
|
||||||
|
fmt.Fprintf(opts.IO.ErrOut, "Opening %s in your browser.\n", utils.DisplayURL(openURL))
|
||||||
|
}
|
||||||
|
return utils.OpenInBrowser(openURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
if isTerminal {
|
||||||
|
fmt.Fprintf(opts.IO.ErrOut, "\nCreating issue in %s\n\n", ghrepo.FullName(baseRepo))
|
||||||
|
}
|
||||||
|
|
||||||
|
repo, err := api.GitHubRepo(apiClient, baseRepo)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !repo.HasIssuesEnabled {
|
||||||
|
return fmt.Errorf("the '%s' repository has disabled issues", ghrepo.FullName(baseRepo))
|
||||||
|
}
|
||||||
|
|
||||||
|
action := prShared.SubmitAction
|
||||||
|
tb := prShared.IssueMetadataState{
|
||||||
|
Type: prShared.IssueMetadata,
|
||||||
|
Assignees: opts.Assignees,
|
||||||
|
Labels: opts.Labels,
|
||||||
|
Projects: opts.Projects,
|
||||||
|
Milestones: milestones,
|
||||||
|
}
|
||||||
|
|
||||||
|
title := opts.Title
|
||||||
|
body := opts.Body
|
||||||
|
|
||||||
|
interactive := !(opts.TitleProvided && opts.BodyProvided)
|
||||||
|
|
||||||
|
if interactive && !isTerminal {
|
||||||
|
return fmt.Errorf("must provide --title and --body when not attached to a terminal")
|
||||||
|
}
|
||||||
|
|
||||||
|
if interactive {
|
||||||
|
var legacyTemplateFile *string
|
||||||
|
if opts.RepoOverride == "" {
|
||||||
|
if rootDir, err := git.ToplevelDir(); err == nil {
|
||||||
|
// TODO: figure out how to stub this in tests
|
||||||
|
legacyTemplateFile = githubtemplate.FindLegacy(rootDir, "ISSUE_TEMPLATE")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
editorCommand, err := cmdutil.DetermineEditor(opts.Config)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = prShared.TitleBodySurvey(opts.IO, editorCommand, &tb, apiClient, baseRepo, title, body, prShared.Defaults{}, nonLegacyTemplateFiles, legacyTemplateFile, false, repo.ViewerCanTriage())
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("could not collect title and/or body: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
action = tb.Action
|
||||||
|
|
||||||
|
if tb.Action == prShared.CancelAction {
|
||||||
|
fmt.Fprintln(opts.IO.ErrOut, "Discarding.")
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if title == "" {
|
||||||
|
title = tb.Title
|
||||||
|
}
|
||||||
|
if body == "" {
|
||||||
|
body = tb.Body
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if title == "" {
|
||||||
|
return fmt.Errorf("title can't be blank")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if action == prShared.PreviewAction {
|
||||||
|
openURL := ghrepo.GenerateRepoURL(baseRepo, "issues/new")
|
||||||
|
openURL, err = prShared.WithPrAndIssueQueryParams(openURL, title, body, tb.Assignees, tb.Labels, tb.Projects, tb.Milestones)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if isTerminal {
|
||||||
|
fmt.Fprintf(opts.IO.ErrOut, "Opening %s in your browser.\n", utils.DisplayURL(openURL))
|
||||||
|
}
|
||||||
|
return utils.OpenInBrowser(openURL)
|
||||||
|
} else if action == prShared.SubmitAction {
|
||||||
|
params := map[string]interface{}{
|
||||||
|
"title": title,
|
||||||
|
"body": body,
|
||||||
|
}
|
||||||
|
|
||||||
|
err = prShared.AddMetadataToIssueParams(apiClient, baseRepo, params, &tb)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
newIssue, err := api.IssueCreate(apiClient, repo, params)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintln(opts.IO.Out, newIssue.URL)
|
||||||
|
} else {
|
||||||
|
panic("Unreachable state")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
280
pkg/cmd/issue/create/create_test.go
Normal file
280
pkg/cmd/issue/create/create_test.go
Normal file
|
|
@ -0,0 +1,280 @@
|
||||||
|
package create
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"io/ioutil"
|
||||||
|
"net/http"
|
||||||
|
"os/exec"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/cli/cli/internal/config"
|
||||||
|
"github.com/cli/cli/internal/ghrepo"
|
||||||
|
"github.com/cli/cli/internal/run"
|
||||||
|
"github.com/cli/cli/pkg/cmdutil"
|
||||||
|
"github.com/cli/cli/pkg/httpmock"
|
||||||
|
"github.com/cli/cli/pkg/iostreams"
|
||||||
|
"github.com/cli/cli/test"
|
||||||
|
"github.com/google/shlex"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func eq(t *testing.T, got interface{}, expected interface{}) {
|
||||||
|
t.Helper()
|
||||||
|
if !reflect.DeepEqual(got, expected) {
|
||||||
|
t.Errorf("expected: %v, got: %v", expected, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runCommand(rt http.RoundTripper, isTTY bool, cli string) (*test.CmdOut, error) {
|
||||||
|
io, _, stdout, stderr := iostreams.Test()
|
||||||
|
io.SetStdoutTTY(isTTY)
|
||||||
|
io.SetStdinTTY(isTTY)
|
||||||
|
io.SetStderrTTY(isTTY)
|
||||||
|
|
||||||
|
factory := &cmdutil.Factory{
|
||||||
|
IOStreams: io,
|
||||||
|
HttpClient: func() (*http.Client, error) {
|
||||||
|
return &http.Client{Transport: rt}, nil
|
||||||
|
},
|
||||||
|
Config: func() (config.Config, error) {
|
||||||
|
return config.NewBlankConfig(), nil
|
||||||
|
},
|
||||||
|
BaseRepo: func() (ghrepo.Interface, error) {
|
||||||
|
return ghrepo.New("OWNER", "REPO"), nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := NewCmdCreate(factory, nil)
|
||||||
|
|
||||||
|
argv, err := shlex.Split(cli)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cmd.SetArgs(argv)
|
||||||
|
|
||||||
|
cmd.SetIn(&bytes.Buffer{})
|
||||||
|
cmd.SetOut(ioutil.Discard)
|
||||||
|
cmd.SetErr(ioutil.Discard)
|
||||||
|
|
||||||
|
_, err = cmd.ExecuteC()
|
||||||
|
return &test.CmdOut{
|
||||||
|
OutBuf: stdout,
|
||||||
|
ErrBuf: stderr,
|
||||||
|
}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIssueCreate_nontty_error(t *testing.T) {
|
||||||
|
http := &httpmock.Registry{}
|
||||||
|
defer http.Verify(t)
|
||||||
|
|
||||||
|
http.StubResponse(200, bytes.NewBufferString(`
|
||||||
|
{ "data": { "repository": {
|
||||||
|
"id": "REPOID",
|
||||||
|
"hasIssuesEnabled": true
|
||||||
|
} } }
|
||||||
|
`))
|
||||||
|
|
||||||
|
_, err := runCommand(http, false, `-t hello`)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error running command `issue create`")
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(t, "must provide --title and --body when not attached to a terminal", err.Error())
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIssueCreate(t *testing.T) {
|
||||||
|
http := &httpmock.Registry{}
|
||||||
|
defer http.Verify(t)
|
||||||
|
|
||||||
|
http.StubResponse(200, bytes.NewBufferString(`
|
||||||
|
{ "data": { "repository": {
|
||||||
|
"id": "REPOID",
|
||||||
|
"hasIssuesEnabled": true
|
||||||
|
} } }
|
||||||
|
`))
|
||||||
|
http.StubResponse(200, bytes.NewBufferString(`
|
||||||
|
{ "data": { "createIssue": { "issue": {
|
||||||
|
"URL": "https://github.com/OWNER/REPO/issues/12"
|
||||||
|
} } } }
|
||||||
|
`))
|
||||||
|
|
||||||
|
output, err := runCommand(http, true, `-t hello -b "cash rules everything around me"`)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("error running command `issue create`: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
bodyBytes, _ := ioutil.ReadAll(http.Requests[1].Body)
|
||||||
|
reqBody := struct {
|
||||||
|
Variables struct {
|
||||||
|
Input struct {
|
||||||
|
RepositoryID string
|
||||||
|
Title string
|
||||||
|
Body string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}{}
|
||||||
|
_ = json.Unmarshal(bodyBytes, &reqBody)
|
||||||
|
|
||||||
|
eq(t, reqBody.Variables.Input.RepositoryID, "REPOID")
|
||||||
|
eq(t, reqBody.Variables.Input.Title, "hello")
|
||||||
|
eq(t, reqBody.Variables.Input.Body, "cash rules everything around me")
|
||||||
|
|
||||||
|
eq(t, output.String(), "https://github.com/OWNER/REPO/issues/12\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIssueCreate_metadata(t *testing.T) {
|
||||||
|
http := &httpmock.Registry{}
|
||||||
|
defer http.Verify(t)
|
||||||
|
|
||||||
|
http.Register(
|
||||||
|
httpmock.GraphQL(`query RepositoryInfo\b`),
|
||||||
|
httpmock.StringResponse(`
|
||||||
|
{ "data": { "repository": {
|
||||||
|
"id": "REPOID",
|
||||||
|
"hasIssuesEnabled": true,
|
||||||
|
"viewerPermission": "WRITE"
|
||||||
|
} } }
|
||||||
|
`))
|
||||||
|
http.Register(
|
||||||
|
httpmock.GraphQL(`query RepositoryResolveMetadataIDs\b`),
|
||||||
|
httpmock.StringResponse(`
|
||||||
|
{ "data": {
|
||||||
|
"u000": { "login": "MonaLisa", "id": "MONAID" },
|
||||||
|
"repository": {
|
||||||
|
"l000": { "name": "bug", "id": "BUGID" },
|
||||||
|
"l001": { "name": "TODO", "id": "TODOID" }
|
||||||
|
}
|
||||||
|
} }
|
||||||
|
`))
|
||||||
|
http.Register(
|
||||||
|
httpmock.GraphQL(`query RepositoryMilestoneList\b`),
|
||||||
|
httpmock.StringResponse(`
|
||||||
|
{ "data": { "repository": { "milestones": {
|
||||||
|
"nodes": [
|
||||||
|
{ "title": "GA", "id": "GAID" },
|
||||||
|
{ "title": "Big One.oh", "id": "BIGONEID" }
|
||||||
|
],
|
||||||
|
"pageInfo": { "hasNextPage": false }
|
||||||
|
} } } }
|
||||||
|
`))
|
||||||
|
http.Register(
|
||||||
|
httpmock.GraphQL(`query RepositoryProjectList\b`),
|
||||||
|
httpmock.StringResponse(`
|
||||||
|
{ "data": { "repository": { "projects": {
|
||||||
|
"nodes": [
|
||||||
|
{ "name": "Cleanup", "id": "CLEANUPID" },
|
||||||
|
{ "name": "Roadmap", "id": "ROADMAPID" }
|
||||||
|
],
|
||||||
|
"pageInfo": { "hasNextPage": false }
|
||||||
|
} } } }
|
||||||
|
`))
|
||||||
|
http.Register(
|
||||||
|
httpmock.GraphQL(`query OrganizationProjectList\b`),
|
||||||
|
httpmock.StringResponse(`
|
||||||
|
{ "data": { "organization": null },
|
||||||
|
"errors": [{
|
||||||
|
"type": "NOT_FOUND",
|
||||||
|
"path": [ "organization" ],
|
||||||
|
"message": "Could not resolve to an Organization with the login of 'OWNER'."
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
`))
|
||||||
|
http.Register(
|
||||||
|
httpmock.GraphQL(`mutation IssueCreate\b`),
|
||||||
|
httpmock.GraphQLMutation(`
|
||||||
|
{ "data": { "createIssue": { "issue": {
|
||||||
|
"URL": "https://github.com/OWNER/REPO/issues/12"
|
||||||
|
} } } }
|
||||||
|
`, func(inputs map[string]interface{}) {
|
||||||
|
eq(t, inputs["title"], "TITLE")
|
||||||
|
eq(t, inputs["body"], "BODY")
|
||||||
|
eq(t, inputs["assigneeIds"], []interface{}{"MONAID"})
|
||||||
|
eq(t, inputs["labelIds"], []interface{}{"BUGID", "TODOID"})
|
||||||
|
eq(t, inputs["projectIds"], []interface{}{"ROADMAPID"})
|
||||||
|
eq(t, inputs["milestoneId"], "BIGONEID")
|
||||||
|
if v, ok := inputs["userIds"]; ok {
|
||||||
|
t.Errorf("did not expect userIds: %v", v)
|
||||||
|
}
|
||||||
|
if v, ok := inputs["teamIds"]; ok {
|
||||||
|
t.Errorf("did not expect teamIds: %v", v)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
output, err := runCommand(http, true, `-t TITLE -b BODY -a monalisa -l bug -l todo -p roadmap -m 'big one.oh'`)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("error running command `issue create`: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
eq(t, output.String(), "https://github.com/OWNER/REPO/issues/12\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIssueCreate_disabledIssues(t *testing.T) {
|
||||||
|
http := &httpmock.Registry{}
|
||||||
|
defer http.Verify(t)
|
||||||
|
|
||||||
|
http.StubResponse(200, bytes.NewBufferString(`
|
||||||
|
{ "data": { "repository": {
|
||||||
|
"id": "REPOID",
|
||||||
|
"hasIssuesEnabled": false
|
||||||
|
} } }
|
||||||
|
`))
|
||||||
|
|
||||||
|
_, err := runCommand(http, true, `-t heres -b johnny`)
|
||||||
|
if err == nil || err.Error() != "the 'OWNER/REPO' repository has disabled issues" {
|
||||||
|
t.Errorf("error running command `issue create`: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIssueCreate_web(t *testing.T) {
|
||||||
|
http := &httpmock.Registry{}
|
||||||
|
defer http.Verify(t)
|
||||||
|
|
||||||
|
var seenCmd *exec.Cmd
|
||||||
|
restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable {
|
||||||
|
seenCmd = cmd
|
||||||
|
return &test.OutputStub{}
|
||||||
|
})
|
||||||
|
defer restoreCmd()
|
||||||
|
|
||||||
|
output, err := runCommand(http, true, `--web`)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("error running command `issue create`: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if seenCmd == nil {
|
||||||
|
t.Fatal("expected a command to run")
|
||||||
|
}
|
||||||
|
url := seenCmd.Args[len(seenCmd.Args)-1]
|
||||||
|
eq(t, url, "https://github.com/OWNER/REPO/issues/new")
|
||||||
|
eq(t, output.String(), "")
|
||||||
|
eq(t, output.Stderr(), "Opening github.com/OWNER/REPO/issues/new in your browser.\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIssueCreate_webTitleBody(t *testing.T) {
|
||||||
|
http := &httpmock.Registry{}
|
||||||
|
defer http.Verify(t)
|
||||||
|
|
||||||
|
var seenCmd *exec.Cmd
|
||||||
|
restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable {
|
||||||
|
seenCmd = cmd
|
||||||
|
return &test.OutputStub{}
|
||||||
|
})
|
||||||
|
defer restoreCmd()
|
||||||
|
|
||||||
|
output, err := runCommand(http, true, `-w -t mytitle -b mybody`)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("error running command `issue create`: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if seenCmd == nil {
|
||||||
|
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?body=mybody&title=mytitle")
|
||||||
|
eq(t, output.String(), "")
|
||||||
|
eq(t, output.Stderr(), "Opening github.com/OWNER/REPO/issues/new in your browser.\n")
|
||||||
|
}
|
||||||
45
pkg/cmd/issue/issue.go
Normal file
45
pkg/cmd/issue/issue.go
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
package issue
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/MakeNowJust/heredoc"
|
||||||
|
cmdClose "github.com/cli/cli/pkg/cmd/issue/close"
|
||||||
|
cmdCreate "github.com/cli/cli/pkg/cmd/issue/create"
|
||||||
|
cmdList "github.com/cli/cli/pkg/cmd/issue/list"
|
||||||
|
cmdReopen "github.com/cli/cli/pkg/cmd/issue/reopen"
|
||||||
|
cmdStatus "github.com/cli/cli/pkg/cmd/issue/status"
|
||||||
|
cmdView "github.com/cli/cli/pkg/cmd/issue/view"
|
||||||
|
"github.com/cli/cli/pkg/cmdutil"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewCmdIssue(f *cmdutil.Factory) *cobra.Command {
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "issue <command>",
|
||||||
|
Short: "Manage issues",
|
||||||
|
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": heredoc.Doc(`
|
||||||
|
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".
|
||||||
|
`),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmdutil.EnableRepoOverride(cmd, f)
|
||||||
|
|
||||||
|
cmd.AddCommand(cmdClose.NewCmdClose(f, nil))
|
||||||
|
cmd.AddCommand(cmdCreate.NewCmdCreate(f, nil))
|
||||||
|
cmd.AddCommand(cmdList.NewCmdList(f, nil))
|
||||||
|
cmd.AddCommand(cmdReopen.NewCmdReopen(f, nil))
|
||||||
|
cmd.AddCommand(cmdStatus.NewCmdStatus(f, nil))
|
||||||
|
cmd.AddCommand(cmdView.NewCmdView(f, nil))
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
127
pkg/cmd/issue/list/list.go
Normal file
127
pkg/cmd/issue/list/list.go
Normal file
|
|
@ -0,0 +1,127 @@
|
||||||
|
package list
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/MakeNowJust/heredoc"
|
||||||
|
"github.com/cli/cli/api"
|
||||||
|
"github.com/cli/cli/internal/config"
|
||||||
|
"github.com/cli/cli/internal/ghrepo"
|
||||||
|
issueShared "github.com/cli/cli/pkg/cmd/issue/shared"
|
||||||
|
prShared "github.com/cli/cli/pkg/cmd/pr/shared"
|
||||||
|
"github.com/cli/cli/pkg/cmdutil"
|
||||||
|
"github.com/cli/cli/pkg/iostreams"
|
||||||
|
"github.com/cli/cli/utils"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ListOptions struct {
|
||||||
|
HttpClient func() (*http.Client, error)
|
||||||
|
Config func() (config.Config, error)
|
||||||
|
IO *iostreams.IOStreams
|
||||||
|
BaseRepo func() (ghrepo.Interface, error)
|
||||||
|
|
||||||
|
WebMode bool
|
||||||
|
|
||||||
|
Assignee string
|
||||||
|
Labels []string
|
||||||
|
State string
|
||||||
|
LimitResults int
|
||||||
|
Author string
|
||||||
|
Mention string
|
||||||
|
Milestone string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Command {
|
||||||
|
opts := &ListOptions{
|
||||||
|
IO: f.IOStreams,
|
||||||
|
HttpClient: f.HttpClient,
|
||||||
|
Config: f.Config,
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "list",
|
||||||
|
Short: "List and filter issues in this repository",
|
||||||
|
Example: heredoc.Doc(`
|
||||||
|
$ gh issue list -l "help wanted"
|
||||||
|
$ gh issue list -A monalisa
|
||||||
|
$ gh issue list --web
|
||||||
|
`),
|
||||||
|
Args: cmdutil.NoArgsQuoteReminder,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
// support `-R, --repo` override
|
||||||
|
opts.BaseRepo = f.BaseRepo
|
||||||
|
|
||||||
|
if opts.LimitResults < 1 {
|
||||||
|
return &cmdutil.FlagError{Err: fmt.Errorf("invalid limit: %v", opts.LimitResults)}
|
||||||
|
}
|
||||||
|
|
||||||
|
if runF != nil {
|
||||||
|
return runF(opts)
|
||||||
|
}
|
||||||
|
return listRun(opts)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Flags().BoolVarP(&opts.WebMode, "web", "w", false, "Open the browser to list the issue(s)")
|
||||||
|
cmd.Flags().StringVarP(&opts.Assignee, "assignee", "a", "", "Filter by assignee")
|
||||||
|
cmd.Flags().StringSliceVarP(&opts.Labels, "label", "l", nil, "Filter by labels")
|
||||||
|
cmd.Flags().StringVarP(&opts.State, "state", "s", "open", "Filter by state: {open|closed|all}")
|
||||||
|
cmd.Flags().IntVarP(&opts.LimitResults, "limit", "L", 30, "Maximum number of issues to fetch")
|
||||||
|
cmd.Flags().StringVarP(&opts.Author, "author", "A", "", "Filter by author")
|
||||||
|
cmd.Flags().StringVar(&opts.Mention, "mention", "", "Filter by mention")
|
||||||
|
cmd.Flags().StringVarP(&opts.Milestone, "milestone", "m", "", "Filter by milestone `name`")
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func listRun(opts *ListOptions) error {
|
||||||
|
httpClient, err := opts.HttpClient()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
apiClient := api.NewClientFromHTTP(httpClient)
|
||||||
|
|
||||||
|
baseRepo, err := opts.BaseRepo()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
isTerminal := opts.IO.IsStdoutTTY()
|
||||||
|
|
||||||
|
if opts.WebMode {
|
||||||
|
issueListURL := ghrepo.GenerateRepoURL(baseRepo, "issues")
|
||||||
|
openURL, err := prShared.ListURLWithQuery(issueListURL, prShared.FilterOptions{
|
||||||
|
Entity: "issue",
|
||||||
|
State: opts.State,
|
||||||
|
Assignee: opts.Assignee,
|
||||||
|
Labels: opts.Labels,
|
||||||
|
Author: opts.Author,
|
||||||
|
Mention: opts.Mention,
|
||||||
|
Milestone: opts.Milestone,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if isTerminal {
|
||||||
|
fmt.Fprintf(opts.IO.ErrOut, "Opening %s in your browser.\n", utils.DisplayURL(openURL))
|
||||||
|
}
|
||||||
|
return utils.OpenInBrowser(openURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
listResult, err := api.IssueList(apiClient, baseRepo, opts.State, opts.Labels, opts.Assignee, opts.LimitResults, opts.Author, opts.Mention, opts.Milestone)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if isTerminal {
|
||||||
|
hasFilters := opts.State != "open" || len(opts.Labels) > 0 || opts.Assignee != "" || opts.Author != "" || opts.Mention != "" || opts.Milestone != ""
|
||||||
|
title := prShared.ListHeader(ghrepo.FullName(baseRepo), "issue", len(listResult.Issues), listResult.TotalCount, hasFilters)
|
||||||
|
fmt.Fprintf(opts.IO.ErrOut, "\n%s\n\n", title)
|
||||||
|
}
|
||||||
|
|
||||||
|
issueShared.PrintIssues(opts.IO, "", len(listResult.Issues), listResult.Issues)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
223
pkg/cmd/issue/list/list_test.go
Normal file
223
pkg/cmd/issue/list/list_test.go
Normal file
|
|
@ -0,0 +1,223 @@
|
||||||
|
package list
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"io/ioutil"
|
||||||
|
"net/http"
|
||||||
|
"os/exec"
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/cli/cli/internal/config"
|
||||||
|
"github.com/cli/cli/internal/ghrepo"
|
||||||
|
"github.com/cli/cli/internal/run"
|
||||||
|
"github.com/cli/cli/pkg/cmdutil"
|
||||||
|
"github.com/cli/cli/pkg/httpmock"
|
||||||
|
"github.com/cli/cli/pkg/iostreams"
|
||||||
|
"github.com/cli/cli/test"
|
||||||
|
"github.com/google/shlex"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func eq(t *testing.T, got interface{}, expected interface{}) {
|
||||||
|
t.Helper()
|
||||||
|
if !reflect.DeepEqual(got, expected) {
|
||||||
|
t.Errorf("expected: %v, got: %v", expected, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runCommand(rt http.RoundTripper, isTTY bool, cli string) (*test.CmdOut, error) {
|
||||||
|
io, _, stdout, stderr := iostreams.Test()
|
||||||
|
io.SetStdoutTTY(isTTY)
|
||||||
|
io.SetStdinTTY(isTTY)
|
||||||
|
io.SetStderrTTY(isTTY)
|
||||||
|
|
||||||
|
factory := &cmdutil.Factory{
|
||||||
|
IOStreams: io,
|
||||||
|
HttpClient: func() (*http.Client, error) {
|
||||||
|
return &http.Client{Transport: rt}, nil
|
||||||
|
},
|
||||||
|
Config: func() (config.Config, error) {
|
||||||
|
return config.NewBlankConfig(), nil
|
||||||
|
},
|
||||||
|
BaseRepo: func() (ghrepo.Interface, error) {
|
||||||
|
return ghrepo.New("OWNER", "REPO"), nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := NewCmdList(factory, nil)
|
||||||
|
|
||||||
|
argv, err := shlex.Split(cli)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cmd.SetArgs(argv)
|
||||||
|
|
||||||
|
cmd.SetIn(&bytes.Buffer{})
|
||||||
|
cmd.SetOut(ioutil.Discard)
|
||||||
|
cmd.SetErr(ioutil.Discard)
|
||||||
|
|
||||||
|
_, err = cmd.ExecuteC()
|
||||||
|
return &test.CmdOut{
|
||||||
|
OutBuf: stdout,
|
||||||
|
ErrBuf: stderr,
|
||||||
|
}, err
|
||||||
|
}
|
||||||
|
func TestIssueList_nontty(t *testing.T) {
|
||||||
|
http := &httpmock.Registry{}
|
||||||
|
defer http.Verify(t)
|
||||||
|
|
||||||
|
http.Register(
|
||||||
|
httpmock.GraphQL(`query IssueList\b`),
|
||||||
|
httpmock.FileResponse("./fixtures/issueList.json"))
|
||||||
|
|
||||||
|
output, err := runCommand(http, false, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("error running command `issue list`: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
eq(t, output.Stderr(), "")
|
||||||
|
test.ExpectLines(t, output.String(),
|
||||||
|
`1[\t]+number won[\t]+label[\t]+\d+`,
|
||||||
|
`2[\t]+number too[\t]+label[\t]+\d+`,
|
||||||
|
`4[\t]+number fore[\t]+label[\t]+\d+`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIssueList_tty(t *testing.T) {
|
||||||
|
http := &httpmock.Registry{}
|
||||||
|
defer http.Verify(t)
|
||||||
|
|
||||||
|
http.Register(
|
||||||
|
httpmock.GraphQL(`query IssueList\b`),
|
||||||
|
httpmock.FileResponse("./fixtures/issueList.json"))
|
||||||
|
|
||||||
|
output, err := runCommand(http, true, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("error running command `issue list`: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
eq(t, output.Stderr(), `
|
||||||
|
Showing 3 of 3 open issues in OWNER/REPO
|
||||||
|
|
||||||
|
`)
|
||||||
|
|
||||||
|
test.ExpectLines(t, output.String(),
|
||||||
|
"number won",
|
||||||
|
"number too",
|
||||||
|
"number fore")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIssueList_tty_withFlags(t *testing.T) {
|
||||||
|
http := &httpmock.Registry{}
|
||||||
|
defer http.Verify(t)
|
||||||
|
|
||||||
|
http.Register(
|
||||||
|
httpmock.GraphQL(`query IssueList\b`),
|
||||||
|
httpmock.GraphQLQuery(`
|
||||||
|
{ "data": { "repository": {
|
||||||
|
"hasIssuesEnabled": true,
|
||||||
|
"issues": { "nodes": [] }
|
||||||
|
} } }`, func(_ string, params map[string]interface{}) {
|
||||||
|
assert.Equal(t, "probablyCher", params["assignee"].(string))
|
||||||
|
assert.Equal(t, "foo", params["author"].(string))
|
||||||
|
assert.Equal(t, "me", params["mention"].(string))
|
||||||
|
assert.Equal(t, "1.x", params["milestone"].(string))
|
||||||
|
assert.Equal(t, []interface{}{"web", "bug"}, params["labels"].([]interface{}))
|
||||||
|
assert.Equal(t, []interface{}{"OPEN"}, params["states"].([]interface{}))
|
||||||
|
}))
|
||||||
|
|
||||||
|
output, err := runCommand(http, true, "-a probablyCher -l web,bug -s open -A foo --mention me --milestone 1.x")
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("error running command `issue list`: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
eq(t, output.String(), "")
|
||||||
|
eq(t, output.Stderr(), `
|
||||||
|
No issues match your search in OWNER/REPO
|
||||||
|
|
||||||
|
`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIssueList_withInvalidLimitFlag(t *testing.T) {
|
||||||
|
http := &httpmock.Registry{}
|
||||||
|
defer http.Verify(t)
|
||||||
|
|
||||||
|
_, err := runCommand(http, true, "--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) {
|
||||||
|
http := &httpmock.Registry{}
|
||||||
|
defer http.Verify(t)
|
||||||
|
|
||||||
|
http.StubResponse(200, bytes.NewBufferString(`
|
||||||
|
{ "data": { "repository": {
|
||||||
|
"hasIssuesEnabled": true,
|
||||||
|
"issues": { "nodes": [] }
|
||||||
|
} } }
|
||||||
|
`))
|
||||||
|
|
||||||
|
_, err := runCommand(http, true, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("error running command `issue list`: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
bodyBytes, _ := ioutil.ReadAll(http.Requests[0].Body)
|
||||||
|
reqBody := struct {
|
||||||
|
Variables map[string]interface{}
|
||||||
|
}{}
|
||||||
|
_ = json.Unmarshal(bodyBytes, &reqBody)
|
||||||
|
|
||||||
|
_, assigneeDeclared := reqBody.Variables["assignee"]
|
||||||
|
_, labelsDeclared := reqBody.Variables["labels"]
|
||||||
|
eq(t, assigneeDeclared, false)
|
||||||
|
eq(t, labelsDeclared, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIssueList_disabledIssues(t *testing.T) {
|
||||||
|
http := &httpmock.Registry{}
|
||||||
|
defer http.Verify(t)
|
||||||
|
|
||||||
|
http.StubResponse(200, bytes.NewBufferString(`
|
||||||
|
{ "data": { "repository": {
|
||||||
|
"hasIssuesEnabled": false
|
||||||
|
} } }
|
||||||
|
`))
|
||||||
|
|
||||||
|
_, err := runCommand(http, true, "")
|
||||||
|
if err == nil || err.Error() != "the 'OWNER/REPO' repository has disabled issues" {
|
||||||
|
t.Errorf("error running command `issue list`: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIssueList_web(t *testing.T) {
|
||||||
|
http := &httpmock.Registry{}
|
||||||
|
defer http.Verify(t)
|
||||||
|
|
||||||
|
var seenCmd *exec.Cmd
|
||||||
|
restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable {
|
||||||
|
seenCmd = cmd
|
||||||
|
return &test.OutputStub{}
|
||||||
|
})
|
||||||
|
defer restoreCmd()
|
||||||
|
|
||||||
|
output, err := runCommand(http, true, "--web -a peter -A john -l bug -l docs -L 10 -s all --mention frank --milestone v1.1")
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("error running command `issue list` with `--web` flag: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
expectedURL := "https://github.com/OWNER/REPO/issues?q=is%3Aissue+assignee%3Apeter+label%3Abug+label%3Adocs+author%3Ajohn+mentions%3Afrank+milestone%3Av1.1"
|
||||||
|
|
||||||
|
eq(t, output.String(), "")
|
||||||
|
eq(t, output.Stderr(), "Opening github.com/OWNER/REPO/issues in your browser.\n")
|
||||||
|
|
||||||
|
if seenCmd == nil {
|
||||||
|
t.Fatal("expected a command to run")
|
||||||
|
}
|
||||||
|
url := seenCmd.Args[len(seenCmd.Args)-1]
|
||||||
|
eq(t, url, expectedURL)
|
||||||
|
}
|
||||||
80
pkg/cmd/issue/reopen/reopen.go
Normal file
80
pkg/cmd/issue/reopen/reopen.go
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
package reopen
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/cli/cli/api"
|
||||||
|
"github.com/cli/cli/internal/config"
|
||||||
|
"github.com/cli/cli/internal/ghrepo"
|
||||||
|
"github.com/cli/cli/pkg/cmd/issue/shared"
|
||||||
|
"github.com/cli/cli/pkg/cmdutil"
|
||||||
|
"github.com/cli/cli/pkg/iostreams"
|
||||||
|
"github.com/cli/cli/utils"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ReopenOptions struct {
|
||||||
|
HttpClient func() (*http.Client, error)
|
||||||
|
Config func() (config.Config, error)
|
||||||
|
IO *iostreams.IOStreams
|
||||||
|
BaseRepo func() (ghrepo.Interface, error)
|
||||||
|
|
||||||
|
SelectorArg string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCmdReopen(f *cmdutil.Factory, runF func(*ReopenOptions) error) *cobra.Command {
|
||||||
|
opts := &ReopenOptions{
|
||||||
|
IO: f.IOStreams,
|
||||||
|
HttpClient: f.HttpClient,
|
||||||
|
Config: f.Config,
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "reopen {<number> | <url>}",
|
||||||
|
Short: "Reopen issue",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
// support `-R, --repo` override
|
||||||
|
opts.BaseRepo = f.BaseRepo
|
||||||
|
|
||||||
|
if len(args) > 0 {
|
||||||
|
opts.SelectorArg = args[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
if runF != nil {
|
||||||
|
return runF(opts)
|
||||||
|
}
|
||||||
|
return reopenRun(opts)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func reopenRun(opts *ReopenOptions) error {
|
||||||
|
httpClient, err := opts.HttpClient()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
apiClient := api.NewClientFromHTTP(httpClient)
|
||||||
|
|
||||||
|
issue, baseRepo, err := shared.IssueFromArg(apiClient, opts.BaseRepo, opts.SelectorArg)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if !issue.Closed {
|
||||||
|
fmt.Fprintf(opts.IO.ErrOut, "%s Issue #%d (%s) is already open\n", utils.Yellow("!"), issue.Number, issue.Title)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
err = api.IssueReopen(apiClient, baseRepo, *issue)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintf(opts.IO.ErrOut, "%s Reopened issue #%d (%s)\n", utils.Green("✔"), issue.Number, issue.Title)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
119
pkg/cmd/issue/reopen/reopen_test.go
Normal file
119
pkg/cmd/issue/reopen/reopen_test.go
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
package reopen
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"io/ioutil"
|
||||||
|
"net/http"
|
||||||
|
"regexp"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/cli/cli/internal/config"
|
||||||
|
"github.com/cli/cli/internal/ghrepo"
|
||||||
|
"github.com/cli/cli/pkg/cmdutil"
|
||||||
|
"github.com/cli/cli/pkg/httpmock"
|
||||||
|
"github.com/cli/cli/pkg/iostreams"
|
||||||
|
"github.com/cli/cli/test"
|
||||||
|
"github.com/google/shlex"
|
||||||
|
)
|
||||||
|
|
||||||
|
func runCommand(rt http.RoundTripper, isTTY bool, cli string) (*test.CmdOut, error) {
|
||||||
|
io, _, stdout, stderr := iostreams.Test()
|
||||||
|
io.SetStdoutTTY(isTTY)
|
||||||
|
io.SetStdinTTY(isTTY)
|
||||||
|
io.SetStderrTTY(isTTY)
|
||||||
|
|
||||||
|
factory := &cmdutil.Factory{
|
||||||
|
IOStreams: io,
|
||||||
|
HttpClient: func() (*http.Client, error) {
|
||||||
|
return &http.Client{Transport: rt}, nil
|
||||||
|
},
|
||||||
|
Config: func() (config.Config, error) {
|
||||||
|
return config.NewBlankConfig(), nil
|
||||||
|
},
|
||||||
|
BaseRepo: func() (ghrepo.Interface, error) {
|
||||||
|
return ghrepo.New("OWNER", "REPO"), nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := NewCmdReopen(factory, nil)
|
||||||
|
|
||||||
|
argv, err := shlex.Split(cli)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cmd.SetArgs(argv)
|
||||||
|
|
||||||
|
cmd.SetIn(&bytes.Buffer{})
|
||||||
|
cmd.SetOut(ioutil.Discard)
|
||||||
|
cmd.SetErr(ioutil.Discard)
|
||||||
|
|
||||||
|
_, err = cmd.ExecuteC()
|
||||||
|
return &test.CmdOut{
|
||||||
|
OutBuf: stdout,
|
||||||
|
ErrBuf: stderr,
|
||||||
|
}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIssueReopen(t *testing.T) {
|
||||||
|
http := &httpmock.Registry{}
|
||||||
|
defer http.Verify(t)
|
||||||
|
|
||||||
|
http.StubResponse(200, bytes.NewBufferString(`
|
||||||
|
{ "data": { "repository": {
|
||||||
|
"hasIssuesEnabled": true,
|
||||||
|
"issue": { "number": 2, "closed": true, "title": "The title of the issue"}
|
||||||
|
} } }
|
||||||
|
`))
|
||||||
|
|
||||||
|
http.StubResponse(200, bytes.NewBufferString(`{"id": "THE-ID"}`))
|
||||||
|
|
||||||
|
output, err := runCommand(http, true, "2")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("error running command `issue reopen`: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
r := regexp.MustCompile(`Reopened issue #2 \(The title of the issue\)`)
|
||||||
|
|
||||||
|
if !r.MatchString(output.Stderr()) {
|
||||||
|
t.Fatalf("output did not match regexp /%s/\n> output\n%q\n", r, output.Stderr())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIssueReopen_alreadyOpen(t *testing.T) {
|
||||||
|
http := &httpmock.Registry{}
|
||||||
|
defer http.Verify(t)
|
||||||
|
|
||||||
|
http.StubResponse(200, bytes.NewBufferString(`
|
||||||
|
{ "data": { "repository": {
|
||||||
|
"hasIssuesEnabled": true,
|
||||||
|
"issue": { "number": 2, "closed": false, "title": "The title of the issue"}
|
||||||
|
} } }
|
||||||
|
`))
|
||||||
|
|
||||||
|
output, err := runCommand(http, true, "2")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("error running command `issue reopen`: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
r := regexp.MustCompile(`Issue #2 \(The title of the issue\) is already open`)
|
||||||
|
|
||||||
|
if !r.MatchString(output.Stderr()) {
|
||||||
|
t.Fatalf("output did not match regexp /%s/\n> output\n%q\n", r, output.Stderr())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIssueReopen_issuesDisabled(t *testing.T) {
|
||||||
|
http := &httpmock.Registry{}
|
||||||
|
defer http.Verify(t)
|
||||||
|
|
||||||
|
http.StubResponse(200, bytes.NewBufferString(`
|
||||||
|
{ "data": { "repository": {
|
||||||
|
"hasIssuesEnabled": false
|
||||||
|
} } }
|
||||||
|
`))
|
||||||
|
|
||||||
|
_, err := runCommand(http, true, "2")
|
||||||
|
if err == nil || err.Error() != "the 'OWNER/REPO' repository has disabled issues" {
|
||||||
|
t.Fatalf("got error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
65
pkg/cmd/issue/shared/display.go
Normal file
65
pkg/cmd/issue/shared/display.go
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
package shared
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/cli/cli/api"
|
||||||
|
prShared "github.com/cli/cli/pkg/cmd/pr/shared"
|
||||||
|
"github.com/cli/cli/pkg/iostreams"
|
||||||
|
"github.com/cli/cli/pkg/text"
|
||||||
|
"github.com/cli/cli/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
func PrintIssues(io *iostreams.IOStreams, prefix string, totalCount int, issues []api.Issue) {
|
||||||
|
table := utils.NewTablePrinter(io)
|
||||||
|
for _, issue := range issues {
|
||||||
|
issueNum := strconv.Itoa(issue.Number)
|
||||||
|
if table.IsTTY() {
|
||||||
|
issueNum = "#" + issueNum
|
||||||
|
}
|
||||||
|
issueNum = prefix + issueNum
|
||||||
|
labels := IssueLabelList(issue)
|
||||||
|
if labels != "" && table.IsTTY() {
|
||||||
|
labels = fmt.Sprintf("(%s)", labels)
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
ago := now.Sub(issue.UpdatedAt)
|
||||||
|
table.AddField(issueNum, nil, prShared.ColorFuncForState(issue.State))
|
||||||
|
if !table.IsTTY() {
|
||||||
|
table.AddField(issue.State, nil, nil)
|
||||||
|
}
|
||||||
|
table.AddField(text.ReplaceExcessiveWhitespace(issue.Title), nil, nil)
|
||||||
|
table.AddField(labels, nil, utils.Gray)
|
||||||
|
if table.IsTTY() {
|
||||||
|
table.AddField(utils.FuzzyAgo(ago), nil, utils.Gray)
|
||||||
|
} else {
|
||||||
|
table.AddField(issue.UpdatedAt.String(), nil, nil)
|
||||||
|
}
|
||||||
|
table.EndRow()
|
||||||
|
}
|
||||||
|
_ = table.Render()
|
||||||
|
remaining := totalCount - len(issues)
|
||||||
|
if remaining > 0 {
|
||||||
|
fmt.Fprintf(io.Out, utils.Gray("%sAnd %d more\n"), prefix, remaining)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func IssueLabelList(issue api.Issue) string {
|
||||||
|
if len(issue.Labels.Nodes) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
labelNames := make([]string, 0, len(issue.Labels.Nodes))
|
||||||
|
for _, label := range issue.Labels.Nodes {
|
||||||
|
labelNames = append(labelNames, label.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
list := strings.Join(labelNames, ", ")
|
||||||
|
if issue.Labels.TotalCount > len(issue.Labels.Nodes) {
|
||||||
|
list += ", …"
|
||||||
|
}
|
||||||
|
return list
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package command
|
package shared
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
@ -8,12 +8,10 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/cli/cli/api"
|
"github.com/cli/cli/api"
|
||||||
"github.com/cli/cli/context"
|
|
||||||
"github.com/cli/cli/internal/ghrepo"
|
"github.com/cli/cli/internal/ghrepo"
|
||||||
"github.com/spf13/cobra"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func issueFromArg(ctx context.Context, apiClient *api.Client, cmd *cobra.Command, arg string) (*api.Issue, ghrepo.Interface, error) {
|
func IssueFromArg(apiClient *api.Client, baseRepoFn func() (ghrepo.Interface, error), arg string) (*api.Issue, ghrepo.Interface, error) {
|
||||||
issue, baseRepo, err := issueFromURL(apiClient, arg)
|
issue, baseRepo, err := issueFromURL(apiClient, arg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
|
|
@ -22,7 +20,7 @@ func issueFromArg(ctx context.Context, apiClient *api.Client, cmd *cobra.Command
|
||||||
return issue, baseRepo, nil
|
return issue, baseRepo, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
baseRepo, err = determineBaseRepo(apiClient, cmd, ctx)
|
baseRepo, err = baseRepoFn()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, fmt.Errorf("could not determine base repo: %w", err)
|
return nil, nil, fmt.Errorf("could not determine base repo: %w", err)
|
||||||
}
|
}
|
||||||
103
pkg/cmd/issue/status/status.go
Normal file
103
pkg/cmd/issue/status/status.go
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
package status
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/cli/cli/api"
|
||||||
|
"github.com/cli/cli/internal/config"
|
||||||
|
"github.com/cli/cli/internal/ghrepo"
|
||||||
|
issueShared "github.com/cli/cli/pkg/cmd/issue/shared"
|
||||||
|
prShared "github.com/cli/cli/pkg/cmd/pr/shared"
|
||||||
|
"github.com/cli/cli/pkg/cmdutil"
|
||||||
|
"github.com/cli/cli/pkg/iostreams"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
type StatusOptions struct {
|
||||||
|
HttpClient func() (*http.Client, error)
|
||||||
|
Config func() (config.Config, error)
|
||||||
|
IO *iostreams.IOStreams
|
||||||
|
BaseRepo func() (ghrepo.Interface, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCmdStatus(f *cmdutil.Factory, runF func(*StatusOptions) error) *cobra.Command {
|
||||||
|
opts := &StatusOptions{
|
||||||
|
IO: f.IOStreams,
|
||||||
|
HttpClient: f.HttpClient,
|
||||||
|
Config: f.Config,
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "status",
|
||||||
|
Short: "Show status of relevant issues",
|
||||||
|
Args: cmdutil.NoArgsQuoteReminder,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
// support `-R, --repo` override
|
||||||
|
opts.BaseRepo = f.BaseRepo
|
||||||
|
|
||||||
|
if runF != nil {
|
||||||
|
return runF(opts)
|
||||||
|
}
|
||||||
|
return statusRun(opts)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func statusRun(opts *StatusOptions) error {
|
||||||
|
httpClient, err := opts.HttpClient()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
apiClient := api.NewClientFromHTTP(httpClient)
|
||||||
|
|
||||||
|
baseRepo, err := opts.BaseRepo()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
currentUser, err := api.CurrentLoginName(apiClient, baseRepo.RepoHost())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
issuePayload, err := api.IssueStatus(apiClient, baseRepo, currentUser)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
out := opts.IO.Out
|
||||||
|
|
||||||
|
fmt.Fprintln(out, "")
|
||||||
|
fmt.Fprintf(out, "Relevant issues in %s\n", ghrepo.FullName(baseRepo))
|
||||||
|
fmt.Fprintln(out, "")
|
||||||
|
|
||||||
|
prShared.PrintHeader(out, "Issues assigned to you")
|
||||||
|
if issuePayload.Assigned.TotalCount > 0 {
|
||||||
|
issueShared.PrintIssues(opts.IO, " ", issuePayload.Assigned.TotalCount, issuePayload.Assigned.Issues)
|
||||||
|
} else {
|
||||||
|
message := " There are no issues assigned to you"
|
||||||
|
prShared.PrintMessage(out, message)
|
||||||
|
}
|
||||||
|
fmt.Fprintln(out)
|
||||||
|
|
||||||
|
prShared.PrintHeader(out, "Issues mentioning you")
|
||||||
|
if issuePayload.Mentioned.TotalCount > 0 {
|
||||||
|
issueShared.PrintIssues(opts.IO, " ", issuePayload.Mentioned.TotalCount, issuePayload.Mentioned.Issues)
|
||||||
|
} else {
|
||||||
|
prShared.PrintMessage(out, " There are no issues mentioning you")
|
||||||
|
}
|
||||||
|
fmt.Fprintln(out)
|
||||||
|
|
||||||
|
prShared.PrintHeader(out, "Issues opened by you")
|
||||||
|
if issuePayload.Authored.TotalCount > 0 {
|
||||||
|
issueShared.PrintIssues(opts.IO, " ", issuePayload.Authored.TotalCount, issuePayload.Authored.Issues)
|
||||||
|
} else {
|
||||||
|
prShared.PrintMessage(out, " There are no issues opened by you")
|
||||||
|
}
|
||||||
|
fmt.Fprintln(out)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
146
pkg/cmd/issue/status/status_test.go
Normal file
146
pkg/cmd/issue/status/status_test.go
Normal file
|
|
@ -0,0 +1,146 @@
|
||||||
|
package status
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"io/ioutil"
|
||||||
|
"net/http"
|
||||||
|
"regexp"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/cli/cli/internal/config"
|
||||||
|
"github.com/cli/cli/internal/ghrepo"
|
||||||
|
"github.com/cli/cli/pkg/cmdutil"
|
||||||
|
"github.com/cli/cli/pkg/httpmock"
|
||||||
|
"github.com/cli/cli/pkg/iostreams"
|
||||||
|
"github.com/cli/cli/test"
|
||||||
|
"github.com/google/shlex"
|
||||||
|
)
|
||||||
|
|
||||||
|
func runCommand(rt http.RoundTripper, isTTY bool, cli string) (*test.CmdOut, error) {
|
||||||
|
io, _, stdout, stderr := iostreams.Test()
|
||||||
|
io.SetStdoutTTY(isTTY)
|
||||||
|
io.SetStdinTTY(isTTY)
|
||||||
|
io.SetStderrTTY(isTTY)
|
||||||
|
|
||||||
|
factory := &cmdutil.Factory{
|
||||||
|
IOStreams: io,
|
||||||
|
HttpClient: func() (*http.Client, error) {
|
||||||
|
return &http.Client{Transport: rt}, nil
|
||||||
|
},
|
||||||
|
Config: func() (config.Config, error) {
|
||||||
|
return config.NewBlankConfig(), nil
|
||||||
|
},
|
||||||
|
BaseRepo: func() (ghrepo.Interface, error) {
|
||||||
|
return ghrepo.New("OWNER", "REPO"), nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := NewCmdStatus(factory, nil)
|
||||||
|
|
||||||
|
argv, err := shlex.Split(cli)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cmd.SetArgs(argv)
|
||||||
|
|
||||||
|
cmd.SetIn(&bytes.Buffer{})
|
||||||
|
cmd.SetOut(ioutil.Discard)
|
||||||
|
cmd.SetErr(ioutil.Discard)
|
||||||
|
|
||||||
|
_, err = cmd.ExecuteC()
|
||||||
|
return &test.CmdOut{
|
||||||
|
OutBuf: stdout,
|
||||||
|
ErrBuf: stderr,
|
||||||
|
}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIssueStatus(t *testing.T) {
|
||||||
|
http := &httpmock.Registry{}
|
||||||
|
defer http.Verify(t)
|
||||||
|
|
||||||
|
http.Register(
|
||||||
|
httpmock.GraphQL(`query UserCurrent\b`),
|
||||||
|
httpmock.StringResponse(`{"data":{"viewer":{"login":"octocat"}}}`))
|
||||||
|
http.Register(
|
||||||
|
httpmock.GraphQL(`query IssueStatus\b`),
|
||||||
|
httpmock.FileResponse("./fixtures/issueStatus.json"))
|
||||||
|
|
||||||
|
output, err := runCommand(http, true, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("error running command `issue status`: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
expectedIssues := []*regexp.Regexp{
|
||||||
|
regexp.MustCompile(`(?m)8.*carrots.*about.*ago`),
|
||||||
|
regexp.MustCompile(`(?m)9.*squash.*about.*ago`),
|
||||||
|
regexp.MustCompile(`(?m)10.*broccoli.*about.*ago`),
|
||||||
|
regexp.MustCompile(`(?m)11.*swiss chard.*about.*ago`),
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, r := range expectedIssues {
|
||||||
|
if !r.MatchString(output.String()) {
|
||||||
|
t.Errorf("output did not match regexp /%s/\n> output\n%s\n", r, output)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIssueStatus_blankSlate(t *testing.T) {
|
||||||
|
http := &httpmock.Registry{}
|
||||||
|
defer http.Verify(t)
|
||||||
|
|
||||||
|
http.Register(
|
||||||
|
httpmock.GraphQL(`query UserCurrent\b`),
|
||||||
|
httpmock.StringResponse(`{"data":{"viewer":{"login":"octocat"}}}`))
|
||||||
|
http.Register(
|
||||||
|
httpmock.GraphQL(`query IssueStatus\b`),
|
||||||
|
httpmock.StringResponse(`
|
||||||
|
{ "data": { "repository": {
|
||||||
|
"hasIssuesEnabled": true,
|
||||||
|
"assigned": { "nodes": [] },
|
||||||
|
"mentioned": { "nodes": [] },
|
||||||
|
"authored": { "nodes": [] }
|
||||||
|
} } }`))
|
||||||
|
|
||||||
|
output, err := runCommand(http, true, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("error running command `issue status`: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
expectedOutput := `
|
||||||
|
Relevant issues in OWNER/REPO
|
||||||
|
|
||||||
|
Issues assigned to you
|
||||||
|
There are no issues assigned to you
|
||||||
|
|
||||||
|
Issues mentioning you
|
||||||
|
There are no issues mentioning you
|
||||||
|
|
||||||
|
Issues opened by you
|
||||||
|
There are no issues opened by you
|
||||||
|
|
||||||
|
`
|
||||||
|
if output.String() != expectedOutput {
|
||||||
|
t.Errorf("expected %q, got %q", expectedOutput, output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIssueStatus_disabledIssues(t *testing.T) {
|
||||||
|
http := &httpmock.Registry{}
|
||||||
|
defer http.Verify(t)
|
||||||
|
|
||||||
|
http.Register(
|
||||||
|
httpmock.GraphQL(`query UserCurrent\b`),
|
||||||
|
httpmock.StringResponse(`{"data":{"viewer":{"login":"octocat"}}}`))
|
||||||
|
http.Register(
|
||||||
|
httpmock.GraphQL(`query IssueStatus\b`),
|
||||||
|
httpmock.StringResponse(`
|
||||||
|
{ "data": { "repository": {
|
||||||
|
"hasIssuesEnabled": false
|
||||||
|
} } }`))
|
||||||
|
|
||||||
|
_, err := runCommand(http, true, "")
|
||||||
|
if err == nil || err.Error() != "the 'OWNER/REPO' repository has disabled issues" {
|
||||||
|
t.Errorf("error running command `issue status`: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
207
pkg/cmd/issue/view/view.go
Normal file
207
pkg/cmd/issue/view/view.go
Normal file
|
|
@ -0,0 +1,207 @@
|
||||||
|
package view
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/MakeNowJust/heredoc"
|
||||||
|
"github.com/cli/cli/api"
|
||||||
|
"github.com/cli/cli/internal/config"
|
||||||
|
"github.com/cli/cli/internal/ghrepo"
|
||||||
|
"github.com/cli/cli/pkg/cmd/issue/shared"
|
||||||
|
issueShared "github.com/cli/cli/pkg/cmd/issue/shared"
|
||||||
|
prShared "github.com/cli/cli/pkg/cmd/pr/shared"
|
||||||
|
"github.com/cli/cli/pkg/cmdutil"
|
||||||
|
"github.com/cli/cli/pkg/iostreams"
|
||||||
|
"github.com/cli/cli/utils"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ViewOptions struct {
|
||||||
|
HttpClient func() (*http.Client, error)
|
||||||
|
Config func() (config.Config, error)
|
||||||
|
IO *iostreams.IOStreams
|
||||||
|
BaseRepo func() (ghrepo.Interface, error)
|
||||||
|
|
||||||
|
SelectorArg string
|
||||||
|
WebMode bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Command {
|
||||||
|
opts := &ViewOptions{
|
||||||
|
IO: f.IOStreams,
|
||||||
|
HttpClient: f.HttpClient,
|
||||||
|
Config: f.Config,
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "view {<number> | <url>}",
|
||||||
|
Short: "View an issue",
|
||||||
|
Long: heredoc.Doc(`
|
||||||
|
Display the title, body, and other information about an issue.
|
||||||
|
|
||||||
|
With '--web', open the issue in a web browser instead.
|
||||||
|
`),
|
||||||
|
Example: heredoc.Doc(`
|
||||||
|
`),
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
// support `-R, --repo` override
|
||||||
|
opts.BaseRepo = f.BaseRepo
|
||||||
|
|
||||||
|
if len(args) > 0 {
|
||||||
|
opts.SelectorArg = args[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
if runF != nil {
|
||||||
|
return runF(opts)
|
||||||
|
}
|
||||||
|
return viewRun(opts)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Flags().BoolVarP(&opts.WebMode, "web", "w", false, "Open an issue in the browser")
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func viewRun(opts *ViewOptions) error {
|
||||||
|
httpClient, err := opts.HttpClient()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
apiClient := api.NewClientFromHTTP(httpClient)
|
||||||
|
|
||||||
|
issue, _, err := issueShared.IssueFromArg(apiClient, opts.BaseRepo, opts.SelectorArg)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
openURL := issue.URL
|
||||||
|
|
||||||
|
if opts.WebMode {
|
||||||
|
fmt.Fprintf(opts.IO.ErrOut, "Opening %s in your browser.\n", openURL)
|
||||||
|
return utils.OpenInBrowser(openURL)
|
||||||
|
}
|
||||||
|
if opts.IO.IsStdoutTTY() {
|
||||||
|
return printHumanIssuePreview(opts.IO.Out, issue)
|
||||||
|
}
|
||||||
|
|
||||||
|
return printRawIssuePreview(opts.IO.Out, issue)
|
||||||
|
}
|
||||||
|
|
||||||
|
func printRawIssuePreview(out io.Writer, issue *api.Issue) error {
|
||||||
|
assignees := issueAssigneeList(*issue)
|
||||||
|
labels := shared.IssueLabelList(*issue)
|
||||||
|
projects := issueProjectList(*issue)
|
||||||
|
|
||||||
|
// Print empty strings for empty values so the number of metadata lines is consistent when
|
||||||
|
// processing many issues with head and grep.
|
||||||
|
fmt.Fprintf(out, "title:\t%s\n", issue.Title)
|
||||||
|
fmt.Fprintf(out, "state:\t%s\n", issue.State)
|
||||||
|
fmt.Fprintf(out, "author:\t%s\n", issue.Author.Login)
|
||||||
|
fmt.Fprintf(out, "labels:\t%s\n", labels)
|
||||||
|
fmt.Fprintf(out, "comments:\t%d\n", issue.Comments.TotalCount)
|
||||||
|
fmt.Fprintf(out, "assignees:\t%s\n", assignees)
|
||||||
|
fmt.Fprintf(out, "projects:\t%s\n", projects)
|
||||||
|
fmt.Fprintf(out, "milestone:\t%s\n", issue.Milestone.Title)
|
||||||
|
|
||||||
|
fmt.Fprintln(out, "--")
|
||||||
|
fmt.Fprintln(out, issue.Body)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func printHumanIssuePreview(out io.Writer, issue *api.Issue) error {
|
||||||
|
now := time.Now()
|
||||||
|
ago := now.Sub(issue.CreatedAt)
|
||||||
|
|
||||||
|
// Header (Title and State)
|
||||||
|
fmt.Fprintln(out, utils.Bold(issue.Title))
|
||||||
|
fmt.Fprint(out, issueStateTitleWithColor(issue.State))
|
||||||
|
fmt.Fprintln(out, utils.Gray(fmt.Sprintf(
|
||||||
|
" • %s opened %s • %s",
|
||||||
|
issue.Author.Login,
|
||||||
|
utils.FuzzyAgo(ago),
|
||||||
|
utils.Pluralize(issue.Comments.TotalCount, "comment"),
|
||||||
|
)))
|
||||||
|
|
||||||
|
// Metadata
|
||||||
|
fmt.Fprintln(out)
|
||||||
|
if assignees := issueAssigneeList(*issue); assignees != "" {
|
||||||
|
fmt.Fprint(out, utils.Bold("Assignees: "))
|
||||||
|
fmt.Fprintln(out, assignees)
|
||||||
|
}
|
||||||
|
if labels := shared.IssueLabelList(*issue); labels != "" {
|
||||||
|
fmt.Fprint(out, utils.Bold("Labels: "))
|
||||||
|
fmt.Fprintln(out, labels)
|
||||||
|
}
|
||||||
|
if projects := issueProjectList(*issue); projects != "" {
|
||||||
|
fmt.Fprint(out, utils.Bold("Projects: "))
|
||||||
|
fmt.Fprintln(out, projects)
|
||||||
|
}
|
||||||
|
if issue.Milestone.Title != "" {
|
||||||
|
fmt.Fprint(out, utils.Bold("Milestone: "))
|
||||||
|
fmt.Fprintln(out, issue.Milestone.Title)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Body
|
||||||
|
if issue.Body != "" {
|
||||||
|
fmt.Fprintln(out)
|
||||||
|
md, err := utils.RenderMarkdown(issue.Body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Fprintln(out, md)
|
||||||
|
}
|
||||||
|
fmt.Fprintln(out)
|
||||||
|
|
||||||
|
// Footer
|
||||||
|
fmt.Fprintf(out, utils.Gray("View this issue on GitHub: %s\n"), issue.URL)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func issueStateTitleWithColor(state string) string {
|
||||||
|
colorFunc := prShared.ColorFuncForState(state)
|
||||||
|
return colorFunc(strings.Title(strings.ToLower(state)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func issueAssigneeList(issue api.Issue) string {
|
||||||
|
if len(issue.Assignees.Nodes) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
AssigneeNames := make([]string, 0, len(issue.Assignees.Nodes))
|
||||||
|
for _, assignee := range issue.Assignees.Nodes {
|
||||||
|
AssigneeNames = append(AssigneeNames, assignee.Login)
|
||||||
|
}
|
||||||
|
|
||||||
|
list := strings.Join(AssigneeNames, ", ")
|
||||||
|
if issue.Assignees.TotalCount > len(issue.Assignees.Nodes) {
|
||||||
|
list += ", …"
|
||||||
|
}
|
||||||
|
return list
|
||||||
|
}
|
||||||
|
|
||||||
|
func issueProjectList(issue api.Issue) string {
|
||||||
|
if len(issue.ProjectCards.Nodes) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
projectNames := make([]string, 0, len(issue.ProjectCards.Nodes))
|
||||||
|
for _, project := range issue.ProjectCards.Nodes {
|
||||||
|
colName := project.Column.Name
|
||||||
|
if colName == "" {
|
||||||
|
colName = "Awaiting triage"
|
||||||
|
}
|
||||||
|
projectNames = append(projectNames, fmt.Sprintf("%s (%s)", project.Project.Name, colName))
|
||||||
|
}
|
||||||
|
|
||||||
|
list := strings.Join(projectNames, ", ")
|
||||||
|
if issue.ProjectCards.TotalCount > len(issue.ProjectCards.Nodes) {
|
||||||
|
list += ", …"
|
||||||
|
}
|
||||||
|
return list
|
||||||
|
}
|
||||||
340
pkg/cmd/issue/view/view_test.go
Normal file
340
pkg/cmd/issue/view/view_test.go
Normal file
|
|
@ -0,0 +1,340 @@
|
||||||
|
package view
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"io/ioutil"
|
||||||
|
"net/http"
|
||||||
|
"os/exec"
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/cli/cli/internal/config"
|
||||||
|
"github.com/cli/cli/internal/ghrepo"
|
||||||
|
"github.com/cli/cli/internal/run"
|
||||||
|
"github.com/cli/cli/pkg/cmdutil"
|
||||||
|
"github.com/cli/cli/pkg/httpmock"
|
||||||
|
"github.com/cli/cli/pkg/iostreams"
|
||||||
|
"github.com/cli/cli/test"
|
||||||
|
"github.com/google/shlex"
|
||||||
|
)
|
||||||
|
|
||||||
|
func eq(t *testing.T, got interface{}, expected interface{}) {
|
||||||
|
t.Helper()
|
||||||
|
if !reflect.DeepEqual(got, expected) {
|
||||||
|
t.Errorf("expected: %v, got: %v", expected, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runCommand(rt http.RoundTripper, isTTY bool, cli string) (*test.CmdOut, error) {
|
||||||
|
io, _, stdout, stderr := iostreams.Test()
|
||||||
|
io.SetStdoutTTY(isTTY)
|
||||||
|
io.SetStdinTTY(isTTY)
|
||||||
|
io.SetStderrTTY(isTTY)
|
||||||
|
|
||||||
|
factory := &cmdutil.Factory{
|
||||||
|
IOStreams: io,
|
||||||
|
HttpClient: func() (*http.Client, error) {
|
||||||
|
return &http.Client{Transport: rt}, nil
|
||||||
|
},
|
||||||
|
Config: func() (config.Config, error) {
|
||||||
|
return config.NewBlankConfig(), nil
|
||||||
|
},
|
||||||
|
BaseRepo: func() (ghrepo.Interface, error) {
|
||||||
|
return ghrepo.New("OWNER", "REPO"), nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := NewCmdView(factory, nil)
|
||||||
|
|
||||||
|
argv, err := shlex.Split(cli)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cmd.SetArgs(argv)
|
||||||
|
|
||||||
|
cmd.SetIn(&bytes.Buffer{})
|
||||||
|
cmd.SetOut(ioutil.Discard)
|
||||||
|
cmd.SetErr(ioutil.Discard)
|
||||||
|
|
||||||
|
_, err = cmd.ExecuteC()
|
||||||
|
return &test.CmdOut{
|
||||||
|
OutBuf: stdout,
|
||||||
|
ErrBuf: stderr,
|
||||||
|
}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIssueView_web(t *testing.T) {
|
||||||
|
http := &httpmock.Registry{}
|
||||||
|
defer http.Verify(t)
|
||||||
|
|
||||||
|
http.StubResponse(200, bytes.NewBufferString(`
|
||||||
|
{ "data": { "repository": { "hasIssuesEnabled": true, "issue": {
|
||||||
|
"number": 123,
|
||||||
|
"url": "https://github.com/OWNER/REPO/issues/123"
|
||||||
|
} } } }
|
||||||
|
`))
|
||||||
|
|
||||||
|
var seenCmd *exec.Cmd
|
||||||
|
restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable {
|
||||||
|
seenCmd = cmd
|
||||||
|
return &test.OutputStub{}
|
||||||
|
})
|
||||||
|
defer restoreCmd()
|
||||||
|
|
||||||
|
output, err := runCommand(http, true, "-w 123")
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("error running command `issue view`: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
eq(t, output.String(), "")
|
||||||
|
eq(t, output.Stderr(), "Opening https://github.com/OWNER/REPO/issues/123 in your browser.\n")
|
||||||
|
|
||||||
|
if seenCmd == nil {
|
||||||
|
t.Fatal("expected a command to run")
|
||||||
|
}
|
||||||
|
url := seenCmd.Args[len(seenCmd.Args)-1]
|
||||||
|
eq(t, url, "https://github.com/OWNER/REPO/issues/123")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIssueView_web_numberArgWithHash(t *testing.T) {
|
||||||
|
http := &httpmock.Registry{}
|
||||||
|
defer http.Verify(t)
|
||||||
|
|
||||||
|
http.StubResponse(200, bytes.NewBufferString(`
|
||||||
|
{ "data": { "repository": { "hasIssuesEnabled": true, "issue": {
|
||||||
|
"number": 123,
|
||||||
|
"url": "https://github.com/OWNER/REPO/issues/123"
|
||||||
|
} } } }
|
||||||
|
`))
|
||||||
|
|
||||||
|
var seenCmd *exec.Cmd
|
||||||
|
restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable {
|
||||||
|
seenCmd = cmd
|
||||||
|
return &test.OutputStub{}
|
||||||
|
})
|
||||||
|
defer restoreCmd()
|
||||||
|
|
||||||
|
output, err := runCommand(http, true, "-w \"#123\"")
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("error running command `issue view`: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
eq(t, output.String(), "")
|
||||||
|
eq(t, output.Stderr(), "Opening https://github.com/OWNER/REPO/issues/123 in your browser.\n")
|
||||||
|
|
||||||
|
if seenCmd == nil {
|
||||||
|
t.Fatal("expected a command to run")
|
||||||
|
}
|
||||||
|
url := seenCmd.Args[len(seenCmd.Args)-1]
|
||||||
|
eq(t, url, "https://github.com/OWNER/REPO/issues/123")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIssueView_nontty_Preview(t *testing.T) {
|
||||||
|
tests := map[string]struct {
|
||||||
|
fixture string
|
||||||
|
expectedOutputs []string
|
||||||
|
}{
|
||||||
|
"Open issue without metadata": {
|
||||||
|
fixture: "./fixtures/issueView_preview.json",
|
||||||
|
expectedOutputs: []string{
|
||||||
|
`title:\tix of coins`,
|
||||||
|
`state:\tOPEN`,
|
||||||
|
`comments:\t9`,
|
||||||
|
`author:\tmarseilles`,
|
||||||
|
`assignees:`,
|
||||||
|
`\*\*bold story\*\*`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"Open issue with metadata": {
|
||||||
|
fixture: "./fixtures/issueView_previewWithMetadata.json",
|
||||||
|
expectedOutputs: []string{
|
||||||
|
`title:\tix of coins`,
|
||||||
|
`assignees:\tmarseilles, monaco`,
|
||||||
|
`author:\tmarseilles`,
|
||||||
|
`state:\tOPEN`,
|
||||||
|
`comments:\t9`,
|
||||||
|
`labels:\tone, two, three, four, five`,
|
||||||
|
`projects:\tProject 1 \(column A\), Project 2 \(column B\), Project 3 \(column C\), Project 4 \(Awaiting triage\)\n`,
|
||||||
|
`milestone:\tuluru\n`,
|
||||||
|
`\*\*bold story\*\*`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"Open issue with empty body": {
|
||||||
|
fixture: "./fixtures/issueView_previewWithEmptyBody.json",
|
||||||
|
expectedOutputs: []string{
|
||||||
|
`title:\tix of coins`,
|
||||||
|
`state:\tOPEN`,
|
||||||
|
`author:\tmarseilles`,
|
||||||
|
`labels:\ttarot`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"Closed issue": {
|
||||||
|
fixture: "./fixtures/issueView_previewClosedState.json",
|
||||||
|
expectedOutputs: []string{
|
||||||
|
`title:\tix of coins`,
|
||||||
|
`state:\tCLOSED`,
|
||||||
|
`\*\*bold story\*\*`,
|
||||||
|
`author:\tmarseilles`,
|
||||||
|
`labels:\ttarot`,
|
||||||
|
`\*\*bold story\*\*`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for name, tc := range tests {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
http := &httpmock.Registry{}
|
||||||
|
defer http.Verify(t)
|
||||||
|
|
||||||
|
http.Register(httpmock.GraphQL(`query IssueByNumber\b`), httpmock.FileResponse(tc.fixture))
|
||||||
|
|
||||||
|
output, err := runCommand(http, false, "123")
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("error running `issue view`: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
eq(t, output.Stderr(), "")
|
||||||
|
|
||||||
|
test.ExpectLines(t, output.String(), tc.expectedOutputs...)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIssueView_tty_Preview(t *testing.T) {
|
||||||
|
tests := map[string]struct {
|
||||||
|
fixture string
|
||||||
|
expectedOutputs []string
|
||||||
|
}{
|
||||||
|
"Open issue without metadata": {
|
||||||
|
fixture: "./fixtures/issueView_preview.json",
|
||||||
|
expectedOutputs: []string{
|
||||||
|
`ix of coins`,
|
||||||
|
`Open.*marseilles opened about 292 years ago.*9 comments`,
|
||||||
|
`bold story`,
|
||||||
|
`View this issue on GitHub: https://github.com/OWNER/REPO/issues/123`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"Open issue with metadata": {
|
||||||
|
fixture: "./fixtures/issueView_previewWithMetadata.json",
|
||||||
|
expectedOutputs: []string{
|
||||||
|
`ix of coins`,
|
||||||
|
`Open.*marseilles opened about 292 years ago.*9 comments`,
|
||||||
|
`Assignees:.*marseilles, monaco\n`,
|
||||||
|
`Labels:.*one, two, three, four, five\n`,
|
||||||
|
`Projects:.*Project 1 \(column A\), Project 2 \(column B\), Project 3 \(column C\), Project 4 \(Awaiting triage\)\n`,
|
||||||
|
`Milestone:.*uluru\n`,
|
||||||
|
`bold story`,
|
||||||
|
`View this issue on GitHub: https://github.com/OWNER/REPO/issues/123`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"Open issue with empty body": {
|
||||||
|
fixture: "./fixtures/issueView_previewWithEmptyBody.json",
|
||||||
|
expectedOutputs: []string{
|
||||||
|
`ix of coins`,
|
||||||
|
`Open.*marseilles opened about 292 years ago.*9 comments`,
|
||||||
|
`View this issue on GitHub: https://github.com/OWNER/REPO/issues/123`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"Closed issue": {
|
||||||
|
fixture: "./fixtures/issueView_previewClosedState.json",
|
||||||
|
expectedOutputs: []string{
|
||||||
|
`ix of coins`,
|
||||||
|
`Closed.*marseilles opened about 292 years ago.*9 comments`,
|
||||||
|
`bold story`,
|
||||||
|
`View this issue on GitHub: https://github.com/OWNER/REPO/issues/123`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for name, tc := range tests {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
http := &httpmock.Registry{}
|
||||||
|
defer http.Verify(t)
|
||||||
|
|
||||||
|
http.Register(httpmock.GraphQL(`query IssueByNumber\b`), httpmock.FileResponse(tc.fixture))
|
||||||
|
|
||||||
|
output, err := runCommand(http, true, "123")
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("error running `issue view`: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
eq(t, output.Stderr(), "")
|
||||||
|
|
||||||
|
test.ExpectLines(t, output.String(), tc.expectedOutputs...)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIssueView_web_notFound(t *testing.T) {
|
||||||
|
http := &httpmock.Registry{}
|
||||||
|
defer http.Verify(t)
|
||||||
|
|
||||||
|
http.StubResponse(200, bytes.NewBufferString(`
|
||||||
|
{ "errors": [
|
||||||
|
{ "message": "Could not resolve to an Issue with the number of 9999." }
|
||||||
|
] }
|
||||||
|
`))
|
||||||
|
|
||||||
|
var seenCmd *exec.Cmd
|
||||||
|
restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable {
|
||||||
|
seenCmd = cmd
|
||||||
|
return &test.OutputStub{}
|
||||||
|
})
|
||||||
|
defer restoreCmd()
|
||||||
|
|
||||||
|
_, err := runCommand(http, true, "-w 9999")
|
||||||
|
if err == nil || err.Error() != "GraphQL error: Could not resolve to an Issue with the number of 9999." {
|
||||||
|
t.Errorf("error running command `issue view`: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if seenCmd != nil {
|
||||||
|
t.Fatal("did not expect any command to run")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIssueView_disabledIssues(t *testing.T) {
|
||||||
|
http := &httpmock.Registry{}
|
||||||
|
defer http.Verify(t)
|
||||||
|
|
||||||
|
http.StubResponse(200, bytes.NewBufferString(`
|
||||||
|
{ "data": { "repository": {
|
||||||
|
"id": "REPOID",
|
||||||
|
"hasIssuesEnabled": false
|
||||||
|
} } }
|
||||||
|
`))
|
||||||
|
|
||||||
|
_, err := runCommand(http, true, `6666`)
|
||||||
|
if err == nil || err.Error() != "the 'OWNER/REPO' repository has disabled issues" {
|
||||||
|
t.Errorf("error running command `issue view`: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIssueView_web_urlArg(t *testing.T) {
|
||||||
|
http := &httpmock.Registry{}
|
||||||
|
defer http.Verify(t)
|
||||||
|
|
||||||
|
http.StubResponse(200, bytes.NewBufferString(`
|
||||||
|
{ "data": { "repository": { "hasIssuesEnabled": true, "issue": {
|
||||||
|
"number": 123,
|
||||||
|
"url": "https://github.com/OWNER/REPO/issues/123"
|
||||||
|
} } } }
|
||||||
|
`))
|
||||||
|
|
||||||
|
var seenCmd *exec.Cmd
|
||||||
|
restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable {
|
||||||
|
seenCmd = cmd
|
||||||
|
return &test.OutputStub{}
|
||||||
|
})
|
||||||
|
defer restoreCmd()
|
||||||
|
|
||||||
|
output, err := runCommand(http, true, "-w https://github.com/OWNER/REPO/issues/123")
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("error running command `issue view`: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
eq(t, output.String(), "")
|
||||||
|
|
||||||
|
if seenCmd == nil {
|
||||||
|
t.Fatal("expected a command to run")
|
||||||
|
}
|
||||||
|
url := seenCmd.Args[len(seenCmd.Args)-1]
|
||||||
|
eq(t, url, "https://github.com/OWNER/REPO/issues/123")
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue