Merge remote-tracking branch 'origin/trunk' into remote-renaming-847

This commit is contained in:
vilmibm 2021-01-20 15:10:20 -08:00
commit 03f99a0140
157 changed files with 9339 additions and 2528 deletions

View file

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

View file

@ -2,6 +2,7 @@ name: Code Scanning
on:
push:
pull_request:
schedule:
- cron: "0 0 * * 0"

View file

@ -39,4 +39,6 @@ jobs:
uses: actions/checkout@v2
- name: Build
env:
CGO_ENABLED: '0'
run: go build -v ./cmd/gh

View file

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

@ -16,4 +16,7 @@
# macOS
.DS_Store
# vim
*.swp
vendor/

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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) != ""
}

View file

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

View file

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

View file

@ -139,7 +139,7 @@ func TestHostnameValidator(t *testing.T) {
assert.Error(t, err)
return
}
assert.Equal(t, nil, err)
assert.NoError(t, err)
})
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

View file

@ -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 == "" {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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()] {

View file

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

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

View file

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

View file

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

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

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

View file

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

View file

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

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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