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