Merge remote-tracking branch 'origin/trunk' into remote-renaming-847
This commit is contained in:
commit
03f99a0140
157 changed files with 9339 additions and 2528 deletions
6
.github/CONTRIBUTING.md
vendored
6
.github/CONTRIBUTING.md
vendored
|
|
@ -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
|
||||
|
|
|
|||
1
.github/workflows/codeql.yml
vendored
1
.github/workflows/codeql.yml
vendored
|
|
@ -2,6 +2,7 @@ name: Code Scanning
|
|||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
schedule:
|
||||
- cron: "0 0 * * 0"
|
||||
|
||||
|
|
|
|||
2
.github/workflows/go.yml
vendored
2
.github/workflows/go.yml
vendored
|
|
@ -39,4 +39,6 @@ jobs:
|
|||
uses: actions/checkout@v2
|
||||
|
||||
- name: Build
|
||||
env:
|
||||
CGO_ENABLED: '0'
|
||||
run: go build -v ./cmd/gh
|
||||
|
|
|
|||
8
.github/workflows/lint.yml
vendored
8
.github/workflows/lint.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -16,4 +16,7 @@
|
|||
# macOS
|
||||
.DS_Store
|
||||
|
||||
# vim
|
||||
*.swp
|
||||
|
||||
vendor/
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
34
Makefile
34
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
|
||||
|
|
|
|||
15
README.md
15
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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
182
api/queries_comments.go
Normal file
182
api/queries_comments.go
Normal file
|
|
@ -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 ""
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`
|
||||
|
|
|
|||
|
|
@ -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, "", "", "")
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
135
api/queries_pr_review.go
Normal file
135
api/queries_pr_review.go
Normal file
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
40
api/reaction_groups.go
Normal file
40
api/reaction_groups.go
Normal file
|
|
@ -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
|
||||
}
|
||||
}`
|
||||
}
|
||||
100
api/reaction_groups_test.go
Normal file
100
api/reaction_groups_test.go
Normal file
|
|
@ -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())
|
||||
})
|
||||
}
|
||||
}
|
||||
275
auth/oauth.go
275
auth/oauth.go
|
|
@ -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, "<p>You have successfully authenticated. You may now close this page.</p>")
|
||||
}
|
||||
}))
|
||||
|
||||
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...)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,15 +13,10 @@ import (
|
|||
)
|
||||
|
||||
var (
|
||||
sshHostRE,
|
||||
sshTokenRE *regexp.Regexp
|
||||
sshConfigLineRE = regexp.MustCompile(`\A\s*(?P<keyword>[A-Za-z][A-Za-z0-9]*)(?:\s+|\s*=\s*)(?P<argument>.+)`)
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
3
go.mod
3
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
|
||||
|
|
|
|||
8
go.sum
8
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=
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) != ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -139,7 +139,7 @@ func TestHostnameValidator(t *testing.T) {
|
|||
assert.Error(t, err)
|
||||
return
|
||||
}
|
||||
assert.Equal(t, nil, err)
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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, ", "))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
117
pkg/cmd/auth/gitcredential/helper.go
Normal file
117
pkg/cmd/auth/gitcredential/helper.go
Normal file
|
|
@ -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
|
||||
}
|
||||
154
pkg/cmd/auth/gitcredential/helper_test.go
Normal file
154
pkg/cmd/auth/gitcredential/helper_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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 == "" {
|
||||
|
|
|
|||
|
|
@ -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"}}}`))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
110
pkg/cmd/auth/shared/git_credential.go
Normal file
110
pkg/cmd/auth/shared/git_credential.go
Normal file
|
|
@ -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"
|
||||
}
|
||||
88
pkg/cmd/auth/shared/git_credential_test.go
Normal file
88
pkg/cmd/auth/shared/git_credential_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -14,22 +14,44 @@ func NewCmdCompletion(io *iostreams.IOStreams) *cobra.Command {
|
|||
var shellType string
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "completion",
|
||||
Use: "completion -s <shell>",
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()] {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
101
pkg/cmd/gist/clone/clone.go
Normal file
101
pkg/cmd/gist/clone/clone.go
Normal file
|
|
@ -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 <gist> [<directory>] [-- <gitflags>...]",
|
||||
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)
|
||||
}
|
||||
118
pkg/cmd/gist/clone/clone_test.go
Normal file
118
pkg/cmd/gist/clone/clone_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
88
pkg/cmd/gist/delete/delete.go
Normal file
88
pkg/cmd/gist/delete/delete.go
Normal file
|
|
@ -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 {<gist ID> | <gist URL>}",
|
||||
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
|
||||
}
|
||||
157
pkg/cmd/gist/delete/delete_test.go
Normal file
157
pkg/cmd/gist/delete/delete_test.go
Normal file
|
|
@ -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)
|
||||
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" {
|
||||
|
|
|
|||
200
pkg/cmd/issue/comment/comment.go
Normal file
200
pkg/cmd/issue/comment/comment.go
Normal file
|
|
@ -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 {<number> | <url>}",
|
||||
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)
|
||||
}
|
||||
}
|
||||
288
pkg/cmd/issue/comment/comment_test.go
Normal file
288
pkg/cmd/issue/comment/comment_test.go
Normal file
|
|
@ -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"])
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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" {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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": ""
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
308
pkg/cmd/issue/view/fixtures/issueView_previewFullComments.json
Normal file
308
pkg/cmd/issue/view/fixtures/issueView_previewFullComments.json
Normal file
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
147
pkg/cmd/issue/view/fixtures/issueView_previewSingleComment.json
Normal file
147
pkg/cmd/issue/view/fixtures/issueView_previewSingleComment.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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], " "))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(`{}`))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue