diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index c15d1e8b0..1ed4766ad 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -44,6 +44,10 @@ Please note that this project adheres to a [Contributor Code of Conduct][code-of We generate manual pages from source on every release. You do not need to submit pull requests for documentation specifically; manual pages for commands will automatically get updated after your pull requests gets accepted. +## Design guidelines + +You may reference the [CLI Design System][] when suggesting features, and are welcome to use our [Google Docs Template][] to suggest designs. + ## Resources - [How to Contribute to Open Source][] @@ -61,3 +65,5 @@ We generate manual pages from source on every release. You do not need to submit [How to Contribute to Open Source]: https://opensource.guide/how-to-contribute/ [Using Pull Requests]: https://docs.github.com/en/free-pro-team@latest/github/collaborating-with-issues-and-pull-requests/about-pull-requests [GitHub Help]: https://docs.github.com/ +[CLI Design System]: https://primer.style/cli/ +[Google Docs Template]: https://docs.google.com/document/d/1JIRErIUuJ6fTgabiFYfCH3x91pyHuytbfa0QLnTfXKM/edit#heading=h.or54sa47ylpg diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 28d17464b..1bf4d7a72 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -2,6 +2,7 @@ name: Code Scanning on: push: + pull_request: schedule: - cron: "0 0 * * 0" diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 83fa87b8e..1d9613f04 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -39,4 +39,6 @@ jobs: uses: actions/checkout@v2 - name: Build + env: + CGO_ENABLED: '0' run: go build -v ./cmd/gh diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 4dc95a4f1..3ba3b3f25 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -29,7 +29,7 @@ jobs: go mod verify go mod download - LINT_VERSION=1.29.0 + LINT_VERSION=1.34.1 curl -fsSL https://github.com/golangci/golangci-lint/releases/download/v${LINT_VERSION}/golangci-lint-${LINT_VERSION}-linux-amd64.tar.gz | \ tar xz --strip-components 1 --wildcards \*/golangci-lint mkdir -p bin && mv golangci-lint bin/ @@ -50,10 +50,6 @@ jobs: assert-nothing-changed go fmt ./... assert-nothing-changed go mod tidy - while read -r file linter msg; do - IFS=: read -ra f <<<"$file" - printf '::error file=%s,line=%s,col=%s::%s\n' "${f[0]}" "${f[1]}" "${f[2]}" "[$linter] $msg" - STATUS=1 - done < <(bin/golangci-lint run --out-format tab) + bin/golangci-lint run --out-format github-actions || STATUS=$? exit $STATUS diff --git a/.gitignore b/.gitignore index 00a5bb5a6..9895939a6 100644 --- a/.gitignore +++ b/.gitignore @@ -16,4 +16,7 @@ # macOS .DS_Store +# vim +*.swp + vendor/ diff --git a/.goreleaser.yml b/.goreleaser.yml index f04e7f7f2..2f6d16f32 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -24,7 +24,9 @@ builds: - <<: *build_defaults id: linux goos: [linux] - goarch: [386, amd64, arm64] + goarch: [386, arm, amd64, arm64] + env: + - CGO_ENABLED=0 - <<: *build_defaults id: windows diff --git a/Makefile b/Makefile index 859461984..e29d67b07 100644 --- a/Makefile +++ b/Makefile @@ -9,15 +9,12 @@ else BUILD_DATE ?= $(shell date "$(DATE_FMT)") endif -ifndef CGO_CPPFLAGS - export CGO_CPPFLAGS := $(CPPFLAGS) -endif -ifndef CGO_CFLAGS - export CGO_CFLAGS := $(CFLAGS) -endif -ifndef CGO_LDFLAGS - export CGO_LDFLAGS := $(LDFLAGS) -endif +CGO_CPPFLAGS ?= ${CPPFLAGS} +export CGO_CPPFLAGS +CGO_CFLAGS ?= ${CFLAGS} +export CGO_CFLAGS +CGO_LDFLAGS ?= $(filter -g -L% -l% -O%,${LDFLAGS}) +export CGO_LDFLAGS GO_LDFLAGS := -X github.com/cli/cli/internal/build.Version=$(GH_VERSION) $(GO_LDFLAGS) GO_LDFLAGS := -X github.com/cli/cli/internal/build.Date=$(BUILD_DATE) $(GO_LDFLAGS) @@ -27,7 +24,7 @@ ifdef GH_OAUTH_CLIENT_SECRET endif bin/gh: $(BUILD_FILES) - @go build -trimpath -ldflags "$(GO_LDFLAGS)" -o "$@" ./cmd/gh + go build -trimpath -ldflags "${GO_LDFLAGS}" -o "$@" ./cmd/gh clean: rm -rf ./bin ./share @@ -58,7 +55,22 @@ endif git -C site commit -m '$(GITHUB_REF:refs/tags/v%=%)' index.html .PHONY: site-bump - .PHONY: manpages manpages: go run ./cmd/gen-docs --man-page --doc-path ./share/man/man1/ + +DESTDIR := +prefix := /usr/local +bindir := ${prefix}/bin +mandir := ${prefix}/share/man + +.PHONY: install +install: bin/gh manpages + install -d ${DESTDIR}${bindir} + install -m755 bin/gh ${DESTDIR}${bindir}/ + install -d ${DESTDIR}${mandir}/man1 + install -m644 ./share/man/man1/* ${DESTDIR}${mandir}/man1/ + +.PHONY: uninstall +uninstall: + rm -f ${DESTDIR}${bindir}/gh ${DESTDIR}${mandir}/man1/gh.1 ${DESTDIR}${mandir}/man1/gh-*.1 diff --git a/README.md b/README.md index 146b44d1c..0c2ea4f48 100644 --- a/README.md +++ b/README.md @@ -55,18 +55,9 @@ For more information and distro-specific instructions, see the [Linux installati #### scoop -Install: - -```powershell -scoop bucket add github-gh https://github.com/cli/scoop-gh.git -scoop install gh -``` - -Upgrade: - -```powershell -scoop update gh -``` +| Install: | Upgrade: | +| ------------------ | ------------------ | +| `scoop install gh` | `scoop update gh` | #### Chocolatey diff --git a/api/cache.go b/api/cache.go index 620660c15..1f6d8896b 100644 --- a/api/cache.go +++ b/api/cache.go @@ -19,7 +19,7 @@ import ( func makeCachedClient(httpClient *http.Client, cacheTTL time.Duration) *http.Client { cacheDir := filepath.Join(os.TempDir(), "gh-cli-cache") return &http.Client{ - Transport: CacheReponse(cacheTTL, cacheDir)(httpClient.Transport), + Transport: CacheResponse(cacheTTL, cacheDir)(httpClient.Transport), } } @@ -39,8 +39,8 @@ func isCacheableResponse(res *http.Response) bool { return res.StatusCode < 500 && res.StatusCode != 403 } -// CacheReponse produces a RoundTripper that caches HTTP responses to disk for a specified amount of time -func CacheReponse(ttl time.Duration, dir string) ClientOption { +// CacheResponse produces a RoundTripper that caches HTTP responses to disk for a specified amount of time +func CacheResponse(ttl time.Duration, dir string) ClientOption { fs := fileStorage{ dir: dir, ttl: ttl, diff --git a/api/cache_test.go b/api/cache_test.go index d1039d71b..f4a6a756e 100644 --- a/api/cache_test.go +++ b/api/cache_test.go @@ -14,7 +14,7 @@ import ( "github.com/stretchr/testify/require" ) -func Test_CacheReponse(t *testing.T) { +func Test_CacheResponse(t *testing.T) { counter := 0 fakeHTTP := funcTripper{ roundTrip: func(req *http.Request) (*http.Response, error) { @@ -32,7 +32,7 @@ func Test_CacheReponse(t *testing.T) { } cacheDir := filepath.Join(t.TempDir(), "gh-cli-cache") - httpClient := NewHTTPClient(ReplaceTripper(fakeHTTP), CacheReponse(time.Minute, cacheDir)) + httpClient := NewHTTPClient(ReplaceTripper(fakeHTTP), CacheResponse(time.Minute, cacheDir)) do := func(method, url string, body io.Reader) (string, error) { req, err := http.NewRequest(method, url, body) diff --git a/api/client_test.go b/api/client_test.go index 35a45af8c..8edf279ea 100644 --- a/api/client_test.go +++ b/api/client_test.go @@ -5,19 +5,12 @@ import ( "errors" "io/ioutil" "net/http" - "reflect" "testing" "github.com/cli/cli/pkg/httpmock" + "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 TestGraphQL(t *testing.T) { http := &httpmock.Registry{} client := NewClient( @@ -32,15 +25,19 @@ func TestGraphQL(t *testing.T) { } }{} - http.StubResponse(200, bytes.NewBufferString(`{"data":{"viewer":{"login":"hubot"}}}`)) + http.Register( + httpmock.GraphQL("QUERY"), + httpmock.StringResponse(`{"data":{"viewer":{"login":"hubot"}}}`), + ) + err := client.GraphQL("github.com", "QUERY", vars, &response) - eq(t, err, nil) - eq(t, response.Viewer.Login, "hubot") + assert.NoError(t, err) + assert.Equal(t, "hubot", response.Viewer.Login) req := http.Requests[0] reqBody, _ := ioutil.ReadAll(req.Body) - eq(t, string(reqBody), `{"query":"QUERY","variables":{"name":"Mona"}}`) - eq(t, req.Header.Get("Authorization"), "token OTOKEN") + assert.Equal(t, `{"query":"QUERY","variables":{"name":"Mona"}}`, string(reqBody)) + assert.Equal(t, "token OTOKEN", req.Header.Get("Authorization")) } func TestGraphQLError(t *testing.T) { @@ -48,12 +45,17 @@ func TestGraphQLError(t *testing.T) { client := NewClient(ReplaceTripper(http)) response := struct{}{} - http.StubResponse(200, bytes.NewBufferString(` - { "errors": [ - {"message":"OH NO"}, - {"message":"this is fine"} - ] - }`)) + + http.Register( + httpmock.GraphQL(""), + httpmock.StringResponse(` + { "errors": [ + {"message":"OH NO"}, + {"message":"this is fine"} + ] + } + `), + ) err := client.GraphQL("github.com", "", nil, &response) if err == nil || err.Error() != "GraphQL error: OH NO\nthis is fine" { @@ -68,11 +70,14 @@ func TestRESTGetDelete(t *testing.T) { ReplaceTripper(http), ) - http.StubResponse(204, bytes.NewBuffer([]byte{})) + http.Register( + httpmock.REST("DELETE", "applications/CLIENTID/grant"), + httpmock.StatusStringResponse(204, "{}"), + ) r := bytes.NewReader([]byte(`{}`)) err := client.REST("github.com", "DELETE", "applications/CLIENTID/grant", r, nil) - eq(t, err, nil) + assert.NoError(t, err) } func TestRESTError(t *testing.T) { diff --git a/api/pull_request_test.go b/api/pull_request_test.go index 609a27e7c..9fb1d9e72 100644 --- a/api/pull_request_test.go +++ b/api/pull_request_test.go @@ -3,6 +3,8 @@ package api import ( "encoding/json" "testing" + + "github.com/stretchr/testify/assert" ) func TestPullRequest_ChecksStatus(t *testing.T) { @@ -31,11 +33,11 @@ func TestPullRequest_ChecksStatus(t *testing.T) { } }] } } ` err := json.Unmarshal([]byte(payload), &pr) - eq(t, err, nil) + assert.NoError(t, err) checks := pr.ChecksStatus() - eq(t, checks.Total, 8) - eq(t, checks.Pending, 3) - eq(t, checks.Failing, 3) - eq(t, checks.Passing, 2) + assert.Equal(t, 8, checks.Total) + assert.Equal(t, 3, checks.Pending) + assert.Equal(t, 3, checks.Failing) + assert.Equal(t, 2, checks.Passing) } diff --git a/api/queries_comments.go b/api/queries_comments.go new file mode 100644 index 000000000..16eba6152 --- /dev/null +++ b/api/queries_comments.go @@ -0,0 +1,182 @@ +package api + +import ( + "context" + "time" + + "github.com/cli/cli/internal/ghrepo" + "github.com/shurcooL/githubv4" + "github.com/shurcooL/graphql" +) + +type Comments struct { + Nodes []Comment + TotalCount int + PageInfo PageInfo +} + +type Comment struct { + Author Author + AuthorAssociation string + Body string + CreatedAt time.Time + IncludesCreatedEdit bool + ReactionGroups ReactionGroups +} + +type PageInfo struct { + HasNextPage bool + EndCursor string +} + +func CommentsForIssue(client *Client, repo ghrepo.Interface, issue *Issue) (*Comments, error) { + type response struct { + Repository struct { + Issue struct { + Comments Comments `graphql:"comments(first: 100, after: $endCursor)"` + } `graphql:"issue(number: $number)"` + } `graphql:"repository(owner: $owner, name: $repo)"` + } + + variables := map[string]interface{}{ + "owner": githubv4.String(repo.RepoOwner()), + "repo": githubv4.String(repo.RepoName()), + "number": githubv4.Int(issue.Number), + "endCursor": (*githubv4.String)(nil), + } + + gql := graphQLClient(client.http, repo.RepoHost()) + + var comments []Comment + for { + var query response + err := gql.QueryNamed(context.Background(), "CommentsForIssue", &query, variables) + if err != nil { + return nil, err + } + + comments = append(comments, query.Repository.Issue.Comments.Nodes...) + if !query.Repository.Issue.Comments.PageInfo.HasNextPage { + break + } + variables["endCursor"] = githubv4.String(query.Repository.Issue.Comments.PageInfo.EndCursor) + } + + return &Comments{Nodes: comments, TotalCount: len(comments)}, nil +} + +func CommentsForPullRequest(client *Client, repo ghrepo.Interface, pr *PullRequest) (*Comments, error) { + type response struct { + Repository struct { + PullRequest struct { + Comments Comments `graphql:"comments(first: 100, after: $endCursor)"` + } `graphql:"pullRequest(number: $number)"` + } `graphql:"repository(owner: $owner, name: $repo)"` + } + + variables := map[string]interface{}{ + "owner": githubv4.String(repo.RepoOwner()), + "repo": githubv4.String(repo.RepoName()), + "number": githubv4.Int(pr.Number), + "endCursor": (*githubv4.String)(nil), + } + + gql := graphQLClient(client.http, repo.RepoHost()) + + var comments []Comment + for { + var query response + err := gql.QueryNamed(context.Background(), "CommentsForPullRequest", &query, variables) + if err != nil { + return nil, err + } + + comments = append(comments, query.Repository.PullRequest.Comments.Nodes...) + if !query.Repository.PullRequest.Comments.PageInfo.HasNextPage { + break + } + variables["endCursor"] = githubv4.String(query.Repository.PullRequest.Comments.PageInfo.EndCursor) + } + + return &Comments{Nodes: comments, TotalCount: len(comments)}, nil +} + +type CommentCreateInput struct { + Body string + SubjectId string +} + +func CommentCreate(client *Client, repoHost string, params CommentCreateInput) (string, error) { + var mutation struct { + AddComment struct { + CommentEdge struct { + Node struct { + URL string + } + } + } `graphql:"addComment(input: $input)"` + } + + variables := map[string]interface{}{ + "input": githubv4.AddCommentInput{ + Body: githubv4.String(params.Body), + SubjectID: graphql.ID(params.SubjectId), + }, + } + + gql := graphQLClient(client.http, repoHost) + err := gql.MutateNamed(context.Background(), "CommentCreate", &mutation, variables) + if err != nil { + return "", err + } + + return mutation.AddComment.CommentEdge.Node.URL, nil +} + +func commentsFragment() string { + return `comments(last: 1) { + nodes { + author { + login + } + authorAssociation + body + createdAt + includesCreatedEdit + ` + reactionGroupsFragment() + ` + } + totalCount + }` +} + +func (c Comment) AuthorLogin() string { + return c.Author.Login +} + +func (c Comment) Association() string { + return c.AuthorAssociation +} + +func (c Comment) Content() string { + return c.Body +} + +func (c Comment) Created() time.Time { + return c.CreatedAt +} + +func (c Comment) IsEdited() bool { + return c.IncludesCreatedEdit +} + +func (c Comment) Reactions() ReactionGroups { + return c.ReactionGroups +} + +func (c Comment) Status() string { + return "" +} + +func (c Comment) Link() string { + return "" +} diff --git a/api/queries_issue.go b/api/queries_issue.go index 08e0cf1d9..17f32eabd 100644 --- a/api/queries_issue.go +++ b/api/queries_issue.go @@ -33,12 +33,8 @@ type Issue struct { Body string CreatedAt time.Time UpdatedAt time.Time - Comments struct { - TotalCount int - } - Author struct { - Login string - } + Comments Comments + Author Author Assignees struct { Nodes []struct { Login string @@ -65,12 +61,17 @@ type Issue struct { Milestone struct { Title string } + ReactionGroups ReactionGroups } type IssuesDisabledError struct { error } +type Author struct { + Login string +} + const fragments = ` fragment issue on Issue { number @@ -341,7 +342,22 @@ func IssueByNumber(client *Client, repo ghrepo.Interface, number int) (*Issue, e author { login } - comments { + comments(last: 1) { + nodes { + author { + login + } + authorAssociation + body + createdAt + includesCreatedEdit + reactionGroups { + content + users { + totalCount + } + } + } totalCount } number @@ -370,9 +386,15 @@ func IssueByNumber(client *Client, repo ghrepo.Interface, number int) (*Issue, e } totalCount } - milestone{ + milestone { title } + reactionGroups { + content + users { + totalCount + } + } } } }` diff --git a/api/queries_issue_test.go b/api/queries_issue_test.go index 83dee55b7..e6aca1907 100644 --- a/api/queries_issue_test.go +++ b/api/queries_issue_test.go @@ -1,7 +1,6 @@ package api import ( - "bytes" "encoding/json" "io/ioutil" "testing" @@ -16,30 +15,36 @@ func TestIssueList(t *testing.T) { http := &httpmock.Registry{} client := NewClient(ReplaceTripper(http)) - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { - "hasIssuesEnabled": true, - "issues": { - "nodes": [], - "pageInfo": { - "hasNextPage": true, - "endCursor": "ENDCURSOR" - } - } - } } } - `)) - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { - "hasIssuesEnabled": true, - "issues": { - "nodes": [], - "pageInfo": { - "hasNextPage": false, - "endCursor": "ENDCURSOR" - } - } - } } } - `)) + http.Register( + httpmock.GraphQL(`query IssueList\b`), + httpmock.StringResponse(` + { "data": { "repository": { + "hasIssuesEnabled": true, + "issues": { + "nodes": [], + "pageInfo": { + "hasNextPage": true, + "endCursor": "ENDCURSOR" + } + } + } } } + `), + ) + http.Register( + httpmock.GraphQL(`query IssueList\b`), + httpmock.StringResponse(` + { "data": { "repository": { + "hasIssuesEnabled": true, + "issues": { + "nodes": [], + "pageInfo": { + "hasNextPage": false, + "endCursor": "ENDCURSOR" + } + } + } } } + `), + ) repo, _ := ghrepo.FromFullName("OWNER/REPO") _, err := IssueList(client, repo, "open", []string{}, "", 251, "", "", "") @@ -75,44 +80,51 @@ func TestIssueList_pagination(t *testing.T) { http := &httpmock.Registry{} client := NewClient(ReplaceTripper(http)) - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { - "hasIssuesEnabled": true, - "issues": { - "nodes": [ - { - "title": "issue1", - "labels": { "nodes": [ { "name": "bug" } ], "totalCount": 1 }, - "assignees": { "nodes": [ { "login": "user1" } ], "totalCount": 1 } + http.Register( + httpmock.GraphQL(`query IssueList\b`), + httpmock.StringResponse(` + { "data": { "repository": { + "hasIssuesEnabled": true, + "issues": { + "nodes": [ + { + "title": "issue1", + "labels": { "nodes": [ { "name": "bug" } ], "totalCount": 1 }, + "assignees": { "nodes": [ { "login": "user1" } ], "totalCount": 1 } + } + ], + "pageInfo": { + "hasNextPage": true, + "endCursor": "ENDCURSOR" + }, + "totalCount": 2 } - ], - "pageInfo": { - "hasNextPage": true, - "endCursor": "ENDCURSOR" - }, - "totalCount": 2 - } - } } } - `)) - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { - "hasIssuesEnabled": true, - "issues": { - "nodes": [ - { - "title": "issue2", - "labels": { "nodes": [ { "name": "enhancement" } ], "totalCount": 1 }, - "assignees": { "nodes": [ { "login": "user2" } ], "totalCount": 1 } + } } } + `), + ) + + http.Register( + httpmock.GraphQL(`query IssueList\b`), + httpmock.StringResponse(` + { "data": { "repository": { + "hasIssuesEnabled": true, + "issues": { + "nodes": [ + { + "title": "issue2", + "labels": { "nodes": [ { "name": "enhancement" } ], "totalCount": 1 }, + "assignees": { "nodes": [ { "login": "user2" } ], "totalCount": 1 } + } + ], + "pageInfo": { + "hasNextPage": false, + "endCursor": "ENDCURSOR" + }, + "totalCount": 2 } - ], - "pageInfo": { - "hasNextPage": false, - "endCursor": "ENDCURSOR" - }, - "totalCount": 2 - } - } } } - `)) + } } } + `), + ) repo := ghrepo.New("OWNER", "REPO") res, err := IssueList(client, repo, "", nil, "", 0, "", "", "") diff --git a/api/queries_pr.go b/api/queries_pr.go index a95feab28..23896ce28 100644 --- a/api/queries_pr.go +++ b/api/queries_pr.go @@ -15,19 +15,6 @@ import ( "github.com/shurcooL/githubv4" ) -type PullRequestReviewState int - -const ( - ReviewApprove PullRequestReviewState = iota - ReviewRequestChanges - ReviewComment -) - -type PullRequestReviewInput struct { - Body string - State PullRequestReviewState -} - type PullRequestsPayload struct { ViewerCreated PullRequestAndTotalCount ReviewRequested PullRequestAndTotalCount @@ -103,14 +90,6 @@ type PullRequest struct { } TotalCount int } - Reviews struct { - Nodes []struct { - Author struct { - Login string - } - State string - } - } Assignees struct { Nodes []struct { Login string @@ -137,6 +116,9 @@ type PullRequest struct { Milestone struct { Title string } + Comments Comments + ReactionGroups ReactionGroups + Reviews PullRequestReviews } type NotFoundError struct { @@ -218,6 +200,18 @@ func (pr *PullRequest) ChecksStatus() (summary PullRequestChecksStatus) { return } +func (pr *PullRequest) DisplayableReviews() PullRequestReviews { + published := []PullRequestReview{} + for _, prr := range pr.Reviews.Nodes { + //Dont display pending reviews + //Dont display commenting reviews without top level comment body + if prr.State != "PENDING" && !(prr.State == "COMMENTED" && prr.Body == "") { + published = append(published, prr) + } + } + return PullRequestReviews{Nodes: published, TotalCount: len(published)} +} + 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) @@ -568,15 +562,6 @@ func PullRequestByNumber(client *Client, repo ghrepo.Interface, number int) (*Pu } totalCount } - reviews(last: 100) { - nodes { - author { - login - } - state - } - totalCount - } assignees(first: 100) { nodes { login @@ -603,6 +588,8 @@ func PullRequestByNumber(client *Client, repo ghrepo.Interface, number int) (*Pu milestone{ title } + ` + commentsFragment() + ` + ` + reactionGroupsFragment() + ` } } }` @@ -639,7 +626,7 @@ func PullRequestForBranch(client *Client, repo ghrepo.Interface, baseBranch, hea query := ` query PullRequestForBranch($owner: String!, $repo: String!, $headRefName: String!, $states: [PullRequestState!]) { repository(owner: $owner, name: $repo) { - pullRequests(headRefName: $headRefName, states: $states, first: 30) { + pullRequests(headRefName: $headRefName, states: $states, first: 30, orderBy: { field: CREATED_AT, direction: DESC }) { nodes { id number @@ -677,15 +664,6 @@ func PullRequestForBranch(client *Client, repo ghrepo.Interface, baseBranch, hea } totalCount } - reviews(last: 100) { - nodes { - author { - login - } - state - } - totalCount - } assignees(first: 100) { nodes { login @@ -712,6 +690,8 @@ func PullRequestForBranch(client *Client, repo ghrepo.Interface, baseBranch, hea milestone{ title } + ` + commentsFragment() + ` + ` + reactionGroupsFragment() + ` } } } @@ -771,7 +751,7 @@ func CreatePullRequest(client *Client, repo *Repository, params map[string]inter } for key, val := range params { switch key { - case "title", "body", "draft", "baseRefName", "headRefName": + case "title", "body", "draft", "baseRefName", "headRefName", "maintainerCanModify": inputParams[key] = val } } @@ -856,34 +836,6 @@ func isBlank(v interface{}) bool { } } -func AddReview(client *Client, repo ghrepo.Interface, pr *PullRequest, input *PullRequestReviewInput) error { - var mutation struct { - AddPullRequestReview struct { - ClientMutationID string - } `graphql:"addPullRequestReview(input:$input)"` - } - - state := githubv4.PullRequestReviewEventComment - switch input.State { - case ReviewApprove: - state = githubv4.PullRequestReviewEventApprove - case ReviewRequestChanges: - state = githubv4.PullRequestReviewEventRequestChanges - } - - body := githubv4.String(input.Body) - variables := map[string]interface{}{ - "input": githubv4.AddPullRequestReviewInput{ - PullRequestID: pr.ID, - Event: &state, - Body: &body, - }, - } - - gql := graphQLClient(client.http, repo.RepoHost()) - return gql.MutateNamed(context.Background(), "PullRequestReviewAdd", &mutation, variables) -} - func PullRequestList(client *Client, repo ghrepo.Interface, vars map[string]interface{}, limit int) (*PullRequestAndTotalCount, error) { type prBlock struct { Edges []struct { diff --git a/api/queries_pr_review.go b/api/queries_pr_review.go new file mode 100644 index 000000000..7378db111 --- /dev/null +++ b/api/queries_pr_review.go @@ -0,0 +1,135 @@ +package api + +import ( + "context" + "time" + + "github.com/cli/cli/internal/ghrepo" + "github.com/shurcooL/githubv4" +) + +type PullRequestReviewState int + +const ( + ReviewApprove PullRequestReviewState = iota + ReviewRequestChanges + ReviewComment +) + +type PullRequestReviewInput struct { + Body string + State PullRequestReviewState +} + +type PullRequestReviews struct { + Nodes []PullRequestReview + PageInfo PageInfo + TotalCount int +} + +type PullRequestReview struct { + Author Author + AuthorAssociation string + Body string + CreatedAt time.Time + IncludesCreatedEdit bool + ReactionGroups ReactionGroups + State string + URL string +} + +func AddReview(client *Client, repo ghrepo.Interface, pr *PullRequest, input *PullRequestReviewInput) error { + var mutation struct { + AddPullRequestReview struct { + ClientMutationID string + } `graphql:"addPullRequestReview(input:$input)"` + } + + state := githubv4.PullRequestReviewEventComment + switch input.State { + case ReviewApprove: + state = githubv4.PullRequestReviewEventApprove + case ReviewRequestChanges: + state = githubv4.PullRequestReviewEventRequestChanges + } + + body := githubv4.String(input.Body) + variables := map[string]interface{}{ + "input": githubv4.AddPullRequestReviewInput{ + PullRequestID: pr.ID, + Event: &state, + Body: &body, + }, + } + + gql := graphQLClient(client.http, repo.RepoHost()) + return gql.MutateNamed(context.Background(), "PullRequestReviewAdd", &mutation, variables) +} + +func ReviewsForPullRequest(client *Client, repo ghrepo.Interface, pr *PullRequest) (*PullRequestReviews, error) { + type response struct { + Repository struct { + PullRequest struct { + Reviews PullRequestReviews `graphql:"reviews(first: 100, after: $endCursor)"` + } `graphql:"pullRequest(number: $number)"` + } `graphql:"repository(owner: $owner, name: $repo)"` + } + + variables := map[string]interface{}{ + "owner": githubv4.String(repo.RepoOwner()), + "repo": githubv4.String(repo.RepoName()), + "number": githubv4.Int(pr.Number), + "endCursor": (*githubv4.String)(nil), + } + + gql := graphQLClient(client.http, repo.RepoHost()) + + var reviews []PullRequestReview + for { + var query response + err := gql.QueryNamed(context.Background(), "ReviewsForPullRequest", &query, variables) + if err != nil { + return nil, err + } + + reviews = append(reviews, query.Repository.PullRequest.Reviews.Nodes...) + if !query.Repository.PullRequest.Reviews.PageInfo.HasNextPage { + break + } + variables["endCursor"] = githubv4.String(query.Repository.PullRequest.Reviews.PageInfo.EndCursor) + } + + return &PullRequestReviews{Nodes: reviews, TotalCount: len(reviews)}, nil +} + +func (prr PullRequestReview) AuthorLogin() string { + return prr.Author.Login +} + +func (prr PullRequestReview) Association() string { + return prr.AuthorAssociation +} + +func (prr PullRequestReview) Content() string { + return prr.Body +} + +func (prr PullRequestReview) Created() time.Time { + return prr.CreatedAt +} + +func (prr PullRequestReview) IsEdited() bool { + return prr.IncludesCreatedEdit +} + +func (prr PullRequestReview) Reactions() ReactionGroups { + return prr.ReactionGroups +} + +func (prr PullRequestReview) Status() string { + return prr.State +} + +func (prr PullRequestReview) Link() string { + return prr.URL +} diff --git a/api/queries_repo.go b/api/queries_repo.go index 54f88a7e1..1ca7a92f8 100644 --- a/api/queries_repo.go +++ b/api/queries_repo.go @@ -27,6 +27,7 @@ type Repository struct { IsPrivate bool HasIssuesEnabled bool + HasWikiEnabled bool ViewerPermission string DefaultBranchRef BranchRef @@ -94,6 +95,7 @@ func GitHubRepo(client *Client, repo ghrepo.Interface) (*Repository, error) { owner { login } hasIssuesEnabled description + hasWikiEnabled viewerPermission defaultBranchRef { name @@ -464,6 +466,28 @@ func (m *RepoMetadataResult) MilestoneToID(title string) (string, error) { return "", errors.New("not found") } +func (m *RepoMetadataResult) Merge(m2 *RepoMetadataResult) { + if len(m2.AssignableUsers) > 0 || len(m.AssignableUsers) == 0 { + m.AssignableUsers = m2.AssignableUsers + } + + if len(m2.Teams) > 0 || len(m.Teams) == 0 { + m.Teams = m2.Teams + } + + if len(m2.Labels) > 0 || len(m.Labels) == 0 { + m.Labels = m2.Labels + } + + if len(m2.Projects) > 0 || len(m.Projects) == 0 { + m.Projects = m2.Projects + } + + if len(m2.Milestones) > 0 || len(m.Milestones) == 0 { + m.Milestones = m2.Milestones + } +} + type RepoMetadataInput struct { Assignees bool Reviewers bool diff --git a/api/reaction_groups.go b/api/reaction_groups.go new file mode 100644 index 000000000..849fe4b36 --- /dev/null +++ b/api/reaction_groups.go @@ -0,0 +1,40 @@ +package api + +type ReactionGroups []ReactionGroup + +type ReactionGroup struct { + Content string + Users ReactionGroupUsers +} + +type ReactionGroupUsers struct { + TotalCount int +} + +func (rg ReactionGroup) Count() int { + return rg.Users.TotalCount +} + +func (rg ReactionGroup) Emoji() string { + return reactionEmoji[rg.Content] +} + +var reactionEmoji = map[string]string{ + "THUMBS_UP": "\U0001f44d", + "THUMBS_DOWN": "\U0001f44e", + "LAUGH": "\U0001f604", + "HOORAY": "\U0001f389", + "CONFUSED": "\U0001f615", + "HEART": "\u2764\ufe0f", + "ROCKET": "\U0001f680", + "EYES": "\U0001f440", +} + +func reactionGroupsFragment() string { + return `reactionGroups { + content + users { + totalCount + } + }` +} diff --git a/api/reaction_groups_test.go b/api/reaction_groups_test.go new file mode 100644 index 000000000..e30a9e1f8 --- /dev/null +++ b/api/reaction_groups_test.go @@ -0,0 +1,100 @@ +package api + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func Test_String(t *testing.T) { + tests := map[string]struct { + rg ReactionGroup + emoji string + count int + }{ + "empty reaction group": { + rg: ReactionGroup{}, + emoji: "", + count: 0, + }, + "unknown reaction group": { + rg: ReactionGroup{ + Content: "UNKNOWN", + Users: ReactionGroupUsers{TotalCount: 1}, + }, + emoji: "", + count: 1, + }, + "thumbs up reaction group": { + rg: ReactionGroup{ + Content: "THUMBS_UP", + Users: ReactionGroupUsers{TotalCount: 2}, + }, + emoji: "\U0001f44d", + count: 2, + }, + "thumbs down reaction group": { + rg: ReactionGroup{ + Content: "THUMBS_DOWN", + Users: ReactionGroupUsers{TotalCount: 3}, + }, + emoji: "\U0001f44e", + count: 3, + }, + "laugh reaction group": { + rg: ReactionGroup{ + Content: "LAUGH", + Users: ReactionGroupUsers{TotalCount: 4}, + }, + emoji: "\U0001f604", + count: 4, + }, + "hooray reaction group": { + rg: ReactionGroup{ + Content: "HOORAY", + Users: ReactionGroupUsers{TotalCount: 5}, + }, + emoji: "\U0001f389", + count: 5, + }, + "confused reaction group": { + rg: ReactionGroup{ + Content: "CONFUSED", + Users: ReactionGroupUsers{TotalCount: 6}, + }, + emoji: "\U0001f615", + count: 6, + }, + "heart reaction group": { + rg: ReactionGroup{ + Content: "HEART", + Users: ReactionGroupUsers{TotalCount: 7}, + }, + emoji: "\u2764\ufe0f", + count: 7, + }, + "rocket reaction group": { + rg: ReactionGroup{ + Content: "ROCKET", + Users: ReactionGroupUsers{TotalCount: 8}, + }, + emoji: "\U0001f680", + count: 8, + }, + "eyes reaction group": { + rg: ReactionGroup{ + Content: "EYES", + Users: ReactionGroupUsers{TotalCount: 9}, + }, + emoji: "\U0001f440", + count: 9, + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + assert.Equal(t, tt.emoji, tt.rg.Emoji()) + assert.Equal(t, tt.count, tt.rg.Count()) + }) + } +} diff --git a/auth/oauth.go b/auth/oauth.go deleted file mode 100644 index 2c8a78cd4..000000000 --- a/auth/oauth.go +++ /dev/null @@ -1,275 +0,0 @@ -package auth - -import ( - "crypto/rand" - "encoding/hex" - "errors" - "fmt" - "io" - "io/ioutil" - "net" - "net/http" - "net/url" - "strconv" - "strings" - "time" - - "github.com/cli/cli/internal/ghinstance" -) - -func randomString(length int) (string, error) { - b := make([]byte, length/2) - _, err := rand.Read(b) - if err != nil { - return "", err - } - return hex.EncodeToString(b), nil -} - -// OAuthFlow represents the setup for authenticating with GitHub -type OAuthFlow struct { - Hostname string - ClientID string - ClientSecret string - Scopes []string - OpenInBrowser func(string, string) error - WriteSuccessHTML func(io.Writer) - VerboseStream io.Writer - HTTPClient *http.Client - TimeNow func() time.Time - TimeSleep func(time.Duration) -} - -func detectDeviceFlow(statusCode int, values url.Values) (bool, error) { - if statusCode == http.StatusUnauthorized || statusCode == http.StatusForbidden || - statusCode == http.StatusNotFound || statusCode == http.StatusUnprocessableEntity || - (statusCode == http.StatusOK && values == nil) || - (statusCode == http.StatusBadRequest && values != nil && values.Get("error") == "unauthorized_client") { - return true, nil - } else if statusCode != http.StatusOK { - if values != nil && values.Get("error_description") != "" { - return false, fmt.Errorf("HTTP %d: %s", statusCode, values.Get("error_description")) - } - return false, fmt.Errorf("error: HTTP %d", statusCode) - } - return false, nil -} - -// ObtainAccessToken guides the user through the browser OAuth flow on GitHub -// and returns the OAuth access token upon completion. -func (oa *OAuthFlow) ObtainAccessToken() (accessToken string, err error) { - // first, check if OAuth Device Flow is supported - initURL := fmt.Sprintf("https://%s/login/device/code", oa.Hostname) - tokenURL := fmt.Sprintf("https://%s/login/oauth/access_token", oa.Hostname) - - oa.logf("POST %s\n", initURL) - resp, err := oa.HTTPClient.PostForm(initURL, url.Values{ - "client_id": {oa.ClientID}, - "scope": {strings.Join(oa.Scopes, " ")}, - }) - if err != nil { - return - } - defer resp.Body.Close() - - var values url.Values - if strings.Contains(resp.Header.Get("Content-Type"), "application/x-www-form-urlencoded") { - var bb []byte - bb, err = ioutil.ReadAll(resp.Body) - if err != nil { - return - } - values, err = url.ParseQuery(string(bb)) - if err != nil { - return - } - } - - if doFallback, err := detectDeviceFlow(resp.StatusCode, values); doFallback { - // OAuth Device Flow is not available; continue with OAuth browser flow with a - // local server endpoint as callback target - return oa.localServerFlow() - } else if err != nil { - return "", fmt.Errorf("%v (%s)", err, initURL) - } - - timeNow := oa.TimeNow - if timeNow == nil { - timeNow = time.Now - } - timeSleep := oa.TimeSleep - if timeSleep == nil { - timeSleep = time.Sleep - } - - intervalSeconds, err := strconv.Atoi(values.Get("interval")) - if err != nil { - return "", fmt.Errorf("could not parse interval=%q as integer: %w", values.Get("interval"), err) - } - checkInterval := time.Duration(intervalSeconds) * time.Second - - expiresIn, err := strconv.Atoi(values.Get("expires_in")) - if err != nil { - return "", fmt.Errorf("could not parse expires_in=%q as integer: %w", values.Get("expires_in"), err) - } - expiresAt := timeNow().Add(time.Duration(expiresIn) * time.Second) - - err = oa.OpenInBrowser(values.Get("verification_uri"), values.Get("user_code")) - if err != nil { - return - } - - for { - timeSleep(checkInterval) - accessToken, err = oa.deviceFlowPing(tokenURL, values.Get("device_code")) - if accessToken == "" && err == nil { - if timeNow().After(expiresAt) { - err = errors.New("authentication timed out") - } else { - continue - } - } - break - } - - return -} - -func (oa *OAuthFlow) deviceFlowPing(tokenURL, deviceCode string) (accessToken string, err error) { - oa.logf("POST %s\n", tokenURL) - resp, err := oa.HTTPClient.PostForm(tokenURL, url.Values{ - "client_id": {oa.ClientID}, - "device_code": {deviceCode}, - "grant_type": {"urn:ietf:params:oauth:grant-type:device_code"}, - }) - if err != nil { - return "", err - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - return "", fmt.Errorf("error: HTTP %d (%s)", resp.StatusCode, tokenURL) - } - - bb, err := ioutil.ReadAll(resp.Body) - if err != nil { - return "", err - } - values, err := url.ParseQuery(string(bb)) - if err != nil { - return "", err - } - - if accessToken := values.Get("access_token"); accessToken != "" { - return accessToken, nil - } - - errorType := values.Get("error") - if errorType == "authorization_pending" { - return "", nil - } - - if errorDescription := values.Get("error_description"); errorDescription != "" { - return "", errors.New(errorDescription) - } - return "", errors.New("OAuth device flow error") -} - -func (oa *OAuthFlow) localServerFlow() (accessToken string, err error) { - state, _ := randomString(20) - - code := "" - listener, err := net.Listen("tcp4", "127.0.0.1:0") - if err != nil { - return - } - port := listener.Addr().(*net.TCPAddr).Port - - scopes := "repo" - if oa.Scopes != nil { - 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://%s:%d%s", localhost, port, callbackPath)) - q.Set("scope", scopes) - q.Set("state", state) - - startURL := fmt.Sprintf("https://%s/login/oauth/authorize?%s", oa.Hostname, q.Encode()) - oa.logf("open %s\n", startURL) - err = oa.OpenInBrowser(startURL, "") - if err != nil { - return - } - - _ = 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 != callbackPath { - w.WriteHeader(404) - return - } - defer listener.Close() - rq := r.URL.Query() - if state != rq.Get("state") { - fmt.Fprintf(w, "Error: state mismatch") - return - } - code = rq.Get("code") - oa.logf("server received code %q\n", code) - w.Header().Add("content-type", "text/html") - if oa.WriteSuccessHTML != nil { - oa.WriteSuccessHTML(w) - } else { - fmt.Fprintf(w, "

You have successfully authenticated. You may now close this page.

") - } - })) - - tokenURL := fmt.Sprintf("https://%s/login/oauth/access_token", oa.Hostname) - oa.logf("POST %s\n", tokenURL) - resp, err := oa.HTTPClient.PostForm(tokenURL, - url.Values{ - "client_id": {oa.ClientID}, - "client_secret": {oa.ClientSecret}, - "code": {code}, - "state": {state}, - }) - if err != nil { - return - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - err = fmt.Errorf("HTTP %d error while obtaining OAuth access token", resp.StatusCode) - return - } - - body, err := ioutil.ReadAll(resp.Body) - if err != nil { - return - } - tokenValues, err := url.ParseQuery(string(body)) - if err != nil { - return - } - accessToken = tokenValues.Get("access_token") - if accessToken == "" { - err = errors.New("the access token could not be read from HTTP response") - } - return -} - -func (oa *OAuthFlow) logf(format string, args ...interface{}) { - if oa.VerboseStream == nil { - return - } - fmt.Fprintf(oa.VerboseStream, format, args...) -} diff --git a/auth/oauth_test.go b/auth/oauth_test.go deleted file mode 100644 index a9070a1b1..000000000 --- a/auth/oauth_test.go +++ /dev/null @@ -1,258 +0,0 @@ -package auth - -import ( - "bytes" - "fmt" - "io/ioutil" - "net/http" - "net/url" - "testing" - "time" -) - -type roundTripper func(*http.Request) (*http.Response, error) - -func (rt roundTripper) RoundTrip(req *http.Request) (*http.Response, error) { - return rt(req) -} - -func TestObtainAccessToken_deviceFlow(t *testing.T) { - requestCount := 0 - rt := func(req *http.Request) (*http.Response, error) { - route := fmt.Sprintf("%s %s", req.Method, req.URL) - switch route { - case "POST https://github.com/login/device/code": - if err := req.ParseForm(); err != nil { - return nil, err - } - if req.PostForm.Get("client_id") != "CLIENT-ID" { - t.Errorf("expected POST /login/device/code to supply client_id=%q, got %q", "CLIENT-ID", req.PostForm.Get("client_id")) - } - if req.PostForm.Get("scope") != "repo gist" { - t.Errorf("expected POST /login/device/code to supply scope=%q, got %q", "repo gist", req.PostForm.Get("scope")) - } - - responseData := url.Values{} - responseData.Set("device_code", "DEVICE-CODE") - responseData.Set("user_code", "1234-ABCD") - responseData.Set("verification_uri", "https://github.com/login/device") - responseData.Set("interval", "5") - responseData.Set("expires_in", "899") - - return &http.Response{ - StatusCode: 200, - Body: ioutil.NopCloser(bytes.NewBufferString(responseData.Encode())), - Header: http.Header{ - "Content-Type": []string{"application/x-www-form-urlencoded; charset=utf-8"}, - }, - }, nil - case "POST https://github.com/login/oauth/access_token": - if err := req.ParseForm(); err != nil { - return nil, err - } - if req.PostForm.Get("client_id") != "CLIENT-ID" { - t.Errorf("expected POST /login/oauth/access_token to supply client_id=%q, got %q", "CLIENT-ID", req.PostForm.Get("client_id")) - } - if req.PostForm.Get("device_code") != "DEVICE-CODE" { - t.Errorf("expected POST /login/oauth/access_token to supply device_code=%q, got %q", "DEVICE-CODE", req.PostForm.Get("scope")) - } - if req.PostForm.Get("grant_type") != "urn:ietf:params:oauth:grant-type:device_code" { - t.Errorf("expected POST /login/oauth/access_token to supply grant_type=%q, got %q", "urn:ietf:params:oauth:grant-type:device_code", req.PostForm.Get("grant_type")) - } - - responseData := url.Values{} - requestCount++ - if requestCount == 1 { - responseData.Set("error", "authorization_pending") - } else { - responseData.Set("access_token", "OTOKEN") - } - - return &http.Response{ - StatusCode: 200, - Body: ioutil.NopCloser(bytes.NewBufferString(responseData.Encode())), - }, nil - default: - return nil, fmt.Errorf("unstubbed HTTP request: %v", route) - } - } - httpClient := &http.Client{ - Transport: roundTripper(rt), - } - - slept := time.Duration(0) - var browseURL string - var browseCode string - - oa := &OAuthFlow{ - Hostname: "github.com", - ClientID: "CLIENT-ID", - ClientSecret: "CLIENT-SEKRIT", - Scopes: []string{"repo", "gist"}, - OpenInBrowser: func(url, code string) error { - browseURL = url - browseCode = code - return nil - }, - HTTPClient: httpClient, - TimeNow: time.Now, - TimeSleep: func(d time.Duration) { - slept += d - }, - } - - token, err := oa.ObtainAccessToken() - if err != nil { - t.Fatalf("ObtainAccessToken error: %v", err) - } - - if token != "OTOKEN" { - t.Errorf("expected token %q, got %q", "OTOKEN", token) - } - if requestCount != 2 { - t.Errorf("expected 2 HTTP pings for token, got %d", requestCount) - } - if slept.String() != "10s" { - t.Errorf("expected total sleep duration of %s, got %s", "10s", slept.String()) - } - if browseURL != "https://github.com/login/device" { - t.Errorf("expected to open browser at %s, got %s", "https://github.com/login/device", browseURL) - } - if browseCode != "1234-ABCD" { - t.Errorf("expected to provide user with one-time code %q, got %q", "1234-ABCD", browseCode) - } -} - -func Test_detectDeviceFlow(t *testing.T) { - type args struct { - statusCode int - values url.Values - } - tests := []struct { - name string - args args - doFallback bool - wantErr string - }{ - { - name: "success", - args: args{ - statusCode: 200, - values: url.Values{}, - }, - doFallback: false, - wantErr: "", - }, - { - name: "wrong response type", - args: args{ - statusCode: 200, - values: nil, - }, - doFallback: true, - wantErr: "", - }, - { - name: "401 unauthorized", - args: args{ - statusCode: 401, - values: nil, - }, - doFallback: true, - wantErr: "", - }, - { - name: "403 forbidden", - args: args{ - statusCode: 403, - values: nil, - }, - doFallback: true, - wantErr: "", - }, - { - name: "404 not found", - args: args{ - statusCode: 404, - values: nil, - }, - doFallback: true, - wantErr: "", - }, - { - name: "422 unprocessable", - args: args{ - statusCode: 422, - values: nil, - }, - doFallback: true, - wantErr: "", - }, - { - name: "402 payment required", - args: args{ - statusCode: 402, - values: nil, - }, - doFallback: false, - wantErr: "error: HTTP 402", - }, - { - name: "400 bad request", - args: args{ - statusCode: 400, - values: nil, - }, - doFallback: false, - wantErr: "error: HTTP 400", - }, - { - name: "400 with values", - args: args{ - statusCode: 400, - values: url.Values{ - "error": []string{"blah"}, - }, - }, - doFallback: false, - wantErr: "error: HTTP 400", - }, - { - name: "400 with unauthorized_client", - args: args{ - statusCode: 400, - values: url.Values{ - "error": []string{"unauthorized_client"}, - }, - }, - doFallback: true, - wantErr: "", - }, - { - name: "400 with error_description", - args: args{ - statusCode: 400, - values: url.Values{ - "error_description": []string{"HI"}, - }, - }, - doFallback: false, - wantErr: "HTTP 400: HI", - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, err := detectDeviceFlow(tt.args.statusCode, tt.args.values) - if (err != nil) != (tt.wantErr != "") { - t.Errorf("detectDeviceFlow() error = %v, wantErr %v", err, tt.wantErr) - return - } - if tt.wantErr != "" && err.Error() != tt.wantErr { - t.Errorf("error = %q, wantErr = %q", err, tt.wantErr) - } - if got != tt.doFallback { - t.Errorf("detectDeviceFlow() = %v, want %v", got, tt.doFallback) - } - }) - } -} diff --git a/cmd/gh/main.go b/cmd/gh/main.go index ec505d4b8..f1e72f1b5 100644 --- a/cmd/gh/main.go +++ b/cmd/gh/main.go @@ -16,11 +16,11 @@ import ( "github.com/cli/cli/internal/config" "github.com/cli/cli/internal/ghinstance" "github.com/cli/cli/internal/run" + "github.com/cli/cli/internal/update" "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" "github.com/cli/safeexec" "github.com/mattn/go-colorable" @@ -140,7 +140,6 @@ func main() { fmt.Fprintln(stderr, cs.Bold("Welcome to GitHub CLI!")) fmt.Fprintln(stderr) fmt.Fprintln(stderr, "To authenticate, please run `gh auth login`.") - fmt.Fprintln(stderr, "You can also set the one of the auth token environment variables, if preferred.") os.Exit(4) } diff --git a/context/remote_test.go b/context/remote_test.go index ab3f7e2e2..de9f21901 100644 --- a/context/remote_test.go +++ b/context/remote_test.go @@ -1,22 +1,14 @@ package context import ( - "errors" "net/url" - "reflect" "testing" "github.com/cli/cli/git" "github.com/cli/cli/internal/ghrepo" + "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 Test_Remotes_FindByName(t *testing.T) { list := Remotes{ &Remote{Remote: &git.Remote{Name: "mona"}, Repo: ghrepo.New("monalisa", "myfork")}, @@ -25,15 +17,15 @@ func Test_Remotes_FindByName(t *testing.T) { } r, err := list.FindByName("upstream", "origin") - eq(t, err, nil) - eq(t, r.Name, "upstream") + assert.NoError(t, err) + assert.Equal(t, "upstream", r.Name) - r, err = list.FindByName("nonexist", "*") - eq(t, err, nil) - eq(t, r.Name, "mona") + r, err = list.FindByName("nonexistent", "*") + assert.NoError(t, err) + assert.Equal(t, "mona", r.Name) - _, err = list.FindByName("nonexist") - eq(t, err, errors.New(`no GitHub remotes found`)) + _, err = list.FindByName("nonexistent") + assert.Error(t, err, "no GitHub remotes found") } func Test_translateRemotes(t *testing.T) { diff --git a/docs/install_linux.md b/docs/install_linux.md index d5392ba47..d51f35d09 100644 --- a/docs/install_linux.md +++ b/docs/install_linux.md @@ -1,4 +1,4 @@ -# Installing gh on Linux +# Installing gh on Linux and FreeBSD Packages downloaded from https://cli.github.com or from https://github.com/cli/cli/releases are considered official binaries. We focus on popular Linux distros and @@ -95,7 +95,21 @@ sudo pacman -S github-cli ### Android -Android users can install via Termux: +Android 7+ users can install via [Termux](https://wiki.termux.com/wiki/Main_Page): + +```bash +pkg install gh +``` + +### FreeBSD + +FreeBSD users can install from the [ports collection](https://www.freshports.org/devel/gh/): + +```bash +cd /usr/ports/devel/gh/ && make install clean +``` + +Or via [pkg(8)](https://www.freebsd.org/cgi/man.cgi?pkg(8)): ```bash pkg install gh @@ -132,6 +146,13 @@ Nix/NixOS users can install from [nixpkgs](https://search.nixos.org/packages?sho nix-env -iA nixos.gitAndTools.gh ``` +### openSUSE Tumbleweed + +openSUSE Tumbleweed users can install from the [offical distribution repo](https://software.opensuse.org/package/gh): +```bash +sudo zypper in gh +``` + ### Snaps Many Linux distro users can install using Snapd from the [Snap Store](https://snapcraft.io/gh) or the associated [repo](https://github.com/casperdcl/cli/tree/snap) diff --git a/docs/source.md b/docs/source.md index fc90ef94f..5a1a420f6 100644 --- a/docs/source.md +++ b/docs/source.md @@ -15,16 +15,25 @@ $ cd gh-cli ``` -2. Build the project - - ``` - $ make - ``` - -3. Move the resulting `bin/gh` executable to somewhere in your PATH +2. Build and install + #### Unix-like systems ```sh - $ sudo mv ./bin/gh /usr/local/bin/ + # installs to '/usr/local' by default; sudo may be required + $ make install + + # install to a different location + $ make install prefix=/path/to/gh ``` -4. Run `gh version` to check if it worked. + #### Windows + ```sh + # build the binary + > go build -o gh.exe ./cmd/gh + ``` + There is no install step available on Windows. + +3. Run `gh version` to check if it worked. + + #### Windows + Run `.\gh version` to check if it worked. diff --git a/git/remote_test.go b/git/remote_test.go index 2e7d30cb6..382896590 100644 --- a/git/remote_test.go +++ b/git/remote_test.go @@ -1,6 +1,10 @@ package git -import "testing" +import ( + "testing" + + "github.com/stretchr/testify/assert" +) func Test_parseRemotes(t *testing.T) { remoteList := []string{ @@ -12,20 +16,20 @@ func Test_parseRemotes(t *testing.T) { "zardoz\thttps://example.com/zed.git (push)", } r := parseRemotes(remoteList) - eq(t, len(r), 4) + assert.Equal(t, 4, len(r)) - eq(t, r[0].Name, "mona") - eq(t, r[0].FetchURL.String(), "ssh://git@github.com/monalisa/myfork.git") + assert.Equal(t, "mona", r[0].Name) + assert.Equal(t, "ssh://git@github.com/monalisa/myfork.git", r[0].FetchURL.String()) if r[0].PushURL != nil { t.Errorf("expected no PushURL, got %q", r[0].PushURL) } - eq(t, r[1].Name, "origin") - eq(t, r[1].FetchURL.Path, "/monalisa/octo-cat.git") - eq(t, r[1].PushURL.Path, "/monalisa/octo-cat-push.git") + assert.Equal(t, "origin", r[1].Name) + assert.Equal(t, "/monalisa/octo-cat.git", r[1].FetchURL.Path) + assert.Equal(t, "/monalisa/octo-cat-push.git", r[1].PushURL.Path) - eq(t, r[2].Name, "upstream") - eq(t, r[2].FetchURL.Host, "example.com") - eq(t, r[2].PushURL.Host, "github.com") + assert.Equal(t, "upstream", r[2].Name) + assert.Equal(t, "example.com", r[2].FetchURL.Host) + assert.Equal(t, "github.com", r[2].PushURL.Host) - eq(t, r[3].Name, "zardoz") + assert.Equal(t, "zardoz", r[3].Name) } diff --git a/git/ssh_config.go b/git/ssh_config.go index 287298cd9..317ff6059 100644 --- a/git/ssh_config.go +++ b/git/ssh_config.go @@ -13,15 +13,10 @@ import ( ) var ( - sshHostRE, - sshTokenRE *regexp.Regexp + sshConfigLineRE = regexp.MustCompile(`\A\s*(?P[A-Za-z][A-Za-z0-9]*)(?:\s+|\s*=\s*)(?P.+)`) + sshTokenRE = regexp.MustCompile(`%[%h]`) ) -func init() { - sshHostRE = regexp.MustCompile("(?i)^[ \t]*(host|hostname)[ \t]+(.+)$") - sshTokenRE = regexp.MustCompile(`%[%h]`) -} - // SSHAliasMap encapsulates the translation of SSH hostname aliases type SSHAliasMap map[string]string @@ -45,6 +40,103 @@ func (m SSHAliasMap) Translator() func(*url.URL) *url.URL { } } +type sshParser struct { + homeDir string + + aliasMap SSHAliasMap + hosts []string + + open func(string) (io.Reader, error) + glob func(string) ([]string, error) +} + +func (p *sshParser) read(fileName string) error { + var file io.Reader + if p.open == nil { + f, err := os.Open(fileName) + if err != nil { + return err + } + defer f.Close() + file = f + } else { + var err error + file, err = p.open(fileName) + if err != nil { + return err + } + } + + if len(p.hosts) == 0 { + p.hosts = []string{"*"} + } + + scanner := bufio.NewScanner(file) + for scanner.Scan() { + m := sshConfigLineRE.FindStringSubmatch(scanner.Text()) + if len(m) < 3 { + continue + } + + keyword, arguments := strings.ToLower(m[1]), m[2] + switch keyword { + case "host": + p.hosts = strings.Fields(arguments) + case "hostname": + for _, host := range p.hosts { + for _, name := range strings.Fields(arguments) { + if p.aliasMap == nil { + p.aliasMap = make(SSHAliasMap) + } + p.aliasMap[host] = sshExpandTokens(name, host) + } + } + case "include": + for _, arg := range strings.Fields(arguments) { + path := p.absolutePath(fileName, arg) + + var fileNames []string + if p.glob == nil { + paths, _ := filepath.Glob(path) + for _, p := range paths { + if s, err := os.Stat(p); err == nil && !s.IsDir() { + fileNames = append(fileNames, p) + } + } + } else { + var err error + fileNames, err = p.glob(path) + if err != nil { + continue + } + } + + for _, fileName := range fileNames { + _ = p.read(fileName) + } + } + } + } + + return scanner.Err() +} + +func (p *sshParser) absolutePath(parentFile, path string) string { + if filepath.IsAbs(path) || strings.HasPrefix(filepath.ToSlash(path), "/") { + return path + } + + if strings.HasPrefix(path, "~") { + return filepath.Join(p.homeDir, strings.TrimPrefix(path, "~")) + } + + if strings.HasPrefix(filepath.ToSlash(parentFile), "/etc/ssh") { + return filepath.Join("/etc/ssh", path) + } + + return filepath.Join(p.homeDir, ".ssh", path) +} + // ParseSSHConfig constructs a map of SSH hostname aliases based on user and // system configuration files func ParseSSHConfig() SSHAliasMap { @@ -52,54 +144,19 @@ func ParseSSHConfig() SSHAliasMap { "/etc/ssh_config", "/etc/ssh/ssh_config", } + + p := sshParser{} + if homedir, err := homedir.Dir(); err == nil { userConfig := filepath.Join(homedir, ".ssh", "config") configFiles = append([]string{userConfig}, configFiles...) + p.homeDir = homedir } - openFiles := make([]io.Reader, 0, len(configFiles)) for _, file := range configFiles { - f, err := os.Open(file) - if err != nil { - continue - } - defer f.Close() - openFiles = append(openFiles, f) + _ = p.read(file) } - return sshParse(openFiles...) -} - -func sshParse(r ...io.Reader) SSHAliasMap { - config := make(SSHAliasMap) - for _, file := range r { - _ = sshParseConfig(config, file) - } - return config -} - -func sshParseConfig(c SSHAliasMap, file io.Reader) error { - hosts := []string{"*"} - scanner := bufio.NewScanner(file) - for scanner.Scan() { - line := scanner.Text() - match := sshHostRE.FindStringSubmatch(line) - if match == nil { - continue - } - - names := strings.Fields(match[2]) - if strings.EqualFold(match[1], "host") { - hosts = names - } else { - for _, host := range hosts { - for _, name := range names { - c[host] = sshExpandTokens(name, host) - } - } - } - } - - return scanner.Err() + return p.aliasMap } func sshExpandTokens(text, host string) string { diff --git a/git/ssh_config_test.go b/git/ssh_config_test.go index 28f339aa6..f05ca303b 100644 --- a/git/ssh_config_test.go +++ b/git/ssh_config_test.go @@ -1,31 +1,127 @@ package git import ( + "bytes" + "fmt" + "io" "net/url" - "reflect" - "strings" + "path/filepath" "testing" + + "github.com/MakeNowJust/heredoc" ) -// TODO: extract assertion helpers into a shared package -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 Test_sshParser_read(t *testing.T) { + testFiles := map[string]string{ + "/etc/ssh/config": heredoc.Doc(` + Include sites/* + `), + "/etc/ssh/sites/cfg1": heredoc.Doc(` + Host s1 + Hostname=site1.net + `), + "/etc/ssh/sites/cfg2": heredoc.Doc(` + Host s2 + Hostname = site2.net + `), + "HOME/.ssh/config": heredoc.Doc(` + Host * + Host gh gittyhubby + Hostname github.com + #Hostname example.com + Host ex + Include ex_config/* + `), + "HOME/.ssh/ex_config/ex_cfg": heredoc.Doc(` + Hostname example.com + `), + } + globResults := map[string][]string{ + "/etc/ssh/sites/*": {"/etc/ssh/sites/cfg1", "/etc/ssh/sites/cfg2"}, + "HOME/.ssh/ex_config/*": {"HOME/.ssh/ex_config/ex_cfg"}, + } + + p := &sshParser{ + homeDir: "HOME", + open: func(s string) (io.Reader, error) { + if contents, ok := testFiles[filepath.ToSlash(s)]; ok { + return bytes.NewBufferString(contents), nil + } else { + return nil, fmt.Errorf("no test file stub found: %q", s) + } + }, + glob: func(p string) ([]string, error) { + if results, ok := globResults[filepath.ToSlash(p)]; ok { + return results, nil + } else { + return nil, fmt.Errorf("no glob stubs found: %q", p) + } + }, + } + + if err := p.read("/etc/ssh/config"); err != nil { + t.Fatalf("read(global config) = %v", err) + } + if err := p.read("HOME/.ssh/config"); err != nil { + t.Fatalf("read(user config) = %v", err) + } + + if got := p.aliasMap["gh"]; got != "github.com" { + t.Errorf("expected alias %q to expand to %q, got %q", "gh", "github.com", got) + } + if got := p.aliasMap["gittyhubby"]; got != "github.com" { + t.Errorf("expected alias %q to expand to %q, got %q", "gittyhubby", "github.com", got) + } + if got := p.aliasMap["example.com"]; got != "" { + t.Errorf("expected alias %q to expand to %q, got %q", "example.com", "", got) + } + if got := p.aliasMap["ex"]; got != "example.com" { + t.Errorf("expected alias %q to expand to %q, got %q", "ex", "example.com", got) + } + if got := p.aliasMap["s1"]; got != "site1.net" { + t.Errorf("expected alias %q to expand to %q, got %q", "s1", "site1.net", got) } } -func Test_sshParse(t *testing.T) { - m := sshParse(strings.NewReader(` - Host foo bar - HostName example.com - `), strings.NewReader(` - Host bar baz - hostname %%%h.net%% - `)) - eq(t, m["foo"], "example.com") - eq(t, m["bar"], "%bar.net%") - eq(t, m["nonexist"], "") +func Test_sshParser_absolutePath(t *testing.T) { + dir := "HOME" + p := &sshParser{homeDir: dir} + + tests := map[string]struct { + parentFile string + arg string + want string + wantErr bool + }{ + "absolute path": { + parentFile: "/etc/ssh/ssh_config", + arg: "/etc/ssh/config", + want: "/etc/ssh/config", + }, + "system relative path": { + parentFile: "/etc/ssh/config", + arg: "configs/*.conf", + want: filepath.Join("/etc", "ssh", "configs", "*.conf"), + }, + "user relative path": { + parentFile: filepath.Join(dir, ".ssh", "ssh_config"), + arg: "configs/*.conf", + want: filepath.Join(dir, ".ssh", "configs/*.conf"), + }, + "shell-like ~ rerefence": { + parentFile: filepath.Join(dir, ".ssh", "ssh_config"), + arg: "~/.ssh/*.conf", + want: filepath.Join(dir, ".ssh", "*.conf"), + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + if got := p.absolutePath(tt.parentFile, tt.arg); got != tt.want { + t.Errorf("absolutePath(): %q, wants %q", got, tt.want) + } + }) + } } func Test_Translator(t *testing.T) { diff --git a/go.mod b/go.mod index 978085109..6de526901 100644 --- a/go.mod +++ b/go.mod @@ -3,10 +3,11 @@ module github.com/cli/cli go 1.13 require ( - github.com/AlecAivazis/survey/v2 v2.2.2 + github.com/AlecAivazis/survey/v2 v2.2.7 github.com/MakeNowJust/heredoc v1.0.0 github.com/briandowns/spinner v1.11.1 github.com/charmbracelet/glamour v0.2.1-0.20200724174618-1246d13c1684 + github.com/cli/oauth v0.8.0 github.com/cli/safeexec v1.0.0 github.com/cpuguy83/go-md2man/v2 v2.0.0 github.com/enescakir/emoji v1.0.0 diff --git a/go.sum b/go.sum index eb7d0ae52..d2f73e243 100644 --- a/go.sum +++ b/go.sum @@ -11,8 +11,8 @@ cloud.google.com/go/firestore v1.1.0/go.mod h1:ulACoGHTpvq5r8rxGJ4ddJZBZqakUQqCl cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I= cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw= dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= -github.com/AlecAivazis/survey/v2 v2.2.2 h1:1I4qBrNsHQE+91tQCqVlfrKe9DEL65949d1oKZWVELY= -github.com/AlecAivazis/survey/v2 v2.2.2/go.mod h1:9FJRdMdDm8rnT+zHVbvQT2RTSTLq0Ttd6q3Vl2fahjk= +github.com/AlecAivazis/survey/v2 v2.2.7 h1:5NbxkF4RSKmpywYdcRgUmos1o+roJY8duCLZXbVjoig= +github.com/AlecAivazis/survey/v2 v2.2.7/go.mod h1:9DYvHgXtiXm6nCn+jXnOXLKbH+Yo9u8fAS/SduGdoPk= github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= @@ -44,6 +44,10 @@ github.com/briandowns/spinner v1.11.1/go.mod h1:QOuQk7x+EaDASo80FEXwlwiA+j/PPIcX github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= github.com/charmbracelet/glamour v0.2.1-0.20200724174618-1246d13c1684 h1:YMyvXRstOQc7n6eneHfudVMbARSCmZ7EZGjtTkkeB3A= github.com/charmbracelet/glamour v0.2.1-0.20200724174618-1246d13c1684/go.mod h1:UA27Kwj3QHialP74iU6C+Gpc8Y7IOAKupeKMLLBURWM= +github.com/cli/browser v1.0.0 h1:RIleZgXrhdiCVgFBSjtWwkLPUCWyhhhN5k5HGSBt1js= +github.com/cli/browser v1.0.0/go.mod h1:IEWkHYbLjkhtjwwWlwTHW2lGxeS5gezEQBMLTwDHf5Q= +github.com/cli/oauth v0.8.0 h1:YTFgPXSTvvDUFti3tR4o6q7Oll2SnQ9ztLwCAn4/IOA= +github.com/cli/oauth v0.8.0/go.mod h1:qd/FX8ZBD6n1sVNQO3aIdRxeu5LGw9WhKnYhIIoC2A4= github.com/cli/safeexec v1.0.0 h1:0VngyaIyqACHdcMNWfo6+KdUYnqEr2Sg+bSP1pdF+dI= github.com/cli/safeexec v1.0.0/go.mod h1:Z/D4tTN8Vs5gXYHDCbaM1S/anmEDnJb1iW0+EJ5zx3Q= github.com/cli/shurcooL-graphql v0.0.0-20200707151639-0f7232a2bf7e h1:aq/1jlmtZoS6nlSp3yLOTZQ50G+dzHdeRNENgE/iBew= diff --git a/internal/authflow/flow.go b/internal/authflow/flow.go index 896895d21..fac9d31a6 100644 --- a/internal/authflow/flow.go +++ b/internal/authflow/flow.go @@ -9,10 +9,11 @@ import ( "strings" "github.com/cli/cli/api" - "github.com/cli/cli/auth" "github.com/cli/cli/internal/config" + "github.com/cli/cli/internal/ghinstance" "github.com/cli/cli/pkg/browser" "github.com/cli/cli/pkg/iostreams" + "github.com/cli/oauth" ) var ( @@ -58,28 +59,33 @@ func authFlow(oauthHost string, IO *iostreams.IOStreams, notice string, addition w := IO.ErrOut cs := IO.ColorScheme() - var verboseStream io.Writer - if strings.Contains(os.Getenv("DEBUG"), "oauth") { - verboseStream = w + httpClient := http.DefaultClient + if envDebug := os.Getenv("DEBUG"); envDebug != "" { + logTraffic := strings.Contains(envDebug, "api") || strings.Contains(envDebug, "oauth") + httpClient.Transport = api.VerboseLog(IO.ErrOut, logTraffic, IO.ColorEnabled())(httpClient.Transport) } - minimumScopes := []string{"repo", "read:org", "gist"} + minimumScopes := []string{"repo", "read:org", "gist", "workflow"} scopes := append(minimumScopes, additionalScopes...) - flow := &auth.OAuthFlow{ + callbackURI := "http://127.0.0.1/callback" + if ghinstance.IsEnterprise(oauthHost) { + // 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 + callbackURI = "http://localhost/" + } + + flow := &oauth.Flow{ Hostname: oauthHost, ClientID: oauthClientID, ClientSecret: oauthClientSecret, + CallbackURI: callbackURI, Scopes: scopes, - WriteSuccessHTML: func(w io.Writer) { - fmt.Fprintln(w, oauthSuccessPage) + DisplayCode: func(code, verificationURL string) error { + fmt.Fprintf(w, "%s First copy your one-time code: %s\n", cs.Yellow("!"), cs.Bold(code)) + return nil }, - VerboseStream: verboseStream, - HTTPClient: http.DefaultClient, - OpenInBrowser: func(url, code string) error { - if code != "" { - fmt.Fprintf(w, "%s First copy your one-time code: %s\n", cs.Yellow("!"), cs.Bold(code)) - } + BrowseURL: func(url string) error { fmt.Fprintf(w, "- %s to open %s in your browser... ", cs.Bold("Press Enter"), oauthHost) _ = waitForEnter(IO.In) @@ -87,29 +93,34 @@ func authFlow(oauthHost string, IO *iostreams.IOStreams, notice string, addition if err != nil { return err } - err = browseCmd.Run() - if err != nil { + if err := browseCmd.Run(); err != nil { fmt.Fprintf(w, "%s Failed opening a web browser at %s\n", cs.Red("!"), url) fmt.Fprintf(w, " %s\n", err) fmt.Fprint(w, " Please try entering the URL in your browser manually\n") } return nil }, + WriteSuccessHTML: func(w io.Writer) { + fmt.Fprintln(w, oauthSuccessPage) + }, + HTTPClient: httpClient, + Stdin: IO.In, + Stdout: w, } fmt.Fprintln(w, notice) - token, err := flow.ObtainAccessToken() + token, err := flow.DetectFlow() if err != nil { return "", "", err } - userLogin, err := getViewer(oauthHost, token) + userLogin, err := getViewer(oauthHost, token.Token) if err != nil { return "", "", err } - return token, userLogin, nil + return token.Token, userLogin, nil } func getViewer(hostname, token string) (string, error) { diff --git a/internal/config/config_file_test.go b/internal/config/config_file_test.go index f40cb9097..35ecabda4 100644 --- a/internal/config/config_file_test.go +++ b/internal/config/config_file_test.go @@ -3,20 +3,12 @@ package config import ( "bytes" "fmt" - "reflect" "testing" "github.com/stretchr/testify/assert" "gopkg.in/yaml.v3" ) -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 Test_parseConfig(t *testing.T) { defer StubConfig(`--- hosts: @@ -25,13 +17,13 @@ hosts: oauth_token: OTOKEN `, "")() config, err := ParseConfig("config.yml") - eq(t, err, nil) + assert.NoError(t, err) user, err := config.Get("github.com", "user") - eq(t, err, nil) - eq(t, user, "monalisa") + assert.NoError(t, err) + assert.Equal(t, "monalisa", user) token, err := config.Get("github.com", "oauth_token") - eq(t, err, nil) - eq(t, token, "OTOKEN") + assert.NoError(t, err) + assert.Equal(t, "OTOKEN", token) } func Test_parseConfig_multipleHosts(t *testing.T) { @@ -45,13 +37,13 @@ hosts: oauth_token: OTOKEN `, "")() config, err := ParseConfig("config.yml") - eq(t, err, nil) + assert.NoError(t, err) user, err := config.Get("github.com", "user") - eq(t, err, nil) - eq(t, user, "monalisa") + assert.NoError(t, err) + assert.Equal(t, "monalisa", user) token, err := config.Get("github.com", "oauth_token") - eq(t, err, nil) - eq(t, token, "OTOKEN") + assert.NoError(t, err) + assert.Equal(t, "OTOKEN", token) } func Test_parseConfig_hostsFile(t *testing.T) { @@ -61,13 +53,13 @@ github.com: oauth_token: OTOKEN `)() config, err := ParseConfig("config.yml") - eq(t, err, nil) + assert.NoError(t, err) user, err := config.Get("github.com", "user") - eq(t, err, nil) - eq(t, user, "monalisa") + assert.NoError(t, err) + assert.Equal(t, "monalisa", user) token, err := config.Get("github.com", "oauth_token") - eq(t, err, nil) - eq(t, token, "OTOKEN") + assert.NoError(t, err) + assert.Equal(t, "OTOKEN", token) } func Test_parseConfig_hostFallback(t *testing.T) { @@ -83,16 +75,16 @@ example.com: git_protocol: https `)() config, err := ParseConfig("config.yml") - eq(t, err, nil) + assert.NoError(t, err) val, err := config.Get("example.com", "git_protocol") - eq(t, err, nil) - eq(t, val, "https") + assert.NoError(t, err) + assert.Equal(t, "https", val) 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") + assert.NoError(t, err) + assert.Equal(t, "ssh", val) + val, err = config.Get("nonexistent.io", "git_protocol") + assert.NoError(t, err) + assert.Equal(t, "ssh", val) } func Test_ParseConfig_migrateConfig(t *testing.T) { @@ -108,7 +100,7 @@ github.com: defer StubBackupConfig()() _, err := ParseConfig("config.yml") - assert.Nil(t, err) + assert.NoError(t, err) expectedHosts := `github.com: user: keiyuri diff --git a/internal/config/config_type.go b/internal/config/config_type.go index 40533f211..4c04d9f6b 100644 --- a/internal/config/config_type.go +++ b/internal/config/config_type.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "sort" + "strings" "github.com/cli/cli/internal/ghinstance" "gopkg.in/yaml.v3" @@ -378,7 +379,7 @@ func (c *fileConfig) configForHost(hostname string) (*HostConfig, error) { } for _, hc := range hosts { - if hc.Host == hostname { + if strings.EqualFold(hc.Host, hostname) { return hc, nil } } diff --git a/internal/config/config_type_test.go b/internal/config/config_type_test.go index 47295230f..fca819f46 100644 --- a/internal/config/config_type_test.go +++ b/internal/config/config_type_test.go @@ -55,15 +55,15 @@ func Test_defaultConfig(t *testing.T) { assert.Equal(t, "", hostsBuf.String()) proto, err := cfg.Get("", "git_protocol") - assert.Nil(t, err) + assert.NoError(t, err) assert.Equal(t, "https", proto) editor, err := cfg.Get("", "editor") - assert.Nil(t, err) + assert.NoError(t, err) assert.Equal(t, "", editor) aliases, err := cfg.Aliases() - assert.Nil(t, err) + assert.NoError(t, err) assert.Equal(t, len(aliases.All()), 1) expansion, _ := aliases.Get("co") assert.Equal(t, expansion, "pr checkout") @@ -74,13 +74,13 @@ func Test_ValidateValue(t *testing.T) { assert.EqualError(t, err, "invalid value") err = ValidateValue("git_protocol", "ssh") - assert.Nil(t, err) + assert.NoError(t, err) err = ValidateValue("editor", "vim") - assert.Nil(t, err) + assert.NoError(t, err) err = ValidateValue("got", "123") - assert.Nil(t, err) + assert.NoError(t, err) } func Test_ValidateKey(t *testing.T) { diff --git a/internal/config/from_env.go b/internal/config/from_env.go index da4ac1536..7b2853bd7 100644 --- a/internal/config/from_env.go +++ b/internal/config/from_env.go @@ -14,6 +14,14 @@ const ( GITHUB_ENTERPRISE_TOKEN = "GITHUB_ENTERPRISE_TOKEN" ) +type ReadOnlyEnvError struct { + Variable string +} + +func (e *ReadOnlyEnvError) Error() string { + return fmt.Sprintf("read-only value in %s", e.Variable) +} + func InheritEnv(c Config) Config { return &envConfig{Config: c} } @@ -56,7 +64,7 @@ func (c *envConfig) GetWithSource(hostname, key string) (string, string, error) func (c *envConfig) CheckWriteable(hostname, key string) error { if hostname != "" && key == "oauth_token" { if token, env := AuthTokenFromEnv(hostname); token != "" { - return fmt.Errorf("read-only token in %s cannot be modified", env) + return &ReadOnlyEnvError{Variable: env} } } @@ -78,3 +86,10 @@ func AuthTokenFromEnv(hostname string) (string, string) { return os.Getenv(GITHUB_TOKEN), GITHUB_TOKEN } + +func AuthTokenProvidedFromEnv() bool { + return os.Getenv(GH_ENTERPRISE_TOKEN) != "" || + os.Getenv(GITHUB_ENTERPRISE_TOKEN) != "" || + os.Getenv(GH_TOKEN) != "" || + os.Getenv(GITHUB_TOKEN) != "" +} diff --git a/internal/config/from_env_test.go b/internal/config/from_env_test.go index baeb63194..989cb3e3c 100644 --- a/internal/config/from_env_test.go +++ b/internal/config/from_env_test.go @@ -279,7 +279,66 @@ func TestInheritEnv(t *testing.T) { assert.Equal(t, tt.wants.token, val) err := cfg.CheckWriteable(tt.hostname, "oauth_token") - assert.Equal(t, tt.wants.writeable, err == nil) + if tt.wants.writeable != (err == nil) { + t.Errorf("CheckWriteable() = %v, wants %v", err, tt.wants.writeable) + } + }) + } +} + +func TestAuthTokenProvidedFromEnv(t *testing.T) { + orig_GITHUB_TOKEN := os.Getenv("GITHUB_TOKEN") + orig_GITHUB_ENTERPRISE_TOKEN := os.Getenv("GITHUB_ENTERPRISE_TOKEN") + orig_GH_TOKEN := os.Getenv("GH_TOKEN") + orig_GH_ENTERPRISE_TOKEN := os.Getenv("GH_ENTERPRISE_TOKEN") + t.Cleanup(func() { + os.Setenv("GITHUB_TOKEN", orig_GITHUB_TOKEN) + os.Setenv("GITHUB_ENTERPRISE_TOKEN", orig_GITHUB_ENTERPRISE_TOKEN) + os.Setenv("GH_TOKEN", orig_GH_TOKEN) + os.Setenv("GH_ENTERPRISE_TOKEN", orig_GH_ENTERPRISE_TOKEN) + }) + + tests := []struct { + name string + GITHUB_TOKEN string + GITHUB_ENTERPRISE_TOKEN string + GH_TOKEN string + GH_ENTERPRISE_TOKEN string + provided bool + }{ + { + name: "no env tokens", + provided: false, + }, + { + name: "GH_TOKEN", + GH_TOKEN: "TOKEN", + provided: true, + }, + { + name: "GITHUB_TOKEN", + GITHUB_TOKEN: "TOKEN", + provided: true, + }, + { + name: "GH_ENTERPRISE_TOKEN", + GH_ENTERPRISE_TOKEN: "TOKEN", + provided: true, + }, + { + name: "GITHUB_ENTERPRISE_TOKEN", + GITHUB_ENTERPRISE_TOKEN: "TOKEN", + provided: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + os.Setenv("GITHUB_TOKEN", tt.GITHUB_TOKEN) + os.Setenv("GITHUB_ENTERPRISE_TOKEN", tt.GITHUB_ENTERPRISE_TOKEN) + os.Setenv("GH_TOKEN", tt.GH_TOKEN) + os.Setenv("GH_ENTERPRISE_TOKEN", tt.GH_ENTERPRISE_TOKEN) + assert.Equal(t, tt.provided, AuthTokenProvidedFromEnv()) }) } } diff --git a/internal/docs/man_test.go b/internal/docs/man_test.go index 4e844ad61..58591e19e 100644 --- a/internal/docs/man_test.go +++ b/internal/docs/man_test.go @@ -106,7 +106,7 @@ func TestGenManSeeAlso(t *testing.T) { } } -func TestManPrintFlagsHidesShortDeperecated(t *testing.T) { +func TestManPrintFlagsHidesShortDeprecated(t *testing.T) { c := &cobra.Command{} c.Flags().StringP("foo", "f", "default", "Foo flag") _ = c.Flags().MarkShorthandDeprecated("foo", "don't use it no more") diff --git a/internal/ghinstance/host_test.go b/internal/ghinstance/host_test.go index 787569c68..a392e4ad2 100644 --- a/internal/ghinstance/host_test.go +++ b/internal/ghinstance/host_test.go @@ -139,7 +139,7 @@ func TestHostnameValidator(t *testing.T) { assert.Error(t, err) return } - assert.Equal(t, nil, err) + assert.NoError(t, err) }) } } diff --git a/internal/run/stub.go b/internal/run/stub.go index 9bd6e279b..f11834c19 100644 --- a/internal/run/stub.go +++ b/internal/run/stub.go @@ -39,7 +39,7 @@ func Stub() (*CommandStubber, func(T)) { return } t.Helper() - t.Errorf("umatched stubs (%d): %s", len(unmatched), strings.Join(unmatched, ", ")) + t.Errorf("unmatched stubs (%d): %s", len(unmatched), strings.Join(unmatched, ", ")) } } diff --git a/update/update.go b/internal/update/update.go similarity index 71% rename from update/update.go rename to internal/update/update.go index bf89a12e8..547ba5a20 100644 --- a/update/update.go +++ b/internal/update/update.go @@ -24,31 +24,31 @@ type StateEntry struct { // CheckForUpdate checks whether this software has had a newer release on GitHub func CheckForUpdate(client *api.Client, stateFilePath, repo, currentVersion string) (*ReleaseInfo, error) { - latestRelease, err := getLatestReleaseInfo(client, stateFilePath, repo, currentVersion) + stateEntry, _ := getStateEntry(stateFilePath) + if stateEntry != nil && time.Since(stateEntry.CheckedForUpdateAt).Hours() < 24 { + return nil, nil + } + + releaseInfo, err := getLatestReleaseInfo(client, repo) if err != nil { return nil, err } - if versionGreaterThan(latestRelease.Version, currentVersion) { - return latestRelease, nil + err = setStateEntry(stateFilePath, time.Now(), *releaseInfo) + if err != nil { + return nil, err + } + + if versionGreaterThan(releaseInfo.Version, currentVersion) { + return releaseInfo, nil } return nil, nil } -func getLatestReleaseInfo(client *api.Client, stateFilePath, repo, currentVersion string) (*ReleaseInfo, error) { - stateEntry, err := getStateEntry(stateFilePath) - if err == nil && time.Since(stateEntry.CheckedForUpdateAt).Hours() < 24 { - return &stateEntry.LatestRelease, nil - } - +func getLatestReleaseInfo(client *api.Client, repo string) (*ReleaseInfo, error) { var latestRelease ReleaseInfo - err = client.REST(ghinstance.Default(), "GET", fmt.Sprintf("repos/%s/releases/latest", repo), nil, &latestRelease) - if err != nil { - return nil, err - } - - err = setStateEntry(stateFilePath, time.Now(), latestRelease) + err := client.REST(ghinstance.Default(), "GET", fmt.Sprintf("repos/%s/releases/latest", repo), nil, &latestRelease) if err != nil { return nil, err } diff --git a/update/update_test.go b/internal/update/update_test.go similarity index 91% rename from update/update_test.go rename to internal/update/update_test.go index 2fcb2d6ab..024122983 100644 --- a/update/update_test.go +++ b/internal/update/update_test.go @@ -1,7 +1,6 @@ package update import ( - "bytes" "fmt" "io/ioutil" "log" @@ -54,10 +53,14 @@ func TestCheckForUpdate(t *testing.T) { t.Run(s.Name, func(t *testing.T) { http := &httpmock.Registry{} client := api.NewClient(api.ReplaceTripper(http)) - http.StubResponse(200, bytes.NewBufferString(fmt.Sprintf(`{ - "tag_name": "%s", - "html_url": "%s" - }`, s.LatestVersion, s.LatestURL))) + + http.Register( + httpmock.REST("GET", "repos/OWNER/REPO/releases/latest"), + httpmock.StringResponse(fmt.Sprintf(`{ + "tag_name": "%s", + "html_url": "%s" + }`, s.LatestVersion, s.LatestURL)), + ) rel, err := CheckForUpdate(client, tempFilePath(), "OWNER/REPO", s.CurrentVersion) if err != nil { diff --git a/pkg/cmd/alias/delete/delete_test.go b/pkg/cmd/alias/delete/delete_test.go index 3c6acea28..6348b361c 100644 --- a/pkg/cmd/alias/delete/delete_test.go +++ b/pkg/cmd/alias/delete/delete_test.go @@ -76,9 +76,7 @@ func TestAliasDelete(t *testing.T) { _, err = cmd.ExecuteC() if tt.wantErr != "" { - if assert.Error(t, err) { - assert.Equal(t, tt.wantErr, err.Error()) - } + assert.EqualError(t, err, tt.wantErr) return } require.NoError(t, err) diff --git a/pkg/cmd/alias/set/set_test.go b/pkg/cmd/alias/set/set_test.go index 95f3a5031..c440d80c1 100644 --- a/pkg/cmd/alias/set/set_test.go +++ b/pkg/cmd/alias/set/set_test.go @@ -65,10 +65,7 @@ func TestAliasSet_gh_command(t *testing.T) { 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()) - } + assert.EqualError(t, err, `could not create alias: "pr" is already a gh command`) } func TestAliasSet_empty_aliases(t *testing.T) { @@ -210,9 +207,7 @@ func TestAliasSet_invalid_command(t *testing.T) { 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()) - } + assert.EqualError(t, err, "could not create alias: pe checkout does not correspond to a gh command") } func TestShellAlias_flag(t *testing.T) { diff --git a/pkg/cmd/api/api.go b/pkg/cmd/api/api.go index 16e61e7e1..63437937c 100644 --- a/pkg/cmd/api/api.go +++ b/pkg/cmd/api/api.go @@ -403,7 +403,7 @@ func parseField(f string) (string, string, error) { func magicFieldValue(v string, opts *ApiOptions) (interface{}, error) { if strings.HasPrefix(v, "@") { - return readUserFile(v[1:], opts.IO.In) + return opts.IO.ReadUserFile(v[1:]) } if n, err := strconv.Atoi(v); err == nil { @@ -422,21 +422,6 @@ func magicFieldValue(v string, opts *ApiOptions) (interface{}, error) { } } -func readUserFile(fn string, stdin io.ReadCloser) ([]byte, error) { - var r io.ReadCloser - if fn == "-" { - r = stdin - } else { - var err error - r, err = os.Open(fn) - if err != nil { - return nil, err - } - } - defer r.Close() - return ioutil.ReadAll(r) -} - func openUserFile(fn string, stdin io.ReadCloser) (io.ReadCloser, int64, error) { if fn == "-" { return stdin, -1, nil diff --git a/pkg/cmd/auth/auth.go b/pkg/cmd/auth/auth.go index ddeb07aa6..6909f99f8 100644 --- a/pkg/cmd/auth/auth.go +++ b/pkg/cmd/auth/auth.go @@ -1,6 +1,7 @@ package auth import ( + gitCredentialCmd "github.com/cli/cli/pkg/cmd/auth/gitcredential" 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" @@ -22,6 +23,7 @@ func NewCmdAuth(f *cmdutil.Factory) *cobra.Command { cmd.AddCommand(authLogoutCmd.NewCmdLogout(f, nil)) cmd.AddCommand(authStatusCmd.NewCmdStatus(f, nil)) cmd.AddCommand(authRefreshCmd.NewCmdRefresh(f, nil)) + cmd.AddCommand(gitCredentialCmd.NewCmdCredential(f, nil)) return cmd } diff --git a/pkg/cmd/auth/gitcredential/helper.go b/pkg/cmd/auth/gitcredential/helper.go new file mode 100644 index 000000000..c62cef3a8 --- /dev/null +++ b/pkg/cmd/auth/gitcredential/helper.go @@ -0,0 +1,117 @@ +package login + +import ( + "bufio" + "fmt" + "net/url" + "strings" + + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/iostreams" + "github.com/spf13/cobra" +) + +type config interface { + Get(string, string) (string, error) +} + +type CredentialOptions struct { + IO *iostreams.IOStreams + Config func() (config, error) + + Operation string +} + +func NewCmdCredential(f *cmdutil.Factory, runF func(*CredentialOptions) error) *cobra.Command { + opts := &CredentialOptions{ + IO: f.IOStreams, + Config: func() (config, error) { + return f.Config() + }, + } + + cmd := &cobra.Command{ + Use: "git-credential", + Args: cobra.ExactArgs(1), + Short: "Implements git credential helper protocol", + Hidden: true, + RunE: func(cmd *cobra.Command, args []string) error { + opts.Operation = args[0] + + if runF != nil { + return runF(opts) + } + return helperRun(opts) + }, + } + + return cmd +} + +func helperRun(opts *CredentialOptions) error { + if opts.Operation == "store" { + // We pretend to implement the "store" operation, but do nothing since we already have a cached token. + return cmdutil.SilentError + } + + if opts.Operation != "get" { + return fmt.Errorf("gh auth git-credential: %q operation not supported", opts.Operation) + } + + wants := map[string]string{} + + s := bufio.NewScanner(opts.IO.In) + for s.Scan() { + line := s.Text() + if line == "" { + break + } + parts := strings.SplitN(line, "=", 2) + if len(parts) < 2 { + continue + } + key, value := parts[0], parts[1] + if key == "url" { + u, err := url.Parse(value) + if err != nil { + return err + } + wants["protocol"] = u.Scheme + wants["host"] = u.Host + wants["path"] = u.Path + wants["username"] = u.User.Username() + wants["password"], _ = u.User.Password() + } else { + wants[key] = value + } + } + if err := s.Err(); err != nil { + return err + } + + if wants["protocol"] != "https" { + return cmdutil.SilentError + } + + cfg, err := opts.Config() + if err != nil { + return err + } + + gotUser, _ := cfg.Get(wants["host"], "user") + gotToken, _ := cfg.Get(wants["host"], "oauth_token") + if gotUser == "" || gotToken == "" { + return cmdutil.SilentError + } + + if wants["username"] != "" && !strings.EqualFold(wants["username"], gotUser) { + return cmdutil.SilentError + } + + fmt.Fprint(opts.IO.Out, "protocol=https\n") + fmt.Fprintf(opts.IO.Out, "host=%s\n", wants["host"]) + fmt.Fprintf(opts.IO.Out, "username=%s\n", gotUser) + fmt.Fprintf(opts.IO.Out, "password=%s\n", gotToken) + + return nil +} diff --git a/pkg/cmd/auth/gitcredential/helper_test.go b/pkg/cmd/auth/gitcredential/helper_test.go new file mode 100644 index 000000000..336d4ef34 --- /dev/null +++ b/pkg/cmd/auth/gitcredential/helper_test.go @@ -0,0 +1,154 @@ +package login + +import ( + "fmt" + "testing" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/pkg/iostreams" +) + +type tinyConfig map[string]string + +func (c tinyConfig) Get(host, key string) (string, error) { + return c[fmt.Sprintf("%s:%s", host, key)], nil +} + +func Test_helperRun(t *testing.T) { + tests := []struct { + name string + opts CredentialOptions + input string + wantStdout string + wantStderr string + wantErr bool + }{ + { + name: "host only, credentials found", + opts: CredentialOptions{ + Operation: "get", + Config: func() (config, error) { + return tinyConfig{ + "example.com:user": "monalisa", + "example.com:oauth_token": "OTOKEN", + }, nil + }, + }, + input: heredoc.Doc(` + protocol=https + host=example.com + `), + wantErr: false, + wantStdout: heredoc.Doc(` + protocol=https + host=example.com + username=monalisa + password=OTOKEN + `), + wantStderr: "", + }, + { + name: "host plus user", + opts: CredentialOptions{ + Operation: "get", + Config: func() (config, error) { + return tinyConfig{ + "example.com:user": "monalisa", + "example.com:oauth_token": "OTOKEN", + }, nil + }, + }, + input: heredoc.Doc(` + protocol=https + host=example.com + username=monalisa + `), + wantErr: false, + wantStdout: heredoc.Doc(` + protocol=https + host=example.com + username=monalisa + password=OTOKEN + `), + wantStderr: "", + }, + { + name: "url input", + opts: CredentialOptions{ + Operation: "get", + Config: func() (config, error) { + return tinyConfig{ + "example.com:user": "monalisa", + "example.com:oauth_token": "OTOKEN", + }, nil + }, + }, + input: heredoc.Doc(` + url=https://monalisa@example.com + `), + wantErr: false, + wantStdout: heredoc.Doc(` + protocol=https + host=example.com + username=monalisa + password=OTOKEN + `), + wantStderr: "", + }, + { + name: "host only, no credentials found", + opts: CredentialOptions{ + Operation: "get", + Config: func() (config, error) { + return tinyConfig{ + "example.com:user": "monalisa", + }, nil + }, + }, + input: heredoc.Doc(` + protocol=https + host=example.com + `), + wantErr: true, + wantStdout: "", + wantStderr: "", + }, + { + name: "user mismatch", + opts: CredentialOptions{ + Operation: "get", + Config: func() (config, error) { + return tinyConfig{ + "example.com:user": "monalisa", + "example.com:oauth_token": "OTOKEN", + }, nil + }, + }, + input: heredoc.Doc(` + protocol=https + host=example.com + username=hubot + `), + wantErr: true, + wantStdout: "", + wantStderr: "", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + io, stdin, stdout, stderr := iostreams.Test() + fmt.Fprint(stdin, tt.input) + opts := &tt.opts + opts.IO = io + if err := helperRun(opts); (err != nil) != tt.wantErr { + t.Fatalf("helperRun() error = %v, wantErr %v", err, tt.wantErr) + } + if tt.wantStdout != stdout.String() { + t.Errorf("stdout: got %q, wants %q", stdout.String(), tt.wantStdout) + } + if tt.wantStderr != stderr.String() { + t.Errorf("stderr: got %q, wants %q", stderr.String(), tt.wantStderr) + } + }) + } +} diff --git a/pkg/cmd/auth/login/login.go b/pkg/cmd/auth/login/login.go index d702d0a1a..eb3b6b645 100644 --- a/pkg/cmd/auth/login/login.go +++ b/pkg/cmd/auth/login/login.go @@ -12,7 +12,7 @@ import ( "github.com/cli/cli/internal/authflow" "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/cmd/auth/shared" "github.com/cli/cli/pkg/cmdutil" "github.com/cli/cli/pkg/iostreams" "github.com/cli/cli/pkg/prompt" @@ -120,70 +120,56 @@ func loginRun(opts *LoginOptions) error { return err } + hostname := opts.Hostname + if hostname == "" { + if opts.Interactive { + var err error + hostname, err = promptForHostname() + if err != nil { + return err + } + } else { + return errors.New("must specify --hostname") + } + } + + if err := cfg.CheckWriteable(hostname, "oauth_token"); err != nil { + var roErr *config.ReadOnlyEnvError + if errors.As(err, &roErr) { + fmt.Fprintf(opts.IO.ErrOut, "The value of the %s environment variable is being used for authentication.\n", roErr.Variable) + fmt.Fprint(opts.IO.ErrOut, "To have GitHub CLI store credentials instead, first clear the value from the environment.\n") + return cmdutil.SilentError + } + 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) + err := cfg.Set(hostname, "oauth_token", opts.Token) if err != nil { return err } - err = client.ValidateHostCfg(opts.Hostname, cfg) + apiClient, err := shared.ClientFromCfg(hostname, cfg) if err != nil { return err } + if err := apiClient.HasMinimumScopes(hostname); err != nil { + return fmt.Errorf("error validating token: %w", err) + } + 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 Server", - }, - }, &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(ghinstance.HostnameValidator)) - 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 != "" && opts.Interactive { - err := client.ValidateHostCfg(hostname, cfg) - if err == nil { - apiClient, err := client.ClientFromCfg(hostname, cfg) - if err != nil { - return err - } + apiClient, err := shared.ClientFromCfg(hostname, cfg) + if err != nil { + return err + } + if err := apiClient.HasMinimumScopes(hostname); err == nil { username, err := api.CurrentLoginName(apiClient, hostname) if err != nil { return fmt.Errorf("error using api: %w", err) @@ -206,10 +192,6 @@ func loginRun(opts *LoginOptions) error { } } - if err := cfg.CheckWriteable(hostname, "oauth_token"); err != nil { - return err - } - var authMode int if opts.Web { authMode = 0 @@ -226,11 +208,13 @@ func loginRun(opts *LoginOptions) error { } } + userValidated := false if authMode == 0 { _, err := authflow.AuthFlowWithConfig(cfg, opts.IO, hostname, "", opts.Scopes) if err != nil { return fmt.Errorf("failed to authenticate via web browser: %w", err) } + userValidated = true } else { fmt.Fprintln(opts.IO.ErrOut) fmt.Fprintln(opts.IO.ErrOut, heredoc.Doc(getAccessTokenTip(hostname))) @@ -242,24 +226,24 @@ func loginRun(opts *LoginOptions) error { 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) + apiClient, err := shared.ClientFromCfg(hostname, cfg) if err != nil { return err } + + if err := apiClient.HasMinimumScopes(hostname); err != nil { + return fmt.Errorf("error validating token: %w", err) + } } cs := opts.IO.ColorScheme() - gitProtocol := "https" + var gitProtocol string if opts.Interactive { err = prompt.SurveyAskOne(&survey.Select{ Message: "Choose default git protocol", @@ -283,19 +267,24 @@ func loginRun(opts *LoginOptions) error { fmt.Fprintf(opts.IO.ErrOut, "%s Configured git protocol\n", cs.SuccessIcon()) } - apiClient, err := client.ClientFromCfg(hostname, cfg) - if err != nil { - return err - } + var username string + if userValidated { + username, _ = cfg.Get(hostname, "user") + } else { + apiClient, err := shared.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) - } + 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.Set(hostname, "user", username) + if err != nil { + return err + } } err = cfg.Write() @@ -303,11 +292,47 @@ func loginRun(opts *LoginOptions) error { return err } + if opts.Interactive && gitProtocol == "https" { + err := shared.GitCredentialSetup(cfg, hostname, username) + if err != nil { + return err + } + } + fmt.Fprintf(opts.IO.ErrOut, "%s Logged in as %s\n", cs.SuccessIcon(), cs.Bold(username)) return nil } +func promptForHostname() (string, error) { + var hostType int + err := prompt.SurveyAskOne(&survey.Select{ + Message: "What account do you want to log into?", + Options: []string{ + "GitHub.com", + "GitHub Enterprise Server", + }, + }, &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(ghinstance.HostnameValidator)) + if err != nil { + return "", fmt.Errorf("could not prompt: %w", err) + } + } + + return hostname, nil +} + func getAccessTokenTip(hostname string) string { ghHostname := hostname if ghHostname == "" { diff --git a/pkg/cmd/auth/login/login_test.go b/pkg/cmd/auth/login/login_test.go index 9a6b5aa8b..e14bc2e3f 100644 --- a/pkg/cmd/auth/login/login_test.go +++ b/pkg/cmd/auth/login/login_test.go @@ -3,12 +3,14 @@ package login import ( "bytes" "net/http" + "os" "regexp" "testing" + "github.com/MakeNowJust/heredoc" "github.com/cli/cli/api" "github.com/cli/cli/internal/config" - "github.com/cli/cli/pkg/cmd/auth/client" + "github.com/cli/cli/pkg/cmd/auth/shared" "github.com/cli/cli/pkg/cmdutil" "github.com/cli/cli/pkg/httpmock" "github.com/cli/cli/pkg/iostreams" @@ -189,11 +191,13 @@ func Test_NewCmdLogin(t *testing.T) { func Test_loginRun_nontty(t *testing.T) { tests := []struct { - name string - opts *LoginOptions - httpStubs func(*httpmock.Registry) - wantHosts string - wantErr *regexp.Regexp + name string + opts *LoginOptions + httpStubs func(*httpmock.Registry) + env map[string]string + wantHosts string + wantErr string + wantStderr string }{ { name: "with token", @@ -201,6 +205,9 @@ func Test_loginRun_nontty(t *testing.T) { Hostname: "github.com", Token: "abc123", }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,read:org")) + }, wantHosts: "github.com:\n oauth_token: abc123\n", }, { @@ -223,7 +230,7 @@ func Test_loginRun_nontty(t *testing.T) { httpStubs: func(reg *httpmock.Registry) { reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("read:org")) }, - wantErr: regexp.MustCompile(`missing required scope 'repo'`), + wantErr: `error validating token: missing required scope 'repo'`, }, { name: "missing read scope", @@ -234,7 +241,7 @@ func Test_loginRun_nontty(t *testing.T) { httpStubs: func(reg *httpmock.Registry) { reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo")) }, - wantErr: regexp.MustCompile(`missing required scope 'read:org'`), + wantErr: `error validating token: missing required scope 'read:org'`, }, { name: "has admin scope", @@ -247,6 +254,36 @@ func Test_loginRun_nontty(t *testing.T) { }, wantHosts: "github.com:\n oauth_token: abc456\n", }, + { + name: "github.com token from environment", + opts: &LoginOptions{ + Hostname: "github.com", + Token: "abc456", + }, + env: map[string]string{ + "GH_TOKEN": "value_from_env", + }, + wantErr: "SilentError", + wantStderr: heredoc.Doc(` + The value of the GH_TOKEN environment variable is being used for authentication. + To have GitHub CLI store credentials instead, first clear the value from the environment. + `), + }, + { + name: "GHE token from environment", + opts: &LoginOptions{ + Hostname: "ghe.io", + Token: "abc456", + }, + env: map[string]string{ + "GH_ENTERPRISE_TOKEN": "value_from_env", + }, + wantErr: "SilentError", + wantStderr: heredoc.Doc(` + The value of the GH_ENTERPRISE_TOKEN environment variable is being used for authentication. + To have GitHub CLI store credentials instead, first clear the value from the environment. + `), + }, } for _, tt := range tests { @@ -256,25 +293,39 @@ func Test_loginRun_nontty(t *testing.T) { io.SetStdoutTTY(false) tt.opts.Config = func() (config.Config, error) { - return config.NewBlankConfig(), nil + cfg := config.NewBlankConfig() + return config.InheritEnv(cfg), nil } tt.opts.IO = io t.Run(tt.name, func(t *testing.T) { reg := &httpmock.Registry{} - origClientFromCfg := client.ClientFromCfg + origClientFromCfg := shared.ClientFromCfg defer func() { - client.ClientFromCfg = origClientFromCfg + shared.ClientFromCfg = origClientFromCfg }() - client.ClientFromCfg = func(_ string, _ config.Config) (*api.Client, error) { + shared.ClientFromCfg = func(_ string, _ config.Config) (*api.Client, error) { httpClient := &http.Client{Transport: reg} return api.NewClientFromHTTP(httpClient), nil } + old_GH_TOKEN := os.Getenv("GH_TOKEN") + os.Setenv("GH_TOKEN", tt.env["GH_TOKEN"]) + old_GITHUB_TOKEN := os.Getenv("GITHUB_TOKEN") + os.Setenv("GITHUB_TOKEN", tt.env["GITHUB_TOKEN"]) + old_GH_ENTERPRISE_TOKEN := os.Getenv("GH_ENTERPRISE_TOKEN") + os.Setenv("GH_ENTERPRISE_TOKEN", tt.env["GH_ENTERPRISE_TOKEN"]) + old_GITHUB_ENTERPRISE_TOKEN := os.Getenv("GITHUB_ENTERPRISE_TOKEN") + os.Setenv("GITHUB_ENTERPRISE_TOKEN", tt.env["GITHUB_ENTERPRISE_TOKEN"]) + defer func() { + os.Setenv("GH_TOKEN", old_GH_TOKEN) + os.Setenv("GITHUB_TOKEN", old_GITHUB_TOKEN) + os.Setenv("GH_ENTERPRISE_TOKEN", old_GH_ENTERPRISE_TOKEN) + os.Setenv("GITHUB_ENTERPRISE_TOKEN", old_GITHUB_ENTERPRISE_TOKEN) + }() + if tt.httpStubs != nil { tt.httpStubs(reg) - } else { - reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,read:org")) } mainBuf := bytes.Buffer{} @@ -282,18 +333,14 @@ func Test_loginRun_nontty(t *testing.T) { 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) - } + if tt.wantErr != "" { + assert.EqualError(t, err, tt.wantErr) + } else { + assert.NoError(t, err) } assert.Equal(t, "", stdout.String()) - assert.Equal(t, "", stderr.String()) + assert.Equal(t, tt.wantStderr, stderr.String()) assert.Equal(t, tt.wantHosts, hostsBuf.String()) reg.Verify(t) }) @@ -319,7 +366,7 @@ func Test_loginRun_Survey(t *testing.T) { _ = 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.REST("GET", ""), httpmock.ScopesResponder("repo,read:org")) reg.Register( httpmock.GraphQL(`query UserCurrent\b`), httpmock.StringResponse(`{"data":{"viewer":{"login":"jillv"}}}`)) @@ -329,7 +376,7 @@ func Test_loginRun_Survey(t *testing.T) { as.StubOne(false) // do not continue }, wantHosts: "", // nothing should have been written to hosts - wantErrOut: regexp.MustCompile("Logging into github.com"), + wantErrOut: nil, }, { name: "hostname set", @@ -342,9 +389,10 @@ func Test_loginRun_Survey(t *testing.T) { as.StubOne(1) // auth mode: token as.StubOne("def456") // auth token as.StubOne("HTTPS") // git_protocol + as.StubOne(false) // cache credentials }, httpStubs: func(reg *httpmock.Registry) { - reg.Register(httpmock.REST("GET", "api/v3/"), httpmock.ScopesResponder("repo,read:org,")) + 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"}}}`)) @@ -363,9 +411,10 @@ func Test_loginRun_Survey(t *testing.T) { as.StubOne(1) // auth mode: token as.StubOne("def456") // auth token as.StubOne("HTTPS") // git_protocol + as.StubOne(false) // cache credentials }, httpStubs: func(reg *httpmock.Registry) { - reg.Register(httpmock.REST("GET", "api/v3/"), httpmock.ScopesResponder("repo,read:org,")) + reg.Register(httpmock.REST("GET", "api/v3/"), httpmock.ScopesResponder("repo,read:org,read:public_key")) reg.Register( httpmock.GraphQL(`query UserCurrent\b`), httpmock.StringResponse(`{"data":{"viewer":{"login":"jillv"}}}`)) @@ -383,6 +432,7 @@ func Test_loginRun_Survey(t *testing.T) { as.StubOne(1) // auth mode: token as.StubOne("def456") // auth token as.StubOne("HTTPS") // git_protocol + as.StubOne(false) // cache credentials }, wantErrOut: regexp.MustCompile("Tip: you can generate a Personal Access Token here https://github.com/settings/tokens"), }, @@ -426,18 +476,18 @@ func Test_loginRun_Survey(t *testing.T) { t.Run(tt.name, func(t *testing.T) { reg := &httpmock.Registry{} - origClientFromCfg := client.ClientFromCfg + origClientFromCfg := shared.ClientFromCfg defer func() { - client.ClientFromCfg = origClientFromCfg + shared.ClientFromCfg = origClientFromCfg }() - client.ClientFromCfg = func(_ string, _ config.Config) (*api.Client, error) { + shared.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.REST("GET", ""), httpmock.ScopesResponder("repo,read:org,read:public_key")) reg.Register( httpmock.GraphQL(`query UserCurrent\b`), httpmock.StringResponse(`{"data":{"viewer":{"login":"jillv"}}}`)) diff --git a/pkg/cmd/auth/logout/logout.go b/pkg/cmd/auth/logout/logout.go index 699de3968..1454a3656 100644 --- a/pkg/cmd/auth/logout/logout.go +++ b/pkg/cmd/auth/logout/logout.go @@ -105,6 +105,12 @@ func logoutRun(opts *LogoutOptions) error { } if err := cfg.CheckWriteable(hostname, "oauth_token"); err != nil { + var roErr *config.ReadOnlyEnvError + if errors.As(err, &roErr) { + fmt.Fprintf(opts.IO.ErrOut, "The value of the %s environment variable is being used for authentication.\n", roErr.Variable) + fmt.Fprint(opts.IO.ErrOut, "To erase credentials stored in GitHub CLI, first clear the value from the environment.\n") + return cmdutil.SilentError + } return err } diff --git a/pkg/cmd/auth/logout/logout_test.go b/pkg/cmd/auth/logout/logout_test.go index a9d14cc91..03b439ac0 100644 --- a/pkg/cmd/auth/logout/logout_test.go +++ b/pkg/cmd/auth/logout/logout_test.go @@ -98,7 +98,7 @@ func Test_logoutRun_tty(t *testing.T) { cfgHosts []string wantHosts string wantErrOut *regexp.Regexp - wantErr *regexp.Regexp + wantErr string }{ { name: "no arguments, multiple hosts", @@ -123,7 +123,7 @@ func Test_logoutRun_tty(t *testing.T) { { name: "no arguments, no hosts", opts: &LogoutOptions{}, - wantErr: regexp.MustCompile(`not logged in to any hosts`), + wantErr: `not logged in to any hosts`, }, { name: "hostname", @@ -176,14 +176,11 @@ func Test_logoutRun_tty(t *testing.T) { } 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.wantErr != "" { + assert.EqualError(t, err, tt.wantErr) + return + } else { + assert.NoError(t, err) } if tt.wantErrOut == nil { @@ -204,7 +201,7 @@ func Test_logoutRun_nontty(t *testing.T) { opts *LogoutOptions cfgHosts []string wantHosts string - wantErr *regexp.Regexp + wantErr string ghtoken string }{ { @@ -227,7 +224,7 @@ func Test_logoutRun_nontty(t *testing.T) { opts: &LogoutOptions{ Hostname: "harry.mason", }, - wantErr: regexp.MustCompile(`not logged in to any hosts`), + wantErr: `not logged in to any hosts`, }, } @@ -258,16 +255,10 @@ func Test_logoutRun_nontty(t *testing.T) { defer config.StubWriteConfig(&mainBuf, &hostsBuf)() err := logoutRun(tt.opts) - assert.Equal(t, tt.wantErr == nil, err == nil) - if err != nil { - if tt.wantErr != nil { - if !tt.wantErr.MatchString(err.Error()) { - t.Errorf("got error: %v", err) - } - return - } else { - t.Fatalf("unexpected error: %s", err) - } + if tt.wantErr != "" { + assert.EqualError(t, err, tt.wantErr) + } else { + assert.NoError(t, err) } assert.Equal(t, "", stderr.String()) diff --git a/pkg/cmd/auth/refresh/refresh.go b/pkg/cmd/auth/refresh/refresh.go index dd7862f3f..11673c58c 100644 --- a/pkg/cmd/auth/refresh/refresh.go +++ b/pkg/cmd/auth/refresh/refresh.go @@ -8,6 +8,7 @@ import ( "github.com/MakeNowJust/heredoc" "github.com/cli/cli/internal/authflow" "github.com/cli/cli/internal/config" + "github.com/cli/cli/pkg/cmd/auth/shared" "github.com/cli/cli/pkg/cmdutil" "github.com/cli/cli/pkg/iostreams" "github.com/cli/cli/pkg/prompt" @@ -21,6 +22,8 @@ type RefreshOptions struct { Hostname string Scopes []string AuthFlow func(config.Config, *iostreams.IOStreams, string, []string) error + + Interactive bool } func NewCmdRefresh(f *cmdutil.Factory, runF func(*RefreshOptions) error) *cobra.Command { @@ -50,21 +53,15 @@ func NewCmdRefresh(f *cmdutil.Factory, runF func(*RefreshOptions) error) *cobra. # => open a browser to ensure your authentication credentials have the correct minimum scopes `), RunE: func(cmd *cobra.Command, args []string) error { - isTTY := opts.IO.IsStdinTTY() && opts.IO.IsStdoutTTY() + opts.Interactive = opts.IO.CanPrompt() - if !isTTY { - return fmt.Errorf("not attached to a terminal; in headless environments, GITHUB_TOKEN is recommended") - } - - if opts.Hostname == "" && !opts.IO.CanPrompt() { - // here, we know we are attached to a TTY but prompts are disabled + if !opts.Interactive && opts.Hostname == "" { return &cmdutil.FlagError{Err: errors.New("--hostname required when not running interactively")} } if runF != nil { return runF(opts) } - return refreshRun(opts) }, } @@ -115,8 +112,26 @@ func refreshRun(opts *RefreshOptions) error { } if err := cfg.CheckWriteable(hostname, "oauth_token"); err != nil { + var roErr *config.ReadOnlyEnvError + if errors.As(err, &roErr) { + fmt.Fprintf(opts.IO.ErrOut, "The value of the %s environment variable is being used for authentication.\n", roErr.Variable) + fmt.Fprint(opts.IO.ErrOut, "To refresh credentials stored in GitHub CLI, first clear the value from the environment.\n") + return cmdutil.SilentError + } return err } - return opts.AuthFlow(cfg, opts.IO, hostname, opts.Scopes) + if err := opts.AuthFlow(cfg, opts.IO, hostname, opts.Scopes); err != nil { + return err + } + + protocol, _ := cfg.Get(hostname, "git_protocol") + if opts.Interactive && protocol == "https" { + username, _ := cfg.Get(hostname, "user") + if err := shared.GitCredentialSetup(cfg, hostname, username); err != nil { + return err + } + } + + return nil } diff --git a/pkg/cmd/auth/refresh/refresh_test.go b/pkg/cmd/auth/refresh/refresh_test.go index 065dd3fa2..e11592e23 100644 --- a/pkg/cmd/auth/refresh/refresh_test.go +++ b/pkg/cmd/auth/refresh/refresh_test.go @@ -2,7 +2,6 @@ package refresh import ( "bytes" - "regexp" "testing" "github.com/cli/cli/internal/config" @@ -37,9 +36,11 @@ func Test_NewCmdRefresh(t *testing.T) { wantsErr: true, }, { - name: "nontty hostname", - cli: "-h aline.cedrac", - wantsErr: true, + name: "nontty hostname", + cli: "-h aline.cedrac", + wants: RefreshOptions{ + Hostname: "aline.cedrac", + }, }, { name: "tty hostname", @@ -132,14 +133,14 @@ func Test_refreshRun(t *testing.T) { opts *RefreshOptions askStubs func(*prompt.AskStubber) cfgHosts []string - wantErr *regexp.Regexp + wantErr string nontty bool wantAuthArgs authArgs }{ { name: "no hosts configured", opts: &RefreshOptions{}, - wantErr: regexp.MustCompile(`not logged in to any hosts`), + wantErr: `not logged in to any hosts`, }, { name: "hostname given but dne", @@ -150,7 +151,7 @@ func Test_refreshRun(t *testing.T) { opts: &RefreshOptions{ Hostname: "obed.morton", }, - wantErr: regexp.MustCompile(`not logged in to obed.morton`), + wantErr: `not logged in to obed.morton`, }, { name: "hostname provided and is configured", @@ -248,14 +249,12 @@ func Test_refreshRun(t *testing.T) { } 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) + if tt.wantErr != "" { + if assert.Error(t, err) { + assert.Contains(t, err.Error(), tt.wantErr) } + } else { + assert.NoError(t, err) } assert.Equal(t, aa.hostname, tt.wantAuthArgs.hostname) diff --git a/pkg/cmd/auth/client/client.go b/pkg/cmd/auth/shared/client.go similarity index 71% rename from pkg/cmd/auth/client/client.go rename to pkg/cmd/auth/shared/client.go index bacde0b82..87acb0a71 100644 --- a/pkg/cmd/auth/client/client.go +++ b/pkg/cmd/auth/shared/client.go @@ -1,4 +1,4 @@ -package client +package shared import ( "fmt" @@ -8,20 +8,6 @@ import ( "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 diff --git a/pkg/cmd/auth/shared/git_credential.go b/pkg/cmd/auth/shared/git_credential.go new file mode 100644 index 000000000..bc0b3de1e --- /dev/null +++ b/pkg/cmd/auth/shared/git_credential.go @@ -0,0 +1,110 @@ +package shared + +import ( + "bytes" + "fmt" + "path/filepath" + "strings" + + "github.com/AlecAivazis/survey/v2" + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/git" + "github.com/cli/cli/internal/run" + "github.com/cli/cli/pkg/prompt" + "github.com/google/shlex" +) + +type configReader interface { + Get(string, string) (string, error) +} + +func GitCredentialSetup(cfg configReader, hostname, username string) error { + helper, _ := gitCredentialHelper(hostname) + if isOurCredentialHelper(helper) { + return nil + } + + var primeCredentials bool + err := prompt.SurveyAskOne(&survey.Confirm{ + Message: "Authenticate Git with your GitHub credentials?", + Default: true, + }, &primeCredentials) + if err != nil { + return fmt.Errorf("could not prompt: %w", err) + } + + if !primeCredentials { + return nil + } + + if helper == "" { + // use GitHub CLI as a credential helper (for this host only) + configureCmd, err := git.GitCommand("config", "--global", gitCredentialHelperKey(hostname), "!gh auth git-credential") + if err != nil { + return err + } + return run.PrepareCmd(configureCmd).Run() + } + + // clear previous cached credentials + rejectCmd, err := git.GitCommand("credential", "reject") + if err != nil { + return err + } + + rejectCmd.Stdin = bytes.NewBufferString(heredoc.Docf(` + protocol=https + host=%s + `, hostname)) + + err = run.PrepareCmd(rejectCmd).Run() + if err != nil { + return err + } + + approveCmd, err := git.GitCommand("credential", "approve") + if err != nil { + return err + } + + password, _ := cfg.Get(hostname, "oauth_token") + approveCmd.Stdin = bytes.NewBufferString(heredoc.Docf(` + protocol=https + host=%s + username=%s + password=%s + `, hostname, username, password)) + + err = run.PrepareCmd(approveCmd).Run() + if err != nil { + return err + } + + return nil +} + +func gitCredentialHelperKey(hostname string) string { + return fmt.Sprintf("credential.https://%s.helper", hostname) +} + +func gitCredentialHelper(hostname string) (helper string, err error) { + helper, err = git.Config(gitCredentialHelperKey(hostname)) + if helper != "" { + return + } + helper, err = git.Config("credential.helper") + return +} + +func isOurCredentialHelper(cmd string) bool { + if !strings.HasPrefix(cmd, "!") { + return false + } + + args, err := shlex.Split(cmd[1:]) + if err != nil || len(args) == 0 { + return false + } + + return strings.TrimSuffix(filepath.Base(args[0]), ".exe") == "gh" +} diff --git a/pkg/cmd/auth/shared/git_credential_test.go b/pkg/cmd/auth/shared/git_credential_test.go new file mode 100644 index 000000000..a8d2fe408 --- /dev/null +++ b/pkg/cmd/auth/shared/git_credential_test.go @@ -0,0 +1,88 @@ +package shared + +import ( + "fmt" + "testing" + + "github.com/cli/cli/internal/run" + "github.com/cli/cli/pkg/prompt" +) + +type tinyConfig map[string]string + +func (c tinyConfig) Get(host, key string) (string, error) { + return c[fmt.Sprintf("%s:%s", host, key)], nil +} + +func TestGitCredentialSetup_configureExisting(t *testing.T) { + cfg := tinyConfig{"example.com:oauth_token": "OTOKEN"} + + cs, restoreRun := run.Stub() + defer restoreRun(t) + cs.Register(`git config credential\.https://example\.com\.helper`, 1, "") + cs.Register(`git config credential\.helper`, 0, "osxkeychain\n") + cs.Register(`git credential reject`, 0, "") + cs.Register(`git credential approve`, 0, "") + + as, restoreAsk := prompt.InitAskStubber() + defer restoreAsk() + as.StubOne(true) + + if err := GitCredentialSetup(cfg, "example.com", "monalisa"); err != nil { + t.Errorf("GitCredentialSetup() error = %v", err) + } +} + +func TestGitCredentialSetup_setOurs(t *testing.T) { + cfg := tinyConfig{"example.com:oauth_token": "OTOKEN"} + + cs, restoreRun := run.Stub() + defer restoreRun(t) + cs.Register(`git config credential\.https://example\.com\.helper`, 1, "") + cs.Register(`git config credential\.helper`, 1, "") + cs.Register(`git config --global credential\.https://example\.com\.helper`, 0, "", func(args []string) { + if val := args[len(args)-1]; val != "!gh auth git-credential" { + t.Errorf("global credential helper configured to %q", val) + } + }) + + as, restoreAsk := prompt.InitAskStubber() + defer restoreAsk() + as.StubOne(true) + + if err := GitCredentialSetup(cfg, "example.com", "monalisa"); err != nil { + t.Errorf("GitCredentialSetup() error = %v", err) + } +} + +func TestGitCredentialSetup_promptDeny(t *testing.T) { + cfg := tinyConfig{"example.com:oauth_token": "OTOKEN"} + + cs, restoreRun := run.Stub() + defer restoreRun(t) + cs.Register(`git config credential\.https://example\.com\.helper`, 1, "") + cs.Register(`git config credential\.helper`, 1, "") + + as, restoreAsk := prompt.InitAskStubber() + defer restoreAsk() + as.StubOne(false) + + if err := GitCredentialSetup(cfg, "example.com", "monalisa"); err != nil { + t.Errorf("GitCredentialSetup() error = %v", err) + } +} + +func TestGitCredentialSetup_isOurs(t *testing.T) { + cfg := tinyConfig{"example.com:oauth_token": "OTOKEN"} + + cs, restoreRun := run.Stub() + defer restoreRun(t) + cs.Register(`git config credential\.https://example\.com\.helper`, 0, "!/path/to/gh auth\n") + + _, restoreAsk := prompt.InitAskStubber() + defer restoreAsk() + + if err := GitCredentialSetup(cfg, "example.com", "monalisa"); err != nil { + t.Errorf("GitCredentialSetup() error = %v", err) + } +} diff --git a/pkg/cmd/auth/status/status_test.go b/pkg/cmd/auth/status/status_test.go index 0de14d388..4eb37bd36 100644 --- a/pkg/cmd/auth/status/status_test.go +++ b/pkg/cmd/auth/status/status_test.go @@ -8,7 +8,7 @@ import ( "github.com/cli/cli/api" "github.com/cli/cli/internal/config" - "github.com/cli/cli/pkg/cmd/auth/client" + "github.com/cli/cli/pkg/cmd/auth/shared" "github.com/cli/cli/pkg/cmdutil" "github.com/cli/cli/pkg/httpmock" "github.com/cli/cli/pkg/iostreams" @@ -78,7 +78,7 @@ func Test_statusRun(t *testing.T) { opts *StatusOptions httpStubs func(*httpmock.Registry) cfg func(config.Config) - wantErr *regexp.Regexp + wantErr string wantErrOut *regexp.Regexp }{ { @@ -91,7 +91,7 @@ func Test_statusRun(t *testing.T) { _ = 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", "api/v3/"), httpmock.ScopesResponder("repo,read:org")) reg.Register( httpmock.GraphQL(`query UserCurrent\b`), httpmock.StringResponse(`{"data":{"viewer":{"login":"tess"}}}`)) @@ -106,14 +106,14 @@ func Test_statusRun(t *testing.T) { _ = 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.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(``), + wantErr: "SilentError", }, { name: "bad token", @@ -124,13 +124,13 @@ func Test_statusRun(t *testing.T) { }, 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.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(``), + wantErr: "SilentError", }, { name: "all good", @@ -140,8 +140,8 @@ func Test_statusRun(t *testing.T) { _ = 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.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"}}}`)) @@ -159,8 +159,8 @@ func Test_statusRun(t *testing.T) { _ = c.Set("github.com", "oauth_token", "xyz456") }, 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.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"}}}`)) @@ -180,8 +180,8 @@ func Test_statusRun(t *testing.T) { _ = c.Set("github.com", "oauth_token", "xyz456") }, 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.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"}}}`)) @@ -217,11 +217,11 @@ func Test_statusRun(t *testing.T) { } reg := &httpmock.Registry{} - origClientFromCfg := client.ClientFromCfg + origClientFromCfg := shared.ClientFromCfg defer func() { - client.ClientFromCfg = origClientFromCfg + shared.ClientFromCfg = origClientFromCfg }() - client.ClientFromCfg = func(_ string, _ config.Config) (*api.Client, error) { + shared.ClientFromCfg = func(_ string, _ config.Config) (*api.Client, error) { httpClient := &http.Client{Transport: reg} return api.NewClientFromHTTP(httpClient), nil } @@ -236,14 +236,11 @@ func Test_statusRun(t *testing.T) { 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.wantErr != "" { + assert.EqualError(t, err, tt.wantErr) + return + } else { + assert.NoError(t, err) } if tt.wantErrOut == nil { diff --git a/pkg/cmd/completion/completion.go b/pkg/cmd/completion/completion.go index 3d3699b5d..24414a5fc 100644 --- a/pkg/cmd/completion/completion.go +++ b/pkg/cmd/completion/completion.go @@ -14,22 +14,44 @@ func NewCmdCompletion(io *iostreams.IOStreams) *cobra.Command { var shellType string cmd := &cobra.Command{ - Use: "completion", + Use: "completion -s ", Short: "Generate shell completion scripts", - Long: heredoc.Doc(` + Long: heredoc.Docf(` 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 + When installing GitHub CLI through a package manager, it's possible that no additional shell configuration is necessary to gain completion support. For Homebrew, see https://docs.brew.sh/Shell-Completion - `), + + If you need to set up completions manually, follow the instructions below. The exact + config file locations might vary based on your system. Make sure to restart your + shell before testing whether completions are working. + + ### bash + + Add this to your %[1]s~/.bash_profile%[1]s: + + eval "$(gh completion -s bash)" + + ### zsh + + Generate a %[1]s_gh%[1]s completion script and put it somewhere in your %[1]s$fpath%[1]s: + + gh completion -s zsh > /usr/local/share/zsh/site-functions/_gh + + Ensure that the following is present in your %[1]s~/.zshrc%[1]s: + + autoload -U compinit + compinit -i + + Zsh version 5.7 or later is recommended. + + ### fish + + Generate a %[1]sgh.fish%[1]s completion script: + + gh completion -s fish > ~/.config/fish/completions/gh.fish + `, "`"), RunE: func(cmd *cobra.Command, args []string) error { if shellType == "" { if io.IsStdoutTTY() { @@ -54,6 +76,7 @@ func NewCmdCompletion(io *iostreams.IOStreams) *cobra.Command { return fmt.Errorf("unsupported shell type %q", shellType) } }, + DisableFlagsInUseLine: true, } cmdutil.DisableAuthCheck(cmd) diff --git a/pkg/cmd/factory/default.go b/pkg/cmd/factory/default.go index ae8622b2a..2c70ab5cd 100644 --- a/pkg/cmd/factory/default.go +++ b/pkg/cmd/factory/default.go @@ -5,9 +5,11 @@ import ( "fmt" "net/http" "os" + "strings" "github.com/cli/cli/git" "github.com/cli/cli/internal/config" + "github.com/cli/cli/internal/ghinstance" "github.com/cli/cli/internal/ghrepo" "github.com/cli/cli/pkg/cmdutil" "github.com/cli/cli/pkg/iostreams" @@ -31,11 +33,16 @@ func New(appVersion string) *cmdutil.Factory { return cachedConfig, configError } + hostOverride := "" + if !strings.EqualFold(ghinstance.Default(), ghinstance.OverridableDefault()) { + hostOverride = ghinstance.OverridableDefault() + } + rr := &remoteResolver{ readRemotes: git.Remotes, getConfig: configFunc, } - remotesFunc := rr.Resolver() + remotesFunc := rr.Resolver(hostOverride) return &cmdutil.Factory{ IOStreams: io, diff --git a/pkg/cmd/factory/http.go b/pkg/cmd/factory/http.go index 47fbbefe8..8cd1bf12a 100644 --- a/pkg/cmd/factory/http.go +++ b/pkg/cmd/factory/http.go @@ -5,6 +5,7 @@ import ( "net/http" "os" "strings" + "time" "github.com/cli/cli/api" "github.com/cli/cli/internal/config" @@ -12,6 +13,46 @@ import ( "github.com/cli/cli/pkg/iostreams" ) +var timezoneNames = map[int]string{ + -39600: "Pacific/Niue", + -36000: "Pacific/Honolulu", + -34200: "Pacific/Marquesas", + -32400: "America/Anchorage", + -28800: "America/Los_Angeles", + -25200: "America/Chihuahua", + -21600: "America/Chicago", + -18000: "America/Bogota", + -14400: "America/Caracas", + -12600: "America/St_Johns", + -10800: "America/Argentina/Buenos_Aires", + -7200: "Atlantic/South_Georgia", + -3600: "Atlantic/Cape_Verde", + 0: "Europe/London", + 3600: "Europe/Amsterdam", + 7200: "Europe/Athens", + 10800: "Europe/Istanbul", + 12600: "Asia/Tehran", + 14400: "Asia/Dubai", + 16200: "Asia/Kabul", + 18000: "Asia/Tashkent", + 19800: "Asia/Kolkata", + 20700: "Asia/Kathmandu", + 21600: "Asia/Dhaka", + 23400: "Asia/Rangoon", + 25200: "Asia/Bangkok", + 28800: "Asia/Manila", + 31500: "Australia/Eucla", + 32400: "Asia/Tokyo", + 34200: "Australia/Darwin", + 36000: "Australia/Brisbane", + 37800: "Australia/Adelaide", + 39600: "Pacific/Guadalcanal", + 43200: "Pacific/Nauru", + 46800: "Pacific/Auckland", + 49500: "Pacific/Chatham", + 50400: "Pacific/Kiritimati", +} + // generic authenticated HTTP client for commands func NewHTTPClient(io *iostreams.IOStreams, cfg config.Config, appVersion string, setAccept bool) *http.Client { var opts []api.ClientOption @@ -29,6 +70,16 @@ func NewHTTPClient(io *iostreams.IOStreams, cfg config.Config, appVersion string } return "", nil }), + api.AddHeaderFunc("Time-Zone", func(req *http.Request) (string, error) { + if req.Method != "GET" && req.Method != "HEAD" { + if time.Local.String() != "Local" { + return time.Local.String(), nil + } + _, offset := time.Now().Zone() + return timezoneNames[offset], nil + } + return "", nil + }), ) if setAccept { diff --git a/pkg/cmd/factory/remote_resolver.go b/pkg/cmd/factory/remote_resolver.go index 589b19959..ee603e49d 100644 --- a/pkg/cmd/factory/remote_resolver.go +++ b/pkg/cmd/factory/remote_resolver.go @@ -4,6 +4,7 @@ import ( "errors" "net/url" "sort" + "strings" "github.com/cli/cli/context" "github.com/cli/cli/git" @@ -17,7 +18,7 @@ type remoteResolver struct { urlTranslator func(*url.URL) *url.URL } -func (rr *remoteResolver) Resolver() func() (context.Remotes, error) { +func (rr *remoteResolver) Resolver(hostOverride string) func() (context.Remotes, error) { var cachedRemotes context.Remotes var remotesError error @@ -59,6 +60,22 @@ func (rr *remoteResolver) Resolver() func() (context.Remotes, error) { var hostname string cachedRemotes = context.Remotes{} sort.Sort(resolvedRemotes) + + if hostOverride != "" { + for _, r := range resolvedRemotes { + if strings.EqualFold(r.RepoHost(), hostOverride) { + cachedRemotes = append(cachedRemotes, r) + } + } + + if len(cachedRemotes) == 0 { + remotesError = errors.New("none of the git remotes configured for this repository correspond to the GH_HOST environment variable. Try adding a matching remote or unsetting the variable.") + return nil, remotesError + } + + return cachedRemotes, nil + } + for _, r := range resolvedRemotes { if hostname == "" { if !knownHosts[r.RepoHost()] { diff --git a/pkg/cmd/factory/remote_resolver_test.go b/pkg/cmd/factory/remote_resolver_test.go index d0337499c..27c937e36 100644 --- a/pkg/cmd/factory/remote_resolver_test.go +++ b/pkg/cmd/factory/remote_resolver_test.go @@ -32,7 +32,7 @@ func Test_remoteResolver(t *testing.T) { }, } - resolver := rr.Resolver() + resolver := rr.Resolver("") remotes, err := resolver() require.NoError(t, err) require.Equal(t, 2, len(remotes)) @@ -40,3 +40,32 @@ func Test_remoteResolver(t *testing.T) { assert.Equal(t, "upstream", remotes[0].Name) assert.Equal(t, "fork", remotes[1].Name) } + +func Test_remoteResolverOverride(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("github.com") + remotes, err := resolver() + require.NoError(t, err) + require.Equal(t, 1, len(remotes)) + + assert.Equal(t, "origin", remotes[0].Name) +} diff --git a/pkg/cmd/gist/clone/clone.go b/pkg/cmd/gist/clone/clone.go new file mode 100644 index 000000000..8bdc932ae --- /dev/null +++ b/pkg/cmd/gist/clone/clone.go @@ -0,0 +1,101 @@ +package clone + +import ( + "fmt" + "net/http" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/git" + "github.com/cli/cli/internal/config" + "github.com/cli/cli/internal/ghinstance" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/iostreams" + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +type CloneOptions struct { + HttpClient func() (*http.Client, error) + Config func() (config.Config, error) + IO *iostreams.IOStreams + + GitArgs []string + Directory string + Gist string +} + +func NewCmdClone(f *cmdutil.Factory, runF func(*CloneOptions) error) *cobra.Command { + opts := &CloneOptions{ + IO: f.IOStreams, + HttpClient: f.HttpClient, + Config: f.Config, + } + + cmd := &cobra.Command{ + DisableFlagsInUseLine: true, + + Use: "clone [] [-- ...]", + Args: cmdutil.MinimumArgs(1, "cannot clone: gist argument required"), + Short: "Clone a gist locally", + Long: heredoc.Doc(` + Clone a GitHub gist locally. + + A gist can be supplied as argument in either of the following formats: + - by ID, e.g. 5b0e0062eb8e9654adad7bb1d81cc75f + - by URL, e.g. "https://gist.github.com/OWNER/5b0e0062eb8e9654adad7bb1d81cc75f" + + Pass additional 'git clone' flags by listing them after '--'. + `), + RunE: func(cmd *cobra.Command, args []string) error { + opts.Gist = args[0] + opts.GitArgs = args[1:] + + if runF != nil { + return runF(opts) + } + + return cloneRun(opts) + }, + } + + cmd.SetFlagErrorFunc(func(cmd *cobra.Command, err error) error { + if err == pflag.ErrHelp { + return err + } + return &cmdutil.FlagError{Err: fmt.Errorf("%w\nSeparate git clone flags with '--'.", err)} + }) + + return cmd +} + +func cloneRun(opts *CloneOptions) error { + gistURL := opts.Gist + + if !git.IsURL(gistURL) { + cfg, err := opts.Config() + if err != nil { + return err + } + hostname := ghinstance.OverridableDefault() + protocol, err := cfg.Get(hostname, "git_protocol") + if err != nil { + return err + } + gistURL = formatRemoteURL(hostname, gistURL, protocol) + } + + _, err := git.RunClone(gistURL, opts.GitArgs) + if err != nil { + return err + } + + return nil +} + +func formatRemoteURL(hostname string, gistID string, protocol string) string { + if protocol == "ssh" { + return fmt.Sprintf("git@gist.%s:%s.git", hostname, gistID) + } + + return fmt.Sprintf("https://gist.%s/%s.git", hostname, gistID) +} diff --git a/pkg/cmd/gist/clone/clone_test.go b/pkg/cmd/gist/clone/clone_test.go new file mode 100644 index 000000000..8faf3d40c --- /dev/null +++ b/pkg/cmd/gist/clone/clone_test.go @@ -0,0 +1,118 @@ +package clone + +import ( + "net/http" + "strings" + "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/test" + "github.com/google/shlex" + "github.com/stretchr/testify/assert" +) + +func runCloneCommand(httpClient *http.Client, cli string) (*test.CmdOut, error) { + io, stdin, stdout, stderr := iostreams.Test() + fac := &cmdutil.Factory{ + IOStreams: io, + HttpClient: func() (*http.Client, error) { + return httpClient, nil + }, + Config: func() (config.Config, error) { + return config.NewBlankConfig(), nil + }, + } + + cmd := NewCmdClone(fac, nil) + + argv, err := shlex.Split(cli) + cmd.SetArgs(argv) + + cmd.SetIn(stdin) + cmd.SetOut(stdout) + cmd.SetErr(stderr) + + if err != nil { + panic(err) + } + + _, err = cmd.ExecuteC() + + if err != nil { + return nil, err + } + + return &test.CmdOut{OutBuf: stdout, ErrBuf: stderr}, nil +} + +func Test_GistClone(t *testing.T) { + tests := []struct { + name string + args string + want string + }{ + { + name: "shorthand", + args: "GIST", + want: "git clone https://gist.github.com/GIST.git", + }, + { + name: "shorthand with directory", + args: "GIST target_directory", + want: "git clone https://gist.github.com/GIST.git target_directory", + }, + { + name: "clone arguments", + args: "GIST -- -o upstream --depth 1", + want: "git clone -o upstream --depth 1 https://gist.github.com/GIST.git", + }, + { + name: "clone arguments with directory", + args: "GIST target_directory -- -o upstream --depth 1", + want: "git clone -o upstream --depth 1 https://gist.github.com/GIST.git target_directory", + }, + { + name: "HTTPS URL", + args: "https://gist.github.com/OWNER/GIST", + want: "git clone https://gist.github.com/OWNER/GIST", + }, + { + name: "SSH URL", + args: "git@gist.github.com:GIST.git", + want: "git clone git@gist.github.com:GIST.git", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reg := &httpmock.Registry{} + + httpClient := &http.Client{Transport: reg} + + cs, restore := test.InitCmdStubber() + defer restore() + + cs.Stub("") // git clone + + output, err := runCloneCommand(httpClient, tt.args) + if err != nil { + t.Fatalf("error running command `gist clone`: %v", err) + } + + assert.Equal(t, "", output.String()) + assert.Equal(t, "", output.Stderr()) + assert.Equal(t, 1, cs.Count) + assert.Equal(t, tt.want, strings.Join(cs.Calls[0].Args, " ")) + reg.Verify(t) + }) + } +} + +func Test_GistClone_flagError(t *testing.T) { + _, err := runCloneCommand(nil, "--depth 1 GIST") + if err == nil || err.Error() != "unknown flag: --depth\nSeparate git clone flags with '--'." { + t.Errorf("unexpected error %v", err) + } +} diff --git a/pkg/cmd/gist/create/create.go b/pkg/cmd/gist/create/create.go index dc7d4b257..dd46b6edd 100644 --- a/pkg/cmd/gist/create/create.go +++ b/pkg/cmd/gist/create/create.go @@ -19,6 +19,7 @@ import ( "github.com/cli/cli/pkg/cmd/gist/shared" "github.com/cli/cli/pkg/cmdutil" "github.com/cli/cli/pkg/iostreams" + "github.com/cli/cli/utils" "github.com/spf13/cobra" ) @@ -30,6 +31,8 @@ type CreateOptions struct { Filenames []string FilenameOverride string + WebMode bool + HttpClient func() (*http.Client, error) } @@ -86,6 +89,7 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co } cmd.Flags().StringVarP(&opts.Description, "desc", "d", "", "A description for this gist") + cmd.Flags().BoolVarP(&opts.WebMode, "web", "w", false, "Open the web browser with created gist") cmd.Flags().BoolVarP(&opts.Public, "public", "p", false, "List the gist publicly (default: private)") cmd.Flags().StringVarP(&opts.FilenameOverride, "filename", "f", "", "Provide a filename to be used when reading from STDIN") return cmd @@ -138,6 +142,12 @@ func createRun(opts *CreateOptions) error { fmt.Fprintf(errOut, "%s %s\n", cs.SuccessIcon(), completionMessage) + if opts.WebMode { + fmt.Fprintf(opts.IO.Out, "Opening %s in your browser.\n", utils.DisplayURL(gist.HTMLURL)) + + return utils.OpenInBrowser(gist.HTMLURL) + } + fmt.Fprintln(opts.IO.Out, gist.HTMLURL) return nil @@ -217,8 +227,8 @@ func createGist(client *http.Client, hostname, description string, public bool, } requestBody := bytes.NewReader(requestByte) - apliClient := api.NewClientFromHTTP(client) - err = apliClient.REST(hostname, "POST", path, requestBody, &result) + apiClient := api.NewClientFromHTTP(client) + err = apiClient.REST(hostname, "POST", path, requestBody, &result) if err != nil { return nil, err } diff --git a/pkg/cmd/gist/create/create_test.go b/pkg/cmd/gist/create/create_test.go index dd1912909..548abd743 100644 --- a/pkg/cmd/gist/create/create_test.go +++ b/pkg/cmd/gist/create/create_test.go @@ -3,6 +3,7 @@ package create import ( "bytes" "encoding/json" + "github.com/cli/cli/test" "io/ioutil" "net/http" "strings" @@ -254,6 +255,26 @@ func Test_createRun(t *testing.T) { }, }, }, + { + name: "web arg", + opts: &CreateOptions{ + WebMode: true, + Filenames: []string{fixtureFile}, + }, + wantOut: "Opening gist.github.com/aa5a315d61ae9438b18d in your browser.\n", + wantStderr: "- Creating gist fixture.txt\n✓ Created gist fixture.txt\n", + wantErr: false, + wantParams: map[string]interface{}{ + "description": "", + "updated_at": "0001-01-01T00:00:00Z", + "public": false, + "files": map[string]interface{}{ + "fixture.txt": map[string]interface{}{ + "content": "{}", + }, + }, + }, + }, } for _, tt := range tests { reg := &httpmock.Registry{} @@ -270,6 +291,13 @@ func Test_createRun(t *testing.T) { io, stdin, stdout, stderr := iostreams.Test() tt.opts.IO = io + cs, cmdTeardown := test.InitCmdStubber() + defer cmdTeardown() + + if tt.opts.WebMode { + cs.Stub("") + } + t.Run(tt.name, func(t *testing.T) { stdin.WriteString(tt.stdin) @@ -285,6 +313,12 @@ func Test_createRun(t *testing.T) { assert.Equal(t, tt.wantOut, stdout.String()) assert.Equal(t, tt.wantStderr, stderr.String()) assert.Equal(t, tt.wantParams, reqBody) + + if tt.opts.WebMode { + browserCall := cs.Calls[0].Args + assert.Equal(t, browserCall[len(browserCall)-1], "https://gist.github.com/aa5a315d61ae9438b18d") + } + reg.Verify(t) }) } diff --git a/pkg/cmd/gist/delete/delete.go b/pkg/cmd/gist/delete/delete.go new file mode 100644 index 000000000..03ddc6e46 --- /dev/null +++ b/pkg/cmd/gist/delete/delete.go @@ -0,0 +1,88 @@ +package delete + +import ( + "fmt" + "github.com/cli/cli/api" + "github.com/cli/cli/internal/ghinstance" + "github.com/cli/cli/pkg/cmd/gist/shared" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/iostreams" + "github.com/spf13/cobra" + "net/http" + "strings" +) + +type DeleteOptions struct { + IO *iostreams.IOStreams + HttpClient func() (*http.Client, error) + + Selector string +} + +func NewCmdDelete(f *cmdutil.Factory, runF func(*DeleteOptions) error) *cobra.Command { + opts := DeleteOptions{ + IO: f.IOStreams, + HttpClient: f.HttpClient, + } + + cmd := &cobra.Command{ + Use: "delete { | }", + Short: "Delete a gist", + Args: cobra.ExactArgs(1), + RunE: func(c *cobra.Command, args []string) error { + opts.Selector = args[0] + if runF != nil { + return runF(&opts) + } + return deleteRun(&opts) + }, + } + return cmd +} + +func deleteRun(opts *DeleteOptions) error { + gistID := opts.Selector + + if strings.Contains(gistID, "/") { + id, err := shared.GistIDFromURL(gistID) + if err != nil { + return err + } + gistID = id + } + client, err := opts.HttpClient() + if err != nil { + return err + } + + apiClient := api.NewClientFromHTTP(client) + + gist, err := shared.GetGist(client, ghinstance.OverridableDefault(), gistID) + if err != nil { + return err + } + username, err := api.CurrentLoginName(apiClient, ghinstance.OverridableDefault()) + if err != nil { + return err + } + + if username != gist.Owner.Login { + return fmt.Errorf("You do not own this gist.") + } + + err = deleteGist(apiClient, ghinstance.OverridableDefault(), gistID) + if err != nil { + return err + } + + return nil +} + +func deleteGist(apiClient *api.Client, hostname string, gistID string) error { + path := "gists/" + gistID + err := apiClient.REST(hostname, "DELETE", path, nil, nil) + if err != nil { + return err + } + return nil +} diff --git a/pkg/cmd/gist/delete/delete_test.go b/pkg/cmd/gist/delete/delete_test.go new file mode 100644 index 000000000..21480803c --- /dev/null +++ b/pkg/cmd/gist/delete/delete_test.go @@ -0,0 +1,157 @@ +package delete + +import ( + "bytes" + "net/http" + "testing" + + "github.com/cli/cli/pkg/cmd/gist/shared" + "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 TestNewCmdDelete(t *testing.T) { + tests := []struct { + name string + cli string + wants DeleteOptions + }{ + { + name: "valid selector", + cli: "123", + wants: DeleteOptions{ + Selector: "123", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f := &cmdutil.Factory{} + + argv, err := shlex.Split(tt.cli) + assert.NoError(t, err) + var gotOpts *DeleteOptions + cmd := NewCmdDelete(f, func(opts *DeleteOptions) error { + gotOpts = opts + return nil + }) + + 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.Selector, gotOpts.Selector) + }) + } +} + +func Test_deleteRun(t *testing.T) { + tests := []struct { + name string + opts *DeleteOptions + gist *shared.Gist + httpStubs func(*httpmock.Registry) + askStubs func(*prompt.AskStubber) + nontty bool + wantErr bool + wantStderr string + wantParams map[string]interface{} + }{ + { + name: "no such gist", + wantErr: true, + }, { + name: "another user's gist", + gist: &shared.Gist{ + ID: "1234", + Files: map[string]*shared.GistFile{ + "cicada.txt": { + Filename: "cicada.txt", + Content: "bwhiizzzbwhuiiizzzz", + Type: "text/plain", + }, + }, + Owner: &shared.GistOwner{Login: "octocat2"}, + }, + wantErr: true, + wantStderr: "You do not own this gist.", + }, { + name: "successfully delete", + gist: &shared.Gist{ + ID: "1234", + Files: map[string]*shared.GistFile{ + "cicada.txt": { + Filename: "cicada.txt", + Content: "bwhiizzzbwhuiiizzzz", + Type: "text/plain", + }, + }, + Owner: &shared.GistOwner{Login: "octocat"}, + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register(httpmock.REST("DELETE", "gists/1234"), + httpmock.StringResponse("{}")) + }, + wantErr: false, + }, + } + + for _, tt := range tests { + reg := &httpmock.Registry{} + if tt.gist == nil { + reg.Register(httpmock.REST("GET", "gists/1234"), + httpmock.StatusStringResponse(404, "Not Found")) + } else { + reg.Register(httpmock.REST("GET", "gists/1234"), + httpmock.JSONResponse(tt.gist)) + reg.Register(httpmock.GraphQL(`query UserCurrent\b`), + httpmock.StringResponse(`{"data":{"viewer":{"login":"octocat"}}}`)) + } + + if tt.httpStubs != nil { + tt.httpStubs(reg) + } + + as, teardown := prompt.InitAskStubber() + defer teardown() + if tt.askStubs != nil { + tt.askStubs(as) + } + + if tt.opts == nil { + tt.opts = &DeleteOptions{} + } + + tt.opts.HttpClient = func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + } + io, _, _, _ := iostreams.Test() + io.SetStdoutTTY(!tt.nontty) + io.SetStdinTTY(!tt.nontty) + tt.opts.IO = io + tt.opts.Selector = "1234" + + t.Run(tt.name, func(t *testing.T) { + err := deleteRun(tt.opts) + reg.Verify(t) + if tt.wantErr { + assert.Error(t, err) + if tt.wantStderr != "" { + assert.EqualError(t, err, tt.wantStderr) + } + return + } + assert.NoError(t, err) + + }) + } +} diff --git a/pkg/cmd/gist/gist.go b/pkg/cmd/gist/gist.go index 9a4d42b87..df7e0f575 100644 --- a/pkg/cmd/gist/gist.go +++ b/pkg/cmd/gist/gist.go @@ -2,7 +2,9 @@ package gist import ( "github.com/MakeNowJust/heredoc" + gistCloneCmd "github.com/cli/cli/pkg/cmd/gist/clone" gistCreateCmd "github.com/cli/cli/pkg/cmd/gist/create" + gistDeleteCmd "github.com/cli/cli/pkg/cmd/gist/delete" gistEditCmd "github.com/cli/cli/pkg/cmd/gist/edit" gistListCmd "github.com/cli/cli/pkg/cmd/gist/list" gistViewCmd "github.com/cli/cli/pkg/cmd/gist/view" @@ -25,10 +27,12 @@ func NewCmdGist(f *cmdutil.Factory) *cobra.Command { }, } + cmd.AddCommand(gistCloneCmd.NewCmdClone(f, nil)) cmd.AddCommand(gistCreateCmd.NewCmdCreate(f, nil)) cmd.AddCommand(gistListCmd.NewCmdList(f, nil)) cmd.AddCommand(gistViewCmd.NewCmdView(f, nil)) cmd.AddCommand(gistEditCmd.NewCmdEdit(f, nil)) + cmd.AddCommand(gistDeleteCmd.NewCmdDelete(f, nil)) return cmd } diff --git a/pkg/cmd/issue/close/close_test.go b/pkg/cmd/issue/close/close_test.go index eadf7f440..5a2054309 100644 --- a/pkg/cmd/issue/close/close_test.go +++ b/pkg/cmd/issue/close/close_test.go @@ -14,6 +14,7 @@ import ( "github.com/cli/cli/pkg/iostreams" "github.com/cli/cli/test" "github.com/google/shlex" + "github.com/stretchr/testify/assert" ) func runCommand(rt http.RoundTripper, isTTY bool, cli string) (*test.CmdOut, error) { @@ -58,14 +59,21 @@ 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"}`)) + http.Register( + httpmock.GraphQL(`query IssueByNumber\b`), + httpmock.StringResponse(` + { "data": { "repository": { + "hasIssuesEnabled": true, + "issue": { "id": "THE-ID", "number": 13, "title": "The title of the issue"} + } } }`), + ) + http.Register( + httpmock.GraphQL(`mutation IssueClose\b`), + httpmock.GraphQLMutation(`{"id": "THE-ID"}`, + func(inputs map[string]interface{}) { + assert.Equal(t, inputs["issueId"], "THE-ID") + }), + ) output, err := runCommand(http, true, "13") if err != nil { @@ -83,12 +91,14 @@ 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} - } } } - `)) + http.Register( + httpmock.GraphQL(`query IssueByNumber\b`), + httpmock.StringResponse(` + { "data": { "repository": { + "hasIssuesEnabled": true, + "issue": { "number": 13, "title": "The title of the issue", "closed": true} + } } }`), + ) output, err := runCommand(http, true, "13") if err != nil { @@ -106,11 +116,13 @@ func TestIssueClose_issuesDisabled(t *testing.T) { http := &httpmock.Registry{} defer http.Verify(t) - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { - "hasIssuesEnabled": false - } } } - `)) + http.Register( + httpmock.GraphQL(`query IssueByNumber\b`), + httpmock.StringResponse(` + { "data": { "repository": { + "hasIssuesEnabled": false + } } }`), + ) _, err := runCommand(http, true, "13") if err == nil || err.Error() != "the 'OWNER/REPO' repository has disabled issues" { diff --git a/pkg/cmd/issue/comment/comment.go b/pkg/cmd/issue/comment/comment.go new file mode 100644 index 000000000..3c6be4812 --- /dev/null +++ b/pkg/cmd/issue/comment/comment.go @@ -0,0 +1,200 @@ +package comment + +import ( + "errors" + "fmt" + "net/http" + + "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/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/pkg/surveyext" + "github.com/cli/cli/utils" + "github.com/spf13/cobra" +) + +type CommentOptions struct { + HttpClient func() (*http.Client, error) + IO *iostreams.IOStreams + BaseRepo func() (ghrepo.Interface, error) + EditSurvey func() (string, error) + InputTypeSurvey func() (inputType, error) + ConfirmSubmitSurvey func() (bool, error) + OpenInBrowser func(string) error + + SelectorArg string + Interactive bool + InputType inputType + Body string +} + +type inputType int + +const ( + inputTypeEditor inputType = iota + inputTypeInline + inputTypeWeb +) + +func NewCmdComment(f *cmdutil.Factory, runF func(*CommentOptions) error) *cobra.Command { + opts := &CommentOptions{ + IO: f.IOStreams, + HttpClient: f.HttpClient, + EditSurvey: editSurvey(f.Config, f.IOStreams), + InputTypeSurvey: inputTypeSurvey, + ConfirmSubmitSurvey: confirmSubmitSurvey, + OpenInBrowser: utils.OpenInBrowser, + } + + var webMode bool + var editorMode bool + + cmd := &cobra.Command{ + Use: "comment { | }", + Short: "Create a new issue comment", + Example: heredoc.Doc(` + $ gh issue comment 22 --body "I was able to reproduce this issue, lets fix it." + `), + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + // support `-R, --repo` override + opts.BaseRepo = f.BaseRepo + opts.SelectorArg = args[0] + + inputFlags := 0 + if cmd.Flags().Changed("body") { + opts.InputType = inputTypeInline + inputFlags++ + } + if webMode { + opts.InputType = inputTypeWeb + inputFlags++ + } + if editorMode { + opts.InputType = inputTypeEditor + inputFlags++ + } + + if inputFlags == 0 { + if !opts.IO.CanPrompt() { + return &cmdutil.FlagError{Err: errors.New("--body or --web required when not running interactively")} + } + opts.Interactive = true + } else if inputFlags == 1 { + if !opts.IO.CanPrompt() && opts.InputType == inputTypeEditor { + return &cmdutil.FlagError{Err: errors.New("--body or --web required when not running interactively")} + } + } else if inputFlags > 1 { + return &cmdutil.FlagError{Err: fmt.Errorf("specify only one of --body, --editor, or --web")} + } + + if runF != nil { + return runF(opts) + } + return commentRun(opts) + }, + } + + cmd.Flags().StringVarP(&opts.Body, "body", "b", "", "Supply a body. Will prompt for one otherwise.") + cmd.Flags().BoolVarP(&editorMode, "editor", "e", false, "Add body using editor") + cmd.Flags().BoolVarP(&webMode, "web", "w", false, "Add body in browser") + + return cmd +} + +func commentRun(opts *CommentOptions) 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 opts.Interactive { + inputType, err := opts.InputTypeSurvey() + if err != nil { + return err + } + opts.InputType = inputType + } + + switch opts.InputType { + case inputTypeWeb: + openURL := issue.URL + "#issuecomment-new" + if opts.IO.IsStdoutTTY() { + fmt.Fprintf(opts.IO.ErrOut, "Opening %s in your browser.\n", utils.DisplayURL(openURL)) + } + return opts.OpenInBrowser(openURL) + case inputTypeEditor: + body, err := opts.EditSurvey() + if err != nil { + return err + } + opts.Body = body + } + + if opts.Interactive { + cont, err := opts.ConfirmSubmitSurvey() + if err != nil { + return err + } + if !cont { + return fmt.Errorf("Discarding...") + } + } + + params := api.CommentCreateInput{Body: opts.Body, SubjectId: issue.ID} + url, err := api.CommentCreate(apiClient, baseRepo.RepoHost(), params) + if err != nil { + return err + } + fmt.Fprintln(opts.IO.Out, url) + return nil +} + +var inputTypeSurvey = func() (inputType, error) { + var result int + inputTypeQuestion := &survey.Select{ + Message: "Where do you want to draft your comment?", + Options: []string{"Editor", "Web"}, + } + err := survey.AskOne(inputTypeQuestion, &result) + if err != nil { + return 0, err + } + + if result == 0 { + return inputTypeEditor, nil + } else { + return inputTypeWeb, nil + } +} + +var confirmSubmitSurvey = func() (bool, error) { + var confirm bool + submit := &survey.Confirm{ + Message: "Submit?", + Default: true, + } + err := survey.AskOne(submit, &confirm) + return confirm, err +} + +var editSurvey = func(cf func() (config.Config, error), io *iostreams.IOStreams) func() (string, error) { + return func() (string, error) { + editorCommand, err := cmdutil.DetermineEditor(cf) + if err != nil { + return "", err + } + return surveyext.Edit(editorCommand, "*.md", "", io.In, io.Out, io.ErrOut, nil) + } +} diff --git a/pkg/cmd/issue/comment/comment_test.go b/pkg/cmd/issue/comment/comment_test.go new file mode 100644 index 000000000..b10e20c54 --- /dev/null +++ b/pkg/cmd/issue/comment/comment_test.go @@ -0,0 +1,288 @@ +package comment + +import ( + "bytes" + "net/http" + "testing" + + "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/google/shlex" + "github.com/stretchr/testify/assert" +) + +func TestNewCmdComment(t *testing.T) { + tests := []struct { + name string + input string + output CommentOptions + wantsErr bool + }{ + { + name: "no arguments", + input: "", + output: CommentOptions{}, + wantsErr: true, + }, + { + name: "issue number", + input: "1", + output: CommentOptions{ + SelectorArg: "1", + Interactive: true, + InputType: 0, + Body: "", + }, + wantsErr: false, + }, + { + name: "issue url", + input: "https://github.com/OWNER/REPO/issues/12", + output: CommentOptions{ + SelectorArg: "https://github.com/OWNER/REPO/issues/12", + Interactive: true, + InputType: 0, + Body: "", + }, + wantsErr: false, + }, + { + name: "body flag", + input: "1 --body test", + output: CommentOptions{ + SelectorArg: "1", + Interactive: false, + InputType: inputTypeInline, + Body: "test", + }, + wantsErr: false, + }, + { + name: "editor flag", + input: "1 --editor", + output: CommentOptions{ + SelectorArg: "1", + Interactive: false, + InputType: inputTypeEditor, + Body: "", + }, + wantsErr: false, + }, + { + name: "web flag", + input: "1 --web", + output: CommentOptions{ + SelectorArg: "1", + Interactive: false, + InputType: inputTypeWeb, + Body: "", + }, + wantsErr: false, + }, + { + name: "editor and web flags", + input: "1 --editor --web", + output: CommentOptions{}, + wantsErr: true, + }, + { + name: "editor and body flags", + input: "1 --editor --body test", + output: CommentOptions{}, + wantsErr: true, + }, + { + name: "web and body flags", + input: "1 --web --body test", + output: CommentOptions{}, + wantsErr: true, + }, + { + name: "editor, web, and body flags", + input: "1 --editor --web --body test", + output: CommentOptions{}, + wantsErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + io, _, _, _ := iostreams.Test() + io.SetStdoutTTY(true) + io.SetStdinTTY(true) + io.SetStderrTTY(true) + + f := &cmdutil.Factory{ + IOStreams: io, + } + + argv, err := shlex.Split(tt.input) + assert.NoError(t, err) + + var gotOpts *CommentOptions + cmd := NewCmdComment(f, func(opts *CommentOptions) error { + gotOpts = opts + return nil + }) + 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.output.SelectorArg, gotOpts.SelectorArg) + assert.Equal(t, tt.output.Interactive, gotOpts.Interactive) + assert.Equal(t, tt.output.InputType, gotOpts.InputType) + assert.Equal(t, tt.output.Body, gotOpts.Body) + }) + } +} + +func Test_commentRun(t *testing.T) { + tests := []struct { + name string + input *CommentOptions + httpStubs func(*testing.T, *httpmock.Registry) + stdout string + stderr string + }{ + { + name: "interactive web", + input: &CommentOptions{ + SelectorArg: "123", + Interactive: true, + InputType: 0, + Body: "", + + InputTypeSurvey: func() (inputType, error) { return inputTypeWeb, nil }, + OpenInBrowser: func(string) error { return nil }, + }, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + mockIssueFromNumber(t, reg) + }, + stderr: "Opening github.com/OWNER/REPO/issues/123 in your browser.\n", + }, + { + name: "interactive editor", + input: &CommentOptions{ + SelectorArg: "123", + Interactive: true, + InputType: 0, + Body: "", + + EditSurvey: func() (string, error) { return "comment body", nil }, + InputTypeSurvey: func() (inputType, error) { return inputTypeEditor, nil }, + ConfirmSubmitSurvey: func() (bool, error) { return true, nil }, + }, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + mockIssueFromNumber(t, reg) + mockCommentCreate(t, reg) + }, + stdout: "https://github.com/OWNER/REPO/issues/123#issuecomment-456\n", + }, + { + name: "non-interactive web", + input: &CommentOptions{ + SelectorArg: "123", + Interactive: false, + InputType: inputTypeWeb, + Body: "", + + OpenInBrowser: func(string) error { return nil }, + }, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + mockIssueFromNumber(t, reg) + }, + stderr: "Opening github.com/OWNER/REPO/issues/123 in your browser.\n", + }, + { + name: "non-interactive editor", + input: &CommentOptions{ + SelectorArg: "123", + Interactive: false, + InputType: inputTypeEditor, + Body: "", + + EditSurvey: func() (string, error) { return "comment body", nil }, + }, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + mockIssueFromNumber(t, reg) + mockCommentCreate(t, reg) + }, + stdout: "https://github.com/OWNER/REPO/issues/123#issuecomment-456\n", + }, + { + name: "non-interactive inline", + input: &CommentOptions{ + SelectorArg: "123", + Interactive: false, + InputType: inputTypeInline, + Body: "comment body", + }, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + mockIssueFromNumber(t, reg) + mockCommentCreate(t, reg) + }, + stdout: "https://github.com/OWNER/REPO/issues/123#issuecomment-456\n", + }, + } + for _, tt := range tests { + io, _, stdout, stderr := iostreams.Test() + io.SetStdoutTTY(true) + io.SetStdinTTY(true) + io.SetStderrTTY(true) + + reg := &httpmock.Registry{} + defer reg.Verify(t) + tt.httpStubs(t, reg) + + tt.input.IO = io + tt.input.HttpClient = func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + } + tt.input.BaseRepo = func() (ghrepo.Interface, error) { + return ghrepo.New("OWNER", "REPO"), nil + } + + t.Run(tt.name, func(t *testing.T) { + err := commentRun(tt.input) + assert.NoError(t, err) + assert.Equal(t, tt.stdout, stdout.String()) + assert.Equal(t, tt.stderr, stderr.String()) + }) + } +} + +func mockIssueFromNumber(_ *testing.T, reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query IssueByNumber\b`), + httpmock.StringResponse(` + { "data": { "repository": { "hasIssuesEnabled": true, "issue": { + "number": 123, + "url": "https://github.com/OWNER/REPO/issues/123" + } } } }`), + ) +} + +func mockCommentCreate(t *testing.T, reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`mutation CommentCreate\b`), + httpmock.GraphQLMutation(` + { "data": { "addComment": { "commentEdge": { "node": { + "url": "https://github.com/OWNER/REPO/issues/123#issuecomment-456" + } } } } }`, + func(inputs map[string]interface{}) { + assert.Equal(t, "comment body", inputs["body"]) + }), + ) +} diff --git a/pkg/cmd/issue/create/create.go b/pkg/cmd/issue/create/create.go index 28c8f0148..f8ee73abb 100644 --- a/pkg/cmd/issue/create/create.go +++ b/pkg/cmd/issue/create/create.go @@ -26,6 +26,7 @@ type CreateOptions struct { RepoOverride string WebMode bool + RecoverFile string Title string Body string @@ -63,6 +64,10 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co bodyProvided := cmd.Flags().Changed("body") opts.RepoOverride, _ = cmd.Flags().GetString("repo") + if !opts.IO.CanPrompt() && opts.RecoverFile != "" { + return &cmdutil.FlagError{Err: errors.New("--recover only supported when running interactively")} + } + opts.Interactive = !(titleProvided && bodyProvided) if opts.Interactive && !opts.IO.CanPrompt() { @@ -83,20 +88,21 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co 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`") + cmd.Flags().StringVar(&opts.RecoverFile, "recover", "", "Recover input from a failed run of create") return cmd } -func createRun(opts *CreateOptions) error { +func createRun(opts *CreateOptions) (err error) { httpClient, err := opts.HttpClient() if err != nil { - return err + return } apiClient := api.NewClientFromHTTP(httpClient) baseRepo, err := opts.BaseRepo() if err != nil { - return err + return } templateFiles, legacyTemplate := prShared.FindTemplates(opts.RootDirOverride, "ISSUE_TEMPLATE") @@ -118,12 +124,20 @@ func createRun(opts *CreateOptions) error { Body: opts.Body, } + if opts.RecoverFile != "" { + err = prShared.FillFromJSON(opts.IO, opts.RecoverFile, &tb) + if err != nil { + err = fmt.Errorf("failed to recover input: %w", err) + return + } + } + if opts.WebMode { openURL := ghrepo.GenerateRepoURL(baseRepo, "issues/new") if opts.Title != "" || opts.Body != "" { openURL, err = prShared.WithPrAndIssueQueryParams(openURL, tb) if err != nil { - return err + return } } else if len(templateFiles) > 1 { openURL += "/choose" @@ -140,38 +154,44 @@ func createRun(opts *CreateOptions) error { repo, err := api.GitHubRepo(apiClient, baseRepo) if err != nil { - return err + return } if !repo.HasIssuesEnabled { - return fmt.Errorf("the '%s' repository has disabled issues", ghrepo.FullName(baseRepo)) + err = fmt.Errorf("the '%s' repository has disabled issues", ghrepo.FullName(baseRepo)) + return } action := prShared.SubmitAction if opts.Interactive { - editorCommand, err := cmdutil.DetermineEditor(opts.Config) + var editorCommand string + editorCommand, err = cmdutil.DetermineEditor(opts.Config) if err != nil { - return err + return } - if tb.Title == "" { + defer prShared.PreserveInput(opts.IO, &tb, &err)() + + if opts.Title == "" { err = prShared.TitleSurvey(&tb) if err != nil { - return err + return } } - if tb.Body == "" { + if opts.Body == "" { templateContent := "" - templateContent, err = prShared.TemplateSurvey(templateFiles, legacyTemplate, tb) - if err != nil { - return err + if opts.RecoverFile == "" { + templateContent, err = prShared.TemplateSurvey(templateFiles, legacyTemplate, tb) + if err != nil { + return + } } err = prShared.BodySurvey(&tb, templateContent, editorCommand) if err != nil { - return err + return } if tb.Body == "" { @@ -179,31 +199,38 @@ func createRun(opts *CreateOptions) error { } } - action, err := prShared.ConfirmSubmission(!tb.HasMetadata(), repo.ViewerCanTriage()) + action, err = prShared.ConfirmSubmission(!tb.HasMetadata(), repo.ViewerCanTriage()) if err != nil { - return fmt.Errorf("unable to confirm: %w", err) + err = fmt.Errorf("unable to confirm: %w", err) + return } if action == prShared.MetadataAction { - err = prShared.MetadataSurvey(opts.IO, apiClient, baseRepo, &tb) + fetcher := &prShared.MetadataFetcher{ + IO: opts.IO, + APIClient: apiClient, + Repo: baseRepo, + State: &tb, + } + err = prShared.MetadataSurvey(opts.IO, baseRepo, fetcher, &tb) if err != nil { - return err + return } action, err = prShared.ConfirmSubmission(!tb.HasMetadata(), false) if err != nil { - return err + return } } if action == prShared.CancelAction { fmt.Fprintln(opts.IO.ErrOut, "Discarding.") - - return nil + return } } else { if tb.Title == "" { - return fmt.Errorf("title can't be blank") + err = fmt.Errorf("title can't be blank") + return } } @@ -211,7 +238,7 @@ func createRun(opts *CreateOptions) error { openURL := ghrepo.GenerateRepoURL(baseRepo, "issues/new") openURL, err = prShared.WithPrAndIssueQueryParams(openURL, tb) if err != nil { - return err + return } if isTerminal { fmt.Fprintf(opts.IO.ErrOut, "Opening %s in your browser.\n", utils.DisplayURL(openURL)) @@ -225,12 +252,13 @@ func createRun(opts *CreateOptions) error { err = prShared.AddMetadataToIssueParams(apiClient, baseRepo, params, &tb) if err != nil { - return err + return } - newIssue, err := api.IssueCreate(apiClient, repo, params) + var newIssue *api.Issue + newIssue, err = api.IssueCreate(apiClient, repo, params) if err != nil { - return err + return } fmt.Fprintln(opts.IO.Out, newIssue.URL) @@ -238,5 +266,5 @@ func createRun(opts *CreateOptions) error { panic("Unreachable state") } - return nil + return } diff --git a/pkg/cmd/issue/create/create_test.go b/pkg/cmd/issue/create/create_test.go index c89a349a0..73a28dc46 100644 --- a/pkg/cmd/issue/create/create_test.go +++ b/pkg/cmd/issue/create/create_test.go @@ -3,16 +3,19 @@ package create import ( "bytes" "encoding/json" + "fmt" "io/ioutil" "net/http" + "os" "os/exec" - "reflect" "strings" "testing" + "github.com/MakeNowJust/heredoc" "github.com/cli/cli/internal/config" "github.com/cli/cli/internal/ghrepo" "github.com/cli/cli/internal/run" + prShared "github.com/cli/cli/pkg/cmd/pr/shared" "github.com/cli/cli/pkg/cmdutil" "github.com/cli/cli/pkg/httpmock" "github.com/cli/cli/pkg/iostreams" @@ -22,13 +25,6 @@ import ( "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) { return runCommandWithRootDirOverridden(rt, isTTY, cli, "") } @@ -79,73 +75,152 @@ func TestIssueCreate_nontty_error(t *testing.T) { defer http.Verify(t) _, 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 running interactively", err.Error()) + assert.EqualError(t, err, "must provide --title and --body when not running interactively") } 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" - } } } } - `)) + http.Register( + httpmock.GraphQL(`query RepositoryInfo\b`), + httpmock.StringResponse(` + { "data": { "repository": { + "id": "REPOID", + "hasIssuesEnabled": true + } } }`), + ) + 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{}) { + assert.Equal(t, inputs["repositoryId"], "REPOID") + assert.Equal(t, inputs["title"], "hello") + assert.Equal(t, inputs["body"], "cash rules everything around me") + }), + ) 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 + assert.Equal(t, "https://github.com/OWNER/REPO/issues/12\n", output.String()) +} + +func TestIssueCreate_recover(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 + } } }`)) + 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" } } - } - }{} - _ = json.Unmarshal(bodyBytes, &reqBody) + } } + `)) + 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{}) { + assert.Equal(t, "recovered title", inputs["title"]) + assert.Equal(t, "recovered body", inputs["body"]) + assert.Equal(t, []interface{}{"BUGID", "TODOID"}, inputs["labelIds"]) + })) - 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") + as, teardown := prompt.InitAskStubber() + defer teardown() - eq(t, output.String(), "https://github.com/OWNER/REPO/issues/12\n") + as.Stub([]*prompt.QuestionStub{ + { + Name: "Title", + Default: true, + }, + }) + as.Stub([]*prompt.QuestionStub{ + { + Name: "Body", + Default: true, + }, + }) + as.Stub([]*prompt.QuestionStub{ + { + Name: "confirmation", + Value: 0, + }, + }) + + tmpfile, err := ioutil.TempFile(os.TempDir(), "testrecover*") + assert.NoError(t, err) + + state := prShared.IssueMetadataState{ + Title: "recovered title", + Body: "recovered body", + Labels: []string{"bug", "TODO"}, + } + + data, err := json.Marshal(state) + assert.NoError(t, err) + + _, err = tmpfile.Write(data) + assert.NoError(t, err) + + args := fmt.Sprintf("--recover '%s'", tmpfile.Name()) + + output, err := runCommandWithRootDirOverridden(http, true, args, "") + if err != nil { + t.Errorf("error running command `issue create`: %v", err) + } + + assert.Equal(t, "https://github.com/OWNER/REPO/issues/12\n", output.String()) } func TestIssueCreate_nonLegacyTemplate(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" - } } } } - `)) + http.Register( + httpmock.GraphQL(`query RepositoryInfo\b`), + httpmock.StringResponse(` + { "data": { "repository": { + "id": "REPOID", + "hasIssuesEnabled": true + } } }`), + ) + 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{}) { + assert.Equal(t, inputs["repositoryId"], "REPOID") + assert.Equal(t, inputs["title"], "hello") + assert.Equal(t, inputs["body"], "I have a suggestion for an enhancement") + }), + ) as, teardown := prompt.InitAskStubber() defer teardown() - // tmeplate + // template as.Stub([]*prompt.QuestionStub{ { Name: "index", @@ -172,23 +247,65 @@ func TestIssueCreate_nonLegacyTemplate(t *testing.T) { 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) + assert.Equal(t, "https://github.com/OWNER/REPO/issues/12\n", output.String()) +} - eq(t, reqBody.Variables.Input.RepositoryID, "REPOID") - eq(t, reqBody.Variables.Input.Title, "hello") - eq(t, reqBody.Variables.Input.Body, "I have a suggestion for an enhancement") +func TestIssueCreate_continueInBrowser(t *testing.T) { + http := &httpmock.Registry{} + defer http.Verify(t) - eq(t, output.String(), "https://github.com/OWNER/REPO/issues/12\n") + http.Register( + httpmock.GraphQL(`query RepositoryInfo\b`), + httpmock.StringResponse(` + { "data": { "repository": { + "id": "REPOID", + "hasIssuesEnabled": true + } } }`), + ) + + as, teardown := prompt.InitAskStubber() + defer teardown() + + // title + as.Stub([]*prompt.QuestionStub{ + { + Name: "Title", + Value: "hello", + }, + }) + // confirm + as.Stub([]*prompt.QuestionStub{ + { + Name: "confirmation", + Value: 1, + }, + }) + + 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, `-b body`) + if err != nil { + t.Errorf("error running command `issue create`: %v", err) + } + + assert.Equal(t, "", output.String()) + assert.Equal(t, heredoc.Doc(` + + Creating issue in OWNER/REPO + + Opening github.com/OWNER/REPO/issues/new in your browser. + `), output.Stderr()) + + if seenCmd == nil { + t.Fatal("expected a command to run") + } + url := strings.ReplaceAll(seenCmd.Args[len(seenCmd.Args)-1], "^", "") + assert.Equal(t, "https://github.com/OWNER/REPO/issues/new?body=body&title=hello", url) } func TestIssueCreate_metadata(t *testing.T) { @@ -247,12 +364,12 @@ func TestIssueCreate_metadata(t *testing.T) { "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") + assert.Equal(t, "TITLE", inputs["title"]) + assert.Equal(t, "BODY", inputs["body"]) + assert.Equal(t, []interface{}{"MONAID"}, inputs["assigneeIds"]) + assert.Equal(t, []interface{}{"BUGID", "TODOID"}, inputs["labelIds"]) + assert.Equal(t, []interface{}{"ROADMAPID"}, inputs["projectIds"]) + assert.Equal(t, "BIGONEID", inputs["milestoneId"]) if v, ok := inputs["userIds"]; ok { t.Errorf("did not expect userIds: %v", v) } @@ -266,19 +383,21 @@ func TestIssueCreate_metadata(t *testing.T) { t.Errorf("error running command `issue create`: %v", err) } - eq(t, output.String(), "https://github.com/OWNER/REPO/issues/12\n") + assert.Equal(t, "https://github.com/OWNER/REPO/issues/12\n", output.String()) } func TestIssueCreate_disabledIssues(t *testing.T) { http := &httpmock.Registry{} defer http.Verify(t) - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { - "id": "REPOID", - "hasIssuesEnabled": false - } } } - `)) + http.Register( + httpmock.GraphQL(`query RepositoryInfo\b`), + httpmock.StringResponse(` + { "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" { @@ -306,9 +425,9 @@ func TestIssueCreate_web(t *testing.T) { 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") + assert.Equal(t, "https://github.com/OWNER/REPO/issues/new", url) + assert.Equal(t, "", output.String()) + assert.Equal(t, "Opening github.com/OWNER/REPO/issues/new in your browser.\n", output.Stderr()) } func TestIssueCreate_webTitleBody(t *testing.T) { @@ -331,7 +450,7 @@ func TestIssueCreate_webTitleBody(t *testing.T) { t.Fatal("expected a command to run") } url := strings.ReplaceAll(seenCmd.Args[len(seenCmd.Args)-1], "^", "") - eq(t, url, "https://github.com/OWNER/REPO/issues/new?body=mybody&title=mytitle") - eq(t, output.String(), "") - eq(t, output.Stderr(), "Opening github.com/OWNER/REPO/issues/new in your browser.\n") + assert.Equal(t, "https://github.com/OWNER/REPO/issues/new?body=mybody&title=mytitle", url) + assert.Equal(t, "", output.String()) + assert.Equal(t, "Opening github.com/OWNER/REPO/issues/new in your browser.\n", output.Stderr()) } diff --git a/pkg/cmd/issue/issue.go b/pkg/cmd/issue/issue.go index 6f2cea6d9..12463b480 100644 --- a/pkg/cmd/issue/issue.go +++ b/pkg/cmd/issue/issue.go @@ -3,6 +3,7 @@ package issue import ( "github.com/MakeNowJust/heredoc" cmdClose "github.com/cli/cli/pkg/cmd/issue/close" + cmdComment "github.com/cli/cli/pkg/cmd/issue/comment" 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" @@ -40,6 +41,7 @@ func NewCmdIssue(f *cmdutil.Factory) *cobra.Command { cmd.AddCommand(cmdReopen.NewCmdReopen(f, nil)) cmd.AddCommand(cmdStatus.NewCmdStatus(f, nil)) cmd.AddCommand(cmdView.NewCmdView(f, nil)) + cmd.AddCommand(cmdComment.NewCmdComment(f, nil)) return cmd } diff --git a/pkg/cmd/issue/list/list_test.go b/pkg/cmd/issue/list/list_test.go index ff202b6f3..a8fb93264 100644 --- a/pkg/cmd/issue/list/list_test.go +++ b/pkg/cmd/issue/list/list_test.go @@ -6,7 +6,6 @@ import ( "io/ioutil" "net/http" "os/exec" - "reflect" "regexp" "testing" @@ -22,13 +21,6 @@ import ( "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) @@ -79,7 +71,7 @@ func TestIssueList_nontty(t *testing.T) { t.Errorf("error running command `issue list`: %v", err) } - eq(t, output.Stderr(), "") + assert.Equal(t, "", output.Stderr()) test.ExpectLines(t, output.String(), `1[\t]+number won[\t]+label[\t]+\d+`, `2[\t]+number too[\t]+label[\t]+\d+`, @@ -147,11 +139,11 @@ func TestIssueList_tty_withFlags(t *testing.T) { t.Errorf("error running command `issue list`: %v", err) } - eq(t, output.Stderr(), "") - eq(t, output.String(), ` + assert.Equal(t, "", output.Stderr()) + assert.Equal(t, ` No issues match your search in OWNER/REPO -`) +`, output.String()) } func TestIssueList_withInvalidLimitFlag(t *testing.T) { @@ -169,12 +161,14 @@ func TestIssueList_nullAssigneeLabels(t *testing.T) { http := &httpmock.Registry{} defer http.Verify(t) - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { - "hasIssuesEnabled": true, - "issues": { "nodes": [] } - } } } - `)) + http.Register( + httpmock.GraphQL(`query IssueList\b`), + httpmock.StringResponse(` + { "data": { "repository": { + "hasIssuesEnabled": true, + "issues": { "nodes": [] } + } } }`), + ) _, err := runCommand(http, true, "") if err != nil { @@ -189,19 +183,21 @@ func TestIssueList_nullAssigneeLabels(t *testing.T) { _, assigneeDeclared := reqBody.Variables["assignee"] _, labelsDeclared := reqBody.Variables["labels"] - eq(t, assigneeDeclared, false) - eq(t, labelsDeclared, false) + assert.Equal(t, false, assigneeDeclared) + assert.Equal(t, false, labelsDeclared) } func TestIssueList_disabledIssues(t *testing.T) { http := &httpmock.Registry{} defer http.Verify(t) - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { - "hasIssuesEnabled": false - } } } - `)) + http.Register( + httpmock.GraphQL(`query IssueList\b`), + httpmock.StringResponse(` + { "data": { "repository": { + "hasIssuesEnabled": false + } } }`), + ) _, err := runCommand(http, true, "") if err == nil || err.Error() != "the 'OWNER/REPO' repository has disabled issues" { @@ -227,14 +223,14 @@ func TestIssueList_web(t *testing.T) { 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") + assert.Equal(t, "", output.String()) + assert.Equal(t, "Opening github.com/OWNER/REPO/issues in your browser.\n", output.Stderr()) if seenCmd == nil { t.Fatal("expected a command to run") } url := seenCmd.Args[len(seenCmd.Args)-1] - eq(t, url, expectedURL) + assert.Equal(t, expectedURL, url) } func TestIssueList_milestoneNotFound(t *testing.T) { diff --git a/pkg/cmd/issue/reopen/reopen_test.go b/pkg/cmd/issue/reopen/reopen_test.go index df8e8ecd7..bce4bc882 100644 --- a/pkg/cmd/issue/reopen/reopen_test.go +++ b/pkg/cmd/issue/reopen/reopen_test.go @@ -14,6 +14,7 @@ import ( "github.com/cli/cli/pkg/iostreams" "github.com/cli/cli/test" "github.com/google/shlex" + "github.com/stretchr/testify/assert" ) func runCommand(rt http.RoundTripper, isTTY bool, cli string) (*test.CmdOut, error) { @@ -58,14 +59,21 @@ 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"}`)) + http.Register( + httpmock.GraphQL(`query IssueByNumber\b`), + httpmock.StringResponse(` + { "data": { "repository": { + "hasIssuesEnabled": true, + "issue": { "id": "THE-ID", "number": 2, "closed": true, "title": "The title of the issue"} + } } }`), + ) + http.Register( + httpmock.GraphQL(`mutation IssueReopen\b`), + httpmock.GraphQLMutation(`{"id": "THE-ID"}`, + func(inputs map[string]interface{}) { + assert.Equal(t, inputs["issueId"], "THE-ID") + }), + ) output, err := runCommand(http, true, "2") if err != nil { @@ -83,12 +91,14 @@ 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"} - } } } - `)) + http.Register( + httpmock.GraphQL(`query IssueByNumber\b`), + httpmock.StringResponse(` + { "data": { "repository": { + "hasIssuesEnabled": true, + "issue": { "number": 2, "closed": false, "title": "The title of the issue"} + } } }`), + ) output, err := runCommand(http, true, "2") if err != nil { @@ -106,11 +116,13 @@ func TestIssueReopen_issuesDisabled(t *testing.T) { http := &httpmock.Registry{} defer http.Verify(t) - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { - "hasIssuesEnabled": false - } } } - `)) + http.Register( + httpmock.GraphQL(`query IssueByNumber\b`), + httpmock.StringResponse(` + { "data": { "repository": { + "hasIssuesEnabled": false + } } }`), + ) _, err := runCommand(http, true, "2") if err == nil || err.Error() != "the 'OWNER/REPO' repository has disabled issues" { diff --git a/pkg/cmd/issue/shared/lookup.go b/pkg/cmd/issue/shared/lookup.go index 90a729599..6e716f6da 100644 --- a/pkg/cmd/issue/shared/lookup.go +++ b/pkg/cmd/issue/shared/lookup.go @@ -12,49 +12,48 @@ import ( ) 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 - } - if issue != nil { - return issue, baseRepo, nil + issueNumber, baseRepo := issueMetadataFromURL(arg) + + if issueNumber == 0 { + var err error + issueNumber, err = strconv.Atoi(strings.TrimPrefix(arg, "#")) + if err != nil { + return nil, nil, fmt.Errorf("invalid issue format: %q", arg) + } } - baseRepo, err = baseRepoFn() - if err != nil { - return nil, nil, fmt.Errorf("could not determine base repo: %w", err) + if baseRepo == nil { + var err error + baseRepo, err = baseRepoFn() + if err != nil { + return nil, nil, fmt.Errorf("could not determine base repo: %w", err) + } } - issueNumber, err := strconv.Atoi(strings.TrimPrefix(arg, "#")) - if err != nil { - return nil, nil, fmt.Errorf("invalid issue format: %q", arg) - } - - issue, err = issueFromNumber(apiClient, baseRepo, issueNumber) + issue, err := issueFromNumber(apiClient, baseRepo, issueNumber) return issue, baseRepo, err } var issueURLRE = regexp.MustCompile(`^/([^/]+)/([^/]+)/issues/(\d+)`) -func issueFromURL(apiClient *api.Client, s string) (*api.Issue, ghrepo.Interface, error) { +func issueMetadataFromURL(s string) (int, ghrepo.Interface) { u, err := url.Parse(s) if err != nil { - return nil, nil, nil + return 0, nil } if u.Scheme != "https" && u.Scheme != "http" { - return nil, nil, nil + return 0, nil } m := issueURLRE.FindStringSubmatch(u.Path) if m == nil { - return nil, nil, nil + return 0, nil } repo := ghrepo.NewWithHost(m[1], m[2], u.Hostname()) issueNumber, _ := strconv.Atoi(m[3]) - issue, err := issueFromNumber(apiClient, repo, issueNumber) - return issue, repo, err + return issueNumber, repo } func issueFromNumber(apiClient *api.Client, repo ghrepo.Interface, issueNumber int) (*api.Issue, error) { diff --git a/pkg/cmd/issue/view/fixtures/issueView_preview.json b/pkg/cmd/issue/view/fixtures/issueView_preview.json index e25090a61..65fc5ef51 100644 --- a/pkg/cmd/issue/view/fixtures/issueView_preview.json +++ b/pkg/cmd/issue/view/fixtures/issueView_preview.json @@ -7,21 +7,21 @@ "body": "**bold story**", "title": "ix of coins", "state": "OPEN", - "created_at": "2011-01-26T19:01:12Z", + "createdAt": "2011-01-26T19:01:12Z", "author": { "login": "marseilles" }, "assignees": { "nodes": [], - "totalcount": 0 + "totalCount": 0 }, "labels": { "nodes": [], - "totalcount": 0 + "totalCount": 0 }, "projectcards": { "nodes": [], - "totalcount": 0 + "totalCount": 0 }, "milestone": { "title": "" diff --git a/pkg/cmd/issue/view/fixtures/issueView_previewClosedState.json b/pkg/cmd/issue/view/fixtures/issueView_previewClosedState.json index 978927125..4665c47e1 100644 --- a/pkg/cmd/issue/view/fixtures/issueView_previewClosedState.json +++ b/pkg/cmd/issue/view/fixtures/issueView_previewClosedState.json @@ -7,7 +7,7 @@ "body": "**bold story**", "title": "ix of coins", "state": "CLOSED", - "created_at": "2011-01-26T19:01:12Z", + "createdAt": "2011-01-26T19:01:12Z", "author": { "login": "marseilles" }, diff --git a/pkg/cmd/issue/view/fixtures/issueView_previewFullComments.json b/pkg/cmd/issue/view/fixtures/issueView_previewFullComments.json new file mode 100644 index 000000000..d2cd27f30 --- /dev/null +++ b/pkg/cmd/issue/view/fixtures/issueView_previewFullComments.json @@ -0,0 +1,308 @@ +{ + "data": { + "repository": { + "issue": { + "comments": { + "nodes": [ + { + "author": { + "login": "monalisa" + }, + "authorAssociation": "NONE", + "body": "Comment 1", + "createdAt": "2020-01-01T12:00:00Z", + "includesCreatedEdit": true, + "reactionGroups": [ + { + "content": "CONFUSED", + "users": { + "totalCount": 1 + } + }, + { + "content": "EYES", + "users": { + "totalCount": 2 + } + }, + { + "content": "HEART", + "users": { + "totalCount": 3 + } + }, + { + "content": "HOORAY", + "users": { + "totalCount": 4 + } + }, + { + "content": "LAUGH", + "users": { + "totalCount": 5 + } + }, + { + "content": "ROCKET", + "users": { + "totalCount": 6 + } + }, + { + "content": "THUMBS_DOWN", + "users": { + "totalCount": 7 + } + }, + { + "content": "THUMBS_UP", + "users": { + "totalCount": 8 + } + } + ] + }, + { + "author": { + "login": "johnnytest" + }, + "authorAssociation": "CONTRIBUTOR", + "body": "Comment 2", + "createdAt": "2020-01-01T12:00:00Z", + "includesCreatedEdit": false, + "reactionGroups": [ + { + "content": "CONFUSED", + "users": { + "totalCount": 0 + } + }, + { + "content": "EYES", + "users": { + "totalCount": 0 + } + }, + { + "content": "HEART", + "users": { + "totalCount": 0 + } + }, + { + "content": "HOORAY", + "users": { + "totalCount": 0 + } + }, + { + "content": "LAUGH", + "users": { + "totalCount": 0 + } + }, + { + "content": "ROCKET", + "users": { + "totalCount": 0 + } + }, + { + "content": "THUMBS_DOWN", + "users": { + "totalCount": 0 + } + }, + { + "content": "THUMBS_UP", + "users": { + "totalCount": 0 + } + } + ] + }, + { + "author": { + "login": "elvisp" + }, + "authorAssociation": "MEMBER", + "body": "Comment 3", + "createdAt": "2020-01-01T12:00:00Z", + "includesCreatedEdit": false, + "reactionGroups": [ + { + "content": "CONFUSED", + "users": { + "totalCount": 0 + } + }, + { + "content": "EYES", + "users": { + "totalCount": 0 + } + }, + { + "content": "HEART", + "users": { + "totalCount": 0 + } + }, + { + "content": "HOORAY", + "users": { + "totalCount": 0 + } + }, + { + "content": "LAUGH", + "users": { + "totalCount": 0 + } + }, + { + "content": "ROCKET", + "users": { + "totalCount": 0 + } + }, + { + "content": "THUMBS_DOWN", + "users": { + "totalCount": 0 + } + }, + { + "content": "THUMBS_UP", + "users": { + "totalCount": 0 + } + } + ] + }, + { + "author": { + "login": "loislane" + }, + "authorAssociation": "OWNER", + "body": "Comment 4", + "createdAt": "2020-01-01T12:00:00Z", + "includesCreatedEdit": false, + "reactionGroups": [ + { + "content": "CONFUSED", + "users": { + "totalCount": 0 + } + }, + { + "content": "EYES", + "users": { + "totalCount": 0 + } + }, + { + "content": "HEART", + "users": { + "totalCount": 0 + } + }, + { + "content": "HOORAY", + "users": { + "totalCount": 0 + } + }, + { + "content": "LAUGH", + "users": { + "totalCount": 0 + } + }, + { + "content": "ROCKET", + "users": { + "totalCount": 0 + } + }, + { + "content": "THUMBS_DOWN", + "users": { + "totalCount": 0 + } + }, + { + "content": "THUMBS_UP", + "users": { + "totalCount": 0 + } + } + ] + }, + { + "author": { + "login": "marseilles" + }, + "authorAssociation": "COLLABORATOR", + "body": "Comment 5", + "createdAt": "2020-01-01T12:00:00Z", + "includesCreatedEdit": false, + "reactionGroups": [ + { + "content": "CONFUSED", + "users": { + "totalCount": 0 + } + }, + { + "content": "EYES", + "users": { + "totalCount": 0 + } + }, + { + "content": "HEART", + "users": { + "totalCount": 0 + } + }, + { + "content": "HOORAY", + "users": { + "totalCount": 0 + } + }, + { + "content": "LAUGH", + "users": { + "totalCount": 0 + } + }, + { + "content": "ROCKET", + "users": { + "totalCount": 0 + } + }, + { + "content": "THUMBS_DOWN", + "users": { + "totalCount": 0 + } + }, + { + "content": "THUMBS_UP", + "users": { + "totalCount": 0 + } + } + ] + } + ], + "totalCount": 5 + } + } + } + } +} diff --git a/pkg/cmd/issue/view/fixtures/issueView_previewSingleComment.json b/pkg/cmd/issue/view/fixtures/issueView_previewSingleComment.json new file mode 100644 index 000000000..87fc7bffc --- /dev/null +++ b/pkg/cmd/issue/view/fixtures/issueView_previewSingleComment.json @@ -0,0 +1,147 @@ +{ + "data": { + "repository": { + "hasIssuesEnabled": true, + "issue": { + "number": 123, + "body": "some body", + "title": "some title", + "state": "OPEN", + "createdAt": "2020-01-01T12:00:00Z", + "author": { + "login": "marseilles" + }, + "assignees": { + "nodes": [], + "totalCount": 0 + }, + "labels": { + "nodes": [], + "totalCount": 0 + }, + "projectcards": { + "nodes": [], + "totalCount": 0 + }, + "milestone": { + "title": "" + }, + "reactionGroups": [ + { + "content": "CONFUSED", + "users": { + "totalCount": 0 + } + }, + { + "content": "EYES", + "users": { + "totalCount": 0 + } + }, + { + "content": "HEART", + "users": { + "totalCount": 0 + } + }, + { + "content": "HOORAY", + "users": { + "totalCount": 0 + } + }, + { + "content": "LAUGH", + "users": { + "totalCount": 0 + } + }, + { + "content": "ROCKET", + "users": { + "totalCount": 0 + } + }, + { + "content": "THUMBS_DOWN", + "users": { + "totalCount": 0 + } + }, + { + "content": "THUMBS_UP", + "users": { + "totalCount": 0 + } + } + ], + "comments": { + "nodes": [ + { + "author": { + "login": "marseilles" + }, + "authorAssociation": "COLLABORATOR", + "body": "Comment 5", + "createdAt": "2020-01-01T12:00:00Z", + "includesCreatedEdit": false, + "reactionGroups": [ + { + "content": "CONFUSED", + "users": { + "totalCount": 0 + } + }, + { + "content": "EYES", + "users": { + "totalCount": 0 + } + }, + { + "content": "HEART", + "users": { + "totalCount": 0 + } + }, + { + "content": "HOORAY", + "users": { + "totalCount": 0 + } + }, + { + "content": "LAUGH", + "users": { + "totalCount": 0 + } + }, + { + "content": "ROCKET", + "users": { + "totalCount": 0 + } + }, + { + "content": "THUMBS_DOWN", + "users": { + "totalCount": 0 + } + }, + { + "content": "THUMBS_UP", + "users": { + "totalCount": 0 + } + } + ] + } + ], + "totalCount": 5 + }, + "url": "https://github.com/OWNER/REPO/issues/123" + } + } + } +} diff --git a/pkg/cmd/issue/view/fixtures/issueView_previewWithEmptyBody.json b/pkg/cmd/issue/view/fixtures/issueView_previewWithEmptyBody.json index 6e87a42b6..104f134be 100644 --- a/pkg/cmd/issue/view/fixtures/issueView_previewWithEmptyBody.json +++ b/pkg/cmd/issue/view/fixtures/issueView_previewWithEmptyBody.json @@ -7,7 +7,7 @@ "body": "", "title": "ix of coins", "state": "OPEN", - "created_at": "2011-01-26T19:01:12Z", + "createdAt": "2011-01-26T19:01:12Z", "author": { "login": "marseilles" }, diff --git a/pkg/cmd/issue/view/fixtures/issueView_previewWithMetadata.json b/pkg/cmd/issue/view/fixtures/issueView_previewWithMetadata.json index 246bbd77b..9bce5f745 100644 --- a/pkg/cmd/issue/view/fixtures/issueView_previewWithMetadata.json +++ b/pkg/cmd/issue/view/fixtures/issueView_previewWithMetadata.json @@ -7,7 +7,7 @@ "body": "**bold story**", "title": "ix of coins", "state": "OPEN", - "created_at": "2011-01-26T19:01:12Z", + "createdAt": "2011-01-26T19:01:12Z", "author": { "login": "marseilles" }, @@ -20,7 +20,7 @@ "login": "monaco" } ], - "totalcount": 2 + "totalCount": 2 }, "labels": { "nodes": [ @@ -40,7 +40,7 @@ "name": "five" } ], - "totalcount": 5 + "totalCount": 5 }, "projectcards": { "nodes": [ @@ -77,13 +77,63 @@ } } ], - "totalcount": 3 + "totalCount": 3 }, "milestone": { "title": "uluru" }, + "reactionGroups": [ + { + "content": "CONFUSED", + "users": { + "totalCount": 8 + } + }, + { + "content": "EYES", + "users": { + "totalCount": 7 + } + }, + { + "content": "HEART", + "users": { + "totalCount": 6 + } + }, + { + "content": "HOORAY", + "users": { + "totalCount": 5 + } + }, + { + "content": "LAUGH", + "users": { + "totalCount": 4 + } + }, + { + "content": "ROCKET", + "users": { + "totalCount": 3 + } + }, + { + "content": "THUMBS_DOWN", + "users": { + "totalCount": 2 + } + }, + { + "content": "THUMBS_UP", + "users": { + "totalCount": 1 + } + } + ], "comments": { - "totalcount": 9 + "totalCount": 9 }, "url": "https://github.com/OWNER/REPO/issues/123" } diff --git a/pkg/cmd/issue/view/view.go b/pkg/cmd/issue/view/view.go index 2bada530c..24867b32b 100644 --- a/pkg/cmd/issue/view/view.go +++ b/pkg/cmd/issue/view/view.go @@ -29,6 +29,7 @@ type ViewOptions struct { SelectorArg string WebMode bool + Comments bool } func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Command { @@ -45,9 +46,7 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman 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 @@ -65,6 +64,7 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman } cmd.Flags().BoolVarP(&opts.WebMode, "web", "w", false, "Open an issue in the browser") + cmd.Flags().BoolVarP(&opts.Comments, "comments", "c", false, "View issue comments") return cmd } @@ -76,20 +76,29 @@ func viewRun(opts *ViewOptions) error { } apiClient := api.NewClientFromHTTP(httpClient) - issue, _, err := issueShared.IssueFromArg(apiClient, opts.BaseRepo, opts.SelectorArg) + issue, repo, err := issueShared.IssueFromArg(apiClient, opts.BaseRepo, opts.SelectorArg) if err != nil { return err } - openURL := issue.URL - if opts.WebMode { + openURL := issue.URL if opts.IO.IsStdoutTTY() { fmt.Fprintf(opts.IO.ErrOut, "Opening %s in your browser.\n", utils.DisplayURL(openURL)) } return utils.OpenInBrowser(openURL) } + if opts.Comments { + opts.IO.StartProgressIndicator() + comments, err := api.CommentsForIssue(apiClient, repo, issue) + opts.IO.StopProgressIndicator() + if err != nil { + return err + } + issue.Comments = *comments + } + opts.IO.DetectTerminalTheme() err = opts.IO.StartPager() @@ -99,8 +108,14 @@ func viewRun(opts *ViewOptions) error { defer opts.IO.StopPager() if opts.IO.IsStdoutTTY() { - return printHumanIssuePreview(opts.IO, issue) + return printHumanIssuePreview(opts, issue) } + + if opts.Comments { + fmt.Fprint(opts.IO.Out, prShared.RawCommentList(issue.Comments, api.PullRequestReviews{})) + return nil + } + return printRawIssuePreview(opts.IO.Out, issue) } @@ -119,30 +134,34 @@ func printRawIssuePreview(out io.Writer, issue *api.Issue) error { 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(io *iostreams.IOStreams, issue *api.Issue) error { - out := io.Out +func printHumanIssuePreview(opts *ViewOptions, issue *api.Issue) error { + out := opts.IO.Out now := time.Now() ago := now.Sub(issue.CreatedAt) - cs := io.ColorScheme() + cs := opts.IO.ColorScheme() // Header (Title and State) fmt.Fprintln(out, cs.Bold(issue.Title)) - fmt.Fprint(out, issueStateTitleWithColor(cs, issue.State)) - fmt.Fprintln(out, cs.Gray(fmt.Sprintf( - " • %s opened %s • %s", + fmt.Fprintf(out, + "%s • %s opened %s • %s\n", + issueStateTitleWithColor(cs, issue.State), issue.Author.Login, utils.FuzzyAgo(ago), utils.Pluralize(issue.Comments.TotalCount, "comment"), - ))) + ) + + // Reactions + if reactions := prShared.ReactionGroupList(issue.ReactionGroups); reactions != "" { + fmt.Fprint(out, reactions) + fmt.Fprintln(out) + } // Metadata - fmt.Fprintln(out) if assignees := issueAssigneeList(*issue); assignees != "" { fmt.Fprint(out, cs.Bold("Assignees: ")) fmt.Fprintln(out, assignees) @@ -161,19 +180,32 @@ func printHumanIssuePreview(io *iostreams.IOStreams, issue *api.Issue) error { } // Body - if issue.Body != "" { - fmt.Fprintln(out) - style := markdown.GetStyle(io.TerminalTheme()) - md, err := markdown.Render(issue.Body, style, "") + var md string + var err error + if issue.Body == "" { + md = fmt.Sprintf("\n %s\n\n", cs.Gray("No description provided")) + } else { + style := markdown.GetStyle(opts.IO.TerminalTheme()) + md, err = markdown.Render(issue.Body, style, "") if err != nil { return err } - fmt.Fprintln(out, md) } - fmt.Fprintln(out) + fmt.Fprintf(out, "\n%s\n", md) + + // Comments + if issue.Comments.TotalCount > 0 { + preview := !opts.Comments + comments, err := prShared.CommentList(opts.IO, issue.Comments, api.PullRequestReviews{}, preview) + if err != nil { + return err + } + fmt.Fprint(out, comments) + } // Footer fmt.Fprintf(out, cs.Gray("View this issue on GitHub: %s\n"), issue.URL) + return nil } diff --git a/pkg/cmd/issue/view/view_test.go b/pkg/cmd/issue/view/view_test.go index 0567c87ff..a1664aa07 100644 --- a/pkg/cmd/issue/view/view_test.go +++ b/pkg/cmd/issue/view/view_test.go @@ -2,12 +2,13 @@ package view import ( "bytes" + "fmt" "io/ioutil" "net/http" "os/exec" - "reflect" "testing" + "github.com/briandowns/spinner" "github.com/cli/cli/internal/config" "github.com/cli/cli/internal/ghrepo" "github.com/cli/cli/internal/run" @@ -15,16 +16,11 @@ import ( "github.com/cli/cli/pkg/httpmock" "github.com/cli/cli/pkg/iostreams" "github.com/cli/cli/test" + "github.com/cli/cli/utils" "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) @@ -67,12 +63,15 @@ 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" - } } } } - `)) + http.Register( + httpmock.GraphQL(`query IssueByNumber\b`), + httpmock.StringResponse(` + { "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 { @@ -86,26 +85,29 @@ func TestIssueView_web(t *testing.T) { t.Errorf("error running command `issue view`: %v", err) } - eq(t, output.String(), "") - eq(t, output.Stderr(), "Opening github.com/OWNER/REPO/issues/123 in your browser.\n") + assert.Equal(t, "", output.String()) + assert.Equal(t, "Opening github.com/OWNER/REPO/issues/123 in your browser.\n", output.Stderr()) 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") + assert.Equal(t, "https://github.com/OWNER/REPO/issues/123", url) } 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" - } } } } - `)) + http.Register( + httpmock.GraphQL(`query IssueByNumber\b`), + httpmock.StringResponse(` + { "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 { @@ -119,14 +121,14 @@ func TestIssueView_web_numberArgWithHash(t *testing.T) { t.Errorf("error running command `issue view`: %v", err) } - eq(t, output.String(), "") - eq(t, output.Stderr(), "Opening github.com/OWNER/REPO/issues/123 in your browser.\n") + assert.Equal(t, "", output.String()) + assert.Equal(t, "Opening github.com/OWNER/REPO/issues/123 in your browser.\n", output.Stderr()) 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") + assert.Equal(t, "https://github.com/OWNER/REPO/issues/123", url) } func TestIssueView_nontty_Preview(t *testing.T) { @@ -192,7 +194,7 @@ func TestIssueView_nontty_Preview(t *testing.T) { t.Errorf("error running `issue view`: %v", err) } - eq(t, output.Stderr(), "") + assert.Equal(t, "", output.Stderr()) test.ExpectLines(t, output.String(), tc.expectedOutputs...) }) @@ -208,7 +210,7 @@ func TestIssueView_tty_Preview(t *testing.T) { fixture: "./fixtures/issueView_preview.json", expectedOutputs: []string{ `ix of coins`, - `Open.*marseilles opened about 292 years ago.*9 comments`, + `Open.*marseilles opened about 9 years ago.*9 comments`, `bold story`, `View this issue on GitHub: https://github.com/OWNER/REPO/issues/123`, }, @@ -217,7 +219,8 @@ func TestIssueView_tty_Preview(t *testing.T) { fixture: "./fixtures/issueView_previewWithMetadata.json", expectedOutputs: []string{ `ix of coins`, - `Open.*marseilles opened about 292 years ago.*9 comments`, + `Open.*marseilles opened about 9 years ago.*9 comments`, + `8 \x{1f615} • 7 \x{1f440} • 6 \x{2764}\x{fe0f} • 5 \x{1f389} • 4 \x{1f604} • 3 \x{1f680} • 2 \x{1f44e} • 1 \x{1f44d}`, `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`, @@ -230,7 +233,8 @@ func TestIssueView_tty_Preview(t *testing.T) { fixture: "./fixtures/issueView_previewWithEmptyBody.json", expectedOutputs: []string{ `ix of coins`, - `Open.*marseilles opened about 292 years ago.*9 comments`, + `Open.*marseilles opened about 9 years ago.*9 comments`, + `No description provided`, `View this issue on GitHub: https://github.com/OWNER/REPO/issues/123`, }, }, @@ -238,7 +242,7 @@ func TestIssueView_tty_Preview(t *testing.T) { fixture: "./fixtures/issueView_previewClosedState.json", expectedOutputs: []string{ `ix of coins`, - `Closed.*marseilles opened about 292 years ago.*9 comments`, + `Closed.*marseilles opened about 9 years ago.*9 comments`, `bold story`, `View this issue on GitHub: https://github.com/OWNER/REPO/issues/123`, }, @@ -256,7 +260,7 @@ func TestIssueView_tty_Preview(t *testing.T) { t.Errorf("error running `issue view`: %v", err) } - eq(t, output.Stderr(), "") + assert.Equal(t, "", output.Stderr()) test.ExpectLines(t, output.String(), tc.expectedOutputs...) }) @@ -267,11 +271,14 @@ 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." } - ] } - `)) + http.Register( + httpmock.GraphQL(`query IssueByNumber\b`), + httpmock.StringResponse(` + { "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 { @@ -294,12 +301,15 @@ func TestIssueView_disabledIssues(t *testing.T) { http := &httpmock.Registry{} defer http.Verify(t) - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { - "id": "REPOID", - "hasIssuesEnabled": false - } } } - `)) + http.Register( + httpmock.GraphQL(`query IssueByNumber\b`), + httpmock.StringResponse(` + { "data": { "repository": { + "id": "REPOID", + "hasIssuesEnabled": false + } } } + `), + ) _, err := runCommand(http, true, `6666`) if err == nil || err.Error() != "the 'OWNER/REPO' repository has disabled issues" { @@ -311,12 +321,15 @@ 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" - } } } } - `)) + http.Register( + httpmock.GraphQL(`query IssueByNumber\b`), + httpmock.StringResponse(` + { "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 { @@ -330,11 +343,161 @@ func TestIssueView_web_urlArg(t *testing.T) { t.Errorf("error running command `issue view`: %v", err) } - eq(t, output.String(), "") + assert.Equal(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") + assert.Equal(t, "https://github.com/OWNER/REPO/issues/123", url) +} + +func TestIssueView_tty_Comments(t *testing.T) { + tests := map[string]struct { + cli string + fixtures map[string]string + expectedOutputs []string + wantsErr bool + }{ + "without comments flag": { + cli: "123", + fixtures: map[string]string{ + "IssueByNumber": "./fixtures/issueView_previewSingleComment.json", + }, + expectedOutputs: []string{ + `some title`, + `some body`, + `———————— Not showing 4 comments ————————`, + `marseilles \(Collaborator\) • Jan 1, 2020 • Newest comment`, + `Comment 5`, + `Use --comments to view the full conversation`, + `View this issue on GitHub: https://github.com/OWNER/REPO/issues/123`, + }, + }, + "with comments flag": { + cli: "123 --comments", + fixtures: map[string]string{ + "IssueByNumber": "./fixtures/issueView_previewSingleComment.json", + "CommentsForIssue": "./fixtures/issueView_previewFullComments.json", + }, + expectedOutputs: []string{ + `some title`, + `some body`, + `monalisa • Jan 1, 2020 • Edited`, + `1 \x{1f615} • 2 \x{1f440} • 3 \x{2764}\x{fe0f} • 4 \x{1f389} • 5 \x{1f604} • 6 \x{1f680} • 7 \x{1f44e} • 8 \x{1f44d}`, + `Comment 1`, + `johnnytest \(Contributor\) • Jan 1, 2020`, + `Comment 2`, + `elvisp \(Member\) • Jan 1, 2020`, + `Comment 3`, + `loislane \(Owner\) • Jan 1, 2020`, + `Comment 4`, + `marseilles \(Collaborator\) • Jan 1, 2020 • Newest comment`, + `Comment 5`, + `View this issue on GitHub: https://github.com/OWNER/REPO/issues/123`, + }, + }, + "with invalid comments flag": { + cli: "123 --comments 3", + wantsErr: true, + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + stubSpinner() + http := &httpmock.Registry{} + defer http.Verify(t) + for name, file := range tc.fixtures { + name := fmt.Sprintf(`query %s\b`, name) + http.Register(httpmock.GraphQL(name), httpmock.FileResponse(file)) + } + output, err := runCommand(http, true, tc.cli) + if tc.wantsErr { + assert.Error(t, err) + return + } + assert.NoError(t, err) + assert.Equal(t, "", output.Stderr()) + test.ExpectLines(t, output.String(), tc.expectedOutputs...) + }) + } +} + +func TestIssueView_nontty_Comments(t *testing.T) { + tests := map[string]struct { + cli string + fixtures map[string]string + expectedOutputs []string + wantsErr bool + }{ + "without comments flag": { + cli: "123", + fixtures: map[string]string{ + "IssueByNumber": "./fixtures/issueView_previewSingleComment.json", + }, + expectedOutputs: []string{ + `title:\tsome title`, + `state:\tOPEN`, + `author:\tmarseilles`, + `comments:\t5`, + `some body`, + }, + }, + "with comments flag": { + cli: "123 --comments", + fixtures: map[string]string{ + "IssueByNumber": "./fixtures/issueView_previewSingleComment.json", + "CommentsForIssue": "./fixtures/issueView_previewFullComments.json", + }, + expectedOutputs: []string{ + `author:\tmonalisa`, + `association:\t`, + `edited:\ttrue`, + `Comment 1`, + `author:\tjohnnytest`, + `association:\tcontributor`, + `edited:\tfalse`, + `Comment 2`, + `author:\telvisp`, + `association:\tmember`, + `edited:\tfalse`, + `Comment 3`, + `author:\tloislane`, + `association:\towner`, + `edited:\tfalse`, + `Comment 4`, + `author:\tmarseilles`, + `association:\tcollaborator`, + `edited:\tfalse`, + `Comment 5`, + }, + }, + "with invalid comments flag": { + cli: "123 --comments 3", + wantsErr: true, + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + http := &httpmock.Registry{} + defer http.Verify(t) + for name, file := range tc.fixtures { + name := fmt.Sprintf(`query %s\b`, name) + http.Register(httpmock.GraphQL(name), httpmock.FileResponse(file)) + } + output, err := runCommand(http, false, tc.cli) + if tc.wantsErr { + assert.Error(t, err) + return + } + assert.NoError(t, err) + assert.Equal(t, "", output.Stderr()) + test.ExpectLines(t, output.String(), tc.expectedOutputs...) + }) + } +} + +func stubSpinner() { + utils.StartSpinner = func(_ *spinner.Spinner) {} + utils.StopSpinner = func(_ *spinner.Spinner) {} } diff --git a/pkg/cmd/pr/checkout/checkout_test.go b/pkg/cmd/pr/checkout/checkout_test.go index 2ca060587..7513e69fe 100644 --- a/pkg/cmd/pr/checkout/checkout_test.go +++ b/pkg/cmd/pr/checkout/checkout_test.go @@ -7,7 +7,6 @@ import ( "io/ioutil" "net/http" "os/exec" - "reflect" "strings" "testing" @@ -25,13 +24,6 @@ import ( "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) - } -} - type errorStub struct { message string } @@ -137,10 +129,10 @@ func TestPRCheckout_sameRepo(t *testing.T) { if !assert.Equal(t, 4, len(ranCommands)) { return } - eq(t, strings.Join(ranCommands[0], " "), "git fetch origin +refs/heads/feature:refs/remotes/origin/feature") - eq(t, strings.Join(ranCommands[1], " "), "git checkout -b feature --no-track origin/feature") - eq(t, strings.Join(ranCommands[2], " "), "git config branch.feature.remote origin") - eq(t, strings.Join(ranCommands[3], " "), "git config branch.feature.merge refs/heads/feature") + assert.Equal(t, "git fetch origin +refs/heads/feature:refs/remotes/origin/feature", strings.Join(ranCommands[0], " ")) + assert.Equal(t, "git checkout -b feature --no-track origin/feature", strings.Join(ranCommands[1], " ")) + assert.Equal(t, "git config branch.feature.remote origin", strings.Join(ranCommands[2], " ")) + assert.Equal(t, "git config branch.feature.merge refs/heads/feature", strings.Join(ranCommands[3], " ")) } func TestPRCheckout_urlArg(t *testing.T) { @@ -174,11 +166,11 @@ func TestPRCheckout_urlArg(t *testing.T) { defer restoreCmd() output, err := runCommand(http, nil, "master", `https://github.com/OWNER/REPO/pull/123/files`) - eq(t, err, nil) - eq(t, output.String(), "") + assert.NoError(t, err) + assert.Equal(t, "", output.String()) - eq(t, len(ranCommands), 4) - eq(t, strings.Join(ranCommands[1], " "), "git checkout -b feature --no-track origin/feature") + assert.Equal(t, 4, len(ranCommands)) + assert.Equal(t, "git checkout -b feature --no-track origin/feature", strings.Join(ranCommands[1], " ")) } func TestPRCheckout_urlArg_differentBase(t *testing.T) { @@ -213,8 +205,8 @@ func TestPRCheckout_urlArg_differentBase(t *testing.T) { defer restoreCmd() output, err := runCommand(http, nil, "master", `https://github.com/OTHER/POE/pull/123/files`) - eq(t, err, nil) - eq(t, output.String(), "") + assert.NoError(t, err) + assert.Equal(t, "", output.String()) bodyBytes, _ := ioutil.ReadAll(http.Requests[0].Body) reqBody := struct { @@ -225,12 +217,12 @@ func TestPRCheckout_urlArg_differentBase(t *testing.T) { }{} _ = json.Unmarshal(bodyBytes, &reqBody) - eq(t, reqBody.Variables.Owner, "OTHER") - eq(t, reqBody.Variables.Repo, "POE") + assert.Equal(t, "OTHER", reqBody.Variables.Owner) + assert.Equal(t, "POE", reqBody.Variables.Repo) - eq(t, len(ranCommands), 5) - eq(t, strings.Join(ranCommands[1], " "), "git fetch https://github.com/OTHER/POE.git refs/pull/123/head:feature") - eq(t, strings.Join(ranCommands[3], " "), "git config branch.feature.remote https://github.com/OTHER/POE.git") + assert.Equal(t, 5, len(ranCommands)) + assert.Equal(t, "git fetch https://github.com/OTHER/POE.git refs/pull/123/head:feature", strings.Join(ranCommands[1], " ")) + assert.Equal(t, "git config branch.feature.remote https://github.com/OTHER/POE.git", strings.Join(ranCommands[3], " ")) } func TestPRCheckout_branchArg(t *testing.T) { @@ -265,11 +257,11 @@ func TestPRCheckout_branchArg(t *testing.T) { defer restoreCmd() output, err := runCommand(http, nil, "master", `hubot:feature`) - eq(t, err, nil) - eq(t, output.String(), "") + assert.NoError(t, err) + assert.Equal(t, "", output.String()) - eq(t, len(ranCommands), 5) - eq(t, strings.Join(ranCommands[1], " "), "git fetch origin refs/pull/123/head:feature") + assert.Equal(t, 5, len(ranCommands)) + assert.Equal(t, "git fetch origin refs/pull/123/head:feature", strings.Join(ranCommands[1], " ")) } func TestPRCheckout_existingBranch(t *testing.T) { @@ -304,13 +296,13 @@ func TestPRCheckout_existingBranch(t *testing.T) { defer restoreCmd() output, err := runCommand(http, nil, "master", `123`) - eq(t, err, nil) - eq(t, output.String(), "") + assert.NoError(t, err) + assert.Equal(t, "", output.String()) - eq(t, len(ranCommands), 3) - eq(t, strings.Join(ranCommands[0], " "), "git fetch origin +refs/heads/feature:refs/remotes/origin/feature") - eq(t, strings.Join(ranCommands[1], " "), "git checkout feature") - eq(t, strings.Join(ranCommands[2], " "), "git merge --ff-only refs/remotes/origin/feature") + assert.Equal(t, 3, len(ranCommands)) + assert.Equal(t, "git fetch origin +refs/heads/feature:refs/remotes/origin/feature", strings.Join(ranCommands[0], " ")) + assert.Equal(t, "git checkout feature", strings.Join(ranCommands[1], " ")) + assert.Equal(t, "git merge --ff-only refs/remotes/origin/feature", strings.Join(ranCommands[2], " ")) } func TestPRCheckout_differentRepo_remoteExists(t *testing.T) { @@ -356,14 +348,14 @@ func TestPRCheckout_differentRepo_remoteExists(t *testing.T) { defer restoreCmd() output, err := runCommand(http, remotes, "master", `123`) - eq(t, err, nil) - eq(t, output.String(), "") + assert.NoError(t, err) + assert.Equal(t, "", output.String()) - eq(t, len(ranCommands), 4) - eq(t, strings.Join(ranCommands[0], " "), "git fetch robot-fork +refs/heads/feature:refs/remotes/robot-fork/feature") - eq(t, strings.Join(ranCommands[1], " "), "git checkout -b feature --no-track robot-fork/feature") - eq(t, strings.Join(ranCommands[2], " "), "git config branch.feature.remote robot-fork") - eq(t, strings.Join(ranCommands[3], " "), "git config branch.feature.merge refs/heads/feature") + assert.Equal(t, 4, len(ranCommands)) + assert.Equal(t, "git fetch robot-fork +refs/heads/feature:refs/remotes/robot-fork/feature", strings.Join(ranCommands[0], " ")) + assert.Equal(t, "git checkout -b feature --no-track robot-fork/feature", strings.Join(ranCommands[1], " ")) + assert.Equal(t, "git config branch.feature.remote robot-fork", strings.Join(ranCommands[2], " ")) + assert.Equal(t, "git config branch.feature.merge refs/heads/feature", strings.Join(ranCommands[3], " ")) } func TestPRCheckout_differentRepo(t *testing.T) { @@ -398,14 +390,14 @@ func TestPRCheckout_differentRepo(t *testing.T) { defer restoreCmd() output, err := runCommand(http, nil, "master", `123`) - eq(t, err, nil) - eq(t, output.String(), "") + assert.NoError(t, err) + assert.Equal(t, "", output.String()) - eq(t, len(ranCommands), 4) - eq(t, strings.Join(ranCommands[0], " "), "git fetch origin refs/pull/123/head:feature") - eq(t, strings.Join(ranCommands[1], " "), "git checkout feature") - eq(t, strings.Join(ranCommands[2], " "), "git config branch.feature.remote origin") - eq(t, strings.Join(ranCommands[3], " "), "git config branch.feature.merge refs/pull/123/head") + assert.Equal(t, 4, len(ranCommands)) + assert.Equal(t, "git fetch origin refs/pull/123/head:feature", strings.Join(ranCommands[0], " ")) + assert.Equal(t, "git checkout feature", strings.Join(ranCommands[1], " ")) + assert.Equal(t, "git config branch.feature.remote origin", strings.Join(ranCommands[2], " ")) + assert.Equal(t, "git config branch.feature.merge refs/pull/123/head", strings.Join(ranCommands[3], " ")) } func TestPRCheckout_differentRepo_existingBranch(t *testing.T) { @@ -440,12 +432,12 @@ func TestPRCheckout_differentRepo_existingBranch(t *testing.T) { defer restoreCmd() output, err := runCommand(http, nil, "master", `123`) - eq(t, err, nil) - eq(t, output.String(), "") + assert.NoError(t, err) + assert.Equal(t, "", output.String()) - eq(t, len(ranCommands), 2) - eq(t, strings.Join(ranCommands[0], " "), "git fetch origin refs/pull/123/head:feature") - eq(t, strings.Join(ranCommands[1], " "), "git checkout feature") + assert.Equal(t, 2, len(ranCommands)) + assert.Equal(t, "git fetch origin refs/pull/123/head:feature", strings.Join(ranCommands[0], " ")) + assert.Equal(t, "git checkout feature", strings.Join(ranCommands[1], " ")) } func TestPRCheckout_detachedHead(t *testing.T) { @@ -480,12 +472,12 @@ func TestPRCheckout_detachedHead(t *testing.T) { defer restoreCmd() output, err := runCommand(http, nil, "", `123`) - eq(t, err, nil) - eq(t, output.String(), "") + assert.NoError(t, err) + assert.Equal(t, "", output.String()) - eq(t, len(ranCommands), 2) - eq(t, strings.Join(ranCommands[0], " "), "git fetch origin refs/pull/123/head:feature") - eq(t, strings.Join(ranCommands[1], " "), "git checkout feature") + assert.Equal(t, 2, len(ranCommands)) + assert.Equal(t, "git fetch origin refs/pull/123/head:feature", strings.Join(ranCommands[0], " ")) + assert.Equal(t, "git checkout feature", strings.Join(ranCommands[1], " ")) } func TestPRCheckout_differentRepo_currentBranch(t *testing.T) { @@ -520,12 +512,12 @@ func TestPRCheckout_differentRepo_currentBranch(t *testing.T) { defer restoreCmd() output, err := runCommand(http, nil, "feature", `123`) - eq(t, err, nil) - eq(t, output.String(), "") + assert.NoError(t, err) + assert.Equal(t, "", output.String()) - eq(t, len(ranCommands), 2) - eq(t, strings.Join(ranCommands[0], " "), "git fetch origin refs/pull/123/head") - eq(t, strings.Join(ranCommands[1], " "), "git merge --ff-only FETCH_HEAD") + assert.Equal(t, 2, len(ranCommands)) + assert.Equal(t, "git fetch origin refs/pull/123/head", strings.Join(ranCommands[0], " ")) + assert.Equal(t, "git merge --ff-only FETCH_HEAD", strings.Join(ranCommands[1], " ")) } func TestPRCheckout_differentRepo_invalidBranchName(t *testing.T) { @@ -554,9 +546,7 @@ func TestPRCheckout_differentRepo_invalidBranchName(t *testing.T) { defer restoreCmd() output, err := runCommand(http, nil, "master", `123`) - if assert.Errorf(t, err, "expected command to fail") { - assert.Equal(t, `invalid branch name: "-foo"`, err.Error()) - } + assert.EqualError(t, err, `invalid branch name: "-foo"`) assert.Equal(t, "", output.Stderr()) } @@ -592,14 +582,14 @@ func TestPRCheckout_maintainerCanModify(t *testing.T) { defer restoreCmd() output, err := runCommand(http, nil, "master", `123`) - eq(t, err, nil) - eq(t, output.String(), "") + assert.NoError(t, err) + assert.Equal(t, "", output.String()) - eq(t, len(ranCommands), 4) - eq(t, strings.Join(ranCommands[0], " "), "git fetch origin refs/pull/123/head:feature") - eq(t, strings.Join(ranCommands[1], " "), "git checkout feature") - eq(t, strings.Join(ranCommands[2], " "), "git config branch.feature.remote https://github.com/hubot/REPO.git") - eq(t, strings.Join(ranCommands[3], " "), "git config branch.feature.merge refs/heads/feature") + assert.Equal(t, 4, len(ranCommands)) + assert.Equal(t, "git fetch origin refs/pull/123/head:feature", strings.Join(ranCommands[0], " ")) + assert.Equal(t, "git checkout feature", strings.Join(ranCommands[1], " ")) + assert.Equal(t, "git config branch.feature.remote https://github.com/hubot/REPO.git", strings.Join(ranCommands[2], " ")) + assert.Equal(t, "git config branch.feature.merge refs/heads/feature", strings.Join(ranCommands[3], " ")) } func TestPRCheckout_recurseSubmodules(t *testing.T) { @@ -633,13 +623,13 @@ func TestPRCheckout_recurseSubmodules(t *testing.T) { defer restoreCmd() output, err := runCommand(http, nil, "master", `123 --recurse-submodules`) - eq(t, err, nil) - eq(t, output.String(), "") + assert.NoError(t, err) + assert.Equal(t, "", output.String()) - eq(t, len(ranCommands), 5) - eq(t, strings.Join(ranCommands[0], " "), "git fetch origin +refs/heads/feature:refs/remotes/origin/feature") - eq(t, strings.Join(ranCommands[1], " "), "git checkout feature") - eq(t, strings.Join(ranCommands[2], " "), "git merge --ff-only refs/remotes/origin/feature") - eq(t, strings.Join(ranCommands[3], " "), "git submodule sync --recursive") - eq(t, strings.Join(ranCommands[4], " "), "git submodule update --init --recursive") + assert.Equal(t, 5, len(ranCommands)) + assert.Equal(t, "git fetch origin +refs/heads/feature:refs/remotes/origin/feature", strings.Join(ranCommands[0], " ")) + assert.Equal(t, "git checkout feature", strings.Join(ranCommands[1], " ")) + assert.Equal(t, "git merge --ff-only refs/remotes/origin/feature", strings.Join(ranCommands[2], " ")) + assert.Equal(t, "git submodule sync --recursive", strings.Join(ranCommands[3], " ")) + assert.Equal(t, "git submodule update --init --recursive", strings.Join(ranCommands[4], " ")) } diff --git a/pkg/cmd/pr/checks/checks.go b/pkg/cmd/pr/checks/checks.go index f93cbe6d1..89eb8e5d3 100644 --- a/pkg/cmd/pr/checks/checks.go +++ b/pkg/cmd/pr/checks/checks.go @@ -24,6 +24,8 @@ type ChecksOptions struct { Branch func() (string, error) Remotes func() (context.Remotes, error) + WebMode bool + SelectorArg string } @@ -60,6 +62,8 @@ func NewCmdChecks(f *cmdutil.Factory, runF func(*ChecksOptions) error) *cobra.Co }, } + cmd.Flags().BoolVarP(&opts.WebMode, "web", "w", false, "Open the web browser to show details about checks") + return cmd } @@ -70,7 +74,7 @@ func checksRun(opts *ChecksOptions) error { } apiClient := api.NewClientFromHTTP(httpClient) - pr, _, err := shared.PRFromArgs(apiClient, opts.BaseRepo, opts.Branch, opts.Remotes, opts.SelectorArg) + pr, baseRepo, err := shared.PRFromArgs(apiClient, opts.BaseRepo, opts.Branch, opts.Remotes, opts.SelectorArg) if err != nil { return err } @@ -84,6 +88,16 @@ func checksRun(opts *ChecksOptions) error { return fmt.Errorf("no checks reported on the '%s' branch", pr.BaseRefName) } + isTerminal := opts.IO.IsStdoutTTY() + + if opts.WebMode { + openURL := ghrepo.GenerateRepoURL(baseRepo, "pull/%d/checks", pr.Number) + if isTerminal { + fmt.Fprintf(opts.IO.ErrOut, "Opening %s in your browser.\n", utils.DisplayURL(openURL)) + } + return utils.OpenInBrowser(openURL) + } + passing := 0 failing := 0 pending := 0 @@ -164,9 +178,8 @@ func checksRun(opts *ChecksOptions) error { if b0 == b1 { if n0 == n1 { return l0 < l1 - } else { - return n0 < n1 } + return n0 < n1 } return (b0 == "fail") || (b0 == "pending" && b1 == "success") @@ -175,7 +188,7 @@ func checksRun(opts *ChecksOptions) error { tp := utils.NewTablePrinter(opts.IO) for _, o := range outputs { - if opts.IO.IsStdoutTTY() { + if isTerminal { tp.AddField(o.mark, nil, o.markColor) tp.AddField(o.name, nil, nil) tp.AddField(o.elapsed, nil, nil) @@ -211,7 +224,7 @@ func checksRun(opts *ChecksOptions) error { summary = fmt.Sprintf("%s\n%s", cs.Bold(summary), tallies) } - if opts.IO.IsStdoutTTY() { + if isTerminal { fmt.Fprintln(opts.IO.Out, summary) fmt.Fprintln(opts.IO.Out) } diff --git a/pkg/cmd/pr/checks/checks_test.go b/pkg/cmd/pr/checks/checks_test.go index 324f8a74f..4e9167353 100644 --- a/pkg/cmd/pr/checks/checks_test.go +++ b/pkg/cmd/pr/checks/checks_test.go @@ -9,6 +9,7 @@ import ( "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" ) @@ -87,11 +88,13 @@ func Test_checksRun(t *testing.T) { { name: "no checks", stubs: func(reg *httpmock.Registry) { - reg.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { - "pullRequest": { "number": 123, "commits": { "nodes": [{"commit": {"oid": "abc"}}]}, "baseRefName": "master" } - } } } - `)) + reg.Register( + httpmock.GraphQL(`query PullRequestByNumber\b`), + httpmock.StringResponse(` + { "data": { "repository": { + "pullRequest": { "number": 123, "commits": { "nodes": [{"commit": {"oid": "abc"}}]}, "baseRefName": "master" } + } } } + `)) }, wantOut: "", wantErr: "no checks reported on the 'master' branch", @@ -124,10 +127,12 @@ func Test_checksRun(t *testing.T) { name: "no checks", nontty: true, stubs: func(reg *httpmock.Registry) { - reg.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { - "pullRequest": { "number": 123, "commits": { "nodes": [{"commit": {"oid": "abc"}}]}, "baseRefName": "master" } - } } } + reg.Register( + httpmock.GraphQL(`query PullRequestByNumber\b`), + httpmock.StringResponse(` + { "data": { "repository": { + "pullRequest": { "number": 123, "commits": { "nodes": [{"commit": {"oid": "abc"}}]}, "baseRefName": "master" } + } } } `)) }, wantOut: "", @@ -190,13 +195,73 @@ func Test_checksRun(t *testing.T) { } err := checksRun(opts) - if err != nil { - assert.Equal(t, tt.wantErr, err.Error()) - } else if tt.wantErr != "" { - t.Errorf("expected %q, got nil error", tt.wantErr) + if tt.wantErr != "" { + assert.EqualError(t, err, tt.wantErr) + } else { + assert.NoError(t, err) } assert.Equal(t, tt.wantOut, stdout.String()) }) } } + +func TestChecksRun_web(t *testing.T) { + tests := []struct { + name string + isTTY bool + wantStderr string + wantStdout string + }{ + { + name: "tty", + isTTY: true, + wantStderr: "Opening github.com/OWNER/REPO/pull/123/checks in your browser.\n", + wantStdout: "", + }, + { + name: "nontty", + isTTY: false, + wantStderr: "", + wantStdout: "", + }, + } + + reg := &httpmock.Registry{} + + opts := &ChecksOptions{ + WebMode: true, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + BaseRepo: func() (ghrepo.Interface, error) { + return ghrepo.New("OWNER", "REPO"), nil + }, + SelectorArg: "123", + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + reg.Register( + httpmock.GraphQL(`query PullRequestByNumber\b`), httpmock.FileResponse("./fixtures/allPassing.json")) + + io, _, stdout, stderr := iostreams.Test() + io.SetStdoutTTY(tc.isTTY) + io.SetStdinTTY(tc.isTTY) + io.SetStderrTTY(tc.isTTY) + + opts.IO = io + + cs, teardown := test.InitCmdStubber() + defer teardown() + + cs.Stub("") // browser open + + err := checksRun(opts) + assert.NoError(t, err) + assert.Equal(t, tc.wantStdout, stdout.String()) + assert.Equal(t, tc.wantStderr, stderr.String()) + reg.Verify(t) + }) + } +} diff --git a/pkg/cmd/pr/close/close_test.go b/pkg/cmd/pr/close/close_test.go index 136554b4f..3154b4e4b 100644 --- a/pkg/cmd/pr/close/close_test.go +++ b/pkg/cmd/pr/close/close_test.go @@ -14,6 +14,7 @@ import ( "github.com/cli/cli/pkg/iostreams" "github.com/cli/cli/test" "github.com/google/shlex" + "github.com/stretchr/testify/assert" ) func runCommand(rt http.RoundTripper, isTTY bool, cli string) (*test.CmdOut, error) { @@ -61,13 +62,20 @@ func TestPrClose(t *testing.T) { http := &httpmock.Registry{} defer http.Verify(t) - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { - "pullRequest": { "number": 96, "title": "The title of the PR" } - } } } - `)) - - http.StubResponse(200, bytes.NewBufferString(`{"id": "THE-ID"}`)) + http.Register( + httpmock.GraphQL(`query PullRequestByNumber\b`), + httpmock.StringResponse(` + { "data": { "repository": { + "pullRequest": { "id": "THE-ID", "number": 96, "title": "The title of the PR" } + } } }`), + ) + http.Register( + httpmock.GraphQL(`mutation PullRequestClose\b`), + httpmock.GraphQLMutation(`{"id": "THE-ID"}`, + func(inputs map[string]interface{}) { + assert.Equal(t, inputs["pullRequestId"], "THE-ID") + }), + ) output, err := runCommand(http, true, "96") if err != nil { @@ -85,11 +93,13 @@ func TestPrClose_alreadyClosed(t *testing.T) { http := &httpmock.Registry{} defer http.Verify(t) - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { - "pullRequest": { "number": 101, "title": "The title of the PR", "closed": true } - } } } - `)) + http.Register( + httpmock.GraphQL(`query PullRequestByNumber\b`), + httpmock.StringResponse(` + { "data": { "repository": { + "pullRequest": { "number": 101, "title": "The title of the PR", "closed": true } + } } }`), + ) output, err := runCommand(http, true, "101") if err != nil { @@ -106,12 +116,21 @@ func TestPrClose_alreadyClosed(t *testing.T) { func TestPrClose_deleteBranch(t *testing.T) { http := &httpmock.Registry{} defer http.Verify(t) - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { - "pullRequest": { "number": 96, "title": "The title of the PR", "headRefName":"blueberries", "headRepositoryOwner": {"login": "OWNER"}} - } } } - `)) - http.StubResponse(200, bytes.NewBufferString(`{"id": "THE-ID"}`)) + + http.Register( + httpmock.GraphQL(`query PullRequestByNumber\b`), + httpmock.StringResponse(` + { "data": { "repository": { + "pullRequest": { "id": "THE-ID", "number": 96, "title": "The title of the PR", "headRefName":"blueberries", "headRepositoryOwner": {"login": "OWNER"}} + } } }`), + ) + http.Register( + httpmock.GraphQL(`mutation PullRequestClose\b`), + httpmock.GraphQLMutation(`{"id": "THE-ID"}`, + func(inputs map[string]interface{}) { + assert.Equal(t, inputs["pullRequestId"], "THE-ID") + }), + ) http.Register( httpmock.REST("DELETE", "repos/OWNER/REPO/git/refs/heads/blueberries"), httpmock.StringResponse(`{}`)) diff --git a/pkg/cmd/pr/create/create.go b/pkg/cmd/pr/create/create.go index 9e0180965..009c567a7 100644 --- a/pkg/cmd/pr/create/create.go +++ b/pkg/cmd/pr/create/create.go @@ -38,8 +38,9 @@ type CreateOptions struct { RootDirOverride string RepoOverride string - Autofill bool - WebMode bool + Autofill bool + WebMode bool + RecoverFile string IsDraft bool Title string @@ -52,6 +53,8 @@ type CreateOptions struct { Labels []string Projects []string Milestone string + + MaintainerCanModify bool } type CreateContext struct { @@ -90,10 +93,14 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co A prompt will also ask for the title and the body of the pull request. Use '--title' and '--body' to skip this, or use '--fill' to autofill these values from git commits. + + By default users with write access to the base respository can add new commits to your branch. + If undesired, you may disable access of maintainers by using '--no-maintainer-edit' + You can always change this setting later via the web interface. `), Example: heredoc.Doc(` $ gh pr create --title "The bug is fixed" --body "Everything works again" - $ gh pr create --reviewer monalisa,hubot + $ gh pr create --reviewer monalisa,hubot --reviewer myorg/team-name $ gh pr create --project "Roadmap" $ gh pr create --base develop --head monalisa:feature `), @@ -102,6 +109,12 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co opts.TitleProvided = cmd.Flags().Changed("title") opts.BodyProvided = cmd.Flags().Changed("body") opts.RepoOverride, _ = cmd.Flags().GetString("repo") + noMaintainerEdit, _ := cmd.Flags().GetBool("no-maintainer-edit") + opts.MaintainerCanModify = !noMaintainerEdit + + if !opts.IO.CanPrompt() && opts.RecoverFile != "" { + return &cmdutil.FlagError{Err: errors.New("--recover only supported when running interactively")} + } if !opts.IO.CanPrompt() && !opts.WebMode && !opts.TitleProvided && !opts.Autofill { return &cmdutil.FlagError{Err: errors.New("--title or --fill required when not running interactively")} @@ -113,6 +126,9 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co if len(opts.Reviewers) > 0 && opts.WebMode { return errors.New("the --reviewer flag is not supported with --web") } + if cmd.Flags().Changed("no-maintainer-edit") && opts.WebMode { + return errors.New("the --no-maintainer-edit flag is not supported with --web") + } if runF != nil { return runF(opts) @@ -129,11 +145,13 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co fl.StringVarP(&opts.HeadBranch, "head", "H", "", "The `branch` that contains commits for your pull request (default: current branch)") fl.BoolVarP(&opts.WebMode, "web", "w", false, "Open the web browser to create a pull request") fl.BoolVarP(&opts.Autofill, "fill", "f", false, "Do not prompt for title/body and just use commit info") - fl.StringSliceVarP(&opts.Reviewers, "reviewer", "r", nil, "Request reviews from people by their `login`") + fl.StringSliceVarP(&opts.Reviewers, "reviewer", "r", nil, "Request reviews from people or teams by their `handle`") fl.StringSliceVarP(&opts.Assignees, "assignee", "a", nil, "Assign people by their `login`") fl.StringSliceVarP(&opts.Labels, "label", "l", nil, "Add labels by `name`") fl.StringSliceVarP(&opts.Projects, "project", "p", nil, "Add the pull request to projects by `name`") fl.StringVarP(&opts.Milestone, "milestone", "m", "", "Add the pull request to a milestone by `name`") + fl.Bool("no-maintainer-edit", false, "Disable maintainer's ability to modify pull request") + fl.StringVar(&opts.RecoverFile, "recover", "", "Recover input from a failed run of create") return cmd } @@ -141,14 +159,14 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co func createRun(opts *CreateOptions) (err error) { ctx, err := NewCreateContext(opts) if err != nil { - return err + return } client := ctx.Client state, err := NewIssueState(*ctx, *opts) if err != nil { - return err + return } if opts.WebMode { @@ -156,9 +174,9 @@ func createRun(opts *CreateOptions) (err error) { state.Title = opts.Title state.Body = opts.Body } - err := handlePush(*opts, *ctx) + err = handlePush(*opts, *ctx) if err != nil { - return err + return } return previewPR(*opts, *ctx, *state) } @@ -199,35 +217,46 @@ func createRun(opts *CreateOptions) (err error) { if opts.Autofill || (opts.TitleProvided && opts.BodyProvided) { err = handlePush(*opts, *ctx) if err != nil { - return err + return } return submitPR(*opts, *ctx, *state) } + if opts.RecoverFile != "" { + err = shared.FillFromJSON(opts.IO, opts.RecoverFile, state) + if err != nil { + return fmt.Errorf("failed to recover input: %w", err) + } + } + if !opts.TitleProvided { err = shared.TitleSurvey(state) if err != nil { - return err + return } } editorCommand, err := cmdutil.DetermineEditor(opts.Config) if err != nil { - return err + return } + defer shared.PreserveInput(opts.IO, state, &err)() + templateContent := "" if !opts.BodyProvided { - templateFiles, legacyTemplate := shared.FindTemplates(opts.RootDirOverride, "PULL_REQUEST_TEMPLATE") + if opts.RecoverFile == "" { + templateFiles, legacyTemplate := shared.FindTemplates(opts.RootDirOverride, "PULL_REQUEST_TEMPLATE") - templateContent, err = shared.TemplateSurvey(templateFiles, legacyTemplate, *state) - if err != nil { - return err + templateContent, err = shared.TemplateSurvey(templateFiles, legacyTemplate, *state) + if err != nil { + return + } } err = shared.BodySurvey(state, templateContent, editorCommand) if err != nil { - return err + return } if state.Body == "" { @@ -242,14 +271,20 @@ func createRun(opts *CreateOptions) (err error) { } if action == shared.MetadataAction { - err = shared.MetadataSurvey(opts.IO, client, ctx.BaseRepo, state) + fetcher := &shared.MetadataFetcher{ + IO: opts.IO, + APIClient: client, + Repo: ctx.BaseRepo, + State: state, + } + err = shared.MetadataSurvey(opts.IO, ctx.BaseRepo, fetcher, state) if err != nil { - return err + return } action, err = shared.ConfirmSubmission(!state.HasMetadata(), false) if err != nil { - return err + return } } @@ -260,7 +295,7 @@ func createRun(opts *CreateOptions) (err error) { err = handlePush(*opts, *ctx) if err != nil { - return err + return } if action == shared.PreviewAction { @@ -271,7 +306,8 @@ func createRun(opts *CreateOptions) (err error) { return submitPR(*opts, *ctx, *state) } - return errors.New("expected to cancel, preview, or submit") + err = errors.New("expected to cancel, preview, or submit") + return } func initDefaultTitleBody(ctx CreateContext, state *shared.IssueMetadataState) error { @@ -362,7 +398,7 @@ func NewIssueState(ctx CreateContext, opts CreateOptions) (*shared.IssueMetadata if opts.Autofill || !opts.TitleProvided || !opts.BodyProvided { err := initDefaultTitleBody(ctx, state) - if err != nil { + if err != nil && opts.Autofill { return nil, fmt.Errorf("could not compute title or body defaults: %w", err) } } @@ -537,11 +573,12 @@ func submitPR(opts CreateOptions, ctx CreateContext, state shared.IssueMetadataS client := ctx.Client params := map[string]interface{}{ - "title": state.Title, - "body": state.Body, - "draft": state.Draft, - "baseRefName": ctx.BaseBranch, - "headRefName": ctx.HeadBranchLabel, + "title": state.Title, + "body": state.Body, + "draft": state.Draft, + "baseRefName": ctx.BaseBranch, + "headRefName": ctx.HeadBranchLabel, + "maintainerCanModify": opts.MaintainerCanModify, } if params["title"] == "" { @@ -666,7 +703,7 @@ func generateCompareURL(ctx CreateContext, state shared.IssueMetadataState) (str u := ghrepo.GenerateRepoURL( ctx.BaseRepo, "compare/%s...%s?expand=1", - url.QueryEscape(ctx.BaseBranch), url.QueryEscape(ctx.HeadBranch)) + url.QueryEscape(ctx.BaseBranch), url.QueryEscape(ctx.HeadBranchLabel)) url, err := shared.WithPrAndIssueQueryParams(u, state) if err != nil { return "", err diff --git a/pkg/cmd/pr/create/create_test.go b/pkg/cmd/pr/create/create_test.go index 0c9a91c1a..6809a2040 100644 --- a/pkg/cmd/pr/create/create_test.go +++ b/pkg/cmd/pr/create/create_test.go @@ -3,9 +3,10 @@ package create import ( "bytes" "encoding/json" + "fmt" "io/ioutil" "net/http" - "reflect" + "os" "strings" "testing" @@ -26,13 +27,6 @@ import ( "github.com/stretchr/testify/require" ) -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, remotes context.Remotes, branch string, isTTY bool, cli string) (*test.CmdOut, error) { return runCommandWithRootDirOverridden(rt, remotes, branch, isTTY, cli, "") } @@ -113,12 +107,12 @@ func TestPRCreate_nontty_web(t *testing.T) { output, err := runCommand(http, nil, "feature", false, `--web --head=feature`) require.NoError(t, err) - eq(t, output.String(), "") - eq(t, output.Stderr(), "") + assert.Equal(t, "", output.String()) + assert.Equal(t, "", output.Stderr()) - eq(t, len(cs.Calls), 3) + assert.Equal(t, 3, len(cs.Calls)) browserCall := cs.Calls[2].Args - eq(t, browserCall[len(browserCall)-1], "https://github.com/OWNER/REPO/compare/master...feature?expand=1") + assert.Equal(t, "https://github.com/OWNER/REPO/compare/master...feature?expand=1", browserCall[len(browserCall)-1]) } @@ -127,29 +121,126 @@ func TestPRCreate_nontty_insufficient_flags(t *testing.T) { defer http.Verify(t) output, err := runCommand(http, nil, "feature", false, "") - if err == nil { - t.Fatal("expected error") - } - - assert.Equal(t, "--title or --fill required when not running interactively", err.Error()) + assert.EqualError(t, err, "--title or --fill required when not running interactively") assert.Equal(t, "", output.String()) } +func TestPRCreate_recover(t *testing.T) { + http := initFakeHTTP() + defer http.Verify(t) + + http.StubRepoInfoResponse("OWNER", "REPO", "master") + http.Register( + httpmock.GraphQL(`query PullRequestForBranch\b`), + httpmock.StringResponse(` + { "data": { "repository": { "pullRequests": { "nodes" : [ + ] } } } } + `)) + http.Register( + httpmock.GraphQL(`query RepositoryResolveMetadataIDs\b`), + httpmock.StringResponse(` + { "data": { + "u000": { "login": "jillValentine", "id": "JILLID" }, + "repository": {}, + "organization": {} + } } + `)) + http.Register( + httpmock.GraphQL(`mutation PullRequestCreateRequestReviews\b`), + httpmock.GraphQLMutation(` + { "data": { "requestReviews": { + "clientMutationId": "" + } } } + `, func(inputs map[string]interface{}) { + assert.Equal(t, []interface{}{"JILLID"}, inputs["userIds"]) + })) + http.Register( + httpmock.GraphQL(`mutation PullRequestCreate\b`), + httpmock.GraphQLMutation(` + { "data": { "createPullRequest": { "pullRequest": { + "URL": "https://github.com/OWNER/REPO/pull/12" + } } } } + `, func(input map[string]interface{}) { + assert.Equal(t, "recovered title", input["title"].(string)) + assert.Equal(t, "recovered body", input["body"].(string)) + })) + + cs, cmdTeardown := test.InitCmdStubber() + defer cmdTeardown() + + cs.Stub("") // git status + cs.Stub("1234567890,commit 0\n2345678901,commit 1") // git log + + as, teardown := prompt.InitAskStubber() + defer teardown() + as.Stub([]*prompt.QuestionStub{ + { + Name: "Title", + Default: true, + }, + }) + as.Stub([]*prompt.QuestionStub{ + { + Name: "Body", + Default: true, + }, + }) + as.Stub([]*prompt.QuestionStub{ + { + Name: "confirmation", + Value: 0, + }, + }) + + tmpfile, err := ioutil.TempFile(os.TempDir(), "testrecover*") + assert.NoError(t, err) + + state := prShared.IssueMetadataState{ + Title: "recovered title", + Body: "recovered body", + Reviewers: []string{"jillValentine"}, + } + + data, err := json.Marshal(state) + assert.NoError(t, err) + + _, err = tmpfile.Write(data) + assert.NoError(t, err) + + args := fmt.Sprintf("--recover '%s' -Hfeature", tmpfile.Name()) + + output, err := runCommandWithRootDirOverridden(http, nil, "feature", true, args, "") + assert.NoError(t, err) + + assert.Equal(t, "https://github.com/OWNER/REPO/pull/12\n", output.String()) +} + func TestPRCreate_nontty(t *testing.T) { http := initFakeHTTP() defer http.Verify(t) http.StubRepoInfoResponse("OWNER", "REPO", "master") - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { "pullRequests": { "nodes" : [ - ] } } } } - `)) - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "createPullRequest": { "pullRequest": { - "URL": "https://github.com/OWNER/REPO/pull/12" - } } } } - `)) + http.Register( + httpmock.GraphQL(`query PullRequestForBranch\b`), + httpmock.StringResponse(` + { "data": { "repository": { "pullRequests": { "nodes" : [ + ] } } } }`), + ) + http.Register( + httpmock.GraphQL(`mutation PullRequestCreate\b`), + httpmock.GraphQLMutation(` + { "data": { "createPullRequest": { "pullRequest": { + "URL": "https://github.com/OWNER/REPO/pull/12" + } } } }`, + func(input map[string]interface{}) { + assert.Equal(t, "REPOID", input["repositoryId"]) + assert.Equal(t, "my title", input["title"]) + assert.Equal(t, "my body", input["body"]) + assert.Equal(t, "master", input["baseRefName"]) + assert.Equal(t, "feature", input["headRefName"]) + }), + ) cs, cmdTeardown := test.InitCmdStubber() defer cmdTeardown() @@ -160,26 +251,6 @@ func TestPRCreate_nontty(t *testing.T) { output, err := runCommand(http, nil, "feature", false, `-t "my title" -b "my body" -H feature`) require.NoError(t, err) - bodyBytes, _ := ioutil.ReadAll(http.Requests[2].Body) - reqBody := struct { - Variables struct { - Input struct { - RepositoryID string - Title string - Body string - BaseRefName string - HeadRefName string - } - } - }{} - _ = json.Unmarshal(bodyBytes, &reqBody) - - assert.Equal(t, "REPOID", reqBody.Variables.Input.RepositoryID) - assert.Equal(t, "my title", reqBody.Variables.Input.Title) - assert.Equal(t, "my body", reqBody.Variables.Input.Body) - assert.Equal(t, "master", reqBody.Variables.Input.BaseRefName) - assert.Equal(t, "feature", reqBody.Variables.Input.HeadRefName) - assert.Equal(t, "", output.Stderr()) assert.Equal(t, "https://github.com/OWNER/REPO/pull/12\n", output.String()) } @@ -233,6 +304,57 @@ func TestPRCreate(t *testing.T) { assert.Equal(t, "\nCreating pull request for feature into master in OWNER/REPO\n\n", output.Stderr()) } +func TestPRCreate_NoMaintainerModify(t *testing.T) { + // TODO update this copypasta + http := initFakeHTTP() + defer http.Verify(t) + + http.StubRepoInfoResponse("OWNER", "REPO", "master") + http.StubRepoResponse("OWNER", "REPO") + http.Register( + httpmock.GraphQL(`query UserCurrent\b`), + httpmock.StringResponse(`{"data": {"viewer": {"login": "OWNER"} } }`)) + http.Register( + httpmock.GraphQL(`query PullRequestForBranch\b`), + httpmock.StringResponse(` + { "data": { "repository": { "pullRequests": { "nodes" : [ + ] } } } } + `)) + http.Register( + httpmock.GraphQL(`mutation PullRequestCreate\b`), + httpmock.GraphQLMutation(` + { "data": { "createPullRequest": { "pullRequest": { + "URL": "https://github.com/OWNER/REPO/pull/12" + } } } } + `, func(input map[string]interface{}) { + assert.Equal(t, false, input["maintainerCanModify"].(bool)) + assert.Equal(t, "REPOID", input["repositoryId"].(string)) + assert.Equal(t, "my title", input["title"].(string)) + assert.Equal(t, "my body", input["body"].(string)) + assert.Equal(t, "master", input["baseRefName"].(string)) + assert.Equal(t, "feature", input["headRefName"].(string)) + })) + + cs, cmdTeardown := test.InitCmdStubber() + defer cmdTeardown() + + cs.Stub("") // git config --get-regexp (determineTrackingBranch) + cs.Stub("") // git show-ref --verify (determineTrackingBranch) + cs.Stub("") // git status + cs.Stub("1234567890,commit 0\n2345678901,commit 1") // git log + cs.Stub("") // git push + + ask, cleanupAsk := prompt.InitAskStubber() + defer cleanupAsk() + ask.StubOne(0) + + output, err := runCommand(http, nil, "feature", true, `-t "my title" -b "my body" --no-maintainer-edit`) + require.NoError(t, err) + + assert.Equal(t, "https://github.com/OWNER/REPO/pull/12\n", output.String()) + assert.Equal(t, "\nCreating pull request for feature into master in OWNER/REPO\n\n", output.Stderr()) +} + func TestPRCreate_createFork(t *testing.T) { http := initFakeHTTP() defer http.Verify(t) @@ -449,7 +571,7 @@ func TestPRCreate_nonLegacyTemplate(t *testing.T) { output, err := runCommandWithRootDirOverridden(http, nil, "feature", true, `-t "my title" -H feature`, "./fixtures/repoWithNonLegacyPRTemplates") require.NoError(t, err) - eq(t, output.String(), "https://github.com/OWNER/REPO/pull/12\n") + assert.Equal(t, "https://github.com/OWNER/REPO/pull/12\n", output.String()) } func TestPRCreate_metadata(t *testing.T) { @@ -517,8 +639,8 @@ func TestPRCreate_metadata(t *testing.T) { "URL": "https://github.com/OWNER/REPO/pull/12" } } } } `, func(inputs map[string]interface{}) { - eq(t, inputs["title"], "TITLE") - eq(t, inputs["body"], "BODY") + assert.Equal(t, "TITLE", inputs["title"]) + assert.Equal(t, "BODY", inputs["body"]) if v, ok := inputs["assigneeIds"]; ok { t.Errorf("did not expect assigneeIds: %v", v) } @@ -533,11 +655,11 @@ func TestPRCreate_metadata(t *testing.T) { "clientMutationId": "" } } } `, func(inputs map[string]interface{}) { - eq(t, inputs["pullRequestId"], "NEWPULLID") - eq(t, inputs["assigneeIds"], []interface{}{"MONAID"}) - eq(t, inputs["labelIds"], []interface{}{"BUGID", "TODOID"}) - eq(t, inputs["projectIds"], []interface{}{"ROADMAPID"}) - eq(t, inputs["milestoneId"], "BIGONEID") + assert.Equal(t, "NEWPULLID", inputs["pullRequestId"]) + assert.Equal(t, []interface{}{"MONAID"}, inputs["assigneeIds"]) + assert.Equal(t, []interface{}{"BUGID", "TODOID"}, inputs["labelIds"]) + assert.Equal(t, []interface{}{"ROADMAPID"}, inputs["projectIds"]) + assert.Equal(t, "BIGONEID", inputs["milestoneId"]) })) http.Register( httpmock.GraphQL(`mutation PullRequestCreateRequestReviews\b`), @@ -546,10 +668,10 @@ func TestPRCreate_metadata(t *testing.T) { "clientMutationId": "" } } } `, func(inputs map[string]interface{}) { - eq(t, inputs["pullRequestId"], "NEWPULLID") - eq(t, inputs["userIds"], []interface{}{"HUBOTID", "MONAID"}) - eq(t, inputs["teamIds"], []interface{}{"COREID", "ROBOTID"}) - eq(t, inputs["union"], true) + assert.Equal(t, "NEWPULLID", inputs["pullRequestId"]) + assert.Equal(t, []interface{}{"HUBOTID", "MONAID"}, inputs["userIds"]) + assert.Equal(t, []interface{}{"COREID", "ROBOTID"}, inputs["teamIds"]) + assert.Equal(t, true, inputs["union"]) })) cs, cmdTeardown := test.InitCmdStubber() @@ -559,9 +681,9 @@ func TestPRCreate_metadata(t *testing.T) { cs.Stub("1234567890,commit 0\n2345678901,commit 1") // git log output, err := runCommand(http, nil, "feature", true, `-t TITLE -b BODY -H feature -a monalisa -l bug -l todo -p roadmap -m 'big one.oh' -r hubot -r monalisa -r /core -r /robots`) - eq(t, err, nil) + assert.NoError(t, err) - eq(t, output.String(), "https://github.com/OWNER/REPO/pull/12\n") + assert.Equal(t, "https://github.com/OWNER/REPO/pull/12\n", output.String()) } func TestPRCreate_alreadyExists(t *testing.T) { @@ -569,13 +691,15 @@ func TestPRCreate_alreadyExists(t *testing.T) { defer http.Verify(t) http.StubRepoInfoResponse("OWNER", "REPO", "master") - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { "pullRequests": { "nodes": [ - { "url": "https://github.com/OWNER/REPO/pull/123", - "headRefName": "feature", - "baseRefName": "master" } - ] } } } } - `)) + http.Register( + httpmock.GraphQL(`query PullRequestForBranch\b`), + httpmock.StringResponse(` + { "data": { "repository": { "pullRequests": { "nodes": [ + { "url": "https://github.com/OWNER/REPO/pull/123", + "headRefName": "feature", + "baseRefName": "master" } + ] } } } }`), + ) cs, cmdTeardown := test.InitCmdStubber() defer cmdTeardown() @@ -620,13 +744,13 @@ func TestPRCreate_web(t *testing.T) { output, err := runCommand(http, nil, "feature", true, `--web`) require.NoError(t, err) - eq(t, output.String(), "") - eq(t, output.Stderr(), "Opening github.com/OWNER/REPO/compare/master...feature in your browser.\n") + assert.Equal(t, "", output.String()) + assert.Equal(t, "Opening github.com/OWNER/REPO/compare/master...feature in your browser.\n", output.Stderr()) - eq(t, len(cs.Calls), 6) - eq(t, strings.Join(cs.Calls[4].Args, " "), "git push --set-upstream origin HEAD:feature") + assert.Equal(t, 6, len(cs.Calls)) + assert.Equal(t, "git push --set-upstream origin HEAD:feature", strings.Join(cs.Calls[4].Args, " ")) browserCall := cs.Calls[5].Args - eq(t, browserCall[len(browserCall)-1], "https://github.com/OWNER/REPO/compare/master...feature?expand=1") + assert.Equal(t, "https://github.com/OWNER/REPO/compare/master...feature?expand=1", browserCall[len(browserCall)-1]) } func Test_determineTrackingBranch_empty(t *testing.T) { @@ -694,10 +818,10 @@ deadbeef refs/remotes/upstream/feature`) // git show-ref --verify (ShowRefs) t.Fatal("expected result, got nil") } - eq(t, cs.Calls[1].Args, []string{"git", "show-ref", "--verify", "--", "HEAD", "refs/remotes/origin/feature", "refs/remotes/upstream/feature"}) + assert.Equal(t, []string{"git", "show-ref", "--verify", "--", "HEAD", "refs/remotes/origin/feature", "refs/remotes/upstream/feature"}, cs.Calls[1].Args) - eq(t, ref.RemoteName, "upstream") - eq(t, ref.BranchName, "feature") + assert.Equal(t, "upstream", ref.RemoteName) + assert.Equal(t, "feature", ref.BranchName) } func Test_determineTrackingBranch_respectTrackingConfig(t *testing.T) { @@ -721,7 +845,7 @@ deadb00f refs/remotes/origin/feature`) // git show-ref --verify (ShowRefs) t.Errorf("expected nil result, got %v", ref) } - eq(t, cs.Calls[1].Args, []string{"git", "show-ref", "--verify", "--", "HEAD", "refs/remotes/origin/great-feat", "refs/remotes/origin/feature"}) + assert.Equal(t, []string{"git", "show-ref", "--verify", "--", "HEAD", "refs/remotes/origin/great-feat", "refs/remotes/origin/feature"}, cs.Calls[1].Args) } func Test_generateCompareURL(t *testing.T) { @@ -735,9 +859,9 @@ func Test_generateCompareURL(t *testing.T) { { name: "basic", ctx: CreateContext{ - BaseRepo: ghrepo.New("OWNER", "REPO"), - BaseBranch: "main", - HeadBranch: "feature", + BaseRepo: ghrepo.New("OWNER", "REPO"), + BaseBranch: "main", + HeadBranchLabel: "feature", }, want: "https://github.com/OWNER/REPO/compare/main...feature?expand=1", wantErr: false, @@ -745,9 +869,9 @@ func Test_generateCompareURL(t *testing.T) { { name: "with labels", ctx: CreateContext{ - BaseRepo: ghrepo.New("OWNER", "REPO"), - BaseBranch: "a", - HeadBranch: "b", + BaseRepo: ghrepo.New("OWNER", "REPO"), + BaseBranch: "a", + HeadBranchLabel: "b", }, state: prShared.IssueMetadataState{ Labels: []string{"one", "two three"}, @@ -758,9 +882,9 @@ func Test_generateCompareURL(t *testing.T) { { name: "complex branch names", ctx: CreateContext{ - BaseRepo: ghrepo.New("OWNER", "REPO"), - BaseBranch: "main/trunk", - HeadBranch: "owner:feature", + BaseRepo: ghrepo.New("OWNER", "REPO"), + BaseBranch: "main/trunk", + HeadBranchLabel: "owner:feature", }, want: "https://github.com/OWNER/REPO/compare/main%2Ftrunk...owner%3Afeature?expand=1", wantErr: false, diff --git a/pkg/cmd/pr/create/regexp_writer_test.go b/pkg/cmd/pr/create/regexp_writer_test.go index fd772a760..f9ec0b863 100644 --- a/pkg/cmd/pr/create/regexp_writer_test.go +++ b/pkg/cmd/pr/create/regexp_writer_test.go @@ -93,14 +93,14 @@ func Test_Write(t *testing.T) { { name: "multiple lines removed", input: input{ - in: []string{"begining line\nremove this whole line\nremove this one also\nnot this one"}, + in: []string{"beginning line\nremove this whole line\nremove this one also\nnot this one"}, re: regexp.MustCompile("(?s)^remove.*$"), repl: "", }, output: output{ wantsErr: false, - out: "begining line\nnot this one", - length: 70, + out: "beginning line\nnot this one", + length: 71, }, }, { diff --git a/pkg/cmd/pr/diff/diff_test.go b/pkg/cmd/pr/diff/diff_test.go index 3e0b23adc..2e81116a4 100644 --- a/pkg/cmd/pr/diff/diff_test.go +++ b/pkg/cmd/pr/diff/diff_test.go @@ -164,44 +164,59 @@ func runCommand(rt http.RoundTripper, remotes context.Remotes, isTTY bool, cli s func TestPRDiff_no_current_pr(t *testing.T) { http := &httpmock.Registry{} defer http.Verify(t) - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { "pullRequests": { "nodes": [] } } } } - `)) + + http.Register( + httpmock.GraphQL(`query PullRequestForBranch\b`), + httpmock.StringResponse(` + { "data": { "repository": { + "pullRequests": { "nodes": [] } + } } }`), + ) + _, err := runCommand(http, nil, false, "") - if err == nil { - t.Fatal("expected error") - } - assert.Equal(t, `no pull requests found for branch "feature"`, err.Error()) + assert.EqualError(t, err, `no pull requests found for branch "feature"`) } func TestPRDiff_argument_not_found(t *testing.T) { http := &httpmock.Registry{} defer http.Verify(t) - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { - "pullRequest": { "number": 123 } - } } } -`)) - http.StubResponse(404, bytes.NewBufferString("")) + + http.Register( + httpmock.GraphQL(`query PullRequestByNumber\b`), + httpmock.StringResponse(` + { "data": { "repository": { + "pullRequest": { "number": 123 } + } } }`), + ) + http.Register( + httpmock.REST("GET", "repos/OWNER/REPO/pulls/123"), + httpmock.StatusStringResponse(404, ""), + ) + _, err := runCommand(http, nil, false, "123") - if err == nil { - t.Fatal("expected error", err) - } - assert.Equal(t, `could not find pull request diff: pull request not found`, err.Error()) + assert.EqualError(t, err, `could not find pull request diff: pull request not found`) } func TestPRDiff_notty(t *testing.T) { http := &httpmock.Registry{} defer http.Verify(t) - 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)) + + http.Register( + httpmock.GraphQL(`query PullRequestForBranch\b`), + httpmock.StringResponse(` + { "data": { "repository": { "pullRequests": { "nodes": [ + { "url": "https://github.com/OWNER/REPO/pull/123", + "number": 123, + "id": "foobar123", + "headRefName": "feature", + "baseRefName": "master" } + ] } } } }`), + ) + http.Register( + httpmock.REST("GET", "repos/OWNER/REPO/pulls/123"), + httpmock.StringResponse(testDiff), + ) + output, err := runCommand(http, nil, false, "") if err != nil { t.Fatalf("unexpected error: %s", err) @@ -214,15 +229,23 @@ func TestPRDiff_notty(t *testing.T) { func TestPRDiff_tty(t *testing.T) { http := &httpmock.Registry{} defer http.Verify(t) - 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)) + + http.Register( + httpmock.GraphQL(`query PullRequestForBranch\b`), + httpmock.StringResponse(` + { "data": { "repository": { "pullRequests": { "nodes": [ + { "url": "https://github.com/OWNER/REPO/pull/123", + "number": 123, + "id": "foobar123", + "headRefName": "feature", + "baseRefName": "master" } + ] } } } }`), + ) + http.Register( + httpmock.REST("GET", "repos/OWNER/REPO/pulls/123"), + httpmock.StringResponse(testDiff), + ) + output, err := runCommand(http, nil, true, "") if err != nil { t.Fatalf("unexpected error: %s", err) diff --git a/pkg/cmd/pr/list/list_test.go b/pkg/cmd/pr/list/list_test.go index ab99e4aa9..d1398a0d6 100644 --- a/pkg/cmd/pr/list/list_test.go +++ b/pkg/cmd/pr/list/list_test.go @@ -5,7 +5,6 @@ import ( "io/ioutil" "net/http" "os/exec" - "reflect" "strings" "testing" @@ -20,12 +19,6 @@ import ( "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) @@ -122,11 +115,11 @@ func TestPRList_filtering(t *testing.T) { t.Fatal(err) } - eq(t, output.Stderr(), "") - eq(t, output.String(), ` + assert.Equal(t, "", output.Stderr()) + assert.Equal(t, ` No pull requests match your search in OWNER/REPO -`) +`, output.String()) } func TestPRList_filteringRemoveDuplicate(t *testing.T) { @@ -220,12 +213,12 @@ func TestPRList_web(t *testing.T) { expectedURL := "https://github.com/OWNER/REPO/pulls?q=is%3Apr+is%3Amerged+assignee%3Apeter+label%3Abug+label%3Adocs+base%3Atrunk" - eq(t, output.String(), "") - eq(t, output.Stderr(), "Opening github.com/OWNER/REPO/pulls in your browser.\n") + assert.Equal(t, "", output.String()) + assert.Equal(t, "Opening github.com/OWNER/REPO/pulls in your browser.\n", output.Stderr()) if seenCmd == nil { t.Fatal("expected a command to run") } url := seenCmd.Args[len(seenCmd.Args)-1] - eq(t, url, expectedURL) + assert.Equal(t, url, expectedURL) } diff --git a/pkg/cmd/pr/merge/merge.go b/pkg/cmd/pr/merge/merge.go index 2eafe9712..4cac24242 100644 --- a/pkg/cmd/pr/merge/merge.go +++ b/pkg/cmd/pr/merge/merge.go @@ -27,11 +27,13 @@ type MergeOptions struct { Remotes func() (context.Remotes, error) Branch func() (string, error) - SelectorArg string - DeleteBranch bool - DeleteLocalBranch bool - MergeMethod api.PullRequestMergeMethod - InteractiveMode bool + SelectorArg string + DeleteBranch bool + MergeMethod api.PullRequestMergeMethod + + IsDeleteBranchIndicated bool + CanDeleteLocalBranch bool + InteractiveMode bool } func NewCmdMerge(f *cmdutil.Factory, runF func(*MergeOptions) error) *cobra.Command { @@ -54,9 +56,6 @@ func NewCmdMerge(f *cmdutil.Factory, runF func(*MergeOptions) error) *cobra.Comm Short: "Merge a pull request", Long: heredoc.Doc(` Merge a pull request on GitHub. - - By default, the head branch of the pull request will get deleted on both remote and local repositories. - To retain the branch, use '--delete-branch=false'. `), Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { @@ -93,7 +92,8 @@ func NewCmdMerge(f *cmdutil.Factory, runF func(*MergeOptions) error) *cobra.Comm return &cmdutil.FlagError{Err: errors.New("only one of --merge, --rebase, or --squash can be enabled")} } - opts.DeleteLocalBranch = !cmd.Flags().Changed("repo") + opts.IsDeleteBranchIndicated = cmd.Flags().Changed("delete-branch") + opts.CanDeleteLocalBranch = !cmd.Flags().Changed("repo") if runF != nil { return runF(opts) @@ -102,7 +102,7 @@ func NewCmdMerge(f *cmdutil.Factory, runF func(*MergeOptions) error) *cobra.Comm }, } - cmd.Flags().BoolVarP(&opts.DeleteBranch, "delete-branch", "d", true, "Delete the local and remote branch after merge") + cmd.Flags().BoolVarP(&opts.DeleteBranch, "delete-branch", "d", false, "Delete the local and remote branch after merge") cmd.Flags().BoolVarP(&flagMerge, "merge", "m", false, "Merge the commits with the base branch") cmd.Flags().BoolVarP(&flagRebase, "rebase", "r", false, "Rebase the commits onto the base branch") cmd.Flags().BoolVarP(&flagSquash, "squash", "s", false, "Squash the commits into one commit and merge it into the base branch") @@ -126,102 +126,102 @@ func mergeRun(opts *MergeOptions) error { if pr.Mergeable == "CONFLICTING" { err := fmt.Errorf("%s Pull request #%d (%s) has conflicts and isn't mergeable ", cs.Red("!"), pr.Number, pr.Title) return err - } else if pr.Mergeable == "UNKNOWN" { - err := fmt.Errorf("%s Pull request #%d (%s) can't be merged right now; try again in a few seconds", cs.Red("!"), pr.Number, pr.Title) - return err - } else if pr.State == "MERGED" { - err := fmt.Errorf("%s Pull request #%d (%s) was already merged", cs.Red("!"), pr.Number, pr.Title) - return err } - mergeMethod := opts.MergeMethod deleteBranch := opts.DeleteBranch crossRepoPR := pr.HeadRepositoryOwner.Login != baseRepo.RepoOwner() + isTerminal := opts.IO.IsStdoutTTY() - if opts.InteractiveMode { - mergeMethod, deleteBranch, err = prInteractiveMerge(opts.DeleteLocalBranch, crossRepoPR) - if err != nil { - if errors.Is(err, cancelError) { - fmt.Fprintln(opts.IO.ErrOut, "Cancelled.") - return cmdutil.SilentError + isPRAlreadyMerged := pr.State == "MERGED" + if !isPRAlreadyMerged { + mergeMethod := opts.MergeMethod + + if opts.InteractiveMode { + mergeMethod, deleteBranch, err = prInteractiveMerge(opts, crossRepoPR) + if err != nil { + if errors.Is(err, cancelError) { + fmt.Fprintln(opts.IO.ErrOut, "Cancelled.") + return cmdutil.SilentError + } + return err } + } + + err = api.PullRequestMerge(apiClient, baseRepo, pr, mergeMethod) + if err != nil { + return err + } + + if isTerminal { + action := "Merged" + switch mergeMethod { + case api.PullRequestMergeMethodRebase: + action = "Rebased and merged" + case api.PullRequestMergeMethodSquash: + action = "Squashed and merged" + } + fmt.Fprintf(opts.IO.ErrOut, "%s %s pull request #%d (%s)\n", cs.Magenta("✔"), action, pr.Number, pr.Title) + } + } else if !opts.IsDeleteBranchIndicated && opts.InteractiveMode { + err := prompt.SurveyAskOne(&survey.Confirm{ + Message: fmt.Sprintf("Pull request #%d was already merged. Delete the branch locally?", pr.Number), + Default: false, + }, &deleteBranch) + if err != nil { + return fmt.Errorf("could not prompt: %w", err) + } + } + + if !deleteBranch { + return nil + } + + branchSwitchString := "" + + if opts.CanDeleteLocalBranch && !crossRepoPR { + currentBranch, err := opts.Branch() + if err != nil { + return err + } + + var branchToSwitchTo string + if currentBranch == pr.HeadRefName { + branchToSwitchTo, err = api.RepoDefaultBranch(apiClient, baseRepo) + if err != nil { + return err + } + err = git.CheckoutBranch(branchToSwitchTo) + if err != nil { + return err + } + } + + localBranchExists := git.HasLocalBranch(pr.HeadRefName) + if localBranchExists { + err = git.DeleteLocalBranch(pr.HeadRefName) + if err != nil { + err = fmt.Errorf("failed to delete local branch %s: %w", cs.Cyan(pr.HeadRefName), err) + return err + } + } + + if branchToSwitchTo != "" { + branchSwitchString = fmt.Sprintf(" and switched to branch %s", cs.Cyan(branchToSwitchTo)) + } + } + + if !isPRAlreadyMerged && !crossRepoPR { + err = api.BranchDeleteRemote(apiClient, baseRepo, pr.HeadRefName) + var httpErr api.HTTPError + // The ref might have already been deleted by GitHub + if err != nil && (!errors.As(err, &httpErr) || httpErr.StatusCode != 422) { + err = fmt.Errorf("failed to delete remote branch %s: %w", cs.Cyan(pr.HeadRefName), err) return err } } - var action string - if mergeMethod == api.PullRequestMergeMethodRebase { - action = "Rebased and merged" - err = api.PullRequestMerge(apiClient, baseRepo, pr, api.PullRequestMergeMethodRebase) - } else if mergeMethod == api.PullRequestMergeMethodSquash { - action = "Squashed and merged" - err = api.PullRequestMerge(apiClient, baseRepo, pr, api.PullRequestMergeMethodSquash) - } else if mergeMethod == api.PullRequestMergeMethodMerge { - action = "Merged" - err = api.PullRequestMerge(apiClient, baseRepo, pr, api.PullRequestMergeMethodMerge) - } else { - err = fmt.Errorf("unknown merge method (%d) used", mergeMethod) - return err - } - - if err != nil { - return fmt.Errorf("API call failed: %w", err) - } - - isTerminal := opts.IO.IsStdoutTTY() - if isTerminal { - fmt.Fprintf(opts.IO.ErrOut, "%s %s pull request #%d (%s)\n", cs.Magenta("✔"), action, pr.Number, pr.Title) - } - - if deleteBranch { - branchSwitchString := "" - - if opts.DeleteLocalBranch && !crossRepoPR { - currentBranch, err := opts.Branch() - if err != nil { - return err - } - - var branchToSwitchTo string - if currentBranch == pr.HeadRefName { - branchToSwitchTo, err = api.RepoDefaultBranch(apiClient, baseRepo) - if err != nil { - return err - } - err = git.CheckoutBranch(branchToSwitchTo) - if err != nil { - return err - } - } - - localBranchExists := git.HasLocalBranch(pr.HeadRefName) - if localBranchExists { - err = git.DeleteLocalBranch(pr.HeadRefName) - if err != nil { - err = fmt.Errorf("failed to delete local branch %s: %w", cs.Cyan(pr.HeadRefName), err) - return err - } - } - - if branchToSwitchTo != "" { - branchSwitchString = fmt.Sprintf(" and switched to branch %s", cs.Cyan(branchToSwitchTo)) - } - } - - if !crossRepoPR { - err = api.BranchDeleteRemote(apiClient, baseRepo, pr.HeadRefName) - var httpErr api.HTTPError - // The ref might have already been deleted by GitHub - if err != nil && (!errors.As(err, &httpErr) || httpErr.StatusCode != 422) { - err = fmt.Errorf("failed to delete remote branch %s: %w", cs.Cyan(pr.HeadRefName), err) - return err - } - } - - if isTerminal { - fmt.Fprintf(opts.IO.ErrOut, "%s Deleted branch %s%s\n", cs.Red("✔"), cs.Cyan(pr.HeadRefName), branchSwitchString) - } + fmt.Fprintf(opts.IO.ErrOut, "%s Deleted branch %s%s\n", cs.Red("✔"), cs.Cyan(pr.HeadRefName), branchSwitchString) } return nil @@ -229,7 +229,7 @@ func mergeRun(opts *MergeOptions) error { var cancelError = errors.New("cancelError") -func prInteractiveMerge(deleteLocalBranch bool, crossRepoPR bool) (api.PullRequestMergeMethod, bool, error) { +func prInteractiveMerge(opts *MergeOptions, crossRepoPR bool) (api.PullRequestMergeMethod, bool, error) { mergeMethodQuestion := &survey.Question{ Name: "mergeMethod", Prompt: &survey.Select{ @@ -241,9 +241,9 @@ func prInteractiveMerge(deleteLocalBranch bool, crossRepoPR bool) (api.PullReque qs := []*survey.Question{mergeMethodQuestion} - if !crossRepoPR { + if !crossRepoPR && !opts.IsDeleteBranchIndicated { var message string - if deleteLocalBranch { + if opts.CanDeleteLocalBranch { message = "Delete the branch locally and on GitHub?" } else { message = "Delete the branch on GitHub?" @@ -271,7 +271,9 @@ func prInteractiveMerge(deleteLocalBranch bool, crossRepoPR bool) (api.PullReque MergeMethod int DeleteBranch bool IsConfirmed bool - }{} + }{ + DeleteBranch: opts.DeleteBranch, + } err := prompt.SurveyAsk(qs, &answers) if err != nil { @@ -291,6 +293,5 @@ func prInteractiveMerge(deleteLocalBranch bool, crossRepoPR bool) (api.PullReque mergeMethod = api.PullRequestMergeMethodSquash } - deleteBranch := answers.DeleteBranch - return mergeMethod, deleteBranch, nil + return mergeMethod, answers.DeleteBranch, nil } diff --git a/pkg/cmd/pr/merge/merge_test.go b/pkg/cmd/pr/merge/merge_test.go index 2815617d2..963fe13fe 100644 --- a/pkg/cmd/pr/merge/merge_test.go +++ b/pkg/cmd/pr/merge/merge_test.go @@ -14,6 +14,7 @@ import ( "github.com/cli/cli/git" "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" @@ -37,11 +38,25 @@ func Test_NewCmdMerge(t *testing.T) { args: "123", isTTY: true, want: MergeOptions{ - SelectorArg: "123", - DeleteBranch: true, - DeleteLocalBranch: true, - MergeMethod: api.PullRequestMergeMethodMerge, - InteractiveMode: true, + SelectorArg: "123", + DeleteBranch: false, + IsDeleteBranchIndicated: false, + CanDeleteLocalBranch: true, + MergeMethod: api.PullRequestMergeMethodMerge, + InteractiveMode: true, + }, + }, + { + name: "delete-branch specified", + args: "--delete-branch=false", + isTTY: true, + want: MergeOptions{ + SelectorArg: "", + DeleteBranch: false, + IsDeleteBranchIndicated: true, + CanDeleteLocalBranch: true, + MergeMethod: api.PullRequestMergeMethodMerge, + InteractiveMode: true, }, }, { @@ -105,7 +120,7 @@ func Test_NewCmdMerge(t *testing.T) { assert.Equal(t, tt.want.SelectorArg, opts.SelectorArg) assert.Equal(t, tt.want.DeleteBranch, opts.DeleteBranch) - assert.Equal(t, tt.want.DeleteLocalBranch, opts.DeleteLocalBranch) + assert.Equal(t, tt.want.CanDeleteLocalBranch, opts.CanDeleteLocalBranch) assert.Equal(t, tt.want.MergeMethod, opts.MergeMethod) assert.Equal(t, tt.want.InteractiveMode, opts.InteractiveMode) }) @@ -192,9 +207,6 @@ func TestPrMerge(t *testing.T) { assert.Equal(t, "MERGE", input["mergeMethod"].(string)) assert.NotContains(t, input, "commitHeadline") })) - http.Register( - httpmock.REST("DELETE", "repos/OWNER/REPO/git/refs/heads/blueberries"), - httpmock.StringResponse(`{}`)) cs, cmdTeardown := test.InitCmdStubber() defer cmdTeardown() @@ -238,9 +250,6 @@ func TestPrMerge_nontty(t *testing.T) { assert.Equal(t, "MERGE", input["mergeMethod"].(string)) assert.NotContains(t, input, "commitHeadline") })) - http.Register( - httpmock.REST("DELETE", "repos/OWNER/REPO/git/refs/heads/blueberries"), - httpmock.StringResponse(`{}`)) cs, cmdTeardown := test.InitCmdStubber() defer cmdTeardown() @@ -281,9 +290,6 @@ func TestPrMerge_withRepoFlag(t *testing.T) { assert.Equal(t, "MERGE", input["mergeMethod"].(string)) assert.NotContains(t, input, "commitHeadline") })) - http.Register( - httpmock.REST("DELETE", "repos/OWNER/REPO/git/refs/heads/blueberries"), - httpmock.StringResponse(`{}`)) cs, cmdTeardown := test.InitCmdStubber() defer cmdTeardown() @@ -385,9 +391,6 @@ func TestPrMerge_noPrNumberGiven(t *testing.T) { assert.Equal(t, "MERGE", input["mergeMethod"].(string)) assert.NotContains(t, input, "commitHeadline") })) - http.Register( - httpmock.REST("DELETE", "repos/OWNER/REPO/git/refs/heads/blueberries"), - httpmock.StringResponse(`{}`)) cs, cmdTeardown := test.InitCmdStubber() defer cmdTeardown() @@ -431,9 +434,6 @@ func TestPrMerge_rebase(t *testing.T) { assert.Equal(t, "REBASE", input["mergeMethod"].(string)) assert.NotContains(t, input, "commitHeadline") })) - http.Register( - httpmock.REST("DELETE", "repos/OWNER/REPO/git/refs/heads/blueberries"), - httpmock.StringResponse(`{}`)) cs, cmdTeardown := test.InitCmdStubber() defer cmdTeardown() @@ -476,9 +476,6 @@ func TestPrMerge_squash(t *testing.T) { assert.Equal(t, "SQUASH", input["mergeMethod"].(string)) assert.Equal(t, "The title of the PR (#3)", input["commitHeadline"].(string)) })) - http.Register( - httpmock.REST("DELETE", "repos/OWNER/REPO/git/refs/heads/blueberries"), - httpmock.StringResponse(`{}`)) cs, cmdTeardown := test.InitCmdStubber() defer cmdTeardown() @@ -493,10 +490,49 @@ func TestPrMerge_squash(t *testing.T) { t.Fatalf("error running command `pr merge`: %v", err) } - test.ExpectLines(t, output.Stderr(), "Squashed and merged pull request #3", `Deleted branch.*blueberries`) + test.ExpectLines(t, output.Stderr(), "Squashed and merged pull request #3") } func TestPrMerge_alreadyMerged(t *testing.T) { + http := initFakeHTTP() + defer http.Verify(t) + http.Register( + httpmock.GraphQL(`query PullRequestByNumber\b`), + httpmock.StringResponse(` + { "data": { "repository": { + "pullRequest": { + "number": 4, + "title": "The title of the PR", + "state": "MERGED", + "baseRefName": "master", + "headRefName": "blueberries", + "headRepositoryOwner": { + "login": "OWNER" + }, + "isCrossRepository": false + } + } } }`)) + + cs, cmdTeardown := run.Stub() + defer cmdTeardown(t) + + cs.Register(`git checkout master`, 0, "") + cs.Register(`git rev-parse --verify refs/heads/blueberries`, 0, "") + cs.Register(`git branch -D blueberries`, 0, "") + + as, surveyTeardown := prompt.InitAskStubber() + defer surveyTeardown() + as.StubOne(true) + + output, err := runCommand(http, "blueberries", true, "pr merge 4") + if err != nil { + t.Fatalf("Got unexpected error running `pr merge` %s", err) + } + + test.ExpectLines(t, output.Stderr(), "✔ Deleted branch blueberries and switched to branch master") +} + +func TestPrMerge_alreadyMerged_nonInteractive(t *testing.T) { http := initFakeHTTP() defer http.Verify(t) http.Register( @@ -506,24 +542,16 @@ func TestPrMerge_alreadyMerged(t *testing.T) { "pullRequest": { "number": 4, "title": "The title of the PR", "state": "MERGED"} } } }`)) - cs, cmdTeardown := test.InitCmdStubber() - defer cmdTeardown() + _, cmdTeardown := run.Stub() + defer cmdTeardown(t) - cs.Stub("") // git config --get-regexp ^branch\.blueberries\.(remote|merge)$ - cs.Stub("") // git symbolic-ref --quiet --short HEAD - cs.Stub("") // git checkout master - cs.Stub("") // git branch -d - - output, err := runCommand(http, "master", true, "pr merge 4") - if err == nil { - t.Fatalf("expected an error running command `pr merge`: %v", err) + output, err := runCommand(http, "blueberries", true, "pr merge 4 --merge") + if err != nil { + t.Fatalf("Got unexpected error running `pr merge` %s", err) } - r := regexp.MustCompile(`Pull request #4 \(The title of the PR\) was already merged`) - - if !r.MatchString(err.Error()) { - t.Fatalf("output did not match regexp /%s/\n> output\n%q\n", r, output.Stderr()) - } + assert.Equal(t, "", output.String()) + assert.Equal(t, "", output.Stderr()) } func TestPRMerge_interactive(t *testing.T) { @@ -581,7 +609,61 @@ func TestPRMerge_interactive(t *testing.T) { t.Fatalf("Got unexpected error running `pr merge` %s", err) } - test.ExpectLines(t, output.Stderr(), "Merged pull request #3", `Deleted branch.*blueberries`) + test.ExpectLines(t, output.Stderr(), "Merged pull request #3") +} + +func TestPRMerge_interactiveWithDeleteBranch(t *testing.T) { + http := initFakeHTTP() + defer http.Verify(t) + http.Register( + httpmock.GraphQL(`query PullRequestForBranch\b`), + httpmock.StringResponse(` + { "data": { "repository": { "pullRequests": { "nodes": [{ + "headRefName": "blueberries", + "headRepositoryOwner": {"login": "OWNER"}, + "id": "THE-ID", + "number": 3 + }] } } } }`)) + http.Register( + httpmock.GraphQL(`mutation PullRequestMerge\b`), + httpmock.GraphQLMutation(`{}`, func(input map[string]interface{}) { + assert.Equal(t, "THE-ID", input["pullRequestId"].(string)) + assert.Equal(t, "MERGE", input["mergeMethod"].(string)) + assert.NotContains(t, input, "commitHeadline") + })) + http.Register( + httpmock.REST("DELETE", "repos/OWNER/REPO/git/refs/heads/blueberries"), + httpmock.StringResponse(`{}`)) + + cs, cmdTeardown := test.InitCmdStubber() + defer cmdTeardown() + + cs.Stub("") // git config --get-regexp ^branch\.blueberries\.(remote|merge)$ + cs.Stub("") // git symbolic-ref --quiet --short HEAD + cs.Stub("") // git checkout master + cs.Stub("") // git push origin --delete blueberries + cs.Stub("") // git branch -d + + as, surveyTeardown := prompt.InitAskStubber() + defer surveyTeardown() + + as.Stub([]*prompt.QuestionStub{ + { + Name: "mergeMethod", + Value: 0, + }, + { + Name: "isConfirmed", + Value: true, + }, + }) + + output, err := runCommand(http, "blueberries", true, "-d") + if err != nil { + t.Fatalf("Got unexpected error running `pr merge` %s", err) + } + + test.ExpectLines(t, output.Stderr(), "Merged pull request #3", "Deleted branch blueberries and switched to branch master") } func TestPRMerge_interactiveCancelled(t *testing.T) { diff --git a/pkg/cmd/pr/ready/ready_test.go b/pkg/cmd/pr/ready/ready_test.go index 4dc5d9eee..caf2c7e5f 100644 --- a/pkg/cmd/pr/ready/ready_test.go +++ b/pkg/cmd/pr/ready/ready_test.go @@ -143,12 +143,20 @@ func TestPRReady(t *testing.T) { http := &httpmock.Registry{} defer http.Verify(t) - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { - "pullRequest": { "number": 444, "closed": false, "isDraft": true} - } } } - `)) - http.StubResponse(200, bytes.NewBufferString(`{"id": "THE-ID"}`)) + http.Register( + httpmock.GraphQL(`query PullRequestByNumber\b`), + httpmock.StringResponse(` + { "data": { "repository": { + "pullRequest": { "id": "THE-ID", "number": 444, "closed": false, "isDraft": true} + } } }`), + ) + http.Register( + httpmock.GraphQL(`mutation PullRequestReadyForReview\b`), + httpmock.GraphQLMutation(`{"id": "THE-ID"}`, + func(inputs map[string]interface{}) { + assert.Equal(t, inputs["pullRequestId"], "THE-ID") + }), + ) output, err := runCommand(http, true, "444") if err != nil { @@ -166,11 +174,13 @@ func TestPRReady_alreadyReady(t *testing.T) { http := &httpmock.Registry{} defer http.Verify(t) - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { - "pullRequest": { "number": 445, "closed": false, "isDraft": false} - } } } - `)) + http.Register( + httpmock.GraphQL(`query PullRequestByNumber\b`), + httpmock.StringResponse(` + { "data": { "repository": { + "pullRequest": { "number": 445, "closed": false, "isDraft": false} + } } }`), + ) output, err := runCommand(http, true, "445") if err != nil { @@ -188,11 +198,13 @@ func TestPRReady_closed(t *testing.T) { http := &httpmock.Registry{} defer http.Verify(t) - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { - "pullRequest": { "number": 446, "closed": true, "isDraft": true} - } } } - `)) + http.Register( + httpmock.GraphQL(`query PullRequestByNumber\b`), + httpmock.StringResponse(` + { "data": { "repository": { + "pullRequest": { "number": 446, "closed": true, "isDraft": true} + } } }`), + ) output, err := runCommand(http, true, "446") if err == nil { diff --git a/pkg/cmd/pr/reopen/reopen_test.go b/pkg/cmd/pr/reopen/reopen_test.go index 24dfa488f..19c4a1e6a 100644 --- a/pkg/cmd/pr/reopen/reopen_test.go +++ b/pkg/cmd/pr/reopen/reopen_test.go @@ -14,6 +14,7 @@ import ( "github.com/cli/cli/pkg/iostreams" "github.com/cli/cli/test" "github.com/google/shlex" + "github.com/stretchr/testify/assert" ) func runCommand(rt http.RoundTripper, isTTY bool, cli string) (*test.CmdOut, error) { @@ -58,13 +59,20 @@ func TestPRReopen(t *testing.T) { http := &httpmock.Registry{} defer http.Verify(t) - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { - "pullRequest": { "number": 666, "title": "The title of the PR", "closed": true} - } } } - `)) - - http.StubResponse(200, bytes.NewBufferString(`{"id": "THE-ID"}`)) + http.Register( + httpmock.GraphQL(`query PullRequestByNumber\b`), + httpmock.StringResponse(` + { "data": { "repository": { + "pullRequest": { "id": "THE-ID", "number": 666, "title": "The title of the PR", "closed": true} + } } }`), + ) + http.Register( + httpmock.GraphQL(`mutation PullRequestReopen\b`), + httpmock.GraphQLMutation(`{"id": "THE-ID"}`, + func(inputs map[string]interface{}) { + assert.Equal(t, inputs["pullRequestId"], "THE-ID") + }), + ) output, err := runCommand(http, true, "666") if err != nil { @@ -82,11 +90,13 @@ func TestPRReopen_alreadyOpen(t *testing.T) { http := &httpmock.Registry{} defer http.Verify(t) - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { - "pullRequest": { "number": 666, "title": "The title of the PR", "closed": false} - } } } - `)) + http.Register( + httpmock.GraphQL(`query PullRequestByNumber\b`), + httpmock.StringResponse(` + { "data": { "repository": { + "pullRequest": { "number": 666, "title": "The title of the PR", "closed": false} + } } }`), + ) output, err := runCommand(http, true, "666") if err != nil { @@ -104,11 +114,13 @@ func TestPRReopen_alreadyMerged(t *testing.T) { http := &httpmock.Registry{} defer http.Verify(t) - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { - "pullRequest": { "number": 666, "title": "The title of the PR", "closed": true, "state": "MERGED"} - } } } - `)) + http.Register( + httpmock.GraphQL(`query PullRequestByNumber\b`), + httpmock.StringResponse(` + { "data": { "repository": { + "pullRequest": { "number": 666, "title": "The title of the PR", "closed": true, "state": "MERGED"} + } } }`), + ) output, err := runCommand(http, true, "666") if err == nil { diff --git a/pkg/cmd/pr/review/review.go b/pkg/cmd/pr/review/review.go index 4bb3317cf..4fb23ec97 100644 --- a/pkg/cmd/pr/review/review.go +++ b/pkg/cmd/pr/review/review.go @@ -60,13 +60,13 @@ func NewCmdReview(f *cmdutil.Factory, runF func(*ReviewOptions) error) *cobra.Co 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" `), diff --git a/pkg/cmd/pr/review/review_test.go b/pkg/cmd/pr/review/review_test.go index b96ab8089..21e513412 100644 --- a/pkg/cmd/pr/review/review_test.go +++ b/pkg/cmd/pr/review/review_test.go @@ -2,7 +2,6 @@ package review import ( "bytes" - "encoding/json" "io/ioutil" "net/http" "regexp" @@ -183,24 +182,36 @@ func runCommand(rt http.RoundTripper, remotes context.Remotes, isTTY bool, cli s func TestPRReview_url_arg(t *testing.T) { http := &httpmock.Registry{} defer http.Verify(t) - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { "pullRequest": { - "id": "foobar123", - "number": 123, - "headRefName": "feature", - "headRepositoryOwner": { - "login": "hubot" - }, - "headRepository": { - "name": "REPO", - "defaultBranchRef": { - "name": "master" - } - }, - "isCrossRepository": false, - "maintainerCanModify": false - } } } } `)) - http.StubResponse(200, bytes.NewBufferString(`{"data": {} }`)) + + http.Register( + httpmock.GraphQL(`query PullRequestByNumber\b`), + httpmock.StringResponse(` + { "data": { "repository": { "pullRequest": { + "id": "foobar123", + "number": 123, + "headRefName": "feature", + "headRepositoryOwner": { + "login": "hubot" + }, + "headRepository": { + "name": "REPO", + "defaultBranchRef": { + "name": "master" + } + }, + "isCrossRepository": false, + "maintainerCanModify": false + } } } }`), + ) + http.Register( + httpmock.GraphQL(`mutation PullRequestReviewAdd\b`), + httpmock.GraphQLMutation(`{"data": {} }`, + func(inputs map[string]interface{}) { + assert.Equal(t, inputs["pullRequestId"], "foobar123") + assert.Equal(t, inputs["event"], "APPROVE") + assert.Equal(t, inputs["body"], "") + }), + ) output, err := runCommand(http, nil, true, "--approve https://github.com/OWNER/REPO/pull/123") if err != nil { @@ -208,45 +219,41 @@ func TestPRReview_url_arg(t *testing.T) { } test.ExpectLines(t, output.Stderr(), "Approved pull request #123") - - bodyBytes, _ := ioutil.ReadAll(http.Requests[1].Body) - reqBody := struct { - Variables struct { - Input struct { - PullRequestID string - Event string - Body string - } - } - }{} - _ = json.Unmarshal(bodyBytes, &reqBody) - - assert.Equal(t, "foobar123", reqBody.Variables.Input.PullRequestID) - assert.Equal(t, "APPROVE", reqBody.Variables.Input.Event) - assert.Equal(t, "", reqBody.Variables.Input.Body) } func TestPRReview_number_arg(t *testing.T) { http := &httpmock.Registry{} defer http.Verify(t) - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { "pullRequest": { - "id": "foobar123", - "number": 123, - "headRefName": "feature", - "headRepositoryOwner": { - "login": "hubot" - }, - "headRepository": { - "name": "REPO", - "defaultBranchRef": { - "name": "master" - } - }, - "isCrossRepository": false, - "maintainerCanModify": false - } } } } `)) - http.StubResponse(200, bytes.NewBufferString(`{"data": {} }`)) + + http.Register( + httpmock.GraphQL(`query PullRequestByNumber\b`), + httpmock.StringResponse(` + { "data": { "repository": { "pullRequest": { + "id": "foobar123", + "number": 123, + "headRefName": "feature", + "headRepositoryOwner": { + "login": "hubot" + }, + "headRepository": { + "name": "REPO", + "defaultBranchRef": { + "name": "master" + } + }, + "isCrossRepository": false, + "maintainerCanModify": false + } } } } `), + ) + http.Register( + httpmock.GraphQL(`mutation PullRequestReviewAdd`), + httpmock.GraphQLMutation(`{"data": {} }`, + func(inputs map[string]interface{}) { + assert.Equal(t, inputs["pullRequestId"], "foobar123") + assert.Equal(t, inputs["event"], "APPROVE") + assert.Equal(t, inputs["body"], "") + }), + ) output, err := runCommand(http, nil, true, "--approve 123") if err != nil { @@ -254,36 +261,32 @@ func TestPRReview_number_arg(t *testing.T) { } test.ExpectLines(t, output.Stderr(), "Approved pull request #123") - - bodyBytes, _ := ioutil.ReadAll(http.Requests[1].Body) - reqBody := struct { - Variables struct { - Input struct { - PullRequestID string - Event string - Body string - } - } - }{} - _ = json.Unmarshal(bodyBytes, &reqBody) - - assert.Equal(t, "foobar123", reqBody.Variables.Input.PullRequestID) - assert.Equal(t, "APPROVE", reqBody.Variables.Input.Event) - assert.Equal(t, "", reqBody.Variables.Input.Body) } func TestPRReview_no_arg(t *testing.T) { http := &httpmock.Registry{} defer http.Verify(t) - 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(`{"data": {} }`)) + + http.Register( + httpmock.GraphQL(`query PullRequestForBranch\b`), + httpmock.StringResponse(` + { "data": { "repository": { "pullRequests": { "nodes": [ + { "url": "https://github.com/OWNER/REPO/pull/123", + "number": 123, + "id": "foobar123", + "headRefName": "feature", + "baseRefName": "master" } + ] } } } }`), + ) + http.Register( + httpmock.GraphQL(`mutation PullRequestReviewAdd\b`), + httpmock.GraphQLMutation(`{"data": {} }`, + func(inputs map[string]interface{}) { + assert.Equal(t, inputs["pullRequestId"], "foobar123") + assert.Equal(t, inputs["event"], "COMMENT") + assert.Equal(t, inputs["body"], "cool story") + }), + ) output, err := runCommand(http, nil, true, `--comment -b "cool story"`) if err != nil { @@ -291,22 +294,6 @@ func TestPRReview_no_arg(t *testing.T) { } test.ExpectLines(t, output.Stderr(), "Reviewed pull request #123") - - bodyBytes, _ := ioutil.ReadAll(http.Requests[1].Body) - reqBody := struct { - Variables struct { - Input struct { - PullRequestID string - Event string - Body string - } - } - }{} - _ = json.Unmarshal(bodyBytes, &reqBody) - - assert.Equal(t, "foobar123", reqBody.Variables.Input.PullRequestID) - assert.Equal(t, "COMMENT", reqBody.Variables.Input.Event) - assert.Equal(t, "cool story", reqBody.Variables.Input.Body) } func TestPRReview(t *testing.T) { @@ -319,41 +306,37 @@ func TestPRReview(t *testing.T) { {`--request-changes -b"bad"`, "REQUEST_CHANGES", "bad"}, {`--approve`, "APPROVE", ""}, {`--approve -b"hot damn"`, "APPROVE", "hot damn"}, - {`--comment --body "i donno"`, "COMMENT", "i donno"}, + {`--comment --body "i dunno"`, "COMMENT", "i dunno"}, } for _, kase := range cases { t.Run(kase.Cmd, func(t *testing.T) { http := &httpmock.Registry{} defer http.Verify(t) - http.StubResponse(200, bytes.NewBufferString(` + + http.Register( + httpmock.GraphQL(`query PullRequestForBranch\b`), + httpmock.StringResponse(` { "data": { "repository": { "pullRequests": { "nodes": [ { "url": "https://github.com/OWNER/REPO/pull/123", "id": "foobar123", "headRefName": "feature", "baseRefName": "master" } - ] } } } } - `)) - http.StubResponse(200, bytes.NewBufferString(`{"data": {} }`)) + ] } } } }`), + ) + http.Register( + httpmock.GraphQL(`mutation PullRequestReviewAdd\b`), + httpmock.GraphQLMutation(`{"data": {} }`, + func(inputs map[string]interface{}) { + assert.Equal(t, inputs["event"], kase.ExpectedEvent) + assert.Equal(t, inputs["body"], kase.ExpectedBody) + }), + ) _, err := runCommand(http, nil, false, kase.Cmd) if err != nil { t.Fatalf("got unexpected error running %s: %s", kase.Cmd, err) } - - bodyBytes, _ := ioutil.ReadAll(http.Requests[1].Body) - reqBody := struct { - Variables struct { - Input struct { - Event string - Body string - } - } - }{} - _ = json.Unmarshal(bodyBytes, &reqBody) - - assert.Equal(t, kase.ExpectedEvent, reqBody.Variables.Input.Event) - assert.Equal(t, kase.ExpectedBody, reqBody.Variables.Input.Body) }) } } @@ -361,17 +344,27 @@ func TestPRReview(t *testing.T) { func TestPRReview_nontty(t *testing.T) { http := &httpmock.Registry{} defer http.Verify(t) - 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(`{"data": {} }`)) + http.Register( + httpmock.GraphQL(`query PullRequestForBranch\b`), + httpmock.StringResponse(` + { "data": { "repository": { "pullRequests": { "nodes": [ + { "url": "https://github.com/OWNER/REPO/pull/123", + "number": 123, + "id": "foobar123", + "headRefName": "feature", + "baseRefName": "master" } + ] } } } }`), + ) + http.Register( + httpmock.GraphQL(`mutation PullRequestReviewAdd\b`), + httpmock.GraphQLMutation(`{"data": {} }`, + func(inputs map[string]interface{}) { + assert.Equal(t, inputs["event"], "COMMENT") + assert.Equal(t, inputs["body"], "cool") + }), + ) + output, err := runCommand(http, nil, false, "-c -bcool") if err != nil { t.Fatalf("unexpected error running command: %s", err) @@ -379,35 +372,32 @@ func TestPRReview_nontty(t *testing.T) { assert.Equal(t, "", output.String()) assert.Equal(t, "", output.Stderr()) - - bodyBytes, _ := ioutil.ReadAll(http.Requests[1].Body) - reqBody := struct { - Variables struct { - Input struct { - Event string - Body string - } - } - }{} - _ = json.Unmarshal(bodyBytes, &reqBody) - - assert.Equal(t, "COMMENT", reqBody.Variables.Input.Event) - assert.Equal(t, "cool", reqBody.Variables.Input.Body) } func TestPRReview_interactive(t *testing.T) { http := &httpmock.Registry{} defer http.Verify(t) - 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(`{"data": {} }`)) + + http.Register( + httpmock.GraphQL(`query PullRequestForBranch\b`), + httpmock.StringResponse(` + { "data": { "repository": { "pullRequests": { "nodes": [ + { "url": "https://github.com/OWNER/REPO/pull/123", + "number": 123, + "id": "foobar123", + "headRefName": "feature", + "baseRefName": "master" } + ] } } } }`), + ) + http.Register( + httpmock.GraphQL(`mutation PullRequestReviewAdd\b`), + httpmock.GraphQLMutation(`{"data": {} }`, + func(inputs map[string]interface{}) { + assert.Equal(t, inputs["event"], "APPROVE") + assert.Equal(t, inputs["body"], "cool story") + }), + ) + as, teardown := prompt.InitAskStubber() defer teardown() @@ -440,33 +430,22 @@ func TestPRReview_interactive(t *testing.T) { test.ExpectLines(t, output.String(), "Got:", "cool.*story") - - bodyBytes, _ := ioutil.ReadAll(http.Requests[1].Body) - reqBody := struct { - Variables struct { - Input struct { - Event string - Body string - } - } - }{} - _ = json.Unmarshal(bodyBytes, &reqBody) - - assert.Equal(t, "APPROVE", reqBody.Variables.Input.Event) - assert.Equal(t, "cool story", reqBody.Variables.Input.Body) } func TestPRReview_interactive_no_body(t *testing.T) { http := &httpmock.Registry{} defer http.Verify(t) - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { "pullRequests": { "nodes": [ - { "url": "https://github.com/OWNER/REPO/pull/123", - "id": "foobar123", - "headRefName": "feature", - "baseRefName": "master" } - ] } } } } - `)) + + http.Register( + httpmock.GraphQL(`query PullRequestForBranch\b`), + httpmock.StringResponse(` + { "data": { "repository": { "pullRequests": { "nodes": [ + { "url": "https://github.com/OWNER/REPO/pull/123", + "id": "foobar123", + "headRefName": "feature", + "baseRefName": "master" } + ] } } } }`), + ) as, teardown := prompt.InitAskStubber() defer teardown() @@ -491,25 +470,33 @@ func TestPRReview_interactive_no_body(t *testing.T) { }) _, err := runCommand(http, nil, true, "") - if err == nil { - t.Fatal("expected error") - } - assert.Equal(t, "this type of review cannot be blank", err.Error()) + assert.EqualError(t, err, "this type of review cannot be blank") } func TestPRReview_interactive_blank_approve(t *testing.T) { http := &httpmock.Registry{} defer http.Verify(t) - 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(`{"data": {} }`)) + + http.Register( + httpmock.GraphQL(`query PullRequestForBranch\b`), + httpmock.StringResponse(` + { "data": { "repository": { "pullRequests": { "nodes": [ + { "url": "https://github.com/OWNER/REPO/pull/123", + "number": 123, + "id": "foobar123", + "headRefName": "feature", + "baseRefName": "master" } + ] } } } }`), + ) + http.Register( + httpmock.GraphQL(`mutation PullRequestReviewAdd\b`), + httpmock.GraphQLMutation(`{"data": {} }`, + func(inputs map[string]interface{}) { + assert.Equal(t, inputs["event"], "APPROVE") + assert.Equal(t, inputs["body"], "") + }), + ) + as, teardown := prompt.InitAskStubber() defer teardown() @@ -543,18 +530,4 @@ func TestPRReview_interactive_blank_approve(t *testing.T) { } test.ExpectLines(t, output.Stderr(), "Approved pull request #123") - - bodyBytes, _ := ioutil.ReadAll(http.Requests[1].Body) - reqBody := struct { - Variables struct { - Input struct { - Event string - Body string - } - } - }{} - _ = json.Unmarshal(bodyBytes, &reqBody) - - assert.Equal(t, "APPROVE", reqBody.Variables.Input.Event) - assert.Equal(t, "", reqBody.Variables.Input.Body) } diff --git a/pkg/cmd/pr/shared/comments.go b/pkg/cmd/pr/shared/comments.go new file mode 100644 index 000000000..9f27416a2 --- /dev/null +++ b/pkg/cmd/pr/shared/comments.go @@ -0,0 +1,184 @@ +package shared + +import ( + "fmt" + "sort" + "strings" + "time" + + "github.com/cli/cli/api" + "github.com/cli/cli/pkg/iostreams" + "github.com/cli/cli/pkg/markdown" + "github.com/cli/cli/utils" +) + +type Comment interface { + AuthorLogin() string + Association() string + Content() string + Created() time.Time + IsEdited() bool + Link() string + Reactions() api.ReactionGroups + Status() string +} + +func RawCommentList(comments api.Comments, reviews api.PullRequestReviews) string { + sortedComments := sortComments(comments, reviews) + var b strings.Builder + for _, comment := range sortedComments { + fmt.Fprint(&b, formatRawComment(comment)) + } + return b.String() +} + +func formatRawComment(comment Comment) string { + var b strings.Builder + fmt.Fprintf(&b, "author:\t%s\n", comment.AuthorLogin()) + fmt.Fprintf(&b, "association:\t%s\n", strings.ToLower(comment.Association())) + fmt.Fprintf(&b, "edited:\t%t\n", comment.IsEdited()) + fmt.Fprintf(&b, "status:\t%s\n", formatRawCommentStatus(comment.Status())) + fmt.Fprintln(&b, "--") + fmt.Fprintln(&b, comment.Content()) + fmt.Fprintln(&b, "--") + return b.String() +} + +func CommentList(io *iostreams.IOStreams, comments api.Comments, reviews api.PullRequestReviews, preview bool) (string, error) { + sortedComments := sortComments(comments, reviews) + if preview && len(sortedComments) > 0 { + sortedComments = sortedComments[len(sortedComments)-1:] + } + var b strings.Builder + cs := io.ColorScheme() + totalCount := comments.TotalCount + reviews.TotalCount + retrievedCount := len(sortedComments) + hiddenCount := totalCount - retrievedCount + + if hiddenCount > 0 { + fmt.Fprint(&b, cs.Gray(fmt.Sprintf("———————— Not showing %s ————————", utils.Pluralize(hiddenCount, "comment")))) + fmt.Fprintf(&b, "\n\n\n") + } + + for i, comment := range sortedComments { + last := i+1 == retrievedCount + cmt, err := formatComment(io, comment, last) + if err != nil { + return "", err + } + fmt.Fprint(&b, cmt) + if last { + fmt.Fprintln(&b) + } + } + + if hiddenCount > 0 { + fmt.Fprint(&b, cs.Gray("Use --comments to view the full conversation")) + fmt.Fprintln(&b) + } + + return b.String(), nil +} + +func formatComment(io *iostreams.IOStreams, comment Comment, newest bool) (string, error) { + var b strings.Builder + cs := io.ColorScheme() + + // Header + fmt.Fprint(&b, cs.Bold(comment.AuthorLogin())) + if comment.Status() != "" { + fmt.Fprint(&b, formatCommentStatus(cs, comment.Status())) + } + if comment.Association() != "NONE" { + fmt.Fprint(&b, cs.Bold(fmt.Sprintf(" (%s)", strings.Title(strings.ToLower(comment.Association()))))) + } + fmt.Fprint(&b, cs.Bold(fmt.Sprintf(" • %s", utils.FuzzyAgoAbbr(time.Now(), comment.Created())))) + if comment.IsEdited() { + fmt.Fprint(&b, cs.Bold(" • Edited")) + } + if newest { + fmt.Fprint(&b, cs.Bold(" • ")) + fmt.Fprint(&b, cs.CyanBold("Newest comment")) + } + fmt.Fprintln(&b) + + // Reactions + if reactions := ReactionGroupList(comment.Reactions()); reactions != "" { + fmt.Fprint(&b, reactions) + fmt.Fprintln(&b) + } + + // Body + var md string + var err error + if comment.Content() == "" { + md = fmt.Sprintf("\n %s\n\n", cs.Gray("No body provided")) + } else { + style := markdown.GetStyle(io.TerminalTheme()) + md, err = markdown.Render(comment.Content(), style, "") + if err != nil { + return "", err + } + } + fmt.Fprint(&b, md) + + // Footer + if comment.Link() != "" { + fmt.Fprintf(&b, cs.Gray("View the full review: %s\n\n"), comment.Link()) + } + + return b.String(), nil +} + +func sortComments(cs api.Comments, rs api.PullRequestReviews) []Comment { + comments := cs.Nodes + reviews := rs.Nodes + var sorted []Comment = make([]Comment, len(comments)+len(reviews)) + + var i int + for _, c := range comments { + sorted[i] = c + i++ + } + for _, r := range reviews { + sorted[i] = r + i++ + } + + sort.Slice(sorted, func(i, j int) bool { + return sorted[i].Created().Before(sorted[j].Created()) + }) + + return sorted +} + +const ( + approvedStatus = "APPROVED" + changesRequestedStatus = "CHANGES_REQUESTED" + commentedStatus = "COMMENTED" + dismissedStatus = "DISMISSED" +) + +func formatCommentStatus(cs *iostreams.ColorScheme, status string) string { + switch status { + case approvedStatus: + return fmt.Sprintf(" %s", cs.Green("approved")) + case changesRequestedStatus: + return fmt.Sprintf(" %s", cs.Red("requested changes")) + case commentedStatus, dismissedStatus: + return fmt.Sprintf(" %s", strings.ToLower(status)) + } + + return "" +} + +func formatRawCommentStatus(status string) string { + if status == approvedStatus || + status == changesRequestedStatus || + status == commentedStatus || + status == dismissedStatus { + return strings.ReplaceAll(strings.ToLower(status), "_", " ") + } + + return "none" +} diff --git a/pkg/cmd/pr/shared/params.go b/pkg/cmd/pr/shared/params.go index 9edb9e9e7..6efdf9494 100644 --- a/pkg/cmd/pr/shared/params.go +++ b/pkg/cmd/pr/shared/params.go @@ -37,25 +37,53 @@ func WithPrAndIssueQueryParams(baseURL string, state IssueMetadataState) (string return u.String(), nil } +// Ensure that tb.MetadataResult object exists and contains enough pre-fetched API data to be able +// to resolve all object listed in tb to GraphQL IDs. +func fillMetadata(client *api.Client, baseRepo ghrepo.Interface, tb *IssueMetadataState) error { + resolveInput := api.RepoResolveInput{} + + if len(tb.Assignees) > 0 && (tb.MetadataResult == nil || len(tb.MetadataResult.AssignableUsers) == 0) { + resolveInput.Assignees = tb.Assignees + } + + if len(tb.Reviewers) > 0 && (tb.MetadataResult == nil || len(tb.MetadataResult.AssignableUsers) == 0) { + resolveInput.Reviewers = tb.Reviewers + } + + if len(tb.Labels) > 0 && (tb.MetadataResult == nil || len(tb.MetadataResult.Labels) == 0) { + resolveInput.Labels = tb.Labels + } + + if len(tb.Projects) > 0 && (tb.MetadataResult == nil || len(tb.MetadataResult.Projects) == 0) { + resolveInput.Projects = tb.Projects + } + + if len(tb.Milestones) > 0 && (tb.MetadataResult == nil || len(tb.MetadataResult.Milestones) == 0) { + resolveInput.Milestones = tb.Milestones + } + + metadataResult, err := api.RepoResolveMetadataIDs(client, baseRepo, resolveInput) + if err != nil { + return err + } + + if tb.MetadataResult == nil { + tb.MetadataResult = metadataResult + } else { + tb.MetadataResult.Merge(metadataResult) + } + + 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 - } + err := fillMetadata(client, baseRepo, tb) + if err != nil { + return err } assigneeIDs, err := tb.MetadataResult.MembersToIDs(tb.Assignees) diff --git a/pkg/cmd/pr/shared/preserve.go b/pkg/cmd/pr/shared/preserve.go new file mode 100644 index 000000000..4105823cd --- /dev/null +++ b/pkg/cmd/pr/shared/preserve.go @@ -0,0 +1,62 @@ +package shared + +import ( + "encoding/json" + "fmt" + "os" + + "github.com/cli/cli/pkg/iostreams" +) + +func PreserveInput(io *iostreams.IOStreams, state *IssueMetadataState, createErr *error) func() { + return func() { + if !state.IsDirty() { + return + } + + if *createErr == nil { + return + } + + out := io.ErrOut + + // this extra newline guards against appending to the end of a survey line + fmt.Fprintln(out) + + data, err := json.Marshal(state) + if err != nil { + fmt.Fprintf(out, "failed to save input to file: %s\n", err) + fmt.Fprintln(out, "would have saved:") + fmt.Fprintf(out, "%v\n", state) + return + } + + tmpfile, err := io.TempFile(os.TempDir(), "gh*.json") + if err != nil { + fmt.Fprintf(out, "failed to save input to file: %s\n", err) + fmt.Fprintln(out, "would have saved:") + fmt.Fprintf(out, "%v\n", state) + return + } + + _, err = tmpfile.Write(data) + if err != nil { + fmt.Fprintf(out, "failed to save input to file: %s\n", err) + fmt.Fprintln(out, "would have saved:") + fmt.Fprintln(out, string(data)) + return + } + + cs := io.ColorScheme() + + issueType := "pr" + if state.Type == IssueMetadata { + issueType = "issue" + } + + fmt.Fprintf(out, "%s operation failed. To restore: gh %s create --recover %s\n", cs.FailureIcon(), issueType, tmpfile.Name()) + + // some whitespace before the actual error + fmt.Fprintln(out) + } +} diff --git a/pkg/cmd/pr/shared/preserve_test.go b/pkg/cmd/pr/shared/preserve_test.go new file mode 100644 index 000000000..28949d957 --- /dev/null +++ b/pkg/cmd/pr/shared/preserve_test.go @@ -0,0 +1,122 @@ +package shared + +import ( + "encoding/json" + "errors" + "io/ioutil" + "os" + "testing" + + "github.com/cli/cli/pkg/iostreams" + "github.com/cli/cli/test" + "github.com/stretchr/testify/assert" +) + +func Test_PreserveInput(t *testing.T) { + tests := []struct { + name string + state *IssueMetadataState + err bool + wantErrLine string + wantPreservation bool + }{ + { + name: "err, no changes to state", + err: true, + }, + { + name: "no err, no changes to state", + err: false, + }, + { + name: "no err, changes to state", + state: &IssueMetadataState{ + dirty: true, + }, + }, + { + name: "err, title/body input received", + state: &IssueMetadataState{ + dirty: true, + Title: "almost a", + Body: "jill sandwich", + Reviewers: []string{"barry", "chris"}, + Labels: []string{"sandwich"}, + }, + wantErrLine: `X operation failed. To restore: gh issue create --recover .*testfile.*`, + err: true, + wantPreservation: true, + }, + { + name: "err, metadata received", + state: &IssueMetadataState{ + Reviewers: []string{"barry", "chris"}, + Labels: []string{"sandwich"}, + }, + wantErrLine: `X operation failed. To restore: gh issue create --recover .*testfile.*`, + err: true, + wantPreservation: true, + }, + { + name: "err, dirty, pull request", + state: &IssueMetadataState{ + dirty: true, + Title: "a pull request", + Type: PRMetadata, + }, + wantErrLine: `X operation failed. To restore: gh pr create --recover .*testfile.*`, + err: true, + wantPreservation: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.state == nil { + tt.state = &IssueMetadataState{} + } + + io, _, _, errOut := iostreams.Test() + + tf, tferr := tmpfile() + assert.NoError(t, tferr) + defer os.Remove(tf.Name()) + + io.TempFileOverride = tf + + var err error + if tt.err { + err = errors.New("error during creation") + } + + PreserveInput(io, tt.state, &err)() + + _, err = tf.Seek(0, 0) + assert.NoError(t, err) + + data, err := ioutil.ReadAll(tf) + assert.NoError(t, err) + + if tt.wantPreservation { + test.ExpectLines(t, errOut.String(), tt.wantErrLine) + preserved := &IssueMetadataState{} + assert.NoError(t, json.Unmarshal(data, preserved)) + preserved.dirty = tt.state.dirty + assert.Equal(t, preserved, tt.state) + } else { + assert.Equal(t, errOut.String(), "") + assert.Equal(t, string(data), "") + } + }) + } +} + +func tmpfile() (*os.File, error) { + dir := os.TempDir() + tmpfile, err := ioutil.TempFile(dir, "testfile*") + if err != nil { + return nil, err + } + + return tmpfile, nil +} diff --git a/pkg/cmd/pr/shared/reaction_groups.go b/pkg/cmd/pr/shared/reaction_groups.go new file mode 100644 index 000000000..caf972672 --- /dev/null +++ b/pkg/cmd/pr/shared/reaction_groups.go @@ -0,0 +1,32 @@ +package shared + +import ( + "fmt" + "strings" + + "github.com/cli/cli/api" +) + +func ReactionGroupList(rgs api.ReactionGroups) string { + var rs []string + + for _, rg := range rgs { + if r := formatReactionGroup(rg); r != "" { + rs = append(rs, r) + } + } + + return strings.Join(rs, " • ") +} + +func formatReactionGroup(rg api.ReactionGroup) string { + c := rg.Count() + if c == 0 { + return "" + } + e := rg.Emoji() + if e == "" { + return "" + } + return fmt.Sprintf("%v %s", c, e) +} diff --git a/pkg/cmd/pr/shared/state.go b/pkg/cmd/pr/shared/state.go new file mode 100644 index 000000000..930a69fcc --- /dev/null +++ b/pkg/cmd/pr/shared/state.go @@ -0,0 +1,68 @@ +package shared + +import ( + "encoding/json" + "fmt" + + "github.com/cli/cli/api" + "github.com/cli/cli/pkg/iostreams" +) + +type metadataStateType int + +const ( + IssueMetadata metadataStateType = iota + PRMetadata +) + +type IssueMetadataState struct { + Type metadataStateType + + Draft bool + + Body string + Title string + + Metadata []string + Reviewers []string + Assignees []string + Labels []string + Projects []string + Milestones []string + + MetadataResult *api.RepoMetadataResult + + dirty bool // whether user i/o has modified this +} + +func (tb *IssueMetadataState) MarkDirty() { + tb.dirty = true +} + +func (tb *IssueMetadataState) IsDirty() bool { + return tb.dirty || tb.HasMetadata() +} + +func (tb *IssueMetadataState) HasMetadata() bool { + return len(tb.Reviewers) > 0 || + len(tb.Assignees) > 0 || + len(tb.Labels) > 0 || + len(tb.Projects) > 0 || + len(tb.Milestones) > 0 +} + +func FillFromJSON(io *iostreams.IOStreams, recoverFile string, state *IssueMetadataState) error { + var data []byte + var err error + data, err = io.ReadUserFile(recoverFile) + if err != nil { + return fmt.Errorf("failed to read file %s: %w", recoverFile, err) + } + + err = json.Unmarshal(data, state) + if err != nil { + return fmt.Errorf("JSON parsing failure: %w", err) + } + + return nil +} diff --git a/pkg/cmd/pr/shared/survey.go b/pkg/cmd/pr/shared/survey.go index 03bddb1f8..8ef40d047 100644 --- a/pkg/cmd/pr/shared/survey.go +++ b/pkg/cmd/pr/shared/survey.go @@ -12,42 +12,9 @@ import ( "github.com/cli/cli/pkg/iostreams" "github.com/cli/cli/pkg/prompt" "github.com/cli/cli/pkg/surveyext" - "github.com/cli/cli/utils" ) type Action int -type metadataStateType int - -const ( - IssueMetadata metadataStateType = iota - PRMetadata -) - -type IssueMetadataState struct { - Type metadataStateType - - Draft bool - - Body string - Title string - - Metadata []string - Reviewers []string - Assignees []string - Labels []string - Projects []string - Milestones []string - - MetadataResult *api.RepoMetadataResult -} - -func (tb *IssueMetadataState) HasMetadata() bool { - return len(tb.Reviewers) > 0 || - len(tb.Assignees) > 0 || - len(tb.Labels) > 0 || - len(tb.Projects) > 0 || - len(tb.Milestones) > 0 -} const ( SubmitAction Action = iota @@ -170,6 +137,8 @@ func BodySurvey(state *IssueMetadataState, templateContent, editorCommand string state.Body += templateContent } + preBody := state.Body + // TODO should just be an AskOne but ran into problems with the stubber qs := []*survey.Question{ { @@ -193,10 +162,16 @@ func BodySurvey(state *IssueMetadataState, templateContent, editorCommand string return err } + if state.Body != "" && preBody != state.Body { + state.MarkDirty() + } + return nil } func TitleSurvey(state *IssueMetadataState) error { + preTitle := state.Title + // TODO should just be an AskOne but ran into problems with the stubber qs := []*survey.Question{ { @@ -213,10 +188,33 @@ func TitleSurvey(state *IssueMetadataState) error { return err } + if preTitle != state.Title { + state.MarkDirty() + } + return nil } -func MetadataSurvey(io *iostreams.IOStreams, client *api.Client, baseRepo ghrepo.Interface, state *IssueMetadataState) error { +type MetadataFetcher struct { + IO *iostreams.IOStreams + APIClient *api.Client + Repo ghrepo.Interface + State *IssueMetadataState +} + +func (mf *MetadataFetcher) RepoMetadataFetch(input api.RepoMetadataInput) (*api.RepoMetadataResult, error) { + mf.IO.StartProgressIndicator() + metadataResult, err := api.RepoMetadata(mf.APIClient, mf.Repo, input) + mf.IO.StopProgressIndicator() + mf.State.MetadataResult = metadataResult + return metadataResult, err +} + +type RepoMetadataFetcher interface { + RepoMetadataFetch(api.RepoMetadataInput) (*api.RepoMetadataResult, error) +} + +func MetadataSurvey(io *iostreams.IOStreams, baseRepo ghrepo.Interface, fetcher RepoMetadataFetcher, state *IssueMetadataState) error { isChosen := func(m string) bool { for _, c := range state.Metadata { if m == c { @@ -254,42 +252,32 @@ func MetadataSurvey(io *iostreams.IOStreams, client *api.Client, baseRepo ghrepo Projects: isChosen("Projects"), Milestones: isChosen("Milestone"), } - s := utils.Spinner(io.ErrOut) - utils.StartSpinner(s) - state.MetadataResult, err = api.RepoMetadata(client, baseRepo, metadataInput) - utils.StopSpinner(s) + metadataResult, err := fetcher.RepoMetadataFetch(metadataInput) if err != nil { return fmt.Errorf("error fetching metadata options: %w", err) } var users []string - for _, u := range state.MetadataResult.AssignableUsers { + for _, u := range metadataResult.AssignableUsers { users = append(users, u.Login) } var teams []string - for _, t := range state.MetadataResult.Teams { + for _, t := range metadataResult.Teams { teams = append(teams, fmt.Sprintf("%s/%s", baseRepo.RepoOwner(), t.Slug)) } var labels []string - for _, l := range state.MetadataResult.Labels { + for _, l := range metadataResult.Labels { labels = append(labels, l.Name) } var projects []string - for _, l := range state.MetadataResult.Projects { + for _, l := range metadataResult.Projects { projects = append(projects, l.Name) } milestones := []string{noMilestone} - for _, m := range state.MetadataResult.Milestones { + for _, m := range metadataResult.Milestones { milestones = append(milestones, m.Title) } - type metadataValues struct { - Reviewers []string - Assignees []string - Labels []string - Projects []string - Milestone string - } var mqs []*survey.Question if isChosen("Reviewers") { if len(users) > 0 || len(teams) > 0 { @@ -365,17 +353,38 @@ func MetadataSurvey(io *iostreams.IOStreams, client *api.Client, baseRepo ghrepo fmt.Fprintln(io.ErrOut, "warning: no milestones in the repository") } } - values := metadataValues{} + + values := struct { + Reviewers []string + Assignees []string + Labels []string + Projects []string + Milestone string + }{} + err = prompt.SurveyAsk(mqs, &values, survey.WithKeepFilter(true)) if err != nil { return fmt.Errorf("could not prompt: %w", err) } - state.Reviewers = values.Reviewers - state.Assignees = values.Assignees - state.Labels = values.Labels - state.Projects = values.Projects - if values.Milestone != "" && values.Milestone != noMilestone { - state.Milestones = []string{values.Milestone} + + if isChosen("Reviewers") { + state.Reviewers = values.Reviewers + } + if isChosen("Assignees") { + state.Assignees = values.Assignees + } + if isChosen("Labels") { + state.Labels = values.Labels + } + if isChosen("Projects") { + state.Projects = values.Projects + } + if isChosen("Milestone") { + if values.Milestone != "" && values.Milestone != noMilestone { + state.Milestones = []string{values.Milestone} + } else { + state.Milestones = []string{} + } } return nil diff --git a/pkg/cmd/pr/shared/survey_test.go b/pkg/cmd/pr/shared/survey_test.go new file mode 100644 index 000000000..a500040d3 --- /dev/null +++ b/pkg/cmd/pr/shared/survey_test.go @@ -0,0 +1,144 @@ +package shared + +import ( + "testing" + + "github.com/cli/cli/api" + "github.com/cli/cli/internal/ghrepo" + "github.com/cli/cli/pkg/iostreams" + "github.com/cli/cli/pkg/prompt" + "github.com/stretchr/testify/assert" +) + +type metadataFetcher struct { + metadataResult *api.RepoMetadataResult +} + +func (mf *metadataFetcher) RepoMetadataFetch(input api.RepoMetadataInput) (*api.RepoMetadataResult, error) { + return mf.metadataResult, nil +} + +func TestMetadataSurvey_selectAll(t *testing.T) { + io, _, stdout, stderr := iostreams.Test() + + repo := ghrepo.New("OWNER", "REPO") + + fetcher := &metadataFetcher{ + metadataResult: &api.RepoMetadataResult{ + AssignableUsers: []api.RepoAssignee{ + {Login: "hubot"}, + {Login: "monalisa"}, + }, + Labels: []api.RepoLabel{ + {Name: "help wanted"}, + {Name: "good first issue"}, + }, + Projects: []api.RepoProject{ + {Name: "Huge Refactoring"}, + {Name: "The road to 1.0"}, + }, + Milestones: []api.RepoMilestone{ + {Title: "1.2 patch release"}, + }, + }, + } + + as, restoreAsk := prompt.InitAskStubber() + defer restoreAsk() + + as.Stub([]*prompt.QuestionStub{ + { + Name: "metadata", + Value: []string{"Labels", "Projects", "Assignees", "Reviewers", "Milestone"}, + }, + }) + as.Stub([]*prompt.QuestionStub{ + { + Name: "reviewers", + Value: []string{"monalisa"}, + }, + { + Name: "assignees", + Value: []string{"hubot"}, + }, + { + Name: "labels", + Value: []string{"good first issue"}, + }, + { + Name: "projects", + Value: []string{"The road to 1.0"}, + }, + { + Name: "milestone", + Value: []string{"(none)"}, + }, + }) + + state := &IssueMetadataState{ + Assignees: []string{"hubot"}, + } + err := MetadataSurvey(io, repo, fetcher, state) + assert.NoError(t, err) + + assert.Equal(t, "", stdout.String()) + assert.Equal(t, "", stderr.String()) + + assert.Equal(t, []string{"hubot"}, state.Assignees) + assert.Equal(t, []string{"monalisa"}, state.Reviewers) + assert.Equal(t, []string{"good first issue"}, state.Labels) + assert.Equal(t, []string{"The road to 1.0"}, state.Projects) + assert.Equal(t, []string{}, state.Milestones) +} + +func TestMetadataSurvey_keepExisting(t *testing.T) { + io, _, stdout, stderr := iostreams.Test() + + repo := ghrepo.New("OWNER", "REPO") + + fetcher := &metadataFetcher{ + metadataResult: &api.RepoMetadataResult{ + Labels: []api.RepoLabel{ + {Name: "help wanted"}, + {Name: "good first issue"}, + }, + Projects: []api.RepoProject{ + {Name: "Huge Refactoring"}, + {Name: "The road to 1.0"}, + }, + }, + } + + as, restoreAsk := prompt.InitAskStubber() + defer restoreAsk() + + as.Stub([]*prompt.QuestionStub{ + { + Name: "metadata", + Value: []string{"Labels", "Projects"}, + }, + }) + as.Stub([]*prompt.QuestionStub{ + { + Name: "labels", + Value: []string{"good first issue"}, + }, + { + Name: "projects", + Value: []string{"The road to 1.0"}, + }, + }) + + state := &IssueMetadataState{ + Assignees: []string{"hubot"}, + } + err := MetadataSurvey(io, repo, fetcher, state) + assert.NoError(t, err) + + assert.Equal(t, "", stdout.String()) + assert.Equal(t, "", stderr.String()) + + assert.Equal(t, []string{"hubot"}, state.Assignees) + assert.Equal(t, []string{"good first issue"}, state.Labels) + assert.Equal(t, []string{"The road to 1.0"}, state.Projects) +} diff --git a/pkg/cmd/pr/view/fixtures/prViewPreviewFullComments.json b/pkg/cmd/pr/view/fixtures/prViewPreviewFullComments.json new file mode 100644 index 000000000..1ee02364a --- /dev/null +++ b/pkg/cmd/pr/view/fixtures/prViewPreviewFullComments.json @@ -0,0 +1,308 @@ +{ + "data": { + "repository": { + "pullRequest": { + "comments": { + "nodes": [ + { + "author": { + "login": "monalisa" + }, + "authorAssociation": "NONE", + "body": "Comment 1", + "createdAt": "2020-01-01T12:00:00Z", + "includesCreatedEdit": true, + "reactionGroups": [ + { + "content": "CONFUSED", + "users": { + "totalCount": 1 + } + }, + { + "content": "EYES", + "users": { + "totalCount": 2 + } + }, + { + "content": "HEART", + "users": { + "totalCount": 3 + } + }, + { + "content": "HOORAY", + "users": { + "totalCount": 4 + } + }, + { + "content": "LAUGH", + "users": { + "totalCount": 5 + } + }, + { + "content": "ROCKET", + "users": { + "totalCount": 6 + } + }, + { + "content": "THUMBS_DOWN", + "users": { + "totalCount": 7 + } + }, + { + "content": "THUMBS_UP", + "users": { + "totalCount": 8 + } + } + ] + }, + { + "author": { + "login": "johnnytest" + }, + "authorAssociation": "CONTRIBUTOR", + "body": "Comment 2", + "createdAt": "2020-01-03T12:00:00Z", + "includesCreatedEdit": false, + "reactionGroups": [ + { + "content": "CONFUSED", + "users": { + "totalCount": 0 + } + }, + { + "content": "EYES", + "users": { + "totalCount": 0 + } + }, + { + "content": "HEART", + "users": { + "totalCount": 0 + } + }, + { + "content": "HOORAY", + "users": { + "totalCount": 0 + } + }, + { + "content": "LAUGH", + "users": { + "totalCount": 0 + } + }, + { + "content": "ROCKET", + "users": { + "totalCount": 0 + } + }, + { + "content": "THUMBS_DOWN", + "users": { + "totalCount": 0 + } + }, + { + "content": "THUMBS_UP", + "users": { + "totalCount": 0 + } + } + ] + }, + { + "author": { + "login": "elvisp" + }, + "authorAssociation": "MEMBER", + "body": "Comment 3", + "createdAt": "2020-01-05T12:00:00Z", + "includesCreatedEdit": false, + "reactionGroups": [ + { + "content": "CONFUSED", + "users": { + "totalCount": 0 + } + }, + { + "content": "EYES", + "users": { + "totalCount": 0 + } + }, + { + "content": "HEART", + "users": { + "totalCount": 0 + } + }, + { + "content": "HOORAY", + "users": { + "totalCount": 0 + } + }, + { + "content": "LAUGH", + "users": { + "totalCount": 0 + } + }, + { + "content": "ROCKET", + "users": { + "totalCount": 0 + } + }, + { + "content": "THUMBS_DOWN", + "users": { + "totalCount": 0 + } + }, + { + "content": "THUMBS_UP", + "users": { + "totalCount": 0 + } + } + ] + }, + { + "author": { + "login": "loislane" + }, + "authorAssociation": "OWNER", + "body": "Comment 4", + "createdAt": "2020-01-07T12:00:00Z", + "includesCreatedEdit": false, + "reactionGroups": [ + { + "content": "CONFUSED", + "users": { + "totalCount": 0 + } + }, + { + "content": "EYES", + "users": { + "totalCount": 0 + } + }, + { + "content": "HEART", + "users": { + "totalCount": 0 + } + }, + { + "content": "HOORAY", + "users": { + "totalCount": 0 + } + }, + { + "content": "LAUGH", + "users": { + "totalCount": 0 + } + }, + { + "content": "ROCKET", + "users": { + "totalCount": 0 + } + }, + { + "content": "THUMBS_DOWN", + "users": { + "totalCount": 0 + } + }, + { + "content": "THUMBS_UP", + "users": { + "totalCount": 0 + } + } + ] + }, + { + "author": { + "login": "marseilles" + }, + "authorAssociation": "COLLABORATOR", + "body": "Comment 5", + "createdAt": "2020-01-09T12:00:00Z", + "includesCreatedEdit": false, + "reactionGroups": [ + { + "content": "CONFUSED", + "users": { + "totalCount": 0 + } + }, + { + "content": "EYES", + "users": { + "totalCount": 0 + } + }, + { + "content": "HEART", + "users": { + "totalCount": 0 + } + }, + { + "content": "HOORAY", + "users": { + "totalCount": 0 + } + }, + { + "content": "LAUGH", + "users": { + "totalCount": 0 + } + }, + { + "content": "ROCKET", + "users": { + "totalCount": 0 + } + }, + { + "content": "THUMBS_DOWN", + "users": { + "totalCount": 0 + } + }, + { + "content": "THUMBS_UP", + "users": { + "totalCount": 0 + } + } + ] + } + ], + "totalCount": 5 + } + } + } + } +} diff --git a/pkg/cmd/pr/view/fixtures/prViewPreviewManyReviews.json b/pkg/cmd/pr/view/fixtures/prViewPreviewManyReviews.json new file mode 100644 index 000000000..5645f7df4 --- /dev/null +++ b/pkg/cmd/pr/view/fixtures/prViewPreviewManyReviews.json @@ -0,0 +1,67 @@ +{ + "data": { + "repository": { + "pullRequest": { + "reviews": { + "nodes": [ + { + "author": { + "login": "123" + }, + "state": "COMMENTED" + }, + { + "author": { + "login": "def" + }, + "state": "CHANGES_REQUESTED" + }, + { + "author": { + "login": "abc" + }, + "state": "APPROVED" + }, + { + "author": { + "login": "DEF" + }, + "state": "COMMENTED" + }, + { + "author": { + "login": "xyz" + }, + "state": "APPROVED" + }, + { + "author": { + "login": "" + }, + "state": "APPROVED" + }, + { + "author": { + "login": "hubot" + }, + "state": "CHANGES_REQUESTED" + }, + { + "author": { + "login": "hubot" + }, + "state": "DISMISSED" + }, + { + "author": { + "login": "monalisa" + }, + "state": "PENDING" + } + ], + "totalCount": 9 + } + } + } + } +} diff --git a/pkg/cmd/pr/view/fixtures/prViewPreviewNoReviews.json b/pkg/cmd/pr/view/fixtures/prViewPreviewNoReviews.json new file mode 100644 index 000000000..92e1a5a75 --- /dev/null +++ b/pkg/cmd/pr/view/fixtures/prViewPreviewNoReviews.json @@ -0,0 +1 @@ +{ "data": { "repository": { "pullRequest": { "reviews": { } } } } } diff --git a/pkg/cmd/pr/view/fixtures/prViewPreviewReviews.json b/pkg/cmd/pr/view/fixtures/prViewPreviewReviews.json new file mode 100644 index 000000000..393003fd9 --- /dev/null +++ b/pkg/cmd/pr/view/fixtures/prViewPreviewReviews.json @@ -0,0 +1,318 @@ +{ + "data": { + "repository": { + "pullRequest": { + "reviews": { + "nodes": [ + { + "author": { + "login": "sam" + }, + "authorAssociation": "NONE", + "body": "Review 1", + "createdAt": "2020-01-02T12:00:00Z", + "includesCreatedEdit": false, + "reactionGroups": [ + { + "content": "CONFUSED", + "users": { + "totalCount": 0 + } + }, + { + "content": "EYES", + "users": { + "totalCount": 0 + } + }, + { + "content": "HEART", + "users": { + "totalCount": 0 + } + }, + { + "content": "HOORAY", + "users": { + "totalCount": 0 + } + }, + { + "content": "LAUGH", + "users": { + "totalCount": 0 + } + }, + { + "content": "ROCKET", + "users": { + "totalCount": 0 + } + }, + { + "content": "THUMBS_DOWN", + "users": { + "totalCount": 1 + } + }, + { + "content": "THUMBS_UP", + "users": { + "totalCount": 1 + } + } + ], + "state": "COMMENTED", + "url": "https://github.com/OWNER/REPO/pull/12#pullrequestreview-1" + }, + { + "author": { + "login": "matt" + }, + "authorAssociation": "OWNER", + "body": "Review 2", + "createdAt": "2020-01-04T12:00:00Z", + "includesCreatedEdit": false, + "reactionGroups": [ + { + "content": "CONFUSED", + "users": { + "totalCount": 1 + } + }, + { + "content": "EYES", + "users": { + "totalCount": 1 + } + }, + { + "content": "HEART", + "users": { + "totalCount": 0 + } + }, + { + "content": "HOORAY", + "users": { + "totalCount": 0 + } + }, + { + "content": "LAUGH", + "users": { + "totalCount": 0 + } + }, + { + "content": "ROCKET", + "users": { + "totalCount": 0 + } + }, + { + "content": "THUMBS_DOWN", + "users": { + "totalCount": 0 + } + }, + { + "content": "THUMBS_UP", + "users": { + "totalCount": 0 + } + } + ], + "state": "CHANGES_REQUESTED", + "url": "https://github.com/OWNER/REPO/pull/12#pullrequestreview-2" + }, + { + "author": { + "login": "leah" + }, + "authorAssociation": "MEMBER", + "body": "Review 3", + "createdAt": "2020-01-06T12:00:00Z", + "includesCreatedEdit": true, + "reactionGroups": [ + { + "content": "CONFUSED", + "users": { + "totalCount": 0 + } + }, + { + "content": "EYES", + "users": { + "totalCount": 0 + } + }, + { + "content": "HEART", + "users": { + "totalCount": 0 + } + }, + { + "content": "HOORAY", + "users": { + "totalCount": 0 + } + }, + { + "content": "LAUGH", + "users": { + "totalCount": 0 + } + }, + { + "content": "ROCKET", + "users": { + "totalCount": 0 + } + }, + { + "content": "THUMBS_DOWN", + "users": { + "totalCount": 0 + } + }, + { + "content": "THUMBS_UP", + "users": { + "totalCount": 0 + } + } + ], + "state": "APPROVED", + "url": "https://github.com/OWNER/REPO/pull/12#pullrequestreview-3" + }, + { + "author": { + "login": "louise" + }, + "authorAssociation": "NONE", + "body": "Review 4", + "createdAt": "2020-01-08T12:00:00Z", + "includesCreatedEdit": false, + "reactionGroups": [ + { + "content": "CONFUSED", + "users": { + "totalCount": 0 + } + }, + { + "content": "EYES", + "users": { + "totalCount": 0 + } + }, + { + "content": "HEART", + "users": { + "totalCount": 0 + } + }, + { + "content": "HOORAY", + "users": { + "totalCount": 0 + } + }, + { + "content": "LAUGH", + "users": { + "totalCount": 0 + } + }, + { + "content": "ROCKET", + "users": { + "totalCount": 0 + } + }, + { + "content": "THUMBS_DOWN", + "users": { + "totalCount": 0 + } + }, + { + "content": "THUMBS_UP", + "users": { + "totalCount": 0 + } + } + ], + "state": "DISMISSED", + "url": "https://github.com/OWNER/REPO/pull/12#pullrequestreview-4" + }, + { + "author": { + "login": "david" + }, + "authorAssociation": "NONE", + "body": "Review 5", + "createdAt": "2020-01-10T12:00:00Z", + "includesCreatedEdit": false, + "reactionGroups": [ + { + "content": "CONFUSED", + "users": { + "totalCount": 0 + } + }, + { + "content": "EYES", + "users": { + "totalCount": 0 + } + }, + { + "content": "HEART", + "users": { + "totalCount": 0 + } + }, + { + "content": "HOORAY", + "users": { + "totalCount": 0 + } + }, + { + "content": "LAUGH", + "users": { + "totalCount": 0 + } + }, + { + "content": "ROCKET", + "users": { + "totalCount": 0 + } + }, + { + "content": "THUMBS_DOWN", + "users": { + "totalCount": 0 + } + }, + { + "content": "THUMBS_UP", + "users": { + "totalCount": 0 + } + } + ], + "state": "PENDING", + "url": "https://github.com/OWNER/REPO/pull/12#pullrequestreview-5" + } + ], + "totalCount": 5 + } + } + } + } +} diff --git a/pkg/cmd/pr/view/fixtures/prViewPreviewSingleComment.json b/pkg/cmd/pr/view/fixtures/prViewPreviewSingleComment.json new file mode 100644 index 000000000..71d58fe83 --- /dev/null +++ b/pkg/cmd/pr/view/fixtures/prViewPreviewSingleComment.json @@ -0,0 +1,155 @@ +{ + "data": { + "repository": { + "pullRequest": { + "number": 12, + "title": "some title", + "state": "OPEN", + "body": "some body", + "url": "https://github.com/OWNER/REPO/pull/12", + "author": { + "login": "nobody" + }, + "assignees": { + "nodes": [], + "totalcount": 0 + }, + "labels": { + "nodes": [], + "totalcount": 0 + }, + "projectcards": { + "nodes": [], + "totalcount": 0 + }, + "milestone": { + "title": "" + }, + "commits": { + "totalCount": 12 + }, + "baseRefName": "master", + "headRefName": "blueberries", + "headRepositoryOwner": { + "login": "hubot" + }, + "isCrossRepository": true, + "isDraft": false, + "reactionGroups": [ + { + "content": "CONFUSED", + "users": { + "totalCount": 1 + } + }, + { + "content": "EYES", + "users": { + "totalCount": 2 + } + }, + { + "content": "HEART", + "users": { + "totalCount": 3 + } + }, + { + "content": "HOORAY", + "users": { + "totalCount": 0 + } + }, + { + "content": "LAUGH", + "users": { + "totalCount": 0 + } + }, + { + "content": "ROCKET", + "users": { + "totalCount": 0 + } + }, + { + "content": "THUMBS_DOWN", + "users": { + "totalCount": 0 + } + }, + { + "content": "THUMBS_UP", + "users": { + "totalCount": 0 + } + } + ], + "comments": { + "nodes": [ + { + "author": { + "login": "marseilles" + }, + "authorAssociation": "COLLABORATOR", + "body": "Comment 5", + "createdAt": "2020-01-09T12:00:00Z", + "includesCreatedEdit": false, + "reactionGroups": [ + { + "content": "CONFUSED", + "users": { + "totalCount": 0 + } + }, + { + "content": "EYES", + "users": { + "totalCount": 0 + } + }, + { + "content": "HEART", + "users": { + "totalCount": 0 + } + }, + { + "content": "HOORAY", + "users": { + "totalCount": 4 + } + }, + { + "content": "LAUGH", + "users": { + "totalCount": 5 + } + }, + { + "content": "ROCKET", + "users": { + "totalCount": 6 + } + }, + { + "content": "THUMBS_DOWN", + "users": { + "totalCount": 0 + } + }, + { + "content": "THUMBS_UP", + "users": { + "totalCount": 0 + } + } + ] + } + ], + "totalCount": 5 + } + } + } + } +} diff --git a/pkg/cmd/pr/view/fixtures/prViewPreviewWithMetadataByNumber.json b/pkg/cmd/pr/view/fixtures/prViewPreviewWithMetadataByNumber.json index 0ca6124e6..c6f801477 100644 --- a/pkg/cmd/pr/view/fixtures/prViewPreviewWithMetadataByNumber.json +++ b/pkg/cmd/pr/view/fixtures/prViewPreviewWithMetadataByNumber.json @@ -21,28 +21,6 @@ ], "totalcount": 1 }, - "reviews": { - "nodes": [ - { - "author": { - "login": "3" - }, - "state": "COMMENTED" - }, - { - "author": { - "login": "2" - }, - "state": "APPROVED" - }, - { - "author": { - "login": "1" - }, - "state": "CHANGES_REQUESTED" - } - ] - }, "assignees": { "nodes": [ { diff --git a/pkg/cmd/pr/view/fixtures/prViewPreviewWithReviewersByNumber.json b/pkg/cmd/pr/view/fixtures/prViewPreviewWithReviewersByNumber.json index 4d2bd57af..6ff594fec 100644 --- a/pkg/cmd/pr/view/fixtures/prViewPreviewWithReviewersByNumber.json +++ b/pkg/cmd/pr/view/fixtures/prViewPreviewWithReviewersByNumber.json @@ -33,64 +33,6 @@ ], "totalcount": 1 }, - "reviews": { - "nodes": [ - { - "author": { - "login": "123" - }, - "state": "COMMENTED" - }, - { - "author": { - "login": "def" - }, - "state": "CHANGES_REQUESTED" - }, - { - "author": { - "login": "abc" - }, - "state": "APPROVED" - }, - { - "author": { - "login": "DEF" - }, - "state": "COMMENTED" - }, - { - "author": { - "login": "xyz" - }, - "state": "APPROVED" - }, - { - "author": { - "login": "" - }, - "state": "APPROVED" - }, - { - "author": { - "login": "hubot" - }, - "state": "CHANGES_REQUESTED" - }, - { - "author": { - "login": "hubot" - }, - "state": "DISMISSED" - }, - { - "author": { - "login": "monalisa" - }, - "state": "PENDING" - } - ] - }, "assignees": { "nodes": [], "totalcount": 0 diff --git a/pkg/cmd/pr/view/view.go b/pkg/cmd/pr/view/view.go index 1bb0eff1b..b2f84ff78 100644 --- a/pkg/cmd/pr/view/view.go +++ b/pkg/cmd/pr/view/view.go @@ -6,6 +6,7 @@ import ( "net/http" "sort" "strings" + "sync" "github.com/MakeNowJust/heredoc" "github.com/cli/cli/api" @@ -30,6 +31,7 @@ type ViewOptions struct { SelectorArg string BrowserMode bool + Comments bool } func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Command { @@ -73,26 +75,23 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman } cmd.Flags().BoolVarP(&opts.BrowserMode, "web", "w", false, "Open a pull request in the browser") + cmd.Flags().BoolVarP(&opts.Comments, "comments", "c", false, "View pull request comments") return cmd } func viewRun(opts *ViewOptions) error { - httpClient, err := opts.HttpClient() - if err != nil { - return err - } - apiClient := api.NewClientFromHTTP(httpClient) - - pr, _, err := shared.PRFromArgs(apiClient, opts.BaseRepo, opts.Branch, opts.Remotes, opts.SelectorArg) + opts.IO.StartProgressIndicator() + pr, err := retrievePullRequest(opts) + opts.IO.StopProgressIndicator() if err != nil { return err } - openURL := pr.URL connectedToTerminal := opts.IO.IsStdoutTTY() && opts.IO.IsStderrTTY() if opts.BrowserMode { + openURL := pr.URL if connectedToTerminal { fmt.Fprintf(opts.IO.ErrOut, "Opening %s in your browser.\n", utils.DisplayURL(openURL)) } @@ -108,7 +107,12 @@ func viewRun(opts *ViewOptions) error { defer opts.IO.StopPager() if connectedToTerminal { - return printHumanPrPreview(opts.IO, pr) + return printHumanPrPreview(opts, pr) + } + + if opts.Comments { + fmt.Fprint(opts.IO.Out, shared.RawCommentList(pr.Comments, pr.Reviews)) + return nil } return printRawPrPreview(opts.IO, pr) @@ -140,22 +144,26 @@ func printRawPrPreview(io *iostreams.IOStreams, pr *api.PullRequest) error { return nil } -func printHumanPrPreview(io *iostreams.IOStreams, pr *api.PullRequest) error { - out := io.Out - - cs := io.ColorScheme() +func printHumanPrPreview(opts *ViewOptions, pr *api.PullRequest) error { + out := opts.IO.Out + cs := opts.IO.ColorScheme() // Header (Title and State) fmt.Fprintln(out, cs.Bold(pr.Title)) - fmt.Fprintf(out, "%s", shared.StateTitleWithColor(cs, *pr)) - fmt.Fprintln(out, cs.Gray(fmt.Sprintf( - " • %s wants to merge %s into %s from %s", + fmt.Fprintf(out, + "%s • %s wants to merge %s into %s from %s\n", + shared.StateTitleWithColor(cs, *pr), pr.Author.Login, utils.Pluralize(pr.Commits.TotalCount, "commit"), pr.BaseRefName, pr.HeadRefName, - ))) - fmt.Fprintln(out) + ) + + // Reactions + if reactions := shared.ReactionGroupList(pr.ReactionGroups); reactions != "" { + fmt.Fprint(out, reactions) + fmt.Fprintln(out) + } // Metadata if reviewers := prReviewerList(*pr, cs); reviewers != "" { @@ -180,19 +188,32 @@ func printHumanPrPreview(io *iostreams.IOStreams, pr *api.PullRequest) error { } // Body - if pr.Body != "" { - fmt.Fprintln(out) - style := markdown.GetStyle(io.TerminalTheme()) - md, err := markdown.Render(pr.Body, style, "") + var md string + var err error + if pr.Body == "" { + md = fmt.Sprintf("\n %s\n\n", cs.Gray("No description provided")) + } else { + style := markdown.GetStyle(opts.IO.TerminalTheme()) + md, err = markdown.Render(pr.Body, style, "") if err != nil { return err } - fmt.Fprintln(out, md) } - fmt.Fprintln(out) + fmt.Fprintf(out, "\n%s\n", md) + + // Reviews and Comments + if pr.Comments.TotalCount > 0 || pr.Reviews.TotalCount > 0 { + preview := !opts.Comments + comments, err := shared.CommentList(opts.IO, pr.Comments, pr.DisplayableReviews(), preview) + if err != nil { + return err + } + fmt.Fprint(out, comments) + } // Footer fmt.Fprintf(out, cs.Gray("View this pull request on GitHub: %s\n"), pr.URL) + return nil } @@ -214,7 +235,7 @@ type reviewerState struct { func formattedReviewerState(cs *iostreams.ColorScheme, reviewer *reviewerState) string { state := reviewer.State if state == dismissedReviewState { - // Show "DISMISSED" review as "COMMENTED", since "dimissed" only makes + // Show "DISMISSED" review as "COMMENTED", since "dismissed" only makes // sense when displayed in an events timeline but not in the final tally. state = commentedReviewState } @@ -373,3 +394,51 @@ func prStateWithDraft(pr *api.PullRequest) string { return pr.State } + +func retrievePullRequest(opts *ViewOptions) (*api.PullRequest, error) { + httpClient, err := opts.HttpClient() + if err != nil { + return nil, err + } + + apiClient := api.NewClientFromHTTP(httpClient) + + pr, repo, err := shared.PRFromArgs(apiClient, opts.BaseRepo, opts.Branch, opts.Remotes, opts.SelectorArg) + if err != nil { + return nil, err + } + + if opts.BrowserMode { + return pr, nil + } + + var errp, errc error + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + var reviews *api.PullRequestReviews + reviews, errp = api.ReviewsForPullRequest(apiClient, repo, pr) + pr.Reviews = *reviews + }() + + if opts.Comments { + wg.Add(1) + go func() { + defer wg.Done() + var comments *api.Comments + comments, errc = api.CommentsForPullRequest(apiClient, repo, pr) + pr.Comments = *comments + }() + } + + wg.Wait() + + if errp != nil { + err = errp + } + if errc != nil { + err = errc + } + return pr, err +} diff --git a/pkg/cmd/pr/view/view_test.go b/pkg/cmd/pr/view/view_test.go index 6dc591243..ff2f255cf 100644 --- a/pkg/cmd/pr/view/view_test.go +++ b/pkg/cmd/pr/view/view_test.go @@ -2,13 +2,14 @@ package view import ( "bytes" + "fmt" "io/ioutil" "net/http" "os/exec" - "reflect" "strings" "testing" + "github.com/briandowns/spinner" "github.com/cli/cli/context" "github.com/cli/cli/git" "github.com/cli/cli/internal/config" @@ -18,6 +19,7 @@ import ( "github.com/cli/cli/pkg/httpmock" "github.com/cli/cli/pkg/iostreams" "github.com/cli/cli/test" + "github.com/cli/cli/utils" "github.com/google/shlex" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -64,6 +66,15 @@ func Test_NewCmdView(t *testing.T) { isTTY: true, wantErr: "argument required when using the --repo flag", }, + { + name: "comments", + args: "123 -c", + isTTY: true, + want: ViewOptions{ + SelectorArg: "123", + Comments: true, + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -104,13 +115,6 @@ func Test_NewCmdView(t *testing.T) { } } -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, branch string, isTTY bool, cli string) (*test.CmdOut, error) { io, _, stdout, stderr := iostreams.Test() io.SetStdoutTTY(isTTY) @@ -164,13 +168,16 @@ func TestPRView_Preview_nontty(t *testing.T) { tests := map[string]struct { branch string args string - fixture string + fixtures map[string]string expectedOutputs []string }{ "Open PR without metadata": { - branch: "master", - args: "12", - fixture: "./fixtures/prViewPreview.json", + branch: "master", + args: "12", + fixtures: map[string]string{ + "PullRequestByNumber": "./fixtures/prViewPreview.json", + "ReviewsForPullRequest": "./fixtures/prViewPreviewNoReviews.json", + }, expectedOutputs: []string{ `title:\tBlueberries are from a fork\n`, `state:\tOPEN\n`, @@ -186,12 +193,15 @@ func TestPRView_Preview_nontty(t *testing.T) { }, }, "Open PR with metadata by number": { - branch: "master", - args: "12", - fixture: "./fixtures/prViewPreviewWithMetadataByNumber.json", + branch: "master", + args: "12", + fixtures: map[string]string{ + "PullRequestByNumber": "./fixtures/prViewPreviewWithMetadataByNumber.json", + "ReviewsForPullRequest": "./fixtures/prViewPreviewNoReviews.json", + }, expectedOutputs: []string{ `title:\tBlueberries are from a fork\n`, - `reviewers:\t2 \(Approved\), 3 \(Commented\), 1 \(Requested\)\n`, + `reviewers:\t1 \(Requested\)\n`, `assignees:\tmarseilles, monaco\n`, `labels:\tone, two, three, four, five\n`, `projects:\tProject 1 \(column A\), Project 2 \(column B\), Project 3 \(column C\), Project 4 \(Awaiting triage\)\n`, @@ -200,9 +210,12 @@ func TestPRView_Preview_nontty(t *testing.T) { }, }, "Open PR with reviewers by number": { - branch: "master", - args: "12", - fixture: "./fixtures/prViewPreviewWithReviewersByNumber.json", + branch: "master", + args: "12", + fixtures: map[string]string{ + "PullRequestByNumber": "./fixtures/prViewPreviewWithReviewersByNumber.json", + "ReviewsForPullRequest": "./fixtures/prViewPreviewManyReviews.json", + }, expectedOutputs: []string{ `title:\tBlueberries are from a fork\n`, `state:\tOPEN\n`, @@ -216,14 +229,18 @@ func TestPRView_Preview_nontty(t *testing.T) { }, }, "Open PR with metadata by branch": { - branch: "master", - args: "blueberries", - fixture: "./fixtures/prViewPreviewWithMetadataByBranch.json", + branch: "master", + args: "blueberries", + fixtures: map[string]string{ + "PullRequestForBranch": "./fixtures/prViewPreviewWithMetadataByBranch.json", + "ReviewsForPullRequest": "./fixtures/prViewPreviewNoReviews.json", + }, expectedOutputs: []string{ `title:\tBlueberries are a good fruit`, `state:\tOPEN`, `author:\tnobody`, `assignees:\tmarseilles, monaco\n`, + `reviewers:\t\n`, `labels:\tone, two, three, four, five\n`, `projects:\tProject 1 \(column A\), Project 2 \(column B\), Project 3 \(column C\)\n`, `milestone:\tuluru\n`, @@ -231,14 +248,18 @@ func TestPRView_Preview_nontty(t *testing.T) { }, }, "Open PR for the current branch": { - branch: "blueberries", - args: "", - fixture: "./fixtures/prView.json", + branch: "blueberries", + args: "", + fixtures: map[string]string{ + "PullRequestForBranch": "./fixtures/prView.json", + "ReviewsForPullRequest": "./fixtures/prViewPreviewNoReviews.json", + }, expectedOutputs: []string{ `title:\tBlueberries are a good fruit`, `state:\tOPEN`, `author:\tnobody`, `assignees:\t\n`, + `reviewers:\t\n`, `labels:\t\n`, `projects:\t\n`, `milestone:\t\n`, @@ -246,23 +267,30 @@ func TestPRView_Preview_nontty(t *testing.T) { }, }, "Open PR wth empty body for the current branch": { - branch: "blueberries", - args: "", - fixture: "./fixtures/prView_EmptyBody.json", + branch: "blueberries", + args: "", + fixtures: map[string]string{ + "PullRequestForBranch": "./fixtures/prView_EmptyBody.json", + "ReviewsForPullRequest": "./fixtures/prViewPreviewNoReviews.json", + }, expectedOutputs: []string{ `title:\tBlueberries are a good fruit`, `state:\tOPEN`, `author:\tnobody`, `assignees:\t\n`, + `reviewers:\t\n`, `labels:\t\n`, `projects:\t\n`, `milestone:\t\n`, }, }, "Closed PR": { - branch: "master", - args: "12", - fixture: "./fixtures/prViewPreviewClosedState.json", + branch: "master", + args: "12", + fixtures: map[string]string{ + "PullRequestByNumber": "./fixtures/prViewPreviewClosedState.json", + "ReviewsForPullRequest": "./fixtures/prViewPreviewNoReviews.json", + }, expectedOutputs: []string{ `state:\tCLOSED\n`, `author:\tnobody\n`, @@ -275,9 +303,12 @@ func TestPRView_Preview_nontty(t *testing.T) { }, }, "Merged PR": { - branch: "master", - args: "12", - fixture: "./fixtures/prViewPreviewMergedState.json", + branch: "master", + args: "12", + fixtures: map[string]string{ + "PullRequestByNumber": "./fixtures/prViewPreviewMergedState.json", + "ReviewsForPullRequest": "./fixtures/prViewPreviewNoReviews.json", + }, expectedOutputs: []string{ `state:\tMERGED\n`, `author:\tnobody\n`, @@ -290,30 +321,38 @@ func TestPRView_Preview_nontty(t *testing.T) { }, }, "Draft PR": { - branch: "master", - args: "12", - fixture: "./fixtures/prViewPreviewDraftState.json", + branch: "master", + args: "12", + fixtures: map[string]string{ + "PullRequestByNumber": "./fixtures/prViewPreviewDraftState.json", + "ReviewsForPullRequest": "./fixtures/prViewPreviewNoReviews.json", + }, expectedOutputs: []string{ `title:\tBlueberries are from a fork\n`, `state:\tDRAFT\n`, `author:\tnobody\n`, `labels:`, `assignees:`, + `reviewers:`, `projects:`, `milestone:`, `\*\*blueberries taste good\*\*`, }, }, "Draft PR by branch": { - branch: "master", - args: "blueberries", - fixture: "./fixtures/prViewPreviewDraftStatebyBranch.json", + branch: "master", + args: "blueberries", + fixtures: map[string]string{ + "PullRequestForBranch": "./fixtures/prViewPreviewDraftStatebyBranch.json", + "ReviewsForPullRequest": "./fixtures/prViewPreviewNoReviews.json", + }, expectedOutputs: []string{ `title:\tBlueberries are a good fruit\n`, `state:\tDRAFT\n`, `author:\tnobody\n`, `labels:`, `assignees:`, + `reviewers:`, `projects:`, `milestone:`, `\*\*blueberries taste good\*\*`, @@ -325,14 +364,17 @@ func TestPRView_Preview_nontty(t *testing.T) { t.Run(name, func(t *testing.T) { http := &httpmock.Registry{} defer http.Verify(t) - http.Register(httpmock.GraphQL(`query PullRequest(ByNumber|ForBranch)\b`), httpmock.FileResponse(tc.fixture)) + for name, file := range tc.fixtures { + name := fmt.Sprintf(`query %s\b`, name) + http.Register(httpmock.GraphQL(name), httpmock.FileResponse(file)) + } output, err := runCommand(http, tc.branch, false, tc.args) if err != nil { t.Errorf("error running command `%v`: %v", tc.args, err) } - eq(t, output.Stderr(), "") + assert.Equal(t, "", output.Stderr()) test.ExpectLines(t, output.String(), tc.expectedOutputs...) }) @@ -343,13 +385,16 @@ func TestPRView_Preview(t *testing.T) { tests := map[string]struct { branch string args string - fixture string + fixtures map[string]string expectedOutputs []string }{ "Open PR without metadata": { - branch: "master", - args: "12", - fixture: "./fixtures/prViewPreview.json", + branch: "master", + args: "12", + fixtures: map[string]string{ + "PullRequestByNumber": "./fixtures/prViewPreview.json", + "ReviewsForPullRequest": "./fixtures/prViewPreviewNoReviews.json", + }, expectedOutputs: []string{ `Blueberries are from a fork`, `Open.*nobody wants to merge 12 commits into master from blueberries`, @@ -358,36 +403,45 @@ func TestPRView_Preview(t *testing.T) { }, }, "Open PR with metadata by number": { - branch: "master", - args: "12", - fixture: "./fixtures/prViewPreviewWithMetadataByNumber.json", + branch: "master", + args: "12", + fixtures: map[string]string{ + "PullRequestByNumber": "./fixtures/prViewPreviewWithMetadataByNumber.json", + "ReviewsForPullRequest": "./fixtures/prViewPreviewNoReviews.json", + }, expectedOutputs: []string{ `Blueberries are from a fork`, `Open.*nobody wants to merge 12 commits into master from blueberries`, - `Reviewers:.*2 \(.*Approved.*\), 3 \(Commented\), 1 \(.*Requested.*\)\n`, + `Reviewers:.*1 \(.*Requested.*\)\n`, `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`, `blueberries taste good`, - `View this pull request on GitHub: https://github.com/OWNER/REPO/pull/12\n`, + `View this pull request on GitHub: https://github.com/OWNER/REPO/pull/12`, }, }, "Open PR with reviewers by number": { - branch: "master", - args: "12", - fixture: "./fixtures/prViewPreviewWithReviewersByNumber.json", + branch: "master", + args: "12", + fixtures: map[string]string{ + "PullRequestByNumber": "./fixtures/prViewPreviewWithReviewersByNumber.json", + "ReviewsForPullRequest": "./fixtures/prViewPreviewManyReviews.json", + }, expectedOutputs: []string{ `Blueberries are from a fork`, `Reviewers:.*DEF \(.*Commented.*\), def \(.*Changes requested.*\), ghost \(.*Approved.*\), hubot \(Commented\), xyz \(.*Approved.*\), 123 \(.*Requested.*\), Team 1 \(.*Requested.*\), abc \(.*Requested.*\)\n`, `blueberries taste good`, - `View this pull request on GitHub: https://github.com/OWNER/REPO/pull/12\n`, + `View this pull request on GitHub: https://github.com/OWNER/REPO/pull/12`, }, }, "Open PR with metadata by branch": { - branch: "master", - args: "blueberries", - fixture: "./fixtures/prViewPreviewWithMetadataByBranch.json", + branch: "master", + args: "blueberries", + fixtures: map[string]string{ + "PullRequestForBranch": "./fixtures/prViewPreviewWithMetadataByBranch.json", + "ReviewsForPullRequest": "./fixtures/prViewPreviewNoReviews.json", + }, expectedOutputs: []string{ `Blueberries are a good fruit`, `Open.*nobody wants to merge 8 commits into master from blueberries`, @@ -396,13 +450,16 @@ func TestPRView_Preview(t *testing.T) { `Projects:.*Project 1 \(column A\), Project 2 \(column B\), Project 3 \(column C\)\n`, `Milestone:.*uluru\n`, `blueberries taste good`, - `View this pull request on GitHub: https://github.com/OWNER/REPO/pull/10\n`, + `View this pull request on GitHub: https://github.com/OWNER/REPO/pull/10`, }, }, "Open PR for the current branch": { - branch: "blueberries", - args: "", - fixture: "./fixtures/prView.json", + branch: "blueberries", + args: "", + fixtures: map[string]string{ + "PullRequestForBranch": "./fixtures/prView.json", + "ReviewsForPullRequest": "./fixtures/prViewPreviewNoReviews.json", + }, expectedOutputs: []string{ `Blueberries are a good fruit`, `Open.*nobody wants to merge 8 commits into master from blueberries`, @@ -411,9 +468,12 @@ func TestPRView_Preview(t *testing.T) { }, }, "Open PR wth empty body for the current branch": { - branch: "blueberries", - args: "", - fixture: "./fixtures/prView_EmptyBody.json", + branch: "blueberries", + args: "", + fixtures: map[string]string{ + "PullRequestForBranch": "./fixtures/prView_EmptyBody.json", + "ReviewsForPullRequest": "./fixtures/prViewPreviewNoReviews.json", + }, expectedOutputs: []string{ `Blueberries are a good fruit`, `Open.*nobody wants to merge 8 commits into master from blueberries`, @@ -421,9 +481,12 @@ func TestPRView_Preview(t *testing.T) { }, }, "Closed PR": { - branch: "master", - args: "12", - fixture: "./fixtures/prViewPreviewClosedState.json", + branch: "master", + args: "12", + fixtures: map[string]string{ + "PullRequestByNumber": "./fixtures/prViewPreviewClosedState.json", + "ReviewsForPullRequest": "./fixtures/prViewPreviewNoReviews.json", + }, expectedOutputs: []string{ `Blueberries are from a fork`, `Closed.*nobody wants to merge 12 commits into master from blueberries`, @@ -432,9 +495,12 @@ func TestPRView_Preview(t *testing.T) { }, }, "Merged PR": { - branch: "master", - args: "12", - fixture: "./fixtures/prViewPreviewMergedState.json", + branch: "master", + args: "12", + fixtures: map[string]string{ + "PullRequestByNumber": "./fixtures/prViewPreviewMergedState.json", + "ReviewsForPullRequest": "./fixtures/prViewPreviewNoReviews.json", + }, expectedOutputs: []string{ `Blueberries are from a fork`, `Merged.*nobody wants to merge 12 commits into master from blueberries`, @@ -443,9 +509,12 @@ func TestPRView_Preview(t *testing.T) { }, }, "Draft PR": { - branch: "master", - args: "12", - fixture: "./fixtures/prViewPreviewDraftState.json", + branch: "master", + args: "12", + fixtures: map[string]string{ + "PullRequestByNumber": "./fixtures/prViewPreviewDraftState.json", + "ReviewsForPullRequest": "./fixtures/prViewPreviewNoReviews.json", + }, expectedOutputs: []string{ `Blueberries are from a fork`, `Draft.*nobody wants to merge 12 commits into master from blueberries`, @@ -454,9 +523,12 @@ func TestPRView_Preview(t *testing.T) { }, }, "Draft PR by branch": { - branch: "master", - args: "blueberries", - fixture: "./fixtures/prViewPreviewDraftStatebyBranch.json", + branch: "master", + args: "blueberries", + fixtures: map[string]string{ + "PullRequestForBranch": "./fixtures/prViewPreviewDraftStatebyBranch.json", + "ReviewsForPullRequest": "./fixtures/prViewPreviewNoReviews.json", + }, expectedOutputs: []string{ `Blueberries are a good fruit`, `Draft.*nobody wants to merge 8 commits into master from blueberries`, @@ -470,14 +542,17 @@ func TestPRView_Preview(t *testing.T) { t.Run(name, func(t *testing.T) { http := &httpmock.Registry{} defer http.Verify(t) - http.Register(httpmock.GraphQL(`query PullRequest(ByNumber|ForBranch)\b`), httpmock.FileResponse(tc.fixture)) + for name, file := range tc.fixtures { + name := fmt.Sprintf(`query %s\b`, name) + http.Register(httpmock.GraphQL(name), httpmock.FileResponse(file)) + } output, err := runCommand(http, tc.branch, true, tc.args) if err != nil { t.Errorf("error running command `%v`: %v", tc.args, err) } - eq(t, output.Stderr(), "") + assert.Equal(t, "", output.Stderr()) test.ExpectLines(t, output.String(), tc.expectedOutputs...) }) @@ -506,8 +581,8 @@ func TestPRView_web_currentBranch(t *testing.T) { t.Errorf("error running command `pr view`: %v", err) } - eq(t, output.String(), "") - eq(t, output.Stderr(), "Opening github.com/OWNER/REPO/pull/10 in your browser.\n") + assert.Equal(t, "", output.String()) + assert.Equal(t, "Opening github.com/OWNER/REPO/pull/10 in your browser.\n", output.Stderr()) if seenCmd == nil { t.Fatal("expected a command to run") @@ -549,11 +624,13 @@ func TestPRView_web_numberArg(t *testing.T) { http := &httpmock.Registry{} defer http.Verify(t) - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { "pullRequest": { - "url": "https://github.com/OWNER/REPO/pull/23" - } } } } - `)) + http.Register( + httpmock.GraphQL(`query PullRequestByNumber\b`), + httpmock.StringResponse(` + { "data": { "repository": { "pullRequest": { + "url": "https://github.com/OWNER/REPO/pull/23" + } } } }`), + ) var seenCmd *exec.Cmd restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable { @@ -567,24 +644,26 @@ func TestPRView_web_numberArg(t *testing.T) { t.Errorf("error running command `pr view`: %v", err) } - eq(t, output.String(), "") + assert.Equal(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/pull/23") + assert.Equal(t, "https://github.com/OWNER/REPO/pull/23", url) } func TestPRView_web_numberArgWithHash(t *testing.T) { http := &httpmock.Registry{} defer http.Verify(t) - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { "pullRequest": { - "url": "https://github.com/OWNER/REPO/pull/23" - } } } } - `)) + http.Register( + httpmock.GraphQL(`query PullRequestByNumber\b`), + httpmock.StringResponse(` + { "data": { "repository": { "pullRequest": { + "url": "https://github.com/OWNER/REPO/pull/23" + } } } }`), + ) var seenCmd *exec.Cmd restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable { @@ -598,23 +677,26 @@ func TestPRView_web_numberArgWithHash(t *testing.T) { t.Errorf("error running command `pr view`: %v", err) } - eq(t, output.String(), "") + assert.Equal(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/pull/23") + assert.Equal(t, "https://github.com/OWNER/REPO/pull/23", url) } func TestPRView_web_urlArg(t *testing.T) { http := &httpmock.Registry{} defer http.Verify(t) - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { "pullRequest": { - "url": "https://github.com/OWNER/REPO/pull/23" - } } } } - `)) + + http.Register( + httpmock.GraphQL(`query PullRequestByNumber\b`), + httpmock.StringResponse(` + { "data": { "repository": { "pullRequest": { + "url": "https://github.com/OWNER/REPO/pull/23" + } } } }`), + ) var seenCmd *exec.Cmd restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable { @@ -628,26 +710,28 @@ func TestPRView_web_urlArg(t *testing.T) { t.Errorf("error running command `pr view`: %v", err) } - eq(t, output.String(), "") + assert.Equal(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/pull/23") + assert.Equal(t, "https://github.com/OWNER/REPO/pull/23", url) } func TestPRView_web_branchArg(t *testing.T) { http := &httpmock.Registry{} defer http.Verify(t) - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { "pullRequests": { "nodes": [ - { "headRefName": "blueberries", - "isCrossRepository": false, - "url": "https://github.com/OWNER/REPO/pull/23" } - ] } } } } - `)) + http.Register( + httpmock.GraphQL(`query PullRequestForBranch\b`), + httpmock.StringResponse(` + { "data": { "repository": { "pullRequests": { "nodes": [ + { "headRefName": "blueberries", + "isCrossRepository": false, + "url": "https://github.com/OWNER/REPO/pull/23" } + ] } } } }`), + ) var seenCmd *exec.Cmd restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable { @@ -661,27 +745,29 @@ func TestPRView_web_branchArg(t *testing.T) { t.Errorf("error running command `pr view`: %v", err) } - eq(t, output.String(), "") + assert.Equal(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/pull/23") + assert.Equal(t, "https://github.com/OWNER/REPO/pull/23", url) } func TestPRView_web_branchWithOwnerArg(t *testing.T) { http := &httpmock.Registry{} defer http.Verify(t) - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { "pullRequests": { "nodes": [ - { "headRefName": "blueberries", - "isCrossRepository": true, - "headRepositoryOwner": { "login": "hubot" }, - "url": "https://github.com/hubot/REPO/pull/23" } - ] } } } } - `)) + http.Register( + httpmock.GraphQL(`query PullRequestForBranch\b`), + httpmock.StringResponse(` + { "data": { "repository": { "pullRequests": { "nodes": [ + { "headRefName": "blueberries", + "isCrossRepository": true, + "headRepositoryOwner": { "login": "hubot" }, + "url": "https://github.com/hubot/REPO/pull/23" } + ] } } } }`), + ) var seenCmd *exec.Cmd restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable { @@ -695,11 +781,214 @@ func TestPRView_web_branchWithOwnerArg(t *testing.T) { t.Errorf("error running command `pr view`: %v", err) } - eq(t, output.String(), "") + assert.Equal(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/hubot/REPO/pull/23") + assert.Equal(t, "https://github.com/hubot/REPO/pull/23", url) +} + +func TestPRView_tty_Comments(t *testing.T) { + tests := map[string]struct { + branch string + cli string + fixtures map[string]string + expectedOutputs []string + wantsErr bool + }{ + "without comments flag": { + branch: "master", + cli: "123", + fixtures: map[string]string{ + "PullRequestByNumber": "./fixtures/prViewPreviewSingleComment.json", + "ReviewsForPullRequest": "./fixtures/prViewPreviewReviews.json", + }, + expectedOutputs: []string{ + `some title`, + `1 \x{1f615} • 2 \x{1f440} • 3 \x{2764}\x{fe0f}`, + `some body`, + `———————— Not showing 8 comments ————————`, + `marseilles \(Collaborator\) • Jan 9, 2020 • Newest comment`, + `4 \x{1f389} • 5 \x{1f604} • 6 \x{1f680}`, + `Comment 5`, + `Use --comments to view the full conversation`, + `View this pull request on GitHub: https://github.com/OWNER/REPO/pull/12`, + }, + }, + "with comments flag": { + branch: "master", + cli: "123 --comments", + fixtures: map[string]string{ + "PullRequestByNumber": "./fixtures/prViewPreviewSingleComment.json", + "ReviewsForPullRequest": "./fixtures/prViewPreviewReviews.json", + "CommentsForPullRequest": "./fixtures/prViewPreviewFullComments.json", + }, + expectedOutputs: []string{ + `some title`, + `some body`, + `monalisa • Jan 1, 2020 • Edited`, + `1 \x{1f615} • 2 \x{1f440} • 3 \x{2764}\x{fe0f} • 4 \x{1f389} • 5 \x{1f604} • 6 \x{1f680} • 7 \x{1f44e} • 8 \x{1f44d}`, + `Comment 1`, + `sam commented • Jan 2, 2020`, + `1 \x{1f44e} • 1 \x{1f44d}`, + `Review 1`, + `View the full review: https://github.com/OWNER/REPO/pull/12#pullrequestreview-1`, + `johnnytest \(Contributor\) • Jan 3, 2020`, + `Comment 2`, + `matt requested changes \(Owner\) • Jan 4, 2020`, + `1 \x{1f615} • 1 \x{1f440}`, + `Review 2`, + `View the full review: https://github.com/OWNER/REPO/pull/12#pullrequestreview-2`, + `elvisp \(Member\) • Jan 5, 2020`, + `Comment 3`, + `leah approved \(Member\) • Jan 6, 2020 • Edited`, + `Review 3`, + `View the full review: https://github.com/OWNER/REPO/pull/12#pullrequestreview-3`, + `loislane \(Owner\) • Jan 7, 2020`, + `Comment 4`, + `louise dismissed • Jan 8, 2020`, + `Review 4`, + `View the full review: https://github.com/OWNER/REPO/pull/12#pullrequestreview-4`, + `marseilles \(Collaborator\) • Jan 9, 2020 • Newest comment`, + `Comment 5`, + `View this pull request on GitHub: https://github.com/OWNER/REPO/pull/12`, + }, + }, + "with invalid comments flag": { + branch: "master", + cli: "123 --comments 3", + wantsErr: true, + }, + } + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + stubSpinner() + http := &httpmock.Registry{} + defer http.Verify(t) + for name, file := range tt.fixtures { + name := fmt.Sprintf(`query %s\b`, name) + http.Register(httpmock.GraphQL(name), httpmock.FileResponse(file)) + } + output, err := runCommand(http, tt.branch, true, tt.cli) + if tt.wantsErr { + assert.Error(t, err) + return + } + assert.NoError(t, err) + assert.Equal(t, "", output.Stderr()) + test.ExpectLines(t, output.String(), tt.expectedOutputs...) + }) + } +} + +func TestPRView_nontty_Comments(t *testing.T) { + tests := map[string]struct { + branch string + cli string + fixtures map[string]string + expectedOutputs []string + wantsErr bool + }{ + "without comments flag": { + branch: "master", + cli: "123", + fixtures: map[string]string{ + "PullRequestByNumber": "./fixtures/prViewPreviewSingleComment.json", + "ReviewsForPullRequest": "./fixtures/prViewPreviewReviews.json", + }, + expectedOutputs: []string{ + `title:\tsome title`, + `state:\tOPEN`, + `author:\tnobody`, + `url:\thttps://github.com/OWNER/REPO/pull/12`, + `some body`, + }, + }, + "with comments flag": { + branch: "master", + cli: "123 --comments", + fixtures: map[string]string{ + "PullRequestByNumber": "./fixtures/prViewPreviewSingleComment.json", + "ReviewsForPullRequest": "./fixtures/prViewPreviewReviews.json", + "CommentsForPullRequest": "./fixtures/prViewPreviewFullComments.json", + }, + expectedOutputs: []string{ + `author:\tmonalisa`, + `association:\tnone`, + `edited:\ttrue`, + `status:\tnone`, + `Comment 1`, + `author:\tsam`, + `association:\tnone`, + `edited:\tfalse`, + `status:\tcommented`, + `Review 1`, + `author:\tjohnnytest`, + `association:\tcontributor`, + `edited:\tfalse`, + `status:\tnone`, + `Comment 2`, + `author:\tmatt`, + `association:\towner`, + `edited:\tfalse`, + `status:\tchanges requested`, + `Review 2`, + `author:\telvisp`, + `association:\tmember`, + `edited:\tfalse`, + `status:\tnone`, + `Comment 3`, + `author:\tleah`, + `association:\tmember`, + `edited:\ttrue`, + `status:\tapproved`, + `Review 3`, + `author:\tloislane`, + `association:\towner`, + `edited:\tfalse`, + `status:\tnone`, + `Comment 4`, + `author:\tlouise`, + `association:\tnone`, + `edited:\tfalse`, + `status:\tdismissed`, + `Review 4`, + `author:\tmarseilles`, + `association:\tcollaborator`, + `edited:\tfalse`, + `status:\tnone`, + `Comment 5`, + }, + }, + "with invalid comments flag": { + branch: "master", + cli: "123 --comments 3", + wantsErr: true, + }, + } + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + http := &httpmock.Registry{} + defer http.Verify(t) + for name, file := range tt.fixtures { + name := fmt.Sprintf(`query %s\b`, name) + http.Register(httpmock.GraphQL(name), httpmock.FileResponse(file)) + } + output, err := runCommand(http, tt.branch, false, tt.cli) + if tt.wantsErr { + assert.Error(t, err) + return + } + assert.NoError(t, err) + assert.Equal(t, "", output.Stderr()) + test.ExpectLines(t, output.String(), tt.expectedOutputs...) + }) + } +} + +func stubSpinner() { + utils.StartSpinner = func(_ *spinner.Spinner) {} + utils.StopSpinner = func(_ *spinner.Spinner) {} } diff --git a/pkg/cmd/repo/clone/clone.go b/pkg/cmd/repo/clone/clone.go index 73f4d7187..ee2a2ecdf 100644 --- a/pkg/cmd/repo/clone/clone.go +++ b/pkg/cmd/repo/clone/clone.go @@ -87,6 +87,7 @@ func cloneRun(opts *CloneOptions) error { var repo ghrepo.Interface var protocol string + if repositoryIsURL { repoURL, err := git.ParseURL(opts.Repository) if err != nil { @@ -123,6 +124,12 @@ func cloneRun(opts *CloneOptions) error { } } + wantsWiki := strings.HasSuffix(repo.RepoName(), ".wiki") + if wantsWiki { + repoName := strings.TrimSuffix(repo.RepoName(), ".wiki") + repo = ghrepo.NewWithHost(repo.RepoOwner(), repoName, repo.RepoHost()) + } + // Load the repo from the API to get the username/repo name in its // canonical capitalization canonicalRepo, err := api.GitHubRepo(apiClient, repo) @@ -131,6 +138,14 @@ func cloneRun(opts *CloneOptions) error { } canonicalCloneURL := ghrepo.FormatRemoteURL(canonicalRepo, protocol) + // If repo HasWikiEnabled and wantsWiki is true then create a new clone URL + if wantsWiki { + if !canonicalRepo.HasWikiEnabled { + return fmt.Errorf("The '%s' repository does not have a wiki", ghrepo.FullName(canonicalRepo)) + } + canonicalCloneURL = strings.TrimSuffix(canonicalCloneURL, ".git") + ".wiki.git" + } + cloneDir, err := git.RunClone(canonicalCloneURL, opts.GitArgs) if err != nil { return err diff --git a/pkg/cmd/repo/clone/clone_test.go b/pkg/cmd/repo/clone/clone_test.go index e7aa57b08..db40f4f2d 100644 --- a/pkg/cmd/repo/clone/clone_test.go +++ b/pkg/cmd/repo/clone/clone_test.go @@ -77,11 +77,11 @@ func TestNewCmdClone(t *testing.T) { cmd.SetErr(stderr) _, err = cmd.ExecuteC() - if err != nil { - assert.Equal(t, tt.wantErr, err.Error()) + if tt.wantErr != "" { + assert.EqualError(t, err, tt.wantErr) return - } else if tt.wantErr != "" { - t.Errorf("expected error %q, got nil", tt.wantErr) + } else { + assert.NoError(t, err) } assert.Equal(t, "", stdout.String()) @@ -168,6 +168,16 @@ func Test_RepoClone(t *testing.T) { args: "Owner/Repo", want: "git clone https://github.com/OWNER/REPO.git", }, + { + name: "clone wiki", + args: "Owner/Repo.wiki", + want: "git clone https://github.com/OWNER/REPO.wiki.git", + }, + { + name: "wiki URL", + args: "https://github.com/owner/repo.wiki", + want: "git clone https://github.com/OWNER/REPO.wiki.git", + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -179,7 +189,8 @@ func Test_RepoClone(t *testing.T) { "name": "REPO", "owner": { "login": "OWNER" - } + }, + "hasWikiEnabled": true } } } `)) diff --git a/pkg/cmd/repo/create/create.go b/pkg/cmd/repo/create/create.go index bdd2e4df5..8b5ab5b13 100644 --- a/pkg/cmd/repo/create/create.go +++ b/pkg/cmd/repo/create/create.go @@ -273,22 +273,26 @@ func createRun(opts *CreateOptions) error { if isTTY { fmt.Fprintf(stderr, "%s Added remote %s\n", cs.SuccessIcon(), remoteURL) } - } else if opts.IO.CanPrompt() { - doSetup := createLocalDirectory - if !doSetup { - err := prompt.Confirm(fmt.Sprintf("Create a local project directory for %s?", ghrepo.FullName(repo)), &doSetup) - if err != nil { - return err + } else { + if opts.IO.CanPrompt() { + if !createLocalDirectory { + err := prompt.Confirm(fmt.Sprintf("Create a local project directory for %s?", ghrepo.FullName(repo)), &createLocalDirectory) + if err != nil { + return err + } } } - if doSetup { + if createLocalDirectory { path := repo.Name gitInit, err := git.GitCommand("init", path) if err != nil { return err } - gitInit.Stdout = stdout + isTTY := opts.IO.IsStdoutTTY() + if isTTY { + gitInit.Stdout = stdout + } gitInit.Stderr = stderr err = run.PrepareCmd(gitInit).Run() if err != nil { @@ -304,8 +308,9 @@ func createRun(opts *CreateOptions) error { if err != nil { return err } - - fmt.Fprintf(stderr, "%s Initialized repository in './%s/'\n", cs.SuccessIcon(), path) + if isTTY { + fmt.Fprintf(stderr, "%s Initialized repository in './%s/'\n", cs.SuccessIcon(), path) + } } } diff --git a/pkg/cmd/repo/create/create_test.go b/pkg/cmd/repo/create/create_test.go index 7282b350d..f9f3ea12d 100644 --- a/pkg/cmd/repo/create/create_test.go +++ b/pkg/cmd/repo/create/create_test.go @@ -3,6 +3,7 @@ package create import ( "bytes" "encoding/json" + "errors" "io/ioutil" "net/http" "os/exec" @@ -20,10 +21,10 @@ import ( "github.com/stretchr/testify/assert" ) -func runCommand(httpClient *http.Client, cli string) (*test.CmdOut, error) { +func runCommand(httpClient *http.Client, cli string, isTTY bool) (*test.CmdOut, error) { io, _, stdout, stderr := iostreams.Test() - io.SetStdoutTTY(true) - io.SetStdinTTY(true) + io.SetStdoutTTY(isTTY) + io.SetStdinTTY(isTTY) fac := &cmdutil.Factory{ IOStreams: io, HttpClient: func() (*http.Client, error) { @@ -106,7 +107,7 @@ func TestRepoCreate(t *testing.T) { }, }) - output, err := runCommand(httpClient, "REPO") + output, err := runCommand(httpClient, "REPO", true) if err != nil { t.Errorf("error running command `repo create`: %v", err) } @@ -143,6 +144,83 @@ func TestRepoCreate(t *testing.T) { } } +func TestRepoCreate_outsideGitWorkDir(t *testing.T) { + reg := &httpmock.Registry{} + reg.Register( + httpmock.GraphQL(`mutation RepositoryCreate\b`), + httpmock.StringResponse(` + { "data": { "createRepository": { + "repository": { + "id": "REPOID", + "url": "https://github.com/OWNER/REPO", + "name": "REPO", + "owner": { + "login": "OWNER" + } + } + } } }`)) + + httpClient := &http.Client{Transport: reg} + + var seenCmds []*exec.Cmd + cmdOutputs := []test.OutputStub{ + { + Error: errors.New("Not a git repository"), + }, + {}, + {}, + } + restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable { + if len(cmdOutputs) == 0 { + t.Fatal("Too many calls to git command") + } + out := cmdOutputs[0] + cmdOutputs = cmdOutputs[1:] + seenCmds = append(seenCmds, cmd) + return &out + }) + defer restoreCmd() + + output, err := runCommand(httpClient, "REPO --private --confirm", false) + if err != nil { + t.Errorf("error running command `repo create`: %v", err) + } + + assert.Equal(t, "https://github.com/OWNER/REPO\n", output.String()) + assert.Equal(t, "", output.Stderr()) + + if len(seenCmds) != 3 { + t.Fatal("expected three commands to run") + } + + assert.Equal(t, "git rev-parse --show-toplevel", strings.Join(seenCmds[0].Args, " ")) + assert.Equal(t, "git init REPO", strings.Join(seenCmds[1].Args, " ")) + assert.Equal(t, "git -C REPO remote add origin https://github.com/OWNER/REPO.git", strings.Join(seenCmds[2].Args, " ")) + + var reqBody struct { + Query string + Variables struct { + Input map[string]interface{} + } + } + + if len(reg.Requests) != 1 { + t.Fatalf("expected 1 HTTP request, got %d", len(reg.Requests)) + } + + bodyBytes, _ := ioutil.ReadAll(reg.Requests[0].Body) + _ = json.Unmarshal(bodyBytes, &reqBody) + if repoName := reqBody.Variables.Input["name"].(string); repoName != "REPO" { + t.Errorf("expected %q, got %q", "REPO", repoName) + } + if repoVisibility := reqBody.Variables.Input["visibility"].(string); repoVisibility != "PRIVATE" { + t.Errorf("expected %q, got %q", "PRIVATE", repoVisibility) + } + if _, ownerSet := reqBody.Variables.Input["ownerId"]; ownerSet { + t.Error("expected ownerId not to be set") + } +} + func TestRepoCreate_org(t *testing.T) { reg := &httpmock.Registry{} reg.Register( @@ -188,7 +266,7 @@ func TestRepoCreate_org(t *testing.T) { }, }) - output, err := runCommand(httpClient, "ORG/REPO") + output, err := runCommand(httpClient, "ORG/REPO", true) if err != nil { t.Errorf("error running command `repo create`: %v", err) } @@ -270,7 +348,7 @@ func TestRepoCreate_orgWithTeam(t *testing.T) { }, }) - output, err := runCommand(httpClient, "ORG/REPO --team monkeys") + output, err := runCommand(httpClient, "ORG/REPO --team monkeys", true) if err != nil { t.Errorf("error running command `repo create`: %v", err) } @@ -353,7 +431,7 @@ func TestRepoCreate_template(t *testing.T) { }, }) - output, err := runCommand(httpClient, "REPO --template='OWNER/REPO'") + output, err := runCommand(httpClient, "REPO --template='OWNER/REPO'", true) if err != nil { t.Errorf("error running command `repo create`: %v", err) } @@ -441,7 +519,7 @@ func TestRepoCreate_withoutNameArg(t *testing.T) { }, }) - output, err := runCommand(httpClient, "") + output, err := runCommand(httpClient, "", true) if err != nil { t.Errorf("error running command `repo create`: %v", err) } diff --git a/pkg/cmd/repo/create/http_test.go b/pkg/cmd/repo/create/http_test.go index c8e2e7a2a..240a1b625 100644 --- a/pkg/cmd/repo/create/http_test.go +++ b/pkg/cmd/repo/create/http_test.go @@ -1,23 +1,28 @@ package create import ( - "bytes" - "encoding/json" - "io/ioutil" "testing" "github.com/cli/cli/api" "github.com/cli/cli/pkg/httpmock" + "github.com/stretchr/testify/assert" ) func Test_RepoCreate(t *testing.T) { reg := &httpmock.Registry{} httpClient := api.NewHTTPClient(api.ReplaceTripper(reg)) - reg.StubResponse(200, bytes.NewBufferString(`{}`)) + reg.Register( + httpmock.GraphQL(`mutation RepositoryCreate\b`), + httpmock.GraphQLMutation(`{}`, + func(inputs map[string]interface{}) { + assert.Equal(t, inputs["description"], "roasted chestnuts") + assert.Equal(t, inputs["homepageUrl"], "http://example.com") + }), + ) input := repoCreateInput{ - Description: "roasted chesnuts", + Description: "roasted chestnuts", HomepageURL: "http://example.com", } @@ -29,20 +34,4 @@ func Test_RepoCreate(t *testing.T) { if len(reg.Requests) != 1 { t.Fatalf("expected 1 HTTP request, seen %d", len(reg.Requests)) } - - var reqBody struct { - Query string - Variables struct { - Input map[string]interface{} - } - } - - bodyBytes, _ := ioutil.ReadAll(reg.Requests[0].Body) - _ = json.Unmarshal(bodyBytes, &reqBody) - if description := reqBody.Variables.Input["description"].(string); description != "roasted chesnuts" { - t.Errorf("expected description to be %q, got %q", "roasted chesnuts", description) - } - if homepage := reqBody.Variables.Input["homepageUrl"].(string); homepage != "http://example.com" { - t.Errorf("expected homepageUrl to be %q, got %q", "http://example.com", homepage) - } } diff --git a/pkg/cmd/repo/fork/fork.go b/pkg/cmd/repo/fork/fork.go index e2eaeee46..46fa3e81c 100644 --- a/pkg/cmd/repo/fork/fork.go +++ b/pkg/cmd/repo/fork/fork.go @@ -196,6 +196,21 @@ func forkRun(opts *ForkOptions) error { if err != nil { return err } + + if remote, err := remotes.FindByRepo(repoToFork.RepoOwner(), repoToFork.RepoName()); err == nil { + + scheme := "" + if remote.FetchURL != nil { + scheme = remote.FetchURL.Scheme + } + if remote.PushURL != nil { + scheme = remote.PushURL.Scheme + } + if scheme != "" { + protocol = scheme + } + } + if remote, err := remotes.FindByRepo(forkedRepo.RepoOwner(), forkedRepo.RepoName()); err == nil { if connectedToTerminal { fmt.Fprintf(stderr, "%s Using existing remote %s\n", cs.SuccessIcon(), cs.Bold(remote.Name)) diff --git a/pkg/cmd/repo/fork/fork_test.go b/pkg/cmd/repo/fork/fork_test.go index 0a92b77e5..20f396394 100644 --- a/pkg/cmd/repo/fork/fork_test.go +++ b/pkg/cmd/repo/fork/fork_test.go @@ -2,6 +2,7 @@ package fork import ( "net/http" + "net/url" "os/exec" "regexp" "strings" @@ -44,8 +45,11 @@ func runCommand(httpClient *http.Client, remotes []*context.Remote, isTTY bool, if remotes == nil { return []*context.Remote{ { - Remote: &git.Remote{Name: "origin"}, - Repo: ghrepo.New("OWNER", "REPO"), + Remote: &git.Remote{ + Name: "origin", + FetchURL: &url.URL{}, + }, + Repo: ghrepo.New("OWNER", "REPO"), }, }, nil } @@ -179,11 +183,11 @@ func TestRepoFork_reuseRemote(t *testing.T) { stubSpinner() remotes := []*context.Remote{ { - Remote: &git.Remote{Name: "origin"}, + Remote: &git.Remote{Name: "origin", FetchURL: &url.URL{}}, Repo: ghrepo.New("someone", "REPO"), }, { - Remote: &git.Remote{Name: "upstream"}, + Remote: &git.Remote{Name: "upstream", FetchURL: &url.URL{}}, Repo: ghrepo.New("OWNER", "REPO"), }, } @@ -465,6 +469,50 @@ func TestRepoFork_in_parent_survey_no(t *testing.T) { reg.Verify(t) } +func TestRepoFork_in_parent_match_protocol(t *testing.T) { + stubSpinner() + defer stubSince(2 * time.Second)() + reg := &httpmock.Registry{} + defer reg.StubWithFixturePath(200, "./forkResult.json")() + httpClient := &http.Client{Transport: reg} + + var seenCmds []*exec.Cmd + defer run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable { + seenCmds = append(seenCmds, cmd) + return &test.OutputStub{} + })() + + remotes := []*context.Remote{ + { + Remote: &git.Remote{Name: "origin", PushURL: &url.URL{ + Scheme: "ssh", + }}, + Repo: ghrepo.New("OWNER", "REPO"), + }, + } + + output, err := runCommand(httpClient, remotes, true, "--remote") + if err != nil { + t.Errorf("error running command `repo fork`: %v", err) + } + + expectedCmds := []string{ + "git remote rename origin upstream", + "git remote add -f origin git@github.com:someone/REPO.git", + } + + for x, cmd := range seenCmds { + assert.Equal(t, expectedCmds[x], strings.Join(cmd.Args, " ")) + } + + assert.Equal(t, "", output.String()) + + test.ExpectLines(t, output.Stderr(), + "Created fork.*someone/REPO", + "Added remote.*origin") + reg.Verify(t) +} + func stubSpinner() { // not bothering with teardown since we never want spinners when doing tests utils.StartSpinner = func(_ *spinner.Spinner) { diff --git a/pkg/cmd/repo/garden/garden.go b/pkg/cmd/repo/garden/garden.go index 359d8d898..c7ee048a7 100644 --- a/pkg/cmd/repo/garden/garden.go +++ b/pkg/cmd/repo/garden/garden.go @@ -190,7 +190,7 @@ func gardenRun(opts *GardenOptions) error { oldTTYCommand := exec.Command("stty", sttyFileArg, "/dev/tty", "-g") oldTTYSettings, err := oldTTYCommand.CombinedOutput() if err != nil { - fmt.Fprintln(out, "getting TTY setings failed:", string(oldTTYSettings)) + fmt.Fprintln(out, "getting TTY settings failed:", string(oldTTYSettings)) return err } diff --git a/pkg/cmd/root/help.go b/pkg/cmd/root/help.go index a3be5a27a..80b61341e 100644 --- a/pkg/cmd/root/help.go +++ b/pkg/cmd/root/help.go @@ -35,7 +35,7 @@ func rootUsageFunc(command *cobra.Command) error { return nil } -func rootFlagErrrorFunc(cmd *cobra.Command, err error) error { +func rootFlagErrorFunc(cmd *cobra.Command, err error) error { if err == pflag.ErrHelp { return err } diff --git a/pkg/cmd/root/help_reference.go b/pkg/cmd/root/help_reference.go new file mode 100644 index 000000000..ecc7ec856 --- /dev/null +++ b/pkg/cmd/root/help_reference.go @@ -0,0 +1,69 @@ +package root + +import ( + "bytes" + "fmt" + "io" + "strings" + + "github.com/cli/cli/pkg/iostreams" + "github.com/cli/cli/pkg/markdown" + "github.com/spf13/cobra" +) + +func referenceHelpFn(io *iostreams.IOStreams) func(*cobra.Command, []string) { + return func(cmd *cobra.Command, args []string) { + wrapWidth := 0 + style := "notty" + if io.IsStdoutTTY() { + wrapWidth = io.TerminalWidth() + style = markdown.GetStyle(io.DetectTerminalTheme()) + } + + md, err := markdown.RenderWrap(cmd.Long, style, wrapWidth) + if err != nil { + fmt.Fprintln(io.ErrOut, err) + return + } + + if !io.IsStdoutTTY() { + fmt.Fprint(io.Out, dedent(md)) + return + } + + _ = io.StartPager() + defer io.StopPager() + fmt.Fprint(io.Out, md) + } +} + +func referenceLong(cmd *cobra.Command) string { + buf := bytes.NewBufferString("# gh reference\n\n") + for _, c := range cmd.Commands() { + if c.Hidden { + continue + } + cmdRef(buf, c, 2) + } + return buf.String() +} + +func cmdRef(w io.Writer, cmd *cobra.Command, depth int) { + // Name + Description + fmt.Fprintf(w, "%s `%s`\n\n", strings.Repeat("#", depth), cmd.UseLine()) + fmt.Fprintf(w, "%s\n\n", cmd.Short) + + // Flags + // TODO: fold in InheritedFlags/PersistentFlags, but omit `--help` due to repetitiveness + if flagUsages := cmd.Flags().FlagUsages(); flagUsages != "" { + fmt.Fprintf(w, "```\n%s````\n\n", dedent(flagUsages)) + } + + // Subcommands + for _, c := range cmd.Commands() { + if c.Hidden { + continue + } + cmdRef(w, c, depth+1) + } +} diff --git a/pkg/cmd/root/help_topic.go b/pkg/cmd/root/help_topic.go index c44bfca00..99055034b 100644 --- a/pkg/cmd/root/help_topic.go +++ b/pkg/cmd/root/help_topic.go @@ -47,6 +47,9 @@ var HelpTopics = map[string]map[string]string{ error if a newer version was found. `), }, + "reference": { + "short": "A comprehensive reference of all gh commands", + }, } func NewHelpTopic(topic string) *cobra.Command { @@ -55,8 +58,6 @@ func NewHelpTopic(topic string) *cobra.Command { Short: HelpTopics[topic]["short"], Long: HelpTopics[topic]["long"], Hidden: true, - Args: cobra.NoArgs, - Run: helpTopicHelpFunc, Annotations: map[string]string{ "markdown:generate": "true", "markdown:basename": "gh_help_" + topic, diff --git a/pkg/cmd/root/help_topic_test.go b/pkg/cmd/root/help_topic_test.go index f194541ac..3aba4bdf3 100644 --- a/pkg/cmd/root/help_topic_test.go +++ b/pkg/cmd/root/help_topic_test.go @@ -34,7 +34,7 @@ func TestNewHelpTopic(t *testing.T) { topic: "environment", args: []string{"invalid"}, flags: []string{}, - wantsErr: true, + wantsErr: false, }, { name: "more than zero flags", @@ -48,7 +48,7 @@ func TestNewHelpTopic(t *testing.T) { topic: "environment", args: []string{"help"}, flags: []string{}, - wantsErr: true, + wantsErr: false, }, { name: "help flag", diff --git a/pkg/cmd/root/root.go b/pkg/cmd/root/root.go index 906223d15..ba05f902c 100644 --- a/pkg/cmd/root/root.go +++ b/pkg/cmd/root/root.go @@ -19,6 +19,8 @@ import ( releaseCmd "github.com/cli/cli/pkg/cmd/release" repoCmd "github.com/cli/cli/pkg/cmd/repo" creditsCmd "github.com/cli/cli/pkg/cmd/repo/credits" + secretCmd "github.com/cli/cli/pkg/cmd/secret" + sshKeyCmd "github.com/cli/cli/pkg/cmd/ssh-key" versionCmd "github.com/cli/cli/pkg/cmd/version" "github.com/cli/cli/pkg/cmdutil" "github.com/spf13/cobra" @@ -59,7 +61,7 @@ func NewCmdRoot(f *cmdutil.Factory, version, buildDate string) *cobra.Command { cmd.PersistentFlags().Bool("help", false, "Show help for command") cmd.SetHelpFunc(helpHelper) cmd.SetUsageFunc(rootUsageFunc) - cmd.SetFlagErrorFunc(rootFlagErrrorFunc) + cmd.SetFlagErrorFunc(rootFlagErrorFunc) formattedVersion := versionCmd.Format(version, buildDate) cmd.SetVersionTemplate(formattedVersion) @@ -74,6 +76,8 @@ func NewCmdRoot(f *cmdutil.Factory, version, buildDate string) *cobra.Command { cmd.AddCommand(creditsCmd.NewCmdCredits(f, nil)) cmd.AddCommand(gistCmd.NewCmdGist(f)) cmd.AddCommand(completionCmd.NewCmdCompletion(f.IOStreams)) + cmd.AddCommand(secretCmd.NewCmdSecret(f)) + cmd.AddCommand(sshKeyCmd.NewCmdSSHKey(f)) // the `api` command should not inherit any extra HTTP headers bareHTTPCmdFactory := *f @@ -92,9 +96,14 @@ func NewCmdRoot(f *cmdutil.Factory, version, buildDate string) *cobra.Command { // Help topics cmd.AddCommand(NewHelpTopic("environment")) + referenceCmd := NewHelpTopic("reference") + referenceCmd.SetHelpFunc(referenceHelpFn(f.IOStreams)) + cmd.AddCommand(referenceCmd) cmdutil.DisableAuthCheck(cmd) + // this needs to appear last: + referenceCmd.Long = referenceLong(cmd) return cmd } diff --git a/pkg/cmd/secret/list/list.go b/pkg/cmd/secret/list/list.go new file mode 100644 index 000000000..792e5fc82 --- /dev/null +++ b/pkg/cmd/secret/list/list.go @@ -0,0 +1,183 @@ +package list + +import ( + "fmt" + "net/http" + "net/url" + "strings" + "time" + + "github.com/cli/cli/api" + "github.com/cli/cli/internal/ghinstance" + "github.com/cli/cli/internal/ghrepo" + "github.com/cli/cli/pkg/cmd/secret/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) + IO *iostreams.IOStreams + BaseRepo func() (ghrepo.Interface, error) + + OrgName string +} + +func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Command { + opts := &ListOptions{ + IO: f.IOStreams, + HttpClient: f.HttpClient, + } + + cmd := &cobra.Command{ + Use: "list", + Short: "List secrets", + Long: "List secrets for a repository or organization", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + // support `-R, --repo` override + opts.BaseRepo = f.BaseRepo + + if runF != nil { + return runF(opts) + } + + return listRun(opts) + }, + } + + cmd.Flags().StringVarP(&opts.OrgName, "org", "o", "", "List secrets for an organization") + + return cmd +} + +func listRun(opts *ListOptions) error { + c, err := opts.HttpClient() + if err != nil { + return fmt.Errorf("could not create http client: %w", err) + } + client := api.NewClientFromHTTP(c) + + orgName := opts.OrgName + + var baseRepo ghrepo.Interface + if orgName == "" { + baseRepo, err = opts.BaseRepo() + if err != nil { + return fmt.Errorf("could not determine base repo: %w", err) + } + } + + var secrets []*Secret + if orgName == "" { + secrets, err = getRepoSecrets(client, baseRepo) + } else { + secrets, err = getOrgSecrets(client, orgName) + } + + if err != nil { + return fmt.Errorf("failed to get secrets: %w", err) + } + + tp := utils.NewTablePrinter(opts.IO) + for _, secret := range secrets { + tp.AddField(secret.Name, nil, nil) + updatedAt := secret.UpdatedAt.Format("2006-01-02") + if opts.IO.IsStdoutTTY() { + updatedAt = fmt.Sprintf("Updated %s", updatedAt) + } + tp.AddField(updatedAt, nil, nil) + if secret.Visibility != "" { + if opts.IO.IsStdoutTTY() { + tp.AddField(fmtVisibility(*secret), nil, nil) + } else { + tp.AddField(strings.ToUpper(string(secret.Visibility)), nil, nil) + } + } + tp.EndRow() + } + + err = tp.Render() + if err != nil { + return err + } + + return nil +} + +type Secret struct { + Name string + UpdatedAt time.Time `json:"updated_at"` + Visibility shared.Visibility + SelectedReposURL string `json:"selected_repositories_url"` + NumSelectedRepos int +} + +func fmtVisibility(s Secret) string { + switch s.Visibility { + case shared.All: + return "Visible to all repositories" + case shared.Private: + return "Visible to private repositories" + case shared.Selected: + if s.NumSelectedRepos == 1 { + return "Visible to 1 selected repository" + } else { + return fmt.Sprintf("Visible to %d selected repositories", s.NumSelectedRepos) + } + } + return "" +} + +func getOrgSecrets(client *api.Client, orgName string) ([]*Secret, error) { + host := ghinstance.OverridableDefault() + secrets, err := getSecrets(client, host, fmt.Sprintf("orgs/%s/actions/secrets", orgName)) + if err != nil { + return nil, err + } + + type responseData struct { + TotalCount int `json:"total_count"` + } + + for _, secret := range secrets { + if secret.SelectedReposURL == "" { + continue + } + u, err := url.Parse(secret.SelectedReposURL) + if err != nil { + return nil, fmt.Errorf("failed determining selected repositories for %s: %w", secret.Name, err) + } + + var result responseData + err = client.REST(u.Host, "GET", u.Path[1:], nil, &result) + if err != nil { + return nil, fmt.Errorf("failed determining selected repositories for %s: %w", secret.Name, err) + } + secret.NumSelectedRepos = result.TotalCount + } + + return secrets, nil +} + +func getRepoSecrets(client *api.Client, repo ghrepo.Interface) ([]*Secret, error) { + return getSecrets(client, repo.RepoHost(), fmt.Sprintf("repos/%s/actions/secrets", + ghrepo.FullName(repo))) +} + +type secretsPayload struct { + Secrets []*Secret +} + +func getSecrets(client *api.Client, host, path string) ([]*Secret, error) { + result := secretsPayload{} + + err := client.REST(host, "GET", path, nil, &result) + if err != nil { + return nil, err + } + + return result.Secrets, nil +} diff --git a/pkg/cmd/secret/list/list_test.go b/pkg/cmd/secret/list/list_test.go new file mode 100644 index 000000000..601c13c27 --- /dev/null +++ b/pkg/cmd/secret/list/list_test.go @@ -0,0 +1,197 @@ +package list + +import ( + "bytes" + "fmt" + "net/http" + "testing" + "time" + + "github.com/cli/cli/internal/ghrepo" + "github.com/cli/cli/pkg/cmd/secret/shared" + "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 Test_NewCmdList(t *testing.T) { + tests := []struct { + name string + cli string + wants ListOptions + }{ + { + name: "repo", + cli: "", + wants: ListOptions{ + OrgName: "", + }, + }, + { + name: "org", + cli: "-oUmbrellaCorporation", + wants: ListOptions{ + OrgName: "UmbrellaCorporation", + }, + }, + } + + 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 *ListOptions + cmd := NewCmdList(f, func(opts *ListOptions) error { + gotOpts = opts + return nil + }) + 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.OrgName, gotOpts.OrgName) + + }) + } +} + +func Test_listRun(t *testing.T) { + tests := []struct { + name string + tty bool + opts *ListOptions + wantOut []string + }{ + { + name: "repo tty", + tty: true, + opts: &ListOptions{}, + wantOut: []string{ + "SECRET_ONE.*Updated 1988-10-11", + "SECRET_TWO.*Updated 2020-12-04", + "SECRET_THREE.*Updated 1975-11-30", + }, + }, + { + name: "repo not tty", + tty: false, + opts: &ListOptions{}, + wantOut: []string{ + "SECRET_ONE\t1988-10-11", + "SECRET_TWO\t2020-12-04", + "SECRET_THREE\t1975-11-30", + }, + }, + { + name: "org tty", + tty: true, + opts: &ListOptions{ + OrgName: "UmbrellaCorporation", + }, + wantOut: []string{ + "SECRET_ONE.*Updated 1988-10-11.*Visible to all repositories", + "SECRET_TWO.*Updated 2020-12-04.*Visible to private repositories", + "SECRET_THREE.*Updated 1975-11-30.*Visible to 2 selected repositories", + }, + }, + { + name: "org not tty", + tty: false, + opts: &ListOptions{ + OrgName: "UmbrellaCorporation", + }, + wantOut: []string{ + "SECRET_ONE\t1988-10-11\tALL", + "SECRET_TWO\t2020-12-04\tPRIVATE", + "SECRET_THREE\t1975-11-30\tSELECTED", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reg := &httpmock.Registry{} + + t0, _ := time.Parse("2006-01-02", "1988-10-11") + t1, _ := time.Parse("2006-01-02", "2020-12-04") + t2, _ := time.Parse("2006-01-02", "1975-11-30") + path := "repos/owner/repo/actions/secrets" + payload := secretsPayload{} + payload.Secrets = []*Secret{ + { + Name: "SECRET_ONE", + UpdatedAt: t0, + }, + { + Name: "SECRET_TWO", + UpdatedAt: t1, + }, + { + Name: "SECRET_THREE", + UpdatedAt: t2, + }, + } + if tt.opts.OrgName != "" { + payload.Secrets = []*Secret{ + { + Name: "SECRET_ONE", + UpdatedAt: t0, + Visibility: shared.All, + }, + { + Name: "SECRET_TWO", + UpdatedAt: t1, + Visibility: shared.Private, + }, + { + Name: "SECRET_THREE", + UpdatedAt: t2, + Visibility: shared.Selected, + SelectedReposURL: fmt.Sprintf("https://api.github.com/orgs/%s/actions/secrets/SECRET_THREE/repositories", tt.opts.OrgName), + }, + } + path = fmt.Sprintf("orgs/%s/actions/secrets", tt.opts.OrgName) + + reg.Register( + httpmock.REST("GET", fmt.Sprintf("orgs/%s/actions/secrets/SECRET_THREE/repositories", tt.opts.OrgName)), + httpmock.JSONResponse(struct { + TotalCount int `json:"total_count"` + }{2})) + } + + reg.Register(httpmock.REST("GET", path), httpmock.JSONResponse(payload)) + + io, _, stdout, _ := iostreams.Test() + + io.SetStdoutTTY(tt.tty) + + tt.opts.IO = io + tt.opts.BaseRepo = func() (ghrepo.Interface, error) { + return ghrepo.FromFullName("owner/repo") + } + tt.opts.HttpClient = func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + } + + err := listRun(tt.opts) + assert.NoError(t, err) + + reg.Verify(t) + + test.ExpectLines(t, stdout.String(), tt.wantOut...) + }) + } +} diff --git a/pkg/cmd/secret/remove/remove.go b/pkg/cmd/secret/remove/remove.go new file mode 100644 index 000000000..000b71ae4 --- /dev/null +++ b/pkg/cmd/secret/remove/remove.go @@ -0,0 +1,94 @@ +package remove + +import ( + "fmt" + "net/http" + + "github.com/cli/cli/api" + "github.com/cli/cli/internal/ghinstance" + "github.com/cli/cli/internal/ghrepo" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/iostreams" + "github.com/spf13/cobra" +) + +type RemoveOptions struct { + HttpClient func() (*http.Client, error) + IO *iostreams.IOStreams + BaseRepo func() (ghrepo.Interface, error) + + SecretName string + OrgName string +} + +func NewCmdRemove(f *cmdutil.Factory, runF func(*RemoveOptions) error) *cobra.Command { + opts := &RemoveOptions{ + IO: f.IOStreams, + HttpClient: f.HttpClient, + } + + cmd := &cobra.Command{ + Use: "remove ", + Short: "Remove an organization or repository secret", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + // support `-R, --repo` override + opts.BaseRepo = f.BaseRepo + + opts.SecretName = args[0] + + if runF != nil { + return runF(opts) + } + + return removeRun(opts) + }, + } + cmd.Flags().StringVarP(&opts.OrgName, "org", "o", "", "List secrets for an organization") + + return cmd +} + +func removeRun(opts *RemoveOptions) error { + c, err := opts.HttpClient() + if err != nil { + return fmt.Errorf("could not create http client: %w", err) + } + client := api.NewClientFromHTTP(c) + + orgName := opts.OrgName + + var baseRepo ghrepo.Interface + if orgName == "" { + baseRepo, err = opts.BaseRepo() + if err != nil { + return fmt.Errorf("could not determine base repo: %w", err) + } + } + + var path string + if orgName == "" { + path = fmt.Sprintf("repos/%s/actions/secrets/%s", ghrepo.FullName(baseRepo), opts.SecretName) + } else { + path = fmt.Sprintf("orgs/%s/actions/secrets/%s", orgName, opts.SecretName) + } + + host := ghinstance.OverridableDefault() + err = client.REST(host, "DELETE", path, nil, nil) + if err != nil { + return fmt.Errorf("failed to delete secret %s: %w", opts.SecretName, err) + } + + if opts.IO.IsStdoutTTY() { + cs := opts.IO.ColorScheme() + if orgName == "" { + fmt.Fprintf(opts.IO.Out, + "%s Removed secret %s from %s\n", cs.SuccessIcon(), opts.SecretName, ghrepo.FullName(baseRepo)) + } else { + fmt.Fprintf(opts.IO.Out, + "%s Removed secret %s from %s\n", cs.SuccessIcon(), opts.SecretName, orgName) + } + } + + return nil +} diff --git a/pkg/cmd/secret/remove/remove_test.go b/pkg/cmd/secret/remove/remove_test.go new file mode 100644 index 000000000..efd1f660d --- /dev/null +++ b/pkg/cmd/secret/remove/remove_test.go @@ -0,0 +1,157 @@ +package remove + +import ( + "bytes" + "fmt" + "net/http" + "testing" + + "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/google/shlex" + "github.com/stretchr/testify/assert" +) + +func TestNewCmdRemove(t *testing.T) { + tests := []struct { + name string + cli string + wants RemoveOptions + wantsErr bool + }{ + { + name: "no args", + wantsErr: true, + }, + { + name: "repo", + cli: "cool", + wants: RemoveOptions{ + SecretName: "cool", + }, + }, + { + name: "org", + cli: "cool --org anOrg", + wants: RemoveOptions{ + SecretName: "cool", + OrgName: "anOrg", + }, + }, + } + + 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 *RemoveOptions + cmd := NewCmdRemove(f, func(opts *RemoveOptions) error { + gotOpts = opts + return nil + }) + 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.SecretName, gotOpts.SecretName) + assert.Equal(t, tt.wants.OrgName, gotOpts.OrgName) + }) + } + +} + +func Test_removeRun_repo(t *testing.T) { + reg := &httpmock.Registry{} + + reg.Register( + httpmock.REST("DELETE", "repos/owner/repo/actions/secrets/cool_secret"), + httpmock.StatusStringResponse(204, "No Content")) + + io, _, _, _ := iostreams.Test() + + opts := &RemoveOptions{ + IO: io, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + BaseRepo: func() (ghrepo.Interface, error) { + return ghrepo.FromFullName("owner/repo") + }, + SecretName: "cool_secret", + } + + err := removeRun(opts) + assert.NoError(t, err) + + reg.Verify(t) +} + +func Test_removeRun_org(t *testing.T) { + tests := []struct { + name string + opts *RemoveOptions + }{ + { + name: "repo", + opts: &RemoveOptions{}, + }, + { + name: "org", + opts: &RemoveOptions{ + OrgName: "UmbrellaCorporation", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reg := &httpmock.Registry{} + + orgName := tt.opts.OrgName + + if orgName == "" { + reg.Register( + httpmock.REST("DELETE", "repos/owner/repo/actions/secrets/tVirus"), + httpmock.StatusStringResponse(204, "No Content")) + } else { + reg.Register( + httpmock.REST("DELETE", fmt.Sprintf("orgs/%s/actions/secrets/tVirus", orgName)), + httpmock.StatusStringResponse(204, "No Content")) + } + + io, _, _, _ := iostreams.Test() + + tt.opts.BaseRepo = func() (ghrepo.Interface, error) { + return ghrepo.FromFullName("owner/repo") + } + tt.opts.HttpClient = func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + } + tt.opts.IO = io + tt.opts.SecretName = "tVirus" + + err := removeRun(tt.opts) + assert.NoError(t, err) + + reg.Verify(t) + + }) + } + +} diff --git a/pkg/cmd/secret/secret.go b/pkg/cmd/secret/secret.go new file mode 100644 index 000000000..e8b82a41d --- /dev/null +++ b/pkg/cmd/secret/secret.go @@ -0,0 +1,30 @@ +package secret + +import ( + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/pkg/cmdutil" + "github.com/spf13/cobra" + + cmdList "github.com/cli/cli/pkg/cmd/secret/list" + cmdRemove "github.com/cli/cli/pkg/cmd/secret/remove" + cmdSet "github.com/cli/cli/pkg/cmd/secret/set" +) + +func NewCmdSecret(f *cmdutil.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "secret ", + Short: "Manage GitHub secrets", + Long: heredoc.Doc(` + Secrets can be set at the repository or organization level for use in GitHub Actions. + Run "gh help secret set" to learn how to get started. +`), + } + + cmdutil.EnableRepoOverride(cmd, f) + + cmd.AddCommand(cmdList.NewCmdList(f, nil)) + cmd.AddCommand(cmdSet.NewCmdSet(f, nil)) + cmd.AddCommand(cmdRemove.NewCmdRemove(f, nil)) + + return cmd +} diff --git a/pkg/cmd/secret/set/http.go b/pkg/cmd/secret/set/http.go new file mode 100644 index 000000000..b8d80974e --- /dev/null +++ b/pkg/cmd/secret/set/http.go @@ -0,0 +1,141 @@ +package set + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "fmt" + "strings" + + "github.com/cli/cli/api" + "github.com/cli/cli/internal/ghinstance" + "github.com/cli/cli/internal/ghrepo" + "github.com/cli/cli/pkg/cmd/secret/shared" +) + +type SecretPayload struct { + EncryptedValue string `json:"encrypted_value"` + Visibility string `json:"visibility,omitempty"` + Repositories []int `json:"selected_repository_ids,omitempty"` + KeyID string `json:"key_id"` +} + +type PubKey struct { + Raw [32]byte + ID string `json:"key_id"` + Key string +} + +func getPubKey(client *api.Client, host, path string) (*PubKey, error) { + pk := PubKey{} + err := client.REST(host, "GET", path, nil, &pk) + if err != nil { + return nil, err + } + + if pk.Key == "" { + return nil, fmt.Errorf("failed to find public key at %s/%s", host, path) + } + + decoded, err := base64.StdEncoding.DecodeString(pk.Key) + if err != nil { + return nil, fmt.Errorf("failed to decode public key: %w", err) + } + + copy(pk.Raw[:], decoded[0:32]) + return &pk, nil +} + +func getOrgPublicKey(client *api.Client, orgName string) (*PubKey, error) { + host := ghinstance.OverridableDefault() + return getPubKey(client, host, fmt.Sprintf("orgs/%s/actions/secrets/public-key", orgName)) +} + +func getRepoPubKey(client *api.Client, repo ghrepo.Interface) (*PubKey, error) { + return getPubKey(client, repo.RepoHost(), fmt.Sprintf("repos/%s/actions/secrets/public-key", + ghrepo.FullName(repo))) +} + +func putSecret(client *api.Client, host, path string, payload SecretPayload) error { + payloadBytes, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("failed to serialize: %w", err) + } + requestBody := bytes.NewReader(payloadBytes) + + return client.REST(host, "PUT", path, requestBody, nil) +} + +func putOrgSecret(client *api.Client, pk *PubKey, opts SetOptions, eValue string) error { + secretName := opts.SecretName + orgName := opts.OrgName + visibility := opts.Visibility + host := ghinstance.OverridableDefault() + + var repositoryIDs []int + var err error + if orgName != "" && visibility == shared.Selected { + repositoryIDs, err = mapRepoNameToID(client, host, orgName, opts.RepositoryNames) + if err != nil { + return fmt.Errorf("failed to look up IDs for repositories %v: %w", opts.RepositoryNames, err) + } + } + + payload := SecretPayload{ + EncryptedValue: eValue, + KeyID: pk.ID, + Repositories: repositoryIDs, + Visibility: visibility, + } + path := fmt.Sprintf("orgs/%s/actions/secrets/%s", orgName, secretName) + + return putSecret(client, host, path, payload) +} + +func putRepoSecret(client *api.Client, pk *PubKey, repo ghrepo.Interface, secretName, eValue string) error { + payload := SecretPayload{ + EncryptedValue: eValue, + KeyID: pk.ID, + } + path := fmt.Sprintf("repos/%s/actions/secrets/%s", ghrepo.FullName(repo), secretName) + return putSecret(client, repo.RepoHost(), path, payload) +} + +func mapRepoNameToID(client *api.Client, host, orgName string, repositoryNames []string) ([]int, error) { + queries := make([]string, 0, len(repositoryNames)) + for _, repoName := range repositoryNames { + queries = append(queries, fmt.Sprintf(` + %s: repository(owner: %q, name :%q) { + databaseId + } + `, repoName, orgName, repoName)) + } + + query := fmt.Sprintf(`query MapRepositoryNames { %s }`, strings.Join(queries, "")) + + graphqlResult := make(map[string]*struct { + DatabaseID int `json:"databaseId"` + }) + + err := client.GraphQL(host, query, nil, &graphqlResult) + + gqlErr, isGqlErr := err.(*api.GraphQLErrorResponse) + if isGqlErr { + for _, ge := range gqlErr.Errors { + if ge.Type == "NOT_FOUND" { + return nil, fmt.Errorf("could not find %s/%s", orgName, ge.Path[0]) + } + } + } + if err != nil { + return nil, fmt.Errorf("failed to look up repositories: %w", err) + } + + result := make([]int, 0, len(repositoryNames)) + + for _, repoName := range repositoryNames { + result = append(result, graphqlResult[repoName].DatabaseID) + } + + return result, nil +} diff --git a/pkg/cmd/secret/set/set.go b/pkg/cmd/secret/set/set.go new file mode 100644 index 000000000..1a05d93cc --- /dev/null +++ b/pkg/cmd/secret/set/set.go @@ -0,0 +1,209 @@ +package set + +import ( + "encoding/base64" + "errors" + "fmt" + "io" + "io/ioutil" + "net/http" + "regexp" + "strings" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/api" + "github.com/cli/cli/internal/ghrepo" + "github.com/cli/cli/pkg/cmd/secret/shared" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/iostreams" + "github.com/spf13/cobra" + "golang.org/x/crypto/nacl/box" +) + +type SetOptions struct { + HttpClient func() (*http.Client, error) + IO *iostreams.IOStreams + BaseRepo func() (ghrepo.Interface, error) + + RandomOverride io.Reader + + SecretName string + OrgName string + Body string + Visibility string + RepositoryNames []string +} + +func NewCmdSet(f *cmdutil.Factory, runF func(*SetOptions) error) *cobra.Command { + opts := &SetOptions{ + IO: f.IOStreams, + HttpClient: f.HttpClient, + } + + cmd := &cobra.Command{ + Use: "set ", + Short: "Create or update secrets", + Long: "Locally encrypt a new or updated secret at either the repository or organization level and send it to GitHub for storage.", + Example: heredoc.Doc(` + $ gh secret set FROM_FLAG -b"some literal value" + $ gh secret set FROM_ENV -b"${ENV_VALUE}" + $ gh secret set FROM_FILE < file.json + $ gh secret set ORG_SECRET -bval --org=anOrg --visibility=all + $ gh secret set ORG_SECRET -bval --org=anOrg --repos="repo1,repo2,repo3" +`), + Args: func(cmd *cobra.Command, args []string) error { + if len(args) != 1 { + return &cmdutil.FlagError{Err: errors.New("must pass single secret name")} + } + if !cmd.Flags().Changed("body") && opts.IO.IsStdinTTY() { + return &cmdutil.FlagError{Err: errors.New("no --body specified but nothing on STIDN")} + } + return nil + }, + RunE: func(cmd *cobra.Command, args []string) error { + // support `-R, --repo` override + opts.BaseRepo = f.BaseRepo + + opts.SecretName = args[0] + + err := validSecretName(opts.SecretName) + if err != nil { + return err + } + + if cmd.Flags().Changed("visibility") { + if opts.OrgName == "" { + return &cmdutil.FlagError{Err: errors.New( + "--visibility not supported for repository secrets; did you mean to pass --org?")} + } + + if opts.Visibility != shared.All && opts.Visibility != shared.Private && opts.Visibility != shared.Selected { + return &cmdutil.FlagError{Err: errors.New( + "--visibility must be one of `all`, `private`, or `selected`")} + } + + if opts.Visibility != shared.Selected && cmd.Flags().Changed("repos") { + return &cmdutil.FlagError{Err: errors.New( + "--repos only supported when --visibility='selected'")} + } + + if opts.Visibility == shared.Selected && !cmd.Flags().Changed("repos") { + return &cmdutil.FlagError{Err: errors.New( + "--repos flag required when --visibility='selected'")} + } + } else { + if cmd.Flags().Changed("repos") { + opts.Visibility = shared.Selected + } + } + + if runF != nil { + return runF(opts) + } + + return setRun(opts) + }, + } + cmd.Flags().StringVarP(&opts.OrgName, "org", "o", "", "List secrets for an organization") + cmd.Flags().StringVarP(&opts.Visibility, "visibility", "v", "private", "Set visibility for an organization secret: `all`, `private`, or `selected`") + cmd.Flags().StringSliceVarP(&opts.RepositoryNames, "repos", "r", []string{}, "List of repository names for `selected` visibility") + cmd.Flags().StringVarP(&opts.Body, "body", "b", "", "A value for the secret. Reads from STDIN if not specified.") + + return cmd +} + +func setRun(opts *SetOptions) error { + body, err := getBody(opts) + if err != nil { + return fmt.Errorf("did not understand secret body: %w", err) + } + + c, err := opts.HttpClient() + if err != nil { + return fmt.Errorf("could not create http client: %w", err) + } + client := api.NewClientFromHTTP(c) + + orgName := opts.OrgName + + var baseRepo ghrepo.Interface + if orgName == "" { + baseRepo, err = opts.BaseRepo() + if err != nil { + return fmt.Errorf("could not determine base repo: %w", err) + } + } + + var pk *PubKey + if orgName != "" { + pk, err = getOrgPublicKey(client, orgName) + } else { + pk, err = getRepoPubKey(client, baseRepo) + } + if err != nil { + return fmt.Errorf("failed to fetch public key: %w", err) + } + + eBody, err := box.SealAnonymous(nil, body, &pk.Raw, opts.RandomOverride) + if err != nil { + return fmt.Errorf("failed to encrypt body: %w", err) + } + + encoded := base64.StdEncoding.EncodeToString(eBody) + + if orgName != "" { + err = putOrgSecret(client, pk, *opts, encoded) + } else { + err = putRepoSecret(client, pk, baseRepo, opts.SecretName, encoded) + } + if err != nil { + return fmt.Errorf("failed to set secret: %w", err) + } + + if opts.IO.IsStdoutTTY() { + cs := opts.IO.ColorScheme() + + if orgName == "" { + fmt.Fprintf(opts.IO.Out, "%s Set secret %s for %s\n", cs.SuccessIcon(), opts.SecretName, ghrepo.FullName(baseRepo)) + } else { + fmt.Fprintf(opts.IO.Out, "%s Set secret %s for %s\n", cs.SuccessIcon(), opts.SecretName, orgName) + } + } + + return nil +} + +func validSecretName(name string) error { + if name == "" { + return errors.New("secret name cannot be blank") + } + + if strings.HasPrefix(name, "GITHUB_") { + return errors.New("secret name cannot begin with GITHUB_") + } + + leadingNumber := regexp.MustCompile(`^[0-9]`) + if leadingNumber.MatchString(name) { + return errors.New("secret name cannot start with a number") + } + + validChars := regexp.MustCompile(`^([0-9]|[a-z]|[A-Z]|_)+$`) + if !validChars.MatchString(name) { + return errors.New("secret name can only contain letters, numbers, and _") + } + + return nil +} + +func getBody(opts *SetOptions) ([]byte, error) { + if opts.Body == "" { + body, err := ioutil.ReadAll(opts.IO.In) + if err != nil { + return nil, fmt.Errorf("failed to read from STDIN: %w", err) + } + + return body, nil + } + + return []byte(opts.Body), nil +} diff --git a/pkg/cmd/secret/set/set_test.go b/pkg/cmd/secret/set/set_test.go new file mode 100644 index 000000000..4536205a1 --- /dev/null +++ b/pkg/cmd/secret/set/set_test.go @@ -0,0 +1,323 @@ +package set + +import ( + "bytes" + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "testing" + + "github.com/cli/cli/internal/ghrepo" + "github.com/cli/cli/pkg/cmd/secret/shared" + "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 TestNewCmdSet(t *testing.T) { + tests := []struct { + name string + cli string + wants SetOptions + stdinTTY bool + wantsErr bool + }{ + { + name: "invalid visibility", + cli: "cool_secret --org coolOrg -v'mistyVeil'", + wantsErr: true, + }, + { + name: "invalid visibility", + cli: "cool_secret --org coolOrg -v'selected'", + wantsErr: true, + }, + { + name: "repos with wrong vis", + cli: "cool_secret --org coolOrg -v'private' -rcoolRepo", + wantsErr: true, + }, + { + name: "no name", + cli: "", + wantsErr: true, + }, + { + name: "multiple names", + cli: "cool_secret good_secret", + wantsErr: true, + }, + { + name: "no body, stdin is terminal", + cli: "cool_secret", + stdinTTY: true, + wantsErr: true, + }, + { + name: "visibility without org", + cli: "cool_secret -vall", + wantsErr: true, + }, + { + name: "repos without vis", + cli: "cool_secret -bs --org coolOrg -rcoolRepo", + wants: SetOptions{ + SecretName: "cool_secret", + Visibility: shared.Selected, + RepositoryNames: []string{"coolRepo"}, + Body: "s", + OrgName: "coolOrg", + }, + }, + { + name: "org with selected repo", + cli: "-ocoolOrg -bs -vselected -rcoolRepo cool_secret", + wants: SetOptions{ + SecretName: "cool_secret", + Visibility: shared.Selected, + RepositoryNames: []string{"coolRepo"}, + Body: "s", + OrgName: "coolOrg", + }, + }, + { + name: "org with selected repos", + cli: `--org=coolOrg -bs -vselected -r="coolRepo,radRepo,goodRepo" cool_secret`, + wants: SetOptions{ + SecretName: "cool_secret", + Visibility: shared.Selected, + RepositoryNames: []string{"coolRepo", "goodRepo", "radRepo"}, + Body: "s", + OrgName: "coolOrg", + }, + }, + { + name: "repo", + cli: `cool_secret -b"a secret"`, + wants: SetOptions{ + SecretName: "cool_secret", + Visibility: shared.Private, + Body: "a secret", + OrgName: "", + }, + }, + { + name: "vis all", + cli: `cool_secret --org coolOrg -b"cool" -vall`, + wants: SetOptions{ + SecretName: "cool_secret", + Visibility: shared.All, + Body: "cool", + OrgName: "coolOrg", + }, + }, + { + name: "bad name prefix", + cli: `GITHUB_SECRET -b"cool"`, + wantsErr: true, + }, + { + name: "leading numbers in name", + cli: `123_SECRET -b"cool"`, + wantsErr: true, + }, + { + name: "invalid characters in name", + cli: `BAD-SECRET -b"cool"`, + wantsErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + io, _, _, _ := iostreams.Test() + f := &cmdutil.Factory{ + IOStreams: io, + } + + io.SetStdinTTY(tt.stdinTTY) + + argv, err := shlex.Split(tt.cli) + assert.NoError(t, err) + + var gotOpts *SetOptions + cmd := NewCmdSet(f, func(opts *SetOptions) error { + gotOpts = opts + return nil + }) + 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.SecretName, gotOpts.SecretName) + assert.Equal(t, tt.wants.Body, gotOpts.Body) + assert.Equal(t, tt.wants.Visibility, gotOpts.Visibility) + assert.Equal(t, tt.wants.OrgName, gotOpts.OrgName) + assert.ElementsMatch(t, tt.wants.RepositoryNames, gotOpts.RepositoryNames) + }) + } +} + +func Test_setRun_repo(t *testing.T) { + reg := &httpmock.Registry{} + + reg.Register(httpmock.REST("GET", "repos/owner/repo/actions/secrets/public-key"), + httpmock.JSONResponse(PubKey{ID: "123", Key: "CDjXqf7AJBXWhMczcy+Fs7JlACEptgceysutztHaFQI="})) + + reg.Register(httpmock.REST("PUT", "repos/owner/repo/actions/secrets/cool_secret"), httpmock.StatusStringResponse(201, `{}`)) + + io, _, _, _ := iostreams.Test() + + opts := &SetOptions{ + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + BaseRepo: func() (ghrepo.Interface, error) { + return ghrepo.FromFullName("owner/repo") + }, + IO: io, + SecretName: "cool_secret", + Body: "a secret", + // Cribbed from https://github.com/golang/crypto/commit/becbf705a91575484002d598f87d74f0002801e7 + RandomOverride: bytes.NewReader([]byte{5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5}), + } + + err := setRun(opts) + assert.NoError(t, err) + + reg.Verify(t) + + data, err := ioutil.ReadAll(reg.Requests[1].Body) + assert.NoError(t, err) + var payload SecretPayload + err = json.Unmarshal(data, &payload) + assert.NoError(t, err) + assert.Equal(t, payload.KeyID, "123") + assert.Equal(t, payload.EncryptedValue, "UKYUCbHd0DJemxa3AOcZ6XcsBwALG9d4bpB8ZT0gSV39vl3BHiGSgj8zJapDxgB2BwqNqRhpjC4=") +} + +func Test_setRun_org(t *testing.T) { + tests := []struct { + name string + opts *SetOptions + wantVisibility shared.Visibility + wantRepositories []int + }{ + { + name: "all vis", + opts: &SetOptions{ + OrgName: "UmbrellaCorporation", + Visibility: shared.All, + }, + }, + { + name: "selected visibility", + opts: &SetOptions{ + OrgName: "UmbrellaCorporation", + Visibility: shared.Selected, + RepositoryNames: []string{"birkin", "wesker"}, + }, + wantRepositories: []int{1, 2}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reg := &httpmock.Registry{} + + orgName := tt.opts.OrgName + + reg.Register(httpmock.REST("GET", + fmt.Sprintf("orgs/%s/actions/secrets/public-key", orgName)), + httpmock.JSONResponse(PubKey{ID: "123", Key: "CDjXqf7AJBXWhMczcy+Fs7JlACEptgceysutztHaFQI="})) + + reg.Register(httpmock.REST("PUT", + fmt.Sprintf("orgs/%s/actions/secrets/cool_secret", orgName)), + httpmock.StatusStringResponse(201, `{}`)) + + if len(tt.opts.RepositoryNames) > 0 { + reg.Register(httpmock.GraphQL(`query MapRepositoryNames\b`), + httpmock.StringResponse(`{"data":{"birkin":{"databaseId":1},"wesker":{"databaseId":2}}}`)) + } + + io, _, _, _ := iostreams.Test() + + tt.opts.BaseRepo = func() (ghrepo.Interface, error) { + return ghrepo.FromFullName("owner/repo") + } + tt.opts.HttpClient = func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + } + tt.opts.IO = io + tt.opts.SecretName = "cool_secret" + tt.opts.Body = "a secret" + // Cribbed from https://github.com/golang/crypto/commit/becbf705a91575484002d598f87d74f0002801e7 + tt.opts.RandomOverride = bytes.NewReader([]byte{5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5}) + + err := setRun(tt.opts) + assert.NoError(t, err) + + reg.Verify(t) + + data, err := ioutil.ReadAll(reg.Requests[len(reg.Requests)-1].Body) + assert.NoError(t, err) + var payload SecretPayload + err = json.Unmarshal(data, &payload) + assert.NoError(t, err) + assert.Equal(t, payload.KeyID, "123") + assert.Equal(t, payload.EncryptedValue, "UKYUCbHd0DJemxa3AOcZ6XcsBwALG9d4bpB8ZT0gSV39vl3BHiGSgj8zJapDxgB2BwqNqRhpjC4=") + assert.Equal(t, payload.Visibility, tt.opts.Visibility) + assert.ElementsMatch(t, payload.Repositories, tt.wantRepositories) + }) + } +} + +func Test_getBody(t *testing.T) { + tests := []struct { + name string + bodyArg string + want string + stdin string + }{ + { + name: "literal value", + bodyArg: "a secret", + want: "a secret", + }, + { + name: "from stdin", + want: "a secret", + stdin: "a secret", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + io, stdin, _, _ := iostreams.Test() + + io.SetStdinTTY(false) + + _, err := stdin.WriteString(tt.stdin) + assert.NoError(t, err) + + body, err := getBody(&SetOptions{ + Body: tt.bodyArg, + IO: io, + }) + assert.NoError(t, err) + + assert.Equal(t, string(body), tt.want) + }) + } +} diff --git a/pkg/cmd/secret/shared/shared.go b/pkg/cmd/secret/shared/shared.go new file mode 100644 index 000000000..4f58dd971 --- /dev/null +++ b/pkg/cmd/secret/shared/shared.go @@ -0,0 +1,9 @@ +package shared + +type Visibility string + +const ( + All = "all" + Private = "private" + Selected = "selected" +) diff --git a/pkg/cmd/ssh-key/list/http.go b/pkg/cmd/ssh-key/list/http.go new file mode 100644 index 000000000..70a8d0578 --- /dev/null +++ b/pkg/cmd/ssh-key/list/http.go @@ -0,0 +1,58 @@ +package list + +import ( + "encoding/json" + "errors" + "fmt" + "io/ioutil" + "net/http" + "time" + + "github.com/cli/cli/api" + "github.com/cli/cli/internal/ghinstance" +) + +var scopesError = errors.New("insufficient OAuth scopes") + +type sshKey struct { + Key string + Title string + CreatedAt time.Time `json:"created_at"` +} + +func userKeys(httpClient *http.Client, userHandle string) ([]sshKey, error) { + resource := "user/keys" + if userHandle != "" { + resource = fmt.Sprintf("users/%s/keys", userHandle) + } + url := fmt.Sprintf("%s%s?per_page=%d", ghinstance.RESTPrefix(ghinstance.OverridableDefault()), resource, 100) + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, err + } + + resp, err := httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode == 404 { + return nil, scopesError + } else if resp.StatusCode > 299 { + return nil, api.HandleHTTPError(resp) + } + + b, err := ioutil.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + var keys []sshKey + err = json.Unmarshal(b, &keys) + if err != nil { + return nil, err + } + + return keys, nil +} diff --git a/pkg/cmd/ssh-key/list/list.go b/pkg/cmd/ssh-key/list/list.go new file mode 100644 index 000000000..93a9f5470 --- /dev/null +++ b/pkg/cmd/ssh-key/list/list.go @@ -0,0 +1,96 @@ +package list + +import ( + "errors" + "fmt" + "net/http" + "time" + + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/iostreams" + "github.com/cli/cli/utils" + "github.com/spf13/cobra" +) + +// ListOptions struct for list command +type ListOptions struct { + IO *iostreams.IOStreams + HTTPClient func() (*http.Client, error) +} + +// NewCmdList creates a command for list all SSH Keys +func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Command { + opts := &ListOptions{ + HTTPClient: f.HttpClient, + IO: f.IOStreams, + } + + cmd := &cobra.Command{ + Use: "list", + Short: "Lists SSH keys in a GitHub account", + Args: cobra.ExactArgs(0), + RunE: func(cmd *cobra.Command, args []string) error { + if runF != nil { + return runF(opts) + } + return listRun(opts) + }, + } + + return cmd +} + +func listRun(opts *ListOptions) error { + apiClient, err := opts.HTTPClient() + if err != nil { + return err + } + + sshKeys, err := userKeys(apiClient, "") + if err != nil { + if errors.Is(err, scopesError) { + cs := opts.IO.ColorScheme() + fmt.Fprint(opts.IO.ErrOut, "Error: insufficient OAuth scopes to list SSH keys\n") + fmt.Fprintf(opts.IO.ErrOut, "Run the following to grant scopes: %s\n", cs.Bold("gh auth refresh -s read:public_key")) + return cmdutil.SilentError + } + return err + } + + if len(sshKeys) == 0 { + fmt.Fprintln(opts.IO.ErrOut, "No SSH keys present in GitHub account.") + return cmdutil.SilentError + } + + t := utils.NewTablePrinter(opts.IO) + cs := opts.IO.ColorScheme() + now := time.Now() + + for _, sshKey := range sshKeys { + t.AddField(sshKey.Title, nil, nil) + t.AddField(sshKey.Key, truncateMiddle, nil) + + createdAt := sshKey.CreatedAt.Format(time.RFC3339) + if t.IsTTY() { + createdAt = utils.FuzzyAgoAbbr(now, sshKey.CreatedAt) + } + t.AddField(createdAt, nil, cs.Gray) + t.EndRow() + } + + return t.Render() +} + +func truncateMiddle(maxWidth int, t string) string { + if len(t) <= maxWidth { + return t + } + + ellipsis := "..." + if maxWidth < len(ellipsis)+2 { + return t[0:maxWidth] + } + + halfWidth := (maxWidth - len(ellipsis)) / 2 + return t[0:halfWidth] + ellipsis + t[len(t)-halfWidth:] +} diff --git a/pkg/cmd/ssh-key/list/list_test.go b/pkg/cmd/ssh-key/list/list_test.go new file mode 100644 index 000000000..9dd261d9d --- /dev/null +++ b/pkg/cmd/ssh-key/list/list_test.go @@ -0,0 +1,131 @@ +package list + +import ( + "fmt" + "net/http" + "testing" + "time" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/pkg/httpmock" + "github.com/cli/cli/pkg/iostreams" +) + +func TestListRun(t *testing.T) { + tests := []struct { + name string + opts ListOptions + isTTY bool + wantStdout string + wantStderr string + wantErr bool + }{ + { + name: "list tty", + opts: ListOptions{ + HTTPClient: func() (*http.Client, error) { + createdAt := time.Now().Add(time.Duration(-24) * time.Hour) + reg := &httpmock.Registry{} + reg.Register( + httpmock.REST("GET", "user/keys"), + httpmock.StringResponse(fmt.Sprintf(`[ + { + "id": 1234, + "key": "ssh-rsa AAAABbBB123", + "title": "Mac", + "created_at": "%[1]s" + }, + { + "id": 5678, + "key": "ssh-rsa EEEEEEEK247", + "title": "hubot@Windows", + "created_at": "%[1]s" + } + ]`, createdAt.Format(time.RFC3339))), + ) + return &http.Client{Transport: reg}, nil + }, + }, + isTTY: true, + wantStdout: heredoc.Doc(` + Mac ssh-rsa AAAABbBB123 1d + hubot@Windows ssh-rsa EEEEEEEK247 1d + `), + wantStderr: "", + }, + { + name: "list non-tty", + opts: ListOptions{ + HTTPClient: func() (*http.Client, error) { + createdAt, _ := time.Parse(time.RFC3339, "2020-08-31T15:44:24+02:00") + reg := &httpmock.Registry{} + reg.Register( + httpmock.REST("GET", "user/keys"), + httpmock.StringResponse(fmt.Sprintf(`[ + { + "id": 1234, + "key": "ssh-rsa AAAABbBB123", + "title": "Mac", + "created_at": "%[1]s" + }, + { + "id": 5678, + "key": "ssh-rsa EEEEEEEK247", + "title": "hubot@Windows", + "created_at": "%[1]s" + } + ]`, createdAt.Format(time.RFC3339))), + ) + return &http.Client{Transport: reg}, nil + }, + }, + isTTY: false, + wantStdout: heredoc.Doc(` + Mac ssh-rsa AAAABbBB123 2020-08-31T15:44:24+02:00 + hubot@Windows ssh-rsa EEEEEEEK247 2020-08-31T15:44:24+02:00 + `), + wantStderr: "", + }, + { + name: "no keys", + opts: ListOptions{ + HTTPClient: func() (*http.Client, error) { + reg := &httpmock.Registry{} + reg.Register( + httpmock.REST("GET", "user/keys"), + httpmock.StringResponse(`[]`), + ) + return &http.Client{Transport: reg}, nil + }, + }, + wantStdout: "", + wantStderr: "No SSH keys present in GitHub account.\n", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + io, _, stdout, stderr := iostreams.Test() + io.SetStdoutTTY(tt.isTTY) + io.SetStdinTTY(tt.isTTY) + io.SetStderrTTY(tt.isTTY) + + opts := tt.opts + opts.IO = io + + err := listRun(&opts) + if (err != nil) != tt.wantErr { + t.Errorf("linRun() return error: %v", err) + return + } + + if stdout.String() != tt.wantStdout { + t.Errorf("wants stdout %q, got %q", tt.wantStdout, stdout.String()) + } + if stderr.String() != tt.wantStderr { + t.Errorf("wants stderr %q, got %q", tt.wantStderr, stderr.String()) + } + }) + } +} diff --git a/pkg/cmd/ssh-key/ssh-key.go b/pkg/cmd/ssh-key/ssh-key.go new file mode 100644 index 000000000..1d0b471e6 --- /dev/null +++ b/pkg/cmd/ssh-key/ssh-key.go @@ -0,0 +1,20 @@ +package key + +import ( + cmdList "github.com/cli/cli/pkg/cmd/ssh-key/list" + "github.com/cli/cli/pkg/cmdutil" + "github.com/spf13/cobra" +) + +// NewCmdSSHKey creates a command for manage SSH Keys +func NewCmdSSHKey(f *cmdutil.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "ssh-key ", + Short: "Manage SSH keys", + Long: "Work with GitHub SSH keys", + } + + cmd.AddCommand(cmdList.NewCmdList(f, nil)) + + return cmd +} diff --git a/pkg/cmd/version/version.go b/pkg/cmd/version/version.go index 040a3132e..d473ab0b2 100644 --- a/pkg/cmd/version/version.go +++ b/pkg/cmd/version/version.go @@ -26,11 +26,12 @@ func NewCmdVersion(f *cmdutil.Factory, version, buildDate string) *cobra.Command func Format(version, buildDate string) string { version = strings.TrimPrefix(version, "v") + var dateStr string if buildDate != "" { - version = fmt.Sprintf("%s (%s)", version, buildDate) + dateStr = fmt.Sprintf(" (%s)", buildDate) } - return fmt.Sprintf("gh version %s\n%s\n", version, changelogURL(version)) + return fmt.Sprintf("gh version %s%s\n%s\n", version, dateStr, changelogURL(version)) } func changelogURL(version string) string { diff --git a/pkg/cmd/version/version_test.go b/pkg/cmd/version/version_test.go index 9a1d49db3..be1065dfd 100644 --- a/pkg/cmd/version/version_test.go +++ b/pkg/cmd/version/version_test.go @@ -4,6 +4,13 @@ import ( "testing" ) +func TestFormat(t *testing.T) { + expects := "gh version 1.4.0 (2020-12-15)\nhttps://github.com/cli/cli/releases/tag/v1.4.0\n" + if got := Format("1.4.0", "2020-12-15"); got != expects { + t.Errorf("Format() = %q, wants %q", got, expects) + } +} + func TestChangelogURL(t *testing.T) { tag := "0.3.2" url := "https://github.com/cli/cli/releases/tag/v0.3.2" diff --git a/pkg/cmdutil/auth_check.go b/pkg/cmdutil/auth_check.go index 5d40d0143..10df9fade 100644 --- a/pkg/cmdutil/auth_check.go +++ b/pkg/cmdutil/auth_check.go @@ -17,6 +17,10 @@ func DisableAuthCheck(cmd *cobra.Command) { } func CheckAuth(cfg config.Config) bool { + if config.AuthTokenProvidedFromEnv() { + return true + } + hosts, err := cfg.Hosts() if err != nil { return false diff --git a/pkg/cmdutil/auth_check_test.go b/pkg/cmdutil/auth_check_test.go index 22b8ff5d4..2798750f0 100644 --- a/pkg/cmdutil/auth_check_test.go +++ b/pkg/cmdutil/auth_check_test.go @@ -1,6 +1,7 @@ package cmdutil import ( + "os" "testing" "github.com/cli/cli/internal/config" @@ -8,21 +9,34 @@ import ( ) func Test_CheckAuth(t *testing.T) { + orig_GITHUB_TOKEN := os.Getenv("GITHUB_TOKEN") + t.Cleanup(func() { + os.Setenv("GITHUB_TOKEN", orig_GITHUB_TOKEN) + }) + tests := []struct { name string cfg func(config.Config) + envToken bool expected bool }{ { name: "no hosts", cfg: func(c config.Config) {}, + envToken: false, expected: false, }, + {name: "no hosts, env auth token", + cfg: func(c config.Config) {}, + envToken: true, + expected: true, + }, { name: "host, no token", cfg: func(c config.Config) { _ = c.Set("github.com", "oauth_token", "") }, + envToken: false, expected: false, }, { @@ -30,12 +44,19 @@ func Test_CheckAuth(t *testing.T) { cfg: func(c config.Config) { _ = c.Set("github.com", "oauth_token", "a token") }, + envToken: false, expected: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + if tt.envToken { + os.Setenv("GITHUB_TOKEN", "TOKEN") + } else { + os.Setenv("GITHUB_TOKEN", "") + } + cfg := config.NewBlankConfig() tt.cfg(cfg) result := CheckAuth(cfg) diff --git a/pkg/httpmock/legacy.go b/pkg/httpmock/legacy.go index 9b5d5afef..071876cc1 100644 --- a/pkg/httpmock/legacy.go +++ b/pkg/httpmock/legacy.go @@ -2,37 +2,12 @@ package httpmock import ( "fmt" - "io" "net/http" "os" - "path" - "strings" ) // TODO: clean up methods in this file when there are no more callers -func (r *Registry) StubResponse(status int, body io.Reader) { - r.Register(MatchAny, func(req *http.Request) (*http.Response, error) { - return httpResponse(status, req, body), nil - }) -} - -func (r *Registry) StubWithFixture(status int, fixtureFileName string) func() { - fixturePath := path.Join("../test/fixtures/", fixtureFileName) - fixtureFile, err := os.Open(fixturePath) - r.Register(MatchAny, func(req *http.Request) (*http.Response, error) { - if err != nil { - return nil, err - } - return httpResponse(200, req, fixtureFile), nil - }) - return func() { - if err == nil { - fixtureFile.Close() - } - } -} - func (r *Registry) StubWithFixturePath(status int, fixturePath string) func() { fixtureFile, err := os.Open(fixturePath) r.Register(MatchAny, func(req *http.Request) (*http.Response, error) { @@ -72,14 +47,6 @@ func (r *Registry) StubRepoResponseWithPermission(owner, repo, permission string r.Register(GraphQL(`query RepositoryNetwork\b`), StringResponse(RepoNetworkStubResponse(owner, repo, "master", permission))) } -func (r *Registry) StubRepoResponseWithDefaultBranch(owner, repo, defaultBranch string) { - r.Register(GraphQL(`query RepositoryNetwork\b`), StringResponse(RepoNetworkStubResponse(owner, repo, defaultBranch, "WRITE"))) -} - -func (r *Registry) StubForkedRepoResponse(ownRepo, parentRepo string) { - r.Register(GraphQL(`query RepositoryNetwork\b`), StringResponse(RepoNetworkStubForkResponse(ownRepo, parentRepo))) -} - func RepoNetworkStubResponse(owner, repo, defaultBranch, permission string) string { return fmt.Sprintf(` { "data": { "repo_000": { @@ -93,28 +60,3 @@ func RepoNetworkStubResponse(owner, repo, defaultBranch, permission string) stri } } } `, repo, owner, defaultBranch, permission) } - -func RepoNetworkStubForkResponse(forkFullName, parentFullName string) string { - forkRepo := strings.SplitN(forkFullName, "/", 2) - parentRepo := strings.SplitN(parentFullName, "/", 2) - return fmt.Sprintf(` - { "data": { "repo_000": { - "id": "REPOID2", - "name": "%s", - "owner": {"login": "%s"}, - "defaultBranchRef": { - "name": "master" - }, - "viewerPermission": "ADMIN", - "parent": { - "id": "REPOID1", - "name": "%s", - "owner": {"login": "%s"}, - "defaultBranchRef": { - "name": "master" - }, - "viewerPermission": "READ" - } - } } } - `, forkRepo[1], forkRepo[0], parentRepo[1], parentRepo[0]) -} diff --git a/pkg/iostreams/color.go b/pkg/iostreams/color.go index 7e429e407..972caab3d 100644 --- a/pkg/iostreams/color.go +++ b/pkg/iostreams/color.go @@ -9,14 +9,15 @@ import ( ) var ( - magenta = ansi.ColorFunc("magenta") - cyan = ansi.ColorFunc("cyan") - red = ansi.ColorFunc("red") - yellow = ansi.ColorFunc("yellow") - blue = ansi.ColorFunc("blue") - green = ansi.ColorFunc("green") - gray = ansi.ColorFunc("black+h") - bold = ansi.ColorFunc("default+b") + magenta = ansi.ColorFunc("magenta") + cyan = ansi.ColorFunc("cyan") + red = ansi.ColorFunc("red") + yellow = ansi.ColorFunc("yellow") + blue = ansi.ColorFunc("blue") + green = ansi.ColorFunc("green") + gray = ansi.ColorFunc("black+h") + bold = ansi.ColorFunc("default+b") + cyanBold = ansi.ColorFunc("cyan+b") gray256 = func(t string) string { return fmt.Sprintf("\x1b[%d;5;%dm%s\x1b[m", 38, 242, t) @@ -107,6 +108,13 @@ func (c *ColorScheme) Cyan(t string) string { return cyan(t) } +func (c *ColorScheme) CyanBold(t string) string { + if !c.enabled { + return t + } + return cyanBold(t) +} + func (c *ColorScheme) Blue(t string) string { if !c.enabled { return t @@ -122,6 +130,10 @@ func (c *ColorScheme) WarningIcon() string { return c.Yellow("!") } +func (c *ColorScheme) FailureIcon() string { + return c.Red("X") +} + func (c *ColorScheme) ColorFromString(s string) func(string) string { s = strings.ToLower(s) var fn func(string) string diff --git a/pkg/iostreams/iostreams.go b/pkg/iostreams/iostreams.go index 99ae0cfdc..5df44098d 100644 --- a/pkg/iostreams/iostreams.go +++ b/pkg/iostreams/iostreams.go @@ -45,6 +45,8 @@ type IOStreams struct { pagerProcess *os.Process neverPrompt bool + + TempFileOverride *os.File } func (s *IOStreams) ColorEnabled() bool { @@ -253,6 +255,28 @@ func (s *IOStreams) ColorScheme() *ColorScheme { return NewColorScheme(s.ColorEnabled(), s.ColorSupport256()) } +func (s *IOStreams) ReadUserFile(fn string) ([]byte, error) { + var r io.ReadCloser + if fn == "-" { + r = s.In + } else { + var err error + r, err = os.Open(fn) + if err != nil { + return nil, err + } + } + defer r.Close() + return ioutil.ReadAll(r) +} + +func (s *IOStreams) TempFile(dir, pattern string) (*os.File, error) { + if s.TempFileOverride != nil { + return s.TempFileOverride, nil + } + return ioutil.TempFile(dir, pattern) +} + func System() *IOStreams { stdoutIsTTY := isTerminal(os.Stdout) stderrIsTTY := isTerminal(os.Stderr) diff --git a/pkg/markdown/markdown.go b/pkg/markdown/markdown.go index 844e06811..505c7d401 100644 --- a/pkg/markdown/markdown.go +++ b/pkg/markdown/markdown.go @@ -24,6 +24,23 @@ func Render(text, style string, baseURL string) (string, error) { return tr.Render(text) } +func RenderWrap(text, style string, wrap int) (string, error) { + // Glamour rendering preserves carriage return characters in code blocks, but + // we need to ensure that no such characters are present in the output. + text = strings.ReplaceAll(text, "\r\n", "\n") + + tr, err := glamour.NewTermRenderer( + glamour.WithStylePath(style), + // glamour.WithBaseURL(""), // TODO: make configurable + glamour.WithWordWrap(wrap), + ) + if err != nil { + return "", err + } + + return tr.Render(text) +} + func GetStyle(defaultStyle string) string { style := fromEnv() if style != "" && style != "auto" { diff --git a/pkg/prompt/stubber.go b/pkg/prompt/stubber.go index a3302c3f9..be920cd25 100644 --- a/pkg/prompt/stubber.go +++ b/pkg/prompt/stubber.go @@ -51,6 +51,9 @@ func InitAskStubber() (*AskStubber, func()) { // actually set response stubbedQuestions := as.Stubs[count] + if len(stubbedQuestions) != len(qs) { + panic(fmt.Sprintf("asked questions: %d; stubbed questions: %d", len(qs), len(stubbedQuestions))) + } for i, sq := range stubbedQuestions { q := qs[i] if q.Name != sq.Name { diff --git a/script/distributions b/script/distributions index 5af84b6b3..20308bbf6 100644 --- a/script/distributions +++ b/script/distributions @@ -1,7 +1,7 @@ Origin: gh Label: gh Codename: stable -Architectures: i386 amd64 arm64 +Architectures: i386 amd64 armhf arm64 Components: main Description: The GitHub CLI - debian stable repo SignWith: C99B11DEB97541F0 @@ -9,7 +9,7 @@ SignWith: C99B11DEB97541F0 Origin: gh Label: gh Codename: oldstable -Architectures: i386 amd64 arm64 +Architectures: i386 amd64 armhf arm64 Components: main Description: The GitHub CLI - debian oldstable repo SignWith: C99B11DEB97541F0 @@ -17,7 +17,7 @@ SignWith: C99B11DEB97541F0 Origin: gh Label: gh Codename: testing -Architectures: i386 amd64 arm64 +Architectures: i386 amd64 armhf arm64 Components: main Description: The GitHub CLI - debian testing repo SignWith: C99B11DEB97541F0 @@ -25,7 +25,7 @@ SignWith: C99B11DEB97541F0 Origin: gh Label: gh Codename: unstable -Architectures: i386 amd64 arm64 +Architectures: i386 amd64 armhf arm64 Components: main Description: The GitHub CLI - debian unstable repo SignWith: C99B11DEB97541F0 @@ -33,7 +33,7 @@ SignWith: C99B11DEB97541F0 Origin: gh Label: gh Codename: buster -Architectures: i386 amd64 arm64 +Architectures: i386 amd64 armhf arm64 Components: main Description: The GitHub CLI - debian buster repo SignWith: C99B11DEB97541F0 @@ -41,7 +41,7 @@ SignWith: C99B11DEB97541F0 Origin: gh Label: gh Codename: bullseye -Architectures: i386 amd64 arm64 +Architectures: i386 amd64 armhf arm64 Components: main Description: The GitHub CLI - debian bullseye repo SignWith: C99B11DEB97541F0 @@ -49,7 +49,7 @@ SignWith: C99B11DEB97541F0 Origin: gh Label: gh Codename: stretch -Architectures: i386 amd64 arm64 +Architectures: i386 amd64 armhf arm64 Components: main Description: The GitHub CLI - debian stretch repo SignWith: C99B11DEB97541F0 @@ -57,7 +57,7 @@ SignWith: C99B11DEB97541F0 Origin: gh Label: gh Codename: jessie -Architectures: i386 amd64 arm64 +Architectures: i386 amd64 armhf arm64 Components: main Description: The GitHub CLI - debian jessie repo SignWith: C99B11DEB97541F0 @@ -65,7 +65,7 @@ SignWith: C99B11DEB97541F0 Origin: gh Label: gh Codename: focal -Architectures: i386 amd64 arm64 +Architectures: i386 amd64 armhf arm64 Components: main Description: The GitHub CLI - ubuntu focal repo SignWith: C99B11DEB97541F0 @@ -74,7 +74,7 @@ DebOverride: override.ubuntu Origin: gh Label: gh Codename: precise -Architectures: i386 amd64 arm64 +Architectures: i386 amd64 armhf arm64 Components: main Description: The GitHub CLI - ubuntu precise repo SignWith: C99B11DEB97541F0 @@ -83,7 +83,7 @@ DebOverride: override.ubuntu Origin: gh Label: gh Codename: bionic -Architectures: i386 amd64 arm64 +Architectures: i386 amd64 armhf arm64 Components: main Description: The GitHub CLI - ubuntu bionic repo SignWith: C99B11DEB97541F0 @@ -92,7 +92,7 @@ DebOverride: override.ubuntu Origin: gh Label: gh Codename: trusty -Architectures: i386 amd64 arm64 +Architectures: i386 amd64 armhf arm64 Components: main Description: The GitHub CLI - ubuntu trusty repo SignWith: C99B11DEB97541F0 @@ -101,7 +101,7 @@ DebOverride: override.ubuntu Origin: gh Label: gh Codename: xenial -Architectures: i386 amd64 arm64 +Architectures: i386 amd64 armhf arm64 Components: main Description: The GitHub CLI - ubuntu xenial repo SignWith: C99B11DEB97541F0 @@ -110,7 +110,7 @@ DebOverride: override.ubuntu Origin: gh Label: gh Codename: groovy -Architectures: i386 amd64 arm64 +Architectures: i386 amd64 armhf arm64 Components: main Description: The GitHub CLI - ubuntu groovy repo SignWith: C99B11DEB97541F0 @@ -119,7 +119,7 @@ DebOverride: override.ubuntu Origin: gh Label: gh Codename: eoan -Architectures: i386 amd64 arm64 +Architectures: i386 amd64 armhf arm64 Components: main Description: The GitHub CLI - ubuntu eoan repo SignWith: C99B11DEB97541F0 @@ -128,7 +128,7 @@ DebOverride: override.ubuntu Origin: gh Label: gh Codename: disco -Architectures: i386 amd64 arm64 +Architectures: i386 amd64 armhf arm64 Components: main Description: The GitHub CLI - ubuntu disco repo SignWith: C99B11DEB97541F0 @@ -137,7 +137,7 @@ DebOverride: override.ubuntu Origin: gh Label: gh Codename: cosmic -Architectures: i386 amd64 arm64 +Architectures: i386 amd64 armhf arm64 Components: main Description: The GitHub CLI - ubuntu cosmic repo SignWith: C99B11DEB97541F0 diff --git a/utils/utils.go b/utils/utils.go index 602b8ab1e..4b58508ef 100644 --- a/utils/utils.go +++ b/utils/utils.go @@ -59,6 +59,22 @@ func FuzzyAgo(ago time.Duration) string { return fmtDuration(int(ago.Hours()/24/365), "year") } +func FuzzyAgoAbbr(now time.Time, createdAt time.Time) string { + ago := now.Sub(createdAt) + + if ago < time.Hour { + return fmt.Sprintf("%d%s", int(ago.Minutes()), "m") + } + if ago < 24*time.Hour { + return fmt.Sprintf("%d%s", int(ago.Hours()), "h") + } + if ago < 30*24*time.Hour { + return fmt.Sprintf("%d%s", int(ago.Hours())/24, "d") + } + + return createdAt.Format("Jan _2, 2006") +} + func Humanize(s string) string { // Replaces - and _ with spaces. replace := "_-" diff --git a/utils/utils_test.go b/utils/utils_test.go index 0891c2a39..5dc7b2478 100644 --- a/utils/utils_test.go +++ b/utils/utils_test.go @@ -6,7 +6,6 @@ import ( ) func TestFuzzyAgo(t *testing.T) { - cases := map[string]string{ "1s": "less than a minute ago", "30s": "less than a minute ago", @@ -36,3 +35,29 @@ func TestFuzzyAgo(t *testing.T) { } } } + +func TestFuzzyAgoAbbr(t *testing.T) { + const form = "2006-Jan-02 15:04:05" + now, _ := time.Parse(form, "2020-Nov-22 14:00:00") + + cases := map[string]string{ + "2020-Nov-22 14:00:00": "0m", + "2020-Nov-22 13:59:00": "1m", + "2020-Nov-22 13:30:00": "30m", + "2020-Nov-22 13:00:00": "1h", + "2020-Nov-22 02:00:00": "12h", + "2020-Nov-21 14:00:00": "1d", + "2020-Nov-07 14:00:00": "15d", + "2020-Oct-24 14:00:00": "29d", + "2020-Oct-23 14:00:00": "Oct 23, 2020", + "2019-Nov-22 14:00:00": "Nov 22, 2019", + } + + for createdAt, expected := range cases { + d, _ := time.Parse(form, createdAt) + fuzzy := FuzzyAgoAbbr(now, d) + if fuzzy != expected { + t.Errorf("unexpected fuzzy duration abbr value: %s for %s", fuzzy, createdAt) + } + } +}