Merge remote-tracking branch 'origin' into success-icon-consistency

This commit is contained in:
Mislav Marohnić 2021-01-22 23:56:54 +01:00
commit 96fa6e7830
110 changed files with 3976 additions and 1651 deletions

View file

@ -26,11 +26,15 @@ Prerequisites:
- Go 1.13+ for building the binary
- Go 1.15+ for running the test suite
Build with: `make` or `go build -o bin/gh ./cmd/gh`
Build with:
* Unix-like systems: `make`
* Windows: `go run script/build.go`
Run the new binary as: `./bin/gh`
Run the new binary as:
* Unix-like systems: `bin/gh`
* Windows: `bin\gh`
Run tests with: `make test` or `go test ./...`
Run tests with: `go test ./...`
## Submitting a pull request

1
.gitignore vendored
View file

@ -6,6 +6,7 @@
/site
.github/**/node_modules
/CHANGELOG.md
/script/build
# VS Code
.vscode

View file

@ -1,3 +1,8 @@
linters:
enable:
gofmt
- gofmt
- nolintlint
issues:
max-issues-per-linter: 0
max-same-issues: 0

View file

@ -1,14 +1,3 @@
BUILD_FILES = $(shell go list -f '{{range .GoFiles}}{{$$.Dir}}/{{.}}\
{{end}}' ./...)
GH_VERSION ?= $(shell git describe --tags 2>/dev/null || git rev-parse --short HEAD)
DATE_FMT = +%Y-%m-%d
ifdef SOURCE_DATE_EPOCH
BUILD_DATE ?= $(shell date -u -d "@$(SOURCE_DATE_EPOCH)" "$(DATE_FMT)" 2>/dev/null || date -u -r "$(SOURCE_DATE_EPOCH)" "$(DATE_FMT)" 2>/dev/null || date -u "$(DATE_FMT)")
else
BUILD_DATE ?= $(shell date "$(DATE_FMT)")
endif
CGO_CPPFLAGS ?= ${CPPFLAGS}
export CGO_CPPFLAGS
CGO_CFLAGS ?= ${CFLAGS}
@ -16,27 +5,34 @@ 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)
ifdef GH_OAUTH_CLIENT_SECRET
GO_LDFLAGS := -X github.com/cli/cli/internal/authflow.oauthClientID=$(GH_OAUTH_CLIENT_ID) $(GO_LDFLAGS)
GO_LDFLAGS := -X github.com/cli/cli/internal/authflow.oauthClientSecret=$(GH_OAUTH_CLIENT_SECRET) $(GO_LDFLAGS)
endif
## The following tasks delegate to `script/build.go` so they can be run cross-platform.
bin/gh: $(BUILD_FILES)
go build -trimpath -ldflags "${GO_LDFLAGS}" -o "$@" ./cmd/gh
.PHONY: bin/gh
bin/gh: script/build
@script/build bin/gh
script/build: script/build.go
go build -o script/build script/build.go
clean:
rm -rf ./bin ./share
.PHONY: clean
clean: script/build
@script/build clean
.PHONY: manpages
manpages: script/build
@script/build manpages
# just a convenience task around `go test`
.PHONY: test
test:
go test ./...
.PHONY: test
## Site-related tasks are exclusively intended for use by the GitHub CLI team and for our release automation.
site:
git clone https://github.com/github/cli.github.com.git "$@"
.PHONY: site-docs
site-docs: site
git -C site pull
git -C site rm 'manual/gh*.md' 2>/dev/null || true
@ -44,8 +40,8 @@ site-docs: site
rm -f site/manual/*.bak
git -C site add 'manual/gh*.md'
git -C site commit -m 'update help docs' || true
.PHONY: site-docs
.PHONY: site-bump
site-bump: site-docs
ifndef GITHUB_REF
$(error GITHUB_REF is not set)
@ -53,11 +49,8 @@ endif
sed -i.bak -E 's/(assign version = )".+"/\1"$(GITHUB_REF:refs/tags/v%=%)"/' site/index.html
rm -f site/index.html.bak
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/
## Install/uninstall tasks are here for use on *nix platform. On Windows, there is no equivalent.
DESTDIR :=
prefix := /usr/local

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(
@ -38,13 +31,13 @@ func TestGraphQL(t *testing.T) {
)
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) {
@ -84,7 +77,7 @@ func TestRESTGetDelete(t *testing.T) {
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)
}

View file

@ -132,3 +132,51 @@ func CommentCreate(client *Client, repoHost string, params CommentCreateInput) (
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

@ -481,3 +481,11 @@ func milestoneNodeIdToDatabaseId(nodeId string) (string, error) {
}
return splitted[1], nil
}
func (i Issue) Link() string {
return i.URL
}
func (i Issue) Identifier() string {
return i.ID
}

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
@ -139,6 +118,7 @@ type PullRequest struct {
}
Comments Comments
ReactionGroups ReactionGroups
Reviews PullRequestReviews
}
type NotFoundError struct {
@ -156,6 +136,14 @@ func (pr PullRequest) HeadLabel() string {
return pr.HeadRefName
}
func (pr PullRequest) Link() string {
return pr.URL
}
func (pr PullRequest) Identifier() string {
return pr.ID
}
type PullRequestReviewStatus struct {
ChangesRequested bool
Approved bool
@ -220,6 +208,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)
@ -570,15 +570,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
@ -605,30 +596,8 @@ func PullRequestByNumber(client *Client, repo ghrepo.Interface, number int) (*Pu
milestone{
title
}
comments(last: 1) {
nodes {
author {
login
}
authorAssociation
body
createdAt
includesCreatedEdit
reactionGroups {
content
users {
totalCount
}
}
}
totalCount
}
reactionGroups {
content
users {
totalCount
}
}
` + commentsFragment() + `
` + reactionGroupsFragment() + `
}
}
}`
@ -703,15 +672,6 @@ func PullRequestForBranch(client *Client, repo ghrepo.Interface, baseBranch, hea
}
totalCount
}
reviews(last: 100) {
nodes {
author {
login
}
state
}
totalCount
}
assignees(first: 100) {
nodes {
login
@ -738,30 +698,8 @@ func PullRequestForBranch(client *Client, repo ghrepo.Interface, baseBranch, hea
milestone{
title
}
comments(last: 1) {
nodes {
author {
login
}
authorAssociation
body
createdAt
includesCreatedEdit
reactionGroups {
content
users {
totalCount
}
}
}
totalCount
}
reactionGroups {
content
users {
totalCount
}
}
` + commentsFragment() + `
` + reactionGroupsFragment() + `
}
}
}
@ -821,7 +759,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
}
}
@ -906,34 +844,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 {
@ -1144,7 +1054,7 @@ func PullRequestReopen(client *Client, repo ghrepo.Interface, pr *PullRequest) e
return err
}
func PullRequestMerge(client *Client, repo ghrepo.Interface, pr *PullRequest, m PullRequestMergeMethod) error {
func PullRequestMerge(client *Client, repo ghrepo.Interface, pr *PullRequest, m PullRequestMergeMethod, body *string) error {
mergeMethod := githubv4.PullRequestMergeMethodMerge
switch m {
case PullRequestMergeMethodRebase:
@ -1170,6 +1080,10 @@ func PullRequestMerge(client *Client, repo ghrepo.Interface, pr *PullRequest, m
commitHeadline := githubv4.String(fmt.Sprintf("%s (#%d)", pr.Title, pr.Number))
input.CommitHeadline = &commitHeadline
}
if body != nil {
commitBody := githubv4.String(*body)
input.CommitBody = &commitBody
}
variables := map[string]interface{}{
"input": input,

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

@ -33,6 +33,10 @@ type Repository struct {
Parent *Repository
MergeCommitAllowed bool
RebaseMergeAllowed bool
SquashMergeAllowed bool
// pseudo-field that keeps track of host name of this repo
hostname string
}
@ -108,6 +112,9 @@ func GitHubRepo(client *Client, repo ghrepo.Interface) (*Repository, error) {
parent {
...repo
}
mergeCommitAllowed
rebaseMergeAllowed
squashMergeAllowed
}
}`
variables := map[string]interface{}{
@ -457,6 +464,33 @@ func (m *RepoMetadataResult) ProjectsToIDs(names []string) ([]string, error) {
return ids, nil
}
func ProjectsToPaths(projects []RepoProject, names []string) ([]string, error) {
var paths []string
for _, projectName := range names {
found := false
for _, p := range projects {
if strings.EqualFold(projectName, p.Name) {
// format of ResourcePath: /OWNER/REPO/projects/PROJECT_NUMBER or /orgs/ORG/projects/PROJECT_NUMBER
// required format of path: OWNER/REPO/PROJECT_NUMBER or ORG/PROJECT_NUMBER
var path string
pathParts := strings.Split(p.ResourcePath, "/")
if pathParts[1] == "orgs" {
path = fmt.Sprintf("%s/%s", pathParts[2], pathParts[4])
} else {
path = fmt.Sprintf("%s/%s/%s", pathParts[1], pathParts[2], pathParts[4])
}
paths = append(paths, path)
found = true
break
}
}
if !found {
return nil, fmt.Errorf("'%s' not found", projectName)
}
}
return paths, nil
}
func (m *RepoMetadataResult) MilestoneToID(title string) (string, error) {
for _, m := range m.Milestones {
if strings.EqualFold(title, m.Title) {
@ -540,20 +574,12 @@ func RepoMetadata(client *Client, repo ghrepo.Interface, input RepoMetadataInput
if input.Projects {
count++
go func() {
projects, err := RepoProjects(client, repo)
projects, err := RepoAndOrgProjects(client, repo)
if err != nil {
errc <- fmt.Errorf("error fetching projects: %w", err)
errc <- err
return
}
result.Projects = projects
orgProjects, err := OrganizationProjects(client, repo)
// TODO: better detection of non-org repos
if err != nil && !strings.HasPrefix(err.Error(), "Could not resolve to an Organization") {
errc <- fmt.Errorf("error fetching organization projects: %w", err)
return
}
result.Projects = append(result.Projects, orgProjects...)
errc <- nil
}()
}
@ -682,8 +708,9 @@ func RepoResolveMetadataIDs(client *Client, repo ghrepo.Interface, input RepoRes
}
type RepoProject struct {
ID string
Name string
ID string
Name string
ResourcePath string
}
// RepoProjects fetches all open projects for a repository
@ -726,6 +753,23 @@ func RepoProjects(client *Client, repo ghrepo.Interface) ([]RepoProject, error)
return projects, nil
}
// RepoAndOrgProjects fetches all open projects for a repository and its org
func RepoAndOrgProjects(client *Client, repo ghrepo.Interface) ([]RepoProject, error) {
projects, err := RepoProjects(client, repo)
if err != nil {
return projects, fmt.Errorf("error fetching projects: %w", err)
}
orgProjects, err := OrganizationProjects(client, repo)
// TODO: better detection of non-org repos
if err != nil && !strings.HasPrefix(err.Error(), "Could not resolve to an Organization") {
return projects, fmt.Errorf("error fetching organization projects: %w", err)
}
projects = append(projects, orgProjects...)
return projects, nil
}
type RepoAssignee struct {
ID string
Login string
@ -913,3 +957,12 @@ func MilestoneByNumber(client *Client, repo ghrepo.Interface, number int32) (*Re
return query.Repository.Milestone, nil
}
func ProjectNamesToPaths(client *Client, repo ghrepo.Interface, projectNames []string) ([]string, error) {
var paths []string
projects, err := RepoAndOrgProjects(client, repo)
if err != nil {
return paths, err
}
return ProjectsToPaths(projects, projectNames)
}

View file

@ -141,6 +141,63 @@ func Test_RepoMetadata(t *testing.T) {
}
}
func Test_ProjectsToPaths(t *testing.T) {
expectedProjectPaths := []string{"OWNER/REPO/PROJECT_NUMBER", "ORG/PROJECT_NUMBER"}
projects := []RepoProject{
{"id1", "My Project", "/OWNER/REPO/projects/PROJECT_NUMBER"},
{"id2", "Org Project", "/orgs/ORG/projects/PROJECT_NUMBER"},
{"id3", "Project", "/orgs/ORG/projects/PROJECT_NUMBER_2"},
}
projectNames := []string{"My Project", "Org Project"}
projectPaths, err := ProjectsToPaths(projects, projectNames)
if err != nil {
t.Errorf("error resolving projects: %v", err)
}
if !sliceEqual(projectPaths, expectedProjectPaths) {
t.Errorf("expected projects %v, got %v", expectedProjectPaths, projectPaths)
}
}
func Test_ProjectNamesToPaths(t *testing.T) {
http := &httpmock.Registry{}
client := NewClient(ReplaceTripper(http))
repo, _ := ghrepo.FromFullName("OWNER/REPO")
http.Register(
httpmock.GraphQL(`query RepositoryProjectList\b`),
httpmock.StringResponse(`
{ "data": { "repository": { "projects": {
"nodes": [
{ "name": "Cleanup", "id": "CLEANUPID", "resourcePath": "/OWNER/REPO/projects/1" },
{ "name": "Roadmap", "id": "ROADMAPID", "resourcePath": "/OWNER/REPO/projects/2" }
],
"pageInfo": { "hasNextPage": false }
} } } }
`))
http.Register(
httpmock.GraphQL(`query OrganizationProjectList\b`),
httpmock.StringResponse(`
{ "data": { "organization": { "projects": {
"nodes": [
{ "name": "Triage", "id": "TRIAGEID", "resourcePath": "/orgs/ORG/projects/1" }
],
"pageInfo": { "hasNextPage": false }
} } } }
`))
projectPaths, err := ProjectNamesToPaths(client, repo, []string{"Triage", "Roadmap"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
expectedProjectPaths := []string{"ORG/1", "OWNER/REPO/2"}
if !sliceEqual(projectPaths, expectedProjectPaths) {
t.Errorf("expected projects paths %v, got %v", expectedProjectPaths, projectPaths)
}
}
func Test_RepoResolveMetadataIDs(t *testing.T) {
http := &httpmock.Registry{}
client := NewClient(ReplaceTripper(http))

View file

@ -29,3 +29,12 @@ var reactionEmoji = map[string]string{
"ROCKET": "\U0001f680",
"EYES": "\U0001f440",
}
func reactionGroupsFragment() string {
return `reactionGroups {
content
users {
totalCount
}
}`
}

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("nonexistent", "*")
eq(t, err, nil)
eq(t, r.Name, "mona")
assert.NoError(t, err)
assert.Equal(t, "mona", r.Name)
_, err = list.FindByName("nonexistent")
eq(t, err, errors.New(`no GitHub remotes found`))
assert.Error(t, err, "no GitHub remotes found")
}
func Test_translateRemotes(t *testing.T) {

View file

@ -22,18 +22,18 @@
# installs to '/usr/local' by default; sudo may be required
$ make install
# install to a different location
# or, install to a different location
$ make install prefix=/path/to/gh
```
#### Windows
```sh
# build the binary
> go build -o gh.exe ./cmd/gh
# build the `bin\gh.exe` binary
> go run script/build.go
```
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.
Run `bin\gh version` to check if it worked.

View file

@ -67,22 +67,21 @@ func CurrentBranch() (string, error) {
return "", err
}
stderr := bytes.Buffer{}
refCmd.Stderr = &stderr
output, err := run.PrepareCmd(refCmd).Output()
if err == nil {
// Found the branch name
return getBranchShortName(output), nil
}
var cmdErr *run.CmdError
if errors.As(err, &cmdErr) {
if cmdErr.Stderr.Len() == 0 {
// Detached head
return "", ErrNotOnAnyBranch
}
if stderr.Len() == 0 {
// Detached head
return "", ErrNotOnAnyBranch
}
// Unknown error
return "", err
return "", fmt.Errorf("%sgit: %s", stderr.String(), err)
}
func listRemotes() ([]string, error) {
@ -308,8 +307,13 @@ func RunClone(cloneURL string, args []string) (target string, err error) {
return
}
func AddUpstreamRemote(upstreamURL, cloneDir string) error {
cloneCmd, err := GitCommand("-C", cloneDir, "remote", "add", "-f", "upstream", upstreamURL)
func AddUpstreamRemote(upstreamURL, cloneDir string, branches []string) error {
args := []string{"-C", cloneDir, "remote", "add"}
for _, branch := range branches {
args = append(args, "-t", branch)
}
args = append(args, "-f", "upstream", upstreamURL)
cloneCmd, err := GitCommand(args...)
if err != nil {
return err
}

View file

@ -1,12 +1,10 @@
package git
import (
"os/exec"
"reflect"
"testing"
"github.com/cli/cli/internal/run"
"github.com/cli/cli/test"
)
func Test_UncommittedChangeCount(t *testing.T) {
@ -21,20 +19,17 @@ func Test_UncommittedChangeCount(t *testing.T) {
{Label: "untracked file", Expected: 2, Output: " M poem.txt\n?? new.txt"},
}
teardown := run.SetPrepareCmd(func(*exec.Cmd) run.Runnable {
return &test.OutputStub{}
})
defer teardown()
for _, v := range cases {
_ = run.SetPrepareCmd(func(*exec.Cmd) run.Runnable {
return &test.OutputStub{Out: []byte(v.Output)}
})
ucc, _ := UncommittedChangeCount()
t.Run(v.Label, func(t *testing.T) {
cs, restore := run.Stub()
defer restore(t)
cs.Register(`git status --porcelain`, 0, v.Output)
if ucc != v.Expected {
t.Errorf("got unexpected ucc value: %d for case %s", ucc, v.Label)
}
ucc, _ := UncommittedChangeCount()
if ucc != v.Expected {
t.Errorf("UncommittedChangeCount() = %d, expected %d", ucc, v.Expected)
}
})
}
}
@ -59,59 +54,32 @@ func Test_CurrentBranch(t *testing.T) {
}
for _, v := range cases {
cs, teardown := test.InitCmdStubber()
cs.Stub(v.Stub)
cs, teardown := run.Stub()
cs.Register(`git symbolic-ref --quiet HEAD`, 0, v.Stub)
result, err := CurrentBranch()
if err != nil {
t.Errorf("got unexpected error: %w", err)
}
if len(cs.Calls) != 1 {
t.Errorf("expected 1 git call, saw %d", len(cs.Calls))
}
if result != v.Expected {
t.Errorf("unexpected branch name: %s instead of %s", result, v.Expected)
}
teardown()
teardown(t)
}
}
func Test_CurrentBranch_detached_head(t *testing.T) {
cs, teardown := test.InitCmdStubber()
defer teardown()
cs.StubError("")
cs, teardown := run.Stub()
defer teardown(t)
cs.Register(`git symbolic-ref --quiet HEAD`, 1, "")
_, err := CurrentBranch()
if err == nil {
t.Errorf("expected an error")
t.Fatal("expected an error, got nil")
}
if err != ErrNotOnAnyBranch {
t.Errorf("got unexpected error: %s instead of %s", err, ErrNotOnAnyBranch)
}
if len(cs.Calls) != 1 {
t.Errorf("expected 1 git call, saw %d", len(cs.Calls))
}
}
func Test_CurrentBranch_unexpected_error(t *testing.T) {
cs, teardown := test.InitCmdStubber()
defer teardown()
cs.StubError("lol")
expectedError := "lol\nstub: lol"
_, err := CurrentBranch()
if err == nil {
t.Errorf("expected an error")
}
if err.Error() != expectedError {
t.Errorf("got unexpected error: %s instead of %s", err.Error(), expectedError)
}
if len(cs.Calls) != 1 {
t.Errorf("expected 1 git call, saw %d", len(cs.Calls))
}
}
func TestParseExtraCloneArgs(t *testing.T) {
@ -170,5 +138,42 @@ func TestParseExtraCloneArgs(t *testing.T) {
}
})
}
}
func TestAddUpstreamRemote(t *testing.T) {
tests := []struct {
name string
upstreamURL string
cloneDir string
branches []string
want string
}{
{
name: "fetch all",
upstreamURL: "URL",
cloneDir: "DIRECTORY",
branches: []string{},
want: "git -C DIRECTORY remote add -f upstream URL",
},
{
name: "fetch specific branches only",
upstreamURL: "URL",
cloneDir: "DIRECTORY",
branches: []string{"master", "dev"},
want: "git -C DIRECTORY remote add -t master -t dev -f upstream URL",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cs, cmdTeardown := run.Stub()
defer cmdTeardown(t)
cs.Register(tt.want, 0, "")
err := AddUpstreamRemote(tt.upstreamURL, tt.cloneDir, tt.branches)
if err != nil {
t.Fatalf("error running command `git remote add -f`: %v", err)
}
})
}
}

View file

@ -1,17 +1,10 @@
package git
import (
"reflect"
"testing"
)
// 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)
}
}
"github.com/stretchr/testify/assert"
)
func Test_parseRemotes(t *testing.T) {
remoteList := []string{
@ -23,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

@ -9,7 +9,7 @@ import (
"regexp"
"strings"
"github.com/mitchellh/go-homedir"
"github.com/cli/cli/internal/config"
)
var (
@ -147,10 +147,10 @@ func ParseSSHConfig() SSHAliasMap {
p := sshParser{}
if homedir, err := homedir.Dir(); err == nil {
userConfig := filepath.Join(homedir, ".ssh", "config")
if sshDir, err := config.HomeDirPath(".ssh"); err == nil {
userConfig := filepath.Join(sshDir, "config")
configFiles = append([]string{userConfig}, configFiles...)
p.homeDir = homedir
p.homeDir = filepath.Dir(sshDir)
}
for _, file := range configFiles {

View file

@ -7,6 +7,7 @@ import (
"io/ioutil"
"os"
"path"
"path/filepath"
"syscall"
"github.com/mitchellh/go-homedir"
@ -14,8 +15,8 @@ import (
)
func ConfigDir() string {
dir, _ := homedir.Expand("~/.config/gh")
return dir
homeDir, _ := homeDirAutoMigrate()
return homeDir
}
func ConfigFile() string {
@ -30,6 +31,62 @@ func ParseDefaultConfig() (Config, error) {
return ParseConfig(ConfigFile())
}
func HomeDirPath(subdir string) (string, error) {
homeDir, err := os.UserHomeDir()
if err != nil {
// TODO: remove go-homedir fallback in GitHub CLI v2
if legacyDir, err := homedir.Dir(); err == nil {
return filepath.Join(legacyDir, subdir), nil
}
return "", err
}
newPath := filepath.Join(homeDir, subdir)
if s, err := os.Stat(newPath); err == nil && s.IsDir() {
return newPath, nil
}
// TODO: remove go-homedir fallback in GitHub CLI v2
if legacyDir, err := homedir.Dir(); err == nil {
legacyPath := filepath.Join(legacyDir, subdir)
if s, err := os.Stat(legacyPath); err == nil && s.IsDir() {
return legacyPath, nil
}
}
return newPath, nil
}
// Looks up the `~/.config/gh` directory with backwards-compatibility with go-homedir and auto-migration
// when an old homedir location was found.
func homeDirAutoMigrate() (string, error) {
homeDir, err := os.UserHomeDir()
if err != nil {
// TODO: remove go-homedir fallback in GitHub CLI v2
if legacyDir, err := homedir.Dir(); err == nil {
return filepath.Join(legacyDir, ".config", "gh"), nil
}
return "", err
}
newPath := filepath.Join(homeDir, ".config", "gh")
_, newPathErr := os.Stat(newPath)
if newPathErr == nil || !os.IsNotExist(err) {
return newPath, newPathErr
}
// TODO: remove go-homedir fallback in GitHub CLI v2
if legacyDir, err := homedir.Dir(); err == nil {
legacyPath := filepath.Join(legacyDir, ".config", "gh")
if s, err := os.Stat(legacyPath); err == nil && s.IsDir() {
_ = os.MkdirAll(filepath.Dir(newPath), 0755)
return newPath, os.Rename(legacyPath, newPath)
}
}
return newPath, nil
}
var ReadConfigFile = func(filename string) ([]byte, error) {
f, err := os.Open(filename)
if err != nil {

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")
assert.NoError(t, err)
assert.Equal(t, "ssh", val)
val, err = config.Get("nonexistent.io", "git_protocol")
eq(t, err, nil)
eq(t, val, "ssh")
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

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

View file

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

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

@ -22,7 +22,7 @@ var PrepareCmd = func(cmd *exec.Cmd) Runnable {
return &cmdWithStderr{cmd}
}
// SetPrepareCmd overrides PrepareCmd and returns a func to revert it back
// Deprecated: use Stub
func SetPrepareCmd(fn func(*exec.Cmd) Runnable) func() {
origPrepare := PrepareCmd
PrepareCmd = func(cmd *exec.Cmd) Runnable {

View file

@ -3,6 +3,9 @@ package update
import (
"fmt"
"io/ioutil"
"regexp"
"strconv"
"strings"
"time"
"github.com/cli/cli/api"
@ -11,6 +14,8 @@ import (
"gopkg.in/yaml.v3"
)
var gitDescribeSuffixRE = regexp.MustCompile(`\d+-\d+-g[a-f0-9]{8}$`)
// ReleaseInfo stores information about a release
type ReleaseInfo struct {
Version string `json:"tag_name"`
@ -83,6 +88,12 @@ func setStateEntry(stateFilePath string, t time.Time, r ReleaseInfo) error {
}
func versionGreaterThan(v, w string) bool {
w = gitDescribeSuffixRE.ReplaceAllStringFunc(w, func(m string) string {
idx := strings.IndexRune(m, '-')
n, _ := strconv.Atoi(m[0:idx])
return fmt.Sprintf("%d-pre.0", n+1)
})
vv, ve := version.NewVersion(v)
vw, we := version.NewVersion(w)

View file

@ -33,6 +33,27 @@ func TestCheckForUpdate(t *testing.T) {
LatestURL: "https://www.spacejam.com/archive/spacejam/movie/jam.htm",
ExpectsResult: true,
},
{
Name: "current is built from source",
CurrentVersion: "v1.2.3-123-gdeadbeef",
LatestVersion: "v1.2.3",
LatestURL: "https://www.spacejam.com/archive/spacejam/movie/jam.htm",
ExpectsResult: false,
},
{
Name: "current is built from source after a prerelease",
CurrentVersion: "v1.2.3-rc.1-123-gdeadbeef",
LatestVersion: "v1.2.3",
LatestURL: "https://www.spacejam.com/archive/spacejam/movie/jam.htm",
ExpectsResult: true,
},
{
Name: "latest is newer than version build from source",
CurrentVersion: "v1.2.3-123-gdeadbeef",
LatestVersion: "v1.2.4",
LatestURL: "https://www.spacejam.com/archive/spacejam/movie/jam.htm",
ExpectsResult: true,
},
{
Name: "latest is current",
CurrentVersion: "v1.0.0",

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

@ -54,6 +54,10 @@ func NewCmdSet(f *cmdutil.Factory, runF func(*SetOptions) error) *cobra.Command
#=> gh pr view -w 123
$ gh alias set bugs 'issue list --label="bugs"'
$ gh bugs
$ gh alias set homework 'issue list --assigned @me'
$ gh homework
$ gh alias set epicsBy 'issue list --author="$1" --label="epic"'
$ gh epicsBy vilmibm

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) {
@ -86,7 +83,9 @@ func TestAliasSet_empty_aliases(t *testing.T) {
t.Fatalf("unexpected error: %s", err)
}
//nolint:staticcheck // prefer exact matchers over ExpectLines
test.ExpectLines(t, output.Stderr(), "Added alias")
//nolint:staticcheck // prefer exact matchers over ExpectLines
test.ExpectLines(t, output.String(), "")
expected := `aliases:
@ -108,6 +107,7 @@ func TestAliasSet_existing_alias(t *testing.T) {
output, err := runCommand(cfg, true, "co 'pr checkout -Rcool/repo'")
require.NoError(t, err)
//nolint:staticcheck // prefer exact matchers over ExpectLines
test.ExpectLines(t, output.Stderr(), "Changed alias.*co.*from.*pr checkout.*to.*pr checkout -Rcool/repo")
}
@ -120,8 +120,10 @@ func TestAliasSet_space_args(t *testing.T) {
output, err := runCommand(cfg, true, `il 'issue list -l "cool story"'`)
require.NoError(t, err)
//nolint:staticcheck // prefer exact matchers over ExpectLines
test.ExpectLines(t, output.Stderr(), `Adding alias for.*il.*issue list -l "cool story"`)
//nolint:staticcheck // prefer exact matchers over ExpectLines
test.ExpectLines(t, mainBuf.String(), `il: issue list -l "cool story"`)
}
@ -156,7 +158,9 @@ func TestAliasSet_arg_processing(t *testing.T) {
t.Fatalf("got unexpected error running %s: %s", c.Cmd, err)
}
//nolint:staticcheck // prefer exact matchers over ExpectLines
test.ExpectLines(t, output.Stderr(), c.ExpectedOutputLine)
//nolint:staticcheck // prefer exact matchers over ExpectLines
test.ExpectLines(t, mainBuf.String(), c.ExpectedConfigLine)
})
}
@ -178,6 +182,7 @@ aliases:
diff: pr diff
`
//nolint:staticcheck // prefer exact matchers over ExpectLines
test.ExpectLines(t, output.Stderr(), "Adding alias for.*diff.*pr diff", "Added alias.")
assert.Equal(t, expected, mainBuf.String())
}
@ -199,6 +204,7 @@ func TestAliasSet_existing_aliases(t *testing.T) {
view: pr view
`
//nolint:staticcheck // prefer exact matchers over ExpectLines
test.ExpectLines(t, output.Stderr(), "Adding alias for.*view.*pr view", "Added alias.")
assert.Equal(t, expected, mainBuf.String())
@ -210,9 +216,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) {
@ -226,6 +230,7 @@ func TestShellAlias_flag(t *testing.T) {
t.Fatalf("unexpected error: %s", err)
}
//nolint:staticcheck // prefer exact matchers over ExpectLines
test.ExpectLines(t, output.Stderr(), "Adding alias for.*igrep")
expected := `aliases:
@ -243,6 +248,7 @@ func TestShellAlias_bang(t *testing.T) {
output, err := runCommand(cfg, true, "igrep '!gh issue list | grep'")
require.NoError(t, err)
//nolint:staticcheck // prefer exact matchers over ExpectLines
test.ExpectLines(t, output.Stderr(), "Adding alias for.*igrep")
expected := `aliases:

View file

@ -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 = shared.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 := shared.ValidateHostCfg(hostname, cfg)
if err == nil {
apiClient, err := shared.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
@ -244,19 +226,19 @@ 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 = shared.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()
@ -322,6 +304,35 @@ func loginRun(opts *LoginOptions) error {
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,9 +3,11 @@ 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/shared"
@ -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,7 +293,8 @@ 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
@ -271,10 +309,23 @@ func Test_loginRun_nontty(t *testing.T) {
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",
@ -345,7 +392,7 @@ func Test_loginRun_Survey(t *testing.T) {
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"}}}`))
@ -367,7 +414,7 @@ func Test_loginRun_Survey(t *testing.T) {
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"}}}`))
@ -440,7 +487,7 @@ func Test_loginRun_Survey(t *testing.T) {
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

@ -112,6 +112,12 @@ 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
}

View file

@ -2,7 +2,6 @@ package refresh
import (
"bytes"
"regexp"
"testing"
"github.com/cli/cli/internal/config"
@ -134,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",
@ -152,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",
@ -250,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

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

@ -81,11 +81,13 @@ func statusRun(opts *StatusOptions) error {
apiClient := api.NewClientFromHTTP(httpClient)
var failed bool
var isHostnameFound bool
for _, hostname := range hostnames {
if opts.Hostname != "" && opts.Hostname != hostname {
continue
}
isHostnameFound = true
token, tokenSource, _ := cfg.GetWithSource(hostname, "oauth_token")
tokenIsWriteable := cfg.CheckWriteable(hostname, "oauth_token") == nil
@ -139,6 +141,12 @@ func statusRun(opts *StatusOptions) error {
// not to since I wanted this command to be read-only.
}
if !isHostnameFound {
fmt.Fprintf(stderr,
"Hostname %q not found among authenticated GitHub hosts\n", opts.Hostname)
return cmdutil.SilentError
}
for _, hostname := range hostnames {
lines, ok := statusInfo[hostname]
if !ok {

View file

@ -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"}}}`))
@ -190,6 +190,17 @@ func Test_statusRun(t *testing.T) {
httpmock.StringResponse(`{"data":{"viewer":{"login":"tess"}}}`))
},
wantErrOut: regexp.MustCompile(`(?s)Token: xyz456.*Token: abc123`),
}, {
name: "missing hostname",
opts: &StatusOptions{
Hostname: "github.example.com",
},
cfg: func(c config.Config) {
_ = c.Set("github.com", "oauth_token", "abc123")
},
httpStubs: func(reg *httpmock.Registry) {},
wantErrOut: regexp.MustCompile(`(?s)Hostname "github.example.com" not found among authenticated GitHub hosts`),
wantErr: "SilentError",
},
}
@ -236,14 +247,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

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

@ -6,6 +6,7 @@ import (
"testing"
"github.com/cli/cli/internal/config"
"github.com/cli/cli/internal/run"
"github.com/cli/cli/pkg/cmdutil"
"github.com/cli/cli/pkg/httpmock"
"github.com/cli/cli/pkg/iostreams"
@ -88,13 +89,15 @@ func Test_GistClone(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
reg := &httpmock.Registry{}
defer reg.Verify(t)
httpClient := &http.Client{Transport: reg}
cs, restore := test.InitCmdStubber()
defer restore()
cs.Stub("") // git clone
cs, restore := run.Stub()
defer restore(t)
cs.Register(`git clone`, 0, "", func(s []string) {
assert.Equal(t, tt.want, strings.Join(s, " "))
})
output, err := runCloneCommand(httpClient, tt.args)
if err != nil {
@ -103,9 +106,6 @@ func Test_GistClone(t *testing.T) {
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)
})
}
}

View file

@ -3,12 +3,12 @@ package create
import (
"bytes"
"encoding/json"
"github.com/cli/cli/test"
"io/ioutil"
"net/http"
"strings"
"testing"
"github.com/cli/cli/internal/run"
"github.com/cli/cli/pkg/cmd/gist/shared"
"github.com/cli/cli/pkg/cmdutil"
"github.com/cli/cli/pkg/httpmock"
@ -291,11 +291,10 @@ func Test_createRun(t *testing.T) {
io, stdin, stdout, stderr := iostreams.Test()
tt.opts.IO = io
cs, cmdTeardown := test.InitCmdStubber()
defer cmdTeardown()
cs, teardown := run.Stub()
defer teardown(t)
if tt.opts.WebMode {
cs.Stub("")
cs.Register(`https://gist\.github\.com/aa5a315d61ae9438b18d$`, 0, "")
}
t.Run(tt.name, func(t *testing.T) {
@ -313,12 +312,6 @@ 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

@ -2,14 +2,15 @@ package delete
import (
"fmt"
"net/http"
"strings"
"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 {
@ -28,7 +29,7 @@ func NewCmdDelete(f *cmdutil.Factory, runF func(*DeleteOptions) error) *cobra.Co
cmd := &cobra.Command{
Use: "delete {<gist ID> | <gist URL>}",
Short: "Delete a gist",
Args: cobra.ExactArgs(1),
Args: cmdutil.MinimumArgs(1, "cannot delete: gist argument required"),
RunE: func(c *cobra.Command, args []string) error {
opts.Selector = args[0]
if runF != nil {

View file

@ -49,7 +49,7 @@ func NewCmdEdit(f *cmdutil.Factory, runF func(*EditOptions) error) *cobra.Comman
cmd := &cobra.Command{
Use: "edit {<gist ID> | <gist URL>}",
Short: "Edit one of your gists",
Args: cobra.ExactArgs(1),
Args: cmdutil.MinimumArgs(1, "cannot edit: gist argument required"),
RunE: func(c *cobra.Command, args []string) error {
opts.Selector = args[0]

View file

@ -34,7 +34,7 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman
cmd := &cobra.Command{
Use: "view {<gist id> | <gist url>}",
Short: "View a gist",
Args: cobra.ExactArgs(1),
Args: cmdutil.MinimumArgs(1, "cannot view: gist argument required"),
RunE: func(cmd *cobra.Command, args []string) error {
opts.Selector = args[0]

View file

@ -1,59 +1,28 @@
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"
issueShared "github.com/cli/cli/pkg/cmd/issue/shared"
prShared "github.com/cli/cli/pkg/cmd/pr/shared"
"github.com/cli/cli/pkg/cmdutil"
"github.com/cli/cli/pkg/iostreams"
"github.com/cli/cli/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,
func NewCmdComment(f *cmdutil.Factory, runF func(*prShared.CommentableOptions) error) *cobra.Command {
opts := &prShared.CommentableOptions{
IO: f.IOStreams,
HttpClient: f.HttpClient,
EditSurvey: prShared.CommentableEditSurvey(f.Config, f.IOStreams),
InteractiveEditSurvey: prShared.CommentableInteractiveEditSurvey(f.Config, f.IOStreams),
ConfirmSubmitSurvey: prShared.CommentableConfirmSubmitSurvey,
OpenInBrowser: utils.OpenInBrowser,
}
var webMode bool
var editorMode bool
cmd := &cobra.Command{
Use: "comment {<number> | <url>}",
Short: "Create a new issue comment",
@ -61,140 +30,40 @@ func NewCmdComment(f *cmdutil.Factory, runF func(*CommentOptions) error) *cobra.
$ 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")}
}
PreRunE: func(cmd *cobra.Command, args []string) error {
opts.RetrieveCommentable = retrieveIssue(f.HttpClient, f.BaseRepo, args[0])
return prShared.CommentablePreRun(cmd, opts)
},
RunE: func(_ *cobra.Command, args []string) error {
if runF != nil {
return runF(opts)
}
return commentRun(opts)
return prShared.CommentableRun(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")
cmd.Flags().BoolP("editor", "e", false, "Add body using editor")
cmd.Flags().BoolP("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()
func retrieveIssue(httpClient func() (*http.Client, error),
baseRepo func() (ghrepo.Interface, error),
selector string) func() (prShared.Commentable, ghrepo.Interface, error) {
return func() (prShared.Commentable, ghrepo.Interface, error) {
httpClient, err := httpClient()
if err != nil {
return err
return nil, nil, err
}
opts.InputType = inputType
}
apiClient := api.NewClientFromHTTP(httpClient)
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()
issue, repo, err := issueShared.IssueFromArg(apiClient, baseRepo, selector)
if err != nil {
return err
return nil, nil, 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)
return issue, repo, nil
}
}

View file

@ -6,6 +6,7 @@ import (
"testing"
"github.com/cli/cli/internal/ghrepo"
"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"
@ -17,20 +18,19 @@ func TestNewCmdComment(t *testing.T) {
tests := []struct {
name string
input string
output CommentOptions
output shared.CommentableOptions
wantsErr bool
}{
{
name: "no arguments",
input: "",
output: CommentOptions{},
output: shared.CommentableOptions{},
wantsErr: true,
},
{
name: "issue number",
input: "1",
output: CommentOptions{
SelectorArg: "1",
output: shared.CommentableOptions{
Interactive: true,
InputType: 0,
Body: "",
@ -40,8 +40,7 @@ func TestNewCmdComment(t *testing.T) {
{
name: "issue url",
input: "https://github.com/OWNER/REPO/issues/12",
output: CommentOptions{
SelectorArg: "https://github.com/OWNER/REPO/issues/12",
output: shared.CommentableOptions{
Interactive: true,
InputType: 0,
Body: "",
@ -51,10 +50,9 @@ func TestNewCmdComment(t *testing.T) {
{
name: "body flag",
input: "1 --body test",
output: CommentOptions{
SelectorArg: "1",
output: shared.CommentableOptions{
Interactive: false,
InputType: inputTypeInline,
InputType: shared.InputTypeInline,
Body: "test",
},
wantsErr: false,
@ -62,10 +60,9 @@ func TestNewCmdComment(t *testing.T) {
{
name: "editor flag",
input: "1 --editor",
output: CommentOptions{
SelectorArg: "1",
output: shared.CommentableOptions{
Interactive: false,
InputType: inputTypeEditor,
InputType: shared.InputTypeEditor,
Body: "",
},
wantsErr: false,
@ -73,10 +70,9 @@ func TestNewCmdComment(t *testing.T) {
{
name: "web flag",
input: "1 --web",
output: CommentOptions{
SelectorArg: "1",
output: shared.CommentableOptions{
Interactive: false,
InputType: inputTypeWeb,
InputType: shared.InputTypeWeb,
Body: "",
},
wantsErr: false,
@ -84,25 +80,25 @@ func TestNewCmdComment(t *testing.T) {
{
name: "editor and web flags",
input: "1 --editor --web",
output: CommentOptions{},
output: shared.CommentableOptions{},
wantsErr: true,
},
{
name: "editor and body flags",
input: "1 --editor --body test",
output: CommentOptions{},
output: shared.CommentableOptions{},
wantsErr: true,
},
{
name: "web and body flags",
input: "1 --web --body test",
output: CommentOptions{},
output: shared.CommentableOptions{},
wantsErr: true,
},
{
name: "editor, web, and body flags",
input: "1 --editor --web --body test",
output: CommentOptions{},
output: shared.CommentableOptions{},
wantsErr: true,
},
}
@ -121,8 +117,8 @@ func TestNewCmdComment(t *testing.T) {
argv, err := shlex.Split(tt.input)
assert.NoError(t, err)
var gotOpts *CommentOptions
cmd := NewCmdComment(f, func(opts *CommentOptions) error {
var gotOpts *shared.CommentableOptions
cmd := NewCmdComment(f, func(opts *shared.CommentableOptions) error {
gotOpts = opts
return nil
})
@ -140,7 +136,6 @@ func TestNewCmdComment(t *testing.T) {
}
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)
@ -151,38 +146,20 @@ func TestNewCmdComment(t *testing.T) {
func Test_commentRun(t *testing.T) {
tests := []struct {
name string
input *CommentOptions
input *shared.CommentableOptions
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",
input: &shared.CommentableOptions{
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 },
InteractiveEditSurvey: func() (string, error) { return "comment body", nil },
ConfirmSubmitSurvey: func() (bool, error) { return true, nil },
},
httpStubs: func(t *testing.T, reg *httpmock.Registry) {
mockIssueFromNumber(t, reg)
@ -192,10 +169,9 @@ func Test_commentRun(t *testing.T) {
},
{
name: "non-interactive web",
input: &CommentOptions{
SelectorArg: "123",
input: &shared.CommentableOptions{
Interactive: false,
InputType: inputTypeWeb,
InputType: shared.InputTypeWeb,
Body: "",
OpenInBrowser: func(string) error { return nil },
@ -207,10 +183,9 @@ func Test_commentRun(t *testing.T) {
},
{
name: "non-interactive editor",
input: &CommentOptions{
SelectorArg: "123",
input: &shared.CommentableOptions{
Interactive: false,
InputType: inputTypeEditor,
InputType: shared.InputTypeEditor,
Body: "",
EditSurvey: func() (string, error) { return "comment body", nil },
@ -223,10 +198,9 @@ func Test_commentRun(t *testing.T) {
},
{
name: "non-interactive inline",
input: &CommentOptions{
SelectorArg: "123",
input: &shared.CommentableOptions{
Interactive: false,
InputType: inputTypeInline,
InputType: shared.InputTypeInline,
Body: "comment body",
},
httpStubs: func(t *testing.T, reg *httpmock.Registry) {
@ -246,16 +220,15 @@ func Test_commentRun(t *testing.T) {
defer reg.Verify(t)
tt.httpStubs(t, reg)
httpClient := func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }
baseRepo := func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil }
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
}
tt.input.HttpClient = httpClient
tt.input.RetrieveCommentable = retrieveIssue(tt.input.HttpClient, baseRepo, "123")
t.Run(tt.name, func(t *testing.T) {
err := commentRun(tt.input)
err := shared.CommentableRun(tt.input)
assert.NoError(t, err)
assert.Equal(t, tt.stdout, stdout.String())
assert.Equal(t, tt.stderr, stderr.String())

View file

@ -9,6 +9,7 @@ import (
"github.com/cli/cli/api"
"github.com/cli/cli/internal/config"
"github.com/cli/cli/internal/ghrepo"
"github.com/cli/cli/pkg/cmd/pr/shared"
prShared "github.com/cli/cli/pkg/cmd/pr/shared"
"github.com/cli/cli/pkg/cmdutil"
"github.com/cli/cli/pkg/iostreams"
@ -53,6 +54,7 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co
$ gh issue create --label "bug,help wanted"
$ gh issue create --label bug --label "help wanted"
$ gh issue create --assignee monalisa,hubot
$ gh issue create --assignee @me
$ gh issue create --project "Roadmap"
`),
Args: cmdutil.NoArgsQuoteReminder,
@ -84,7 +86,7 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co
cmd.Flags().StringVarP(&opts.Title, "title", "t", "", "Supply a title. Will prompt for one otherwise.")
cmd.Flags().StringVarP(&opts.Body, "body", "b", "", "Supply a body. Will prompt for one otherwise.")
cmd.Flags().BoolVarP(&opts.WebMode, "web", "w", false, "Open the browser to create an issue")
cmd.Flags().StringSliceVarP(&opts.Assignees, "assignee", "a", nil, "Assign people by their `login`")
cmd.Flags().StringSliceVarP(&opts.Assignees, "assignee", "a", nil, "Assign people by their `login`. Use \"@me\" to self-assign.")
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`")
@ -114,9 +116,15 @@ func createRun(opts *CreateOptions) (err error) {
milestones = []string{opts.Milestone}
}
meReplacer := shared.NewMeReplacer(apiClient, baseRepo.RepoHost())
assignees, err := meReplacer.ReplaceSlice(opts.Assignees)
if err != nil {
return err
}
tb := prShared.IssueMetadataState{
Type: prShared.IssueMetadata,
Assignees: opts.Assignees,
Assignees: assignees,
Labels: opts.Labels,
Projects: opts.Projects,
Milestones: milestones,
@ -134,7 +142,14 @@ func createRun(opts *CreateOptions) (err error) {
if opts.WebMode {
openURL := ghrepo.GenerateRepoURL(baseRepo, "issues/new")
if opts.Title != "" || opts.Body != "" {
if opts.Title != "" || opts.Body != "" || tb.HasMetadata() {
if len(opts.Projects) > 0 {
var err error
tb.Projects, err = api.ProjectNamesToPaths(apiClient, baseRepo, tb.Projects)
if err != nil {
return fmt.Errorf("could not add to project: %w", err)
}
}
openURL, err = prShared.WithPrAndIssueQueryParams(openURL, tb)
if err != nil {
return
@ -235,6 +250,13 @@ func createRun(opts *CreateOptions) (err error) {
}
if action == prShared.PreviewAction {
if len(tb.Projects) > 0 {
var err error
tb.Projects, err = api.ProjectNamesToPaths(apiClient, repo, tb.Projects)
if err != nil {
return fmt.Errorf("could not add to project: %w", err)
}
}
openURL := ghrepo.GenerateRepoURL(baseRepo, "issues/new")
openURL, err = prShared.WithPrAndIssueQueryParams(openURL, tb)
if err != nil {

View file

@ -8,7 +8,6 @@ import (
"net/http"
"os"
"os/exec"
"reflect"
"strings"
"testing"
@ -26,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, "")
}
@ -83,11 +75,7 @@ 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) {
@ -120,7 +108,7 @@ func TestIssueCreate(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_recover(t *testing.T) {
@ -152,9 +140,9 @@ func TestIssueCreate_recover(t *testing.T) {
"URL": "https://github.com/OWNER/REPO/issues/12"
} } } }
`, func(inputs map[string]interface{}) {
eq(t, inputs["title"], "recovered title")
eq(t, inputs["body"], "recovered body")
eq(t, inputs["labelIds"], []interface{}{"BUGID", "TODOID"})
assert.Equal(t, "recovered title", inputs["title"])
assert.Equal(t, "recovered body", inputs["body"])
assert.Equal(t, []interface{}{"BUGID", "TODOID"}, inputs["labelIds"])
}))
as, teardown := prompt.InitAskStubber()
@ -201,7 +189,7 @@ func TestIssueCreate_recover(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_nonLegacyTemplate(t *testing.T) {
@ -259,7 +247,7 @@ func TestIssueCreate_nonLegacyTemplate(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_continueInBrowser(t *testing.T) {
@ -294,6 +282,7 @@ func TestIssueCreate_continueInBrowser(t *testing.T) {
})
var seenCmd *exec.Cmd
//nolint:staticcheck // SA1019 TODO: rewrite to use run.Stub
restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable {
seenCmd = cmd
return &test.OutputStub{}
@ -376,12 +365,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)
}
@ -395,7 +384,7 @@ 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) {
@ -421,14 +410,24 @@ func TestIssueCreate_web(t *testing.T) {
http := &httpmock.Registry{}
defer http.Verify(t)
http.Register(
httpmock.GraphQL(`query UserCurrent\b`),
httpmock.StringResponse(`
{ "data": {
"viewer": { "login": "MonaLisa" }
} }
`),
)
var seenCmd *exec.Cmd
//nolint:staticcheck // SA1019 TODO: rewrite to use run.Stub
restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable {
seenCmd = cmd
return &test.OutputStub{}
})
defer restoreCmd()
output, err := runCommand(http, true, `--web`)
output, err := runCommand(http, true, `--web -a @me`)
if err != nil {
t.Errorf("error running command `issue create`: %v", err)
}
@ -437,9 +436,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?assignees=MonaLisa", 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) {
@ -447,6 +446,7 @@ func TestIssueCreate_webTitleBody(t *testing.T) {
defer http.Verify(t)
var seenCmd *exec.Cmd
//nolint:staticcheck // SA1019 TODO: rewrite to use run.Stub
restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable {
seenCmd = cmd
return &test.OutputStub{}
@ -462,7 +462,142 @@ 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())
}
func TestIssueCreate_webTitleBodyAtMeAssignee(t *testing.T) {
http := &httpmock.Registry{}
defer http.Verify(t)
http.Register(
httpmock.GraphQL(`query UserCurrent\b`),
httpmock.StringResponse(`
{ "data": {
"viewer": { "login": "MonaLisa" }
} }
`),
)
var seenCmd *exec.Cmd
//nolint:staticcheck // SA1019 TODO: rewrite to use run.Stub
restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable {
seenCmd = cmd
return &test.OutputStub{}
})
defer restoreCmd()
output, err := runCommand(http, true, `-w -t mytitle -b mybody -a @me`)
if err != nil {
t.Errorf("error running command `issue create`: %v", err)
}
if seenCmd == nil {
t.Fatal("expected a command to run")
}
url := strings.ReplaceAll(seenCmd.Args[len(seenCmd.Args)-1], "^", "")
assert.Equal(t, "https://github.com/OWNER/REPO/issues/new?assignees=MonaLisa&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())
}
func TestIssueCreate_AtMeAssignee(t *testing.T) {
http := &httpmock.Registry{}
defer http.Verify(t)
http.Register(
httpmock.GraphQL(`query UserCurrent\b`),
httpmock.StringResponse(`
{ "data": {
"viewer": { "login": "MonaLisa" }
} }
`),
)
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" },
"u001": { "login": "SomeOneElse", "id": "SOMEID" },
"repository": {
"l000": { "name": "bug", "id": "BUGID" },
"l001": { "name": "TODO", "id": "TODOID" }
}
} }
`),
)
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, "hello", inputs["title"])
assert.Equal(t, "cash rules everything around me", inputs["body"])
assert.Equal(t, []interface{}{"MONAID", "SOMEID"}, inputs["assigneeIds"])
}))
output, err := runCommand(http, true, `-a @me -a someoneelse -t hello -b "cash rules everything around me"`)
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_webProject(t *testing.T) {
http := &httpmock.Registry{}
defer http.Verify(t)
http.Register(
httpmock.GraphQL(`query RepositoryProjectList\b`),
httpmock.StringResponse(`
{ "data": { "repository": { "projects": {
"nodes": [
{ "name": "Cleanup", "id": "CLEANUPID", "resourcePath": "/OWNER/REPO/projects/1" }
],
"pageInfo": { "hasNextPage": false }
} } } }
`))
http.Register(
httpmock.GraphQL(`query OrganizationProjectList\b`),
httpmock.StringResponse(`
{ "data": { "organization": { "projects": {
"nodes": [
{ "name": "Triage", "id": "TRIAGEID", "resourcePath": "/orgs/ORG/projects/1" }
],
"pageInfo": { "hasNextPage": false }
} } } }
`))
var seenCmd *exec.Cmd
//nolint:staticcheck // SA1019 TODO: rewrite to use run.Stub
restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable {
seenCmd = cmd
return &test.OutputStub{}
})
defer restoreCmd()
output, err := runCommand(http, true, `-w -t Title -p Cleanup`)
if err != nil {
t.Errorf("error running command `issue create`: %v", err)
}
if seenCmd == nil {
t.Fatal("expected a command to run")
}
url := strings.ReplaceAll(seenCmd.Args[len(seenCmd.Args)-1], "^", "")
assert.Equal(t, "https://github.com/OWNER/REPO/issues/new?projects=OWNER%2FREPO%2F1&title=Title", url)
assert.Equal(t, "", output.String())
assert.Equal(t, "Opening github.com/OWNER/REPO/issues/new in your browser.\n", output.Stderr())
}

View file

@ -9,6 +9,7 @@ import (
"github.com/cli/cli/internal/config"
"github.com/cli/cli/internal/ghrepo"
issueShared "github.com/cli/cli/pkg/cmd/issue/shared"
"github.com/cli/cli/pkg/cmd/pr/shared"
prShared "github.com/cli/cli/pkg/cmd/pr/shared"
"github.com/cli/cli/pkg/cmdutil"
"github.com/cli/cli/pkg/iostreams"
@ -46,6 +47,7 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman
Example: heredoc.Doc(`
$ gh issue list -l "help wanted"
$ gh issue list -A monalisa
$ gh issue list -a @me
$ gh issue list --web
$ gh issue list --milestone 'MVP'
`),
@ -91,15 +93,29 @@ func listRun(opts *ListOptions) error {
isTerminal := opts.IO.IsStdoutTTY()
meReplacer := shared.NewMeReplacer(apiClient, baseRepo.RepoHost())
filterAssignee, err := meReplacer.Replace(opts.Assignee)
if err != nil {
return err
}
filterAuthor, err := meReplacer.Replace(opts.Author)
if err != nil {
return err
}
filterMention, err := meReplacer.Replace(opts.Mention)
if err != nil {
return err
}
if opts.WebMode {
issueListURL := ghrepo.GenerateRepoURL(baseRepo, "issues")
openURL, err := prShared.ListURLWithQuery(issueListURL, prShared.FilterOptions{
Entity: "issue",
State: opts.State,
Assignee: opts.Assignee,
Assignee: filterAssignee,
Labels: opts.Labels,
Author: opts.Author,
Mention: opts.Mention,
Author: filterAuthor,
Mention: filterMention,
Milestone: opts.Milestone,
})
if err != nil {
@ -111,7 +127,7 @@ func listRun(opts *ListOptions) error {
return utils.OpenInBrowser(openURL)
}
listResult, err := api.IssueList(apiClient, baseRepo, opts.State, opts.Labels, opts.Assignee, opts.LimitResults, opts.Author, opts.Mention, opts.Milestone)
listResult, err := api.IssueList(apiClient, baseRepo, opts.State, opts.Labels, filterAssignee, opts.LimitResults, filterAuthor, filterMention, opts.Milestone)
if err != nil {
return err
}

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,8 @@ func TestIssueList_nontty(t *testing.T) {
t.Errorf("error running command `issue list`: %v", err)
}
eq(t, output.Stderr(), "")
assert.Equal(t, "", output.Stderr())
//nolint:staticcheck // prefer exact matchers over ExpectLines
test.ExpectLines(t, output.String(),
`1[\t]+number won[\t]+label[\t]+\d+`,
`2[\t]+number too[\t]+label[\t]+\d+`,
@ -147,11 +140,36 @@ 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_atMe(t *testing.T) {
http := &httpmock.Registry{}
defer http.Verify(t)
http.Register(
httpmock.GraphQL(`query UserCurrent\b`),
httpmock.StringResponse(`{"data": {"viewer": {"login": "monalisa"} } }`))
http.Register(
httpmock.GraphQL(`query IssueList\b`),
httpmock.GraphQLQuery(`
{ "data": { "repository": {
"hasIssuesEnabled": true,
"issues": { "nodes": [] }
} } }`, func(_ string, params map[string]interface{}) {
assert.Equal(t, "monalisa", params["assignee"].(string))
assert.Equal(t, "monalisa", params["author"].(string))
assert.Equal(t, "monalisa", params["mention"].(string))
}))
_, err := runCommand(http, true, "-a @me -A @me --mention @me")
if err != nil {
t.Errorf("error running command `issue list`: %v", err)
}
}
func TestIssueList_withInvalidLimitFlag(t *testing.T) {
@ -191,8 +209,8 @@ 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) {
@ -218,6 +236,7 @@ func TestIssueList_web(t *testing.T) {
defer http.Verify(t)
var seenCmd *exec.Cmd
//nolint:staticcheck // SA1019 TODO: rewrite to use run.Stub
restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable {
seenCmd = cmd
return &test.OutputStub{}
@ -231,14 +250,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

@ -46,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
@ -110,11 +108,11 @@ 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))
fmt.Fprint(opts.IO.Out, prShared.RawCommentList(issue.Comments, api.PullRequestReviews{}))
return nil
}
@ -141,11 +139,11 @@ func printRawIssuePreview(out io.Writer, issue *api.Issue) error {
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))
@ -182,21 +180,23 @@ func printHumanIssuePreview(io *iostreams.IOStreams, issue *api.Issue) error {
}
// Body
fmt.Fprintln(out)
var md string
var err error
if issue.Body == "" {
issue.Body = "_No description provided_"
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
}
}
style := markdown.GetStyle(io.TerminalTheme())
md, err := markdown.Render(issue.Body, style, "")
if err != nil {
return err
}
fmt.Fprint(out, md)
fmt.Fprintln(out)
fmt.Fprintf(out, "\n%s\n", md)
// Comments
if issue.Comments.TotalCount > 0 {
comments, err := prShared.CommentList(io, issue.Comments)
preview := !opts.Comments
comments, err := prShared.CommentList(opts.IO, issue.Comments, api.PullRequestReviews{}, preview)
if err != nil {
return err
}

View file

@ -8,7 +8,6 @@ import (
"os/exec"
"testing"
"github.com/briandowns/spinner"
"github.com/cli/cli/internal/config"
"github.com/cli/cli/internal/ghrepo"
"github.com/cli/cli/internal/run"
@ -16,7 +15,6 @@ 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"
)
@ -74,6 +72,7 @@ func TestIssueView_web(t *testing.T) {
)
var seenCmd *exec.Cmd
//nolint:staticcheck // SA1019 TODO: rewrite to use run.Stub
restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable {
seenCmd = cmd
return &test.OutputStub{}
@ -110,6 +109,7 @@ func TestIssueView_web_numberArgWithHash(t *testing.T) {
)
var seenCmd *exec.Cmd
//nolint:staticcheck // SA1019 TODO: rewrite to use run.Stub
restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable {
seenCmd = cmd
return &test.OutputStub{}
@ -196,6 +196,7 @@ func TestIssueView_nontty_Preview(t *testing.T) {
assert.Equal(t, "", output.Stderr())
//nolint:staticcheck // prefer exact matchers over ExpectLines
test.ExpectLines(t, output.String(), tc.expectedOutputs...)
})
}
@ -262,6 +263,7 @@ func TestIssueView_tty_Preview(t *testing.T) {
assert.Equal(t, "", output.Stderr())
//nolint:staticcheck // prefer exact matchers over ExpectLines
test.ExpectLines(t, output.String(), tc.expectedOutputs...)
})
}
@ -281,6 +283,7 @@ func TestIssueView_web_notFound(t *testing.T) {
)
var seenCmd *exec.Cmd
//nolint:staticcheck // SA1019 TODO: rewrite to use run.Stub
restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable {
seenCmd = cmd
return &test.OutputStub{}
@ -332,6 +335,7 @@ func TestIssueView_web_urlArg(t *testing.T) {
)
var seenCmd *exec.Cmd
//nolint:staticcheck // SA1019 TODO: rewrite to use run.Stub
restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable {
seenCmd = cmd
return &test.OutputStub{}
@ -368,7 +372,7 @@ func TestIssueView_tty_Comments(t *testing.T) {
`some title`,
`some body`,
`———————— Not showing 4 comments ————————`,
`marseilles \(collaborator\) • Jan 1, 2020 • Newest comment`,
`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`,
@ -383,16 +387,16 @@ func TestIssueView_tty_Comments(t *testing.T) {
expectedOutputs: []string{
`some title`,
`some body`,
`monalisa • Jan 1, 2020 • edited`,
`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`,
`johnnytest \(Contributor\) • Jan 1, 2020`,
`Comment 2`,
`elvisp \(member\) • Jan 1, 2020`,
`elvisp \(Member\) • Jan 1, 2020`,
`Comment 3`,
`loislane \(owner\) • Jan 1, 2020`,
`loislane \(Owner\) • Jan 1, 2020`,
`Comment 4`,
`marseilles \(collaborator\) • Jan 1, 2020 • Newest comment`,
`marseilles \(Collaborator\) • Jan 1, 2020 • Newest comment`,
`Comment 5`,
`View this issue on GitHub: https://github.com/OWNER/REPO/issues/123`,
},
@ -404,7 +408,6 @@ func TestIssueView_tty_Comments(t *testing.T) {
}
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 {
@ -418,6 +421,7 @@ func TestIssueView_tty_Comments(t *testing.T) {
}
assert.NoError(t, err)
assert.Equal(t, "", output.Stderr())
//nolint:staticcheck // prefer exact matchers over ExpectLines
test.ExpectLines(t, output.String(), tc.expectedOutputs...)
})
}
@ -492,12 +496,8 @@ func TestIssueView_nontty_Comments(t *testing.T) {
}
assert.NoError(t, err)
assert.Equal(t, "", output.Stderr())
//nolint:staticcheck // prefer exact matchers over ExpectLines
test.ExpectLines(t, output.String(), tc.expectedOutputs...)
})
}
}
func stubSpinner() {
utils.StartSpinner = func(_ *spinner.Spinner) {}
utils.StopSpinner = func(_ *spinner.Spinner) {}
}

View file

@ -30,6 +30,7 @@ type CheckoutOptions struct {
SelectorArg string
RecurseSubmodules bool
Force bool
}
func NewCmdCheckout(f *cmdutil.Factory, runF func(*CheckoutOptions) error) *cobra.Command {
@ -60,7 +61,8 @@ func NewCmdCheckout(f *cmdutil.Factory, runF func(*CheckoutOptions) error) *cobr
},
}
cmd.Flags().BoolVarP(&opts.RecurseSubmodules, "recurse-submodules", "", false, "Update all active submodules (recursively)")
cmd.Flags().BoolVarP(&opts.RecurseSubmodules, "recurse-submodules", "", false, "Update all submodules after checkout")
cmd.Flags().BoolVarP(&opts.Force, "force", "f", false, "Reset the existing local branch to the latest state of the pull request")
return cmd
}
@ -116,7 +118,12 @@ func checkoutRun(opts *CheckoutOptions) error {
// local branch already exists
if _, err := git.ShowRefs("refs/heads/" + newBranchName); err == nil {
cmdQueue = append(cmdQueue, []string{"git", "checkout", newBranchName})
cmdQueue = append(cmdQueue, []string{"git", "merge", "--ff-only", fmt.Sprintf("refs/remotes/%s", remoteBranch)})
if opts.Force {
cmdQueue = append(cmdQueue, []string{"git", "reset", "--hard", fmt.Sprintf("refs/remotes/%s", remoteBranch)})
} else {
// TODO: check if non-fast-forward and suggest to use `--force`
cmdQueue = append(cmdQueue, []string{"git", "merge", "--ff-only", fmt.Sprintf("refs/remotes/%s", remoteBranch)})
}
} else {
cmdQueue = append(cmdQueue, []string{"git", "checkout", "-b", newBranchName, "--no-track", remoteBranch})
cmdQueue = append(cmdQueue, []string{"git", "config", fmt.Sprintf("branch.%s.remote", newBranchName), headRemote.Name})
@ -139,11 +146,24 @@ func checkoutRun(opts *CheckoutOptions) error {
ref := fmt.Sprintf("refs/pull/%d/head", pr.Number)
if newBranchName == currentBranch {
// PR head matches currently checked out branch
cmdQueue = append(cmdQueue, []string{"git", "fetch", baseURLOrName, ref})
cmdQueue = append(cmdQueue, []string{"git", "merge", "--ff-only", "FETCH_HEAD"})
if opts.Force {
cmdQueue = append(cmdQueue, []string{"git", "reset", "--hard", "FETCH_HEAD"})
} else {
// TODO: check if non-fast-forward and suggest to use `--force`
cmdQueue = append(cmdQueue, []string{"git", "merge", "--ff-only", "FETCH_HEAD"})
}
} else {
// create a new branch
cmdQueue = append(cmdQueue, []string{"git", "fetch", baseURLOrName, fmt.Sprintf("%s:%s", ref, newBranchName)})
if opts.Force {
cmdQueue = append(cmdQueue, []string{"git", "fetch", baseURLOrName, fmt.Sprintf("%s:%s", ref, newBranchName), "--force"})
} else {
// TODO: check if non-fast-forward and suggest to use `--force`
cmdQueue = append(cmdQueue, []string{"git", "fetch", baseURLOrName, fmt.Sprintf("%s:%s", ref, newBranchName)})
}
cmdQueue = append(cmdQueue, []string{"git", "checkout", newBranchName})
}

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
}
@ -117,6 +109,7 @@ func TestPRCheckout_sameRepo(t *testing.T) {
`))
ranCommands := [][]string{}
//nolint:staticcheck // SA1019 TODO: rewrite to use run.Stub
restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable {
switch strings.Join(cmd.Args, " ") {
case "git show-ref --verify -- refs/heads/feature":
@ -137,10 +130,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) {
@ -162,6 +155,7 @@ func TestPRCheckout_urlArg(t *testing.T) {
`))
ranCommands := [][]string{}
//nolint:staticcheck // SA1019 TODO: rewrite to use run.Stub
restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable {
switch strings.Join(cmd.Args, " ") {
case "git show-ref --verify -- refs/heads/feature":
@ -174,11 +168,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) {
@ -201,6 +195,7 @@ func TestPRCheckout_urlArg_differentBase(t *testing.T) {
http.StubRepoInfoResponse("OWNER", "REPO", "master")
ranCommands := [][]string{}
//nolint:staticcheck // SA1019 TODO: rewrite to use run.Stub
restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable {
switch strings.Join(cmd.Args, " ") {
case "git show-ref --verify -- refs/heads/feature":
@ -213,8 +208,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 +220,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) {
@ -253,6 +248,7 @@ func TestPRCheckout_branchArg(t *testing.T) {
`))
ranCommands := [][]string{}
//nolint:staticcheck // SA1019 TODO: rewrite to use run.Stub
restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable {
switch strings.Join(cmd.Args, " ") {
case "git show-ref --verify -- refs/heads/feature":
@ -265,11 +261,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) {
@ -292,6 +288,7 @@ func TestPRCheckout_existingBranch(t *testing.T) {
`))
ranCommands := [][]string{}
//nolint:staticcheck // SA1019 TODO: rewrite to use run.Stub
restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable {
switch strings.Join(cmd.Args, " ") {
case "git show-ref --verify -- refs/heads/feature":
@ -304,13 +301,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) {
@ -344,6 +341,7 @@ func TestPRCheckout_differentRepo_remoteExists(t *testing.T) {
`))
ranCommands := [][]string{}
//nolint:staticcheck // SA1019 TODO: rewrite to use run.Stub
restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable {
switch strings.Join(cmd.Args, " ") {
case "git show-ref --verify -- refs/heads/feature":
@ -356,14 +354,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) {
@ -386,6 +384,7 @@ func TestPRCheckout_differentRepo(t *testing.T) {
`))
ranCommands := [][]string{}
//nolint:staticcheck // SA1019 TODO: rewrite to use run.Stub
restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable {
switch strings.Join(cmd.Args, " ") {
case "git config branch.feature.merge":
@ -398,14 +397,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) {
@ -428,6 +427,7 @@ func TestPRCheckout_differentRepo_existingBranch(t *testing.T) {
`))
ranCommands := [][]string{}
//nolint:staticcheck // SA1019 TODO: rewrite to use run.Stub
restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable {
switch strings.Join(cmd.Args, " ") {
case "git config branch.feature.merge":
@ -440,12 +440,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) {
@ -468,6 +468,7 @@ func TestPRCheckout_detachedHead(t *testing.T) {
`))
ranCommands := [][]string{}
//nolint:staticcheck // SA1019 TODO: rewrite to use run.Stub
restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable {
switch strings.Join(cmd.Args, " ") {
case "git config branch.feature.merge":
@ -480,12 +481,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) {
@ -508,6 +509,7 @@ func TestPRCheckout_differentRepo_currentBranch(t *testing.T) {
`))
ranCommands := [][]string{}
//nolint:staticcheck // SA1019 TODO: rewrite to use run.Stub
restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable {
switch strings.Join(cmd.Args, " ") {
case "git config branch.feature.merge":
@ -520,12 +522,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) {
@ -547,6 +549,7 @@ func TestPRCheckout_differentRepo_invalidBranchName(t *testing.T) {
} } } }
`))
//nolint:staticcheck // SA1019 TODO: rewrite to use run.Stub
restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable {
t.Errorf("unexpected external invocation: %v", cmd.Args)
return &test.OutputStub{}
@ -554,9 +557,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())
}
@ -580,6 +581,7 @@ func TestPRCheckout_maintainerCanModify(t *testing.T) {
`))
ranCommands := [][]string{}
//nolint:staticcheck // SA1019 TODO: rewrite to use run.Stub
restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable {
switch strings.Join(cmd.Args, " ") {
case "git config branch.feature.merge":
@ -592,14 +594,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) {
@ -621,6 +623,7 @@ func TestPRCheckout_recurseSubmodules(t *testing.T) {
`))
ranCommands := [][]string{}
//nolint:staticcheck // SA1019 TODO: rewrite to use run.Stub
restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable {
switch strings.Join(cmd.Args, " ") {
case "git show-ref --verify -- refs/heads/feature":
@ -633,13 +636,55 @@ 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], " "))
}
func TestPRCheckout_force(t *testing.T) {
http := &httpmock.Registry{}
http.Register(httpmock.GraphQL(`query PullRequestByNumber\b`), httpmock.StringResponse(`
{ "data": { "repository": { "pullRequest": {
"number": 123,
"headRefName": "feature",
"headRepositoryOwner": {
"login": "hubot"
},
"headRepository": {
"name": "REPO"
},
"isCrossRepository": false,
"maintainerCanModify": false
} } } }
`))
ranCommands := [][]string{}
//nolint:staticcheck // SA1019 TODO: rewrite to use run.Stub
restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable {
switch strings.Join(cmd.Args, " ") {
case "git show-ref --verify -- refs/heads/feature":
return &test.OutputStub{}
default:
ranCommands = append(ranCommands, cmd.Args)
return &test.OutputStub{}
}
})
defer restoreCmd()
output, err := runCommand(http, nil, "master", `123 --force`)
assert.NoError(t, err)
assert.Equal(t, "", output.String())
assert.Equal(t, len(ranCommands), 3)
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 reset --hard refs/remotes/origin/feature", strings.Join(ranCommands[2], " "))
}

View file

@ -6,10 +6,10 @@ import (
"testing"
"github.com/cli/cli/internal/ghrepo"
"github.com/cli/cli/internal/run"
"github.com/cli/cli/pkg/cmdutil"
"github.com/cli/cli/pkg/httpmock"
"github.com/cli/cli/pkg/iostreams"
"github.com/cli/cli/test"
"github.com/google/shlex"
"github.com/stretchr/testify/assert"
)
@ -195,10 +195,10 @@ 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())
@ -252,10 +252,9 @@ func TestChecksRun_web(t *testing.T) {
opts.IO = io
cs, teardown := test.InitCmdStubber()
defer teardown()
cs.Stub("") // browser open
cs, teardown := run.Stub()
defer teardown(t)
cs.Register(`https://github\.com/OWNER/REPO/pull/123/checks$`, 0, "")
err := checksRun(opts)
assert.NoError(t, err)

View file

@ -9,6 +9,7 @@ import (
"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"
@ -135,18 +136,17 @@ func TestPrClose_deleteBranch(t *testing.T) {
httpmock.REST("DELETE", "repos/OWNER/REPO/git/refs/heads/blueberries"),
httpmock.StringResponse(`{}`))
cs, cmdTeardown := test.InitCmdStubber()
defer cmdTeardown()
cs, cmdTeardown := run.Stub()
defer cmdTeardown(t)
cs.Stub("") // git config --get-regexp ^branch\.blueberries\.(remote|merge)$
cs.Stub("") // git rev-parse --verify blueberries`
cs.Stub("") // git branch -d
cs.Stub("") // git push origin --delete blueberries
cs.Register(`git rev-parse --verify refs/heads/blueberries`, 0, "")
cs.Register(`git branch -D blueberries`, 0, "")
output, err := runCommand(http, true, `96 --delete-branch`)
if err != nil {
t.Fatalf("Got unexpected error running `pr close` %s", err)
}
//nolint:staticcheck // prefer exact matchers over ExpectLines
test.ExpectLines(t, output.Stderr(), `Closed pull request #96 \(The title of the PR\)`, `Deleted branch blueberries`)
}

View file

@ -0,0 +1,78 @@
package comment
import (
"errors"
"net/http"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/api"
"github.com/cli/cli/context"
"github.com/cli/cli/internal/ghrepo"
"github.com/cli/cli/pkg/cmd/pr/shared"
"github.com/cli/cli/pkg/cmdutil"
"github.com/cli/cli/utils"
"github.com/spf13/cobra"
)
func NewCmdComment(f *cmdutil.Factory, runF func(*shared.CommentableOptions) error) *cobra.Command {
opts := &shared.CommentableOptions{
IO: f.IOStreams,
HttpClient: f.HttpClient,
EditSurvey: shared.CommentableEditSurvey(f.Config, f.IOStreams),
InteractiveEditSurvey: shared.CommentableInteractiveEditSurvey(f.Config, f.IOStreams),
ConfirmSubmitSurvey: shared.CommentableConfirmSubmitSurvey,
OpenInBrowser: utils.OpenInBrowser,
}
cmd := &cobra.Command{
Use: "comment [<number> | <url> | <branch>]",
Short: "Create a new pr comment",
Example: heredoc.Doc(`
$ gh pr comment 22 --body "This looks great, lets get it deployed."
`),
PreRunE: func(cmd *cobra.Command, args []string) error {
if repoOverride, _ := cmd.Flags().GetString("repo"); repoOverride != "" && len(args) == 0 {
return &cmdutil.FlagError{Err: errors.New("argument required when using the --repo flag")}
}
var selector string
if len(args) > 0 {
selector = args[0]
}
opts.RetrieveCommentable = retrievePR(f.HttpClient, f.BaseRepo, f.Branch, f.Remotes, selector)
return shared.CommentablePreRun(cmd, opts)
},
RunE: func(cmd *cobra.Command, args []string) error {
if runF != nil {
return runF(opts)
}
return shared.CommentableRun(opts)
},
}
cmd.Flags().StringVarP(&opts.Body, "body", "b", "", "Supply a body. Will prompt for one otherwise.")
cmd.Flags().BoolP("editor", "e", false, "Add body using editor")
cmd.Flags().BoolP("web", "w", false, "Add body in browser")
return cmd
}
func retrievePR(httpClient func() (*http.Client, error),
baseRepo func() (ghrepo.Interface, error),
branch func() (string, error),
remotes func() (context.Remotes, error),
selector string) func() (shared.Commentable, ghrepo.Interface, error) {
return func() (shared.Commentable, ghrepo.Interface, error) {
httpClient, err := httpClient()
if err != nil {
return nil, nil, err
}
apiClient := api.NewClientFromHTTP(httpClient)
pr, repo, err := shared.PRFromArgs(apiClient, baseRepo, branch, remotes, selector)
if err != nil {
return nil, nil, err
}
return pr, repo, nil
}
}

View file

@ -0,0 +1,278 @@
package comment
import (
"bytes"
"net/http"
"testing"
"github.com/cli/cli/context"
"github.com/cli/cli/internal/ghrepo"
"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"
"github.com/google/shlex"
"github.com/stretchr/testify/assert"
)
func TestNewCmdComment(t *testing.T) {
tests := []struct {
name string
input string
output shared.CommentableOptions
wantsErr bool
}{
{
name: "no arguments",
input: "",
output: shared.CommentableOptions{
Interactive: true,
InputType: 0,
Body: "",
},
wantsErr: false,
},
{
name: "pr number",
input: "1",
output: shared.CommentableOptions{
Interactive: true,
InputType: 0,
Body: "",
},
wantsErr: false,
},
{
name: "pr url",
input: "https://github.com/OWNER/REPO/pull/12",
output: shared.CommentableOptions{
Interactive: true,
InputType: 0,
Body: "",
},
wantsErr: false,
},
{
name: "pr branch",
input: "branch-name",
output: shared.CommentableOptions{
Interactive: true,
InputType: 0,
Body: "",
},
wantsErr: false,
},
{
name: "body flag",
input: "1 --body test",
output: shared.CommentableOptions{
Interactive: false,
InputType: shared.InputTypeInline,
Body: "test",
},
wantsErr: false,
},
{
name: "editor flag",
input: "1 --editor",
output: shared.CommentableOptions{
Interactive: false,
InputType: shared.InputTypeEditor,
Body: "",
},
wantsErr: false,
},
{
name: "web flag",
input: "1 --web",
output: shared.CommentableOptions{
Interactive: false,
InputType: shared.InputTypeWeb,
Body: "",
},
wantsErr: false,
},
{
name: "editor and web flags",
input: "1 --editor --web",
output: shared.CommentableOptions{},
wantsErr: true,
},
{
name: "editor and body flags",
input: "1 --editor --body test",
output: shared.CommentableOptions{},
wantsErr: true,
},
{
name: "web and body flags",
input: "1 --web --body test",
output: shared.CommentableOptions{},
wantsErr: true,
},
{
name: "editor, web, and body flags",
input: "1 --editor --web --body test",
output: shared.CommentableOptions{},
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 *shared.CommentableOptions
cmd := NewCmdComment(f, func(opts *shared.CommentableOptions) 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.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 *shared.CommentableOptions
httpStubs func(*testing.T, *httpmock.Registry)
stdout string
stderr string
}{
{
name: "interactive editor",
input: &shared.CommentableOptions{
Interactive: true,
InputType: 0,
Body: "",
InteractiveEditSurvey: func() (string, error) { return "comment body", nil },
ConfirmSubmitSurvey: func() (bool, error) { return true, nil },
},
httpStubs: func(t *testing.T, reg *httpmock.Registry) {
mockPullRequestFromNumber(t, reg)
mockCommentCreate(t, reg)
},
stdout: "https://github.com/OWNER/REPO/pull/123#issuecomment-456\n",
},
{
name: "non-interactive web",
input: &shared.CommentableOptions{
Interactive: false,
InputType: shared.InputTypeWeb,
Body: "",
OpenInBrowser: func(string) error { return nil },
},
httpStubs: func(t *testing.T, reg *httpmock.Registry) {
mockPullRequestFromNumber(t, reg)
},
stderr: "Opening github.com/OWNER/REPO/pull/123 in your browser.\n",
},
{
name: "non-interactive editor",
input: &shared.CommentableOptions{
Interactive: false,
InputType: shared.InputTypeEditor,
Body: "",
EditSurvey: func() (string, error) { return "comment body", nil },
},
httpStubs: func(t *testing.T, reg *httpmock.Registry) {
mockPullRequestFromNumber(t, reg)
mockCommentCreate(t, reg)
},
stdout: "https://github.com/OWNER/REPO/pull/123#issuecomment-456\n",
},
{
name: "non-interactive inline",
input: &shared.CommentableOptions{
Interactive: false,
InputType: shared.InputTypeInline,
Body: "comment body",
},
httpStubs: func(t *testing.T, reg *httpmock.Registry) {
mockPullRequestFromNumber(t, reg)
mockCommentCreate(t, reg)
},
stdout: "https://github.com/OWNER/REPO/pull/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)
httpClient := func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }
baseRepo := func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil }
branch := func() (string, error) { return "", nil }
remotes := func() (context.Remotes, error) { return nil, nil }
tt.input.IO = io
tt.input.HttpClient = httpClient
tt.input.RetrieveCommentable = retrievePR(httpClient, baseRepo, branch, remotes, "123")
t.Run(tt.name, func(t *testing.T) {
err := shared.CommentableRun(tt.input)
assert.NoError(t, err)
assert.Equal(t, tt.stdout, stdout.String())
assert.Equal(t, tt.stderr, stderr.String())
})
}
}
func mockPullRequestFromNumber(_ *testing.T, reg *httpmock.Registry) {
reg.Register(
httpmock.GraphQL(`query PullRequestByNumber\b`),
httpmock.StringResponse(`
{ "data": { "repository": { "pullRequest": {
"number": 123,
"url": "https://github.com/OWNER/REPO/pull/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/pull/123#issuecomment-456"
} } } } }`,
func(inputs map[string]interface{}) {
assert.Equal(t, "comment body", inputs["body"])
}),
)
}

View file

@ -53,13 +53,15 @@ type CreateOptions struct {
Labels []string
Projects []string
Milestone string
MaintainerCanModify bool
}
type CreateContext struct {
// This struct stores contextual data about the creation process and is for building up enough
// data to create a pull request
RepoContext *context.ResolvedRemotes
BaseRepo ghrepo.Interface
BaseRepo *api.Repository
HeadRepo ghrepo.Interface
BaseTrackingBranch string
BaseBranch string
@ -91,6 +93,10 @@ 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"
@ -103,6 +109,8 @@ 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")}
@ -118,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)
@ -135,10 +146,11 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co
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 or teams by their `handle`")
fl.StringSliceVarP(&opts.Assignees, "assignee", "a", nil, "Assign people by their `login`")
fl.StringSliceVarP(&opts.Assignees, "assignee", "a", nil, "Assign people by their `login`. Use \"@me\" to self-assign.")
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
@ -252,7 +264,7 @@ func createRun(opts *CreateOptions) (err error) {
}
}
allowMetadata := ctx.BaseRepo.(*api.Repository).ViewerCanTriage()
allowMetadata := ctx.BaseRepo.ViewerCanTriage()
action, err := shared.ConfirmSubmission(!state.HasMetadata(), allowMetadata)
if err != nil {
return fmt.Errorf("unable to confirm: %w", err)
@ -374,10 +386,16 @@ func NewIssueState(ctx CreateContext, opts CreateOptions) (*shared.IssueMetadata
milestoneTitles = []string{opts.Milestone}
}
meReplacer := shared.NewMeReplacer(ctx.Client, ctx.BaseRepo.RepoHost())
assignees, err := meReplacer.ReplaceSlice(opts.Assignees)
if err != nil {
return nil, err
}
state := &shared.IssueMetadataState{
Type: shared.PRMetadata,
Reviewers: opts.Reviewers,
Assignees: opts.Assignees,
Assignees: assignees,
Labels: opts.Labels,
Projects: opts.Projects,
Milestones: milestoneTitles,
@ -561,11 +579,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"] == "" {
@ -577,7 +596,9 @@ func submitPR(opts CreateOptions, ctx CreateContext, state shared.IssueMetadataS
return err
}
pr, err := api.CreatePullRequest(client, ctx.BaseRepo.(*api.Repository), params)
opts.IO.StartProgressIndicator()
pr, err := api.CreatePullRequest(client, ctx.BaseRepo, params)
opts.IO.StopProgressIndicator()
if pr != nil {
fmt.Fprintln(opts.IO.Out, pr.URL)
}
@ -591,6 +612,14 @@ func submitPR(opts CreateOptions, ctx CreateContext, state shared.IssueMetadataS
}
func previewPR(opts CreateOptions, ctx CreateContext, state shared.IssueMetadataState) error {
if len(state.Projects) > 0 {
var err error
state.Projects, err = api.ProjectNamesToPaths(ctx.Client, ctx.BaseRepo, state.Projects)
if err != nil {
return fmt.Errorf("could not add to project: %w", err)
}
}
openURL, err := generateCompareURL(ctx, state)
if err != nil {
return err
@ -613,7 +642,9 @@ func handlePush(opts CreateOptions, ctx CreateContext) error {
// if a head repository could not be determined so far, automatically create
// one by forking the base repository
if headRepo == nil && ctx.IsPushEnabled {
opts.IO.StartProgressIndicator()
headRepo, err = api.ForkRepo(client, ctx.BaseRepo)
opts.IO.StopProgressIndicator()
if err != nil {
return fmt.Errorf("error forking repo: %w", err)
}

View file

@ -7,11 +7,11 @@ import (
"io/ioutil"
"net/http"
"os"
"reflect"
"strings"
"testing"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/api"
"github.com/cli/cli/context"
"github.com/cli/cli/git"
"github.com/cli/cli/internal/config"
@ -28,13 +28,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, "")
}
@ -105,6 +98,7 @@ func TestPRCreate_nontty_web(t *testing.T) {
http.StubRepoInfoResponse("OWNER", "REPO", "master")
//nolint:staticcheck // SA1019 TODO: rewrite to use run.Stub
cs, cmdTeardown := test.InitCmdStubber()
defer cmdTeardown()
@ -115,12 +109,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])
}
@ -129,11 +123,7 @@ 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())
}
@ -165,7 +155,7 @@ func TestPRCreate_recover(t *testing.T) {
"clientMutationId": ""
} } }
`, func(inputs map[string]interface{}) {
eq(t, inputs["userIds"], []interface{}{"JILLID"})
assert.Equal(t, []interface{}{"JILLID"}, inputs["userIds"])
}))
http.Register(
httpmock.GraphQL(`mutation PullRequestCreate\b`),
@ -178,6 +168,7 @@ func TestPRCreate_recover(t *testing.T) {
assert.Equal(t, "recovered body", input["body"].(string))
}))
//nolint:staticcheck // SA1019 TODO: rewrite to use run.Stub
cs, cmdTeardown := test.InitCmdStubber()
defer cmdTeardown()
@ -225,7 +216,7 @@ func TestPRCreate_recover(t *testing.T) {
output, err := runCommandWithRootDirOverridden(http, nil, "feature", true, args, "")
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_nontty(t *testing.T) {
@ -254,6 +245,7 @@ func TestPRCreate_nontty(t *testing.T) {
}),
)
//nolint:staticcheck // SA1019 TODO: rewrite to use run.Stub
cs, cmdTeardown := test.InitCmdStubber()
defer cmdTeardown()
@ -296,6 +288,7 @@ func TestPRCreate(t *testing.T) {
assert.Equal(t, "feature", input["headRefName"].(string))
}))
//nolint:staticcheck // SA1019 TODO: rewrite to use run.Stub
cs, cmdTeardown := test.InitCmdStubber()
defer cmdTeardown()
@ -316,6 +309,58 @@ 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))
}))
//nolint:staticcheck // SA1019 TODO: rewrite to use run.Stub
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)
@ -351,6 +396,7 @@ func TestPRCreate_createFork(t *testing.T) {
assert.Equal(t, "monalisa:feature", input["headRefName"].(string))
}))
//nolint:staticcheck // SA1019 TODO: rewrite to use run.Stub
cs, cmdTeardown := test.InitCmdStubber()
defer cmdTeardown()
@ -502,6 +548,7 @@ func TestPRCreate_nonLegacyTemplate(t *testing.T) {
assert.Equal(t, "- commit 1\n- commit 0\n\nFixes a bug and Closes an issue", input["body"].(string))
}))
//nolint:staticcheck // SA1019 TODO: rewrite to use run.Stub
cs, cmdTeardown := test.InitCmdStubber()
defer cmdTeardown()
@ -532,7 +579,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) {
@ -600,8 +647,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)
}
@ -616,11 +663,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`),
@ -629,12 +676,13 @@ 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"])
}))
//nolint:staticcheck // SA1019 TODO: rewrite to use run.Stub
cs, cmdTeardown := test.InitCmdStubber()
defer cmdTeardown()
@ -642,9 +690,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) {
@ -662,6 +710,7 @@ func TestPRCreate_alreadyExists(t *testing.T) {
] } } } }`),
)
//nolint:staticcheck // SA1019 TODO: rewrite to use run.Stub
cs, cmdTeardown := test.InitCmdStubber()
defer cmdTeardown()
@ -688,6 +737,7 @@ func TestPRCreate_web(t *testing.T) {
httpmock.GraphQL(`query UserCurrent\b`),
httpmock.StringResponse(`{"data": {"viewer": {"login": "OWNER"} } }`))
//nolint:staticcheck // SA1019 TODO: rewrite to use run.Stub
cs, cmdTeardown := test.InitCmdStubber()
defer cmdTeardown()
@ -705,16 +755,75 @@ 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 TestPRCreate_webProject(t *testing.T) {
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 RepositoryProjectList\b`),
httpmock.StringResponse(`
{ "data": { "repository": { "projects": {
"nodes": [
{ "name": "Cleanup", "id": "CLEANUPID", "resourcePath": "/OWNER/REPO/projects/1" }
],
"pageInfo": { "hasNextPage": false }
} } } }
`))
http.Register(
httpmock.GraphQL(`query OrganizationProjectList\b`),
httpmock.StringResponse(`
{ "data": { "organization": { "projects": {
"nodes": [
{ "name": "Triage", "id": "TRIAGEID", "resourcePath": "/orgs/ORG/projects/1" }
],
"pageInfo": { "hasNextPage": false }
} } } }
`))
//nolint:staticcheck // SA1019 TODO: rewrite to use run.Stub
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
cs.Stub("") // browser
ask, cleanupAsk := prompt.InitAskStubber()
defer cleanupAsk()
ask.StubOne(0)
output, err := runCommand(http, nil, "feature", true, `--web -p Triage`)
require.NoError(t, err)
assert.Equal(t, "", output.String())
assert.Equal(t, "Opening github.com/OWNER/REPO/compare/master...feature in your browser.\n", output.Stderr())
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
url := strings.ReplaceAll(browserCall[len(browserCall)-1], "^", "")
assert.Equal(t, "https://github.com/OWNER/REPO/compare/master...feature?expand=1&projects=ORG%2F1", url)
}
func Test_determineTrackingBranch_empty(t *testing.T) {
//nolint:staticcheck // SA1019 TODO: rewrite to use run.Stub
cs, cmdTeardown := test.InitCmdStubber()
defer cmdTeardown()
@ -730,6 +839,7 @@ func Test_determineTrackingBranch_empty(t *testing.T) {
}
func Test_determineTrackingBranch_noMatch(t *testing.T) {
//nolint:staticcheck // SA1019 TODO: rewrite to use run.Stub
cs, cmdTeardown := test.InitCmdStubber()
defer cmdTeardown()
@ -755,6 +865,7 @@ deadb00f refs/remotes/origin/feature`) // git show-ref --verify (ShowRefs)
}
func Test_determineTrackingBranch_hasMatch(t *testing.T) {
//nolint:staticcheck // SA1019 TODO: rewrite to use run.Stub
cs, cmdTeardown := test.InitCmdStubber()
defer cmdTeardown()
@ -779,13 +890,14 @@ 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) {
//nolint:staticcheck // SA1019 TODO: rewrite to use run.Stub
cs, cmdTeardown := test.InitCmdStubber()
defer cmdTeardown()
@ -806,7 +918,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) {
@ -820,7 +932,7 @@ func Test_generateCompareURL(t *testing.T) {
{
name: "basic",
ctx: CreateContext{
BaseRepo: ghrepo.New("OWNER", "REPO"),
BaseRepo: api.InitRepoHostname(&api.Repository{Name: "REPO", Owner: api.RepositoryOwner{Login: "OWNER"}}, "github.com"),
BaseBranch: "main",
HeadBranchLabel: "feature",
},
@ -830,7 +942,7 @@ func Test_generateCompareURL(t *testing.T) {
{
name: "with labels",
ctx: CreateContext{
BaseRepo: ghrepo.New("OWNER", "REPO"),
BaseRepo: api.InitRepoHostname(&api.Repository{Name: "REPO", Owner: api.RepositoryOwner{Login: "OWNER"}}, "github.com"),
BaseBranch: "a",
HeadBranchLabel: "b",
},
@ -843,7 +955,7 @@ func Test_generateCompareURL(t *testing.T) {
{
name: "complex branch names",
ctx: CreateContext{
BaseRepo: ghrepo.New("OWNER", "REPO"),
BaseRepo: api.InitRepoHostname(&api.Repository{Name: "REPO", Owner: api.RepositoryOwner{Login: "OWNER"}}, "github.com"),
BaseBranch: "main/trunk",
HeadBranchLabel: "owner:feature",
},

View file

@ -174,10 +174,7 @@ func TestPRDiff_no_current_pr(t *testing.T) {
)
_, 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) {
@ -197,10 +194,7 @@ func TestPRDiff_argument_not_found(t *testing.T) {
)
_, 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) {

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) {
@ -207,6 +200,7 @@ func TestPRList_web(t *testing.T) {
defer http.Verify(t)
var seenCmd *exec.Cmd
//nolint:staticcheck // SA1019 TODO: rewrite to use run.Stub
restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable {
seenCmd = cmd
return &test.OutputStub{}
@ -220,12 +214,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

@ -31,6 +31,8 @@ type MergeOptions struct {
DeleteBranch bool
MergeMethod api.PullRequestMergeMethod
Body *string
IsDeleteBranchIndicated bool
CanDeleteLocalBranch bool
InteractiveMode bool
@ -95,6 +97,11 @@ func NewCmdMerge(f *cmdutil.Factory, runF func(*MergeOptions) error) *cobra.Comm
opts.IsDeleteBranchIndicated = cmd.Flags().Changed("delete-branch")
opts.CanDeleteLocalBranch = !cmd.Flags().Changed("repo")
if cmd.Flags().Changed("body") {
bodyStr, _ := cmd.Flags().GetString("body")
opts.Body = &bodyStr
}
if runF != nil {
return runF(opts)
}
@ -103,6 +110,7 @@ func NewCmdMerge(f *cmdutil.Factory, runF func(*MergeOptions) error) *cobra.Comm
}
cmd.Flags().BoolVarP(&opts.DeleteBranch, "delete-branch", "d", false, "Delete the local and remote branch after merge")
cmd.Flags().StringP("body", "b", "", "Body for merge commit")
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")
@ -124,8 +132,8 @@ 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
fmt.Fprintf(opts.IO.ErrOut, "%s Pull request #%d (%s) has conflicts and isn't mergeable\n", cs.Red("!"), pr.Number, pr.Title)
return cmdutil.SilentError
}
deleteBranch := opts.DeleteBranch
@ -137,17 +145,29 @@ func mergeRun(opts *MergeOptions) error {
mergeMethod := opts.MergeMethod
if opts.InteractiveMode {
mergeMethod, deleteBranch, err = prInteractiveMerge(opts, crossRepoPR)
r, err := api.GitHubRepo(apiClient, baseRepo)
if err != nil {
if errors.Is(err, cancelError) {
fmt.Fprintln(opts.IO.ErrOut, "Cancelled.")
return cmdutil.SilentError
}
return err
}
mergeMethod, err = mergeMethodSurvey(r)
if err != nil {
return err
}
deleteBranch, err = deleteBranchSurvey(opts, crossRepoPR)
if err != nil {
return err
}
confirm, err := confirmSurvey()
if err != nil {
return err
}
if !confirm {
fmt.Fprintln(opts.IO.ErrOut, "Cancelled.")
return cmdutil.SilentError
}
}
err = api.PullRequestMerge(apiClient, baseRepo, pr, mergeMethod)
err = api.PullRequestMerge(apiClient, baseRepo, pr, mergeMethod, opts.Body)
if err != nil {
return err
}
@ -162,7 +182,7 @@ func mergeRun(opts *MergeOptions) error {
}
fmt.Fprintf(opts.IO.ErrOut, "%s %s pull request #%d (%s)\n", cs.SuccessIconOfColor(cs.Magenta), action, pr.Number, pr.Title)
}
} else if !opts.IsDeleteBranchIndicated && opts.InteractiveMode {
} else if !opts.IsDeleteBranchIndicated && opts.InteractiveMode && !crossRepoPR {
err := prompt.SurveyAskOne(&survey.Confirm{
Message: fmt.Sprintf("Pull request #%d was already merged. Delete the branch locally?", pr.Number),
Default: false,
@ -170,15 +190,17 @@ func mergeRun(opts *MergeOptions) error {
if err != nil {
return fmt.Errorf("could not prompt: %w", err)
}
} else if crossRepoPR {
fmt.Fprintf(opts.IO.ErrOut, "%s Pull request #%d was already merged\n", cs.WarningIcon(), pr.Number)
}
if !deleteBranch {
if !deleteBranch || crossRepoPR {
return nil
}
branchSwitchString := ""
if opts.CanDeleteLocalBranch && !crossRepoPR {
if opts.CanDeleteLocalBranch {
currentBranch, err := opts.Branch()
if err != nil {
return err
@ -210,7 +232,7 @@ func mergeRun(opts *MergeOptions) error {
}
}
if !isPRAlreadyMerged && !crossRepoPR {
if !isPRAlreadyMerged {
err = api.BranchDeleteRemote(apiClient, baseRepo, pr.HeadRefName)
var httpErr api.HTTPError
// The ref might have already been deleted by GitHub
@ -227,20 +249,43 @@ func mergeRun(opts *MergeOptions) error {
return nil
}
var cancelError = errors.New("cancelError")
func prInteractiveMerge(opts *MergeOptions, crossRepoPR bool) (api.PullRequestMergeMethod, bool, error) {
mergeMethodQuestion := &survey.Question{
Name: "mergeMethod",
Prompt: &survey.Select{
Message: "What merge method would you like to use?",
Options: []string{"Create a merge commit", "Rebase and merge", "Squash and merge"},
Default: "Create a merge commit",
},
func mergeMethodSurvey(baseRepo *api.Repository) (api.PullRequestMergeMethod, error) {
type mergeOption struct {
title string
method api.PullRequestMergeMethod
}
qs := []*survey.Question{mergeMethodQuestion}
var mergeOpts []mergeOption
if baseRepo.MergeCommitAllowed {
opt := mergeOption{title: "Create a merge commit", method: api.PullRequestMergeMethodMerge}
mergeOpts = append(mergeOpts, opt)
}
if baseRepo.RebaseMergeAllowed {
opt := mergeOption{title: "Rebase and merge", method: api.PullRequestMergeMethodRebase}
mergeOpts = append(mergeOpts, opt)
}
if baseRepo.SquashMergeAllowed {
opt := mergeOption{title: "Squash and merge", method: api.PullRequestMergeMethodSquash}
mergeOpts = append(mergeOpts, opt)
}
var surveyOpts []string
for _, v := range mergeOpts {
surveyOpts = append(surveyOpts, v.title)
}
mergeQuestion := &survey.Select{
Message: "What merge method would you like to use?",
Options: surveyOpts,
Default: "Create a merge commit",
}
var result int
err := prompt.SurveyAskOne(mergeQuestion, &result)
return mergeOpts[result].method, err
}
func deleteBranchSurvey(opts *MergeOptions, crossRepoPR bool) (bool, error) {
if !crossRepoPR && !opts.IsDeleteBranchIndicated {
var message string
if opts.CanDeleteLocalBranch {
@ -249,49 +294,24 @@ func prInteractiveMerge(opts *MergeOptions, crossRepoPR bool) (api.PullRequestMe
message = "Delete the branch on GitHub?"
}
deleteBranchQuestion := &survey.Question{
Name: "deleteBranch",
Prompt: &survey.Confirm{
Message: message,
Default: false,
},
}
qs = append(qs, deleteBranchQuestion)
}
qs = append(qs, &survey.Question{
Name: "isConfirmed",
Prompt: &survey.Confirm{
Message: "Submit?",
var result bool
submit := &survey.Confirm{
Message: message,
Default: false,
},
})
answers := struct {
MergeMethod int
DeleteBranch bool
IsConfirmed bool
}{
DeleteBranch: opts.DeleteBranch,
}
err := prompt.SurveyAskOne(submit, &result)
return result, err
}
err := prompt.SurveyAsk(qs, &answers)
if err != nil {
return 0, false, fmt.Errorf("could not prompt: %w", err)
}
if !answers.IsConfirmed {
return 0, false, cancelError
}
var mergeMethod api.PullRequestMergeMethod
switch answers.MergeMethod {
case 0:
mergeMethod = api.PullRequestMergeMethodMerge
case 1:
mergeMethod = api.PullRequestMergeMethodRebase
case 2:
mergeMethod = api.PullRequestMergeMethodSquash
}
return mergeMethod, answers.DeleteBranch, nil
return opts.DeleteBranch, nil
}
func confirmSurvey() (bool, error) {
var confirm bool
submit := &survey.Confirm{
Message: "Submit?",
Default: true,
}
err := prompt.SurveyAskOne(submit, &confirm)
return confirm, err
}

View file

@ -27,11 +27,12 @@ import (
func Test_NewCmdMerge(t *testing.T) {
tests := []struct {
name string
args string
isTTY bool
want MergeOptions
wantErr string
name string
args string
isTTY bool
want MergeOptions
wantBody string
wantErr string
}{
{
name: "number argument",
@ -59,6 +60,20 @@ func Test_NewCmdMerge(t *testing.T) {
InteractiveMode: true,
},
},
{
name: "body",
args: "123 -bcool",
isTTY: true,
want: MergeOptions{
SelectorArg: "123",
DeleteBranch: false,
IsDeleteBranchIndicated: false,
CanDeleteLocalBranch: true,
MergeMethod: api.PullRequestMergeMethodMerge,
InteractiveMode: true,
},
wantBody: "cool",
},
{
name: "no argument with --repo override",
args: "-R owner/repo",
@ -123,6 +138,12 @@ func Test_NewCmdMerge(t *testing.T) {
assert.Equal(t, tt.want.CanDeleteLocalBranch, opts.CanDeleteLocalBranch)
assert.Equal(t, tt.want.MergeMethod, opts.MergeMethod)
assert.Equal(t, tt.want.InteractiveMode, opts.InteractiveMode)
if tt.wantBody == "" {
assert.Nil(t, opts.Body)
} else {
assert.Equal(t, tt.wantBody, *opts.Body)
}
})
}
}
@ -208,14 +229,8 @@ func TestPrMerge(t *testing.T) {
assert.NotContains(t, input, "commitHeadline")
}))
cs, cmdTeardown := test.InitCmdStubber()
defer cmdTeardown()
cs.Stub("branch.blueberries.remote origin\nbranch.blueberries.merge refs/heads/blueberries") // git config --get-regexp ^branch\.master\.(remote|merge)
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("")
_, cmdTeardown := run.Stub()
defer cmdTeardown(t)
output, err := runCommand(http, "master", true, "pr merge 1 --merge")
if err != nil {
@ -251,14 +266,8 @@ func TestPrMerge_nontty(t *testing.T) {
assert.NotContains(t, input, "commitHeadline")
}))
cs, cmdTeardown := test.InitCmdStubber()
defer cmdTeardown()
cs.Stub("branch.blueberries.remote origin\nbranch.blueberries.merge refs/heads/blueberries") // git config --get-regexp ^branch\.master\.(remote|merge)
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("")
_, cmdTeardown := run.Stub()
defer cmdTeardown(t)
output, err := runCommand(http, "master", false, "pr merge 1 --merge")
if err != nil {
@ -291,16 +300,14 @@ func TestPrMerge_withRepoFlag(t *testing.T) {
assert.NotContains(t, input, "commitHeadline")
}))
cs, cmdTeardown := test.InitCmdStubber()
defer cmdTeardown()
_, cmdTeardown := run.Stub()
defer cmdTeardown(t)
output, err := runCommand(http, "master", true, "pr merge 1 --merge -R OWNER/REPO")
if err != nil {
t.Fatalf("error running command `pr merge`: %v", err)
}
assert.Equal(t, 0, len(cs.Calls))
r := regexp.MustCompile(`Merged pull request #1 \(The title of the PR\)`)
if !r.MatchString(output.Stderr()) {
@ -326,20 +333,20 @@ func TestPrMerge_deleteBranch(t *testing.T) {
httpmock.REST("DELETE", "repos/OWNER/REPO/git/refs/heads/blueberries"),
httpmock.StringResponse(`{}`))
cs, cmdTeardown := test.InitCmdStubber()
defer cmdTeardown()
cs, cmdTeardown := run.Stub()
defer cmdTeardown(t)
cs.Stub("") // git config --get-regexp ^branch\.blueberries\.(remote|merge)$
cs.Stub("") // git checkout master
cs.Stub("") // git rev-parse --verify blueberries`
cs.Stub("") // git branch -d
cs.Stub("") // git push origin --delete blueberries
cs.Register(`git config --get-regexp.+branch\\\.blueberries\\\.`, 0, "")
cs.Register(`git checkout master`, 0, "")
cs.Register(`git rev-parse --verify refs/heads/blueberries`, 0, "")
cs.Register(`git branch -D blueberries`, 0, "")
output, err := runCommand(http, "blueberries", true, `pr merge --merge --delete-branch`)
if err != nil {
t.Fatalf("Got unexpected error running `pr merge` %s", err)
}
//nolint:staticcheck // prefer exact matchers over ExpectLines
test.ExpectLines(t, output.Stderr(), `Merged pull request #10 \(Blueberries are a good fruit\)`, `Deleted branch.*blueberries`)
}
@ -361,19 +368,18 @@ func TestPrMerge_deleteNonCurrentBranch(t *testing.T) {
httpmock.REST("DELETE", "repos/OWNER/REPO/git/refs/heads/blueberries"),
httpmock.StringResponse(`{}`))
cs, cmdTeardown := test.InitCmdStubber()
defer cmdTeardown()
cs, cmdTeardown := run.Stub()
defer cmdTeardown(t)
// We don't expect the default branch to be checked out, just that blueberries is deleted
cs.Stub("") // git rev-parse --verify blueberries
cs.Stub("") // git branch -d blueberries
cs.Stub("") // git push origin --delete blueberries
cs.Register(`git rev-parse --verify refs/heads/blueberries`, 0, "")
cs.Register(`git branch -D blueberries`, 0, "")
output, err := runCommand(http, "master", true, `pr merge --merge --delete-branch blueberries`)
if err != nil {
t.Fatalf("Got unexpected error running `pr merge` %s", err)
}
//nolint:staticcheck // prefer exact matchers over ExpectLines
test.ExpectLines(t, output.Stderr(), `Merged pull request #10 \(Blueberries are a good fruit\)`, `Deleted branch.*blueberries`)
}
@ -392,14 +398,10 @@ func TestPrMerge_noPrNumberGiven(t *testing.T) {
assert.NotContains(t, input, "commitHeadline")
}))
cs, cmdTeardown := test.InitCmdStubber()
defer cmdTeardown()
cs, cmdTeardown := run.Stub()
defer cmdTeardown(t)
cs.Stub("branch.blueberries.remote origin\nbranch.blueberries.merge refs/heads/blueberries") // git config --get-regexp ^branch\.master\.(remote|merge)
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
cs.Register(`git config --get-regexp.+branch\\\.blueberries\\\.`, 0, "")
output, err := runCommand(http, "blueberries", true, "pr merge --merge")
if err != nil {
@ -435,13 +437,8 @@ func TestPrMerge_rebase(t *testing.T) {
assert.NotContains(t, input, "commitHeadline")
}))
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 branch -d
_, cmdTeardown := run.Stub()
defer cmdTeardown(t)
output, err := runCommand(http, "master", true, "pr merge 2 --rebase")
if err != nil {
@ -477,19 +474,15 @@ func TestPrMerge_squash(t *testing.T) {
assert.Equal(t, "The title of the PR (#3)", input["commitHeadline"].(string))
}))
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 branch -d
_, cmdTeardown := run.Stub()
defer cmdTeardown(t)
output, err := runCommand(http, "master", true, "pr merge 3 --squash")
if err != nil {
t.Fatalf("error running command `pr merge`: %v", err)
}
//nolint:staticcheck // prefer exact matchers over ExpectLines
test.ExpectLines(t, output.Stderr(), "Squashed and merged pull request #3")
}
@ -529,6 +522,7 @@ func TestPrMerge_alreadyMerged(t *testing.T) {
t.Fatalf("Got unexpected error running `pr merge` %s", err)
}
//nolint:staticcheck // prefer exact matchers over ExpectLines
test.ExpectLines(t, output.Stderr(), "✓ Deleted branch blueberries and switched to branch master")
}
@ -551,7 +545,7 @@ func TestPrMerge_alreadyMerged_nonInteractive(t *testing.T) {
}
assert.Equal(t, "", output.String())
assert.Equal(t, "", output.Stderr())
assert.Equal(t, "! Pull request #4 was already merged\n", output.Stderr())
}
func TestPRMerge_interactive(t *testing.T) {
@ -566,6 +560,14 @@ func TestPRMerge_interactive(t *testing.T) {
"id": "THE-ID",
"number": 3
}] } } } }`))
http.Register(
httpmock.GraphQL(`query RepositoryInfo\b`),
httpmock.StringResponse(`
{ "data": { "repository": {
"mergeCommitAllowed": true,
"rebaseMergeAllowed": true,
"squashMergeAllowed": true
} } }`))
http.Register(
httpmock.GraphQL(`mutation PullRequestMerge\b`),
httpmock.GraphQLMutation(`{}`, func(input map[string]interface{}) {
@ -577,38 +579,27 @@ func TestPRMerge_interactive(t *testing.T) {
httpmock.REST("DELETE", "repos/OWNER/REPO/git/refs/heads/blueberries"),
httpmock.StringResponse(`{}`))
cs, cmdTeardown := test.InitCmdStubber()
defer cmdTeardown()
cs, 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 push origin --delete blueberries
cs.Stub("") // git branch -d
cs.Register(`git config --get-regexp.+branch\\\.blueberries\\\.`, 0, "")
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.Stub([]*prompt.QuestionStub{
{
Name: "mergeMethod",
Value: 0,
},
{
Name: "deleteBranch",
Value: true,
},
{
Name: "isConfirmed",
Value: true,
},
})
as.StubOne(0) // Merge method survey
as.StubOne(true) // Delete branch survey
as.StubOne(true) // Confirm submit survey
output, err := runCommand(http, "blueberries", true, "")
if err != nil {
t.Fatalf("Got unexpected error running `pr merge` %s", err)
}
//nolint:staticcheck // prefer exact matchers over ExpectLines
test.ExpectLines(t, output.Stderr(), "Merged pull request #3")
}
@ -624,6 +615,14 @@ func TestPRMerge_interactiveWithDeleteBranch(t *testing.T) {
"id": "THE-ID",
"number": 3
}] } } } }`))
http.Register(
httpmock.GraphQL(`query RepositoryInfo\b`),
httpmock.StringResponse(`
{ "data": { "repository": {
"mergeCommitAllowed": true,
"rebaseMergeAllowed": true,
"squashMergeAllowed": true
} } }`))
http.Register(
httpmock.GraphQL(`mutation PullRequestMerge\b`),
httpmock.GraphQLMutation(`{}`, func(input map[string]interface{}) {
@ -635,34 +634,26 @@ func TestPRMerge_interactiveWithDeleteBranch(t *testing.T) {
httpmock.REST("DELETE", "repos/OWNER/REPO/git/refs/heads/blueberries"),
httpmock.StringResponse(`{}`))
cs, cmdTeardown := test.InitCmdStubber()
defer cmdTeardown()
cs, 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 push origin --delete blueberries
cs.Stub("") // git branch -d
cs.Register(`git config --get-regexp.+branch\\\.blueberries\\\.`, 0, "")
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.Stub([]*prompt.QuestionStub{
{
Name: "mergeMethod",
Value: 0,
},
{
Name: "isConfirmed",
Value: true,
},
})
as.StubOne(0) // Merge method survey
as.StubOne(true) // Confirm submit survey
output, err := runCommand(http, "blueberries", true, "-d")
if err != nil {
t.Fatalf("Got unexpected error running `pr merge` %s", err)
}
//nolint:staticcheck // prefer exact matchers over ExpectLines
test.ExpectLines(t, output.Stderr(), "Merged pull request #3", "Deleted branch blueberries and switched to branch master")
}
@ -678,33 +669,26 @@ func TestPRMerge_interactiveCancelled(t *testing.T) {
"id": "THE-ID",
"number": 3
}] } } } }`))
http.Register(
httpmock.GraphQL(`query RepositoryInfo\b`),
httpmock.StringResponse(`
{ "data": { "repository": {
"mergeCommitAllowed": true,
"rebaseMergeAllowed": true,
"squashMergeAllowed": true
} } }`))
cs, cmdTeardown := test.InitCmdStubber()
defer cmdTeardown()
cs, 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 push origin --delete blueberries
cs.Stub("") // git branch -d
cs.Register(`git config --get-regexp.+branch\\\.blueberries\\\.`, 0, "")
as, surveyTeardown := prompt.InitAskStubber()
defer surveyTeardown()
as.Stub([]*prompt.QuestionStub{
{
Name: "mergeMethod",
Value: 0,
},
{
Name: "deleteBranch",
Value: true,
},
{
Name: "isConfirmed",
Value: false,
},
})
as.StubOne(0) // Merge method survey
as.StubOne(true) // Delete branch survey
as.StubOne(false) // Confirm submit survey
output, err := runCommand(http, "blueberries", true, "")
if !errors.Is(err, cmdutil.SilentError) {
@ -713,3 +697,17 @@ func TestPRMerge_interactiveCancelled(t *testing.T) {
assert.Equal(t, "Cancelled.\n", output.Stderr())
}
func Test_mergeMethodSurvey(t *testing.T) {
repo := &api.Repository{
MergeCommitAllowed: false,
RebaseMergeAllowed: true,
SquashMergeAllowed: true,
}
as, surveyTeardown := prompt.InitAskStubber()
defer surveyTeardown()
as.StubOne(0) // Select first option which is rebase merge
method, err := mergeMethodSurvey(repo)
assert.Nil(t, err)
assert.Equal(t, api.PullRequestMergeMethodRebase, method)
}

View file

@ -5,6 +5,7 @@ import (
cmdCheckout "github.com/cli/cli/pkg/cmd/pr/checkout"
cmdChecks "github.com/cli/cli/pkg/cmd/pr/checks"
cmdClose "github.com/cli/cli/pkg/cmd/pr/close"
cmdComment "github.com/cli/cli/pkg/cmd/pr/comment"
cmdCreate "github.com/cli/cli/pkg/cmd/pr/create"
cmdDiff "github.com/cli/cli/pkg/cmd/pr/diff"
cmdList "github.com/cli/cli/pkg/cmd/pr/list"
@ -53,6 +54,7 @@ func NewCmdPR(f *cmdutil.Factory) *cobra.Command {
cmd.AddCommand(cmdStatus.NewCmdStatus(f, nil))
cmd.AddCommand(cmdView.NewCmdView(f, nil))
cmd.AddCommand(cmdChecks.NewCmdChecks(f, nil))
cmd.AddCommand(cmdComment.NewCmdComment(f, nil))
return cmd
}

View file

@ -60,13 +60,13 @@ func NewCmdReview(f *cmdutil.Factory, runF func(*ReviewOptions) error) *cobra.Co
Example: heredoc.Doc(`
# approve the pull request of the current branch
$ gh pr review --approve
# leave a review comment for the current branch
$ gh pr review --comment -b "interesting"
# add a review for a specific pull request
$ gh pr review 123
# request changes on a specific pull request
$ gh pr review 123 -r -b "needs more ASCII art"
`),

View file

@ -218,6 +218,7 @@ func TestPRReview_url_arg(t *testing.T) {
t.Fatalf("error running pr review: %s", err)
}
//nolint:staticcheck // prefer exact matchers over ExpectLines
test.ExpectLines(t, output.Stderr(), "Approved pull request #123")
}
@ -260,6 +261,7 @@ func TestPRReview_number_arg(t *testing.T) {
t.Fatalf("error running pr review: %s", err)
}
//nolint:staticcheck // prefer exact matchers over ExpectLines
test.ExpectLines(t, output.Stderr(), "Approved pull request #123")
}
@ -293,6 +295,7 @@ func TestPRReview_no_arg(t *testing.T) {
t.Fatalf("error running pr review: %s", err)
}
//nolint:staticcheck // prefer exact matchers over ExpectLines
test.ExpectLines(t, output.Stderr(), "Reviewed pull request #123")
}
@ -425,8 +428,10 @@ func TestPRReview_interactive(t *testing.T) {
t.Fatalf("got unexpected error running pr review: %s", err)
}
//nolint:staticcheck // prefer exact matchers over ExpectLines
test.ExpectLines(t, output.Stderr(), "Approved pull request #123")
//nolint:staticcheck // prefer exact matchers over ExpectLines
test.ExpectLines(t, output.String(),
"Got:",
"cool.*story")
@ -470,10 +475,7 @@ func TestPRReview_interactive_no_body(t *testing.T) {
})
_, err := runCommand(http, nil, true, "")
if err == nil {
t.Fatal("expected error")
}
assert.Equal(t, "this type of review cannot be blank", err.Error())
assert.EqualError(t, err, "this type of review cannot be blank")
}
func TestPRReview_interactive_blank_approve(t *testing.T) {
@ -532,5 +534,6 @@ func TestPRReview_interactive_blank_approve(t *testing.T) {
t.Errorf("did not expect to see body printed in %s", output.String())
}
//nolint:staticcheck // prefer exact matchers over ExpectLines
test.ExpectLines(t, output.Stderr(), "Approved pull request #123")
}

View file

@ -0,0 +1,168 @@
package shared
import (
"bufio"
"errors"
"fmt"
"io"
"net/http"
"github.com/AlecAivazis/survey/v2"
"github.com/cli/cli/api"
"github.com/cli/cli/internal/config"
"github.com/cli/cli/internal/ghrepo"
"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 InputType int
const (
InputTypeEditor InputType = iota
InputTypeInline
InputTypeWeb
)
type Commentable interface {
Link() string
Identifier() string
}
type CommentableOptions struct {
IO *iostreams.IOStreams
HttpClient func() (*http.Client, error)
RetrieveCommentable func() (Commentable, ghrepo.Interface, error)
EditSurvey func() (string, error)
InteractiveEditSurvey func() (string, error)
ConfirmSubmitSurvey func() (bool, error)
OpenInBrowser func(string) error
Interactive bool
InputType InputType
Body string
}
func CommentablePreRun(cmd *cobra.Command, opts *CommentableOptions) error {
inputFlags := 0
if cmd.Flags().Changed("body") {
opts.InputType = InputTypeInline
inputFlags++
}
if web, _ := cmd.Flags().GetBool("web"); web {
opts.InputType = InputTypeWeb
inputFlags++
}
if editor, _ := cmd.Flags().GetBool("editor"); editor {
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")}
}
return nil
}
func CommentableRun(opts *CommentableOptions) error {
commentable, repo, err := opts.RetrieveCommentable()
if err != nil {
return err
}
switch opts.InputType {
case InputTypeWeb:
openURL := commentable.Link() + "#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:
var body string
if opts.Interactive {
body, err = opts.InteractiveEditSurvey()
} else {
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 errors.New("Discarding...")
}
}
httpClient, err := opts.HttpClient()
if err != nil {
return err
}
apiClient := api.NewClientFromHTTP(httpClient)
params := api.CommentCreateInput{Body: opts.Body, SubjectId: commentable.Identifier()}
url, err := api.CommentCreate(apiClient, repo.RepoHost(), params)
if err != nil {
return err
}
fmt.Fprintln(opts.IO.Out, url)
return nil
}
func CommentableConfirmSubmitSurvey() (bool, error) {
var confirm bool
submit := &survey.Confirm{
Message: "Submit?",
Default: true,
}
err := survey.AskOne(submit, &confirm)
return confirm, err
}
func CommentableInteractiveEditSurvey(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
}
if editorCommand == "" {
editorCommand = surveyext.DefaultEditorName()
}
cs := io.ColorScheme()
fmt.Fprintf(io.Out, "- %s to draft your comment in %s... ", cs.Bold("Press Enter"), cs.Bold(editorCommand))
_ = waitForEnter(io.In)
return surveyext.Edit(editorCommand, "*.md", "", io.In, io.Out, io.ErrOut, nil)
}
}
func CommentableEditSurvey(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)
}
}
func waitForEnter(r io.Reader) error {
scanner := bufio.NewScanner(r)
scanner.Scan()
return scanner.Err()
}

View file

@ -2,6 +2,7 @@ package shared
import (
"fmt"
"sort"
"strings"
"time"
@ -11,37 +12,55 @@ import (
"github.com/cli/cli/utils"
)
func RawCommentList(comments api.Comments) string {
type Comment interface {
AuthorLogin() string
Association() string
Content() string
Created() time.Time
IsEdited() bool
Link() string
Reactions() api.ReactionGroups
Status() string
}
func RawCommentList(comments api.Comments, reviews api.PullRequestReviews) string {
sortedComments := sortComments(comments, reviews)
var b strings.Builder
for _, comment := range comments.Nodes {
for _, comment := range sortedComments {
fmt.Fprint(&b, formatRawComment(comment))
}
return b.String()
}
func formatRawComment(comment api.Comment) string {
func formatRawComment(comment Comment) string {
var b strings.Builder
fmt.Fprintf(&b, "author:\t%s\n", comment.Author.Login)
fmt.Fprintf(&b, "association:\t%s\n", strings.ToLower(comment.AuthorAssociation))
fmt.Fprintf(&b, "edited:\t%t\n", comment.IncludesCreatedEdit)
fmt.Fprintf(&b, "author:\t%s\n", comment.AuthorLogin())
fmt.Fprintf(&b, "association:\t%s\n", strings.ToLower(comment.Association()))
fmt.Fprintf(&b, "edited:\t%t\n", comment.IsEdited())
fmt.Fprintf(&b, "status:\t%s\n", formatRawCommentStatus(comment.Status()))
fmt.Fprintln(&b, "--")
fmt.Fprintln(&b, comment.Body)
fmt.Fprintln(&b, comment.Content())
fmt.Fprintln(&b, "--")
return b.String()
}
func CommentList(io *iostreams.IOStreams, comments api.Comments) (string, error) {
func CommentList(io *iostreams.IOStreams, comments api.Comments, reviews api.PullRequestReviews, preview bool) (string, error) {
sortedComments := sortComments(comments, reviews)
if preview && len(sortedComments) > 0 {
sortedComments = sortedComments[len(sortedComments)-1:]
}
var b strings.Builder
cs := io.ColorScheme()
retrievedCount := len(comments.Nodes)
hiddenCount := comments.TotalCount - retrievedCount
totalCount := comments.TotalCount + reviews.TotalCount
retrievedCount := len(sortedComments)
hiddenCount := totalCount - retrievedCount
if hiddenCount > 0 {
fmt.Fprint(&b, cs.Gray(fmt.Sprintf("———————— Not showing %s ————————", utils.Pluralize(hiddenCount, "comment"))))
fmt.Fprintf(&b, "\n\n\n")
}
for i, comment := range comments.Nodes {
for i, comment := range sortedComments {
last := i+1 == retrievedCount
cmt, err := formatComment(io, comment, last)
if err != nil {
@ -61,18 +80,21 @@ func CommentList(io *iostreams.IOStreams, comments api.Comments) (string, error)
return b.String(), nil
}
func formatComment(io *iostreams.IOStreams, comment api.Comment, newest bool) (string, error) {
func formatComment(io *iostreams.IOStreams, comment Comment, newest bool) (string, error) {
var b strings.Builder
cs := io.ColorScheme()
// Header
fmt.Fprint(&b, cs.Bold(comment.Author.Login))
if comment.AuthorAssociation != "NONE" {
fmt.Fprint(&b, cs.Bold(fmt.Sprintf(" (%s)", strings.ToLower(comment.AuthorAssociation))))
fmt.Fprint(&b, cs.Bold(comment.AuthorLogin()))
if comment.Status() != "" {
fmt.Fprint(&b, formatCommentStatus(cs, comment.Status()))
}
fmt.Fprint(&b, cs.Bold(fmt.Sprintf(" • %s", utils.FuzzyAgoAbbr(time.Now(), comment.CreatedAt))))
if comment.IncludesCreatedEdit {
fmt.Fprint(&b, cs.Bold(" • edited"))
if comment.Association() != "NONE" {
fmt.Fprint(&b, cs.Bold(fmt.Sprintf(" (%s)", strings.Title(strings.ToLower(comment.Association())))))
}
fmt.Fprint(&b, cs.Bold(fmt.Sprintf(" • %s", utils.FuzzyAgoAbbr(time.Now(), comment.Created()))))
if comment.IsEdited() {
fmt.Fprint(&b, cs.Bold(" • Edited"))
}
if newest {
fmt.Fprint(&b, cs.Bold(" • "))
@ -81,20 +103,82 @@ func formatComment(io *iostreams.IOStreams, comment api.Comment, newest bool) (s
fmt.Fprintln(&b)
// Reactions
if reactions := ReactionGroupList(comment.ReactionGroups); reactions != "" {
if reactions := ReactionGroupList(comment.Reactions()); reactions != "" {
fmt.Fprint(&b, reactions)
fmt.Fprintln(&b)
}
// Body
if comment.Body != "" {
var md string
var err error
if comment.Content() == "" {
md = fmt.Sprintf("\n %s\n\n", cs.Gray("No body provided"))
} else {
style := markdown.GetStyle(io.TerminalTheme())
md, err := markdown.Render(comment.Body, style, "")
md, err = markdown.Render(comment.Content(), style, "")
if err != nil {
return "", err
}
fmt.Fprint(&b, md)
}
fmt.Fprint(&b, md)
// Footer
if comment.Link() != "" {
fmt.Fprintf(&b, cs.Gray("View the full review: %s\n\n"), comment.Link())
}
return b.String(), nil
}
func sortComments(cs api.Comments, rs api.PullRequestReviews) []Comment {
comments := cs.Nodes
reviews := rs.Nodes
var sorted []Comment = make([]Comment, len(comments)+len(reviews))
var i int
for _, c := range comments {
sorted[i] = c
i++
}
for _, r := range reviews {
sorted[i] = r
i++
}
sort.Slice(sorted, func(i, j int) bool {
return sorted[i].Created().Before(sorted[j].Created())
})
return sorted
}
const (
approvedStatus = "APPROVED"
changesRequestedStatus = "CHANGES_REQUESTED"
commentedStatus = "COMMENTED"
dismissedStatus = "DISMISSED"
)
func formatCommentStatus(cs *iostreams.ColorScheme, status string) string {
switch status {
case approvedStatus:
return fmt.Sprintf(" %s", cs.Green("approved"))
case changesRequestedStatus:
return fmt.Sprintf(" %s", cs.Red("requested changes"))
case commentedStatus, dismissedStatus:
return fmt.Sprintf(" %s", strings.ToLower(status))
}
return ""
}
func formatRawCommentStatus(status string) string {
if status == approvedStatus ||
status == changesRequestedStatus ||
status == commentedStatus ||
status == dismissedStatus {
return strings.ReplaceAll(strings.ToLower(status), "_", " ")
}
return "none"
}

View file

@ -81,8 +81,7 @@ func AddMetadataToIssueParams(client *api.Client, baseRepo ghrepo.Interface, par
return nil
}
err := fillMetadata(client, baseRepo, tb)
if err != nil {
if err := fillMetadata(client, baseRepo, tb); err != nil {
return err
}
@ -191,3 +190,48 @@ func quoteValueForQuery(v string) string {
}
return v
}
// MeReplacer resolves usages of `@me` to the handle of the currently logged in user.
type MeReplacer struct {
apiClient *api.Client
hostname string
login string
}
func NewMeReplacer(apiClient *api.Client, hostname string) *MeReplacer {
return &MeReplacer{
apiClient: apiClient,
hostname: hostname,
}
}
func (r *MeReplacer) currentLogin() (string, error) {
if r.login != "" {
return r.login, nil
}
login, err := api.CurrentLoginName(r.apiClient, r.hostname)
if err != nil {
return "", fmt.Errorf("failed resolving `@me` to your user handle: %w", err)
}
r.login = login
return login, nil
}
func (r *MeReplacer) Replace(handle string) (string, error) {
if handle == "@me" {
return r.currentLogin()
}
return handle, nil
}
func (r *MeReplacer) ReplaceSlice(handles []string) ([]string, error) {
res := make([]string, len(handles))
for i, h := range handles {
var err error
res[i], err = r.Replace(h)
if err != nil {
return nil, err
}
}
return res, nil
}

View file

@ -1,6 +1,14 @@
package shared
import "testing"
import (
"net/http"
"reflect"
"testing"
"github.com/cli/cli/api"
"github.com/cli/cli/internal/ghrepo"
"github.com/cli/cli/pkg/httpmock"
)
func Test_listURLWithQuery(t *testing.T) {
type args struct {
@ -69,3 +77,77 @@ func Test_listURLWithQuery(t *testing.T) {
})
}
}
func TestMeReplacer_Replace(t *testing.T) {
rtSuccess := &httpmock.Registry{}
rtSuccess.Register(
httpmock.GraphQL(`query UserCurrent\b`),
httpmock.StringResponse(`
{ "data": {
"viewer": { "login": "ResolvedLogin" }
} }
`),
)
rtFailure := &httpmock.Registry{}
rtFailure.Register(
httpmock.GraphQL(`query UserCurrent\b`),
httpmock.StatusStringResponse(500, `
{ "data": {
"viewer": { }
} }
`),
)
type args struct {
logins []string
client *api.Client
repo ghrepo.Interface
}
tests := []struct {
name string
args args
verify func(t httpmock.Testing)
want []string
wantErr bool
}{
{
name: "succeeds resolving the userlogin",
args: args{
client: api.NewClientFromHTTP(&http.Client{Transport: rtSuccess}),
repo: ghrepo.New("OWNER", "REPO"),
logins: []string{"some", "@me", "other"},
},
verify: rtSuccess.Verify,
want: []string{"some", "ResolvedLogin", "other"},
},
{
name: "fails resolving the userlogin",
args: args{
client: api.NewClientFromHTTP(&http.Client{Transport: rtFailure}),
repo: ghrepo.New("OWNER", "REPO"),
logins: []string{"some", "@me", "other"},
},
verify: rtFailure.Verify,
want: []string(nil),
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
me := NewMeReplacer(tt.args.client, tt.args.repo.RepoHost())
got, err := me.ReplaceSlice(tt.args.logins)
if (err != nil) != tt.wantErr {
t.Errorf("ReplaceAtMeLogin() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("ReplaceAtMeLogin() = %v, want %v", got, tt.want)
}
if tt.verify != nil {
tt.verify(t)
}
})
}
}

View file

@ -98,6 +98,7 @@ func Test_PreserveInput(t *testing.T) {
assert.NoError(t, err)
if tt.wantPreservation {
//nolint:staticcheck // prefer exact matchers over ExpectLines
test.ExpectLines(t, errOut.String(), tt.wantErrLine)
preserved := &IssueMetadataState{}
assert.NoError(t, json.Unmarshal(data, preserved))

View file

@ -69,7 +69,7 @@
},
"authorAssociation": "CONTRIBUTOR",
"body": "Comment 2",
"createdAt": "2020-01-01T12:00:00Z",
"createdAt": "2020-01-03T12:00:00Z",
"includesCreatedEdit": false,
"reactionGroups": [
{
@ -128,7 +128,7 @@
},
"authorAssociation": "MEMBER",
"body": "Comment 3",
"createdAt": "2020-01-01T12:00:00Z",
"createdAt": "2020-01-05T12:00:00Z",
"includesCreatedEdit": false,
"reactionGroups": [
{
@ -187,7 +187,7 @@
},
"authorAssociation": "OWNER",
"body": "Comment 4",
"createdAt": "2020-01-01T12:00:00Z",
"createdAt": "2020-01-07T12:00:00Z",
"includesCreatedEdit": false,
"reactionGroups": [
{
@ -246,7 +246,7 @@
},
"authorAssociation": "COLLABORATOR",
"body": "Comment 5",
"createdAt": "2020-01-01T12:00:00Z",
"createdAt": "2020-01-09T12:00:00Z",
"includesCreatedEdit": false,
"reactionGroups": [
{

View file

@ -0,0 +1,67 @@
{
"data": {
"repository": {
"pullRequest": {
"reviews": {
"nodes": [
{
"author": {
"login": "123"
},
"state": "COMMENTED"
},
{
"author": {
"login": "def"
},
"state": "CHANGES_REQUESTED"
},
{
"author": {
"login": "abc"
},
"state": "APPROVED"
},
{
"author": {
"login": "DEF"
},
"state": "COMMENTED"
},
{
"author": {
"login": "xyz"
},
"state": "APPROVED"
},
{
"author": {
"login": ""
},
"state": "APPROVED"
},
{
"author": {
"login": "hubot"
},
"state": "CHANGES_REQUESTED"
},
{
"author": {
"login": "hubot"
},
"state": "DISMISSED"
},
{
"author": {
"login": "monalisa"
},
"state": "PENDING"
}
],
"totalCount": 9
}
}
}
}
}

View file

@ -0,0 +1 @@
{ "data": { "repository": { "pullRequest": { "reviews": { } } } } }

View file

@ -0,0 +1,318 @@
{
"data": {
"repository": {
"pullRequest": {
"reviews": {
"nodes": [
{
"author": {
"login": "sam"
},
"authorAssociation": "NONE",
"body": "Review 1",
"createdAt": "2020-01-02T12:00:00Z",
"includesCreatedEdit": false,
"reactionGroups": [
{
"content": "CONFUSED",
"users": {
"totalCount": 0
}
},
{
"content": "EYES",
"users": {
"totalCount": 0
}
},
{
"content": "HEART",
"users": {
"totalCount": 0
}
},
{
"content": "HOORAY",
"users": {
"totalCount": 0
}
},
{
"content": "LAUGH",
"users": {
"totalCount": 0
}
},
{
"content": "ROCKET",
"users": {
"totalCount": 0
}
},
{
"content": "THUMBS_DOWN",
"users": {
"totalCount": 1
}
},
{
"content": "THUMBS_UP",
"users": {
"totalCount": 1
}
}
],
"state": "COMMENTED",
"url": "https://github.com/OWNER/REPO/pull/12#pullrequestreview-1"
},
{
"author": {
"login": "matt"
},
"authorAssociation": "OWNER",
"body": "Review 2",
"createdAt": "2020-01-04T12:00:00Z",
"includesCreatedEdit": false,
"reactionGroups": [
{
"content": "CONFUSED",
"users": {
"totalCount": 1
}
},
{
"content": "EYES",
"users": {
"totalCount": 1
}
},
{
"content": "HEART",
"users": {
"totalCount": 0
}
},
{
"content": "HOORAY",
"users": {
"totalCount": 0
}
},
{
"content": "LAUGH",
"users": {
"totalCount": 0
}
},
{
"content": "ROCKET",
"users": {
"totalCount": 0
}
},
{
"content": "THUMBS_DOWN",
"users": {
"totalCount": 0
}
},
{
"content": "THUMBS_UP",
"users": {
"totalCount": 0
}
}
],
"state": "CHANGES_REQUESTED",
"url": "https://github.com/OWNER/REPO/pull/12#pullrequestreview-2"
},
{
"author": {
"login": "leah"
},
"authorAssociation": "MEMBER",
"body": "Review 3",
"createdAt": "2020-01-06T12:00:00Z",
"includesCreatedEdit": true,
"reactionGroups": [
{
"content": "CONFUSED",
"users": {
"totalCount": 0
}
},
{
"content": "EYES",
"users": {
"totalCount": 0
}
},
{
"content": "HEART",
"users": {
"totalCount": 0
}
},
{
"content": "HOORAY",
"users": {
"totalCount": 0
}
},
{
"content": "LAUGH",
"users": {
"totalCount": 0
}
},
{
"content": "ROCKET",
"users": {
"totalCount": 0
}
},
{
"content": "THUMBS_DOWN",
"users": {
"totalCount": 0
}
},
{
"content": "THUMBS_UP",
"users": {
"totalCount": 0
}
}
],
"state": "APPROVED",
"url": "https://github.com/OWNER/REPO/pull/12#pullrequestreview-3"
},
{
"author": {
"login": "louise"
},
"authorAssociation": "NONE",
"body": "Review 4",
"createdAt": "2020-01-08T12:00:00Z",
"includesCreatedEdit": false,
"reactionGroups": [
{
"content": "CONFUSED",
"users": {
"totalCount": 0
}
},
{
"content": "EYES",
"users": {
"totalCount": 0
}
},
{
"content": "HEART",
"users": {
"totalCount": 0
}
},
{
"content": "HOORAY",
"users": {
"totalCount": 0
}
},
{
"content": "LAUGH",
"users": {
"totalCount": 0
}
},
{
"content": "ROCKET",
"users": {
"totalCount": 0
}
},
{
"content": "THUMBS_DOWN",
"users": {
"totalCount": 0
}
},
{
"content": "THUMBS_UP",
"users": {
"totalCount": 0
}
}
],
"state": "DISMISSED",
"url": "https://github.com/OWNER/REPO/pull/12#pullrequestreview-4"
},
{
"author": {
"login": "david"
},
"authorAssociation": "NONE",
"body": "Review 5",
"createdAt": "2020-01-10T12:00:00Z",
"includesCreatedEdit": false,
"reactionGroups": [
{
"content": "CONFUSED",
"users": {
"totalCount": 0
}
},
{
"content": "EYES",
"users": {
"totalCount": 0
}
},
{
"content": "HEART",
"users": {
"totalCount": 0
}
},
{
"content": "HOORAY",
"users": {
"totalCount": 0
}
},
{
"content": "LAUGH",
"users": {
"totalCount": 0
}
},
{
"content": "ROCKET",
"users": {
"totalCount": 0
}
},
{
"content": "THUMBS_DOWN",
"users": {
"totalCount": 0
}
},
{
"content": "THUMBS_UP",
"users": {
"totalCount": 0
}
}
],
"state": "PENDING",
"url": "https://github.com/OWNER/REPO/pull/12#pullrequestreview-5"
}
],
"totalCount": 5
}
}
}
}
}

View file

@ -93,7 +93,7 @@
},
"authorAssociation": "COLLABORATOR",
"body": "Comment 5",
"createdAt": "2020-01-01T12:00:00Z",
"createdAt": "2020-01-09T12:00:00Z",
"includesCreatedEdit": false,
"reactionGroups": [
{

View file

@ -21,28 +21,6 @@
],
"totalcount": 1
},
"reviews": {
"nodes": [
{
"author": {
"login": "3"
},
"state": "COMMENTED"
},
{
"author": {
"login": "2"
},
"state": "APPROVED"
},
{
"author": {
"login": "1"
},
"state": "CHANGES_REQUESTED"
}
]
},
"assignees": {
"nodes": [
{

View file

@ -33,64 +33,6 @@
],
"totalcount": 1
},
"reviews": {
"nodes": [
{
"author": {
"login": "123"
},
"state": "COMMENTED"
},
{
"author": {
"login": "def"
},
"state": "CHANGES_REQUESTED"
},
{
"author": {
"login": "abc"
},
"state": "APPROVED"
},
{
"author": {
"login": "DEF"
},
"state": "COMMENTED"
},
{
"author": {
"login": "xyz"
},
"state": "APPROVED"
},
{
"author": {
"login": ""
},
"state": "APPROVED"
},
{
"author": {
"login": "hubot"
},
"state": "CHANGES_REQUESTED"
},
{
"author": {
"login": "hubot"
},
"state": "DISMISSED"
},
{
"author": {
"login": "monalisa"
},
"state": "PENDING"
}
]
},
"assignees": {
"nodes": [],
"totalcount": 0

View file

@ -6,6 +6,7 @@ import (
"net/http"
"sort"
"strings"
"sync"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/api"
@ -80,13 +81,9 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman
}
func viewRun(opts *ViewOptions) error {
httpClient, err := opts.HttpClient()
if err != nil {
return err
}
apiClient := api.NewClientFromHTTP(httpClient)
pr, repo, err := shared.PRFromArgs(apiClient, opts.BaseRepo, opts.Branch, opts.Remotes, opts.SelectorArg)
opts.IO.StartProgressIndicator()
pr, err := retrievePullRequest(opts)
opts.IO.StopProgressIndicator()
if err != nil {
return err
}
@ -101,16 +98,6 @@ func viewRun(opts *ViewOptions) error {
return utils.OpenInBrowser(openURL)
}
if opts.Comments {
opts.IO.StartProgressIndicator()
comments, err := api.CommentsForPullRequest(apiClient, repo, pr)
opts.IO.StopProgressIndicator()
if err != nil {
return err
}
pr.Comments = *comments
}
opts.IO.DetectTerminalTheme()
err = opts.IO.StartPager()
@ -120,11 +107,11 @@ func viewRun(opts *ViewOptions) error {
defer opts.IO.StopPager()
if connectedToTerminal {
return printHumanPrPreview(opts.IO, pr)
return printHumanPrPreview(opts, pr)
}
if opts.Comments {
fmt.Fprint(opts.IO.Out, shared.RawCommentList(pr.Comments))
fmt.Fprint(opts.IO.Out, shared.RawCommentList(pr.Comments, pr.Reviews))
return nil
}
@ -157,9 +144,9 @@ func printRawPrPreview(io *iostreams.IOStreams, pr *api.PullRequest) error {
return nil
}
func printHumanPrPreview(io *iostreams.IOStreams, pr *api.PullRequest) error {
out := io.Out
cs := io.ColorScheme()
func printHumanPrPreview(opts *ViewOptions, pr *api.PullRequest) error {
out := opts.IO.Out
cs := opts.IO.ColorScheme()
// Header (Title and State)
fmt.Fprintln(out, cs.Bold(pr.Title))
@ -201,21 +188,23 @@ func printHumanPrPreview(io *iostreams.IOStreams, pr *api.PullRequest) error {
}
// Body
fmt.Fprintln(out)
var md string
var err error
if pr.Body == "" {
pr.Body = "_No description provided_"
md = fmt.Sprintf("\n %s\n\n", cs.Gray("No description provided"))
} else {
style := markdown.GetStyle(opts.IO.TerminalTheme())
md, err = markdown.Render(pr.Body, style, "")
if err != nil {
return err
}
}
style := markdown.GetStyle(io.TerminalTheme())
md, err := markdown.Render(pr.Body, style, "")
if err != nil {
return err
}
fmt.Fprint(out, md)
fmt.Fprintln(out)
fmt.Fprintf(out, "\n%s\n", md)
// Comments
if pr.Comments.TotalCount > 0 {
comments, err := shared.CommentList(io, pr.Comments)
// Reviews and Comments
if pr.Comments.TotalCount > 0 || pr.Reviews.TotalCount > 0 {
preview := !opts.Comments
comments, err := shared.CommentList(opts.IO, pr.Comments, pr.DisplayableReviews(), preview)
if err != nil {
return err
}
@ -405,3 +394,51 @@ func prStateWithDraft(pr *api.PullRequest) string {
return pr.State
}
func retrievePullRequest(opts *ViewOptions) (*api.PullRequest, error) {
httpClient, err := opts.HttpClient()
if err != nil {
return nil, err
}
apiClient := api.NewClientFromHTTP(httpClient)
pr, repo, err := shared.PRFromArgs(apiClient, opts.BaseRepo, opts.Branch, opts.Remotes, opts.SelectorArg)
if err != nil {
return nil, err
}
if opts.BrowserMode {
return pr, nil
}
var errp, errc error
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
var reviews *api.PullRequestReviews
reviews, errp = api.ReviewsForPullRequest(apiClient, repo, pr)
pr.Reviews = *reviews
}()
if opts.Comments {
wg.Add(1)
go func() {
defer wg.Done()
var comments *api.Comments
comments, errc = api.CommentsForPullRequest(apiClient, repo, pr)
pr.Comments = *comments
}()
}
wg.Wait()
if errp != nil {
err = errp
}
if errc != nil {
err = errc
}
return pr, err
}

View file

@ -9,7 +9,6 @@ import (
"strings"
"testing"
"github.com/briandowns/spinner"
"github.com/cli/cli/context"
"github.com/cli/cli/git"
"github.com/cli/cli/internal/config"
@ -19,7 +18,6 @@ import (
"github.com/cli/cli/pkg/httpmock"
"github.com/cli/cli/pkg/iostreams"
"github.com/cli/cli/test"
"github.com/cli/cli/utils"
"github.com/google/shlex"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@ -168,13 +166,16 @@ func TestPRView_Preview_nontty(t *testing.T) {
tests := map[string]struct {
branch string
args string
fixture string
fixtures map[string]string
expectedOutputs []string
}{
"Open PR without metadata": {
branch: "master",
args: "12",
fixture: "./fixtures/prViewPreview.json",
branch: "master",
args: "12",
fixtures: map[string]string{
"PullRequestByNumber": "./fixtures/prViewPreview.json",
"ReviewsForPullRequest": "./fixtures/prViewPreviewNoReviews.json",
},
expectedOutputs: []string{
`title:\tBlueberries are from a fork\n`,
`state:\tOPEN\n`,
@ -190,12 +191,15 @@ func TestPRView_Preview_nontty(t *testing.T) {
},
},
"Open PR with metadata by number": {
branch: "master",
args: "12",
fixture: "./fixtures/prViewPreviewWithMetadataByNumber.json",
branch: "master",
args: "12",
fixtures: map[string]string{
"PullRequestByNumber": "./fixtures/prViewPreviewWithMetadataByNumber.json",
"ReviewsForPullRequest": "./fixtures/prViewPreviewNoReviews.json",
},
expectedOutputs: []string{
`title:\tBlueberries are from a fork\n`,
`reviewers:\t2 \(Approved\), 3 \(Commented\), 1 \(Requested\)\n`,
`reviewers:\t1 \(Requested\)\n`,
`assignees:\tmarseilles, monaco\n`,
`labels:\tone, two, three, four, five\n`,
`projects:\tProject 1 \(column A\), Project 2 \(column B\), Project 3 \(column C\), Project 4 \(Awaiting triage\)\n`,
@ -204,9 +208,12 @@ func TestPRView_Preview_nontty(t *testing.T) {
},
},
"Open PR with reviewers by number": {
branch: "master",
args: "12",
fixture: "./fixtures/prViewPreviewWithReviewersByNumber.json",
branch: "master",
args: "12",
fixtures: map[string]string{
"PullRequestByNumber": "./fixtures/prViewPreviewWithReviewersByNumber.json",
"ReviewsForPullRequest": "./fixtures/prViewPreviewManyReviews.json",
},
expectedOutputs: []string{
`title:\tBlueberries are from a fork\n`,
`state:\tOPEN\n`,
@ -220,14 +227,18 @@ func TestPRView_Preview_nontty(t *testing.T) {
},
},
"Open PR with metadata by branch": {
branch: "master",
args: "blueberries",
fixture: "./fixtures/prViewPreviewWithMetadataByBranch.json",
branch: "master",
args: "blueberries",
fixtures: map[string]string{
"PullRequestForBranch": "./fixtures/prViewPreviewWithMetadataByBranch.json",
"ReviewsForPullRequest": "./fixtures/prViewPreviewNoReviews.json",
},
expectedOutputs: []string{
`title:\tBlueberries are a good fruit`,
`state:\tOPEN`,
`author:\tnobody`,
`assignees:\tmarseilles, monaco\n`,
`reviewers:\t\n`,
`labels:\tone, two, three, four, five\n`,
`projects:\tProject 1 \(column A\), Project 2 \(column B\), Project 3 \(column C\)\n`,
`milestone:\tuluru\n`,
@ -235,14 +246,18 @@ func TestPRView_Preview_nontty(t *testing.T) {
},
},
"Open PR for the current branch": {
branch: "blueberries",
args: "",
fixture: "./fixtures/prView.json",
branch: "blueberries",
args: "",
fixtures: map[string]string{
"PullRequestForBranch": "./fixtures/prView.json",
"ReviewsForPullRequest": "./fixtures/prViewPreviewNoReviews.json",
},
expectedOutputs: []string{
`title:\tBlueberries are a good fruit`,
`state:\tOPEN`,
`author:\tnobody`,
`assignees:\t\n`,
`reviewers:\t\n`,
`labels:\t\n`,
`projects:\t\n`,
`milestone:\t\n`,
@ -250,23 +265,30 @@ func TestPRView_Preview_nontty(t *testing.T) {
},
},
"Open PR wth empty body for the current branch": {
branch: "blueberries",
args: "",
fixture: "./fixtures/prView_EmptyBody.json",
branch: "blueberries",
args: "",
fixtures: map[string]string{
"PullRequestForBranch": "./fixtures/prView_EmptyBody.json",
"ReviewsForPullRequest": "./fixtures/prViewPreviewNoReviews.json",
},
expectedOutputs: []string{
`title:\tBlueberries are a good fruit`,
`state:\tOPEN`,
`author:\tnobody`,
`assignees:\t\n`,
`reviewers:\t\n`,
`labels:\t\n`,
`projects:\t\n`,
`milestone:\t\n`,
},
},
"Closed PR": {
branch: "master",
args: "12",
fixture: "./fixtures/prViewPreviewClosedState.json",
branch: "master",
args: "12",
fixtures: map[string]string{
"PullRequestByNumber": "./fixtures/prViewPreviewClosedState.json",
"ReviewsForPullRequest": "./fixtures/prViewPreviewNoReviews.json",
},
expectedOutputs: []string{
`state:\tCLOSED\n`,
`author:\tnobody\n`,
@ -279,9 +301,12 @@ func TestPRView_Preview_nontty(t *testing.T) {
},
},
"Merged PR": {
branch: "master",
args: "12",
fixture: "./fixtures/prViewPreviewMergedState.json",
branch: "master",
args: "12",
fixtures: map[string]string{
"PullRequestByNumber": "./fixtures/prViewPreviewMergedState.json",
"ReviewsForPullRequest": "./fixtures/prViewPreviewNoReviews.json",
},
expectedOutputs: []string{
`state:\tMERGED\n`,
`author:\tnobody\n`,
@ -294,30 +319,38 @@ func TestPRView_Preview_nontty(t *testing.T) {
},
},
"Draft PR": {
branch: "master",
args: "12",
fixture: "./fixtures/prViewPreviewDraftState.json",
branch: "master",
args: "12",
fixtures: map[string]string{
"PullRequestByNumber": "./fixtures/prViewPreviewDraftState.json",
"ReviewsForPullRequest": "./fixtures/prViewPreviewNoReviews.json",
},
expectedOutputs: []string{
`title:\tBlueberries are from a fork\n`,
`state:\tDRAFT\n`,
`author:\tnobody\n`,
`labels:`,
`assignees:`,
`reviewers:`,
`projects:`,
`milestone:`,
`\*\*blueberries taste good\*\*`,
},
},
"Draft PR by branch": {
branch: "master",
args: "blueberries",
fixture: "./fixtures/prViewPreviewDraftStatebyBranch.json",
branch: "master",
args: "blueberries",
fixtures: map[string]string{
"PullRequestForBranch": "./fixtures/prViewPreviewDraftStatebyBranch.json",
"ReviewsForPullRequest": "./fixtures/prViewPreviewNoReviews.json",
},
expectedOutputs: []string{
`title:\tBlueberries are a good fruit\n`,
`state:\tDRAFT\n`,
`author:\tnobody\n`,
`labels:`,
`assignees:`,
`reviewers:`,
`projects:`,
`milestone:`,
`\*\*blueberries taste good\*\*`,
@ -329,7 +362,10 @@ func TestPRView_Preview_nontty(t *testing.T) {
t.Run(name, func(t *testing.T) {
http := &httpmock.Registry{}
defer http.Verify(t)
http.Register(httpmock.GraphQL(`query PullRequest(ByNumber|ForBranch)\b`), httpmock.FileResponse(tc.fixture))
for name, file := range tc.fixtures {
name := fmt.Sprintf(`query %s\b`, name)
http.Register(httpmock.GraphQL(name), httpmock.FileResponse(file))
}
output, err := runCommand(http, tc.branch, false, tc.args)
if err != nil {
@ -338,6 +374,7 @@ func TestPRView_Preview_nontty(t *testing.T) {
assert.Equal(t, "", output.Stderr())
//nolint:staticcheck // prefer exact matchers over ExpectLines
test.ExpectLines(t, output.String(), tc.expectedOutputs...)
})
}
@ -347,13 +384,16 @@ func TestPRView_Preview(t *testing.T) {
tests := map[string]struct {
branch string
args string
fixture string
fixtures map[string]string
expectedOutputs []string
}{
"Open PR without metadata": {
branch: "master",
args: "12",
fixture: "./fixtures/prViewPreview.json",
branch: "master",
args: "12",
fixtures: map[string]string{
"PullRequestByNumber": "./fixtures/prViewPreview.json",
"ReviewsForPullRequest": "./fixtures/prViewPreviewNoReviews.json",
},
expectedOutputs: []string{
`Blueberries are from a fork`,
`Open.*nobody wants to merge 12 commits into master from blueberries`,
@ -362,13 +402,16 @@ func TestPRView_Preview(t *testing.T) {
},
},
"Open PR with metadata by number": {
branch: "master",
args: "12",
fixture: "./fixtures/prViewPreviewWithMetadataByNumber.json",
branch: "master",
args: "12",
fixtures: map[string]string{
"PullRequestByNumber": "./fixtures/prViewPreviewWithMetadataByNumber.json",
"ReviewsForPullRequest": "./fixtures/prViewPreviewNoReviews.json",
},
expectedOutputs: []string{
`Blueberries are from a fork`,
`Open.*nobody wants to merge 12 commits into master from blueberries`,
`Reviewers:.*2 \(.*Approved.*\), 3 \(Commented\), 1 \(.*Requested.*\)\n`,
`Reviewers:.*1 \(.*Requested.*\)\n`,
`Assignees:.*marseilles, monaco\n`,
`Labels:.*one, two, three, four, five\n`,
`Projects:.*Project 1 \(column A\), Project 2 \(column B\), Project 3 \(column C\), Project 4 \(Awaiting triage\)\n`,
@ -378,9 +421,12 @@ func TestPRView_Preview(t *testing.T) {
},
},
"Open PR with reviewers by number": {
branch: "master",
args: "12",
fixture: "./fixtures/prViewPreviewWithReviewersByNumber.json",
branch: "master",
args: "12",
fixtures: map[string]string{
"PullRequestByNumber": "./fixtures/prViewPreviewWithReviewersByNumber.json",
"ReviewsForPullRequest": "./fixtures/prViewPreviewManyReviews.json",
},
expectedOutputs: []string{
`Blueberries are from a fork`,
`Reviewers:.*DEF \(.*Commented.*\), def \(.*Changes requested.*\), ghost \(.*Approved.*\), hubot \(Commented\), xyz \(.*Approved.*\), 123 \(.*Requested.*\), Team 1 \(.*Requested.*\), abc \(.*Requested.*\)\n`,
@ -389,9 +435,12 @@ func TestPRView_Preview(t *testing.T) {
},
},
"Open PR with metadata by branch": {
branch: "master",
args: "blueberries",
fixture: "./fixtures/prViewPreviewWithMetadataByBranch.json",
branch: "master",
args: "blueberries",
fixtures: map[string]string{
"PullRequestForBranch": "./fixtures/prViewPreviewWithMetadataByBranch.json",
"ReviewsForPullRequest": "./fixtures/prViewPreviewNoReviews.json",
},
expectedOutputs: []string{
`Blueberries are a good fruit`,
`Open.*nobody wants to merge 8 commits into master from blueberries`,
@ -404,9 +453,12 @@ func TestPRView_Preview(t *testing.T) {
},
},
"Open PR for the current branch": {
branch: "blueberries",
args: "",
fixture: "./fixtures/prView.json",
branch: "blueberries",
args: "",
fixtures: map[string]string{
"PullRequestForBranch": "./fixtures/prView.json",
"ReviewsForPullRequest": "./fixtures/prViewPreviewNoReviews.json",
},
expectedOutputs: []string{
`Blueberries are a good fruit`,
`Open.*nobody wants to merge 8 commits into master from blueberries`,
@ -415,9 +467,12 @@ func TestPRView_Preview(t *testing.T) {
},
},
"Open PR wth empty body for the current branch": {
branch: "blueberries",
args: "",
fixture: "./fixtures/prView_EmptyBody.json",
branch: "blueberries",
args: "",
fixtures: map[string]string{
"PullRequestForBranch": "./fixtures/prView_EmptyBody.json",
"ReviewsForPullRequest": "./fixtures/prViewPreviewNoReviews.json",
},
expectedOutputs: []string{
`Blueberries are a good fruit`,
`Open.*nobody wants to merge 8 commits into master from blueberries`,
@ -425,9 +480,12 @@ func TestPRView_Preview(t *testing.T) {
},
},
"Closed PR": {
branch: "master",
args: "12",
fixture: "./fixtures/prViewPreviewClosedState.json",
branch: "master",
args: "12",
fixtures: map[string]string{
"PullRequestByNumber": "./fixtures/prViewPreviewClosedState.json",
"ReviewsForPullRequest": "./fixtures/prViewPreviewNoReviews.json",
},
expectedOutputs: []string{
`Blueberries are from a fork`,
`Closed.*nobody wants to merge 12 commits into master from blueberries`,
@ -436,9 +494,12 @@ func TestPRView_Preview(t *testing.T) {
},
},
"Merged PR": {
branch: "master",
args: "12",
fixture: "./fixtures/prViewPreviewMergedState.json",
branch: "master",
args: "12",
fixtures: map[string]string{
"PullRequestByNumber": "./fixtures/prViewPreviewMergedState.json",
"ReviewsForPullRequest": "./fixtures/prViewPreviewNoReviews.json",
},
expectedOutputs: []string{
`Blueberries are from a fork`,
`Merged.*nobody wants to merge 12 commits into master from blueberries`,
@ -447,9 +508,12 @@ func TestPRView_Preview(t *testing.T) {
},
},
"Draft PR": {
branch: "master",
args: "12",
fixture: "./fixtures/prViewPreviewDraftState.json",
branch: "master",
args: "12",
fixtures: map[string]string{
"PullRequestByNumber": "./fixtures/prViewPreviewDraftState.json",
"ReviewsForPullRequest": "./fixtures/prViewPreviewNoReviews.json",
},
expectedOutputs: []string{
`Blueberries are from a fork`,
`Draft.*nobody wants to merge 12 commits into master from blueberries`,
@ -458,9 +522,12 @@ func TestPRView_Preview(t *testing.T) {
},
},
"Draft PR by branch": {
branch: "master",
args: "blueberries",
fixture: "./fixtures/prViewPreviewDraftStatebyBranch.json",
branch: "master",
args: "blueberries",
fixtures: map[string]string{
"PullRequestForBranch": "./fixtures/prViewPreviewDraftStatebyBranch.json",
"ReviewsForPullRequest": "./fixtures/prViewPreviewNoReviews.json",
},
expectedOutputs: []string{
`Blueberries are a good fruit`,
`Draft.*nobody wants to merge 8 commits into master from blueberries`,
@ -474,7 +541,10 @@ func TestPRView_Preview(t *testing.T) {
t.Run(name, func(t *testing.T) {
http := &httpmock.Registry{}
defer http.Verify(t)
http.Register(httpmock.GraphQL(`query PullRequest(ByNumber|ForBranch)\b`), httpmock.FileResponse(tc.fixture))
for name, file := range tc.fixtures {
name := fmt.Sprintf(`query %s\b`, name)
http.Register(httpmock.GraphQL(name), httpmock.FileResponse(file))
}
output, err := runCommand(http, tc.branch, true, tc.args)
if err != nil {
@ -483,6 +553,7 @@ func TestPRView_Preview(t *testing.T) {
assert.Equal(t, "", output.Stderr())
//nolint:staticcheck // prefer exact matchers over ExpectLines
test.ExpectLines(t, output.String(), tc.expectedOutputs...)
})
}
@ -494,6 +565,7 @@ func TestPRView_web_currentBranch(t *testing.T) {
http.Register(httpmock.GraphQL(`query PullRequestForBranch\b`), httpmock.FileResponse("./fixtures/prView.json"))
var seenCmd *exec.Cmd
//nolint:staticcheck // SA1019 TODO: rewrite to use run.Stub
restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable {
switch strings.Join(cmd.Args, " ") {
case `git config --get-regexp ^branch\.blueberries\.(remote|merge)$`:
@ -528,6 +600,7 @@ func TestPRView_web_noResultsForBranch(t *testing.T) {
http.Register(httpmock.GraphQL(`query PullRequestForBranch\b`), httpmock.FileResponse("./fixtures/prView_NoActiveBranch.json"))
var seenCmd *exec.Cmd
//nolint:staticcheck // SA1019 TODO: rewrite to use run.Stub
restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable {
switch strings.Join(cmd.Args, " ") {
case `git config --get-regexp ^branch\.blueberries\.(remote|merge)$`:
@ -562,6 +635,7 @@ func TestPRView_web_numberArg(t *testing.T) {
)
var seenCmd *exec.Cmd
//nolint:staticcheck // SA1019 TODO: rewrite to use run.Stub
restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable {
seenCmd = cmd
return &test.OutputStub{}
@ -595,6 +669,7 @@ func TestPRView_web_numberArgWithHash(t *testing.T) {
)
var seenCmd *exec.Cmd
//nolint:staticcheck // SA1019 TODO: rewrite to use run.Stub
restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable {
seenCmd = cmd
return &test.OutputStub{}
@ -628,6 +703,7 @@ func TestPRView_web_urlArg(t *testing.T) {
)
var seenCmd *exec.Cmd
//nolint:staticcheck // SA1019 TODO: rewrite to use run.Stub
restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable {
seenCmd = cmd
return &test.OutputStub{}
@ -663,6 +739,7 @@ func TestPRView_web_branchArg(t *testing.T) {
)
var seenCmd *exec.Cmd
//nolint:staticcheck // SA1019 TODO: rewrite to use run.Stub
restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable {
seenCmd = cmd
return &test.OutputStub{}
@ -699,6 +776,7 @@ func TestPRView_web_branchWithOwnerArg(t *testing.T) {
)
var seenCmd *exec.Cmd
//nolint:staticcheck // SA1019 TODO: rewrite to use run.Stub
restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable {
seenCmd = cmd
return &test.OutputStub{}
@ -731,14 +809,15 @@ func TestPRView_tty_Comments(t *testing.T) {
branch: "master",
cli: "123",
fixtures: map[string]string{
"PullRequestByNumber": "./fixtures/prViewPreviewSingleComment.json",
"PullRequestByNumber": "./fixtures/prViewPreviewSingleComment.json",
"ReviewsForPullRequest": "./fixtures/prViewPreviewReviews.json",
},
expectedOutputs: []string{
`some title`,
`1 \x{1f615} • 2 \x{1f440} • 3 \x{2764}\x{fe0f}`,
`some body`,
`———————— Not showing 4 comments ————————`,
`marseilles \(collaborator\) • Jan 1, 2020 • Newest comment`,
`———————— Not showing 8 comments ————————`,
`marseilles \(Collaborator\) • Jan 9, 2020 • Newest comment`,
`4 \x{1f389} • 5 \x{1f604} • 6 \x{1f680}`,
`Comment 5`,
`Use --comments to view the full conversation`,
@ -750,21 +829,36 @@ func TestPRView_tty_Comments(t *testing.T) {
cli: "123 --comments",
fixtures: map[string]string{
"PullRequestByNumber": "./fixtures/prViewPreviewSingleComment.json",
"ReviewsForPullRequest": "./fixtures/prViewPreviewReviews.json",
"CommentsForPullRequest": "./fixtures/prViewPreviewFullComments.json",
},
expectedOutputs: []string{
`some title`,
`some body`,
`monalisa • Jan 1, 2020 • edited`,
`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`,
`sam commented • Jan 2, 2020`,
`1 \x{1f44e} • 1 \x{1f44d}`,
`Review 1`,
`View the full review: https://github.com/OWNER/REPO/pull/12#pullrequestreview-1`,
`johnnytest \(Contributor\) • Jan 3, 2020`,
`Comment 2`,
`elvisp \(member\) • Jan 1, 2020`,
`matt requested changes \(Owner\) • Jan 4, 2020`,
`1 \x{1f615} • 1 \x{1f440}`,
`Review 2`,
`View the full review: https://github.com/OWNER/REPO/pull/12#pullrequestreview-2`,
`elvisp \(Member\) • Jan 5, 2020`,
`Comment 3`,
`loislane \(owner\) • Jan 1, 2020`,
`leah approved \(Member\) • Jan 6, 2020 • Edited`,
`Review 3`,
`View the full review: https://github.com/OWNER/REPO/pull/12#pullrequestreview-3`,
`loislane \(Owner\) • Jan 7, 2020`,
`Comment 4`,
`marseilles \(collaborator\) • Jan 1, 2020 • Newest comment`,
`louise dismissed • Jan 8, 2020`,
`Review 4`,
`View the full review: https://github.com/OWNER/REPO/pull/12#pullrequestreview-4`,
`marseilles \(Collaborator\) • Jan 9, 2020 • Newest comment`,
`Comment 5`,
`View this pull request on GitHub: https://github.com/OWNER/REPO/pull/12`,
},
@ -777,7 +871,6 @@ func TestPRView_tty_Comments(t *testing.T) {
}
for name, tt := range tests {
t.Run(name, func(t *testing.T) {
stubSpinner()
http := &httpmock.Registry{}
defer http.Verify(t)
for name, file := range tt.fixtures {
@ -791,6 +884,7 @@ func TestPRView_tty_Comments(t *testing.T) {
}
assert.NoError(t, err)
assert.Equal(t, "", output.Stderr())
//nolint:staticcheck // prefer exact matchers over ExpectLines
test.ExpectLines(t, output.String(), tt.expectedOutputs...)
})
}
@ -808,7 +902,8 @@ func TestPRView_nontty_Comments(t *testing.T) {
branch: "master",
cli: "123",
fixtures: map[string]string{
"PullRequestByNumber": "./fixtures/prViewPreviewSingleComment.json",
"PullRequestByNumber": "./fixtures/prViewPreviewSingleComment.json",
"ReviewsForPullRequest": "./fixtures/prViewPreviewReviews.json",
},
expectedOutputs: []string{
`title:\tsome title`,
@ -823,28 +918,54 @@ func TestPRView_nontty_Comments(t *testing.T) {
cli: "123 --comments",
fixtures: map[string]string{
"PullRequestByNumber": "./fixtures/prViewPreviewSingleComment.json",
"ReviewsForPullRequest": "./fixtures/prViewPreviewReviews.json",
"CommentsForPullRequest": "./fixtures/prViewPreviewFullComments.json",
},
expectedOutputs: []string{
`author:\tmonalisa`,
`association:\t`,
`association:\tnone`,
`edited:\ttrue`,
`status:\tnone`,
`Comment 1`,
`author:\tsam`,
`association:\tnone`,
`edited:\tfalse`,
`status:\tcommented`,
`Review 1`,
`author:\tjohnnytest`,
`association:\tcontributor`,
`edited:\tfalse`,
`status:\tnone`,
`Comment 2`,
`author:\tmatt`,
`association:\towner`,
`edited:\tfalse`,
`status:\tchanges requested`,
`Review 2`,
`author:\telvisp`,
`association:\tmember`,
`edited:\tfalse`,
`status:\tnone`,
`Comment 3`,
`author:\tleah`,
`association:\tmember`,
`edited:\ttrue`,
`status:\tapproved`,
`Review 3`,
`author:\tloislane`,
`association:\towner`,
`edited:\tfalse`,
`status:\tnone`,
`Comment 4`,
`author:\tlouise`,
`association:\tnone`,
`edited:\tfalse`,
`status:\tdismissed`,
`Review 4`,
`author:\tmarseilles`,
`association:\tcollaborator`,
`edited:\tfalse`,
`status:\tnone`,
`Comment 5`,
},
},
@ -869,12 +990,8 @@ func TestPRView_nontty_Comments(t *testing.T) {
}
assert.NoError(t, err)
assert.Equal(t, "", output.Stderr())
//nolint:staticcheck // prefer exact matchers over ExpectLines
test.ExpectLines(t, output.String(), tt.expectedOutputs...)
})
}
}
func stubSpinner() {
utils.StartSpinner = func(_ *spinner.Spinner) {}
utils.StopSpinner = func(_ *spinner.Spinner) {}
}

View file

@ -159,7 +159,7 @@ func cloneRun(opts *CloneOptions) error {
}
upstreamURL := ghrepo.FormatRemoteURL(canonicalRepo.Parent, protocol)
err = git.AddUpstreamRemote(upstreamURL, cloneDir)
err = git.AddUpstreamRemote(upstreamURL, cloneDir, []string{canonicalRepo.Parent.DefaultBranchRef.Name})
if err != nil {
return err
}

View file

@ -6,6 +6,7 @@ import (
"testing"
"github.com/cli/cli/internal/config"
"github.com/cli/cli/internal/run"
"github.com/cli/cli/pkg/cmdutil"
"github.com/cli/cli/pkg/httpmock"
"github.com/cli/cli/pkg/iostreams"
@ -77,11 +78,11 @@ func TestNewCmdClone(t *testing.T) {
cmd.SetErr(stderr)
_, err = cmd.ExecuteC()
if err != nil {
assert.Equal(t, tt.wantErr, err.Error())
if tt.wantErr != "" {
assert.EqualError(t, err, tt.wantErr)
return
} else if tt.wantErr != "" {
t.Errorf("expected error %q, got nil", tt.wantErr)
} else {
assert.NoError(t, err)
}
assert.Equal(t, "", stdout.String())
@ -182,6 +183,7 @@ func Test_RepoClone(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
reg := &httpmock.Registry{}
defer reg.Verify(t)
reg.Register(
httpmock.GraphQL(`query RepositoryInfo\b`),
httpmock.StringResponse(`
@ -196,10 +198,11 @@ func Test_RepoClone(t *testing.T) {
httpClient := &http.Client{Transport: reg}
cs, restore := test.InitCmdStubber()
defer restore()
cs.Stub("") // git clone
cs, restore := run.Stub()
defer restore(t)
cs.Register(`git clone`, 0, "", func(s []string) {
assert.Equal(t, tt.want, strings.Join(s, " "))
})
output, err := runCloneCommand(httpClient, tt.args)
if err != nil {
@ -208,9 +211,6 @@ func Test_RepoClone(t *testing.T) {
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)
})
}
}
@ -229,6 +229,9 @@ func Test_RepoClone_hasParent(t *testing.T) {
"name": "ORIG",
"owner": {
"login": "hubot"
},
"defaultBranchRef": {
"name": "trunk"
}
}
} } }
@ -236,23 +239,21 @@ func Test_RepoClone_hasParent(t *testing.T) {
httpClient := &http.Client{Transport: reg}
cs, restore := test.InitCmdStubber()
defer restore()
cs, cmdTeardown := run.Stub()
defer cmdTeardown(t)
cs.Stub("") // git clone
cs.Stub("") // git remote add
cs.Register(`git clone https://github.com/OWNER/REPO.git`, 0, "")
cs.Register(`git -C REPO remote add -t trunk -f upstream https://github.com/hubot/ORIG.git`, 0, "")
_, err := runCloneCommand(httpClient, "OWNER/REPO")
if err != nil {
t.Fatalf("error running command `repo clone`: %v", err)
}
assert.Equal(t, 2, cs.Count)
assert.Equal(t, "git -C REPO remote add -f upstream https://github.com/hubot/ORIG.git", strings.Join(cs.Calls[1].Args, " "))
}
func Test_RepoClone_withoutUsername(t *testing.T) {
reg := &httpmock.Registry{}
defer reg.Verify(t)
reg.Register(
httpmock.GraphQL(`query UserCurrent\b`),
httpmock.StringResponse(`
@ -269,19 +270,12 @@ func Test_RepoClone_withoutUsername(t *testing.T) {
}
} } }
`))
reg.Register(
httpmock.GraphQL(`query RepositoryFindParent\b`),
httpmock.StringResponse(`
{ "data": { "repository": {
"parent": null
} } }`))
httpClient := &http.Client{Transport: reg}
cs, restore := test.InitCmdStubber()
defer restore()
cs.Stub("") // git clone
cs, restore := run.Stub()
defer restore(t)
cs.Register(`git clone https://github\.com/OWNER/REPO\.git`, 0, "")
output, err := runCloneCommand(httpClient, "REPO")
if err != nil {
@ -290,6 +284,4 @@ func Test_RepoClone_withoutUsername(t *testing.T) {
assert.Equal(t, "", output.String())
assert.Equal(t, "", output.Stderr())
assert.Equal(t, 1, cs.Count)
assert.Equal(t, "git clone https://github.com/OWNER/REPO.git", strings.Join(cs.Calls[0].Args, " "))
}

View file

@ -231,7 +231,7 @@ func createRun(opts *CreateOptions) error {
createLocalDirectory := opts.ConfirmSubmit
if !opts.ConfirmSubmit {
opts.ConfirmSubmit, err = confirmSubmission(input.Name, input.OwnerID, &opts.ConfirmSubmit)
opts.ConfirmSubmit, err = confirmSubmission(input.Name, input.OwnerID)
if err != nil {
return err
}
@ -369,7 +369,7 @@ func interactiveRepoCreate(isDescEmpty bool, isVisibilityPassed bool, repoName s
return answers.RepoName, answers.RepoDescription, strings.ToUpper(answers.RepoVisibility), nil
}
func confirmSubmission(repoName string, repoOwner string, isConfirmFlagPassed *bool) (bool, error) {
func confirmSubmission(repoName string, repoOwner string) (bool, error) {
qs := []*survey.Question{}
promptString := ""

View file

@ -85,6 +85,7 @@ func TestRepoCreate(t *testing.T) {
httpClient := &http.Client{Transport: reg}
var seenCmd *exec.Cmd
//nolint:staticcheck // SA1019 TODO: rewrite to use run.Stub
restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable {
seenCmd = cmd
return &test.OutputStub{}
@ -170,6 +171,7 @@ func TestRepoCreate_outsideGitWorkDir(t *testing.T) {
{},
{},
}
//nolint:staticcheck // SA1019 TODO: rewrite to use run.Stub
restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable {
if len(cmdOutputs) == 0 {
t.Fatal("Too many calls to git command")
@ -244,6 +246,7 @@ func TestRepoCreate_org(t *testing.T) {
httpClient := &http.Client{Transport: reg}
var seenCmd *exec.Cmd
//nolint:staticcheck // SA1019 TODO: rewrite to use run.Stub
restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable {
seenCmd = cmd
return &test.OutputStub{}
@ -326,6 +329,7 @@ func TestRepoCreate_orgWithTeam(t *testing.T) {
httpClient := &http.Client{Transport: reg}
var seenCmd *exec.Cmd
//nolint:staticcheck // SA1019 TODO: rewrite to use run.Stub
restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable {
seenCmd = cmd
return &test.OutputStub{}
@ -409,6 +413,7 @@ func TestRepoCreate_template(t *testing.T) {
httpClient := &http.Client{Transport: reg}
var seenCmd *exec.Cmd
//nolint:staticcheck // SA1019 TODO: rewrite to use run.Stub
restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable {
seenCmd = cmd
return &test.OutputStub{}
@ -489,6 +494,7 @@ func TestRepoCreate_withoutNameArg(t *testing.T) {
httpClient := &http.Client{Transport: reg}
var seenCmd *exec.Cmd
//nolint:staticcheck // SA1019 TODO: rewrite to use run.Stub
restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable {
seenCmd = cmd
return &test.OutputStub{}

View file

@ -12,12 +12,12 @@ 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/iostreams"
"github.com/cli/cli/pkg/prompt"
"github.com/cli/cli/utils"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
type ForkOptions struct {
@ -27,11 +27,13 @@ type ForkOptions struct {
BaseRepo func() (ghrepo.Interface, error)
Remotes func() (context.Remotes, error)
GitArgs []string
Repository string
Clone bool
Remote bool
PromptClone bool
PromptRemote bool
RemoteName string
}
var Since = func(t time.Time) time.Duration {
@ -48,16 +50,24 @@ func NewCmdFork(f *cmdutil.Factory, runF func(*ForkOptions) error) *cobra.Comman
}
cmd := &cobra.Command{
Use: "fork [<repository>]",
Args: cobra.MaximumNArgs(1),
Use: "fork [<repository>] [-- <gitflags>...]",
Args: func(cmd *cobra.Command, args []string) error {
if cmd.ArgsLenAtDash() == 0 && len(args[1:]) > 0 {
return cmdutil.FlagError{Err: fmt.Errorf("repository argument required when passing 'git clone' flags")}
}
return nil
},
Short: "Create a fork of a repository",
Long: `Create a fork of a repository.
With no argument, creates a fork of the current repository. Otherwise, forks the specified repository.`,
With no argument, creates a fork of the current repository. Otherwise, forks the specified repository.
Additional 'git clone' flags can be passed in by listing them after '--'.`,
RunE: func(cmd *cobra.Command, args []string) error {
promptOk := opts.IO.CanPrompt()
if len(args) > 0 {
opts.Repository = args[0]
opts.GitArgs = args[1:]
}
if promptOk && !cmd.Flags().Changed("clone") {
@ -74,9 +84,16 @@ With no argument, creates a fork of the current repository. Otherwise, forks the
return forkRun(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)}
})
cmd.Flags().BoolVar(&opts.Clone, "clone", false, "Clone the fork {true|false}")
cmd.Flags().BoolVar(&opts.Remote, "remote", false, "Add remote for fork {true|false}")
cmd.Flags().StringVar(&opts.RemoteName, "remote-name", "origin", "Specify a name for a fork's new remote.")
return cmd
}
@ -127,18 +144,6 @@ func forkRun(opts *ForkOptions) error {
cs := opts.IO.ColorScheme()
stderr := opts.IO.ErrOut
s := utils.Spinner(stderr)
stopSpinner := func() {}
if connectedToTerminal {
loading := cs.Gray("Forking ") + cs.Bold(cs.Gray(ghrepo.FullName(repoToFork))) + cs.Gray("...")
s.Suffix = " " + loading
s.FinalMSG = cs.Gray(fmt.Sprintf("- %s\n", loading))
utils.StartSpinner(s)
stopSpinner = func() {
utils.StopSpinner(s)
}
}
httpClient, err := opts.HttpClient()
if err != nil {
@ -147,14 +152,13 @@ func forkRun(opts *ForkOptions) error {
apiClient := api.NewClientFromHTTP(httpClient)
opts.IO.StartProgressIndicator()
forkedRepo, err := api.ForkRepo(apiClient, repoToFork)
opts.IO.StopProgressIndicator()
if err != nil {
stopSpinner()
return fmt.Errorf("failed to fork: %w", err)
}
stopSpinner()
// This is weird. There is not an efficient way to determine via the GitHub API whether or not a
// given user has forked a given repo. We noticed, also, that the create fork API endpoint just
// returns the fork repo data even if it already exists -- with no change in status code or
@ -225,25 +229,13 @@ func forkRun(opts *ForkOptions) error {
}
}
if remoteDesired {
remoteName := "origin"
remoteName := opts.RemoteName
remotes, err := opts.Remotes()
if err != nil {
return err
}
if _, err := remotes.FindByName(remoteName); err == nil {
renameTarget := "upstream"
renameCmd, err := git.GitCommand("remote", "rename", remoteName, renameTarget)
if err != nil {
return err
}
err = run.PrepareCmd(renameCmd).Run()
if err != nil {
return err
}
if connectedToTerminal {
fmt.Fprintf(stderr, "%s Renamed %s remote to %s\n", cs.SuccessIcon(), cs.Bold(remoteName), cs.Bold(renameTarget))
}
return fmt.Errorf("a remote called '%s' already exists. You can rerun this command with --remote-name to specify a different remote name.", remoteName)
}
forkedRepoCloneURL := ghrepo.FormatRemoteURL(forkedRepo, protocol)
@ -267,13 +259,13 @@ func forkRun(opts *ForkOptions) error {
}
if cloneDesired {
forkedRepoURL := ghrepo.FormatRemoteURL(forkedRepo, protocol)
cloneDir, err := git.RunClone(forkedRepoURL, []string{})
cloneDir, err := git.RunClone(forkedRepoURL, opts.GitArgs)
if err != nil {
return fmt.Errorf("failed to clone fork: %w", err)
}
upstreamURL := ghrepo.FormatRemoteURL(repoToFork, protocol)
err = git.AddUpstreamRemote(upstreamURL, cloneDir)
err = git.AddUpstreamRemote(upstreamURL, cloneDir, []string{})
if err != nil {
return err
}

View file

@ -3,13 +3,10 @@ package fork
import (
"net/http"
"net/url"
"os/exec"
"regexp"
"strings"
"testing"
"time"
"github.com/briandowns/spinner"
"github.com/cli/cli/context"
"github.com/cli/cli/git"
"github.com/cli/cli/internal/config"
@ -20,7 +17,6 @@ import (
"github.com/cli/cli/pkg/iostreams"
"github.com/cli/cli/pkg/prompt"
"github.com/cli/cli/test"
"github.com/cli/cli/utils"
"github.com/google/shlex"
"github.com/stretchr/testify/assert"
)
@ -85,44 +81,85 @@ func runCommand(httpClient *http.Client, remotes []*context.Remote, isTTY bool,
func TestRepoFork_nontty(t *testing.T) {
defer stubSince(2 * time.Second)()
reg := &httpmock.Registry{}
defer reg.Verify(t)
defer reg.StubWithFixturePath(200, "./forkResult.json")()
httpClient := &http.Client{Transport: reg}
cs, restore := test.InitCmdStubber()
defer restore()
_, restore := run.Stub()
defer restore(t)
output, err := runCommand(httpClient, nil, false, "")
if err != nil {
t.Fatalf("error running command `repo fork`: %v", err)
}
assert.Equal(t, 0, len(cs.Calls))
assert.Equal(t, "", output.String())
assert.Equal(t, "", output.Stderr())
}
func TestRepoFork_existing_remote_error(t *testing.T) {
defer stubSince(2 * time.Second)()
reg := &httpmock.Registry{}
defer reg.StubWithFixturePath(200, "./forkResult.json")()
httpClient := &http.Client{Transport: reg}
_, err := runCommand(httpClient, nil, false, "--remote")
if err == nil {
t.Fatal("expected error running command `repo fork`")
}
assert.Equal(t, "a remote called 'origin' already exists. You can rerun this command with --remote-name to specify a different remote name.", err.Error())
reg.Verify(t)
}
func TestRepoFork_no_existing_remote(t *testing.T) {
remotes := []*context.Remote{
{
Remote: &git.Remote{
Name: "upstream",
FetchURL: &url.URL{},
},
Repo: ghrepo.New("OWNER", "REPO"),
},
}
defer stubSince(2 * time.Second)()
reg := &httpmock.Registry{}
defer reg.Verify(t)
defer reg.StubWithFixturePath(200, "./forkResult.json")()
httpClient := &http.Client{Transport: reg}
cs, restore := run.Stub()
defer restore(t)
cs.Register(`git remote add -f origin https://github\.com/someone/REPO\.git`, 0, "")
output, err := runCommand(httpClient, remotes, false, "--remote")
if err != nil {
t.Fatalf("error running command `repo fork`: %v", err)
}
assert.Equal(t, "", output.String())
assert.Equal(t, "", output.Stderr())
}
func TestRepoFork_in_parent_nontty(t *testing.T) {
defer stubSince(2 * time.Second)()
reg := &httpmock.Registry{}
defer reg.StubWithFixturePath(200, "./forkResult.json")()
httpClient := &http.Client{Transport: reg}
cs, restore := test.InitCmdStubber()
defer restore()
cs, restore := run.Stub()
defer restore(t)
cs.Stub("") // git remote rename
cs.Stub("") // git remote add
cs.Register(`git remote add -f fork https://github\.com/someone/REPO\.git`, 0, "")
output, err := runCommand(httpClient, nil, false, "--remote")
output, err := runCommand(httpClient, nil, false, "--remote --remote-name=fork")
if err != nil {
t.Fatalf("error running command `repo fork`: %v", err)
}
assert.Equal(t, 2, len(cs.Calls))
assert.Equal(t, "git remote rename origin upstream", strings.Join(cs.Calls[0].Args, " "))
assert.Equal(t, "git remote add -f origin https://github.com/someone/REPO.git", strings.Join(cs.Calls[1].Args, " "))
assert.Equal(t, "", output.String())
assert.Equal(t, "", output.Stderr())
reg.Verify(t)
@ -131,14 +168,15 @@ func TestRepoFork_in_parent_nontty(t *testing.T) {
func TestRepoFork_outside_parent_nontty(t *testing.T) {
defer stubSince(2 * time.Second)()
reg := &httpmock.Registry{}
reg.Verify(t)
defer reg.StubWithFixturePath(200, "./forkResult.json")()
httpClient := &http.Client{Transport: reg}
cs, restore := test.InitCmdStubber()
defer restore()
cs, restore := run.Stub()
defer restore(t)
cs.Stub("") // git clone
cs.Stub("") // git remote add
cs.Register(`git clone https://github.com/someone/REPO\.git`, 0, "")
cs.Register(`git -C REPO remote add -f upstream https://github\.com/OWNER/REPO\.git`, 0, "")
output, err := runCommand(httpClient, nil, false, "--clone OWNER/REPO")
if err != nil {
@ -146,30 +184,23 @@ func TestRepoFork_outside_parent_nontty(t *testing.T) {
}
assert.Equal(t, "", output.String())
assert.Equal(t, "git clone https://github.com/someone/REPO.git", strings.Join(cs.Calls[0].Args, " "))
assert.Equal(t, "git -C REPO remote add -f upstream https://github.com/OWNER/REPO.git", strings.Join(cs.Calls[1].Args, " "))
assert.Equal(t, output.Stderr(), "")
reg.Verify(t)
}
func TestRepoFork_already_forked(t *testing.T) {
stubSpinner()
reg := &httpmock.Registry{}
defer reg.StubWithFixturePath(200, "./forkResult.json")()
httpClient := &http.Client{Transport: reg}
cs, restore := test.InitCmdStubber()
defer restore()
_, restore := run.Stub()
defer restore(t)
output, err := runCommand(httpClient, nil, true, "--remote=false")
if err != nil {
t.Errorf("got unexpected error: %v", err)
}
assert.Equal(t, 0, len(cs.Calls))
r := regexp.MustCompile(`someone/REPO.*already exists`)
if !r.MatchString(output.Stderr()) {
t.Errorf("output did not match regexp /%s/\n> output\n%s\n", r, output.Stderr())
@ -180,7 +211,6 @@ func TestRepoFork_already_forked(t *testing.T) {
}
func TestRepoFork_reuseRemote(t *testing.T) {
stubSpinner()
remotes := []*context.Remote{
{
Remote: &git.Remote{Name: "origin", FetchURL: &url.URL{}},
@ -208,13 +238,12 @@ func TestRepoFork_reuseRemote(t *testing.T) {
}
func TestRepoFork_in_parent(t *testing.T) {
stubSpinner()
reg := &httpmock.Registry{}
defer reg.StubWithFixturePath(200, "./forkResult.json")()
httpClient := &http.Client{Transport: reg}
cs, restore := test.InitCmdStubber()
defer restore()
_, restore := run.Stub()
defer restore(t)
defer stubSince(2 * time.Second)()
output, err := runCommand(httpClient, nil, true, "--remote=false")
@ -222,7 +251,6 @@ func TestRepoFork_in_parent(t *testing.T) {
t.Errorf("error running command `repo fork`: %v", err)
}
assert.Equal(t, 0, len(cs.Calls))
assert.Equal(t, "", output.String())
r := regexp.MustCompile(`Created fork.*someone/REPO`)
@ -234,7 +262,6 @@ func TestRepoFork_in_parent(t *testing.T) {
}
func TestRepoFork_outside(t *testing.T) {
stubSpinner()
tests := []struct {
name string
args string
@ -273,52 +300,40 @@ func TestRepoFork_outside(t *testing.T) {
}
func TestRepoFork_in_parent_yes(t *testing.T) {
stubSpinner()
defer stubSince(2 * time.Second)()
reg := &httpmock.Registry{}
defer reg.StubWithFixturePath(200, "./forkResult.json")()
httpClient := &http.Client{Transport: reg}
var seenCmds []*exec.Cmd
defer run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable {
seenCmds = append(seenCmds, cmd)
return &test.OutputStub{}
})()
cs, restore := run.Stub()
defer restore(t)
output, err := runCommand(httpClient, nil, true, "--remote")
cs.Register(`git remote add -f fork https://github\.com/someone/REPO\.git`, 0, "")
output, err := runCommand(httpClient, nil, true, "--remote --remote-name=fork")
if err != nil {
t.Errorf("error running command `repo fork`: %v", err)
}
expectedCmds := []string{
"git remote rename origin upstream",
"git remote add -f origin https://github.com/someone/REPO.git",
}
for x, cmd := range seenCmds {
assert.Equal(t, expectedCmds[x], strings.Join(cmd.Args, " "))
}
assert.Equal(t, "", output.String())
//nolint:staticcheck // prefer exact matchers over ExpectLines
test.ExpectLines(t, output.Stderr(),
"Created fork.*someone/REPO",
"Added remote.*origin")
"Added remote.*fork")
reg.Verify(t)
}
func TestRepoFork_outside_yes(t *testing.T) {
stubSpinner()
defer stubSince(2 * time.Second)()
reg := &httpmock.Registry{}
defer reg.StubWithFixturePath(200, "./forkResult.json")()
httpClient := &http.Client{Transport: reg}
cs, restore := test.InitCmdStubber()
defer restore()
cs, restore := run.Stub()
defer restore(t)
cs.Stub("") // git clone
cs.Stub("") // git remote add
cs.Register(`git clone https://github\.com/someone/REPO\.git`, 0, "")
cs.Register(`git -C REPO remote add -f upstream https://github\.com/OWNER/REPO\.git`, 0, "")
output, err := runCommand(httpClient, nil, true, "--clone OWNER/REPO")
if err != nil {
@ -326,10 +341,7 @@ func TestRepoFork_outside_yes(t *testing.T) {
}
assert.Equal(t, "", output.String())
assert.Equal(t, "git clone https://github.com/someone/REPO.git", strings.Join(cs.Calls[0].Args, " "))
assert.Equal(t, "git -C REPO remote add -f upstream https://github.com/OWNER/REPO.git", strings.Join(cs.Calls[1].Args, " "))
//nolint:staticcheck // prefer exact matchers over ExpectLines
test.ExpectLines(t, output.Stderr(),
"Created fork.*someone/REPO",
"Cloned fork")
@ -337,17 +349,16 @@ func TestRepoFork_outside_yes(t *testing.T) {
}
func TestRepoFork_outside_survey_yes(t *testing.T) {
stubSpinner()
defer stubSince(2 * time.Second)()
reg := &httpmock.Registry{}
defer reg.StubWithFixturePath(200, "./forkResult.json")()
httpClient := &http.Client{Transport: reg}
cs, restore := test.InitCmdStubber()
defer restore()
cs, restore := run.Stub()
defer restore(t)
cs.Stub("") // git clone
cs.Stub("") // git remote add
cs.Register(`git clone https://github\.com/someone/REPO\.git`, 0, "")
cs.Register(`git -C REPO remote add -f upstream https://github\.com/OWNER/REPO\.git`, 0, "")
defer prompt.StubConfirm(true)()
@ -357,10 +368,7 @@ func TestRepoFork_outside_survey_yes(t *testing.T) {
}
assert.Equal(t, "", output.String())
assert.Equal(t, "git clone https://github.com/someone/REPO.git", strings.Join(cs.Calls[0].Args, " "))
assert.Equal(t, "git -C REPO remote add -f upstream https://github.com/OWNER/REPO.git", strings.Join(cs.Calls[1].Args, " "))
//nolint:staticcheck // prefer exact matchers over ExpectLines
test.ExpectLines(t, output.Stderr(),
"Created fork.*someone/REPO",
"Cloned fork")
@ -368,17 +376,13 @@ func TestRepoFork_outside_survey_yes(t *testing.T) {
}
func TestRepoFork_outside_survey_no(t *testing.T) {
stubSpinner()
defer stubSince(2 * time.Second)()
reg := &httpmock.Registry{}
defer reg.StubWithFixturePath(200, "./forkResult.json")()
httpClient := &http.Client{Transport: reg}
cmdRun := false
defer run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable {
cmdRun = true
return &test.OutputStub{}
})()
_, restore := run.Stub()
defer restore(t)
defer prompt.StubConfirm(false)()
@ -389,8 +393,6 @@ func TestRepoFork_outside_survey_no(t *testing.T) {
assert.Equal(t, "", output.String())
assert.Equal(t, false, cmdRun)
r := regexp.MustCompile(`Created fork.*someone/REPO`)
if !r.MatchString(output.Stderr()) {
t.Errorf("output did not match regexp /%s/\n> output\n%s\n", r, output)
@ -400,55 +402,41 @@ func TestRepoFork_outside_survey_no(t *testing.T) {
}
func TestRepoFork_in_parent_survey_yes(t *testing.T) {
stubSpinner()
reg := &httpmock.Registry{}
defer reg.StubWithFixturePath(200, "./forkResult.json")()
httpClient := &http.Client{Transport: reg}
defer stubSince(2 * time.Second)()
var seenCmds []*exec.Cmd
defer run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable {
seenCmds = append(seenCmds, cmd)
return &test.OutputStub{}
})()
cs, restore := run.Stub()
defer restore(t)
cs.Register(`git remote add -f fork https://github\.com/someone/REPO\.git`, 0, "")
defer prompt.StubConfirm(true)()
output, err := runCommand(httpClient, nil, true, "")
output, err := runCommand(httpClient, nil, true, "--remote-name=fork")
if err != nil {
t.Errorf("error running command `repo fork`: %v", err)
}
expectedCmds := []string{
"git remote rename origin upstream",
"git remote add -f origin https://github.com/someone/REPO.git",
}
for x, cmd := range seenCmds {
assert.Equal(t, expectedCmds[x], strings.Join(cmd.Args, " "))
}
assert.Equal(t, "", output.String())
//nolint:staticcheck // prefer exact matchers over ExpectLines
test.ExpectLines(t, output.Stderr(),
"Created fork.*someone/REPO",
"Renamed.*origin.*remote to.*upstream",
"Added remote.*origin")
"Added remote.*fork")
reg.Verify(t)
}
func TestRepoFork_in_parent_survey_no(t *testing.T) {
stubSpinner()
reg := &httpmock.Registry{}
defer reg.Verify(t)
defer reg.StubWithFixturePath(200, "./forkResult.json")()
httpClient := &http.Client{Transport: reg}
defer stubSince(2 * time.Second)()
cmdRun := false
defer run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable {
cmdRun = true
return &test.OutputStub{}
})()
_, restore := run.Stub()
defer restore(t)
defer prompt.StubConfirm(false)()
@ -459,28 +447,53 @@ func TestRepoFork_in_parent_survey_no(t *testing.T) {
assert.Equal(t, "", output.String())
assert.Equal(t, false, cmdRun)
r := regexp.MustCompile(`Created fork.*someone/REPO`)
if !r.MatchString(output.Stderr()) {
t.Errorf("output did not match regexp /%s/\n> output\n%s\n", r, output)
return
}
reg.Verify(t)
}
func TestRepoFork_in_parent_match_protocol(t *testing.T) {
stubSpinner()
func Test_RepoFork_gitFlags(t *testing.T) {
defer stubSince(2 * time.Second)()
reg := &httpmock.Registry{}
defer reg.StubWithFixturePath(200, "./forkResult.json")()
httpClient := &http.Client{Transport: reg}
var seenCmds []*exec.Cmd
defer run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable {
seenCmds = append(seenCmds, cmd)
return &test.OutputStub{}
})()
cs, cmdTeardown := run.Stub()
defer cmdTeardown(t)
cs.Register(`git clone --depth 1 https://github.com/someone/REPO.git`, 0, "")
cs.Register(`git -C REPO remote add -f upstream https://github.com/OWNER/REPO.git`, 0, "")
output, err := runCommand(httpClient, nil, false, "--clone OWNER/REPO -- --depth 1")
if err != nil {
t.Errorf("error running command `repo fork`: %v", err)
}
assert.Equal(t, "", output.String())
assert.Equal(t, output.Stderr(), "")
reg.Verify(t)
}
func Test_RepoFork_flagError(t *testing.T) {
_, err := runCommand(nil, nil, true, "--depth 1 OWNER/REPO")
if err == nil || err.Error() != "unknown flag: --depth\nSeparate git clone flags with '--'." {
t.Errorf("unexpected error %v", err)
}
}
func TestRepoFork_in_parent_match_protocol(t *testing.T) {
defer stubSince(2 * time.Second)()
reg := &httpmock.Registry{}
defer reg.Verify(t)
defer reg.StubWithFixturePath(200, "./forkResult.json")()
httpClient := &http.Client{Transport: reg}
cs, restore := run.Stub()
defer restore(t)
cs.Register(`git remote add -f fork git@github\.com:someone/REPO\.git`, 0, "")
remotes := []*context.Remote{
{
@ -491,34 +504,17 @@ func TestRepoFork_in_parent_match_protocol(t *testing.T) {
},
}
output, err := runCommand(httpClient, remotes, true, "--remote")
output, err := runCommand(httpClient, remotes, true, "--remote --remote-name=fork")
if err != nil {
t.Errorf("error running command `repo fork`: %v", err)
}
expectedCmds := []string{
"git remote rename origin upstream",
"git remote add -f origin git@github.com:someone/REPO.git",
}
for x, cmd := range seenCmds {
assert.Equal(t, expectedCmds[x], strings.Join(cmd.Args, " "))
}
assert.Equal(t, "", output.String())
//nolint:staticcheck // prefer exact matchers over ExpectLines
test.ExpectLines(t, output.Stderr(),
"Created fork.*someone/REPO",
"Added remote.*origin")
reg.Verify(t)
}
func stubSpinner() {
// not bothering with teardown since we never want spinners when doing tests
utils.StartSpinner = func(_ *spinner.Spinner) {
}
utils.StopSpinner = func(_ *spinner.Spinner) {
}
"Added remote.*fork")
}
func stubSince(d time.Duration) func() {

View file

@ -8,10 +8,10 @@ import (
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/internal/ghrepo"
"github.com/cli/cli/internal/run"
"github.com/cli/cli/pkg/cmdutil"
"github.com/cli/cli/pkg/httpmock"
"github.com/cli/cli/pkg/iostreams"
"github.com/cli/cli/test"
"github.com/google/shlex"
"github.com/stretchr/testify/assert"
)
@ -132,18 +132,14 @@ func Test_RepoView_Web(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
io.SetStdoutTTY(tt.stdoutTTY)
cs, teardown := test.InitCmdStubber()
defer teardown()
cs.Stub("") // browser open
cs, teardown := run.Stub()
defer teardown(t)
cs.Register(`https://github\.com/OWNER/REPO$`, 0, "")
if err := viewRun(opts); err != nil {
t.Errorf("viewRun() error = %v", err)
}
assert.Equal(t, "", stdout.String())
assert.Equal(t, 1, len(cs.Calls))
call := cs.Calls[0]
assert.Equal(t, "https://github.com/OWNER/REPO", call.Args[len(call.Args)-1])
assert.Equal(t, tt.wantStderr, stderr.String())
reg.Verify(t)
})

View file

@ -20,6 +20,7 @@ import (
repoCmd "github.com/cli/cli/pkg/cmd/repo"
creditsCmd "github.com/cli/cli/pkg/cmd/repo/credits"
secretCmd "github.com/cli/cli/pkg/cmd/secret"
sshKeyCmd "github.com/cli/cli/pkg/cmd/ssh-key"
versionCmd "github.com/cli/cli/pkg/cmd/version"
"github.com/cli/cli/pkg/cmdutil"
"github.com/spf13/cobra"
@ -76,6 +77,7 @@ func NewCmdRoot(f *cmdutil.Factory, version, buildDate string) *cobra.Command {
cmd.AddCommand(gistCmd.NewCmdGist(f))
cmd.AddCommand(completionCmd.NewCmdCompletion(f.IOStreams))
cmd.AddCommand(secretCmd.NewCmdSecret(f))
cmd.AddCommand(sshKeyCmd.NewCmdSSHKey(f))
// the `api` command should not inherit any extra HTTP headers
bareHTTPCmdFactory := *f

View file

@ -191,6 +191,7 @@ func Test_listRun(t *testing.T) {
reg.Verify(t)
//nolint:staticcheck // prefer exact matchers over ExpectLines
test.ExpectLines(t, stdout.String(), tt.wantOut...)
})
}

View file

@ -0,0 +1,58 @@
package list
import (
"encoding/json"
"errors"
"fmt"
"io/ioutil"
"net/http"
"time"
"github.com/cli/cli/api"
"github.com/cli/cli/internal/ghinstance"
)
var scopesError = errors.New("insufficient OAuth scopes")
type sshKey struct {
Key string
Title string
CreatedAt time.Time `json:"created_at"`
}
func userKeys(httpClient *http.Client, userHandle string) ([]sshKey, error) {
resource := "user/keys"
if userHandle != "" {
resource = fmt.Sprintf("users/%s/keys", userHandle)
}
url := fmt.Sprintf("%s%s?per_page=%d", ghinstance.RESTPrefix(ghinstance.OverridableDefault()), resource, 100)
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, err
}
resp, err := httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode == 404 {
return nil, scopesError
} else if resp.StatusCode > 299 {
return nil, api.HandleHTTPError(resp)
}
b, err := ioutil.ReadAll(resp.Body)
if err != nil {
return nil, err
}
var keys []sshKey
err = json.Unmarshal(b, &keys)
if err != nil {
return nil, err
}
return keys, nil
}

View file

@ -0,0 +1,96 @@
package list
import (
"errors"
"fmt"
"net/http"
"time"
"github.com/cli/cli/pkg/cmdutil"
"github.com/cli/cli/pkg/iostreams"
"github.com/cli/cli/utils"
"github.com/spf13/cobra"
)
// ListOptions struct for list command
type ListOptions struct {
IO *iostreams.IOStreams
HTTPClient func() (*http.Client, error)
}
// NewCmdList creates a command for list all SSH Keys
func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Command {
opts := &ListOptions{
HTTPClient: f.HttpClient,
IO: f.IOStreams,
}
cmd := &cobra.Command{
Use: "list",
Short: "Lists SSH keys in a GitHub account",
Args: cobra.ExactArgs(0),
RunE: func(cmd *cobra.Command, args []string) error {
if runF != nil {
return runF(opts)
}
return listRun(opts)
},
}
return cmd
}
func listRun(opts *ListOptions) error {
apiClient, err := opts.HTTPClient()
if err != nil {
return err
}
sshKeys, err := userKeys(apiClient, "")
if err != nil {
if errors.Is(err, scopesError) {
cs := opts.IO.ColorScheme()
fmt.Fprint(opts.IO.ErrOut, "Error: insufficient OAuth scopes to list SSH keys\n")
fmt.Fprintf(opts.IO.ErrOut, "Run the following to grant scopes: %s\n", cs.Bold("gh auth refresh -s read:public_key"))
return cmdutil.SilentError
}
return err
}
if len(sshKeys) == 0 {
fmt.Fprintln(opts.IO.ErrOut, "No SSH keys present in GitHub account.")
return cmdutil.SilentError
}
t := utils.NewTablePrinter(opts.IO)
cs := opts.IO.ColorScheme()
now := time.Now()
for _, sshKey := range sshKeys {
t.AddField(sshKey.Title, nil, nil)
t.AddField(sshKey.Key, truncateMiddle, nil)
createdAt := sshKey.CreatedAt.Format(time.RFC3339)
if t.IsTTY() {
createdAt = utils.FuzzyAgoAbbr(now, sshKey.CreatedAt)
}
t.AddField(createdAt, nil, cs.Gray)
t.EndRow()
}
return t.Render()
}
func truncateMiddle(maxWidth int, t string) string {
if len(t) <= maxWidth {
return t
}
ellipsis := "..."
if maxWidth < len(ellipsis)+2 {
return t[0:maxWidth]
}
halfWidth := (maxWidth - len(ellipsis)) / 2
return t[0:halfWidth] + ellipsis + t[len(t)-halfWidth:]
}

View file

@ -0,0 +1,131 @@
package list
import (
"fmt"
"net/http"
"testing"
"time"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/pkg/httpmock"
"github.com/cli/cli/pkg/iostreams"
)
func TestListRun(t *testing.T) {
tests := []struct {
name string
opts ListOptions
isTTY bool
wantStdout string
wantStderr string
wantErr bool
}{
{
name: "list tty",
opts: ListOptions{
HTTPClient: func() (*http.Client, error) {
createdAt := time.Now().Add(time.Duration(-24) * time.Hour)
reg := &httpmock.Registry{}
reg.Register(
httpmock.REST("GET", "user/keys"),
httpmock.StringResponse(fmt.Sprintf(`[
{
"id": 1234,
"key": "ssh-rsa AAAABbBB123",
"title": "Mac",
"created_at": "%[1]s"
},
{
"id": 5678,
"key": "ssh-rsa EEEEEEEK247",
"title": "hubot@Windows",
"created_at": "%[1]s"
}
]`, createdAt.Format(time.RFC3339))),
)
return &http.Client{Transport: reg}, nil
},
},
isTTY: true,
wantStdout: heredoc.Doc(`
Mac ssh-rsa AAAABbBB123 1d
hubot@Windows ssh-rsa EEEEEEEK247 1d
`),
wantStderr: "",
},
{
name: "list non-tty",
opts: ListOptions{
HTTPClient: func() (*http.Client, error) {
createdAt, _ := time.Parse(time.RFC3339, "2020-08-31T15:44:24+02:00")
reg := &httpmock.Registry{}
reg.Register(
httpmock.REST("GET", "user/keys"),
httpmock.StringResponse(fmt.Sprintf(`[
{
"id": 1234,
"key": "ssh-rsa AAAABbBB123",
"title": "Mac",
"created_at": "%[1]s"
},
{
"id": 5678,
"key": "ssh-rsa EEEEEEEK247",
"title": "hubot@Windows",
"created_at": "%[1]s"
}
]`, createdAt.Format(time.RFC3339))),
)
return &http.Client{Transport: reg}, nil
},
},
isTTY: false,
wantStdout: heredoc.Doc(`
Mac ssh-rsa AAAABbBB123 2020-08-31T15:44:24+02:00
hubot@Windows ssh-rsa EEEEEEEK247 2020-08-31T15:44:24+02:00
`),
wantStderr: "",
},
{
name: "no keys",
opts: ListOptions{
HTTPClient: func() (*http.Client, error) {
reg := &httpmock.Registry{}
reg.Register(
httpmock.REST("GET", "user/keys"),
httpmock.StringResponse(`[]`),
)
return &http.Client{Transport: reg}, nil
},
},
wantStdout: "",
wantStderr: "No SSH keys present in GitHub account.\n",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
io, _, stdout, stderr := iostreams.Test()
io.SetStdoutTTY(tt.isTTY)
io.SetStdinTTY(tt.isTTY)
io.SetStderrTTY(tt.isTTY)
opts := tt.opts
opts.IO = io
err := listRun(&opts)
if (err != nil) != tt.wantErr {
t.Errorf("linRun() return error: %v", err)
return
}
if stdout.String() != tt.wantStdout {
t.Errorf("wants stdout %q, got %q", tt.wantStdout, stdout.String())
}
if stderr.String() != tt.wantStderr {
t.Errorf("wants stderr %q, got %q", tt.wantStderr, stderr.String())
}
})
}
}

View file

@ -0,0 +1,22 @@
package key
import (
cmdList "github.com/cli/cli/pkg/cmd/ssh-key/list"
"github.com/cli/cli/pkg/cmdutil"
"github.com/spf13/cobra"
)
// NewCmdSSHKey creates a command for manage SSH Keys
func NewCmdSSHKey(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "ssh-key <command>",
Short: "Manage SSH keys",
Long: "Work with GitHub SSH keys",
Hidden: true,
}
cmd.AddCommand(cmdList.NewCmdList(f, nil))
return cmd
}

View file

@ -157,3 +157,7 @@ func (e *GhEditor) Prompt(config *survey.PromptConfig) (interface{}, error) {
}
return e.prompt(initialValue, config)
}
func DefaultEditorName() string {
return filepath.Base(defaultEditor)
}

196
script/build.go Normal file
View file

@ -0,0 +1,196 @@
// Build tasks for the GitHub CLI project.
//
// Usage: go run script/build.go [<task>]
//
// Known tasks are:
//
// bin/gh:
// Builds the main executable.
// Supported environment variables:
// - GH_VERSION: determined from source by default
// - GH_OAUTH_CLIENT_ID
// - GH_OAUTH_CLIENT_SECRET
// - SOURCE_DATE_EPOCH: enables reproducible builds
// - GO_LDFLAGS
//
// manpages:
// Builds the man pages under `share/man/man1/`.
//
// clean:
// Deletes all built files.
//
package main
import (
"fmt"
"io/ioutil"
"os"
"os/exec"
"path/filepath"
"runtime"
"strconv"
"strings"
"time"
"github.com/cli/safeexec"
)
var tasks = map[string]func(string) error{
"bin/gh": func(exe string) error {
info, err := os.Stat(exe)
if err == nil && !sourceFilesLaterThan(info.ModTime()) {
fmt.Printf("%s: `%s` is up to date.\n", self, exe)
return nil
}
ldflags := os.Getenv("GO_LDFLAGS")
ldflags = fmt.Sprintf("-X github.com/cli/cli/internal/build.Version=%s %s", version(), ldflags)
ldflags = fmt.Sprintf("-X github.com/cli/cli/internal/build.Date=%s %s", date(), ldflags)
if oauthSecret := os.Getenv("GH_OAUTH_CLIENT_SECRET"); oauthSecret != "" {
ldflags = fmt.Sprintf("-X github.com/cli/cli/internal/authflow.oauthClientSecret=%s %s", oauthSecret, ldflags)
ldflags = fmt.Sprintf("-X github.com/cli/cli/internal/authflow.oauthClientID=%s %s", os.Getenv("GH_OAUTH_CLIENT_ID"), ldflags)
}
return run("go", "build", "-trimpath", "-ldflags", ldflags, "-o", exe, "./cmd/gh")
},
"manpages": func(_ string) error {
return run("go", "run", "./cmd/gen-docs", "--man-page", "--doc-path", "./share/man/man1/")
},
"clean": func(_ string) error {
return rmrf("bin", "share")
},
}
var self string
func main() {
task := "bin/gh"
if runtime.GOOS == "windows" {
task = "bin\\gh.exe"
}
if len(os.Args) > 1 {
task = os.Args[1]
}
self = filepath.Base(os.Args[0])
if self == "build" {
self = "build.go"
}
t := tasks[normalizeTask(task)]
if t == nil {
fmt.Fprintf(os.Stderr, "Don't know how to build task `%s`.\n", task)
os.Exit(1)
}
err := t(task)
if err != nil {
fmt.Fprintln(os.Stderr, err)
fmt.Fprintf(os.Stderr, "%s: building task `%s` failed.\n", self, task)
os.Exit(1)
}
}
func version() string {
if versionEnv := os.Getenv("GH_VERSION"); versionEnv != "" {
return versionEnv
}
if desc, err := cmdOutput("git", "describe", "--tags"); err == nil {
return desc
}
rev, _ := cmdOutput("git", "rev-parse", "--short", "HEAD")
return rev
}
func date() string {
t := time.Now()
if sourceDate := os.Getenv("SOURCE_DATE_EPOCH"); sourceDate != "" {
if sec, err := strconv.ParseInt(sourceDate, 10, 64); err == nil {
t = time.Unix(sec, 0)
}
}
return t.Format("2006-01-02")
}
func sourceFilesLaterThan(t time.Time) bool {
foundLater := false
_ = filepath.Walk(".", func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if foundLater {
return filepath.SkipDir
}
if len(path) > 1 && (path[0] == '.' || path[0] == '_') {
if info.IsDir() {
return filepath.SkipDir
} else {
return nil
}
}
if info.IsDir() {
return nil
}
if path == "go.mod" || path == "go.sum" || (strings.HasSuffix(path, ".go") && !strings.HasSuffix(path, "_test.go")) {
if info.ModTime().After(t) {
foundLater = true
}
}
return nil
})
return foundLater
}
func rmrf(targets ...string) error {
args := append([]string{"rm", "-rf"}, targets...)
announce(args...)
for _, target := range targets {
if err := os.RemoveAll(target); err != nil {
return err
}
}
return nil
}
func announce(args ...string) {
fmt.Println(shellInspect(args))
}
func run(args ...string) error {
exe, err := safeexec.LookPath(args[0])
if err != nil {
return err
}
announce(args...)
cmd := exec.Command(exe, args[1:]...)
return cmd.Run()
}
func cmdOutput(args ...string) (string, error) {
exe, err := safeexec.LookPath(args[0])
if err != nil {
return "", err
}
cmd := exec.Command(exe, args[1:]...)
cmd.Stderr = ioutil.Discard
out, err := cmd.Output()
return strings.TrimSuffix(string(out), "\n"), err
}
func shellInspect(args []string) string {
fmtArgs := make([]string, len(args))
for i, arg := range args {
if strings.ContainsAny(arg, " \t'\"") {
fmtArgs[i] = fmt.Sprintf("%q", arg)
} else {
fmtArgs[i] = arg
}
}
return strings.Join(fmtArgs, " ")
}
func normalizeTask(t string) string {
return filepath.ToSlash(strings.TrimSuffix(t, ".exe"))
}

View file

@ -1 +0,0 @@
ref: refs/heads/master

View file

@ -1,6 +0,0 @@
[core]
repositoryformatversion = 0
filemode = true
bare = true
ignorecase = true
precomposeunicode = false

Some files were not shown because too many files have changed in this diff Show more