Merge remote-tracking branch 'origin/trunk' into prclose-delete

This commit is contained in:
vilmibm 2020-08-19 21:06:04 -05:00
commit e44eb665a1
179 changed files with 12538 additions and 8277 deletions

View file

@ -9,10 +9,10 @@ jobs:
runs-on: ${{ matrix.os }}
steps:
- name: Set up Go 1.14
- name: Set up Go 1.15
uses: actions/setup-go@v2
with:
go-version: 1.14
go-version: 1.15
- name: Check out code
uses: actions/checkout@v2

View file

@ -16,10 +16,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Set up Go 1.14
- name: Set up Go 1.15
uses: actions/setup-go@v2
with:
go-version: 1.14
go-version: 1.15
- name: Check out code
uses: actions/checkout@v2

View file

@ -11,10 +11,10 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v2
- name: Set up Go 1.14
- name: Set up Go 1.15
uses: actions/setup-go@v2
with:
go-version: 1.14
go-version: 1.15
- name: Generate changelog
run: |
echo ::set-env name=GORELEASER_CURRENT_TAG::${GITHUB_REF#refs/tags/}
@ -32,6 +32,7 @@ jobs:
if: "!contains(github.ref, '-')" # skip prereleases
with:
formula-name: gh
download-url: https://github.com/cli/cli.git
env:
COMMITTER_TOKEN: ${{ secrets.UPLOAD_GITHUB_TOKEN }}
- name: Checkout documentation site

View file

@ -113,43 +113,53 @@ MSI installers are available for download on the [releases page][].
Install and upgrade:
1. Download the `.deb` file from the [releases page][]
2. `sudo apt install ./gh_*_linux_amd64.deb` install the downloaded file
1. Download the `.deb` file from the [releases page][];
2. Install the downloaded file: `sudo apt install ./gh_*_linux_amd64.deb`
### Fedora Linux
Install and upgrade:
1. Download the `.rpm` file from the [releases page][]
2. `sudo dnf install gh_*_linux_amd64.rpm` install the downloaded file
1. Download the `.rpm` file from the [releases page][];
2. Install the downloaded file: `sudo dnf install gh_*_linux_amd64.rpm`
### Centos Linux
Install and upgrade:
1. Download the `.rpm` file from the [releases page][]
2. `sudo yum localinstall gh_*_linux_amd64.rpm` install the downloaded file
1. Download the `.rpm` file from the [releases page][];
2. Install the downloaded file: `sudo yum localinstall gh_*_linux_amd64.rpm`
### openSUSE/SUSE Linux
Install and upgrade:
1. Download the `.rpm` file from the [releases page][]
2. `sudo zypper in gh_*_linux_amd64.rpm` install the downloaded file
1. Download the `.rpm` file from the [releases page][];
2. Install the downloaded file: `sudo zypper in gh_*_linux_amd64.rpm`
### 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
pacman -S github-cli
```
### Android
Android users can install via Termux:
```bash
pkg install gh
```
### 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
[scoop]: https://scoop.sh

View file

@ -3,6 +3,7 @@ package api
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"io/ioutil"
@ -11,6 +12,7 @@ import (
"regexp"
"strings"
"github.com/cli/cli/internal/ghinstance"
"github.com/henvic/httpretty"
"github.com/shurcooL/graphql"
)
@ -43,25 +45,21 @@ func NewClientFromHTTP(httpClient *http.Client) *Client {
func AddHeader(name, value string) ClientOption {
return func(tr http.RoundTripper) http.RoundTripper {
return &funcTripper{roundTrip: func(req *http.Request) (*http.Response, error) {
// prevent the token from leaking to non-GitHub hosts
// TODO: GHE support
if !strings.EqualFold(name, "Authorization") || strings.HasSuffix(req.URL.Hostname(), ".github.com") {
req.Header.Add(name, value)
}
req.Header.Add(name, value)
return tr.RoundTrip(req)
}}
}
}
// 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 &funcTripper{roundTrip: func(req *http.Request) (*http.Response, error) {
// prevent the token from leaking to non-GitHub hosts
// TODO: GHE support
if !strings.EqualFold(name, "Authorization") || strings.HasSuffix(req.URL.Hostname(), ".github.com") {
req.Header.Add(name, value())
value, err := getValue(req)
if err != nil {
return nil, err
}
req.Header.Add(name, value)
return tr.RoundTrip(req)
}}
}
@ -198,19 +196,22 @@ func (err HTTPError) Error() string {
return fmt.Sprintf("HTTP %d (%s)", err.StatusCode, err.RequestURL)
}
// Returns whether or not scopes are present, appID, and error
func (c Client) HasScopes(wantedScopes ...string) (bool, string, error) {
url := "https://api.github.com/user"
req, err := http.NewRequest("GET", url, nil)
type MissingScopesError struct {
error
}
func (c Client) HasMinimumScopes(hostname string) error {
apiEndpoint := ghinstance.RESTPrefix(hostname)
req, err := http.NewRequest("GET", apiEndpoint, nil)
if err != nil {
return false, "", err
return err
}
req.Header.Set("Content-Type", "application/json; charset=utf-8")
res, err := c.http.Do(req)
if err != nil {
return false, "", err
return err
}
defer func() {
@ -221,37 +222,45 @@ func (c Client) HasScopes(wantedScopes ...string) (bool, string, error) {
}()
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"), ",")
found := 0
search := map[string]bool{
"repo": false,
"read:org": false,
"admin:org": false,
}
for _, s := range hasScopes {
for _, w := range wantedScopes {
if w == strings.TrimSpace(s) {
found++
}
}
search[strings.TrimSpace(s)] = true
}
if found == len(wantedScopes) {
return true, appID, nil
errorMsgs := []string{}
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
func (c Client) GraphQL(query string, variables map[string]interface{}, data interface{}) error {
url := "https://api.github.com/graphql"
func (c Client) GraphQL(hostname string, query string, variables map[string]interface{}, data interface{}) error {
reqBody, err := json.Marshal(map[string]interface{}{"query": query, "variables": variables})
if err != nil {
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 {
return err
}
@ -267,13 +276,13 @@ func (c Client) GraphQL(query string, variables map[string]interface{}, data int
return handleResponse(resp, data)
}
func graphQLClient(h *http.Client) *graphql.Client {
return graphql.NewClient("https://api.github.com/graphql", h)
func graphQLClient(h *http.Client, hostname string) *graphql.Client {
return graphql.NewClient(ghinstance.GraphQLEndpoint(hostname), h)
}
// REST performs a REST request and parses the response.
func (c Client) REST(method string, p string, body io.Reader, data interface{}) error {
url := "https://api.github.com/" + p
func (c Client) REST(hostname string, method string, p string, body io.Reader, data interface{}) error {
url := ghinstance.RESTPrefix(hostname) + p
req, err := http.NewRequest(method, url, body)
if err != nil {
return err

View file

@ -33,7 +33,7 @@ func TestGraphQL(t *testing.T) {
}{}
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, 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" {
t.Fatalf("got %q", err.Error())
}
@ -71,7 +71,7 @@ func TestRESTGetDelete(t *testing.T) {
http.StubResponse(204, bytes.NewBuffer([]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)
}
@ -82,7 +82,7 @@ func TestRESTError(t *testing.T) {
http.StubResponse(422, bytes.NewBufferString(`{"message": "OH NO"}`))
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) {
t.Fatalf("got %v", err)
}

View file

@ -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
}

View file

@ -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 {
return nil, err
}
@ -171,7 +171,7 @@ func IssueStatus(client *Client, repo ghrepo.Interface, currentUsername string)
}
var resp response
err := client.GraphQL(query, variables, &resp)
err := client.GraphQL(repo.RepoHost(), query, variables, &resp)
if err != nil {
return nil, err
}
@ -270,7 +270,7 @@ func IssueList(client *Client, repo ghrepo.Interface, state string, labels []str
loop:
for {
variables["limit"] = pageLimit
err := client.GraphQL(query, variables, &response)
err := client.GraphQL(repo.RepoHost(), query, variables, &response)
if err != nil {
return nil, err
}
@ -361,7 +361,7 @@ func IssueByNumber(client *Client, repo ghrepo.Interface, number int) (*Issue, e
}
var resp response
err := client.GraphQL(query, variables, &resp)
err := client.GraphQL(repo.RepoHost(), query, variables, &resp)
if err != nil {
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)
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)
return err

View file

@ -3,11 +3,12 @@ package api
import (
"context"
"github.com/cli/cli/internal/ghrepo"
"github.com/shurcooL/githubv4"
)
// 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 {
Organization struct {
Projects struct {
@ -21,11 +22,11 @@ func OrganizationProjects(client *Client, owner string) ([]RepoProject, error) {
}
variables := map[string]interface{}{
"owner": githubv4.String(owner),
"owner": githubv4.String(repo.RepoOwner()),
"endCursor": (*githubv4.String)(nil),
}
gql := graphQLClient(client.http)
gql := graphQLClient(client.http, repo.RepoHost())
var projects []RepoProject
for {
@ -50,7 +51,7 @@ type OrgTeam struct {
}
// 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 {
Organization struct {
Teams struct {
@ -64,11 +65,11 @@ func OrganizationTeams(client *Client, owner string) ([]OrgTeam, error) {
}
variables := map[string]interface{}{
"owner": githubv4.String(owner),
"owner": githubv4.String(repo.RepoOwner()),
"endCursor": (*githubv4.String)(nil),
}
gql := graphQLClient(client.http)
gql := graphQLClient(client.http, repo.RepoHost())
var teams []OrgTeam
for {

View file

@ -4,13 +4,13 @@ import (
"context"
"errors"
"fmt"
"io/ioutil"
"io"
"net/http"
"strings"
"github.com/shurcooL/githubv4"
"github.com/cli/cli/internal/ghinstance"
"github.com/cli/cli/internal/ghrepo"
"github.com/shurcooL/githubv4"
)
type PullRequestReviewState int
@ -209,36 +209,28 @@ func (pr *PullRequest) ChecksStatus() (summary PullRequestChecksStatus) {
return
}
func (c Client) PullRequestDiff(baseRepo ghrepo.Interface, prNumber int) (string, error) {
url := fmt.Sprintf("https://api.github.com/repos/%s/pulls/%d",
ghrepo.FullName(baseRepo), prNumber)
func (c Client) PullRequestDiff(baseRepo ghrepo.Interface, prNumber int) (io.ReadCloser, error) {
url := fmt.Sprintf("%srepos/%s/pulls/%d",
ghinstance.RESTPrefix(baseRepo.RepoHost()), ghrepo.FullName(baseRepo), prNumber)
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return "", err
return nil, err
}
req.Header.Set("Accept", "application/vnd.github.v3.diff; charset=utf-8")
resp, err := c.http.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
b, err := ioutil.ReadAll(resp.Body)
if err != nil {
return "", err
}
if resp.StatusCode == 200 {
return string(b), nil
return nil, err
}
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) {
@ -363,7 +355,7 @@ func PullRequests(client *Client, repo ghrepo.Interface, currentPRNumber int, cu
}
var resp response
err := client.GraphQL(query, variables, &resp)
err := client.GraphQL(repo.RepoHost(), query, variables, &resp)
if err != nil {
return nil, err
}
@ -500,7 +492,7 @@ func PullRequestByNumber(client *Client, repo ghrepo.Interface, number int) (*Pu
}
var resp response
err := client.GraphQL(query, variables, &resp)
err := client.GraphQL(repo.RepoHost(), query, variables, &resp)
if err != nil {
return nil, err
}
@ -613,7 +605,7 @@ func PullRequestForBranch(client *Client, repo ghrepo.Interface, baseBranch, hea
}
var resp response
err := client.GraphQL(query, variables, &resp)
err := client.GraphQL(repo.RepoHost(), query, variables, &resp)
if err != nil {
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 {
return nil, err
}
@ -689,7 +681,7 @@ func CreatePullRequest(client *Client, repo *Repository, params map[string]inter
variables := map[string]interface{}{
"input": updateParams,
}
err := client.GraphQL(updateQuery, variables, &result)
err := client.GraphQL(repo.RepoHost(), updateQuery, variables, &result)
if err != nil {
return nil, err
}
@ -714,7 +706,7 @@ func CreatePullRequest(client *Client, repo *Repository, params map[string]inter
variables := map[string]interface{}{
"input": reviewParams,
}
err := client.GraphQL(reviewQuery, variables, &result)
err := client.GraphQL(repo.RepoHost(), reviewQuery, variables, &result)
if err != nil {
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 {
AddPullRequestReview struct {
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)
}
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 {
Edges []struct {
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{
fmt.Sprintf("repo:%s/%s", owner, repo),
fmt.Sprintf("repo:%s/%s", repo.RepoOwner(), repo.RepoName()),
fmt.Sprintf("assignee:%s", assignee),
"is:pr",
"sort:created-desc",
@ -888,6 +878,8 @@ func PullRequestList(client *Client, vars map[string]interface{}, limit int) (*P
}
variables["q"] = strings.Join(search, " ")
} else {
variables["owner"] = repo.RepoOwner()
variables["repo"] = repo.RepoName()
for name, val := range vars {
variables[name] = val
}
@ -896,7 +888,7 @@ loop:
for {
variables["limit"] = pageLimit
var data response
err := client.GraphQL(query, variables, &data)
err := client.GraphQL(repo.RepoHost(), query, variables, &data)
if err != nil {
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)
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)
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)
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)
}
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)
return client.REST("DELETE", path, nil, nil)
return client.REST(repo.RepoHost(), "DELETE", path, nil, nil)
}
func min(a, b int) int {

View file

@ -27,9 +27,7 @@ type Repository struct {
IsPrivate bool
HasIssuesEnabled bool
ViewerPermission string
DefaultBranchRef struct {
Name string
}
DefaultBranchRef BranchRef
Parent *Repository
@ -42,6 +40,11 @@ type RepositoryOwner struct {
Login string
}
// BranchRef is the branch name in a GitHub repository
type BranchRef struct {
Name string
}
// RepoOwner is the login name of the owner
func (r Repository) RepoOwner() string {
return r.Owner.Login
@ -103,7 +106,7 @@ func GitHubRepo(client *Client, repo ghrepo.Interface) (*Repository, error) {
result := struct {
Repository Repository
}{}
err := client.GraphQL(query, variables, &result)
err := client.GraphQL(repo.RepoHost(), query, variables, &result)
if err != nil {
return nil, err
@ -142,7 +145,7 @@ func RepoParent(client *Client, repo ghrepo.Interface) (ghrepo.Interface, error)
"name": githubv4.String(repo.RepoName()),
}
gql := graphQLClient(client.http)
gql := graphQLClient(client.http, repo.RepoHost())
err := gql.QueryNamed(context.Background(), "RepositoryFindParent", &query, variables)
if err != nil {
return nil, err
@ -186,7 +189,7 @@ func RepoNetwork(client *Client, repos []ghrepo.Interface) (RepoNetworkResult, e
graphqlResult := make(map[string]*json.RawMessage)
var result RepoNetworkResult
err := client.GraphQL(fmt.Sprintf(`
err := client.GraphQL(hostname, fmt.Sprintf(`
fragment repo on Repository {
id
name
@ -282,7 +285,7 @@ func ForkRepo(client *Client, repo ghrepo.Interface) (*Repository, error) {
path := fmt.Sprintf("repos/%s/forks", ghrepo.FullName(repo))
body := bytes.NewBufferString(`{}`)
result := repositoryV3{}
err := client.REST("POST", path, body, &result)
err := client.REST(repo.RepoHost(), "POST", path, body, &result)
if err != nil {
return nil, err
}
@ -315,7 +318,7 @@ func RepoFindFork(client *Client, repo ghrepo.Interface) (*Repository, error) {
"repo": repo.RepoName(),
}
if err := client.GraphQL(`
if err := client.GraphQL(repo.RepoHost(), `
query RepositoryFindFork($owner: String!, $repo: String!) {
repository(owner: $owner, name: $repo) {
forks(first: 1, affiliations: [OWNER, COLLABORATOR]) {
@ -461,7 +464,7 @@ func RepoMetadata(client *Client, repo ghrepo.Interface, input RepoMetadataInput
if input.Reviewers {
count++
go func() {
teams, err := OrganizationTeams(client, repo.RepoOwner())
teams, err := OrganizationTeams(client, repo)
// TODO: better detection of non-org repos
if err != nil && !strings.HasPrefix(err.Error(), "Could not resolve to an Organization") {
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
orgProjects, err := OrganizationProjects(client, repo.RepoOwner())
orgProjects, err := OrganizationProjects(client, repo)
// TODO: better detection of non-org repos
if err != nil && !strings.HasPrefix(err.Error(), "Could not resolve to an Organization") {
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")
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 {
return result, err
}
@ -651,7 +654,7 @@ func RepoProjects(client *Client, repo ghrepo.Interface) ([]RepoProject, error)
"endCursor": (*githubv4.String)(nil),
}
gql := graphQLClient(client.http)
gql := graphQLClient(client.http, repo.RepoHost())
var projects []RepoProject
for {
@ -695,7 +698,7 @@ func RepoAssignableUsers(client *Client, repo ghrepo.Interface) ([]RepoAssignee,
"endCursor": (*githubv4.String)(nil),
}
gql := graphQLClient(client.http)
gql := graphQLClient(client.http, repo.RepoHost())
var users []RepoAssignee
for {
@ -739,7 +742,7 @@ func RepoLabels(client *Client, repo ghrepo.Interface) ([]RepoLabel, error) {
"endCursor": (*githubv4.String)(nil),
}
gql := graphQLClient(client.http)
gql := graphQLClient(client.http, repo.RepoHost())
var labels []RepoLabel
for {
@ -783,7 +786,7 @@ func RepoMilestones(client *Client, repo ghrepo.Interface) ([]RepoMilestone, err
"endCursor": (*githubv4.String)(nil),
}
gql := graphQLClient(client.http)
gql := graphQLClient(client.http, repo.RepoHost())
var milestones []RepoMilestone
for {

View file

@ -4,13 +4,13 @@ import (
"context"
)
func CurrentLoginName(client *Client) (string, error) {
func CurrentLoginName(client *Client, hostname string) (string, error) {
var query struct {
Viewer struct {
Login string
}
}
gql := graphQLClient(client.http)
gql := graphQLClient(client.http, hostname)
err := gql.QueryNamed(context.Background(), "UserCurrent", &query, nil)
return query.Viewer.Login, err
}

View file

@ -13,6 +13,7 @@ import (
"os"
"strings"
"github.com/cli/cli/internal/ghinstance"
"github.com/cli/cli/pkg/browser"
)
@ -52,9 +53,18 @@ func (oa *OAuthFlow) ObtainAccessToken() (accessToken string, err error) {
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.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("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) {
oa.logf("server handler: %s\n", r.URL.Path)
if r.URL.Path != "/callback" {
if r.URL.Path != callbackPath {
w.WriteHeader(404)
return
}

View file

@ -5,7 +5,9 @@ import (
"os"
"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/pflag"
)
@ -35,13 +37,16 @@ func main() {
fatal("no dir set")
}
io, _, _, _ := iostreams.Test()
rootCmd := root.NewCmdRoot(&cmdutil.Factory{IOStreams: io}, "", "")
err := os.MkdirAll(*dir, 0755)
if err != nil {
fatal(err)
}
if *website {
err = doc.GenMarkdownTreeCustom(command.RootCmd, *dir, filePrepender, linkHandler)
err = doc.GenMarkdownTreeCustom(rootCmd, *dir, filePrepender, linkHandler)
if err != nil {
fatal(err)
}
@ -54,7 +59,7 @@ func main() {
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"
}
err = doc.GenManTree(command.RootCmd, header, *dir)
err = doc.GenManTree(rootCmd, header, *dir)
if err != nil {
fatal(err)
}

View file

@ -12,6 +12,11 @@ import (
"github.com/cli/cli/command"
"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/update"
"github.com/cli/cli/utils"
@ -31,25 +36,48 @@ func main() {
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{}
if len(os.Args) > 0 {
expandedArgs = os.Args[1:]
}
cmd, _, err := command.RootCmd.Traverse(expandedArgs)
if err != nil || cmd == command.RootCmd {
cmd, _, err := rootCmd.Traverse(expandedArgs)
if err != nil || cmd == rootCmd {
originalArgs := expandedArgs
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 {
fmt.Fprintf(stderr, "failed to process aliases: %s\n", err)
os.Exit(2)
}
if hasDebug {
fmt.Fprintf(stderr, "%v -> %v\n", originalArgs, expandedArgs)
}
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 ee, ok := err.(*exec.ExitError); ok {
os.Exit(ee.ExitCode())
@ -61,19 +89,15 @@ func main() {
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 {
printError(os.Stderr, err, cmd, hasDebug)
if cmd, err := rootCmd.ExecuteC(); err != nil {
printError(stderr, err, cmd, hasDebug)
os.Exit(1)
}
if command.HasFailed() {
if root.HasFailed() {
os.Exit(1)
}
@ -85,7 +109,6 @@ func main() {
ansi.Color(newRelease.Version, "cyan"),
ansi.Color(newRelease.URL, "yellow"))
stderr := utils.NewColorable(os.Stderr)
fmt.Fprintf(stderr, "\n\n%s\n\n", msg)
}
}

View file

@ -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
}

View file

@ -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)
}

View file

@ -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)
}
},
}

View file

@ -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)
}
}

View file

@ -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
}

View file

@ -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())
}
}

View file

@ -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

File diff suppressed because it is too large Load diff

View file

@ -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"
}

View file

@ -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
`

View file

@ -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
}

File diff suppressed because it is too large Load diff

View file

@ -1,231 +1,33 @@
package command
import (
"errors"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"regexp"
"runtime"
"runtime/debug"
"strings"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/api"
"github.com/cli/cli/context"
"github.com/cli/cli/internal/config"
"github.com/cli/cli/internal/ghrepo"
"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/internal/ghinstance"
"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.
var Version = "DEV"
// BuildDate is dynamically set at build time in the Makefile.
var BuildDate = "" // YYYY-MM-DD
var versionOutput = ""
func init() {
if Version == "DEV" {
if info, ok := debug.ReadBuildInfo(); ok && info.Main.Version != "(devel)" {
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
var RootCmd = &cobra.Command{
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
// BasicClient returns an API client for github.com only that borrows from but
// does not depend on user configuration
func BasicClient() (*api.Client, error) {
var opts []api.ClientOption
if verbose := os.Getenv("DEBUG"); verbose != "" {
@ -236,7 +38,7 @@ func BasicClient() (*api.Client, error) {
token := os.Getenv("GITHUB_TOKEN")
if token == "" {
if c, err := config.ParseDefaultConfig(); err == nil {
token, _ = c.Get(defaultHostname, "oauth_token")
token, _ = c.Get(ghinstance.Default(), "oauth_token")
}
}
if token != "" {
@ -245,283 +47,8 @@ func BasicClient() (*api.Client, error) {
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 {
logTraffic := strings.Contains(os.Getenv("DEBUG"), "api")
colorize := utils.IsTerminal(os.Stderr)
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())
}

View file

@ -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
}
}

View file

@ -2,11 +2,8 @@ package context
import (
"fmt"
"strings"
"github.com/cli/cli/git"
"github.com/cli/cli/internal/config"
"github.com/cli/cli/internal/ghrepo"
)
// NewBlank initializes a blank Context suitable for testing
@ -16,10 +13,6 @@ func NewBlank() *blankContext {
// A Context implementation that queries the filesystem
type blankContext struct {
authToken string
branch string
baseRepo ghrepo.Interface
remotes Remotes
}
func (c *blankContext) Config() (config.Config, error) {
@ -29,59 +22,3 @@ func (c *blankContext) Config() (config.Config, error) {
}
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
}

View file

@ -8,23 +8,12 @@ import (
"strings"
"github.com/cli/cli/api"
"github.com/cli/cli/git"
"github.com/cli/cli/internal/config"
"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
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)
}
@ -51,7 +40,7 @@ func ResolveRemotesToRepos(remotes Remotes, client *api.Client, base string) (Re
continue
}
repos = append(repos, r)
if ghrepo.IsSame(r, baseOverride) {
if baseOverride != nil && ghrepo.IsSame(r, baseOverride) {
foundBaseOverride = true
}
if len(repos) == maxRemotesForLookup {
@ -164,11 +153,7 @@ func New() Context {
// A Context implementation that queries the filesystem
type fsContext struct {
config config.Config
remotes Remotes
branch string
baseRepo ghrepo.Interface
authToken string
config config.Config
}
func (c *fsContext) Config() (config.Config, error) {
@ -180,105 +165,6 @@ func (c *fsContext) Config() (config.Config, error) {
return nil, err
}
c.config = cfg
c.authToken = ""
}
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, &notFound) {
// 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)
}

View file

@ -76,7 +76,7 @@ func (r Remote) RepoHost() string {
}
// 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 {
var repo ghrepo.Interface
if r.FetchURL != nil {

View file

@ -57,7 +57,7 @@ func Test_translateRemotes(t *testing.T) {
identityURL := func(u *url.URL) *url.URL {
return u
}
result := translateRemotes(gitRemotes, identityURL)
result := TranslateRemotes(gitRemotes, identityURL)
if len(result) != 1 {
t.Errorf("got %d results", len(result))

View file

@ -1,6 +1,6 @@
# Installation from source
0. Verify that you have Go 1.13.8+ installed
0. Verify that you have Go 1.15+ installed
```sh
$ go version

View file

@ -14,6 +14,15 @@ var remoteRE = regexp.MustCompile(`(.+)\s+(.+)\s+\((push|fetch)\)`)
// RemoteSet is a slice of git remotes
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
type Remote struct {
Name string

View file

@ -10,6 +10,10 @@ var (
protocolRe = regexp.MustCompile("^[a-zA-Z_+-]+://")
)
func IsURL(u string) bool {
return strings.HasPrefix(u, "git@") || protocolRe.MatchString(u)
}
// ParseURL normalizes git remote urls
func ParseURL(rawURL string) (u *url.URL, err error) {
if !protocolRe.MatchString(rawURL) &&

View file

@ -75,22 +75,30 @@ func parseConfigFile(filename string) ([]byte, *yaml.Node, error) {
return nil, nil, err
}
var root yaml.Node
err = yaml.Unmarshal(data, &root)
root, err := parseConfigData(data)
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 {
return data, &yaml.Node{
return &yaml.Node{
Kind: yaml.DocumentNode,
Content: []*yaml.Node{{Kind: yaml.MappingNode}},
}, nil
}
if root.Content[0].Kind != yaml.MappingNode {
return data, &root, fmt.Errorf("expected a top level map")
return &root, fmt.Errorf("expected a top level map")
}
return data, &root, nil
return &root, nil
}
func isLegacy(root *yaml.Node) bool {

View file

@ -2,7 +2,6 @@ package config
import (
"bytes"
"errors"
"fmt"
"reflect"
"testing"
@ -71,17 +70,29 @@ github.com:
eq(t, token, "OTOKEN")
}
func Test_parseConfig_notFound(t *testing.T) {
func Test_parseConfig_hostFallback(t *testing.T) {
defer StubConfig(`---
hosts:
example.com:
git_protocol: ssh
`, `---
github.com:
user: monalisa
oauth_token: OTOKEN
example.com:
user: wronguser
oauth_token: NOTTHIS
`, "")()
git_protocol: https
`)()
config, err := ParseConfig("config.yml")
eq(t, err, nil)
_, err = config.Get("github.com", "user")
eq(t, err, &NotFoundError{errors.New(`could not find config entry for "github.com"`)})
val, err := config.Get("example.com", "git_protocol")
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) {

View file

@ -9,6 +9,7 @@ import (
"github.com/cli/cli/api"
"github.com/cli/cli/auth"
"github.com/cli/cli/utils"
)
var (
@ -25,8 +26,8 @@ func IsGitHubApp(id string) bool {
return id == "178c6fc778ccc68e1d6a" || id == "4d747ba5675d5d66553f"
}
func AuthFlowWithConfig(cfg Config, hostname, notice string) (string, error) {
token, userLogin, err := authFlow(hostname, notice)
func AuthFlowWithConfig(cfg Config, hostname, notice string, additionalScopes []string) (string, error) {
token, userLogin, err := authFlow(hostname, notice, additionalScopes)
if err != nil {
return "", err
}
@ -49,17 +50,20 @@ func AuthFlowWithConfig(cfg Config, hostname, notice string) (string, error) {
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
if strings.Contains(os.Getenv("DEBUG"), "oauth") {
verboseStream = os.Stderr
}
minimumScopes := []string{"repo", "read:org", "gist"}
scopes := append(minimumScopes, additionalScopes...)
flow := &auth.OAuthFlow{
Hostname: oauthHost,
ClientID: oauthClientID,
ClientSecret: oauthClientSecret,
Scopes: []string{"repo", "read:org", "gist"},
Scopes: scopes,
WriteSuccessHTML: func(w io.Writer) {
fmt.Fprintln(w, oauthSuccessPage)
},
@ -67,14 +71,14 @@ func authFlow(oauthHost, notice string) (string, string, error) {
}
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)
token, err := flow.ObtainAccessToken()
if err != nil {
return "", "", err
}
userLogin, err := getViewer(token)
userLogin, err := getViewer(oauthHost, token)
if err != nil {
return "", "", err
}
@ -83,13 +87,14 @@ func authFlow(oauthHost, notice string) (string, string, error) {
}
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)
}
func getViewer(token string) (string, error) {
func getViewer(hostname, token string) (string, error) {
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 {

View file

@ -4,7 +4,9 @@ import (
"bytes"
"errors"
"fmt"
"sort"
"github.com/cli/cli/internal/ghinstance"
"gopkg.in/yaml.v3"
)
@ -14,6 +16,8 @@ const defaultGitProtocol = "https"
type Config interface {
Get(string, string) (string, error)
Set(string, string, string) error
UnsetHost(string)
Hosts() ([]string, error)
Aliases() (*AliasConfig, 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
// 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 {
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 {
return NewConfig(NewBlankRoot())
}
@ -187,16 +201,19 @@ func (c *fileConfig) Root() *yaml.Node {
func (c *fileConfig) Get(hostname, key string) (string, error) {
if hostname != "" {
var notFound *NotFoundError
hostCfg, err := c.configForHost(hostname)
if err != nil {
if err != nil && !errors.As(err, &notFound) {
return "", err
}
hostValue, err := hostCfg.GetStringValue(key)
var notFound *NotFoundError
if err != nil && !errors.As(err, &notFound) {
return "", err
var hostValue string
if hostCfg != nil {
hostValue, err = hostCfg.GetStringValue(key)
if err != nil && !errors.As(err, &notFound) {
return "", err
}
}
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) {
hosts, err := c.hostEntries()
if err != nil {
@ -357,6 +388,23 @@ func (c *fileConfig) hostEntries() ([]*HostConfig, error) {
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 {
hostRoot := &yaml.Node{Kind: yaml.MappingNode}
hostCfg := &HostConfig{

View 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/"
}

View 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)
}
})
}
}

View file

@ -4,9 +4,10 @@ import (
"fmt"
"net/url"
"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
type Interface interface {
@ -17,10 +18,7 @@ type Interface interface {
// New instantiates a GitHub repository from owner and name arguments
func New(owner, repo string) Interface {
return &ghRepo{
owner: owner,
name: repo,
}
return NewWithHost(owner, repo, ghinstance.OverridableDefault())
}
// NewWithHost is like New with an explicit host name
@ -28,7 +26,7 @@ func NewWithHost(owner, repo, hostname string) Interface {
return &ghRepo{
owner: owner,
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())
}
// 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) {
var r ghRepo
parts := strings.SplitN(nwo, "/", 2)
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
return &r, fmt.Errorf("expected OWNER/REPO format, got %q", nwo)
if git.IsURL(nwo) {
u, err := git.ParseURL(nwo)
if err != nil {
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
@ -59,11 +73,7 @@ func FromURL(u *url.URL) (Interface, error) {
return nil, fmt.Errorf("invalid path: %s", u.Path)
}
return &ghRepo{
owner: parts[0],
name: strings.TrimSuffix(parts[1], ".git"),
hostname: normalizeHostname(u.Hostname()),
}, nil
return NewWithHost(parts[0], strings.TrimSuffix(parts[1], ".git"), u.Hostname()), nil
}
func normalizeHostname(h string) string {
@ -109,8 +119,5 @@ func (r ghRepo) RepoName() string {
}
func (r ghRepo) RepoHost() string {
if r.hostname != "" {
return r.hostname
}
return defaultHostname
return r.hostname
}

View file

@ -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())
}
})
}
}

View file

@ -20,20 +20,21 @@ func Command(url string) (*exec.Cmd, error) {
// ForOS produces an exec.Cmd to open the web browser for different OS
func ForOS(goos, url string) *exec.Cmd {
exe := "open"
var args []string
switch goos {
case "darwin":
args = []string{"open"}
args = append(args, url)
case "windows":
args = []string{"cmd", "/c", "start"}
exe = "cmd"
r := strings.NewReplacer("&", "^&")
url = r.Replace(url)
args = append(args, "/c", "start", r.Replace(url))
default:
args = []string{"xdg-open"}
exe = "xdg-open"
args = append(args, url)
}
args = append(args, url)
cmd := exec.Command(args[0], args[1:]...)
cmd := exec.Command(exe, args...)
cmd.Stderr = os.Stderr
return cmd
}

28
pkg/cmd/alias/alias.go Normal file
View 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
}

View 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
}

View 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())
})
}
}

View 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")
}

View 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{}, ""},
// } {

View 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()
}

View 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
View 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
}

View 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())
}

View file

@ -15,6 +15,7 @@ import (
"strings"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/internal/ghinstance"
"github.com/cli/cli/internal/ghrepo"
"github.com/cli/cli/pkg/cmdutil"
"github.com/cli/cli/pkg/iostreams"
@ -38,6 +39,7 @@ type ApiOptions struct {
HttpClient func() (*http.Client, error)
BaseRepo func() (ghrepo.Interface, error)
Branch func() (string, error)
}
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,
HttpClient: f.HttpClient,
BaseRepo: f.BaseRepo,
Branch: f.Branch,
}
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
"graphql" to access the GitHub API v4.
Placeholder values ":owner" and ":repo" in the endpoint argument will get replaced
with values from the repository of the current directory.
Placeholder values ":owner", ":repo", and ":branch" in the endpoint argument will
get replaced with values from the repository of the current directory.
The default HTTP request method is "GET" normally and "POST" if any parameters
were added. Override the method with '--method'.
@ -69,8 +72,8 @@ on the format of the value:
- literal values "true", "false", "null", and integer numbers get converted to
appropriate JSON types;
- placeholder values ":owner" and ":repo" get populated with values from the
repository of the current directory;
- placeholder values ":owner", ":repo", and ":branch" get populated with values
from the repository of the current directory;
- if the value starts with "@", the rest of the value is interpreted as a
filename to read the value from. Pass "-" to read from standard input.
@ -193,9 +196,11 @@ func apiRun(opts *ApiOptions) error {
opts.IO.Out = ioutil.Discard
}
host := ghinstance.OverridableDefault()
hasNextPage := true
for hasNextPage {
resp, err := httpRequest(httpClient, method, requestPath, requestBody, requestHeaders)
resp, err := httpRequest(httpClient, host, method, requestPath, requestBody, requestHeaders)
if err != nil {
return err
}
@ -285,7 +290,7 @@ func processResponse(resp *http.Response, opts *ApiOptions, headersOutputStream
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
func fillPlaceholders(value string, opts *ApiOptions) (string, error) {
@ -298,18 +303,28 @@ func fillPlaceholders(value string, opts *ApiOptions) (string, error) {
return value, err
}
value = placeholderRE.ReplaceAllStringFunc(value, func(m string) string {
filled := placeholderRE.ReplaceAllStringFunc(value, func(m string) string {
switch m {
case ":owner":
return baseRepo.RepoOwner()
case ":repo":
return baseRepo.RepoName()
case ":branch":
branch, e := opts.Branch()
if e != nil {
err = e
}
return branch
default:
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) {
@ -426,23 +441,43 @@ func parseErrorResponse(r io.Reader, statusCode int) (io.Reader, string, error)
var parsedBody struct {
Message string
Errors []struct {
Message string
}
Errors []json.RawMessage
}
err = json.Unmarshal(b, &parsedBody)
if err != nil {
return r, "", err
}
if parsedBody.Message != "" {
return bodyCopy, fmt.Sprintf("%s (HTTP %d)", parsedBody.Message, statusCode), nil
} else if len(parsedBody.Errors) > 0 {
msgs := make([]string, len(parsedBody.Errors))
for i, e := range parsedBody.Errors {
msgs[i] = e.Message
}
type errorMessage struct {
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

View file

@ -9,6 +9,7 @@ import (
"os"
"testing"
"github.com/cli/cli/git"
"github.com/cli/cli/internal/ghrepo"
"github.com/cli/cli/pkg/cmdutil"
"github.com/cli/cli/pkg/iostreams"
@ -264,6 +265,17 @@ func Test_apiRun(t *testing.T) {
stdout: `{"message": "THIS IS FINE"}`,
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",
options: ApiOptions{
@ -742,6 +754,38 @@ func Test_fillPlaceholders(t *testing.T) {
want: "repos/hubot/robot-uprising/releases",
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",
args: args{

View file

@ -9,20 +9,23 @@ import (
"net/url"
"strconv"
"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
// TODO: GHE support
if strings.Contains(p, "://") {
requestURL = p
} else if isGraphQL {
requestURL = ghinstance.GraphQLEndpoint(hostname)
} else {
requestURL = "https://api.github.com/" + p
requestURL = ghinstance.RESTPrefix(hostname) + strings.TrimPrefix(p, "/")
}
var body io.Reader
var bodyIsJSON bool
isGraphQL := p == "graphql"
switch pp := params.(type) {
case map[string]interface{}:

View file

@ -93,6 +93,7 @@ func Test_httpRequest(t *testing.T) {
type args struct {
client *http.Client
host string
method string
p string
params interface{}
@ -114,6 +115,7 @@ func Test_httpRequest(t *testing.T) {
name: "simple GET",
args: args{
client: &httpClient,
host: "github.com",
method: "GET",
p: "repos/octocat/spoon-knife",
params: nil,
@ -127,10 +129,47 @@ func Test_httpRequest(t *testing.T) {
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",
args: args{
client: &httpClient,
host: "github.com",
method: "GET",
p: "repos/octocat/spoon-knife",
params: map[string]interface{}{
@ -150,6 +189,7 @@ func Test_httpRequest(t *testing.T) {
name: "POST with params",
args: args{
client: &httpClient,
host: "github.com",
method: "POST",
p: "repos",
params: map[string]interface{}{
@ -169,6 +209,7 @@ func Test_httpRequest(t *testing.T) {
name: "POST GraphQL",
args: args{
client: &httpClient,
host: "github.com",
method: "POST",
p: "graphql",
params: map[string]interface{}{
@ -184,10 +225,29 @@ func Test_httpRequest(t *testing.T) {
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",
args: args{
client: &httpClient,
host: "github.com",
method: "POST",
p: "repos",
params: bytes.NewBufferString("CUSTOM"),
@ -207,7 +267,7 @@ func Test_httpRequest(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := httpRequest(tt.args.client, tt.args.method, tt.args.p, tt.args.params, tt.args.headers)
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 {
t.Errorf("httpRequest() error = %v, wantErr %v", err, tt.wantErr)
return

25
pkg/cmd/auth/auth.go Normal file
View 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
}

View 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
View 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
}

View 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)
})
}
}

View 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
}

View 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)
})
}
}

View 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)
}

View 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)
})
}
}

View 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
}

View 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
View 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
}

View 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"])
})
}
}

View 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
View 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, &notFound) && 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...)
}

View 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
}
}

View 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)
}

View file

@ -7,10 +7,13 @@ import (
"io/ioutil"
"net/http"
"path"
"regexp"
"sort"
"strings"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/api"
"github.com/cli/cli/internal/ghinstance"
"github.com/cli/cli/pkg/cmdutil"
"github.com/cli/cli/pkg/iostreams"
"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)
}
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
fmt.Fprintf(errOut, "%s Creating gist...\n", utils.Gray("-"))
fmt.Fprintf(errOut, "%s %s\n", utils.Gray("-"), processMessage)
httpClient, err := opts.HttpClient()
if err != nil {
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 {
var httpError api.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)
}
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)
@ -152,3 +168,22 @@ func processFiles(stdin io.ReadCloser, filenames []string) (map[string]string, e
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
}

View file

@ -30,6 +30,24 @@ func Test_processFiles(t *testing.T) {
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) {
tests := []struct {
name string
@ -157,7 +175,7 @@ func Test_createRun(t *testing.T) {
Filenames: []string{fixtureFile},
},
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,
wantParams: map[string]interface{}{
"public": true,
@ -175,7 +193,7 @@ func Test_createRun(t *testing.T) {
Filenames: []string{fixtureFile},
},
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,
wantParams: map[string]interface{}{
"description": "an incredibly interesting gist",
@ -193,7 +211,7 @@ func Test_createRun(t *testing.T) {
},
stdin: "cool stdin content",
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,
wantParams: map[string]interface{}{
"files": map[string]interface{}{

View file

@ -22,7 +22,7 @@ type GistFile struct {
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{}
for filename, content := range files {
@ -44,7 +44,7 @@ func apiCreate(httpClient *http.Client, description string, public bool, files m
requestBody := bytes.NewReader(requestByte)
apiClient := api.NewClientFromHTTP(httpClient)
err = apiClient.REST("POST", path, requestBody, &result)
err = apiClient.REST(hostname, "POST", path, requestBody, &result)
if err != nil {
return nil, err
}

19
pkg/cmd/gist/gist.go Normal file
View 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
}

View 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
}

View 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)
}
}

View 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
}

View 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
View 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
View 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
}

View 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)
}

View 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
}

View 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)
}
}

View 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
}

View file

@ -1,4 +1,4 @@
package command
package shared
import (
"fmt"
@ -8,12 +8,10 @@ import (
"strings"
"github.com/cli/cli/api"
"github.com/cli/cli/context"
"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)
if err != nil {
return nil, nil, err
@ -22,7 +20,7 @@ func issueFromArg(ctx context.Context, apiClient *api.Client, cmd *cobra.Command
return issue, baseRepo, nil
}
baseRepo, err = determineBaseRepo(apiClient, cmd, ctx)
baseRepo, err = baseRepoFn()
if err != nil {
return nil, nil, fmt.Errorf("could not determine base repo: %w", err)
}

View 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
}

View 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
View 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
}

View 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