diff --git a/.github/workflows/releases.yml b/.github/workflows/releases.yml index 7de6e176a..1654e74df 100644 --- a/.github/workflows/releases.yml +++ b/.github/workflows/releases.yml @@ -32,6 +32,7 @@ jobs: if: "!contains(github.ref, '-')" # skip prereleases with: formula-name: gh + download-url: https://github.com/cli/cli.git env: COMMITTER_TOKEN: ${{ secrets.UPLOAD_GITHUB_TOKEN }} - name: Checkout documentation site diff --git a/api/client.go b/api/client.go index 827ee401d..233d460f0 100644 --- a/api/client.go +++ b/api/client.go @@ -3,6 +3,7 @@ package api import ( "bytes" "encoding/json" + "errors" "fmt" "io" "io/ioutil" @@ -11,6 +12,7 @@ import ( "regexp" "strings" + "github.com/cli/cli/internal/ghinstance" "github.com/henvic/httpretty" "github.com/shurcooL/graphql" ) @@ -43,25 +45,21 @@ func NewClientFromHTTP(httpClient *http.Client) *Client { func AddHeader(name, value string) ClientOption { return func(tr http.RoundTripper) http.RoundTripper { return &funcTripper{roundTrip: func(req *http.Request) (*http.Response, error) { - // prevent the token from leaking to non-GitHub hosts - // TODO: GHE support - if !strings.EqualFold(name, "Authorization") || strings.HasSuffix(req.URL.Hostname(), ".github.com") { - req.Header.Add(name, value) - } + req.Header.Add(name, value) return tr.RoundTrip(req) }} } } // AddHeaderFunc is an AddHeader that gets the string value from a function -func AddHeaderFunc(name string, value func() string) ClientOption { +func AddHeaderFunc(name string, getValue func(*http.Request) (string, error)) ClientOption { return func(tr http.RoundTripper) http.RoundTripper { return &funcTripper{roundTrip: func(req *http.Request) (*http.Response, error) { - // prevent the token from leaking to non-GitHub hosts - // TODO: GHE support - if !strings.EqualFold(name, "Authorization") || strings.HasSuffix(req.URL.Hostname(), ".github.com") { - req.Header.Add(name, value()) + value, err := getValue(req) + if err != nil { + return nil, err } + req.Header.Add(name, value) return tr.RoundTrip(req) }} } @@ -198,19 +196,18 @@ func (err HTTPError) Error() string { return fmt.Sprintf("HTTP %d (%s)", err.StatusCode, err.RequestURL) } -// Returns whether or not scopes are present, appID, and error -func (c Client) HasScopes(wantedScopes ...string) (bool, string, error) { - url := "https://api.github.com/user" - req, err := http.NewRequest("GET", url, nil) +func (c Client) HasMinimumScopes(hostname string) (bool, error) { + apiEndpoint := ghinstance.RESTPrefix(hostname) + + req, err := http.NewRequest("GET", apiEndpoint, nil) if err != nil { - return false, "", err + return false, err } req.Header.Set("Content-Type", "application/json; charset=utf-8") - res, err := c.http.Do(req) if err != nil { - return false, "", err + return false, err } defer func() { @@ -221,37 +218,46 @@ func (c Client) HasScopes(wantedScopes ...string) (bool, string, error) { }() if res.StatusCode != 200 { - return false, "", handleHTTPError(res) + return false, handleHTTPError(res) } - appID := res.Header.Get("X-Oauth-Client-Id") hasScopes := strings.Split(res.Header.Get("X-Oauth-Scopes"), ",") - found := 0 + search := map[string]bool{ + "repo": false, + "read:org": false, + "admin:org": false, + } + for _, s := range hasScopes { - for _, w := range wantedScopes { - if w == strings.TrimSpace(s) { - found++ - } - } + search[strings.TrimSpace(s)] = true } - if found == len(wantedScopes) { - return true, appID, nil + errorMsgs := []string{} + if !search["repo"] { + errorMsgs = append(errorMsgs, "missing required scope 'repo'") } - return false, appID, nil + if !search["read:org"] && !search["admin:org"] { + errorMsgs = append(errorMsgs, "missing required scope 'read:org'") + } + + if len(errorMsgs) > 0 { + return false, errors.New(strings.Join(errorMsgs, ";")) + } + + return true, nil + } // GraphQL performs a GraphQL request and parses the response -func (c Client) GraphQL(query string, variables map[string]interface{}, data interface{}) error { - url := "https://api.github.com/graphql" +func (c Client) GraphQL(hostname string, query string, variables map[string]interface{}, data interface{}) error { reqBody, err := json.Marshal(map[string]interface{}{"query": query, "variables": variables}) if err != nil { return err } - req, err := http.NewRequest("POST", url, bytes.NewBuffer(reqBody)) + req, err := http.NewRequest("POST", ghinstance.GraphQLEndpoint(hostname), bytes.NewBuffer(reqBody)) if err != nil { return err } @@ -267,13 +273,13 @@ func (c Client) GraphQL(query string, variables map[string]interface{}, data int return handleResponse(resp, data) } -func graphQLClient(h *http.Client) *graphql.Client { - return graphql.NewClient("https://api.github.com/graphql", h) +func graphQLClient(h *http.Client, hostname string) *graphql.Client { + return graphql.NewClient(ghinstance.GraphQLEndpoint(hostname), h) } // REST performs a REST request and parses the response. -func (c Client) REST(method string, p string, body io.Reader, data interface{}) error { - url := "https://api.github.com/" + p +func (c Client) REST(hostname string, method string, p string, body io.Reader, data interface{}) error { + url := ghinstance.RESTPrefix(hostname) + p req, err := http.NewRequest(method, url, body) if err != nil { return err diff --git a/api/client_test.go b/api/client_test.go index 7307ce2b6..063d4648c 100644 --- a/api/client_test.go +++ b/api/client_test.go @@ -33,7 +33,7 @@ func TestGraphQL(t *testing.T) { }{} http.StubResponse(200, bytes.NewBufferString(`{"data":{"viewer":{"login":"hubot"}}}`)) - err := client.GraphQL("QUERY", vars, &response) + err := client.GraphQL("github.com", "QUERY", vars, &response) eq(t, err, nil) eq(t, response.Viewer.Login, "hubot") @@ -55,7 +55,7 @@ func TestGraphQLError(t *testing.T) { ] }`)) - err := client.GraphQL("", nil, &response) + err := client.GraphQL("github.com", "", nil, &response) if err == nil || err.Error() != "GraphQL error: OH NO\nthis is fine" { t.Fatalf("got %q", err.Error()) } @@ -71,7 +71,7 @@ func TestRESTGetDelete(t *testing.T) { http.StubResponse(204, bytes.NewBuffer([]byte{})) r := bytes.NewReader([]byte(`{}`)) - err := client.REST("DELETE", "applications/CLIENTID/grant", r, nil) + err := client.REST("github.com", "DELETE", "applications/CLIENTID/grant", r, nil) eq(t, err, nil) } @@ -82,7 +82,7 @@ func TestRESTError(t *testing.T) { http.StubResponse(422, bytes.NewBufferString(`{"message": "OH NO"}`)) var httpErr HTTPError - err := client.REST("DELETE", "repos/branch", nil, nil) + err := client.REST("github.com", "DELETE", "repos/branch", nil, nil) if err == nil || !errors.As(err, &httpErr) { t.Fatalf("got %v", err) } diff --git a/api/queries_gist.go b/api/queries_gist.go deleted file mode 100644 index 7a9dc14b6..000000000 --- a/api/queries_gist.go +++ /dev/null @@ -1,52 +0,0 @@ -package api - -import ( - "bytes" - "encoding/json" -) - -// Gist represents a GitHub's gist. -type Gist struct { - Description string `json:"description,omitempty"` - Public bool `json:"public,omitempty"` - Files map[GistFilename]GistFile `json:"files,omitempty"` - HTMLURL string `json:"html_url,omitempty"` -} - -type GistFilename string - -type GistFile struct { - Content string `json:"content,omitempty"` -} - -// Create a gist for authenticated user. -// -// GitHub API docs: https://developer.github.com/v3/gists/#create-a-gist -func GistCreate(client *Client, description string, public bool, files map[string]string) (*Gist, error) { - gistFiles := map[GistFilename]GistFile{} - - for filename, content := range files { - gistFiles[GistFilename(filename)] = GistFile{content} - } - - path := "gists" - body := &Gist{ - Description: description, - Public: public, - Files: gistFiles, - } - result := Gist{} - - requestByte, err := json.Marshal(body) - if err != nil { - return nil, err - } - requestBody := bytes.NewReader(requestByte) - - err = client.REST("POST", path, requestBody, &result) - if err != nil { - return nil, err - } - - return &result, nil -} diff --git a/api/queries_issue.go b/api/queries_issue.go index 1b3bddd64..4f2c0eb14 100644 --- a/api/queries_issue.go +++ b/api/queries_issue.go @@ -112,7 +112,7 @@ func IssueCreate(client *Client, repo *Repository, params map[string]interface{} } }{} - err := client.GraphQL(query, variables, &result) + err := client.GraphQL(repo.RepoHost(), query, variables, &result) if err != nil { return nil, err } @@ -171,7 +171,7 @@ func IssueStatus(client *Client, repo ghrepo.Interface, currentUsername string) } var resp response - err := client.GraphQL(query, variables, &resp) + err := client.GraphQL(repo.RepoHost(), query, variables, &resp) if err != nil { return nil, err } @@ -270,7 +270,7 @@ func IssueList(client *Client, repo ghrepo.Interface, state string, labels []str loop: for { variables["limit"] = pageLimit - err := client.GraphQL(query, variables, &response) + err := client.GraphQL(repo.RepoHost(), query, variables, &response) if err != nil { return nil, err } @@ -361,7 +361,7 @@ func IssueByNumber(client *Client, repo ghrepo.Interface, number int) (*Issue, e } var resp response - err := client.GraphQL(query, variables, &resp) + err := client.GraphQL(repo.RepoHost(), query, variables, &resp) if err != nil { return nil, err } @@ -389,7 +389,7 @@ func IssueClose(client *Client, repo ghrepo.Interface, issue Issue) error { }, } - gql := graphQLClient(client.http) + gql := graphQLClient(client.http, repo.RepoHost()) err := gql.MutateNamed(context.Background(), "IssueClose", &mutation, variables) if err != nil { @@ -414,7 +414,7 @@ func IssueReopen(client *Client, repo ghrepo.Interface, issue Issue) error { }, } - gql := graphQLClient(client.http) + gql := graphQLClient(client.http, repo.RepoHost()) err := gql.MutateNamed(context.Background(), "IssueReopen", &mutation, variables) return err diff --git a/api/queries_org.go b/api/queries_org.go index e1b6a083c..f3744e147 100644 --- a/api/queries_org.go +++ b/api/queries_org.go @@ -3,11 +3,12 @@ package api import ( "context" + "github.com/cli/cli/internal/ghrepo" "github.com/shurcooL/githubv4" ) // OrganizationProjects fetches all open projects for an organization -func OrganizationProjects(client *Client, owner string) ([]RepoProject, error) { +func OrganizationProjects(client *Client, repo ghrepo.Interface) ([]RepoProject, error) { var query struct { Organization struct { Projects struct { @@ -21,11 +22,11 @@ func OrganizationProjects(client *Client, owner string) ([]RepoProject, error) { } variables := map[string]interface{}{ - "owner": githubv4.String(owner), + "owner": githubv4.String(repo.RepoOwner()), "endCursor": (*githubv4.String)(nil), } - gql := graphQLClient(client.http) + gql := graphQLClient(client.http, repo.RepoHost()) var projects []RepoProject for { @@ -50,7 +51,7 @@ type OrgTeam struct { } // OrganizationTeams fetches all the teams in an organization -func OrganizationTeams(client *Client, owner string) ([]OrgTeam, error) { +func OrganizationTeams(client *Client, repo ghrepo.Interface) ([]OrgTeam, error) { var query struct { Organization struct { Teams struct { @@ -64,11 +65,11 @@ func OrganizationTeams(client *Client, owner string) ([]OrgTeam, error) { } variables := map[string]interface{}{ - "owner": githubv4.String(owner), + "owner": githubv4.String(repo.RepoOwner()), "endCursor": (*githubv4.String)(nil), } - gql := graphQLClient(client.http) + gql := graphQLClient(client.http, repo.RepoHost()) var teams []OrgTeam for { diff --git a/api/queries_pr.go b/api/queries_pr.go index 4437f8f7e..b2fade857 100644 --- a/api/queries_pr.go +++ b/api/queries_pr.go @@ -4,7 +4,7 @@ import ( "context" "errors" "fmt" - "io/ioutil" + "io" "net/http" "strings" @@ -209,36 +209,28 @@ func (pr *PullRequest) ChecksStatus() (summary PullRequestChecksStatus) { return } -func (c Client) PullRequestDiff(baseRepo ghrepo.Interface, prNumber int) (string, error) { +func (c Client) PullRequestDiff(baseRepo ghrepo.Interface, prNumber int) (io.ReadCloser, error) { url := fmt.Sprintf("https://api.github.com/repos/%s/pulls/%d", ghrepo.FullName(baseRepo), prNumber) req, err := http.NewRequest("GET", url, nil) if err != nil { - return "", err + return nil, err } req.Header.Set("Accept", "application/vnd.github.v3.diff; charset=utf-8") resp, err := c.http.Do(req) if err != nil { - return "", err - } - defer resp.Body.Close() - - b, err := ioutil.ReadAll(resp.Body) - if err != nil { - return "", err - } - - if resp.StatusCode == 200 { - return string(b), nil + return nil, err } if resp.StatusCode == 404 { - return "", &NotFoundError{errors.New("pull request not found")} + return nil, &NotFoundError{errors.New("pull request not found")} + } else if resp.StatusCode != 200 { + return nil, handleHTTPError(resp) } - return "", errors.New("pull request diff lookup failed") + return resp.Body, nil } func PullRequests(client *Client, repo ghrepo.Interface, currentPRNumber int, currentPRHeadRef, currentUsername string) (*PullRequestsPayload, error) { @@ -363,7 +355,7 @@ func PullRequests(client *Client, repo ghrepo.Interface, currentPRNumber int, cu } var resp response - err := client.GraphQL(query, variables, &resp) + err := client.GraphQL(repo.RepoHost(), query, variables, &resp) if err != nil { return nil, err } @@ -500,7 +492,7 @@ func PullRequestByNumber(client *Client, repo ghrepo.Interface, number int) (*Pu } var resp response - err := client.GraphQL(query, variables, &resp) + err := client.GraphQL(repo.RepoHost(), query, variables, &resp) if err != nil { return nil, err } @@ -613,7 +605,7 @@ func PullRequestForBranch(client *Client, repo ghrepo.Interface, baseBranch, hea } var resp response - err := client.GraphQL(query, variables, &resp) + err := client.GraphQL(repo.RepoHost(), query, variables, &resp) if err != nil { return nil, err } @@ -663,7 +655,7 @@ func CreatePullRequest(client *Client, repo *Repository, params map[string]inter } }{} - err := client.GraphQL(query, variables, &result) + err := client.GraphQL(repo.RepoHost(), query, variables, &result) if err != nil { return nil, err } @@ -689,7 +681,7 @@ func CreatePullRequest(client *Client, repo *Repository, params map[string]inter variables := map[string]interface{}{ "input": updateParams, } - err := client.GraphQL(updateQuery, variables, &result) + err := client.GraphQL(repo.RepoHost(), updateQuery, variables, &result) if err != nil { return nil, err } @@ -714,7 +706,7 @@ func CreatePullRequest(client *Client, repo *Repository, params map[string]inter variables := map[string]interface{}{ "input": reviewParams, } - err := client.GraphQL(reviewQuery, variables, &result) + err := client.GraphQL(repo.RepoHost(), reviewQuery, variables, &result) if err != nil { return nil, err } @@ -734,7 +726,7 @@ func isBlank(v interface{}) bool { } } -func AddReview(client *Client, pr *PullRequest, input *PullRequestReviewInput) error { +func AddReview(client *Client, repo ghrepo.Interface, pr *PullRequest, input *PullRequestReviewInput) error { var mutation struct { AddPullRequestReview struct { ClientMutationID string @@ -758,11 +750,11 @@ func AddReview(client *Client, pr *PullRequest, input *PullRequestReviewInput) e }, } - gql := graphQLClient(client.http) + gql := graphQLClient(client.http, repo.RepoHost()) return gql.MutateNamed(context.Background(), "PullRequestReviewAdd", &mutation, variables) } -func PullRequestList(client *Client, vars map[string]interface{}, limit int) (*PullRequestAndTotalCount, error) { +func PullRequestList(client *Client, repo ghrepo.Interface, vars map[string]interface{}, limit int) (*PullRequestAndTotalCount, error) { type prBlock struct { Edges []struct { Node PullRequest @@ -859,10 +851,8 @@ func PullRequestList(client *Client, vars map[string]interface{}, limit int) (*P } } }` - owner := vars["owner"].(string) - repo := vars["repo"].(string) search := []string{ - fmt.Sprintf("repo:%s/%s", owner, repo), + fmt.Sprintf("repo:%s/%s", repo.RepoOwner(), repo.RepoName()), fmt.Sprintf("assignee:%s", assignee), "is:pr", "sort:created-desc", @@ -888,6 +878,8 @@ func PullRequestList(client *Client, vars map[string]interface{}, limit int) (*P } variables["q"] = strings.Join(search, " ") } else { + variables["owner"] = repo.RepoOwner() + variables["repo"] = repo.RepoName() for name, val := range vars { variables[name] = val } @@ -896,7 +888,7 @@ loop: for { variables["limit"] = pageLimit var data response - err := client.GraphQL(query, variables, &data) + err := client.GraphQL(repo.RepoHost(), query, variables, &data) if err != nil { return nil, err } @@ -945,7 +937,7 @@ func PullRequestClose(client *Client, repo ghrepo.Interface, pr *PullRequest) er }, } - gql := graphQLClient(client.http) + gql := graphQLClient(client.http, repo.RepoHost()) err := gql.MutateNamed(context.Background(), "PullRequestClose", &mutation, variables) return err @@ -966,7 +958,7 @@ func PullRequestReopen(client *Client, repo ghrepo.Interface, pr *PullRequest) e }, } - gql := graphQLClient(client.http) + gql := graphQLClient(client.http, repo.RepoHost()) err := gql.MutateNamed(context.Background(), "PullRequestReopen", &mutation, variables) return err @@ -996,7 +988,7 @@ func PullRequestMerge(client *Client, repo ghrepo.Interface, pr *PullRequest, m }, } - gql := graphQLClient(client.http) + gql := graphQLClient(client.http, repo.RepoHost()) err := gql.MutateNamed(context.Background(), "PullRequestMerge", &mutation, variables) return err @@ -1017,13 +1009,13 @@ func PullRequestReady(client *Client, repo ghrepo.Interface, pr *PullRequest) er }, } - gql := graphQLClient(client.http) + gql := graphQLClient(client.http, repo.RepoHost()) return gql.MutateNamed(context.Background(), "PullRequestReadyForReview", &mutation, variables) } func BranchDeleteRemote(client *Client, repo ghrepo.Interface, branch string) error { path := fmt.Sprintf("repos/%s/%s/git/refs/heads/%s", repo.RepoOwner(), repo.RepoName(), branch) - return client.REST("DELETE", path, nil, nil) + return client.REST(repo.RepoHost(), "DELETE", path, nil, nil) } func min(a, b int) int { diff --git a/api/queries_repo.go b/api/queries_repo.go index 06d4bfdab..e19bc1cf6 100644 --- a/api/queries_repo.go +++ b/api/queries_repo.go @@ -27,9 +27,7 @@ type Repository struct { IsPrivate bool HasIssuesEnabled bool ViewerPermission string - DefaultBranchRef struct { - Name string - } + DefaultBranchRef BranchRef Parent *Repository @@ -42,6 +40,11 @@ type RepositoryOwner struct { Login string } +// BranchRef is the branch name in a GitHub repository +type BranchRef struct { + Name string +} + // RepoOwner is the login name of the owner func (r Repository) RepoOwner() string { return r.Owner.Login @@ -103,7 +106,7 @@ func GitHubRepo(client *Client, repo ghrepo.Interface) (*Repository, error) { result := struct { Repository Repository }{} - err := client.GraphQL(query, variables, &result) + err := client.GraphQL(repo.RepoHost(), query, variables, &result) if err != nil { return nil, err @@ -142,7 +145,7 @@ func RepoParent(client *Client, repo ghrepo.Interface) (ghrepo.Interface, error) "name": githubv4.String(repo.RepoName()), } - gql := graphQLClient(client.http) + gql := graphQLClient(client.http, repo.RepoHost()) err := gql.QueryNamed(context.Background(), "RepositoryFindParent", &query, variables) if err != nil { return nil, err @@ -186,7 +189,7 @@ func RepoNetwork(client *Client, repos []ghrepo.Interface) (RepoNetworkResult, e graphqlResult := make(map[string]*json.RawMessage) var result RepoNetworkResult - err := client.GraphQL(fmt.Sprintf(` + err := client.GraphQL(hostname, fmt.Sprintf(` fragment repo on Repository { id name @@ -282,7 +285,7 @@ func ForkRepo(client *Client, repo ghrepo.Interface) (*Repository, error) { path := fmt.Sprintf("repos/%s/forks", ghrepo.FullName(repo)) body := bytes.NewBufferString(`{}`) result := repositoryV3{} - err := client.REST("POST", path, body, &result) + err := client.REST(repo.RepoHost(), "POST", path, body, &result) if err != nil { return nil, err } @@ -315,7 +318,7 @@ func RepoFindFork(client *Client, repo ghrepo.Interface) (*Repository, error) { "repo": repo.RepoName(), } - if err := client.GraphQL(` + if err := client.GraphQL(repo.RepoHost(), ` query RepositoryFindFork($owner: String!, $repo: String!) { repository(owner: $owner, name: $repo) { forks(first: 1, affiliations: [OWNER, COLLABORATOR]) { @@ -461,7 +464,7 @@ func RepoMetadata(client *Client, repo ghrepo.Interface, input RepoMetadataInput if input.Reviewers { count++ go func() { - teams, err := OrganizationTeams(client, repo.RepoOwner()) + teams, err := OrganizationTeams(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 teams: %w", err) @@ -492,7 +495,7 @@ func RepoMetadata(client *Client, repo ghrepo.Interface, input RepoMetadataInput } result.Projects = projects - orgProjects, err := OrganizationProjects(client, repo.RepoOwner()) + 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) @@ -588,7 +591,7 @@ func RepoResolveMetadataIDs(client *Client, repo ghrepo.Interface, input RepoRes fmt.Fprint(query, "}\n") response := make(map[string]json.RawMessage) - err = client.GraphQL(query.String(), nil, &response) + err = client.GraphQL(repo.RepoHost(), query.String(), nil, &response) if err != nil { return result, err } @@ -651,7 +654,7 @@ func RepoProjects(client *Client, repo ghrepo.Interface) ([]RepoProject, error) "endCursor": (*githubv4.String)(nil), } - gql := graphQLClient(client.http) + gql := graphQLClient(client.http, repo.RepoHost()) var projects []RepoProject for { @@ -695,7 +698,7 @@ func RepoAssignableUsers(client *Client, repo ghrepo.Interface) ([]RepoAssignee, "endCursor": (*githubv4.String)(nil), } - gql := graphQLClient(client.http) + gql := graphQLClient(client.http, repo.RepoHost()) var users []RepoAssignee for { @@ -739,7 +742,7 @@ func RepoLabels(client *Client, repo ghrepo.Interface) ([]RepoLabel, error) { "endCursor": (*githubv4.String)(nil), } - gql := graphQLClient(client.http) + gql := graphQLClient(client.http, repo.RepoHost()) var labels []RepoLabel for { @@ -783,7 +786,7 @@ func RepoMilestones(client *Client, repo ghrepo.Interface) ([]RepoMilestone, err "endCursor": (*githubv4.String)(nil), } - gql := graphQLClient(client.http) + gql := graphQLClient(client.http, repo.RepoHost()) var milestones []RepoMilestone for { diff --git a/api/queries_user.go b/api/queries_user.go index ea31c59ea..0a9b68bd2 100644 --- a/api/queries_user.go +++ b/api/queries_user.go @@ -4,13 +4,13 @@ import ( "context" ) -func CurrentLoginName(client *Client) (string, error) { +func CurrentLoginName(client *Client, hostname string) (string, error) { var query struct { Viewer struct { Login string } } - gql := graphQLClient(client.http) + gql := graphQLClient(client.http, hostname) err := gql.QueryNamed(context.Background(), "UserCurrent", &query, nil) return query.Viewer.Login, err } diff --git a/auth/oauth.go b/auth/oauth.go index 3568956c5..1d421fa17 100644 --- a/auth/oauth.go +++ b/auth/oauth.go @@ -13,6 +13,7 @@ import ( "os" "strings" + "github.com/cli/cli/internal/ghinstance" "github.com/cli/cli/pkg/browser" ) @@ -52,9 +53,18 @@ func (oa *OAuthFlow) ObtainAccessToken() (accessToken string, err error) { scopes = strings.Join(oa.Scopes, " ") } + localhost := "127.0.0.1" + callbackPath := "/callback" + if ghinstance.IsEnterprise(oa.Hostname) { + // the OAuth app on Enterprise hosts is still registered with a legacy callback URL + // see https://github.com/cli/cli/pull/222, https://github.com/cli/cli/pull/650 + localhost = "localhost" + callbackPath = "/" + } + q := url.Values{} q.Set("client_id", oa.ClientID) - q.Set("redirect_uri", fmt.Sprintf("http://127.0.0.1:%d/callback", port)) + q.Set("redirect_uri", fmt.Sprintf("http://%s:%d%s", localhost, port, callbackPath)) q.Set("scope", scopes) q.Set("state", state) @@ -73,7 +83,7 @@ func (oa *OAuthFlow) ObtainAccessToken() (accessToken string, err error) { _ = http.Serve(listener, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { oa.logf("server handler: %s\n", r.URL.Path) - if r.URL.Path != "/callback" { + if r.URL.Path != callbackPath { w.WriteHeader(404) return } diff --git a/command/alias.go b/command/alias.go index 7f02b03d5..a820268fe 100644 --- a/command/alias.go +++ b/command/alias.go @@ -6,6 +6,7 @@ import ( "strings" "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/pkg/iostreams" "github.com/cli/cli/utils" "github.com/google/shlex" "github.com/spf13/cobra" @@ -166,9 +167,9 @@ func aliasList(cmd *cobra.Command, args []string) error { return nil } - stdout := colorableOut(cmd) - - tp := utils.NewTablePrinter(stdout) + tp := utils.NewTablePrinter(&iostreams.IOStreams{ + Out: cmd.OutOrStdout(), + }) aliasMap := aliasCfg.All() keys := []string{} @@ -193,7 +194,7 @@ func aliasList(cmd *cobra.Command, args []string) error { var aliasDeleteCmd = &cobra.Command{ Use: "delete ", - Short: "Delete an alias.", + Short: "Delete an alias", Args: cobra.ExactArgs(1), RunE: aliasDelete, } diff --git a/command/issue.go b/command/issue.go index 3e481ef8e..41dd30b7f 100644 --- a/command/issue.go +++ b/command/issue.go @@ -3,7 +3,6 @@ package command import ( "fmt" "io" - "net/url" "strconv" "strings" "time" @@ -12,8 +11,11 @@ import ( "github.com/cli/cli/api" "github.com/cli/cli/git" "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/githubtemplate" + "github.com/cli/cli/pkg/iostreams" + "github.com/cli/cli/pkg/text" "github.com/cli/cli/utils" "github.com/spf13/cobra" "github.com/spf13/pflag" @@ -120,57 +122,6 @@ var issueReopenCmd = &cobra.Command{ RunE: issueReopen, } -type filterOptions struct { - entity string - state string - assignee string - labels []string - author string - baseBranch string - mention string - milestone string -} - -func listURLWithQuery(listURL string, options filterOptions) (string, error) { - u, err := url.Parse(listURL) - if err != nil { - return "", err - } - query := fmt.Sprintf("is:%s ", options.entity) - if options.state != "all" { - query += fmt.Sprintf("is:%s ", options.state) - } - if options.assignee != "" { - query += fmt.Sprintf("assignee:%s ", options.assignee) - } - for _, label := range options.labels { - query += fmt.Sprintf("label:%s ", quoteValueForQuery(label)) - } - if options.author != "" { - query += fmt.Sprintf("author:%s ", options.author) - } - if options.baseBranch != "" { - query += fmt.Sprintf("base:%s ", options.baseBranch) - } - if options.mention != "" { - query += fmt.Sprintf("mentions:%s ", options.mention) - } - if options.milestone != "" { - query += fmt.Sprintf("milestone:%s ", quoteValueForQuery(options.milestone)) - } - q := u.Query() - q.Set("q", strings.TrimSuffix(query, " ")) - u.RawQuery = q.Encode() - return u.String(), nil -} - -func quoteValueForQuery(v string) string { - if strings.ContainsAny(v, " \"\t\r\n") { - return fmt.Sprintf("%q", v) - } - return v -} - func issueList(cmd *cobra.Command, args []string) error { ctx := contextForCommand(cmd) apiClient, err := apiClientForContext(ctx) @@ -228,14 +179,14 @@ func issueList(cmd *cobra.Command, args []string) error { if web { issueListURL := ghrepo.GenerateRepoURL(baseRepo, "issues") - openURL, err := listURLWithQuery(issueListURL, filterOptions{ - entity: "issue", - state: state, - assignee: assignee, - labels: labels, - author: author, - mention: mention, - milestone: milestone, + openURL, err := shared.ListURLWithQuery(issueListURL, shared.FilterOptions{ + Entity: "issue", + State: state, + Assignee: assignee, + Labels: labels, + Author: author, + Mention: mention, + Milestone: milestone, }) if err != nil { return err @@ -257,7 +208,7 @@ func issueList(cmd *cobra.Command, args []string) error { } }) - title := listHeader(ghrepo.FullName(baseRepo), "issue", len(listResult.Issues), listResult.TotalCount, hasFilters) + title := shared.ListHeader(ghrepo.FullName(baseRepo), "issue", len(listResult.Issues), listResult.TotalCount, hasFilters) if connectedToTerminal(cmd) { fmt.Fprintf(colorableErr(cmd), "\n%s\n\n", title) } @@ -281,7 +232,7 @@ func issueStatus(cmd *cobra.Command, args []string) error { return err } - currentUser, err := api.CurrentLoginName(apiClient) + currentUser, err := api.CurrentLoginName(apiClient, baseRepo.RepoHost()) if err != nil { return err } @@ -297,28 +248,28 @@ func issueStatus(cmd *cobra.Command, args []string) error { fmt.Fprintf(out, "Relevant issues in %s\n", ghrepo.FullName(baseRepo)) fmt.Fprintln(out, "") - printHeader(out, "Issues assigned to you") + shared.PrintHeader(out, "Issues assigned to you") if issuePayload.Assigned.TotalCount > 0 { printIssues(out, " ", issuePayload.Assigned.TotalCount, issuePayload.Assigned.Issues) } else { message := " There are no issues assigned to you" - printMessage(out, message) + shared.PrintMessage(out, message) } fmt.Fprintln(out) - printHeader(out, "Issues mentioning you") + shared.PrintHeader(out, "Issues mentioning you") if issuePayload.Mentioned.TotalCount > 0 { printIssues(out, " ", issuePayload.Mentioned.TotalCount, issuePayload.Mentioned.Issues) } else { - printMessage(out, " There are no issues mentioning you") + shared.PrintMessage(out, " There are no issues mentioning you") } fmt.Fprintln(out) - printHeader(out, "Issues opened by you") + shared.PrintHeader(out, "Issues opened by you") if issuePayload.Authored.TotalCount > 0 { printIssues(out, " ", issuePayload.Authored.TotalCount, issuePayload.Authored.Issues) } else { - printMessage(out, " There are no issues opened by you") + shared.PrintMessage(out, " There are no issues opened by you") } fmt.Fprintln(out) @@ -356,29 +307,10 @@ func issueView(cmd *cobra.Command, args []string) error { } func issueStateTitleWithColor(state string) string { - colorFunc := colorFuncForState(state) + colorFunc := shared.ColorFuncForState(state) return colorFunc(strings.Title(strings.ToLower(state))) } -func listHeader(repoName string, itemName string, matchCount int, totalMatchCount int, hasFilters bool) string { - if totalMatchCount == 0 { - if hasFilters { - return fmt.Sprintf("No %ss match your search in %s", itemName, repoName) - } - return fmt.Sprintf("There are no open %ss in %s", itemName, repoName) - } - - if hasFilters { - matchVerb := "match" - if totalMatchCount == 1 { - matchVerb = "matches" - } - return fmt.Sprintf("Showing %d of %s in %s that %s your search", matchCount, utils.Pluralize(totalMatchCount, itemName), repoName, matchVerb) - } - - return fmt.Sprintf("Showing %d of %s in %s", matchCount, utils.Pluralize(totalMatchCount, fmt.Sprintf("open %s", itemName)), repoName) -} - func printRawIssuePreview(out io.Writer, issue *api.Issue) error { assignees := issueAssigneeList(*issue) labels := issueLabelList(*issue) @@ -506,11 +438,7 @@ func issueCreate(cmd *cobra.Command, args []string) error { if isWeb, err := cmd.Flags().GetBool("web"); err == nil && isWeb { openURL := ghrepo.GenerateRepoURL(baseRepo, "issues/new") if title != "" || body != "" { - milestone := "" - if len(milestoneTitles) > 0 { - milestone = milestoneTitles[0] - } - openURL, err = withPrAndIssueQueryParams(openURL, title, body, assignees, labelNames, projectNames, milestone) + openURL, err = shared.WithPrAndIssueQueryParams(openURL, title, body, assignees, labelNames, projectNames, milestoneTitles) if err != nil { return err } @@ -533,9 +461,9 @@ func issueCreate(cmd *cobra.Command, args []string) error { return fmt.Errorf("the '%s' repository has disabled issues", ghrepo.FullName(baseRepo)) } - action := SubmitAction - tb := issueMetadataState{ - Type: issueMetadata, + action := shared.SubmitAction + tb := shared.IssueMetadataState{ + Type: shared.IssueMetadata, Assignees: assignees, Labels: labelNames, Projects: projectNames, @@ -556,14 +484,20 @@ func issueCreate(cmd *cobra.Command, args []string) error { legacyTemplateFile = githubtemplate.FindLegacy(rootDir, "ISSUE_TEMPLATE") } } - err := titleBodySurvey(cmd, &tb, apiClient, baseRepo, title, body, defaults{}, nonLegacyTemplateFiles, legacyTemplateFile, false, repo.ViewerCanTriage()) + + editorCommand, err := cmdutil.DetermineEditor(ctx.Config) + if err != nil { + return err + } + + err = shared.TitleBodySurvey(defaultStreams, editorCommand, &tb, apiClient, baseRepo, title, body, shared.Defaults{}, nonLegacyTemplateFiles, legacyTemplateFile, false, repo.ViewerCanTriage()) if err != nil { return fmt.Errorf("could not collect title and/or body: %w", err) } action = tb.Action - if tb.Action == CancelAction { + if tb.Action == shared.CancelAction { fmt.Fprintln(cmd.ErrOrStderr(), "Discarding.") return nil @@ -581,26 +515,22 @@ func issueCreate(cmd *cobra.Command, args []string) error { } } - if action == PreviewAction { + if action == shared.PreviewAction { openURL := ghrepo.GenerateRepoURL(baseRepo, "issues/new") - milestone := "" - if len(milestoneTitles) > 0 { - milestone = milestoneTitles[0] - } - openURL, err = withPrAndIssueQueryParams(openURL, title, body, assignees, labelNames, projectNames, milestone) + openURL, err = shared.WithPrAndIssueQueryParams(openURL, title, body, assignees, labelNames, projectNames, milestoneTitles) if err != nil { return err } // TODO could exceed max url length for explorer fmt.Fprintf(cmd.ErrOrStderr(), "Opening %s in your browser.\n", utils.DisplayURL(openURL)) return utils.OpenInBrowser(openURL) - } else if action == SubmitAction { + } else if action == shared.SubmitAction { params := map[string]interface{}{ "title": title, "body": body, } - err = addMetadataToIssueParams(apiClient, baseRepo, params, &tb) + err = shared.AddMetadataToIssueParams(apiClient, baseRepo, params, &tb) if err != nil { return err } @@ -618,84 +548,10 @@ func issueCreate(cmd *cobra.Command, args []string) error { return nil } -func addMetadataToIssueParams(client *api.Client, baseRepo ghrepo.Interface, params map[string]interface{}, tb *issueMetadataState) error { - if !tb.HasMetadata() { - return nil - } - - if tb.MetadataResult == nil { - resolveInput := api.RepoResolveInput{ - Reviewers: tb.Reviewers, - Assignees: tb.Assignees, - Labels: tb.Labels, - Projects: tb.Projects, - Milestones: tb.Milestones, - } - - var err error - tb.MetadataResult, err = api.RepoResolveMetadataIDs(client, baseRepo, resolveInput) - if err != nil { - return err - } - } - - assigneeIDs, err := tb.MetadataResult.MembersToIDs(tb.Assignees) - if err != nil { - return fmt.Errorf("could not assign user: %w", err) - } - params["assigneeIds"] = assigneeIDs - - labelIDs, err := tb.MetadataResult.LabelsToIDs(tb.Labels) - if err != nil { - return fmt.Errorf("could not add label: %w", err) - } - params["labelIds"] = labelIDs - - projectIDs, err := tb.MetadataResult.ProjectsToIDs(tb.Projects) - if err != nil { - return fmt.Errorf("could not add to project: %w", err) - } - params["projectIds"] = projectIDs - - if len(tb.Milestones) > 0 { - milestoneID, err := tb.MetadataResult.MilestoneToID(tb.Milestones[0]) - if err != nil { - return fmt.Errorf("could not add to milestone '%s': %w", tb.Milestones[0], err) - } - params["milestoneId"] = milestoneID - } - - if len(tb.Reviewers) == 0 { - return nil - } - - var userReviewers []string - var teamReviewers []string - for _, r := range tb.Reviewers { - if strings.ContainsRune(r, '/') { - teamReviewers = append(teamReviewers, r) - } else { - userReviewers = append(userReviewers, r) - } - } - - userReviewerIDs, err := tb.MetadataResult.MembersToIDs(userReviewers) - if err != nil { - return fmt.Errorf("could not request reviewer: %w", err) - } - params["userReviewerIds"] = userReviewerIDs - - teamReviewerIDs, err := tb.MetadataResult.TeamsToIDs(teamReviewers) - if err != nil { - return fmt.Errorf("could not request reviewer: %w", err) - } - params["teamReviewerIds"] = teamReviewerIDs - - return nil -} - func printIssues(w io.Writer, prefix string, totalCount int, issues []api.Issue) { - table := utils.NewTablePrinter(w) + io := &iostreams.IOStreams{Out: w} + io.SetStdoutTTY(utils.IsTerminal(w)) + table := utils.NewTablePrinter(io) for _, issue := range issues { issueNum := strconv.Itoa(issue.Number) if table.IsTTY() { @@ -708,11 +564,11 @@ func printIssues(w io.Writer, prefix string, totalCount int, issues []api.Issue) } now := time.Now() ago := now.Sub(issue.UpdatedAt) - table.AddField(issueNum, nil, colorFuncForState(issue.State)) + table.AddField(issueNum, nil, shared.ColorFuncForState(issue.State)) if !table.IsTTY() { table.AddField(issue.State, nil, nil) } - table.AddField(replaceExcessiveWhitespace(issue.Title), nil, nil) + table.AddField(text.ReplaceExcessiveWhitespace(issue.Title), nil, nil) table.AddField(labels, nil, utils.Gray) if table.IsTTY() { table.AddField(utils.FuzzyAgo(ago), nil, utils.Gray) diff --git a/command/issue_test.go b/command/issue_test.go index 8cde92d6e..16d613d72 100644 --- a/command/issue_test.go +++ b/command/issue_test.go @@ -803,117 +803,6 @@ func TestIssueCreate_webTitleBody(t *testing.T) { eq(t, output.String(), "Opening github.com/OWNER/REPO/issues/new in your browser.\n") } -func Test_listHeader(t *testing.T) { - type args struct { - repoName string - itemName string - matchCount int - totalMatchCount int - hasFilters bool - } - tests := []struct { - name string - args args - want string - }{ - { - name: "no results", - args: args{ - repoName: "REPO", - itemName: "table", - matchCount: 0, - totalMatchCount: 0, - hasFilters: false, - }, - want: "There are no open tables in REPO", - }, - { - name: "no matches after filters", - args: args{ - repoName: "REPO", - itemName: "Luftballon", - matchCount: 0, - totalMatchCount: 0, - hasFilters: true, - }, - want: "No Luftballons match your search in REPO", - }, - { - name: "one result", - args: args{ - repoName: "REPO", - itemName: "genie", - matchCount: 1, - totalMatchCount: 23, - hasFilters: false, - }, - want: "Showing 1 of 23 open genies in REPO", - }, - { - name: "one result after filters", - args: args{ - repoName: "REPO", - itemName: "tiny cup", - matchCount: 1, - totalMatchCount: 23, - hasFilters: true, - }, - want: "Showing 1 of 23 tiny cups in REPO that match your search", - }, - { - name: "one result in total", - args: args{ - repoName: "REPO", - itemName: "chip", - matchCount: 1, - totalMatchCount: 1, - hasFilters: false, - }, - want: "Showing 1 of 1 open chip in REPO", - }, - { - name: "one result in total after filters", - args: args{ - repoName: "REPO", - itemName: "spicy noodle", - matchCount: 1, - totalMatchCount: 1, - hasFilters: true, - }, - want: "Showing 1 of 1 spicy noodle in REPO that matches your search", - }, - { - name: "multiple results", - args: args{ - repoName: "REPO", - itemName: "plant", - matchCount: 4, - totalMatchCount: 23, - hasFilters: false, - }, - want: "Showing 4 of 23 open plants in REPO", - }, - { - name: "multiple results after filters", - args: args{ - repoName: "REPO", - itemName: "boomerang", - matchCount: 4, - totalMatchCount: 23, - hasFilters: true, - }, - want: "Showing 4 of 23 boomerangs in REPO that match your search", - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := listHeader(tt.args.repoName, tt.args.itemName, tt.args.matchCount, tt.args.totalMatchCount, tt.args.hasFilters); got != tt.want { - t.Errorf("listHeader() = %v, want %v", got, tt.want) - } - }) - } -} - func TestIssueStateTitleWithColor(t *testing.T) { tests := map[string]struct { state string @@ -1071,71 +960,3 @@ func TestIssueReopen_issuesDisabled(t *testing.T) { t.Fatalf("got error: %v", err) } } - -func Test_listURLWithQuery(t *testing.T) { - type args struct { - listURL string - options filterOptions - } - tests := []struct { - name string - args args - want string - wantErr bool - }{ - { - name: "blank", - args: args{ - listURL: "https://example.com/path?a=b", - options: filterOptions{ - entity: "issue", - state: "open", - }, - }, - want: "https://example.com/path?a=b&q=is%3Aissue+is%3Aopen", - wantErr: false, - }, - { - name: "all", - args: args{ - listURL: "https://example.com/path", - options: filterOptions{ - entity: "issue", - state: "open", - assignee: "bo", - author: "ka", - baseBranch: "trunk", - mention: "nu", - }, - }, - want: "https://example.com/path?q=is%3Aissue+is%3Aopen+assignee%3Abo+author%3Aka+base%3Atrunk+mentions%3Anu", - wantErr: false, - }, - { - name: "spaces in values", - args: args{ - listURL: "https://example.com/path", - options: filterOptions{ - entity: "pr", - state: "open", - labels: []string{"docs", "help wanted"}, - milestone: `Codename "What Was Missing"`, - }, - }, - want: "https://example.com/path?q=is%3Apr+is%3Aopen+label%3Adocs+label%3A%22help+wanted%22+milestone%3A%22Codename+%5C%22What+Was+Missing%5C%22%22", - wantErr: false, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, err := listURLWithQuery(tt.args.listURL, tt.args.options) - if (err != nil) != tt.wantErr { - t.Errorf("listURLWithQuery() error = %v, wantErr %v", err, tt.wantErr) - return - } - if got != tt.want { - t.Errorf("listURLWithQuery() = %v, want %v", got, tt.want) - } - }) - } -} diff --git a/command/pr.go b/command/pr.go deleted file mode 100644 index 3f8e53481..000000000 --- a/command/pr.go +++ /dev/null @@ -1,1047 +0,0 @@ -package command - -import ( - "errors" - "fmt" - "io" - "regexp" - "sort" - "strconv" - "strings" - - "github.com/AlecAivazis/survey/v2" - "github.com/MakeNowJust/heredoc" - "github.com/cli/cli/api" - "github.com/cli/cli/context" - "github.com/cli/cli/git" - "github.com/cli/cli/internal/ghrepo" - "github.com/cli/cli/pkg/cmdutil" - "github.com/cli/cli/pkg/prompt" - "github.com/cli/cli/pkg/text" - "github.com/cli/cli/utils" - "github.com/spf13/cobra" - "github.com/spf13/pflag" -) - -func init() { - prCmd.PersistentFlags().StringP("repo", "R", "", "Select another repository using the `OWNER/REPO` format") - - RootCmd.AddCommand(prCmd) - prCmd.AddCommand(prCheckoutCmd) - prCmd.AddCommand(prCreateCmd) - prCmd.AddCommand(prStatusCmd) - prCmd.AddCommand(prCloseCmd) - prCmd.AddCommand(prReopenCmd) - prCmd.AddCommand(prMergeCmd) - prMergeCmd.Flags().BoolP("delete-branch", "d", true, "Delete the local and remote branch after merge") - prMergeCmd.Flags().BoolP("merge", "m", false, "Merge the commits with the base branch") - prMergeCmd.Flags().BoolP("rebase", "r", false, "Rebase the commits onto the base branch") - prMergeCmd.Flags().BoolP("squash", "s", false, "Squash the commits into one commit and merge it into the base branch") - prCmd.AddCommand(prReadyCmd) - - prCmd.AddCommand(prListCmd) - prListCmd.Flags().BoolP("web", "w", false, "Open the browser to list the pull request(s)") - prListCmd.Flags().IntP("limit", "L", 30, "Maximum number of items to fetch") - prListCmd.Flags().StringP("state", "s", "open", "Filter by state: {open|closed|merged|all}") - prListCmd.Flags().StringP("base", "B", "", "Filter by base branch") - prListCmd.Flags().StringSliceP("label", "l", nil, "Filter by labels") - prListCmd.Flags().StringP("assignee", "a", "", "Filter by assignee") - - prCmd.AddCommand(prViewCmd) - prViewCmd.Flags().BoolP("web", "w", false, "Open a pull request in the browser") -} - -var prCmd = &cobra.Command{ - Use: "pr ", - Short: "Create, view, and checkout pull requests", - Long: `Work with GitHub pull requests`, - Example: heredoc.Doc(` - $ gh pr checkout 353 - $ gh pr create --fill - $ gh pr view --web - `), - Annotations: map[string]string{ - "IsCore": "true", - "help:arguments": `A pull request can be supplied as argument in any of the following formats: -- by number, e.g. "123"; -- by URL, e.g. "https://github.com/OWNER/REPO/pull/123"; or -- by the name of its head branch, e.g. "patch-1" or "OWNER:patch-1".`}, -} -var prListCmd = &cobra.Command{ - Use: "list", - Short: "List and filter pull requests in this repository", - Args: cmdutil.NoArgsQuoteReminder, - Example: heredoc.Doc(` - $ gh pr list --limit 999 - $ gh pr list --state closed - $ gh pr list --label "priority 1" --label "bug" - $ gh pr list --web - `), - RunE: prList, -} -var prStatusCmd = &cobra.Command{ - Use: "status", - Short: "Show status of relevant pull requests", - Args: cmdutil.NoArgsQuoteReminder, - RunE: prStatus, -} -var prViewCmd = &cobra.Command{ - Use: "view [ | | ]", - Short: "View a pull request", - Long: `Display the title, body, and other information about a pull request. - -Without an argument, the pull request that belongs to the current branch -is displayed. - -With '--web', open the pull request in a web browser instead.`, - RunE: prView, -} -var prCloseCmd = &cobra.Command{ - Use: "close { | | }", - Short: "Close a pull request", - Args: cobra.ExactArgs(1), - RunE: prClose, -} -var prReopenCmd = &cobra.Command{ - Use: "reopen { | | }", - Short: "Reopen a pull request", - Args: cobra.ExactArgs(1), - RunE: prReopen, -} -var prMergeCmd = &cobra.Command{ - Use: "merge [ | | ]", - Short: "Merge a pull request", - Long: heredoc.Doc(` - Merge a pull request on GitHub. - - By default, the head branch of the pull request will get deleted on both remote and local repositories. - To retain the branch, use '--delete-branch=false'. - `), - Args: cobra.MaximumNArgs(1), - RunE: prMerge, -} -var prReadyCmd = &cobra.Command{ - Use: "ready [ | | ]", - Short: "Mark a pull request as ready for review", - Args: cobra.MaximumNArgs(1), - RunE: prReady, -} - -func prStatus(cmd *cobra.Command, args []string) error { - ctx := contextForCommand(cmd) - apiClient, err := apiClientForContext(ctx) - if err != nil { - return err - } - - baseRepo, err := determineBaseRepo(apiClient, cmd, ctx) - if err != nil { - return err - } - - repoOverride, _ := cmd.Flags().GetString("repo") - currentPRNumber, currentPRHeadRef, err := prSelectorForCurrentBranch(ctx, baseRepo) - - if err != nil && repoOverride == "" && !errors.Is(err, git.ErrNotOnAnyBranch) { - return fmt.Errorf("could not query for pull request for current branch: %w", err) - } - - // the `@me` macro is available because the API lookup is ElasticSearch-based - currentUser := "@me" - prPayload, err := api.PullRequests(apiClient, baseRepo, currentPRNumber, currentPRHeadRef, currentUser) - if err != nil { - return err - } - - out := colorableOut(cmd) - - fmt.Fprintln(out, "") - fmt.Fprintf(out, "Relevant pull requests in %s\n", ghrepo.FullName(baseRepo)) - fmt.Fprintln(out, "") - - printHeader(out, "Current branch") - currentPR := prPayload.CurrentPR - currentBranch, _ := ctx.Branch() - if currentPR != nil && currentPR.State != "OPEN" && prPayload.DefaultBranch == currentBranch { - currentPR = nil - } - if currentPR != nil { - printPrs(out, 1, *currentPR) - } else if currentPRHeadRef == "" { - printMessage(out, " There is no current branch") - } else { - printMessage(out, fmt.Sprintf(" There is no pull request associated with %s", utils.Cyan("["+currentPRHeadRef+"]"))) - } - fmt.Fprintln(out) - - printHeader(out, "Created by you") - if prPayload.ViewerCreated.TotalCount > 0 { - printPrs(out, prPayload.ViewerCreated.TotalCount, prPayload.ViewerCreated.PullRequests...) - } else { - printMessage(out, " You have no open pull requests") - } - fmt.Fprintln(out) - - printHeader(out, "Requesting a code review from you") - if prPayload.ReviewRequested.TotalCount > 0 { - printPrs(out, prPayload.ReviewRequested.TotalCount, prPayload.ReviewRequested.PullRequests...) - } else { - printMessage(out, " You have no pull requests to review") - } - fmt.Fprintln(out) - - return nil -} - -func prList(cmd *cobra.Command, args []string) error { - ctx := contextForCommand(cmd) - apiClient, err := apiClientForContext(ctx) - if err != nil { - return err - } - - baseRepo, err := determineBaseRepo(apiClient, cmd, ctx) - if err != nil { - return err - } - - web, err := cmd.Flags().GetBool("web") - if err != nil { - return err - } - - limit, err := cmd.Flags().GetInt("limit") - if err != nil { - return err - } - if limit <= 0 { - return fmt.Errorf("invalid limit: %v", limit) - } - - state, err := cmd.Flags().GetString("state") - if err != nil { - return err - } - baseBranch, err := cmd.Flags().GetString("base") - if err != nil { - return err - } - labels, err := cmd.Flags().GetStringSlice("label") - if err != nil { - return err - } - assignee, err := cmd.Flags().GetString("assignee") - if err != nil { - return err - } - - if web { - prListURL := ghrepo.GenerateRepoURL(baseRepo, "pulls") - openURL, err := listURLWithQuery(prListURL, filterOptions{ - entity: "pr", - state: state, - assignee: assignee, - labels: labels, - baseBranch: baseBranch, - }) - if err != nil { - return err - } - fmt.Fprintf(cmd.ErrOrStderr(), "Opening %s in your browser.\n", utils.DisplayURL(openURL)) - return utils.OpenInBrowser(openURL) - } - - var graphqlState []string - switch state { - case "open": - graphqlState = []string{"OPEN"} - case "closed": - graphqlState = []string{"CLOSED", "MERGED"} - case "merged": - graphqlState = []string{"MERGED"} - case "all": - graphqlState = []string{"OPEN", "CLOSED", "MERGED"} - default: - return fmt.Errorf("invalid state: %s", state) - } - - params := map[string]interface{}{ - "owner": baseRepo.RepoOwner(), - "repo": baseRepo.RepoName(), - "state": graphqlState, - } - if len(labels) > 0 { - params["labels"] = labels - } - if baseBranch != "" { - params["baseBranch"] = baseBranch - } - if assignee != "" { - params["assignee"] = assignee - } - - listResult, err := api.PullRequestList(apiClient, params, limit) - if err != nil { - return err - } - - hasFilters := false - cmd.Flags().Visit(func(f *pflag.Flag) { - switch f.Name { - case "state", "label", "base", "assignee": - hasFilters = true - } - }) - - title := listHeader(ghrepo.FullName(baseRepo), "pull request", len(listResult.PullRequests), listResult.TotalCount, hasFilters) - if connectedToTerminal(cmd) { - fmt.Fprintf(colorableErr(cmd), "\n%s\n\n", title) - } - - table := utils.NewTablePrinter(cmd.OutOrStdout()) - for _, pr := range listResult.PullRequests { - prNum := strconv.Itoa(pr.Number) - if table.IsTTY() { - prNum = "#" + prNum - } - table.AddField(prNum, nil, colorFuncForPR(pr)) - table.AddField(replaceExcessiveWhitespace(pr.Title), nil, nil) - table.AddField(pr.HeadLabel(), nil, utils.Cyan) - if !table.IsTTY() { - table.AddField(prStateWithDraft(&pr), nil, nil) - } - table.EndRow() - } - err = table.Render() - if err != nil { - return err - } - - return nil -} - -func prStateTitleWithColor(pr api.PullRequest) string { - prStateColorFunc := colorFuncForPR(pr) - if pr.State == "OPEN" && pr.IsDraft { - return prStateColorFunc(strings.Title(strings.ToLower("Draft"))) - } - return prStateColorFunc(strings.Title(strings.ToLower(pr.State))) -} - -func colorFuncForPR(pr api.PullRequest) func(string) string { - if pr.State == "OPEN" && pr.IsDraft { - return utils.Gray - } - return colorFuncForState(pr.State) -} - -// colorFuncForState returns a color function for a PR/Issue state -func colorFuncForState(state string) func(string) string { - switch state { - case "OPEN": - return utils.Green - case "CLOSED": - return utils.Red - case "MERGED": - return utils.Magenta - default: - return nil - } -} - -func prView(cmd *cobra.Command, args []string) error { - ctx := contextForCommand(cmd) - - apiClient, err := apiClientForContext(ctx) - if err != nil { - return err - } - - web, err := cmd.Flags().GetBool("web") - if err != nil { - return err - } - - pr, _, err := prFromArgs(ctx, apiClient, cmd, args) - if err != nil { - return err - } - openURL := pr.URL - - if web { - if connectedToTerminal(cmd) { - fmt.Fprintf(cmd.ErrOrStderr(), "Opening %s in your browser.\n", openURL) - } - return utils.OpenInBrowser(openURL) - } - - if connectedToTerminal(cmd) { - out := colorableOut(cmd) - return printHumanPrPreview(out, pr) - } - - return printRawPrPreview(cmd.OutOrStdout(), pr) -} - -func prClose(cmd *cobra.Command, args []string) error { - ctx := contextForCommand(cmd) - apiClient, err := apiClientForContext(ctx) - if err != nil { - return err - } - - pr, baseRepo, err := prFromArgs(ctx, apiClient, cmd, args) - if err != nil { - return err - } - - if pr.State == "MERGED" { - err := fmt.Errorf("%s Pull request #%d (%s) can't be closed because it was already merged", utils.Red("!"), pr.Number, pr.Title) - return err - } else if pr.Closed { - fmt.Fprintf(colorableErr(cmd), "%s Pull request #%d (%s) is already closed\n", utils.Yellow("!"), pr.Number, pr.Title) - return nil - } - - err = api.PullRequestClose(apiClient, baseRepo, pr) - if err != nil { - return fmt.Errorf("API call failed: %w", err) - } - - fmt.Fprintf(colorableErr(cmd), "%s Closed pull request #%d (%s)\n", utils.Red("✔"), pr.Number, pr.Title) - - return nil -} - -func prReopen(cmd *cobra.Command, args []string) error { - ctx := contextForCommand(cmd) - apiClient, err := apiClientForContext(ctx) - if err != nil { - return err - } - - pr, baseRepo, err := prFromArgs(ctx, apiClient, cmd, args) - if err != nil { - return err - } - - if pr.State == "MERGED" { - err := fmt.Errorf("%s Pull request #%d (%s) can't be reopened because it was already merged", utils.Red("!"), pr.Number, pr.Title) - return err - } - - if !pr.Closed { - fmt.Fprintf(colorableErr(cmd), "%s Pull request #%d (%s) is already open\n", utils.Yellow("!"), pr.Number, pr.Title) - return nil - } - - err = api.PullRequestReopen(apiClient, baseRepo, pr) - if err != nil { - return fmt.Errorf("API call failed: %w", err) - } - - fmt.Fprintf(colorableErr(cmd), "%s Reopened pull request #%d (%s)\n", utils.Green("✔"), pr.Number, pr.Title) - - return nil -} - -func prMerge(cmd *cobra.Command, args []string) error { - ctx := contextForCommand(cmd) - apiClient, err := apiClientForContext(ctx) - if err != nil { - return err - } - - pr, baseRepo, err := prFromArgs(ctx, apiClient, cmd, args) - if err != nil { - return err - } - - if pr.Mergeable == "CONFLICTING" { - err := fmt.Errorf("%s Pull request #%d (%s) has conflicts and isn't mergeable ", utils.Red("!"), pr.Number, pr.Title) - return err - } else if pr.Mergeable == "UNKNOWN" { - err := fmt.Errorf("%s Pull request #%d (%s) can't be merged right now; try again in a few seconds", utils.Red("!"), pr.Number, pr.Title) - return err - } else if pr.State == "MERGED" { - err := fmt.Errorf("%s Pull request #%d (%s) was already merged", utils.Red("!"), pr.Number, pr.Title) - return err - } - - var mergeMethod api.PullRequestMergeMethod - deleteBranch, err := cmd.Flags().GetBool("delete-branch") - if err != nil { - return err - } - - deleteLocalBranch := !cmd.Flags().Changed("repo") - crossRepoPR := pr.HeadRepositoryOwner.Login != baseRepo.RepoOwner() - - // Ensure only one merge method is specified - enabledFlagCount := 0 - isInteractive := false - if b, _ := cmd.Flags().GetBool("merge"); b { - enabledFlagCount++ - mergeMethod = api.PullRequestMergeMethodMerge - } - if b, _ := cmd.Flags().GetBool("rebase"); b { - enabledFlagCount++ - mergeMethod = api.PullRequestMergeMethodRebase - } - if b, _ := cmd.Flags().GetBool("squash"); b { - enabledFlagCount++ - mergeMethod = api.PullRequestMergeMethodSquash - } - - if enabledFlagCount == 0 { - if !connectedToTerminal(cmd) { - return errors.New("--merge, --rebase, or --squash required when not attached to a tty") - } - isInteractive = true - } else if enabledFlagCount > 1 { - return errors.New("expected exactly one of --merge, --rebase, or --squash to be true") - } - - if isInteractive { - mergeMethod, deleteBranch, err = prInteractiveMerge(deleteLocalBranch, crossRepoPR) - if err != nil { - return nil - } - } - - var action string - if mergeMethod == api.PullRequestMergeMethodRebase { - action = "Rebased and merged" - err = api.PullRequestMerge(apiClient, baseRepo, pr, api.PullRequestMergeMethodRebase) - } else if mergeMethod == api.PullRequestMergeMethodSquash { - action = "Squashed and merged" - err = api.PullRequestMerge(apiClient, baseRepo, pr, api.PullRequestMergeMethodSquash) - } else if mergeMethod == api.PullRequestMergeMethodMerge { - action = "Merged" - err = api.PullRequestMerge(apiClient, baseRepo, pr, api.PullRequestMergeMethodMerge) - } else { - err = fmt.Errorf("unknown merge method (%d) used", mergeMethod) - return err - } - - if err != nil { - return fmt.Errorf("API call failed: %w", err) - } - - if connectedToTerminal(cmd) { - fmt.Fprintf(colorableErr(cmd), "%s %s pull request #%d (%s)\n", utils.Magenta("✔"), action, pr.Number, pr.Title) - } - - if deleteBranch { - branchSwitchString := "" - - if deleteLocalBranch && !crossRepoPR { - currentBranch, err := ctx.Branch() - if err != nil { - return err - } - - var branchToSwitchTo string - if currentBranch == pr.HeadRefName { - branchToSwitchTo, err = api.RepoDefaultBranch(apiClient, baseRepo) - if err != nil { - return err - } - err = git.CheckoutBranch(branchToSwitchTo) - if err != nil { - return err - } - } - - localBranchExists := git.HasLocalBranch(pr.HeadRefName) - if localBranchExists { - err = git.DeleteLocalBranch(pr.HeadRefName) - if err != nil { - err = fmt.Errorf("failed to delete local branch %s: %w", utils.Cyan(pr.HeadRefName), err) - return err - } - } - - if branchToSwitchTo != "" { - branchSwitchString = fmt.Sprintf(" and switched to branch %s", utils.Cyan(branchToSwitchTo)) - } - } - - if !crossRepoPR { - err = api.BranchDeleteRemote(apiClient, baseRepo, pr.HeadRefName) - var httpErr api.HTTPError - // The ref might have already been deleted by GitHub - if err != nil && (!errors.As(err, &httpErr) || httpErr.StatusCode != 422) { - err = fmt.Errorf("failed to delete remote branch %s: %w", utils.Cyan(pr.HeadRefName), err) - return err - } - } - - if connectedToTerminal(cmd) { - fmt.Fprintf(colorableErr(cmd), "%s Deleted branch %s%s\n", utils.Red("✔"), utils.Cyan(pr.HeadRefName), branchSwitchString) - } - } - - return nil -} - -func prInteractiveMerge(deleteLocalBranch bool, 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", - }, - } - - qs := []*survey.Question{mergeMethodQuestion} - - if !crossRepoPR { - var message string - if deleteLocalBranch { - message = "Delete the branch locally and on GitHub?" - } else { - message = "Delete the branch on GitHub?" - } - - deleteBranchQuestion := &survey.Question{ - Name: "deleteBranch", - Prompt: &survey.Confirm{ - Message: message, - Default: true, - }, - } - qs = append(qs, deleteBranchQuestion) - } - - answers := struct { - MergeMethod int - DeleteBranch bool - }{} - - err := prompt.SurveyAsk(qs, &answers) - if err != nil { - return 0, false, fmt.Errorf("could not prompt: %w", err) - } - - var mergeMethod api.PullRequestMergeMethod - switch answers.MergeMethod { - case 0: - mergeMethod = api.PullRequestMergeMethodMerge - case 1: - mergeMethod = api.PullRequestMergeMethodRebase - case 2: - mergeMethod = api.PullRequestMergeMethodSquash - } - - deleteBranch := answers.DeleteBranch - return mergeMethod, deleteBranch, nil -} - -func prStateWithDraft(pr *api.PullRequest) string { - if pr.IsDraft && pr.State == "OPEN" { - return "DRAFT" - } - - return pr.State -} - -func printRawPrPreview(out io.Writer, pr *api.PullRequest) error { - reviewers := prReviewerList(*pr) - assignees := prAssigneeList(*pr) - labels := prLabelList(*pr) - projects := prProjectList(*pr) - - fmt.Fprintf(out, "title:\t%s\n", pr.Title) - fmt.Fprintf(out, "state:\t%s\n", prStateWithDraft(pr)) - fmt.Fprintf(out, "author:\t%s\n", pr.Author.Login) - fmt.Fprintf(out, "labels:\t%s\n", labels) - fmt.Fprintf(out, "assignees:\t%s\n", assignees) - fmt.Fprintf(out, "reviewers:\t%s\n", reviewers) - fmt.Fprintf(out, "projects:\t%s\n", projects) - fmt.Fprintf(out, "milestone:\t%s\n", pr.Milestone.Title) - - fmt.Fprintln(out, "--") - fmt.Fprintln(out, pr.Body) - - return nil -} - -func printHumanPrPreview(out io.Writer, pr *api.PullRequest) error { - // Header (Title and State) - fmt.Fprintln(out, utils.Bold(pr.Title)) - fmt.Fprintf(out, "%s", prStateTitleWithColor(*pr)) - fmt.Fprintln(out, utils.Gray(fmt.Sprintf( - " • %s wants to merge %s into %s from %s", - pr.Author.Login, - utils.Pluralize(pr.Commits.TotalCount, "commit"), - pr.BaseRefName, - pr.HeadRefName, - ))) - fmt.Fprintln(out) - - // Metadata - if reviewers := prReviewerList(*pr); reviewers != "" { - fmt.Fprint(out, utils.Bold("Reviewers: ")) - fmt.Fprintln(out, reviewers) - } - if assignees := prAssigneeList(*pr); assignees != "" { - fmt.Fprint(out, utils.Bold("Assignees: ")) - fmt.Fprintln(out, assignees) - } - if labels := prLabelList(*pr); labels != "" { - fmt.Fprint(out, utils.Bold("Labels: ")) - fmt.Fprintln(out, labels) - } - if projects := prProjectList(*pr); projects != "" { - fmt.Fprint(out, utils.Bold("Projects: ")) - fmt.Fprintln(out, projects) - } - if pr.Milestone.Title != "" { - fmt.Fprint(out, utils.Bold("Milestone: ")) - fmt.Fprintln(out, pr.Milestone.Title) - } - - // Body - if pr.Body != "" { - fmt.Fprintln(out) - md, err := utils.RenderMarkdown(pr.Body) - if err != nil { - return err - } - fmt.Fprintln(out, md) - } - fmt.Fprintln(out) - - // Footer - fmt.Fprintf(out, utils.Gray("View this pull request on GitHub: %s\n"), pr.URL) - return nil -} - -func prReady(cmd *cobra.Command, args []string) error { - ctx := contextForCommand(cmd) - apiClient, err := apiClientForContext(ctx) - if err != nil { - return err - } - - pr, baseRepo, err := prFromArgs(ctx, apiClient, cmd, args) - if err != nil { - return err - } - - if pr.Closed { - err := fmt.Errorf("%s Pull request #%d is closed. Only draft pull requests can be marked as \"ready for review\"", utils.Red("!"), pr.Number) - return err - } else if !pr.IsDraft { - fmt.Fprintf(colorableErr(cmd), "%s Pull request #%d is already \"ready for review\"\n", utils.Yellow("!"), pr.Number) - return nil - } - - err = api.PullRequestReady(apiClient, baseRepo, pr) - if err != nil { - return fmt.Errorf("API call failed: %w", err) - } - - fmt.Fprintf(colorableErr(cmd), "%s Pull request #%d is marked as \"ready for review\"\n", utils.Green("✔"), pr.Number) - - return nil -} - -// Ref. https://developer.github.com/v4/enum/pullrequestreviewstate/ -const ( - requestedReviewState = "REQUESTED" // This is our own state for review request - approvedReviewState = "APPROVED" - changesRequestedReviewState = "CHANGES_REQUESTED" - commentedReviewState = "COMMENTED" - dismissedReviewState = "DISMISSED" - pendingReviewState = "PENDING" -) - -type reviewerState struct { - Name string - State string -} - -// colorFuncForReviewerState returns a color function for a reviewer state -func colorFuncForReviewerState(state string) func(string) string { - switch state { - case requestedReviewState: - return utils.Yellow - case approvedReviewState: - return utils.Green - case changesRequestedReviewState: - return utils.Red - case commentedReviewState: - return func(str string) string { return str } // Do nothing - default: - return nil - } -} - -// formattedReviewerState formats a reviewerState with state color -func formattedReviewerState(reviewer *reviewerState) string { - state := reviewer.State - if state == dismissedReviewState { - // Show "DISMISSED" review as "COMMENTED", since "dimissed" only makes - // sense when displayed in an events timeline but not in the final tally. - state = commentedReviewState - } - stateColorFunc := colorFuncForReviewerState(state) - return fmt.Sprintf("%s (%s)", reviewer.Name, stateColorFunc(strings.ReplaceAll(strings.Title(strings.ToLower(state)), "_", " "))) -} - -// prReviewerList generates a reviewer list with their last state -func prReviewerList(pr api.PullRequest) string { - reviewerStates := parseReviewers(pr) - reviewers := make([]string, 0, len(reviewerStates)) - - sortReviewerStates(reviewerStates) - - for _, reviewer := range reviewerStates { - reviewers = append(reviewers, formattedReviewerState(reviewer)) - } - - reviewerList := strings.Join(reviewers, ", ") - - return reviewerList -} - -// Ref. https://developer.github.com/v4/union/requestedreviewer/ -const teamTypeName = "Team" - -const ghostName = "ghost" - -// parseReviewers parses given Reviews and ReviewRequests -func parseReviewers(pr api.PullRequest) []*reviewerState { - reviewerStates := make(map[string]*reviewerState) - - for _, review := range pr.Reviews.Nodes { - if review.Author.Login != pr.Author.Login { - name := review.Author.Login - if name == "" { - name = ghostName - } - reviewerStates[name] = &reviewerState{ - Name: name, - State: review.State, - } - } - } - - // Overwrite reviewer's state if a review request for the same reviewer exists. - for _, reviewRequest := range pr.ReviewRequests.Nodes { - name := reviewRequest.RequestedReviewer.Login - if reviewRequest.RequestedReviewer.TypeName == teamTypeName { - name = reviewRequest.RequestedReviewer.Name - } - reviewerStates[name] = &reviewerState{ - Name: name, - State: requestedReviewState, - } - } - - // Convert map to slice for ease of sort - result := make([]*reviewerState, 0, len(reviewerStates)) - for _, reviewer := range reviewerStates { - if reviewer.State == pendingReviewState { - continue - } - result = append(result, reviewer) - } - - return result -} - -// sortReviewerStates puts completed reviews before review requests and sorts names alphabetically -func sortReviewerStates(reviewerStates []*reviewerState) { - sort.Slice(reviewerStates, func(i, j int) bool { - if reviewerStates[i].State == requestedReviewState && - reviewerStates[j].State != requestedReviewState { - return false - } - if reviewerStates[j].State == requestedReviewState && - reviewerStates[i].State != requestedReviewState { - return true - } - - return reviewerStates[i].Name < reviewerStates[j].Name - }) -} - -func prAssigneeList(pr api.PullRequest) string { - if len(pr.Assignees.Nodes) == 0 { - return "" - } - - AssigneeNames := make([]string, 0, len(pr.Assignees.Nodes)) - for _, assignee := range pr.Assignees.Nodes { - AssigneeNames = append(AssigneeNames, assignee.Login) - } - - list := strings.Join(AssigneeNames, ", ") - if pr.Assignees.TotalCount > len(pr.Assignees.Nodes) { - list += ", …" - } - return list -} - -func prLabelList(pr api.PullRequest) string { - if len(pr.Labels.Nodes) == 0 { - return "" - } - - labelNames := make([]string, 0, len(pr.Labels.Nodes)) - for _, label := range pr.Labels.Nodes { - labelNames = append(labelNames, label.Name) - } - - list := strings.Join(labelNames, ", ") - if pr.Labels.TotalCount > len(pr.Labels.Nodes) { - list += ", …" - } - return list -} - -func prProjectList(pr api.PullRequest) string { - if len(pr.ProjectCards.Nodes) == 0 { - return "" - } - - projectNames := make([]string, 0, len(pr.ProjectCards.Nodes)) - for _, project := range pr.ProjectCards.Nodes { - colName := project.Column.Name - if colName == "" { - colName = "Awaiting triage" - } - projectNames = append(projectNames, fmt.Sprintf("%s (%s)", project.Project.Name, colName)) - } - - list := strings.Join(projectNames, ", ") - if pr.ProjectCards.TotalCount > len(pr.ProjectCards.Nodes) { - list += ", …" - } - return list -} - -func prSelectorForCurrentBranch(ctx context.Context, baseRepo ghrepo.Interface) (prNumber int, prHeadRef string, err error) { - prHeadRef, err = ctx.Branch() - if err != nil { - return - } - branchConfig := git.ReadBranchConfig(prHeadRef) - - // the branch is configured to merge a special PR head ref - prHeadRE := regexp.MustCompile(`^refs/pull/(\d+)/head$`) - if m := prHeadRE.FindStringSubmatch(branchConfig.MergeRef); m != nil { - prNumber, _ = strconv.Atoi(m[1]) - return - } - - var branchOwner string - if branchConfig.RemoteURL != nil { - // the branch merges from a remote specified by URL - if r, err := ghrepo.FromURL(branchConfig.RemoteURL); err == nil { - branchOwner = r.RepoOwner() - } - } else if branchConfig.RemoteName != "" { - // the branch merges from a remote specified by name - rem, _ := ctx.Remotes() - if r, err := rem.FindByName(branchConfig.RemoteName); err == nil { - branchOwner = r.RepoOwner() - } - } - - if branchOwner != "" { - if strings.HasPrefix(branchConfig.MergeRef, "refs/heads/") { - prHeadRef = strings.TrimPrefix(branchConfig.MergeRef, "refs/heads/") - } - // prepend `OWNER:` if this branch is pushed to a fork - if !strings.EqualFold(branchOwner, baseRepo.RepoOwner()) { - prHeadRef = fmt.Sprintf("%s:%s", branchOwner, prHeadRef) - } - } - - return -} - -func printPrs(w io.Writer, totalCount int, prs ...api.PullRequest) { - for _, pr := range prs { - prNumber := fmt.Sprintf("#%d", pr.Number) - - prStateColorFunc := utils.Green - if pr.IsDraft { - prStateColorFunc = utils.Gray - } else if pr.State == "MERGED" { - prStateColorFunc = utils.Magenta - } else if pr.State == "CLOSED" { - prStateColorFunc = utils.Red - } - - fmt.Fprintf(w, " %s %s %s", prStateColorFunc(prNumber), text.Truncate(50, replaceExcessiveWhitespace(pr.Title)), utils.Cyan("["+pr.HeadLabel()+"]")) - - checks := pr.ChecksStatus() - reviews := pr.ReviewStatus() - - if pr.State == "OPEN" { - reviewStatus := reviews.ChangesRequested || reviews.Approved || reviews.ReviewRequired - if checks.Total > 0 || reviewStatus { - // show checks & reviews on their own line - fmt.Fprintf(w, "\n ") - } - - if checks.Total > 0 { - var summary string - if checks.Failing > 0 { - if checks.Failing == checks.Total { - summary = utils.Red("× All checks failing") - } else { - summary = utils.Red(fmt.Sprintf("× %d/%d checks failing", checks.Failing, checks.Total)) - } - } else if checks.Pending > 0 { - summary = utils.Yellow("- Checks pending") - } else if checks.Passing == checks.Total { - summary = utils.Green("✓ Checks passing") - } - fmt.Fprint(w, summary) - } - - if checks.Total > 0 && reviewStatus { - // add padding between checks & reviews - fmt.Fprint(w, " ") - } - - if reviews.ChangesRequested { - fmt.Fprint(w, utils.Red("+ Changes requested")) - } else if reviews.ReviewRequired { - fmt.Fprint(w, utils.Yellow("- Review required")) - } else if reviews.Approved { - fmt.Fprint(w, utils.Green("✓ Approved")) - } - } else { - fmt.Fprintf(w, " - %s", prStateTitleWithColor(pr)) - } - - fmt.Fprint(w, "\n") - } - remaining := totalCount - len(prs) - if remaining > 0 { - fmt.Fprintf(w, utils.Gray(" And %d more\n"), remaining) - } -} - -func printHeader(w io.Writer, s string) { - fmt.Fprintln(w, utils.Bold(s)) -} - -func printMessage(w io.Writer, s string) { - fmt.Fprintln(w, utils.Gray(s)) -} - -func replaceExcessiveWhitespace(s string) string { - s = strings.TrimSpace(s) - s = regexp.MustCompile(`\r?\n`).ReplaceAllString(s, " ") - s = regexp.MustCompile(`\s{2,}`).ReplaceAllString(s, " ") - return s -} diff --git a/command/pr_diff.go b/command/pr_diff.go deleted file mode 100644 index ae50a2618..000000000 --- a/command/pr_diff.go +++ /dev/null @@ -1,104 +0,0 @@ -package command - -import ( - "fmt" - "os" - "strings" - - "github.com/cli/cli/utils" - "github.com/spf13/cobra" -) - -var prDiffCmd = &cobra.Command{ - Use: "diff { | }", - Short: "View a pull request's changes.", - RunE: prDiff, -} - -func init() { - prDiffCmd.Flags().StringP("color", "c", "auto", "Whether or not to output color: {always|never|auto}") - - prCmd.AddCommand(prDiffCmd) -} - -func prDiff(cmd *cobra.Command, args []string) error { - color, err := cmd.Flags().GetString("color") - if err != nil { - return err - } - if !validColorFlag(color) { - return fmt.Errorf("did not understand color: %q. Expected one of always, never, or auto", color) - } - - ctx := contextForCommand(cmd) - apiClient, err := apiClientForContext(ctx) - if err != nil { - return err - } - - pr, baseRepo, err := prFromArgs(ctx, apiClient, cmd, args) - if err != nil { - return fmt.Errorf("could not find pull request: %w", err) - } - - diff, err := apiClient.PullRequestDiff(baseRepo, pr.Number) - if err != nil { - return fmt.Errorf("could not find pull request diff: %w", err) - } - - out := cmd.OutOrStdout() - if color == "auto" { - color = "never" - isTTY := false - if outFile, isFile := out.(*os.File); isFile { - isTTY = utils.IsTerminal(outFile) - if isTTY { - color = "always" - } - } - } - - if color == "never" { - fmt.Fprint(out, diff) - return nil - } - - out = colorableOut(cmd) - for _, diffLine := range strings.Split(diff, "\n") { - output := diffLine - switch { - case isHeaderLine(diffLine): - output = utils.Bold(diffLine) - case isAdditionLine(diffLine): - output = utils.Green(diffLine) - case isRemovalLine(diffLine): - output = utils.Red(diffLine) - } - - fmt.Fprintln(out, output) - } - - return nil -} - -func isHeaderLine(dl string) bool { - prefixes := []string{"+++", "---", "diff", "index"} - for _, p := range prefixes { - if strings.HasPrefix(dl, p) { - return true - } - } - return false -} - -func isAdditionLine(dl string) bool { - return strings.HasPrefix(dl, "+") -} - -func isRemovalLine(dl string) bool { - return strings.HasPrefix(dl, "-") -} - -func validColorFlag(c string) bool { - return c == "auto" || c == "always" || c == "never" -} diff --git a/command/pr_diff_test.go b/command/pr_diff_test.go deleted file mode 100644 index 725b21f03..000000000 --- a/command/pr_diff_test.go +++ /dev/null @@ -1,104 +0,0 @@ -package command - -import ( - "bytes" - "testing" -) - -func TestPRDiff_validation(t *testing.T) { - _, err := RunCommand("pr diff --color=doublerainbow") - if err == nil { - t.Fatal("expected error") - } - eq(t, err.Error(), `did not understand color: "doublerainbow". Expected one of always, never, or auto`) -} - -func TestPRDiff_no_current_pr(t *testing.T) { - initBlankContext("", "OWNER/REPO", "master") - http := initFakeHTTP() - http.StubRepoResponse("OWNER", "REPO") - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { "pullRequests": { "nodes": [ - { "url": "https://github.com/OWNER/REPO/pull/123", - "number": 123, - "id": "foobar123", - "headRefName": "feature", - "baseRefName": "master" } - ] } } } }`)) - http.StubResponse(200, bytes.NewBufferString(testDiff)) - _, err := RunCommand("pr diff") - if err == nil { - t.Fatal("expected error", err) - } - eq(t, err.Error(), `could not find pull request: no open pull requests found for branch "master"`) -} - -func TestPRDiff_argument_not_found(t *testing.T) { - initBlankContext("", "OWNER/REPO", "master") - http := initFakeHTTP() - http.StubRepoResponse("OWNER", "REPO") - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { - "pullRequest": { "number": 123 } - } } } -`)) - http.StubResponse(404, bytes.NewBufferString("")) - _, err := RunCommand("pr diff 123") - if err == nil { - t.Fatal("expected error", err) - } - eq(t, err.Error(), `could not find pull request diff: pull request not found`) -} - -func TestPRDiff(t *testing.T) { - initBlankContext("", "OWNER/REPO", "feature") - http := initFakeHTTP() - http.StubRepoResponse("OWNER", "REPO") - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { "pullRequests": { "nodes": [ - { "url": "https://github.com/OWNER/REPO/pull/123", - "number": 123, - "id": "foobar123", - "headRefName": "feature", - "baseRefName": "master" } - ] } } } }`)) - http.StubResponse(200, bytes.NewBufferString(testDiff)) - output, err := RunCommand("pr diff") - if err != nil { - t.Fatalf("unexpected error: %s", err) - } - eq(t, output.String(), testDiff) -} - -const testDiff = `diff --git a/.github/workflows/releases.yml b/.github/workflows/releases.yml -index 73974448..b7fc0154 100644 ---- a/.github/workflows/releases.yml -+++ b/.github/workflows/releases.yml -@@ -44,6 +44,11 @@ jobs: - token: ${{secrets.SITE_GITHUB_TOKEN}} - - name: Publish documentation site - if: "!contains(github.ref, '-')" # skip prereleases -+ env: -+ GIT_COMMITTER_NAME: cli automation -+ GIT_AUTHOR_NAME: cli automation -+ GIT_COMMITTER_EMAIL: noreply@github.com -+ GIT_AUTHOR_EMAIL: noreply@github.com - run: make site-publish - - name: Move project cards - if: "!contains(github.ref, '-')" # skip prereleases -diff --git a/Makefile b/Makefile -index f2b4805c..3d7bd0f9 100644 ---- a/Makefile -+++ b/Makefile -@@ -22,8 +22,8 @@ test: - go test ./... - .PHONY: test - --site: -- git clone https://github.com/github/cli.github.com.git "$@" -+site: bin/gh -+ bin/gh repo clone github/cli.github.com "$@" - - site-docs: site - git -C site pull -` diff --git a/command/pr_review.go b/command/pr_review.go deleted file mode 100644 index f2cbb68be..000000000 --- a/command/pr_review.go +++ /dev/null @@ -1,254 +0,0 @@ -package command - -import ( - "errors" - "fmt" - - "github.com/AlecAivazis/survey/v2" - "github.com/MakeNowJust/heredoc" - "github.com/spf13/cobra" - - "github.com/cli/cli/api" - "github.com/cli/cli/pkg/prompt" - "github.com/cli/cli/pkg/surveyext" - "github.com/cli/cli/utils" -) - -func init() { - prCmd.AddCommand(prReviewCmd) - - prReviewCmd.Flags().BoolP("approve", "a", false, "Approve pull request") - prReviewCmd.Flags().BoolP("request-changes", "r", false, "Request changes on a pull request") - prReviewCmd.Flags().BoolP("comment", "c", false, "Comment on a pull request") - prReviewCmd.Flags().StringP("body", "b", "", "Specify the body of a review") -} - -var prReviewCmd = &cobra.Command{ - Use: "review [ | | ]", - Short: "Add a review to a pull request", - Long: `Add a review to a pull request. - -Without an argument, the pull request that belongs to the current branch is reviewed.`, - 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" - `), - Args: cobra.MaximumNArgs(1), - RunE: prReview, -} - -func processReviewOpt(cmd *cobra.Command) (*api.PullRequestReviewInput, error) { - found := 0 - flag := "" - var state api.PullRequestReviewState - - if cmd.Flags().Changed("approve") { - found++ - flag = "approve" - state = api.ReviewApprove - } - if cmd.Flags().Changed("request-changes") { - found++ - flag = "request-changes" - state = api.ReviewRequestChanges - } - if cmd.Flags().Changed("comment") { - found++ - flag = "comment" - state = api.ReviewComment - } - - body, err := cmd.Flags().GetString("body") - if err != nil { - return nil, err - } - - if found == 0 && body == "" { - if connectedToTerminal(cmd) { - return nil, nil // signal interactive mode - } - return nil, errors.New("--approve, --request-changes, or --comment required when not attached to a tty") - } else if found == 0 && body != "" { - return nil, errors.New("--body unsupported without --approve, --request-changes, or --comment") - } else if found > 1 { - return nil, errors.New("need exactly one of --approve, --request-changes, or --comment") - } - - if (flag == "request-changes" || flag == "comment") && body == "" { - return nil, fmt.Errorf("body cannot be blank for %s review", flag) - } - - return &api.PullRequestReviewInput{ - Body: body, - State: state, - }, nil -} - -func prReview(cmd *cobra.Command, args []string) error { - ctx := contextForCommand(cmd) - apiClient, err := apiClientForContext(ctx) - if err != nil { - return err - } - - pr, _, err := prFromArgs(ctx, apiClient, cmd, args) - if err != nil { - return err - } - - reviewData, err := processReviewOpt(cmd) - if err != nil { - return fmt.Errorf("did not understand desired review action: %w", err) - } - - stderr := colorableErr(cmd) - - if reviewData == nil { - reviewData, err = reviewSurvey(cmd) - if err != nil { - return err - } - if reviewData == nil && err == nil { - fmt.Fprint(stderr, "Discarding.\n") - return nil - } - } - - err = api.AddReview(apiClient, pr, reviewData) - if err != nil { - return fmt.Errorf("failed to create review: %w", err) - } - - if !connectedToTerminal(cmd) { - return nil - } - - switch reviewData.State { - case api.ReviewComment: - fmt.Fprintf(stderr, "%s Reviewed pull request #%d\n", utils.Gray("-"), pr.Number) - case api.ReviewApprove: - fmt.Fprintf(stderr, "%s Approved pull request #%d\n", utils.Green("✓"), pr.Number) - case api.ReviewRequestChanges: - fmt.Fprintf(stderr, "%s Requested changes to pull request #%d\n", utils.Red("+"), pr.Number) - } - - return nil -} - -func reviewSurvey(cmd *cobra.Command) (*api.PullRequestReviewInput, error) { - editorCommand, err := determineEditor(cmd) - if err != nil { - return nil, err - } - - typeAnswers := struct { - ReviewType string - }{} - typeQs := []*survey.Question{ - { - Name: "reviewType", - Prompt: &survey.Select{ - Message: "What kind of review do you want to give?", - Options: []string{ - "Comment", - "Approve", - "Request changes", - }, - }, - }, - } - - err = prompt.SurveyAsk(typeQs, &typeAnswers) - if err != nil { - return nil, err - } - - var reviewState api.PullRequestReviewState - - switch typeAnswers.ReviewType { - case "Approve": - reviewState = api.ReviewApprove - case "Request changes": - reviewState = api.ReviewRequestChanges - case "Comment": - reviewState = api.ReviewComment - default: - panic("unreachable state") - } - - bodyAnswers := struct { - Body string - }{} - - blankAllowed := false - if reviewState == api.ReviewApprove { - blankAllowed = true - } - - bodyQs := []*survey.Question{ - { - Name: "body", - Prompt: &surveyext.GhEditor{ - BlankAllowed: blankAllowed, - EditorCommand: editorCommand, - Editor: &survey.Editor{ - Message: "Review body", - FileName: "*.md", - }, - }, - }, - } - - err = prompt.SurveyAsk(bodyQs, &bodyAnswers) - if err != nil { - return nil, err - } - - if bodyAnswers.Body == "" && (reviewState == api.ReviewComment || reviewState == api.ReviewRequestChanges) { - return nil, errors.New("this type of review cannot be blank") - } - - if len(bodyAnswers.Body) > 0 { - out := colorableOut(cmd) - renderedBody, err := utils.RenderMarkdown(bodyAnswers.Body) - if err != nil { - return nil, err - } - - fmt.Fprintf(out, "Got:\n%s", renderedBody) - } - - confirm := false - confirmQs := []*survey.Question{ - { - Name: "confirm", - Prompt: &survey.Confirm{ - Message: "Submit?", - Default: true, - }, - }, - } - - err = prompt.SurveyAsk(confirmQs, &confirm) - if err != nil { - return nil, err - } - - if !confirm { - return nil, nil - } - - return &api.PullRequestReviewInput{ - Body: bodyAnswers.Body, - State: reviewState, - }, nil -} diff --git a/command/pr_test.go b/command/pr_test.go deleted file mode 100644 index f11939b1b..000000000 --- a/command/pr_test.go +++ /dev/null @@ -1,1731 +0,0 @@ -package command - -import ( - "bytes" - "os/exec" - "reflect" - "regexp" - "strings" - "testing" - - "github.com/cli/cli/api" - "github.com/cli/cli/internal/run" - "github.com/cli/cli/pkg/httpmock" - "github.com/cli/cli/pkg/prompt" - "github.com/cli/cli/test" - "github.com/google/go-cmp/cmp" - "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 TestPRStatus(t *testing.T) { - initBlankContext("", "OWNER/REPO", "blueberries") - http := initFakeHTTP() - http.StubRepoResponse("OWNER", "REPO") - http.Register(httpmock.GraphQL(`query PullRequestStatus\b`), httpmock.FileResponse("../test/fixtures/prStatus.json")) - - output, err := RunCommand("pr status") - if err != nil { - t.Errorf("error running command `pr status`: %v", err) - } - - expectedPrs := []*regexp.Regexp{ - regexp.MustCompile(`#8.*\[strawberries\]`), - regexp.MustCompile(`#9.*\[apples\]`), - regexp.MustCompile(`#10.*\[blueberries\]`), - regexp.MustCompile(`#11.*\[figs\]`), - } - - for _, r := range expectedPrs { - if !r.MatchString(output.String()) { - t.Errorf("output did not match regexp /%s/", r) - } - } -} - -func TestPRStatus_fork(t *testing.T) { - initBlankContext("", "OWNER/REPO", "blueberries") - http := initFakeHTTP() - http.StubForkedRepoResponse("OWNER/REPO", "PARENT/REPO") - http.Register(httpmock.GraphQL(`query PullRequestStatus\b`), httpmock.FileResponse("../test/fixtures/prStatusFork.json")) - - defer run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable { - switch strings.Join(cmd.Args, " ") { - case `git config --get-regexp ^branch\.blueberries\.(remote|merge)$`: - return &test.OutputStub{Out: []byte(`branch.blueberries.remote origin -branch.blueberries.merge refs/heads/blueberries`)} - default: - panic("not implemented") - } - })() - - output, err := RunCommand("pr status") - if err != nil { - t.Fatalf("error running command `pr status`: %v", err) - } - - branchRE := regexp.MustCompile(`#10.*\[OWNER:blueberries\]`) - if !branchRE.MatchString(output.String()) { - t.Errorf("did not match current branch:\n%v", output.String()) - } -} - -func TestPRStatus_reviewsAndChecks(t *testing.T) { - initBlankContext("", "OWNER/REPO", "blueberries") - http := initFakeHTTP() - http.StubRepoResponse("OWNER", "REPO") - http.Register(httpmock.GraphQL(`query PullRequestStatus\b`), httpmock.FileResponse("../test/fixtures/prStatusChecks.json")) - - output, err := RunCommand("pr status") - if err != nil { - t.Errorf("error running command `pr status`: %v", err) - } - - expected := []string{ - "✓ Checks passing + Changes requested", - "- Checks pending ✓ Approved", - "× 1/3 checks failing - Review required", - } - - for _, line := range expected { - if !strings.Contains(output.String(), line) { - t.Errorf("output did not contain %q: %q", line, output.String()) - } - } -} - -func TestPRStatus_currentBranch_showTheMostRecentPR(t *testing.T) { - initBlankContext("", "OWNER/REPO", "blueberries") - http := initFakeHTTP() - http.StubRepoResponse("OWNER", "REPO") - http.Register(httpmock.GraphQL(`query PullRequestStatus\b`), httpmock.FileResponse("../test/fixtures/prStatusCurrentBranch.json")) - - output, err := RunCommand("pr status") - if err != nil { - t.Errorf("error running command `pr status`: %v", err) - } - - expectedLine := regexp.MustCompile(`#10 Blueberries are certainly a good fruit \[blueberries\]`) - if !expectedLine.MatchString(output.String()) { - t.Errorf("output did not match regexp /%s/\n> output\n%s\n", expectedLine, output) - return - } - - unexpectedLines := []*regexp.Regexp{ - regexp.MustCompile(`#9 Blueberries are a good fruit \[blueberries\] - Merged`), - regexp.MustCompile(`#8 Blueberries are probably a good fruit \[blueberries\] - Closed`), - } - for _, r := range unexpectedLines { - if r.MatchString(output.String()) { - t.Errorf("output unexpectedly match regexp /%s/\n> output\n%s\n", r, output) - return - } - } -} - -func TestPRStatus_currentBranch_defaultBranch(t *testing.T) { - initBlankContext("", "OWNER/REPO", "blueberries") - http := initFakeHTTP() - http.StubRepoResponseWithDefaultBranch("OWNER", "REPO", "blueberries") - http.Register(httpmock.GraphQL(`query PullRequestStatus\b`), httpmock.FileResponse("../test/fixtures/prStatusCurrentBranch.json")) - - output, err := RunCommand("pr status") - if err != nil { - t.Errorf("error running command `pr status`: %v", err) - } - - expectedLine := regexp.MustCompile(`#10 Blueberries are certainly a good fruit \[blueberries\]`) - if !expectedLine.MatchString(output.String()) { - t.Errorf("output did not match regexp /%s/\n> output\n%s\n", expectedLine, output) - return - } -} - -func TestPRStatus_currentBranch_defaultBranch_repoFlag(t *testing.T) { - initBlankContext("", "OWNER/REPO", "blueberries") - http := initFakeHTTP() - http.Register(httpmock.GraphQL(`query PullRequestStatus\b`), httpmock.FileResponse("../test/fixtures/prStatusCurrentBranchClosedOnDefaultBranch.json")) - - output, err := RunCommand("pr status -R OWNER/REPO") - if err != nil { - t.Errorf("error running command `pr status`: %v", err) - } - - expectedLine := regexp.MustCompile(`#8 Blueberries are a good fruit \[blueberries\]`) - if expectedLine.MatchString(output.String()) { - t.Errorf("output not expected to match regexp /%s/\n> output\n%s\n", expectedLine, output) - return - } -} - -func TestPRStatus_currentBranch_Closed(t *testing.T) { - initBlankContext("", "OWNER/REPO", "blueberries") - http := initFakeHTTP() - http.StubRepoResponse("OWNER", "REPO") - http.Register(httpmock.GraphQL(`query PullRequestStatus\b`), httpmock.FileResponse("../test/fixtures/prStatusCurrentBranchClosed.json")) - - output, err := RunCommand("pr status") - if err != nil { - t.Errorf("error running command `pr status`: %v", err) - } - - expectedLine := regexp.MustCompile(`#8 Blueberries are a good fruit \[blueberries\] - Closed`) - if !expectedLine.MatchString(output.String()) { - t.Errorf("output did not match regexp /%s/\n> output\n%s\n", expectedLine, output) - return - } -} - -func TestPRStatus_currentBranch_Closed_defaultBranch(t *testing.T) { - initBlankContext("", "OWNER/REPO", "blueberries") - http := initFakeHTTP() - http.StubRepoResponseWithDefaultBranch("OWNER", "REPO", "blueberries") - http.Register(httpmock.GraphQL(`query PullRequestStatus\b`), httpmock.FileResponse("../test/fixtures/prStatusCurrentBranchClosedOnDefaultBranch.json")) - - output, err := RunCommand("pr status") - if err != nil { - t.Errorf("error running command `pr status`: %v", err) - } - - expectedLine := regexp.MustCompile(`There is no pull request associated with \[blueberries\]`) - if !expectedLine.MatchString(output.String()) { - t.Errorf("output did not match regexp /%s/\n> output\n%s\n", expectedLine, output) - return - } -} - -func TestPRStatus_currentBranch_Merged(t *testing.T) { - initBlankContext("", "OWNER/REPO", "blueberries") - http := initFakeHTTP() - http.StubRepoResponse("OWNER", "REPO") - http.Register(httpmock.GraphQL(`query PullRequestStatus\b`), httpmock.FileResponse("../test/fixtures/prStatusCurrentBranchMerged.json")) - - output, err := RunCommand("pr status") - if err != nil { - t.Errorf("error running command `pr status`: %v", err) - } - - expectedLine := regexp.MustCompile(`#8 Blueberries are a good fruit \[blueberries\] - Merged`) - if !expectedLine.MatchString(output.String()) { - t.Errorf("output did not match regexp /%s/\n> output\n%s\n", expectedLine, output) - return - } -} - -func TestPRStatus_currentBranch_Merged_defaultBranch(t *testing.T) { - initBlankContext("", "OWNER/REPO", "blueberries") - http := initFakeHTTP() - http.StubRepoResponseWithDefaultBranch("OWNER", "REPO", "blueberries") - http.Register(httpmock.GraphQL(`query PullRequestStatus\b`), httpmock.FileResponse("../test/fixtures/prStatusCurrentBranchMergedOnDefaultBranch.json")) - - output, err := RunCommand("pr status") - if err != nil { - t.Errorf("error running command `pr status`: %v", err) - } - - expectedLine := regexp.MustCompile(`There is no pull request associated with \[blueberries\]`) - if !expectedLine.MatchString(output.String()) { - t.Errorf("output did not match regexp /%s/\n> output\n%s\n", expectedLine, output) - return - } -} - -func TestPRStatus_blankSlate(t *testing.T) { - initBlankContext("", "OWNER/REPO", "blueberries") - http := initFakeHTTP() - http.StubRepoResponse("OWNER", "REPO") - http.Register(httpmock.GraphQL(`query PullRequestStatus\b`), httpmock.StringResponse(`{"data": {}}`)) - - output, err := RunCommand("pr status") - if err != nil { - t.Errorf("error running command `pr status`: %v", err) - } - - expected := ` -Relevant pull requests in OWNER/REPO - -Current branch - There is no pull request associated with [blueberries] - -Created by you - You have no open pull requests - -Requesting a code review from you - You have no pull requests to review - -` - if output.String() != expected { - t.Errorf("expected %q, got %q", expected, output.String()) - } -} - -func TestPRStatus_detachedHead(t *testing.T) { - initBlankContext("", "OWNER/REPO", "") - http := initFakeHTTP() - http.StubRepoResponse("OWNER", "REPO") - http.Register(httpmock.GraphQL(`query PullRequestStatus\b`), httpmock.StringResponse(`{"data": {}}`)) - - output, err := RunCommand("pr status") - if err != nil { - t.Errorf("error running command `pr status`: %v", err) - } - - expected := ` -Relevant pull requests in OWNER/REPO - -Current branch - There is no current branch - -Created by you - You have no open pull requests - -Requesting a code review from you - You have no pull requests to review - -` - if output.String() != expected { - t.Errorf("expected %q, got %q", expected, output.String()) - } -} - -func TestPRList(t *testing.T) { - initBlankContext("", "OWNER/REPO", "master") - defer stubTerminal(true)() - http := initFakeHTTP() - http.StubRepoResponse("OWNER", "REPO") - http.Register(httpmock.GraphQL(`query PullRequestList\b`), httpmock.FileResponse("../test/fixtures/prList.json")) - - output, err := RunCommand("pr list") - if err != nil { - t.Fatal(err) - } - - assert.Equal(t, ` -Showing 3 of 3 open pull requests in OWNER/REPO - -`, output.Stderr()) - - lines := strings.Split(output.String(), "\n") - res := []*regexp.Regexp{ - regexp.MustCompile(`#32.*New feature.*feature`), - regexp.MustCompile(`#29.*Fixed bad bug.*hubot:bug-fix`), - regexp.MustCompile(`#28.*Improve documentation.*docs`), - } - - for i, r := range res { - if !r.MatchString(lines[i]) { - t.Errorf("%s did not match %s", lines[i], r) - } - } -} - -func TestPRList_nontty(t *testing.T) { - initBlankContext("", "OWNER/REPO", "master") - defer stubTerminal(false)() - http := initFakeHTTP() - http.StubRepoResponse("OWNER", "REPO") - http.Register(httpmock.GraphQL(`query PullRequestList\b`), httpmock.FileResponse("../test/fixtures/prList.json")) - - output, err := RunCommand("pr list") - if err != nil { - t.Fatal(err) - } - - assert.Equal(t, "", output.Stderr()) - - assert.Equal(t, `32 New feature feature DRAFT -29 Fixed bad bug hubot:bug-fix OPEN -28 Improve documentation docs MERGED -`, output.String()) -} - -func TestPRList_filtering(t *testing.T) { - initBlankContext("", "OWNER/REPO", "master") - defer stubTerminal(true)() - http := initFakeHTTP() - http.StubRepoResponse("OWNER", "REPO") - http.Register( - httpmock.GraphQL(`query PullRequestList\b`), - httpmock.GraphQLQuery(`{}`, func(_ string, params map[string]interface{}) { - assert.Equal(t, []interface{}{"OPEN", "CLOSED", "MERGED"}, params["state"].([]interface{})) - assert.Equal(t, []interface{}{"one", "two", "three"}, params["labels"].([]interface{})) - })) - - output, err := RunCommand(`pr list -s all -l one,two -l three`) - if err != nil { - t.Fatal(err) - } - - eq(t, output.String(), "") - eq(t, output.Stderr(), ` -No pull requests match your search in OWNER/REPO - -`) -} - -func TestPRList_filteringRemoveDuplicate(t *testing.T) { - initBlankContext("", "OWNER/REPO", "master") - defer stubTerminal(true)() - http := initFakeHTTP() - http.StubRepoResponse("OWNER", "REPO") - http.Register( - httpmock.GraphQL(`query PullRequestList\b`), - httpmock.FileResponse("../test/fixtures/prListWithDuplicates.json")) - - output, err := RunCommand("pr list -l one,two") - if err != nil { - t.Fatal(err) - } - - lines := strings.Split(output.String(), "\n") - - res := []*regexp.Regexp{ - regexp.MustCompile(`#32.*New feature.*feature`), - regexp.MustCompile(`#29.*Fixed bad bug.*hubot:bug-fix`), - regexp.MustCompile(`#28.*Improve documentation.*docs`), - } - - for i, r := range res { - if !r.MatchString(lines[i]) { - t.Errorf("%s did not match %s", lines[i], r) - } - } -} - -func TestPRList_filteringClosed(t *testing.T) { - initBlankContext("", "OWNER/REPO", "master") - defer stubTerminal(true)() - http := initFakeHTTP() - http.StubRepoResponse("OWNER", "REPO") - http.Register( - httpmock.GraphQL(`query PullRequestList\b`), - httpmock.GraphQLQuery(`{}`, func(_ string, params map[string]interface{}) { - assert.Equal(t, []interface{}{"CLOSED", "MERGED"}, params["state"].([]interface{})) - })) - - _, err := RunCommand(`pr list -s closed`) - if err != nil { - t.Fatal(err) - } -} - -func TestPRList_filteringAssignee(t *testing.T) { - initBlankContext("", "OWNER/REPO", "master") - defer stubTerminal(true)() - http := initFakeHTTP() - http.StubRepoResponse("OWNER", "REPO") - http.Register( - httpmock.GraphQL(`query PullRequestList\b`), - httpmock.GraphQLQuery(`{}`, func(_ string, params map[string]interface{}) { - assert.Equal(t, `repo:OWNER/REPO assignee:hubot is:pr sort:created-desc is:merged label:"needs tests" base:"develop"`, params["q"].(string)) - })) - - _, err := RunCommand(`pr list -s merged -l "needs tests" -a hubot -B develop`) - if err != nil { - t.Fatal(err) - } -} - -func TestPRList_filteringAssigneeLabels(t *testing.T) { - initBlankContext("", "OWNER/REPO", "master") - defer stubTerminal(true)() - initFakeHTTP() - - _, err := RunCommand(`pr list -l one,two -a hubot`) - if err == nil && err.Error() != "multiple labels with --assignee are not supported" { - t.Fatal(err) - } -} - -func TestPRList_withInvalidLimitFlag(t *testing.T) { - initBlankContext("", "OWNER/REPO", "master") - defer stubTerminal(true)() - initFakeHTTP() - - _, err := RunCommand(`pr list --limit=0`) - if err == nil && err.Error() != "invalid limit: 0" { - t.Errorf("error running command `issue list`: %v", err) - } -} - -func TestPRList_web(t *testing.T) { - initBlankContext("", "OWNER/REPO", "master") - defer stubTerminal(true)() - http := initFakeHTTP() - http.StubRepoResponse("OWNER", "REPO") - - var seenCmd *exec.Cmd - restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable { - seenCmd = cmd - return &test.OutputStub{} - }) - defer restoreCmd() - - output, err := RunCommand("pr list --web -a peter -l bug -l docs -L 10 -s merged -B trunk") - if err != nil { - t.Errorf("error running command `pr list` with `--web` flag: %v", err) - } - - 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") - - if seenCmd == nil { - t.Fatal("expected a command to run") - } - url := seenCmd.Args[len(seenCmd.Args)-1] - eq(t, url, expectedURL) -} - -func TestPRView_Preview_nontty(t *testing.T) { - defer stubTerminal(false)() - tests := map[string]struct { - ownerRepo string - args string - fixture string - expectedOutputs []string - }{ - "Open PR without metadata": { - ownerRepo: "master", - args: "pr view 12", - fixture: "../test/fixtures/prViewPreview.json", - expectedOutputs: []string{ - `title:\tBlueberries are from a fork\n`, - `state:\tOPEN\n`, - `author:\tnobody\n`, - `labels:\t\n`, - `assignees:\t\n`, - `reviewers:\t\n`, - `projects:\t\n`, - `milestone:\t\n`, - `blueberries taste good`, - }, - }, - "Open PR with metadata by number": { - ownerRepo: "master", - args: "pr view 12", - fixture: "../test/fixtures/prViewPreviewWithMetadataByNumber.json", - expectedOutputs: []string{ - `title:\tBlueberries are from a fork\n`, - `reviewers:\t2 \(Approved\), 3 \(Commented\), 1 \(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`, - `milestone:\tuluru\n`, - `\*\*blueberries taste good\*\*`, - }, - }, - "Open PR with reviewers by number": { - ownerRepo: "master", - args: "pr view 12", - fixture: "../test/fixtures/prViewPreviewWithReviewersByNumber.json", - expectedOutputs: []string{ - `title:\tBlueberries are from a fork\n`, - `state:\tOPEN\n`, - `author:\tnobody\n`, - `labels:\t\n`, - `assignees:\t\n`, - `projects:\t\n`, - `milestone:\t\n`, - `reviewers:\tDEF \(Commented\), def \(Changes requested\), ghost \(Approved\), hubot \(Commented\), xyz \(Approved\), 123 \(Requested\), Team 1 \(Requested\), abc \(Requested\)\n`, - `\*\*blueberries taste good\*\*`, - }, - }, - "Open PR with metadata by branch": { - ownerRepo: "master", - args: "pr view blueberries", - fixture: "../test/fixtures/prViewPreviewWithMetadataByBranch.json", - expectedOutputs: []string{ - `title:\tBlueberries are a good fruit`, - `state:\tOPEN`, - `author:\tnobody`, - `assignees:\tmarseilles, monaco\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`, - `blueberries taste good`, - }, - }, - "Open PR for the current branch": { - ownerRepo: "blueberries", - args: "pr view", - fixture: "../test/fixtures/prView.json", - expectedOutputs: []string{ - `title:\tBlueberries are a good fruit`, - `state:\tOPEN`, - `author:\tnobody`, - `assignees:\t\n`, - `labels:\t\n`, - `projects:\t\n`, - `milestone:\t\n`, - `\*\*blueberries taste good\*\*`, - }, - }, - "Open PR wth empty body for the current branch": { - ownerRepo: "blueberries", - args: "pr view", - fixture: "../test/fixtures/prView_EmptyBody.json", - expectedOutputs: []string{ - `title:\tBlueberries are a good fruit`, - `state:\tOPEN`, - `author:\tnobody`, - `assignees:\t\n`, - `labels:\t\n`, - `projects:\t\n`, - `milestone:\t\n`, - }, - }, - "Closed PR": { - ownerRepo: "master", - args: "pr view 12", - fixture: "../test/fixtures/prViewPreviewClosedState.json", - expectedOutputs: []string{ - `state:\tCLOSED\n`, - `author:\tnobody\n`, - `labels:\t\n`, - `assignees:\t\n`, - `reviewers:\t\n`, - `projects:\t\n`, - `milestone:\t\n`, - `\*\*blueberries taste good\*\*`, - }, - }, - "Merged PR": { - ownerRepo: "master", - args: "pr view 12", - fixture: "../test/fixtures/prViewPreviewMergedState.json", - expectedOutputs: []string{ - `state:\tMERGED\n`, - `author:\tnobody\n`, - `labels:\t\n`, - `assignees:\t\n`, - `reviewers:\t\n`, - `projects:\t\n`, - `milestone:\t\n`, - `\*\*blueberries taste good\*\*`, - }, - }, - "Draft PR": { - ownerRepo: "master", - args: "pr view 12", - fixture: "../test/fixtures/prViewPreviewDraftState.json", - expectedOutputs: []string{ - `title:\tBlueberries are from a fork\n`, - `state:\tDRAFT\n`, - `author:\tnobody\n`, - `labels:`, - `assignees:`, - `projects:`, - `milestone:`, - `\*\*blueberries taste good\*\*`, - }, - }, - "Draft PR by branch": { - ownerRepo: "master", - args: "pr view blueberries", - fixture: "../test/fixtures/prViewPreviewDraftStatebyBranch.json", - expectedOutputs: []string{ - `title:\tBlueberries are a good fruit\n`, - `state:\tDRAFT\n`, - `author:\tnobody\n`, - `labels:`, - `assignees:`, - `projects:`, - `milestone:`, - `\*\*blueberries taste good\*\*`, - }, - }, - } - - for name, tc := range tests { - t.Run(name, func(t *testing.T) { - initBlankContext("", "OWNER/REPO", tc.ownerRepo) - http := initFakeHTTP() - http.StubRepoResponse("OWNER", "REPO") - http.Register(httpmock.GraphQL(`query PullRequest(ByNumber|ForBranch)\b`), httpmock.FileResponse(tc.fixture)) - - output, err := RunCommand(tc.args) - if err != nil { - t.Errorf("error running command `%v`: %v", tc.args, err) - } - - eq(t, output.Stderr(), "") - - test.ExpectLines(t, output.String(), tc.expectedOutputs...) - }) - } -} - -func TestPRView_Preview(t *testing.T) { - defer stubTerminal(true)() - tests := map[string]struct { - ownerRepo string - args string - fixture string - expectedOutputs []string - }{ - "Open PR without metadata": { - ownerRepo: "master", - args: "pr view 12", - fixture: "../test/fixtures/prViewPreview.json", - expectedOutputs: []string{ - `Blueberries are from a fork`, - `Open.*nobody wants to merge 12 commits into master from blueberries`, - `blueberries taste good`, - `View this pull request on GitHub: https://github.com/OWNER/REPO/pull/12`, - }, - }, - "Open PR with metadata by number": { - ownerRepo: "master", - args: "pr view 12", - fixture: "../test/fixtures/prViewPreviewWithMetadataByNumber.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`, - `Assignees:.*marseilles, monaco\n`, - `Labels:.*one, two, three, four, five\n`, - `Projects:.*Project 1 \(column A\), Project 2 \(column B\), Project 3 \(column C\), Project 4 \(Awaiting triage\)\n`, - `Milestone:.*uluru\n`, - `blueberries taste good`, - `View this pull request on GitHub: https://github.com/OWNER/REPO/pull/12\n`, - }, - }, - "Open PR with reviewers by number": { - ownerRepo: "master", - args: "pr view 12", - fixture: "../test/fixtures/prViewPreviewWithReviewersByNumber.json", - expectedOutputs: []string{ - `Blueberries are from a fork`, - `Reviewers:.*DEF \(.*Commented.*\), def \(.*Changes requested.*\), ghost \(.*Approved.*\), hubot \(Commented\), xyz \(.*Approved.*\), 123 \(.*Requested.*\), Team 1 \(.*Requested.*\), abc \(.*Requested.*\)\n`, - `blueberries taste good`, - `View this pull request on GitHub: https://github.com/OWNER/REPO/pull/12\n`, - }, - }, - "Open PR with metadata by branch": { - ownerRepo: "master", - args: "pr view blueberries", - fixture: "../test/fixtures/prViewPreviewWithMetadataByBranch.json", - expectedOutputs: []string{ - `Blueberries are a good fruit`, - `Open.*nobody wants to merge 8 commits into master from blueberries`, - `Assignees:.*marseilles, monaco\n`, - `Labels:.*one, two, three, four, five\n`, - `Projects:.*Project 1 \(column A\), Project 2 \(column B\), Project 3 \(column C\)\n`, - `Milestone:.*uluru\n`, - `blueberries taste good`, - `View this pull request on GitHub: https://github.com/OWNER/REPO/pull/10\n`, - }, - }, - "Open PR for the current branch": { - ownerRepo: "blueberries", - args: "pr view", - fixture: "../test/fixtures/prView.json", - expectedOutputs: []string{ - `Blueberries are a good fruit`, - `Open.*nobody wants to merge 8 commits into master from blueberries`, - `blueberries taste good`, - `View this pull request on GitHub: https://github.com/OWNER/REPO/pull/10`, - }, - }, - "Open PR wth empty body for the current branch": { - ownerRepo: "blueberries", - args: "pr view", - fixture: "../test/fixtures/prView_EmptyBody.json", - expectedOutputs: []string{ - `Blueberries are a good fruit`, - `Open.*nobody wants to merge 8 commits into master from blueberries`, - `View this pull request on GitHub: https://github.com/OWNER/REPO/pull/10`, - }, - }, - "Closed PR": { - ownerRepo: "master", - args: "pr view 12", - fixture: "../test/fixtures/prViewPreviewClosedState.json", - expectedOutputs: []string{ - `Blueberries are from a fork`, - `Closed.*nobody wants to merge 12 commits into master from blueberries`, - `blueberries taste good`, - `View this pull request on GitHub: https://github.com/OWNER/REPO/pull/12`, - }, - }, - "Merged PR": { - ownerRepo: "master", - args: "pr view 12", - fixture: "../test/fixtures/prViewPreviewMergedState.json", - expectedOutputs: []string{ - `Blueberries are from a fork`, - `Merged.*nobody wants to merge 12 commits into master from blueberries`, - `blueberries taste good`, - `View this pull request on GitHub: https://github.com/OWNER/REPO/pull/12`, - }, - }, - "Draft PR": { - ownerRepo: "master", - args: "pr view 12", - fixture: "../test/fixtures/prViewPreviewDraftState.json", - expectedOutputs: []string{ - `Blueberries are from a fork`, - `Draft.*nobody wants to merge 12 commits into master from blueberries`, - `blueberries taste good`, - `View this pull request on GitHub: https://github.com/OWNER/REPO/pull/12`, - }, - }, - "Draft PR by branch": { - ownerRepo: "master", - args: "pr view blueberries", - fixture: "../test/fixtures/prViewPreviewDraftStatebyBranch.json", - expectedOutputs: []string{ - `Blueberries are a good fruit`, - `Draft.*nobody wants to merge 8 commits into master from blueberries`, - `blueberries taste good`, - `View this pull request on GitHub: https://github.com/OWNER/REPO/pull/10`, - }, - }, - } - - for name, tc := range tests { - t.Run(name, func(t *testing.T) { - initBlankContext("", "OWNER/REPO", tc.ownerRepo) - http := initFakeHTTP() - http.StubRepoResponse("OWNER", "REPO") - http.Register(httpmock.GraphQL(`query PullRequest(ByNumber|ForBranch)\b`), httpmock.FileResponse(tc.fixture)) - - output, err := RunCommand(tc.args) - if err != nil { - t.Errorf("error running command `%v`: %v", tc.args, err) - } - - eq(t, output.Stderr(), "") - - test.ExpectLines(t, output.String(), tc.expectedOutputs...) - }) - } -} - -func TestPRView_web_currentBranch(t *testing.T) { - initBlankContext("", "OWNER/REPO", "blueberries") - defer stubTerminal(true)() - http := initFakeHTTP() - http.StubRepoResponse("OWNER", "REPO") - http.Register(httpmock.GraphQL(`query PullRequestForBranch\b`), httpmock.FileResponse("../test/fixtures/prView.json")) - - var seenCmd *exec.Cmd - restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable { - switch strings.Join(cmd.Args, " ") { - case `git config --get-regexp ^branch\.blueberries\.(remote|merge)$`: - return &test.OutputStub{} - default: - seenCmd = cmd - return &test.OutputStub{} - } - }) - defer restoreCmd() - - output, err := RunCommand("pr view -w") - if err != nil { - t.Errorf("error running command `pr view`: %v", err) - } - - eq(t, output.String(), "") - eq(t, output.Stderr(), "Opening https://github.com/OWNER/REPO/pull/10 in your browser.\n") - - if seenCmd == nil { - t.Fatal("expected a command to run") - } - url := seenCmd.Args[len(seenCmd.Args)-1] - if url != "https://github.com/OWNER/REPO/pull/10" { - t.Errorf("got: %q", url) - } -} - -func TestPRView_web_noResultsForBranch(t *testing.T) { - initBlankContext("", "OWNER/REPO", "blueberries") - defer stubTerminal(true)() - http := initFakeHTTP() - http.StubRepoResponse("OWNER", "REPO") - http.Register(httpmock.GraphQL(`query PullRequestForBranch\b`), httpmock.FileResponse("../test/fixtures/prView_NoActiveBranch.json")) - - var seenCmd *exec.Cmd - restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable { - switch strings.Join(cmd.Args, " ") { - case `git config --get-regexp ^branch\.blueberries\.(remote|merge)$`: - return &test.OutputStub{} - default: - seenCmd = cmd - return &test.OutputStub{} - } - }) - defer restoreCmd() - - _, err := RunCommand("pr view -w") - if err == nil || err.Error() != `no open pull requests found for branch "blueberries"` { - t.Errorf("error running command `pr view`: %v", err) - } - - if seenCmd != nil { - t.Fatalf("unexpected command: %v", seenCmd.Args) - } -} - -func TestPRView_web_numberArg(t *testing.T) { - initBlankContext("", "OWNER/REPO", "master") - defer stubTerminal(true)() - http := initFakeHTTP() - http.StubRepoResponse("OWNER", "REPO") - - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { "pullRequest": { - "url": "https://github.com/OWNER/REPO/pull/23" - } } } } - `)) - - var seenCmd *exec.Cmd - restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable { - seenCmd = cmd - return &test.OutputStub{} - }) - defer restoreCmd() - - output, err := RunCommand("pr view -w 23") - if err != nil { - t.Errorf("error running command `pr view`: %v", err) - } - - eq(t, output.String(), "") - - if seenCmd == nil { - t.Fatal("expected a command to run") - } - url := seenCmd.Args[len(seenCmd.Args)-1] - eq(t, url, "https://github.com/OWNER/REPO/pull/23") -} - -func TestPRView_web_numberArgWithHash(t *testing.T) { - initBlankContext("", "OWNER/REPO", "master") - defer stubTerminal(true)() - http := initFakeHTTP() - http.StubRepoResponse("OWNER", "REPO") - - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { "pullRequest": { - "url": "https://github.com/OWNER/REPO/pull/23" - } } } } - `)) - - var seenCmd *exec.Cmd - restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable { - seenCmd = cmd - return &test.OutputStub{} - }) - defer restoreCmd() - - output, err := RunCommand("pr view -w \"#23\"") - if err != nil { - t.Errorf("error running command `pr view`: %v", err) - } - - eq(t, output.String(), "") - - if seenCmd == nil { - t.Fatal("expected a command to run") - } - url := seenCmd.Args[len(seenCmd.Args)-1] - eq(t, url, "https://github.com/OWNER/REPO/pull/23") -} - -func TestPRView_web_urlArg(t *testing.T) { - initBlankContext("", "OWNER/REPO", "master") - defer stubTerminal(true)() - http := initFakeHTTP() - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { "pullRequest": { - "url": "https://github.com/OWNER/REPO/pull/23" - } } } } - `)) - - var seenCmd *exec.Cmd - restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable { - seenCmd = cmd - return &test.OutputStub{} - }) - defer restoreCmd() - - output, err := RunCommand("pr view -w https://github.com/OWNER/REPO/pull/23/files") - if err != nil { - t.Errorf("error running command `pr view`: %v", err) - } - - eq(t, output.String(), "") - - if seenCmd == nil { - t.Fatal("expected a command to run") - } - url := seenCmd.Args[len(seenCmd.Args)-1] - eq(t, url, "https://github.com/OWNER/REPO/pull/23") -} - -func TestPRView_web_branchArg(t *testing.T) { - initBlankContext("", "OWNER/REPO", "master") - defer stubTerminal(true)() - http := initFakeHTTP() - http.StubRepoResponse("OWNER", "REPO") - - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { "pullRequests": { "nodes": [ - { "headRefName": "blueberries", - "isCrossRepository": false, - "url": "https://github.com/OWNER/REPO/pull/23" } - ] } } } } - `)) - - var seenCmd *exec.Cmd - restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable { - seenCmd = cmd - return &test.OutputStub{} - }) - defer restoreCmd() - - output, err := RunCommand("pr view -w blueberries") - if err != nil { - t.Errorf("error running command `pr view`: %v", err) - } - - eq(t, output.String(), "") - - if seenCmd == nil { - t.Fatal("expected a command to run") - } - url := seenCmd.Args[len(seenCmd.Args)-1] - eq(t, url, "https://github.com/OWNER/REPO/pull/23") -} - -func TestPRView_web_branchWithOwnerArg(t *testing.T) { - initBlankContext("", "OWNER/REPO", "master") - defer stubTerminal(true)() - http := initFakeHTTP() - http.StubRepoResponse("OWNER", "REPO") - - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { "pullRequests": { "nodes": [ - { "headRefName": "blueberries", - "isCrossRepository": true, - "headRepositoryOwner": { "login": "hubot" }, - "url": "https://github.com/hubot/REPO/pull/23" } - ] } } } } - `)) - - var seenCmd *exec.Cmd - restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable { - seenCmd = cmd - return &test.OutputStub{} - }) - defer restoreCmd() - - output, err := RunCommand("pr view -w hubot:blueberries") - if err != nil { - t.Errorf("error running command `pr view`: %v", err) - } - - eq(t, output.String(), "") - - if seenCmd == nil { - t.Fatal("expected a command to run") - } - url := seenCmd.Args[len(seenCmd.Args)-1] - eq(t, url, "https://github.com/hubot/REPO/pull/23") -} - -func TestReplaceExcessiveWhitespace(t *testing.T) { - eq(t, replaceExcessiveWhitespace("hello\ngoodbye"), "hello goodbye") - eq(t, replaceExcessiveWhitespace(" hello goodbye "), "hello goodbye") - eq(t, replaceExcessiveWhitespace("hello goodbye"), "hello goodbye") - eq(t, replaceExcessiveWhitespace(" hello \n goodbye "), "hello goodbye") -} - -func TestPrStateTitleWithColor(t *testing.T) { - tests := map[string]struct { - pr api.PullRequest - want string - }{ - "Format OPEN state": {pr: api.PullRequest{State: "OPEN", IsDraft: false}, want: "Open"}, - "Format OPEN state for Draft PR": {pr: api.PullRequest{State: "OPEN", IsDraft: true}, want: "Draft"}, - "Format CLOSED state": {pr: api.PullRequest{State: "CLOSED", IsDraft: false}, want: "Closed"}, - "Format MERGED state": {pr: api.PullRequest{State: "MERGED", IsDraft: false}, want: "Merged"}, - } - - for name, tc := range tests { - t.Run(name, func(t *testing.T) { - got := prStateTitleWithColor(tc.pr) - diff := cmp.Diff(tc.want, got) - if diff != "" { - t.Fatalf(diff) - } - }) - } -} - -func TestPrClose(t *testing.T) { - initBlankContext("", "OWNER/REPO", "master") - http := initFakeHTTP() - http.StubRepoResponse("OWNER", "REPO") - - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { - "pullRequest": { "number": 96, "title": "The title of the PR" } - } } } - `)) - - http.StubResponse(200, bytes.NewBufferString(`{"id": "THE-ID"}`)) - - output, err := RunCommand("pr close 96") - if err != nil { - t.Fatalf("error running command `pr close`: %v", err) - } - - r := regexp.MustCompile(`Closed pull request #96 \(The title of the PR\)`) - - if !r.MatchString(output.Stderr()) { - t.Fatalf("output did not match regexp /%s/\n> output\n%q\n", r, output.Stderr()) - } -} - -func TestPrClose_alreadyClosed(t *testing.T) { - initBlankContext("", "OWNER/REPO", "master") - http := initFakeHTTP() - http.StubRepoResponse("OWNER", "REPO") - - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { - "pullRequest": { "number": 101, "title": "The title of the PR", "closed": true } - } } } - `)) - - http.StubResponse(200, bytes.NewBufferString(`{"id": "THE-ID"}`)) - - output, err := RunCommand("pr close 101") - if err != nil { - t.Fatalf("error running command `pr close`: %v", err) - } - - r := regexp.MustCompile(`Pull request #101 \(The title of the PR\) is already closed`) - - if !r.MatchString(output.Stderr()) { - t.Fatalf("output did not match regexp /%s/\n> output\n%q\n", r, output.Stderr()) - } -} - -func TestPRReopen(t *testing.T) { - initBlankContext("", "OWNER/REPO", "master") - http := initFakeHTTP() - http.StubRepoResponse("OWNER", "REPO") - - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { - "pullRequest": { "number": 666, "title": "The title of the PR", "closed": true} - } } } - `)) - - http.StubResponse(200, bytes.NewBufferString(`{"id": "THE-ID"}`)) - - output, err := RunCommand("pr reopen 666") - if err != nil { - t.Fatalf("error running command `pr reopen`: %v", err) - } - - r := regexp.MustCompile(`Reopened pull request #666 \(The title of the PR\)`) - - if !r.MatchString(output.Stderr()) { - t.Fatalf("output did not match regexp /%s/\n> output\n%q\n", r, output.Stderr()) - } -} - -func TestPRReopen_alreadyOpen(t *testing.T) { - initBlankContext("", "OWNER/REPO", "master") - http := initFakeHTTP() - http.StubRepoResponse("OWNER", "REPO") - - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { - "pullRequest": { "number": 666, "title": "The title of the PR", "closed": false} - } } } - `)) - - http.StubResponse(200, bytes.NewBufferString(`{"id": "THE-ID"}`)) - - output, err := RunCommand("pr reopen 666") - if err != nil { - t.Fatalf("error running command `pr reopen`: %v", err) - } - - r := regexp.MustCompile(`Pull request #666 \(The title of the PR\) is already open`) - - if !r.MatchString(output.Stderr()) { - t.Fatalf("output did not match regexp /%s/\n> output\n%q\n", r, output.Stderr()) - } -} - -func TestPRReopen_alreadyMerged(t *testing.T) { - initBlankContext("", "OWNER/REPO", "master") - http := initFakeHTTP() - http.StubRepoResponse("OWNER", "REPO") - - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { - "pullRequest": { "number": 666, "title": "The title of the PR", "closed": true, "state": "MERGED"} - } } } - `)) - - http.StubResponse(200, bytes.NewBufferString(`{"id": "THE-ID"}`)) - - output, err := RunCommand("pr reopen 666") - if err == nil { - t.Fatalf("expected an error running command `pr reopen`: %v", err) - } - - r := regexp.MustCompile(`Pull request #666 \(The title of the PR\) can't be reopened because it was already merged`) - - if !r.MatchString(err.Error()) { - t.Fatalf("output did not match regexp /%s/\n> output\n%q\n", r, output.Stderr()) - } -} - -func TestPrMerge(t *testing.T) { - initBlankContext("", "OWNER/REPO", "master") - defer stubTerminal(true)() - http := initFakeHTTP() - defer http.Verify(t) - http.StubRepoResponse("OWNER", "REPO") - http.Register( - httpmock.GraphQL(`query PullRequestByNumber\b`), - httpmock.StringResponse(` - { "data": { "repository": { "pullRequest": { - "id": "THE-ID", - "number": 1, - "title": "The title of the PR", - "state": "OPEN", - "headRefName": "blueberries", - "headRepositoryOwner": {"login": "OWNER"} - } } } }`)) - http.Register( - httpmock.GraphQL(`mutation PullRequestMerge\b`), - httpmock.GraphQLMutation(`{}`, func(input map[string]interface{}) { - assert.Equal(t, "THE-ID", input["pullRequestId"].(string)) - assert.Equal(t, "MERGE", input["mergeMethod"].(string)) - })) - http.Register( - httpmock.REST("DELETE", "repos/OWNER/REPO/git/refs/heads/blueberries"), - httpmock.StringResponse(`{}`)) - - 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("") - - output, err := RunCommand("pr merge 1 --merge") - if err != nil { - t.Fatalf("error running command `pr merge`: %v", err) - } - - r := regexp.MustCompile(`Merged pull request #1 \(The title of the PR\)`) - - if !r.MatchString(output.Stderr()) { - t.Fatalf("output did not match regexp /%s/\n> output\n%q\n", r, output.Stderr()) - } -} - -func TestPrMerge_nontty(t *testing.T) { - initBlankContext("", "OWNER/REPO", "master") - defer stubTerminal(false)() - http := initFakeHTTP() - http.StubRepoResponse("OWNER", "REPO") - http.Register( - httpmock.GraphQL(`query PullRequestByNumber\b`), - httpmock.StringResponse(` - { "data": { "repository": { - "pullRequest": { "number": 1, "title": "The title of the PR", "state": "OPEN", "id": "THE-ID"} - } } }`)) - http.Register( - httpmock.GraphQL(`mutation PullRequestMerge\b`), - httpmock.GraphQLMutation(`{}`, func(input map[string]interface{}) { - assert.Equal(t, "THE-ID", input["pullRequestId"].(string)) - assert.Equal(t, "MERGE", input["mergeMethod"].(string)) - })) - http.Register( - httpmock.GraphQL(`query RepositoryInfo\b`), - httpmock.StringResponse(`{ - "data": { - "repository": { - "defaultBranchRef": {"name": "master"} - } - } - }`)) - http.Register( - httpmock.REST("DELETE", "repos/OWNER/REPO/git/refs/heads/blueberries"), - httpmock.StringResponse(`{}`)) - - 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("") - - output, err := RunCommand("pr merge 1 --merge") - if err != nil { - t.Fatalf("error running command `pr merge`: %v", err) - } - - assert.Equal(t, "", output.String()) - assert.Equal(t, "", output.Stderr()) -} - -func TestPrMerge_nontty_insufficient_flags(t *testing.T) { - initBlankContext("", "OWNER/REPO", "master") - defer stubTerminal(false)() - http := initFakeHTTP() - http.StubRepoResponse("OWNER", "REPO") - http.Register( - httpmock.GraphQL(`query PullRequestByNumber\b`), - httpmock.StringResponse(` - { "data": { "repository": { - "pullRequest": { "number": 1, "title": "The title of the PR", "state": "OPEN", "id": "THE-ID"} - } } }`)) - - output, err := RunCommand("pr merge 1") - if err == nil { - t.Fatal("expected error") - } - - assert.Equal(t, "--merge, --rebase, or --squash required when not attached to a tty", err.Error()) - assert.Equal(t, "", output.String()) -} - -func TestPrMerge_withRepoFlag(t *testing.T) { - initBlankContext("", "OWNER/REPO", "master") - defer stubTerminal(true)() - http := initFakeHTTP() - defer http.Verify(t) - http.Register( - httpmock.GraphQL(`query PullRequestByNumber\b`), - httpmock.GraphQLQuery(` - { "data": { "repository": { - "pullRequest": { "number": 1, "title": "The title of the PR", "state": "OPEN", "id": "THE-ID"} - } } }`, func(_ string, params map[string]interface{}) { - assert.Equal(t, "stinky", params["owner"].(string)) - assert.Equal(t, "boi", params["repo"].(string)) - })) - http.Register( - httpmock.GraphQL(`mutation PullRequestMerge\b`), - httpmock.GraphQLMutation(`{}`, func(input map[string]interface{}) { - assert.Equal(t, "THE-ID", input["pullRequestId"].(string)) - assert.Equal(t, "MERGE", input["mergeMethod"].(string)) - })) - - cs, cmdTeardown := test.InitCmdStubber() - defer cmdTeardown() - - eq(t, len(cs.Calls), 0) - - output, err := RunCommand("pr merge 1 --merge -R stinky/boi") - if err != nil { - t.Fatalf("error running command `pr merge`: %v", err) - } - - r := regexp.MustCompile(`Merged pull request #1 \(The title of the PR\)`) - - if !r.MatchString(output.Stderr()) { - t.Fatalf("output did not match regexp /%s/\n> output\n%q\n", r, output.Stderr()) - } -} - -func TestPrMerge_deleteBranch(t *testing.T) { - initBlankContext("", "OWNER/REPO", "blueberries") - defer stubTerminal(true)() - http := initFakeHTTP() - defer http.Verify(t) - http.StubRepoResponse("OWNER", "REPO") - http.Register( - httpmock.GraphQL(`query PullRequestForBranch\b`), - httpmock.FileResponse("../test/fixtures/prViewPreviewWithMetadataByBranch.json")) - http.Register( - httpmock.GraphQL(`mutation PullRequestMerge\b`), - httpmock.GraphQLMutation(`{}`, func(input map[string]interface{}) { - assert.Equal(t, "PR_10", input["pullRequestId"].(string)) - assert.Equal(t, "MERGE", input["mergeMethod"].(string)) - })) - http.Register( - httpmock.REST("DELETE", "repos/OWNER/REPO/git/refs/heads/blueberries"), - httpmock.StringResponse(`{}`)) - - cs, cmdTeardown := test.InitCmdStubber() - defer cmdTeardown() - - cs.Stub("") // git config --get-regexp ^branch\.blueberries\.(remote|merge)$ - cs.Stub("") // git checkout master - cs.Stub("") // git rev-parse --verify blueberries` - cs.Stub("") // git branch -d - cs.Stub("") // git push origin --delete blueberries - - output, err := RunCommand(`pr merge --merge --delete-branch`) - if err != nil { - t.Fatalf("Got unexpected error running `pr merge` %s", err) - } - - test.ExpectLines(t, output.Stderr(), `Merged pull request #10 \(Blueberries are a good fruit\)`, `Deleted branch.*blueberries`) -} - -func TestPrMerge_deleteNonCurrentBranch(t *testing.T) { - initBlankContext("", "OWNER/REPO", "another-branch") - defer stubTerminal(true)() - http := initFakeHTTP() - defer http.Verify(t) - http.StubRepoResponse("OWNER", "REPO") - http.Register( - httpmock.GraphQL(`query PullRequestForBranch\b`), - httpmock.FileResponse("../test/fixtures/prViewPreviewWithMetadataByBranch.json")) - http.Register( - httpmock.GraphQL(`mutation PullRequestMerge\b`), - httpmock.GraphQLMutation(`{}`, func(input map[string]interface{}) { - assert.Equal(t, "PR_10", input["pullRequestId"].(string)) - assert.Equal(t, "MERGE", input["mergeMethod"].(string)) - })) - http.Register( - httpmock.REST("DELETE", "repos/OWNER/REPO/git/refs/heads/blueberries"), - httpmock.StringResponse(`{}`)) - - cs, cmdTeardown := test.InitCmdStubber() - defer cmdTeardown() - // 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 - - output, err := RunCommand(`pr merge --merge --delete-branch blueberries`) - if err != nil { - t.Fatalf("Got unexpected error running `pr merge` %s", err) - } - - test.ExpectLines(t, output.Stderr(), `Merged pull request #10 \(Blueberries are a good fruit\)`, `Deleted branch.*blueberries`) -} - -func TestPrMerge_noPrNumberGiven(t *testing.T) { - initBlankContext("", "OWNER/REPO", "blueberries") - defer stubTerminal(true)() - http := initFakeHTTP() - defer http.Verify(t) - http.StubRepoResponse("OWNER", "REPO") - http.Register( - httpmock.GraphQL(`query PullRequestForBranch\b`), - httpmock.FileResponse("../test/fixtures/prViewPreviewWithMetadataByBranch.json")) - http.Register( - httpmock.GraphQL(`mutation PullRequestMerge\b`), - httpmock.GraphQLMutation(`{}`, func(input map[string]interface{}) { - assert.Equal(t, "PR_10", input["pullRequestId"].(string)) - assert.Equal(t, "MERGE", input["mergeMethod"].(string)) - })) - http.Register( - httpmock.REST("DELETE", "repos/OWNER/REPO/git/refs/heads/blueberries"), - httpmock.StringResponse(`{}`)) - - 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("") // git branch -d - - output, err := RunCommand("pr merge --merge") - if err != nil { - t.Fatalf("error running command `pr merge`: %v", err) - } - - r := regexp.MustCompile(`Merged pull request #10 \(Blueberries are a good fruit\)`) - - if !r.MatchString(output.Stderr()) { - t.Fatalf("output did not match regexp /%s/\n> output\n%q\n", r, output.Stderr()) - } -} - -func TestPrMerge_rebase(t *testing.T) { - initBlankContext("", "OWNER/REPO", "master") - defer stubTerminal(true)() - http := initFakeHTTP() - defer http.Verify(t) - http.StubRepoResponse("OWNER", "REPO") - http.Register( - httpmock.GraphQL(`query PullRequestByNumber\b`), - httpmock.StringResponse(` - { "data": { "repository": { "pullRequest": { - "id": "THE-ID", - "number": 2, - "title": "The title of the PR", - "state": "OPEN", - "headRefName": "blueberries", - "headRepositoryOwner": {"login": "OWNER"} - } } } }`)) - http.Register( - httpmock.GraphQL(`mutation PullRequestMerge\b`), - httpmock.GraphQLMutation(`{}`, func(input map[string]interface{}) { - assert.Equal(t, "THE-ID", input["pullRequestId"].(string)) - assert.Equal(t, "REBASE", input["mergeMethod"].(string)) - })) - http.Register( - httpmock.REST("DELETE", "repos/OWNER/REPO/git/refs/heads/blueberries"), - httpmock.StringResponse(`{}`)) - - cs, cmdTeardown := test.InitCmdStubber() - defer cmdTeardown() - - cs.Stub("") // git config --get-regexp ^branch\.blueberries\.(remote|merge)$ - cs.Stub("") // git symbolic-ref --quiet --short HEAD - cs.Stub("") // git checkout master - cs.Stub("") // git branch -d - - output, err := RunCommand("pr merge 2 --rebase") - if err != nil { - t.Fatalf("error running command `pr merge`: %v", err) - } - - r := regexp.MustCompile(`Rebased and merged pull request #2 \(The title of the PR\)`) - - if !r.MatchString(output.Stderr()) { - t.Fatalf("output did not match regexp /%s/\n> output\n%q\n", r, output.Stderr()) - } -} - -func TestPrMerge_squash(t *testing.T) { - initBlankContext("", "OWNER/REPO", "master") - defer stubTerminal(true)() - http := initFakeHTTP() - defer http.Verify(t) - http.StubRepoResponse("OWNER", "REPO") - http.Register( - httpmock.GraphQL(`query PullRequestByNumber\b`), - httpmock.StringResponse(` - { "data": { "repository": { "pullRequest": { - "id": "THE-ID", - "number": 3, - "title": "The title of the PR", - "state": "OPEN", - "headRefName": "blueberries", - "headRepositoryOwner": {"login": "OWNER"} - } } } }`)) - http.Register( - httpmock.GraphQL(`mutation PullRequestMerge\b`), - httpmock.GraphQLMutation(`{}`, func(input map[string]interface{}) { - assert.Equal(t, "THE-ID", input["pullRequestId"].(string)) - assert.Equal(t, "SQUASH", input["mergeMethod"].(string)) - })) - http.Register( - httpmock.REST("DELETE", "repos/OWNER/REPO/git/refs/heads/blueberries"), - httpmock.StringResponse(`{}`)) - - cs, cmdTeardown := test.InitCmdStubber() - defer cmdTeardown() - - cs.Stub("") // git config --get-regexp ^branch\.blueberries\.(remote|merge)$ - cs.Stub("") // git symbolic-ref --quiet --short HEAD - cs.Stub("") // git checkout master - cs.Stub("") // git branch -d - - output, err := RunCommand("pr merge 3 --squash") - if err != nil { - t.Fatalf("error running command `pr merge`: %v", err) - } - - test.ExpectLines(t, output.Stderr(), "Squashed and merged pull request #3", `Deleted branch.*blueberries`) -} - -func TestPrMerge_alreadyMerged(t *testing.T) { - initBlankContext("", "OWNER/REPO", "master") - defer stubTerminal(true)() - http := initFakeHTTP() - defer http.Verify(t) - http.StubRepoResponse("OWNER", "REPO") - http.Register( - httpmock.GraphQL(`query PullRequestByNumber\b`), - httpmock.StringResponse(` - { "data": { "repository": { - "pullRequest": { "number": 4, "title": "The title of the PR", "state": "MERGED"} - } } }`)) - - 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 - - output, err := RunCommand("pr merge 4") - if err == nil { - t.Fatalf("expected an error running command `pr merge`: %v", err) - } - - r := regexp.MustCompile(`Pull request #4 \(The title of the PR\) was already merged`) - - if !r.MatchString(err.Error()) { - t.Fatalf("output did not match regexp /%s/\n> output\n%q\n", r, output.Stderr()) - } -} - -func TestPRMerge_interactive(t *testing.T) { - initBlankContext("", "OWNER/REPO", "blueberries") - defer stubTerminal(true)() - http := initFakeHTTP() - defer http.Verify(t) - http.StubRepoResponse("OWNER", "REPO") - http.Register( - httpmock.GraphQL(`query PullRequestForBranch\b`), - httpmock.StringResponse(` - { "data": { "repository": { "pullRequests": { "nodes": [{ - "headRefName": "blueberries", - "headRepositoryOwner": {"login": "OWNER"}, - "id": "THE-ID", - "number": 3 - }] } } } }`)) - http.Register( - httpmock.GraphQL(`mutation PullRequestMerge\b`), - httpmock.GraphQLMutation(`{}`, func(input map[string]interface{}) { - assert.Equal(t, "THE-ID", input["pullRequestId"].(string)) - assert.Equal(t, "MERGE", input["mergeMethod"].(string)) - })) - http.Register( - httpmock.REST("DELETE", "repos/OWNER/REPO/git/refs/heads/blueberries"), - httpmock.StringResponse(`{}`)) - - cs, cmdTeardown := test.InitCmdStubber() - defer cmdTeardown() - - cs.Stub("") // git config --get-regexp ^branch\.blueberries\.(remote|merge)$ - cs.Stub("") // git symbolic-ref --quiet --short HEAD - cs.Stub("") // git checkout master - cs.Stub("") // git push origin --delete blueberries - cs.Stub("") // git branch -d - - as, surveyTeardown := prompt.InitAskStubber() - defer surveyTeardown() - - as.Stub([]*prompt.QuestionStub{ - { - Name: "mergeMethod", - Value: 0, - }, - { - Name: "deleteBranch", - Value: true, - }, - }) - - output, err := RunCommand(`pr merge`) - if err != nil { - t.Fatalf("Got unexpected error running `pr merge` %s", err) - } - - test.ExpectLines(t, output.Stderr(), "Merged pull request #3", `Deleted branch.*blueberries`) -} - -func TestPrMerge_multipleMergeMethods(t *testing.T) { - initBlankContext("", "OWNER/REPO", "master") - defer stubTerminal(true)() - - _, err := RunCommand("pr merge 1 --merge --squash") - if err == nil { - t.Fatal("expected error running `pr merge` with multiple merge methods") - } -} - -func TestPrMerge_multipleMergeMethods_nontty(t *testing.T) { - initBlankContext("", "OWNER/REPO", "master") - defer stubTerminal(false)() - - _, err := RunCommand("pr merge 1 --merge --squash") - if err == nil { - t.Fatal("expected error running `pr merge` with multiple merge methods") - } -} - -func TestPRReady(t *testing.T) { - initBlankContext("", "OWNER/REPO", "master") - http := initFakeHTTP() - http.StubRepoResponse("OWNER", "REPO") - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { - "pullRequest": { "number": 444, "closed": false, "isDraft": true} - } } } - `)) - http.StubResponse(200, bytes.NewBufferString(`{"id": "THE-ID"}`)) - - output, err := RunCommand("pr ready 444") - if err != nil { - t.Fatalf("error running command `pr ready`: %v", err) - } - - r := regexp.MustCompile(`Pull request #444 is marked as "ready for review"`) - - if !r.MatchString(output.Stderr()) { - t.Fatalf("output did not match regexp /%s/\n> output\n%q\n", r, output.Stderr()) - } -} - -func TestPRReady_alreadyReady(t *testing.T) { - initBlankContext("", "OWNER/REPO", "master") - http := initFakeHTTP() - http.StubRepoResponse("OWNER", "REPO") - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { - "pullRequest": { "number": 445, "closed": false, "isDraft": false} - } } } - `)) - http.StubResponse(200, bytes.NewBufferString(`{"id": "THE-ID"}`)) - - output, err := RunCommand("pr ready 445") - if err != nil { - t.Fatalf("error running command `pr ready`: %v", err) - } - - r := regexp.MustCompile(`Pull request #445 is already "ready for review"`) - - if !r.MatchString(output.Stderr()) { - t.Fatalf("output did not match regexp /%s/\n> output\n%q\n", r, output.Stderr()) - } -} - -func TestPRReady_closed(t *testing.T) { - initBlankContext("", "OWNER/REPO", "master") - http := initFakeHTTP() - http.StubRepoResponse("OWNER", "REPO") - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { - "pullRequest": { "number": 446, "closed": true, "isDraft": true} - } } } - `)) - http.StubResponse(200, bytes.NewBufferString(`{"id": "THE-ID"}`)) - - _, err := RunCommand("pr ready 446") - if err == nil { - t.Fatalf("expected an error running command `pr ready` on a review that is closed!: %v", err) - } - - r := regexp.MustCompile(`Pull request #446 is closed. Only draft pull requests can be marked as "ready for review"`) - - if !r.MatchString(err.Error()) { - t.Fatalf("output did not match regexp /%s/\n> output\n%q\n", r, err.Error()) - } -} diff --git a/command/root.go b/command/root.go index 3ee9f2203..9eb1c9287 100644 --- a/command/root.go +++ b/command/root.go @@ -16,11 +16,17 @@ import ( "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" + "github.com/cli/cli/internal/ghinstance" "github.com/cli/cli/internal/ghrepo" "github.com/cli/cli/internal/run" apiCmd "github.com/cli/cli/pkg/cmd/api" + authCmd "github.com/cli/cli/pkg/cmd/auth" + authLoginCmd "github.com/cli/cli/pkg/cmd/auth/login" + authLogoutCmd "github.com/cli/cli/pkg/cmd/auth/logout" gistCreateCmd "github.com/cli/cli/pkg/cmd/gist/create" + prCmd "github.com/cli/cli/pkg/cmd/pr" repoCmd "github.com/cli/cli/pkg/cmd/repo" repoCloneCmd "github.com/cli/cli/pkg/cmd/repo/clone" repoCreateCmd "github.com/cli/cli/pkg/cmd/repo/create" @@ -36,9 +42,6 @@ import ( "github.com/spf13/pflag" ) -// TODO these are sprinkled across command, context, config, and ghrepo -const defaultHostname = "github.com" - // Version is dynamically set by the toolchain or overridden by the Makefile. var Version = "DEV" @@ -47,6 +50,8 @@ var BuildDate = "" // YYYY-MM-DD var versionOutput = "" +var defaultStreams *iostreams.IOStreams + func init() { if Version == "DEV" { if info, ok := debug.ReadBuildInfo(); ok && info.Main.Version != "(devel)" { @@ -78,22 +83,21 @@ func init() { return &cmdutil.FlagError{Err: err} }) + defaultStreams = iostreams.System() + // TODO: iron out how a factory incorporates context cmdFactory := &cmdutil.Factory{ - IOStreams: iostreams.System(), + IOStreams: defaultStreams, HttpClient: func() (*http.Client, error) { - token := os.Getenv("GITHUB_TOKEN") - if len(token) == 0 { - // TODO: decouple from `context` - ctx := context.New() - var err error - // TODO: pass IOStreams to this so that the auth flow knows if it's interactive or not - token, err = ctx.AuthToken() - if err != nil { - return nil, err - } + // TODO: decouple from `context` + ctx := context.New() + cfg, err := ctx.Config() + if err != nil { + return nil, err } - return httpClient(token), nil + + // TODO: avoid setting Accept header for `api` command + return httpClient(defaultStreams, cfg, true), nil }, BaseRepo: func() (ghrepo.Interface, error) { // TODO: decouple from `context` @@ -113,6 +117,13 @@ func init() { } return cfg, nil }, + Branch: func() (string, error) { + currentBranch, err := git.CurrentBranch() + if err != nil { + return "", fmt.Errorf("could not determine current branch: %w", err) + } + return currentBranch, nil + }, } RootCmd.AddCommand(apiCmd.NewCmdApi(cmdFactory, nil)) @@ -124,6 +135,10 @@ func init() { RootCmd.AddCommand(gistCmd) gistCmd.AddCommand(gistCreateCmd.NewCmdCreate(cmdFactory, nil)) + RootCmd.AddCommand(authCmd.Cmd) + authCmd.Cmd.AddCommand(authLoginCmd.NewCmdLogin(cmdFactory, nil)) + authCmd.Cmd.AddCommand(authLogoutCmd.NewCmdLogout(cmdFactory, nil)) + resolvedBaseRepo := func() (ghrepo.Interface, error) { httpClient, err := cmdFactory.HttpClient() if err != nil { @@ -160,6 +175,7 @@ func init() { repoCmd.Cmd.AddCommand(repoCreateCmd.NewCmdCreate(cmdFactory, nil)) repoCmd.Cmd.AddCommand(creditsCmd.NewCmdRepoCredits(&repoResolvingCmdFactory, nil)) + RootCmd.AddCommand(prCmd.NewCmdPR(&repoResolvingCmdFactory)) RootCmd.AddCommand(creditsCmd.NewCmdCredits(cmdFactory, nil)) } @@ -218,14 +234,11 @@ var initContext = func() context.Context { if repo := os.Getenv("GH_REPO"); repo != "" { ctx.SetBaseRepo(repo) } - if token := os.Getenv("GITHUB_TOKEN"); token != "" { - ctx.SetAuthToken(token) - } return ctx } -// BasicClient returns an API client that borrows from but does not depend on -// user configuration +// BasicClient returns an API client for github.com only that borrows from but +// does not depend on user configuration func BasicClient() (*api.Client, error) { var opts []api.ClientOption if verbose := os.Getenv("DEBUG"); verbose != "" { @@ -236,7 +249,7 @@ func BasicClient() (*api.Client, error) { token := os.Getenv("GITHUB_TOKEN") if token == "" { if c, err := config.ParseDefaultConfig(); err == nil { - token, _ = c.Get(defaultHostname, "oauth_token") + token, _ = c.Get(ghinstance.Default(), "oauth_token") } } if token != "" { @@ -253,72 +266,71 @@ func contextForCommand(cmd *cobra.Command) context.Context { return ctx } -// for cmdutil-powered commands -func httpClient(token string) *http.Client { +// generic authenticated HTTP client for commands +func httpClient(io *iostreams.IOStreams, cfg config.Config, setAccept bool) *http.Client { var opts []api.ClientOption if verbose := os.Getenv("DEBUG"); verbose != "" { opts = append(opts, apiVerboseLog()) } + opts = append(opts, - api.AddHeader("Authorization", fmt.Sprintf("token %s", token)), api.AddHeader("User-Agent", fmt.Sprintf("GitHub CLI %s", Version)), + // antiope-preview: Checks + // FIXME: avoid setting this header for `api` command + api.AddHeader("Accept", "application/vnd.github.antiope-preview+json"), + api.AddHeaderFunc("Authorization", func(req *http.Request) (string, error) { + if token := os.Getenv("GITHUB_TOKEN"); token != "" { + return fmt.Sprintf("token %s", token), nil + } + + hostname := ghinstance.NormalizeHostname(req.URL.Hostname()) + token, err := cfg.Get(hostname, "oauth_token") + if token == "" { + var notFound *config.NotFoundError + // TODO: check if stdout is TTY too + if errors.As(err, ¬Found) && io.IsStdinTTY() { + // interactive OAuth flow + token, err = config.AuthFlowWithConfig(cfg, hostname, "Notice: authentication required") + } + if err != nil { + return "", err + } + if token == "" { + // TODO: instruct user how to manually authenticate + return "", fmt.Errorf("authentication required for %s", hostname) + } + } + + return fmt.Sprintf("token %s", token), nil + }), ) + + if setAccept { + opts = append(opts, + api.AddHeaderFunc("Accept", func(req *http.Request) (string, error) { + // antiope-preview: Checks + accept := "application/vnd.github.antiope-preview+json" + if ghinstance.IsEnterprise(req.URL.Hostname()) { + // shadow-cat-preview: Draft pull requests + accept += ", application/vnd.github.shadow-cat-preview" + } + return accept, nil + }), + ) + } + return api.NewHTTPClient(opts...) } -// overridden in tests +// LEGACY; overridden in tests var apiClientForContext = func(ctx context.Context) (*api.Client, error) { - token, err := ctx.AuthToken() + cfg, err := ctx.Config() if err != nil { return nil, err } - var opts []api.ClientOption - if verbose := os.Getenv("DEBUG"); verbose != "" { - opts = append(opts, apiVerboseLog()) - } - - getAuthValue := func() string { - return fmt.Sprintf("token %s", token) - } - - tokenFromEnv := func() bool { - return os.Getenv("GITHUB_TOKEN") == token - } - - checkScopesFunc := func(appID string) error { - if config.IsGitHubApp(appID) && !tokenFromEnv() && utils.IsTerminal(os.Stdin) && utils.IsTerminal(os.Stderr) { - cfg, err := ctx.Config() - if err != nil { - return err - } - newToken, err := config.AuthFlowWithConfig(cfg, defaultHostname, "Notice: additional authorization required") - if err != nil { - return err - } - // update configuration in memory - token = newToken - } else { - fmt.Fprintln(os.Stderr, "Warning: gh now requires the `read:org` OAuth scope.") - fmt.Fprintln(os.Stderr, "Visit https://github.com/settings/tokens and edit your token to enable `read:org`") - if tokenFromEnv() { - fmt.Fprintln(os.Stderr, "or generate a new token for the GITHUB_TOKEN environment variable") - } else { - fmt.Fprintln(os.Stderr, "or generate a new token and paste it via `gh config set -h github.com oauth_token MYTOKEN`") - } - } - return nil - } - - opts = append(opts, - api.CheckScopes("read:org", checkScopesFunc), - api.AddHeaderFunc("Authorization", getAuthValue), - api.AddHeader("User-Agent", fmt.Sprintf("GitHub CLI %s", Version)), - // antiope-preview: Checks - api.AddHeader("Accept", "application/vnd.github.antiope-preview+json"), - ) - - return api.NewClient(opts...), nil + http := httpClient(defaultStreams, cfg, true) + return api.NewClientFromHTTP(http), nil } func apiVerboseLog() api.ClientOption { @@ -382,39 +394,6 @@ func determineBaseRepo(apiClient *api.Client, cmd *cobra.Command, ctx context.Co return baseRepo, nil } -// TODO there is a parallel implementation for isolated commands -func formatRemoteURL(cmd *cobra.Command, repo ghrepo.Interface) string { - ctx := contextForCommand(cmd) - - var protocol string - cfg, err := ctx.Config() - if err != nil { - fmt.Fprintf(colorableErr(cmd), "%s failed to load config: %s. using defaults\n", utils.Yellow("!"), err) - } else { - protocol, _ = cfg.Get(repo.RepoHost(), "git_protocol") - } - - if protocol == "ssh" { - return fmt.Sprintf("git@%s:%s/%s.git", repo.RepoHost(), repo.RepoOwner(), repo.RepoName()) - } - - return fmt.Sprintf("https://%s/%s/%s.git", repo.RepoHost(), repo.RepoOwner(), repo.RepoName()) -} - -func determineEditor(cmd *cobra.Command) (string, error) { - editorCommand := os.Getenv("GH_EDITOR") - if editorCommand == "" { - ctx := contextForCommand(cmd) - cfg, err := ctx.Config() - if err != nil { - return "", fmt.Errorf("could not read config: %w", err) - } - editorCommand, _ = cfg.Get(defaultHostname, "editor") - } - - return editorCommand, nil -} - func ExecuteShellAlias(args []string) error { externalCmd := exec.Command(args[0], args[1:]...) externalCmd.Stderr = os.Stderr diff --git a/command/root_test.go b/command/root_test.go index 7d93169a4..c0b1fd7d3 100644 --- a/command/root_test.go +++ b/command/root_test.go @@ -2,8 +2,6 @@ package command import ( "testing" - - "github.com/cli/cli/internal/ghrepo" ) func TestChangelogURL(t *testing.T) { @@ -42,22 +40,3 @@ func TestChangelogURL(t *testing.T) { t.Errorf("expected %s to create url %s but got %s", tag, url, result) } } - -func TestRemoteURLFormatting_no_config(t *testing.T) { - initBlankContext("", "OWNER/REPO", "master") - result := formatRemoteURL(prCheckoutCmd, ghrepo.New("OWNER", "REPO")) - eq(t, result, "https://github.com/OWNER/REPO.git") -} - -func TestRemoteURLFormatting_ssh_config(t *testing.T) { - cfg := `--- -hosts: - github.com: - user: OWNER - oauth_token: MUSTBEHIGHCUZIMATOKEN -git_protocol: ssh -` - initBlankContext(cfg, "OWNER/REPO", "master") - result := formatRemoteURL(prCheckoutCmd, ghrepo.New("OWNER", "REPO")) - eq(t, result, "git@github.com:OWNER/REPO.git") -} diff --git a/command/testing.go b/command/testing.go index e12cc38e3..fe9b76ee7 100644 --- a/command/testing.go +++ b/command/testing.go @@ -2,8 +2,9 @@ package command import ( "bytes" - "errors" "fmt" + "reflect" + "testing" "github.com/cli/cli/api" "github.com/cli/cli/context" @@ -14,6 +15,13 @@ import ( "github.com/spf13/pflag" ) +func eq(t *testing.T, got interface{}, expected interface{}) { + t.Helper() + if !reflect.DeepEqual(got, expected) { + t.Errorf("expected: %v, got: %v", expected, got) + } +} + const defaultTestConfig = `hosts: github.com: user: OWNER @@ -102,18 +110,6 @@ func RunCommand(args string) (*cmdOut, error) { return &cmdOut{&outBuf, &errBuf}, err } -type errorStub struct { - message string -} - -func (s errorStub) Output() ([]byte, error) { - return nil, errors.New(s.message) -} - -func (s errorStub) Run() error { - return errors.New(s.message) -} - func stubTerminal(connected bool) func() { isTerminal := utils.IsTerminal utils.IsTerminal = func(_ interface{}) bool { diff --git a/context/blank_context.go b/context/blank_context.go index 3035b4d21..f14310937 100644 --- a/context/blank_context.go +++ b/context/blank_context.go @@ -16,10 +16,9 @@ func NewBlank() *blankContext { // A Context implementation that queries the filesystem type blankContext struct { - authToken string - branch string - baseRepo ghrepo.Interface - remotes Remotes + branch string + baseRepo ghrepo.Interface + remotes Remotes } func (c *blankContext) Config() (config.Config, error) { @@ -30,14 +29,6 @@ func (c *blankContext) Config() (config.Config, error) { return cfg, nil } -func (c *blankContext) AuthToken() (string, error) { - return c.authToken, nil -} - -func (c *blankContext) SetAuthToken(t string) { - c.authToken = t -} - func (c *blankContext) Branch() (string, error) { if c.branch == "" { return "", fmt.Errorf("branch was not initialized: %w", git.ErrNotOnAnyBranch) diff --git a/context/context.go b/context/context.go index e83e0b44e..6cff06b4c 100644 --- a/context/context.go +++ b/context/context.go @@ -13,13 +13,8 @@ import ( "github.com/cli/cli/internal/ghrepo" ) -// TODO these are sprinkled across command, context, config, and ghrepo -const defaultHostname = "github.com" - // Context represents the interface for querying information about the current environment type Context interface { - AuthToken() (string, error) - SetAuthToken(string) Branch() (string, error) SetBranch(string) Remotes() (Remotes, error) @@ -164,11 +159,10 @@ func New() Context { // A Context implementation that queries the filesystem type fsContext struct { - config config.Config - remotes Remotes - branch string - baseRepo ghrepo.Interface - authToken string + config config.Config + remotes Remotes + branch string + baseRepo ghrepo.Interface } func (c *fsContext) Config() (config.Config, error) { @@ -180,37 +174,10 @@ func (c *fsContext) Config() (config.Config, error) { return nil, err } c.config = cfg - c.authToken = "" } return c.config, nil } -func (c *fsContext) AuthToken() (string, error) { - if c.authToken != "" { - return c.authToken, nil - } - - cfg, err := c.Config() - if err != nil { - return "", err - } - - var notFound *config.NotFoundError - token, err := cfg.Get(defaultHostname, "oauth_token") - if token == "" || errors.As(err, ¬Found) { - // interactive OAuth flow - return config.AuthFlowWithConfig(cfg, defaultHostname, "Notice: authentication required") - } else if err != nil { - return "", err - } - - return token, nil -} - -func (c *fsContext) SetAuthToken(t string) { - c.authToken = t -} - func (c *fsContext) Branch() (string, error) { if c.branch != "" { return c.branch, nil @@ -242,11 +209,16 @@ func (c *fsContext) Remotes() (Remotes, error) { sshTranslate := git.ParseSSHConfig().Translator() resolvedRemotes := translateRemotes(gitRemotes, sshTranslate) - // ignore non-github.com remotes - // TODO: GHE compatibility + // determine hostname by looking at the "main" remote + var hostname string + if mainRemote, err := resolvedRemotes.FindByName("upstream", "github", "origin", "*"); err == nil { + hostname = mainRemote.RepoHost() + } + + // filter the rest of the remotes to just that hostname filteredRemotes := Remotes{} for _, r := range resolvedRemotes { - if r.RepoHost() != defaultHostname { + if r.RepoHost() != hostname { continue } filteredRemotes = append(filteredRemotes, r) @@ -255,7 +227,6 @@ func (c *fsContext) Remotes() (Remotes, error) { } if len(c.remotes) == 0 { - // TODO: GHE compatibility return nil, errors.New("no git remote found for a github.com repository") } return c.remotes, nil diff --git a/internal/config/config_setup.go b/internal/config/config_setup.go index 7217eb3d9..d8fcef1ec 100644 --- a/internal/config/config_setup.go +++ b/internal/config/config_setup.go @@ -9,6 +9,7 @@ import ( "github.com/cli/cli/api" "github.com/cli/cli/auth" + "github.com/cli/cli/utils" ) var ( @@ -67,14 +68,14 @@ func authFlow(oauthHost, notice string) (string, string, error) { } fmt.Fprintln(os.Stderr, notice) - fmt.Fprintf(os.Stderr, "Press Enter to open %s in your browser... ", flow.Hostname) + fmt.Fprintf(os.Stderr, "- %s to open %s in your browser... ", utils.Bold("Press Enter"), flow.Hostname) _ = waitForEnter(os.Stdin) token, err := flow.ObtainAccessToken() if err != nil { return "", "", err } - userLogin, err := getViewer(token) + userLogin, err := getViewer(oauthHost, token) if err != nil { return "", "", err } @@ -83,13 +84,14 @@ func authFlow(oauthHost, notice string) (string, string, error) { } func AuthFlowComplete() { - fmt.Fprintln(os.Stderr, "Authentication complete. Press Enter to continue... ") + fmt.Fprintf(os.Stderr, "%s Authentication complete. %s to continue...\n", + utils.GreenCheck(), utils.Bold("Press Enter")) _ = waitForEnter(os.Stdin) } -func getViewer(token string) (string, error) { +func getViewer(hostname, token string) (string, error) { http := api.NewClient(api.AddHeader("Authorization", fmt.Sprintf("token %s", token))) - return api.CurrentLoginName(http) + return api.CurrentLoginName(http, hostname) } func waitForEnter(r io.Reader) error { diff --git a/internal/config/config_type.go b/internal/config/config_type.go index 85e5c0d4a..523430c54 100644 --- a/internal/config/config_type.go +++ b/internal/config/config_type.go @@ -4,7 +4,9 @@ import ( "bytes" "errors" "fmt" + "sort" + "github.com/cli/cli/internal/ghinstance" "gopkg.in/yaml.v3" ) @@ -14,6 +16,8 @@ const defaultGitProtocol = "https" type Config interface { Get(string, string) (string, error) Set(string, string, string) error + UnsetHost(string) + Hosts() ([]string, error) Aliases() (*AliasConfig, error) Write() error } @@ -29,7 +33,7 @@ type HostConfig struct { // This type implements a low-level get/set config that is backed by an in-memory tree of Yaml // nodes. It allows us to interact with a yaml-based config programmatically, preserving any -// comments that were present when the yaml waas parsed. +// comments that were present when the yaml was parsed. type ConfigMap struct { Root *yaml.Node } @@ -236,6 +240,20 @@ func (c *fileConfig) Set(hostname, key, value string) error { } } +func (c *fileConfig) UnsetHost(hostname string) { + if hostname == "" { + return + } + + hostsEntry, err := c.FindEntry("hosts") + if err != nil { + return + } + + cm := ConfigMap{hostsEntry.ValueNode} + cm.RemoveEntry(hostname) +} + func (c *fileConfig) configForHost(hostname string) (*HostConfig, error) { hosts, err := c.hostEntries() if err != nil { @@ -357,6 +375,23 @@ func (c *fileConfig) hostEntries() ([]*HostConfig, error) { return hostConfigs, nil } +// Hosts returns a list of all known hostnames configred in hosts.yml +func (c *fileConfig) Hosts() ([]string, error) { + entries, err := c.hostEntries() + if err != nil { + return nil, err + } + + hostnames := []string{} + for _, entry := range entries { + hostnames = append(hostnames, entry.Host) + } + + sort.SliceStable(hostnames, func(i, j int) bool { return hostnames[i] == ghinstance.Default() }) + + return hostnames, nil +} + func (c *fileConfig) makeConfigForHost(hostname string) *HostConfig { hostRoot := &yaml.Node{Kind: yaml.MappingNode} hostCfg := &HostConfig{ diff --git a/internal/ghinstance/host.go b/internal/ghinstance/host.go new file mode 100644 index 000000000..c05c3b263 --- /dev/null +++ b/internal/ghinstance/host.go @@ -0,0 +1,41 @@ +package ghinstance + +import ( + "fmt" + "strings" +) + +const defaultHostname = "github.com" + +// Default returns the host name of the default GitHub instance +func Default() string { + return defaultHostname +} + +// IsEnterprise reports whether a non-normalized host name looks like a GHE instance +func IsEnterprise(h string) bool { + return NormalizeHostname(h) != defaultHostname +} + +// NormalizeHostname returns the canonical host name of a GitHub instance +func NormalizeHostname(h string) string { + hostname := strings.ToLower(h) + if strings.HasSuffix(hostname, "."+defaultHostname) { + return defaultHostname + } + return hostname +} + +func GraphQLEndpoint(hostname string) string { + if IsEnterprise(hostname) { + return fmt.Sprintf("https://%s/api/graphql", hostname) + } + return "https://api.github.com/graphql" +} + +func RESTPrefix(hostname string) string { + if IsEnterprise(hostname) { + return fmt.Sprintf("https://%s/api/v3/", hostname) + } + return "https://api.github.com/" +} diff --git a/internal/ghinstance/host_test.go b/internal/ghinstance/host_test.go new file mode 100644 index 000000000..26515dc39 --- /dev/null +++ b/internal/ghinstance/host_test.go @@ -0,0 +1,121 @@ +package ghinstance + +import ( + "testing" +) + +func TestIsEnterprise(t *testing.T) { + tests := []struct { + host string + want bool + }{ + { + host: "github.com", + want: false, + }, + { + host: "api.github.com", + want: false, + }, + { + host: "ghe.io", + want: true, + }, + { + host: "example.com", + want: true, + }, + } + for _, tt := range tests { + t.Run(tt.host, func(t *testing.T) { + if got := IsEnterprise(tt.host); got != tt.want { + t.Errorf("IsEnterprise() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestNormalizeHostname(t *testing.T) { + tests := []struct { + host string + want string + }{ + { + host: "GitHub.com", + want: "github.com", + }, + { + host: "api.github.com", + want: "github.com", + }, + { + host: "ssh.github.com", + want: "github.com", + }, + { + host: "upload.github.com", + want: "github.com", + }, + { + host: "GHE.IO", + want: "ghe.io", + }, + { + host: "git.my.org", + want: "git.my.org", + }, + } + for _, tt := range tests { + t.Run(tt.host, func(t *testing.T) { + if got := NormalizeHostname(tt.host); got != tt.want { + t.Errorf("NormalizeHostname() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestGraphQLEndpoint(t *testing.T) { + tests := []struct { + host string + want string + }{ + { + host: "github.com", + want: "https://api.github.com/graphql", + }, + { + host: "ghe.io", + want: "https://ghe.io/api/graphql", + }, + } + for _, tt := range tests { + t.Run(tt.host, func(t *testing.T) { + if got := GraphQLEndpoint(tt.host); got != tt.want { + t.Errorf("GraphQLEndpoint() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestRESTPrefix(t *testing.T) { + tests := []struct { + host string + want string + }{ + { + host: "github.com", + want: "https://api.github.com/", + }, + { + host: "ghe.io", + want: "https://ghe.io/api/v3/", + }, + } + for _, tt := range tests { + t.Run(tt.host, func(t *testing.T) { + if got := RESTPrefix(tt.host); got != tt.want { + t.Errorf("RESTPrefix() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/pkg/browser/browser.go b/pkg/browser/browser.go index 1f926c462..67ebca92f 100644 --- a/pkg/browser/browser.go +++ b/pkg/browser/browser.go @@ -20,20 +20,21 @@ func Command(url string) (*exec.Cmd, error) { // ForOS produces an exec.Cmd to open the web browser for different OS func ForOS(goos, url string) *exec.Cmd { + exe := "open" var args []string switch goos { case "darwin": - args = []string{"open"} + args = append(args, url) case "windows": - args = []string{"cmd", "/c", "start"} + exe = "cmd" r := strings.NewReplacer("&", "^&") - url = r.Replace(url) + args = append(args, "/c", "start", r.Replace(url)) default: - args = []string{"xdg-open"} + exe = "xdg-open" + args = append(args, url) } - args = append(args, url) - cmd := exec.Command(args[0], args[1:]...) + cmd := exec.Command(exe, args...) cmd.Stderr = os.Stderr return cmd } diff --git a/pkg/cmd/api/api.go b/pkg/cmd/api/api.go index 8900f3f39..27810187b 100644 --- a/pkg/cmd/api/api.go +++ b/pkg/cmd/api/api.go @@ -426,23 +426,43 @@ func parseErrorResponse(r io.Reader, statusCode int) (io.Reader, string, error) var parsedBody struct { Message string - Errors []struct { - Message string - } + Errors []json.RawMessage } err = json.Unmarshal(b, &parsedBody) if err != nil { return r, "", err } - if parsedBody.Message != "" { return bodyCopy, fmt.Sprintf("%s (HTTP %d)", parsedBody.Message, statusCode), nil - } else if len(parsedBody.Errors) > 0 { - msgs := make([]string, len(parsedBody.Errors)) - for i, e := range parsedBody.Errors { - msgs[i] = e.Message + } + + type errorMessage struct { + Message string + } + var errors []string + for _, rawErr := range parsedBody.Errors { + if len(rawErr) == 0 { + continue } - return bodyCopy, strings.Join(msgs, "\n"), nil + if rawErr[0] == '{' { + var objectError errorMessage + err := json.Unmarshal(rawErr, &objectError) + if err != nil { + return r, "", err + } + errors = append(errors, objectError.Message) + } else if rawErr[0] == '"' { + var stringError string + err := json.Unmarshal(rawErr, &stringError) + if err != nil { + return r, "", err + } + errors = append(errors, stringError) + } + } + + if len(errors) > 0 { + return bodyCopy, strings.Join(errors, "\n"), nil } return bodyCopy, "", nil diff --git a/pkg/cmd/api/api_test.go b/pkg/cmd/api/api_test.go index f590d93b4..e5aa9f31d 100644 --- a/pkg/cmd/api/api_test.go +++ b/pkg/cmd/api/api_test.go @@ -264,6 +264,17 @@ func Test_apiRun(t *testing.T) { stdout: `{"message": "THIS IS FINE"}`, stderr: "gh: THIS IS FINE (HTTP 400)\n", }, + { + name: "REST string errors", + httpResponse: &http.Response{ + StatusCode: 400, + Body: ioutil.NopCloser(bytes.NewBufferString(`{"errors": ["ALSO", "FINE"]}`)), + Header: http.Header{"Content-Type": []string{"application/json; charset=utf-8"}}, + }, + err: cmdutil.SilentError, + stdout: `{"errors": ["ALSO", "FINE"]}`, + stderr: "gh: ALSO\nFINE\n", + }, { name: "GraphQL error", options: ApiOptions{ diff --git a/pkg/cmd/api/http.go b/pkg/cmd/api/http.go index 3efeed6e4..696ebd61f 100644 --- a/pkg/cmd/api/http.go +++ b/pkg/cmd/api/http.go @@ -13,10 +13,10 @@ import ( func httpRequest(client *http.Client, method string, p string, params interface{}, headers []string) (*http.Response, error) { var requestURL string - // TODO: GHE support if strings.Contains(p, "://") { requestURL = p } else { + // TODO: GHE support requestURL = "https://api.github.com/" + p } diff --git a/pkg/cmd/auth/auth.go b/pkg/cmd/auth/auth.go new file mode 100644 index 000000000..ff1f79c7c --- /dev/null +++ b/pkg/cmd/auth/auth.go @@ -0,0 +1,18 @@ +package auth + +import ( + "github.com/spf13/cobra" +) + +var Cmd = &cobra.Command{ + Use: "auth ", + Short: "Login, logout, and refresh your authentication", + Long: `Manage gh's authentication state.`, + // TODO this all doesn't exist yet + //Example: heredoc.Doc(` + // $ gh auth login + // $ gh auth status + // $ gh auth refresh --scopes gist + // $ gh auth logout + //`), +} diff --git a/pkg/cmd/auth/login/client.go b/pkg/cmd/auth/login/client.go new file mode 100644 index 000000000..e133ca5c8 --- /dev/null +++ b/pkg/cmd/auth/login/client.go @@ -0,0 +1,48 @@ +package login + +import ( + "fmt" + "net/http" + + "github.com/cli/cli/api" + "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 + + token, err := cfg.Get(hostname, "oauth_token") + if err != nil { + return nil, err + } + + if token == "" { + return nil, fmt.Errorf("no token found in config for %s", hostname) + } + + opts = append(opts, + // no access to Version so the user agent is more generic here. + api.AddHeader("User-Agent", "GitHub CLI"), + api.AddHeaderFunc("Authorization", func(req *http.Request) (string, error) { + return fmt.Sprintf("token %s", token), nil + }), + ) + + httpClient := api.NewHTTPClient(opts...) + + return api.NewClientFromHTTP(httpClient), nil +} diff --git a/pkg/cmd/auth/login/login.go b/pkg/cmd/auth/login/login.go new file mode 100644 index 000000000..72710f5d0 --- /dev/null +++ b/pkg/cmd/auth/login/login.go @@ -0,0 +1,289 @@ +package login + +import ( + "errors" + "fmt" + "io/ioutil" + "os" + "strings" + + "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/ghinstance" + "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" +) + +type LoginOptions struct { + IO *iostreams.IOStreams + Config func() (config.Config, error) + + Hostname string + Token string + OnlyValidate bool +} + +func NewCmdLogin(f *cmdutil.Factory, runF func(*LoginOptions) error) *cobra.Command { + opts := &LoginOptions{ + IO: f.IOStreams, + Config: f.Config, + } + + cmd := &cobra.Command{ + Use: "login", + Args: cobra.ExactArgs(0), + Short: "Authenticate with a GitHub host", + Long: heredoc.Doc(`Authenticate with a GitHub host. + + This interactive command initializes your authentication state either by helping you log into + GitHub via browser-based OAuth or by accepting a Personal Access Token. + + The interactivity can be avoided by specifying --with-token and passing a token on STDIN. + `), + Example: heredoc.Doc(` + $ gh auth login + # => do an interactive setup + + $ gh auth login --with-token < mytoken.txt + # => read token from mytoken.txt and authenticate against github.com + + $ gh auth login --hostname enterprise.internal --with-token < mytoken.txt + # => read token from mytoken.txt and authenticate against a GitHub Enterprise instance + `), + RunE: func(cmd *cobra.Command, args []string) error { + isTTY := opts.IO.IsStdinTTY() + + // TODO support other ways of naming + ghToken := os.Getenv("GITHUB_TOKEN") + + if !isTTY && (!cmd.Flags().Changed("with-token") && ghToken == "") { + return &cmdutil.FlagError{Err: errors.New("no terminal detected; please use '--with-token' or set GITHUB_TOKEN")} + } + + wt, _ := cmd.Flags().GetBool("with-token") + if wt { + defer opts.IO.In.Close() + token, err := ioutil.ReadAll(opts.IO.In) + if err != nil { + return &cmdutil.FlagError{Err: fmt.Errorf("failed to read token from STDIN: %w", err)} + } + + opts.Token = strings.TrimSpace(string(token)) + } else if ghToken != "" { + opts.OnlyValidate = true + opts.Token = ghToken + } + + if opts.Token != "" { + // Assume non-interactive if a token is specified + if opts.Hostname == "" { + opts.Hostname = ghinstance.Default() + } + } + + if runF != nil { + return runF(opts) + } + + return loginRun(opts) + }, + } + + cmd.Flags().StringVarP(&opts.Hostname, "hostname", "h", "", "The hostname of the GitHub instance to authenticate with") + cmd.Flags().Bool("with-token", false, "Read token from standard input") + + return cmd +} + +func loginRun(opts *LoginOptions) error { + cfg, err := opts.Config() + if err != nil { + 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) + if err != nil { + return err + } + + err = validateHostCfg(opts.Hostname, cfg) + if err != nil { + return err + } + + if opts.OnlyValidate { + return nil + } + + 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", + }, + }, &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(survey.Required)) + 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 != "" { + err := validateHostCfg(hostname, cfg) + if err == nil { + apiClient, err := clientFromCfg(hostname, cfg) + if err != nil { + return err + } + + username, err := api.CurrentLoginName(apiClient, hostname) + if err != nil { + return fmt.Errorf("error using api: %w", err) + } + var keepGoing bool + err = prompt.SurveyAskOne(&survey.Confirm{ + Message: fmt.Sprintf( + "You're already logged into %s as %s. Do you want to re-authenticate?", + hostname, + username), + Default: false, + }, &keepGoing) + if err != nil { + return fmt.Errorf("could not prompt: %w", err) + } + + if !keepGoing { + return nil + } + } + } + + var authMode int + err = prompt.SurveyAskOne(&survey.Select{ + Message: "How would you like to authenticate?", + Options: []string{ + "Login with a web browser", + "Paste an authentication token", + }, + }, &authMode) + if err != nil { + return fmt.Errorf("could not prompt: %w", err) + } + + if authMode == 0 { + _, err := config.AuthFlowWithConfig(cfg, hostname, "") + if err != nil { + return fmt.Errorf("failed to authenticate via web browser: %w", err) + } + } else { + fmt.Fprintln(opts.IO.ErrOut) + fmt.Fprintln(opts.IO.ErrOut, heredoc.Doc(` + Tip: you can generate a Personal Access Token here https://github.com/settings/tokens + The minimum required scopes are 'repo' and 'read:org'.`)) + var token string + err := prompt.SurveyAskOne(&survey.Password{ + Message: "Paste your authentication token:", + }, &token, survey.WithValidator(survey.Required)) + if err != nil { + 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 = validateHostCfg(hostname, cfg) + if err != nil { + return err + } + } + + var gitProtocol string + err = prompt.SurveyAskOne(&survey.Select{ + Message: "Choose default git protocol", + Options: []string{ + "HTTPS", + "SSH", + }, + }, &gitProtocol) + if err != nil { + return fmt.Errorf("could not prompt: %w", err) + } + + gitProtocol = strings.ToLower(gitProtocol) + + fmt.Fprintf(opts.IO.ErrOut, "- gh config set -h%s git_protocol %s\n", hostname, gitProtocol) + err = cfg.Set(hostname, "git_protocol", gitProtocol) + if err != nil { + return err + } + + fmt.Fprintf(opts.IO.ErrOut, "%s Configured git protocol\n", utils.GreenCheck()) + + apiClient, err := clientFromCfg(hostname, cfg) + if err != nil { + return err + } + + username, err := api.CurrentLoginName(apiClient, hostname) + if err != nil { + return fmt.Errorf("error using api: %w", err) + } + + err = cfg.Set(hostname, "user", username) + if err != nil { + return err + } + + err = cfg.Write() + if err != nil { + return err + } + + fmt.Fprintf(opts.IO.ErrOut, "%s Logged in as %s\n", utils.GreenCheck(), utils.Bold(username)) + + return nil +} diff --git a/pkg/cmd/auth/login/login_test.go b/pkg/cmd/auth/login/login_test.go new file mode 100644 index 000000000..4cc818670 --- /dev/null +++ b/pkg/cmd/auth/login/login_test.go @@ -0,0 +1,436 @@ +package login + +import ( + "bytes" + "io/ioutil" + "net/http" + "os" + "regexp" + "testing" + + "github.com/cli/cli/api" + "github.com/cli/cli/internal/config" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/httpmock" + "github.com/cli/cli/pkg/iostreams" + "github.com/cli/cli/pkg/prompt" + "github.com/google/shlex" + "github.com/stretchr/testify/assert" +) + +func Test_NewCmdLogin(t *testing.T) { + tests := []struct { + name string + cli string + stdin string + stdinTTY bool + wants LoginOptions + wantsErr bool + ghtoken string + }{ + { + name: "nontty, with-token", + stdin: "abc123\n", + cli: "--with-token", + wants: LoginOptions{ + Hostname: "github.com", + Token: "abc123", + }, + }, + { + name: "tty, with-token", + stdinTTY: true, + stdin: "def456", + cli: "--with-token", + wants: LoginOptions{ + Hostname: "github.com", + Token: "def456", + }, + }, + { + name: "nontty, hostname", + cli: "--hostname claire.redfield", + wantsErr: true, + }, + { + name: "nontty", + cli: "", + wantsErr: true, + }, + { + name: "nontty, with-token, hostname", + cli: "--hostname claire.redfield --with-token", + stdin: "abc123\n", + wants: LoginOptions{ + Hostname: "claire.redfield", + Token: "abc123", + }, + }, + { + name: "tty, with-token, hostname", + stdinTTY: true, + stdin: "ghi789", + cli: "--with-token --hostname brad.vickers", + wants: LoginOptions{ + Hostname: "brad.vickers", + Token: "ghi789", + }, + }, + { + name: "tty, hostname", + stdinTTY: true, + cli: "--hostname barry.burton", + wants: LoginOptions{ + Hostname: "barry.burton", + Token: "", + }, + }, + { + name: "tty", + stdinTTY: true, + cli: "", + wants: LoginOptions{ + Hostname: "", + Token: "", + }, + }, + { + name: "tty, GITHUB_TOKEN", + stdinTTY: true, + cli: "", + ghtoken: "abc123", + wants: LoginOptions{ + Hostname: "github.com", + Token: "abc123", + OnlyValidate: true, + }, + }, + { + name: "nontty, GITHUB_TOKEN", + stdinTTY: false, + cli: "", + ghtoken: "abc123", + wants: LoginOptions{ + Hostname: "github.com", + Token: "abc123", + OnlyValidate: true, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ghtoken := os.Getenv("GITHUB_TOKEN") + defer func() { + os.Setenv("GITHUB_TOKEN", ghtoken) + }() + os.Setenv("GITHUB_TOKEN", tt.ghtoken) + io, stdin, _, _ := iostreams.Test() + f := &cmdutil.Factory{ + IOStreams: io, + } + + io.SetStdinTTY(tt.stdinTTY) + if tt.stdin != "" { + stdin.WriteString(tt.stdin) + } + + argv, err := shlex.Split(tt.cli) + assert.NoError(t, err) + + var gotOpts *LoginOptions + cmd := NewCmdLogin(f, func(opts *LoginOptions) error { + gotOpts = opts + return nil + }) + // TODO cobra hack-around + 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.wants.Token, gotOpts.Token) + assert.Equal(t, tt.wants.Hostname, gotOpts.Hostname) + }) + } +} + +func scopesResponder(scopes string) func(*http.Request) (*http.Response, error) { + return func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: 200, + Request: req, + Header: map[string][]string{ + "X-Oauth-Scopes": {scopes}, + }, + Body: ioutil.NopCloser(bytes.NewBufferString("")), + }, nil + } +} + +func Test_loginRun_nontty(t *testing.T) { + tests := []struct { + name string + opts *LoginOptions + httpStubs func(*httpmock.Registry) + wantHosts string + wantErr *regexp.Regexp + }{ + { + name: "with token", + opts: &LoginOptions{ + Hostname: "github.com", + Token: "abc123", + }, + wantHosts: "github.com:\n oauth_token: abc123\n", + }, + { + name: "with token and non-default host", + opts: &LoginOptions{ + Hostname: "albert.wesker", + Token: "abc123", + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register(httpmock.REST("GET", "api/v3/"), scopesResponder("repo,read:org")) + }, + wantHosts: "albert.wesker:\n oauth_token: abc123\n", + }, + { + name: "missing repo scope", + opts: &LoginOptions{ + Hostname: "github.com", + Token: "abc456", + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register(httpmock.REST("GET", ""), scopesResponder("read:org")) + }, + wantErr: regexp.MustCompile(`missing required scope 'repo'`), + }, + { + name: "missing read scope", + opts: &LoginOptions{ + Hostname: "github.com", + Token: "abc456", + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register(httpmock.REST("GET", ""), scopesResponder("repo")) + }, + wantErr: regexp.MustCompile(`missing required scope 'read:org'`), + }, + { + name: "has admin scope", + opts: &LoginOptions{ + Hostname: "github.com", + Token: "abc456", + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register(httpmock.REST("GET", ""), scopesResponder("repo,admin:org")) + }, + wantHosts: "github.com:\n oauth_token: abc456\n", + }, + } + + for _, tt := range tests { + io, _, stdout, stderr := iostreams.Test() + + io.SetStdinTTY(false) + io.SetStdoutTTY(false) + + tt.opts.Config = func() (config.Config, error) { + return config.NewBlankConfig(), nil + } + + tt.opts.IO = io + t.Run(tt.name, func(t *testing.T) { + reg := &httpmock.Registry{} + origClientFromCfg := clientFromCfg + defer func() { + clientFromCfg = origClientFromCfg + }() + clientFromCfg = func(_ string, _ config.Config) (*api.Client, error) { + httpClient := &http.Client{Transport: reg} + return api.NewClientFromHTTP(httpClient), nil + } + + if tt.httpStubs != nil { + tt.httpStubs(reg) + } else { + reg.Register(httpmock.REST("GET", ""), scopesResponder("repo,read:org")) + } + + mainBuf := bytes.Buffer{} + hostsBuf := bytes.Buffer{} + 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) + } + } + + assert.Equal(t, "", stdout.String()) + assert.Equal(t, "", stderr.String()) + assert.Equal(t, tt.wantHosts, hostsBuf.String()) + reg.Verify(t) + }) + } +} + +func Test_loginRun_Survey(t *testing.T) { + tests := []struct { + name string + opts *LoginOptions + httpStubs func(*httpmock.Registry) + askStubs func(*prompt.AskStubber) + wantHosts string + cfg func(config.Config) + }{ + { + name: "already authenticated", + cfg: func(cfg config.Config) { + _ = cfg.Set("github.com", "oauth_token", "ghi789") + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register(httpmock.REST("GET", ""), scopesResponder("repo,read:org,")) + reg.Register( + httpmock.GraphQL(`query UserCurrent\b`), + httpmock.StringResponse(`{"data":{"viewer":{"login":"jillv"}}}`)) + }, + askStubs: func(as *prompt.AskStubber) { + as.StubOne(0) // host type github.com + as.StubOne(false) // do not continue + }, + wantHosts: "", // nothing should have been written to hosts + }, + { + name: "hostname set", + opts: &LoginOptions{ + Hostname: "rebecca.chambers", + }, + wantHosts: "rebecca.chambers:\n oauth_token: def456\n git_protocol: https\n user: jillv\n", + askStubs: func(as *prompt.AskStubber) { + as.StubOne(1) // auth mode: token + as.StubOne("def456") // auth token + as.StubOne("HTTPS") // git_protocol + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register(httpmock.REST("GET", "api/v3/"), scopesResponder("repo,read:org,")) + reg.Register( + httpmock.GraphQL(`query UserCurrent\b`), + httpmock.StringResponse(`{"data":{"viewer":{"login":"jillv"}}}`)) + }, + }, + { + name: "choose enterprise", + wantHosts: "brad.vickers:\n oauth_token: def456\n git_protocol: https\n user: jillv\n", + askStubs: func(as *prompt.AskStubber) { + as.StubOne(1) // host type enterprise + as.StubOne("brad.vickers") // hostname + as.StubOne(1) // auth mode: token + as.StubOne("def456") // auth token + as.StubOne("HTTPS") // git_protocol + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register(httpmock.REST("GET", "api/v3/"), scopesResponder("repo,read:org,")) + reg.Register( + httpmock.GraphQL(`query UserCurrent\b`), + httpmock.StringResponse(`{"data":{"viewer":{"login":"jillv"}}}`)) + }, + }, + { + name: "choose github.com", + wantHosts: "github.com:\n oauth_token: def456\n git_protocol: https\n user: jillv\n", + askStubs: func(as *prompt.AskStubber) { + as.StubOne(0) // host type github.com + as.StubOne(1) // auth mode: token + as.StubOne("def456") // auth token + as.StubOne("HTTPS") // git_protocol + }, + }, + { + name: "sets git_protocol", + wantHosts: "github.com:\n oauth_token: def456\n git_protocol: ssh\n user: jillv\n", + askStubs: func(as *prompt.AskStubber) { + as.StubOne(0) // host type github.com + as.StubOne(1) // auth mode: token + as.StubOne("def456") // auth token + as.StubOne("SSH") // git_protocol + }, + }, + // TODO how to test browser auth? + } + + for _, tt := range tests { + if tt.opts == nil { + tt.opts = &LoginOptions{} + } + io, _, _, _ := iostreams.Test() + + io.SetStdinTTY(true) + io.SetStderrTTY(true) + io.SetStdoutTTY(true) + + tt.opts.IO = io + + cfg := config.NewBlankConfig() + + if tt.cfg != nil { + tt.cfg(cfg) + } + tt.opts.Config = func() (config.Config, error) { + return cfg, nil + } + + t.Run(tt.name, func(t *testing.T) { + reg := &httpmock.Registry{} + origClientFromCfg := clientFromCfg + defer func() { + clientFromCfg = origClientFromCfg + }() + clientFromCfg = func(_ string, _ config.Config) (*api.Client, error) { + httpClient := &http.Client{Transport: reg} + return api.NewClientFromHTTP(httpClient), nil + } + if tt.httpStubs != nil { + tt.httpStubs(reg) + } else { + reg.Register(httpmock.REST("GET", ""), scopesResponder("repo,read:org,")) + reg.Register( + httpmock.GraphQL(`query UserCurrent\b`), + httpmock.StringResponse(`{"data":{"viewer":{"login":"jillv"}}}`)) + } + + mainBuf := bytes.Buffer{} + hostsBuf := bytes.Buffer{} + defer config.StubWriteConfig(&mainBuf, &hostsBuf)() + + as, teardown := prompt.InitAskStubber() + defer teardown() + if tt.askStubs != nil { + tt.askStubs(as) + } + + err := loginRun(tt.opts) + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + + assert.Equal(t, tt.wantHosts, hostsBuf.String()) + reg.Verify(t) + }) + } +} diff --git a/pkg/cmd/auth/logout/logout.go b/pkg/cmd/auth/logout/logout.go new file mode 100644 index 000000000..744cd6a3f --- /dev/null +++ b/pkg/cmd/auth/logout/logout.go @@ -0,0 +1,157 @@ +package logout + +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/pkg/cmdutil" + "github.com/cli/cli/pkg/iostreams" + "github.com/cli/cli/pkg/prompt" + "github.com/cli/cli/utils" + "github.com/spf13/cobra" +) + +type LogoutOptions struct { + HttpClient func() (*http.Client, error) + IO *iostreams.IOStreams + Config func() (config.Config, error) + + Hostname string +} + +func NewCmdLogout(f *cmdutil.Factory, runF func(*LogoutOptions) error) *cobra.Command { + opts := &LogoutOptions{ + HttpClient: f.HttpClient, + IO: f.IOStreams, + Config: f.Config, + } + + cmd := &cobra.Command{ + Use: "logout", + Args: cobra.ExactArgs(0), + Short: "Log out of a GitHub host", + Long: heredoc.Doc(`Remove authentication for a GitHub host. + + This command removes the authentication configuration for a host either specified + interactively or via --hostname. + `), + Example: heredoc.Doc(` + $ gh auth logout + # => select what host to log out of via a prompt + + $ gh auth logout --hostname enterprise.internal + # => log out of specified host + `), + RunE: func(cmd *cobra.Command, args []string) error { + if runF != nil { + return runF(opts) + } + + return logoutRun(opts) + }, + } + + cmd.Flags().StringVarP(&opts.Hostname, "hostname", "h", "", "The hostname of the GitHub instance to log out of") + + return cmd +} + +func logoutRun(opts *LogoutOptions) error { + isTTY := opts.IO.IsStdinTTY() && opts.IO.IsStdoutTTY() + + hostname := opts.Hostname + + if !isTTY && hostname == "" { + return errors.New("--hostname required when not attached to a terminal") + } + + showConfirm := isTTY && hostname == "" + + cfg, err := opts.Config() + if err != nil { + return err + } + + candidates, err := cfg.Hosts() + if err != nil { + return fmt.Errorf("not logged in to any hosts") + } + + if hostname == "" { + if len(candidates) == 1 { + hostname = candidates[0] + } else { + err = prompt.SurveyAskOne(&survey.Select{ + Message: "What account do you want to log out of?", + Options: candidates, + }, &hostname) + + if err != nil { + return fmt.Errorf("could not prompt: %w", err) + } + } + } else { + var found bool + for _, c := range candidates { + if c == hostname { + found = true + break + } + } + + if !found { + return fmt.Errorf("not logged into %s", hostname) + } + } + + httpClient, err := opts.HttpClient() + if err != nil { + return err + } + apiClient := api.NewClientFromHTTP(httpClient) + + username, err := api.CurrentLoginName(apiClient, hostname) + if err != nil { + // suppressing; the user is trying to delete this token and it might be bad. + // we'll see if the username is in the config and fall back to that. + username, _ = cfg.Get(hostname, "user") + } + + usernameStr := "" + if username != "" { + usernameStr = fmt.Sprintf(" account '%s'", username) + } + + if showConfirm { + var keepGoing bool + err := prompt.SurveyAskOne(&survey.Confirm{ + Message: fmt.Sprintf("Are you sure you want to log out of %s%s?", hostname, usernameStr), + Default: true, + }, &keepGoing) + if err != nil { + return fmt.Errorf("could not prompt: %w", err) + } + + if !keepGoing { + return nil + } + } + + cfg.UnsetHost(hostname) + err = cfg.Write() + if err != nil { + return fmt.Errorf("failed to write config, authentication configuration not updated: %w", err) + } + + if isTTY { + fmt.Fprintf(opts.IO.ErrOut, "%s Logged out of %s%s\n", + utils.GreenCheck(), utils.Bold(hostname), usernameStr) + } + + return nil +} diff --git a/pkg/cmd/auth/logout/logout_test.go b/pkg/cmd/auth/logout/logout_test.go new file mode 100644 index 000000000..83d13aad3 --- /dev/null +++ b/pkg/cmd/auth/logout/logout_test.go @@ -0,0 +1,259 @@ +package logout + +import ( + "bytes" + "net/http" + "regexp" + "testing" + + "github.com/cli/cli/internal/config" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/httpmock" + "github.com/cli/cli/pkg/iostreams" + "github.com/cli/cli/pkg/prompt" + "github.com/google/shlex" + "github.com/stretchr/testify/assert" +) + +func Test_NewCmdLogout(t *testing.T) { + tests := []struct { + name string + cli string + wants LogoutOptions + }{ + { + name: "with hostname", + cli: "--hostname harry.mason", + wants: LogoutOptions{ + Hostname: "harry.mason", + }, + }, + { + name: "no arguments", + cli: "", + wants: LogoutOptions{ + Hostname: "", + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + io, _, _, _ := iostreams.Test() + f := &cmdutil.Factory{ + IOStreams: io, + } + + argv, err := shlex.Split(tt.cli) + assert.NoError(t, err) + + var gotOpts *LogoutOptions + cmd := NewCmdLogout(f, func(opts *LogoutOptions) error { + gotOpts = opts + return nil + }) + // TODO cobra hack-around + cmd.Flags().BoolP("help", "x", false, "") + + cmd.SetArgs(argv) + cmd.SetIn(&bytes.Buffer{}) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + + _, err = cmd.ExecuteC() + assert.NoError(t, err) + + assert.Equal(t, tt.wants.Hostname, gotOpts.Hostname) + }) + + } +} + +func Test_logoutRun_tty(t *testing.T) { + tests := []struct { + name string + opts *LogoutOptions + askStubs func(*prompt.AskStubber) + cfgHosts []string + wantHosts string + wantErrOut *regexp.Regexp + wantErr *regexp.Regexp + }{ + { + name: "no arguments, multiple hosts", + opts: &LogoutOptions{}, + cfgHosts: []string{"cheryl.mason", "github.com"}, + wantHosts: "cheryl.mason:\n oauth_token: abc123\n", + askStubs: func(as *prompt.AskStubber) { + as.StubOne("github.com") + as.StubOne(true) + }, + wantErrOut: regexp.MustCompile(`Logged out of github.com account 'cybilb'`), + }, + { + name: "no arguments, one host", + opts: &LogoutOptions{}, + cfgHosts: []string{"github.com"}, + askStubs: func(as *prompt.AskStubber) { + as.StubOne(true) + }, + wantErrOut: regexp.MustCompile(`Logged out of github.com account 'cybilb'`), + }, + { + name: "no arguments, no hosts", + opts: &LogoutOptions{}, + wantErr: regexp.MustCompile(`not logged in to any hosts`), + }, + { + name: "hostname", + opts: &LogoutOptions{ + Hostname: "cheryl.mason", + }, + cfgHosts: []string{"cheryl.mason", "github.com"}, + wantHosts: "github.com:\n oauth_token: abc123\n", + askStubs: func(as *prompt.AskStubber) { + as.StubOne(true) + }, + wantErrOut: regexp.MustCompile(`Logged out of cheryl.mason account 'cybilb'`), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + io, _, _, stderr := iostreams.Test() + + io.SetStdinTTY(true) + io.SetStdoutTTY(true) + + tt.opts.IO = io + cfg := config.NewBlankConfig() + tt.opts.Config = func() (config.Config, error) { + return cfg, nil + } + + for _, hostname := range tt.cfgHosts { + _ = cfg.Set(hostname, "oauth_token", "abc123") + } + + reg := &httpmock.Registry{} + reg.Register( + httpmock.GraphQL(`query UserCurrent\b`), + httpmock.StringResponse(`{"data":{"viewer":{"login":"cybilb"}}}`)) + + tt.opts.HttpClient = func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + } + + mainBuf := bytes.Buffer{} + hostsBuf := bytes.Buffer{} + defer config.StubWriteConfig(&mainBuf, &hostsBuf)() + + as, teardown := prompt.InitAskStubber() + defer teardown() + if tt.askStubs != nil { + tt.askStubs(as) + } + + 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.wantErrOut == nil { + assert.Equal(t, "", stderr.String()) + } else { + assert.True(t, tt.wantErrOut.MatchString(stderr.String())) + } + + assert.Equal(t, tt.wantHosts, hostsBuf.String()) + reg.Verify(t) + }) + } +} + +func Test_logoutRun_nontty(t *testing.T) { + tests := []struct { + name string + opts *LogoutOptions + cfgHosts []string + wantHosts string + wantErr *regexp.Regexp + }{ + { + name: "no arguments", + wantErr: regexp.MustCompile(`hostname required when not`), + opts: &LogoutOptions{}, + }, + { + name: "hostname, one host", + opts: &LogoutOptions{ + Hostname: "harry.mason", + }, + cfgHosts: []string{"harry.mason"}, + }, + { + name: "hostname, multiple hosts", + opts: &LogoutOptions{ + Hostname: "harry.mason", + }, + cfgHosts: []string{"harry.mason", "cheryl.mason"}, + wantHosts: "cheryl.mason:\n oauth_token: abc123\n", + }, + { + name: "hostname, no hosts", + opts: &LogoutOptions{ + Hostname: "harry.mason", + }, + wantErr: regexp.MustCompile(`not logged in to any hosts`), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + io, _, _, stderr := iostreams.Test() + + io.SetStdinTTY(false) + io.SetStdoutTTY(false) + + tt.opts.IO = io + cfg := config.NewBlankConfig() + tt.opts.Config = func() (config.Config, error) { + return cfg, nil + } + + for _, hostname := range tt.cfgHosts { + _ = cfg.Set(hostname, "oauth_token", "abc123") + } + + reg := &httpmock.Registry{} + tt.opts.HttpClient = func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + } + + mainBuf := bytes.Buffer{} + hostsBuf := bytes.Buffer{} + defer config.StubWriteConfig(&mainBuf, &hostsBuf)() + + 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) + } + } + + assert.Equal(t, "", stderr.String()) + + assert.Equal(t, tt.wantHosts, hostsBuf.String()) + reg.Verify(t) + }) + } +} diff --git a/pkg/cmd/gist/create/create.go b/pkg/cmd/gist/create/create.go index 76cdd1463..10cdd42ea 100644 --- a/pkg/cmd/gist/create/create.go +++ b/pkg/cmd/gist/create/create.go @@ -11,6 +11,7 @@ import ( "github.com/MakeNowJust/heredoc" "github.com/cli/cli/api" + "github.com/cli/cli/internal/ghinstance" "github.com/cli/cli/pkg/cmdutil" "github.com/cli/cli/pkg/iostreams" "github.com/cli/cli/utils" @@ -103,7 +104,8 @@ func createRun(opts *CreateOptions) error { return err } - gist, err := apiCreate(httpClient, opts.Description, opts.Public, files) + // TODO: GHE support + gist, err := apiCreate(httpClient, ghinstance.Default(), opts.Description, opts.Public, files) if err != nil { var httpError api.HTTPError if errors.As(err, &httpError) { diff --git a/pkg/cmd/gist/create/http.go b/pkg/cmd/gist/create/http.go index 55ea61033..42b970221 100644 --- a/pkg/cmd/gist/create/http.go +++ b/pkg/cmd/gist/create/http.go @@ -22,7 +22,7 @@ type GistFile struct { Content string `json:"content,omitempty"` } -func apiCreate(httpClient *http.Client, description string, public bool, files map[string]string) (*Gist, error) { +func apiCreate(httpClient *http.Client, hostname string, description string, public bool, files map[string]string) (*Gist, error) { gistFiles := map[GistFilename]GistFile{} for filename, content := range files { @@ -44,7 +44,7 @@ func apiCreate(httpClient *http.Client, description string, public bool, files m requestBody := bytes.NewReader(requestByte) apiClient := api.NewClientFromHTTP(httpClient) - err = apiClient.REST("POST", path, requestBody, &result) + err = apiClient.REST(hostname, "POST", path, requestBody, &result) if err != nil { return nil, err } diff --git a/command/pr_checkout.go b/pkg/cmd/pr/checkout/checkout.go similarity index 64% rename from command/pr_checkout.go rename to pkg/cmd/pr/checkout/checkout.go index 256a3dfe8..23c4bb70b 100644 --- a/command/pr_checkout.go +++ b/pkg/cmd/pr/checkout/checkout.go @@ -1,41 +1,103 @@ -package command +package checkout import ( "errors" "fmt" + "net/http" "os" "os/exec" "strings" - "github.com/spf13/cobra" - "github.com/cli/cli/api" + "github.com/cli/cli/context" "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/cmd/pr/shared" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/iostreams" + "github.com/spf13/cobra" ) -func prCheckout(cmd *cobra.Command, args []string) error { - ctx := contextForCommand(cmd) - currentBranch, _ := ctx.Branch() - remotes, err := ctx.Remotes() +type CheckoutOptions struct { + HttpClient func() (*http.Client, error) + Config func() (config.Config, error) + IO *iostreams.IOStreams + BaseRepo func() (ghrepo.Interface, error) + Remotes func() (context.Remotes, error) + Branch func() (string, error) + + SelectorArg string +} + +func NewCmdCheckout(f *cmdutil.Factory, runF func(*CheckoutOptions) error) *cobra.Command { + opts := &CheckoutOptions{ + IO: f.IOStreams, + HttpClient: f.HttpClient, + Config: f.Config, + Remotes: f.Remotes, + Branch: f.Branch, + } + + cmd := &cobra.Command{ + Use: "checkout { | | }", + Short: "Check out a pull request in git", + Args: func(cmd *cobra.Command, args []string) error { + if len(args) == 0 { + return &cmdutil.FlagError{Err: errors.New("argument required")} + } + return nil + }, + RunE: func(cmd *cobra.Command, args []string) error { + // support `-R, --repo` override + opts.BaseRepo = f.BaseRepo + + if len(args) > 0 { + opts.SelectorArg = args[0] + } + + if runF != nil { + return runF(opts) + } + return checkoutRun(opts) + }, + } + + return cmd +} + +func checkoutRun(opts *CheckoutOptions) error { + currentBranch, err := opts.Branch() if err != nil { return err } - apiClient, err := apiClientForContext(ctx) + remotes, err := opts.Remotes() if err != nil { return err } - pr, baseRepo, err := prFromArgs(ctx, apiClient, cmd, args) + httpClient, err := opts.HttpClient() if err != nil { return err } + apiClient := api.NewClientFromHTTP(httpClient) + + pr, baseRepo, err := shared.PRFromArgs(apiClient, opts.BaseRepo, opts.Branch, opts.Remotes, opts.SelectorArg) + if err != nil { + return err + } + + cfg, err := opts.Config() + if err != nil { + return err + } + protocol, _ := cfg.Get(baseRepo.RepoHost(), "git_protocol") baseRemote, _ := remotes.FindByRepo(baseRepo.RepoOwner(), baseRepo.RepoName()) // baseRemoteSpec is a repository URL or a remote name to be used in git fetch - baseURLOrName := formatRemoteURL(cmd, baseRepo) + baseURLOrName := ghrepo.FormatRemoteURL(baseRepo, protocol) if baseRemote != nil { baseURLOrName = baseRemote.Name } @@ -95,7 +157,7 @@ func prCheckout(cmd *cobra.Command, args []string) error { mergeRef := ref if pr.MaintainerCanModify { headRepo := ghrepo.NewWithHost(pr.HeadRepositoryOwner.Login, pr.HeadRepository.Name, baseRepo.RepoHost()) - remote = formatRemoteURL(cmd, headRepo) + remote = ghrepo.FormatRemoteURL(headRepo, protocol) mergeRef = fmt.Sprintf("refs/heads/%s", pr.HeadRefName) } if mc, err := git.Config(fmt.Sprintf("branch.%s.merge", newBranchName)); err != nil || mc == "" { @@ -115,15 +177,3 @@ func prCheckout(cmd *cobra.Command, args []string) error { return nil } - -var prCheckoutCmd = &cobra.Command{ - Use: "checkout { | | }", - Short: "Check out a pull request in Git", - Args: func(cmd *cobra.Command, args []string) error { - if len(args) < 1 { - return errors.New("requires a pull request number as an argument") - } - return nil - }, - RunE: prCheckout, -} diff --git a/command/pr_checkout_test.go b/pkg/cmd/pr/checkout/checkout_test.go similarity index 79% rename from command/pr_checkout_test.go rename to pkg/cmd/pr/checkout/checkout_test.go index 525225802..847702c77 100644 --- a/command/pr_checkout_test.go +++ b/pkg/cmd/pr/checkout/checkout_test.go @@ -1,31 +1,105 @@ -package command +package checkout import ( + "bytes" "encoding/json" + "errors" "io/ioutil" + "net/http" "os/exec" + "reflect" "strings" "testing" + "github.com/cli/cli/api" "github.com/cli/cli/context" + "github.com/cli/cli/git" + "github.com/cli/cli/internal/config" + "github.com/cli/cli/internal/ghrepo" "github.com/cli/cli/internal/run" + "github.com/cli/cli/pkg/cmdutil" "github.com/cli/cli/pkg/httpmock" + "github.com/cli/cli/pkg/iostreams" "github.com/cli/cli/test" + "github.com/google/shlex" "github.com/stretchr/testify/assert" ) -func TestPRCheckout_sameRepo(t *testing.T) { - ctx := context.NewBlank() - ctx.SetBranch("master") - ctx.SetRemotes(map[string]string{ - "origin": "OWNER/REPO", - }) - initContext = func() context.Context { - return ctx +func eq(t *testing.T, got interface{}, expected interface{}) { + t.Helper() + if !reflect.DeepEqual(got, expected) { + t.Errorf("expected: %v, got: %v", expected, got) } - http := initFakeHTTP() +} + +type errorStub struct { + message string +} + +func (s errorStub) Output() ([]byte, error) { + return nil, errors.New(s.message) +} + +func (s errorStub) Run() error { + return errors.New(s.message) +} + +func runCommand(rt http.RoundTripper, remotes context.Remotes, branch string, cli string) (*test.CmdOut, error) { + io, _, stdout, stderr := iostreams.Test() + + factory := &cmdutil.Factory{ + IOStreams: io, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: rt}, nil + }, + Config: func() (config.Config, error) { + return config.NewBlankConfig(), nil + }, + BaseRepo: func() (ghrepo.Interface, error) { + return api.InitRepoHostname(&api.Repository{ + Name: "REPO", + Owner: api.RepositoryOwner{Login: "OWNER"}, + DefaultBranchRef: api.BranchRef{Name: "master"}, + }, "github.com"), nil + }, + Remotes: func() (context.Remotes, error) { + if remotes == nil { + return context.Remotes{ + { + Remote: &git.Remote{Name: "origin"}, + Repo: ghrepo.New("OWNER", "REPO"), + }, + }, nil + } + return remotes, nil + }, + Branch: func() (string, error) { + return branch, nil + }, + } + + cmd := NewCmdCheckout(factory, nil) + + argv, err := shlex.Split(cli) + if err != nil { + return nil, err + } + cmd.SetArgs(argv) + + cmd.SetIn(&bytes.Buffer{}) + cmd.SetOut(ioutil.Discard) + cmd.SetErr(ioutil.Discard) + + _, err = cmd.ExecuteC() + return &test.CmdOut{ + OutBuf: stdout, + ErrBuf: stderr, + }, err +} + +func TestPRCheckout_sameRepo(t *testing.T) { + http := &httpmock.Registry{} defer http.Verify(t) - http.StubRepoResponse("OWNER", "REPO") http.Register(httpmock.GraphQL(`query PullRequestByNumber\b`), httpmock.StringResponse(` { "data": { "repository": { "pullRequest": { @@ -54,11 +128,15 @@ func TestPRCheckout_sameRepo(t *testing.T) { }) defer restoreCmd() - output, err := RunCommand(`pr checkout 123`) - eq(t, err, nil) - eq(t, output.String(), "") + output, err := runCommand(http, nil, "master", `123`) + if !assert.NoError(t, err) { + return + } - eq(t, len(ranCommands), 4) + assert.Equal(t, "", output.String()) + 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") @@ -66,15 +144,7 @@ func TestPRCheckout_sameRepo(t *testing.T) { } func TestPRCheckout_urlArg(t *testing.T) { - ctx := context.NewBlank() - ctx.SetBranch("master") - ctx.SetRemotes(map[string]string{ - "origin": "OWNER/REPO", - }) - initContext = func() context.Context { - return ctx - } - http := initFakeHTTP() + http := &httpmock.Registry{} defer http.Verify(t) http.Register(httpmock.GraphQL(`query PullRequestByNumber\b`), httpmock.StringResponse(` { "data": { "repository": { "pullRequest": { @@ -103,7 +173,7 @@ func TestPRCheckout_urlArg(t *testing.T) { }) defer restoreCmd() - output, err := RunCommand(`pr checkout https://github.com/OWNER/REPO/pull/123/files`) + output, err := runCommand(http, nil, "master", `https://github.com/OWNER/REPO/pull/123/files`) eq(t, err, nil) eq(t, output.String(), "") @@ -112,15 +182,7 @@ func TestPRCheckout_urlArg(t *testing.T) { } func TestPRCheckout_urlArg_differentBase(t *testing.T) { - ctx := context.NewBlank() - ctx.SetBranch("master") - ctx.SetRemotes(map[string]string{ - "origin": "OWNER/REPO", - }) - initContext = func() context.Context { - return ctx - } - http := initFakeHTTP() + http := &httpmock.Registry{} defer http.Verify(t) http.Register(httpmock.GraphQL(`query PullRequestByNumber\b`), httpmock.StringResponse(` { "data": { "repository": { "pullRequest": { @@ -154,7 +216,7 @@ func TestPRCheckout_urlArg_differentBase(t *testing.T) { }) defer restoreCmd() - output, err := RunCommand(`pr checkout https://github.com/OTHER/POE/pull/123/files`) + output, err := runCommand(http, nil, "master", `https://github.com/OTHER/POE/pull/123/files`) eq(t, err, nil) eq(t, output.String(), "") @@ -176,17 +238,8 @@ func TestPRCheckout_urlArg_differentBase(t *testing.T) { } func TestPRCheckout_branchArg(t *testing.T) { - ctx := context.NewBlank() - ctx.SetBranch("master") - ctx.SetRemotes(map[string]string{ - "origin": "OWNER/REPO", - }) - initContext = func() context.Context { - return ctx - } - http := initFakeHTTP() + http := &httpmock.Registry{} defer http.Verify(t) - http.StubRepoResponse("OWNER", "REPO") http.Register(httpmock.GraphQL(`query PullRequestForBranch\b`), httpmock.StringResponse(` { "data": { "repository": { "pullRequests": { "nodes": [ @@ -215,7 +268,7 @@ func TestPRCheckout_branchArg(t *testing.T) { }) defer restoreCmd() - output, err := RunCommand(`pr checkout hubot:feature`) + output, err := runCommand(http, nil, "master", `hubot:feature`) eq(t, err, nil) eq(t, output.String(), "") @@ -224,17 +277,8 @@ func TestPRCheckout_branchArg(t *testing.T) { } func TestPRCheckout_existingBranch(t *testing.T) { - ctx := context.NewBlank() - ctx.SetBranch("master") - ctx.SetRemotes(map[string]string{ - "origin": "OWNER/REPO", - }) - initContext = func() context.Context { - return ctx - } - http := initFakeHTTP() + http := &httpmock.Registry{} defer http.Verify(t) - http.StubRepoResponse("OWNER", "REPO") http.Register(httpmock.GraphQL(`query PullRequestByNumber\b`), httpmock.StringResponse(` { "data": { "repository": { "pullRequest": { @@ -263,7 +307,7 @@ func TestPRCheckout_existingBranch(t *testing.T) { }) defer restoreCmd() - output, err := RunCommand(`pr checkout 123`) + output, err := runCommand(http, nil, "master", `123`) eq(t, err, nil) eq(t, output.String(), "") @@ -274,18 +318,19 @@ func TestPRCheckout_existingBranch(t *testing.T) { } func TestPRCheckout_differentRepo_remoteExists(t *testing.T) { - ctx := context.NewBlank() - ctx.SetBranch("master") - ctx.SetRemotes(map[string]string{ - "origin": "OWNER/REPO", - "robot-fork": "hubot/REPO", - }) - initContext = func() context.Context { - return ctx + remotes := context.Remotes{ + { + Remote: &git.Remote{Name: "origin"}, + Repo: ghrepo.New("OWNER", "REPO"), + }, + { + Remote: &git.Remote{Name: "robot-fork"}, + Repo: ghrepo.New("hubot", "REPO"), + }, } - http := initFakeHTTP() + + http := &httpmock.Registry{} defer http.Verify(t) - http.StubRepoResponse("OWNER", "REPO") http.Register(httpmock.GraphQL(`query PullRequestByNumber\b`), httpmock.StringResponse(` { "data": { "repository": { "pullRequest": { @@ -314,7 +359,7 @@ func TestPRCheckout_differentRepo_remoteExists(t *testing.T) { }) defer restoreCmd() - output, err := RunCommand(`pr checkout 123`) + output, err := runCommand(http, remotes, "master", `123`) eq(t, err, nil) eq(t, output.String(), "") @@ -326,17 +371,8 @@ func TestPRCheckout_differentRepo_remoteExists(t *testing.T) { } func TestPRCheckout_differentRepo(t *testing.T) { - ctx := context.NewBlank() - ctx.SetBranch("master") - ctx.SetRemotes(map[string]string{ - "origin": "OWNER/REPO", - }) - initContext = func() context.Context { - return ctx - } - http := initFakeHTTP() + http := &httpmock.Registry{} defer http.Verify(t) - http.StubRepoResponse("OWNER", "REPO") http.Register(httpmock.GraphQL(`query PullRequestByNumber\b`), httpmock.StringResponse(` { "data": { "repository": { "pullRequest": { @@ -365,7 +401,7 @@ func TestPRCheckout_differentRepo(t *testing.T) { }) defer restoreCmd() - output, err := RunCommand(`pr checkout 123`) + output, err := runCommand(http, nil, "master", `123`) eq(t, err, nil) eq(t, output.String(), "") @@ -377,17 +413,8 @@ func TestPRCheckout_differentRepo(t *testing.T) { } func TestPRCheckout_differentRepo_existingBranch(t *testing.T) { - ctx := context.NewBlank() - ctx.SetBranch("master") - ctx.SetRemotes(map[string]string{ - "origin": "OWNER/REPO", - }) - initContext = func() context.Context { - return ctx - } - http := initFakeHTTP() + http := &httpmock.Registry{} defer http.Verify(t) - http.StubRepoResponse("OWNER", "REPO") http.Register(httpmock.GraphQL(`query PullRequestByNumber\b`), httpmock.StringResponse(` { "data": { "repository": { "pullRequest": { @@ -416,7 +443,7 @@ func TestPRCheckout_differentRepo_existingBranch(t *testing.T) { }) defer restoreCmd() - output, err := RunCommand(`pr checkout 123`) + output, err := runCommand(http, nil, "master", `123`) eq(t, err, nil) eq(t, output.String(), "") @@ -426,17 +453,8 @@ func TestPRCheckout_differentRepo_existingBranch(t *testing.T) { } func TestPRCheckout_differentRepo_currentBranch(t *testing.T) { - ctx := context.NewBlank() - ctx.SetBranch("feature") - ctx.SetRemotes(map[string]string{ - "origin": "OWNER/REPO", - }) - initContext = func() context.Context { - return ctx - } - http := initFakeHTTP() + http := &httpmock.Registry{} defer http.Verify(t) - http.StubRepoResponse("OWNER", "REPO") http.Register(httpmock.GraphQL(`query PullRequestByNumber\b`), httpmock.StringResponse(` { "data": { "repository": { "pullRequest": { @@ -465,7 +483,7 @@ func TestPRCheckout_differentRepo_currentBranch(t *testing.T) { }) defer restoreCmd() - output, err := RunCommand(`pr checkout 123`) + output, err := runCommand(http, nil, "feature", `123`) eq(t, err, nil) eq(t, output.String(), "") @@ -475,17 +493,8 @@ func TestPRCheckout_differentRepo_currentBranch(t *testing.T) { } func TestPRCheckout_differentRepo_invalidBranchName(t *testing.T) { - ctx := context.NewBlank() - ctx.SetBranch("feature") - ctx.SetRemotes(map[string]string{ - "origin": "OWNER/REPO", - }) - initContext = func() context.Context { - return ctx - } - http := initFakeHTTP() + http := &httpmock.Registry{} defer http.Verify(t) - http.StubRepoResponse("OWNER", "REPO") http.Register(httpmock.GraphQL(`query PullRequestByNumber\b`), httpmock.StringResponse(` { "data": { "repository": { "pullRequest": { @@ -508,7 +517,7 @@ func TestPRCheckout_differentRepo_invalidBranchName(t *testing.T) { }) defer restoreCmd() - output, err := RunCommand(`pr checkout 123`) + 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()) } @@ -516,17 +525,8 @@ func TestPRCheckout_differentRepo_invalidBranchName(t *testing.T) { } func TestPRCheckout_maintainerCanModify(t *testing.T) { - ctx := context.NewBlank() - ctx.SetBranch("master") - ctx.SetRemotes(map[string]string{ - "origin": "OWNER/REPO", - }) - initContext = func() context.Context { - return ctx - } - http := initFakeHTTP() + http := &httpmock.Registry{} defer http.Verify(t) - http.StubRepoResponse("OWNER", "REPO") http.Register(httpmock.GraphQL(`query PullRequestByNumber\b`), httpmock.StringResponse(` { "data": { "repository": { "pullRequest": { @@ -555,7 +555,7 @@ func TestPRCheckout_maintainerCanModify(t *testing.T) { }) defer restoreCmd() - output, err := RunCommand(`pr checkout 123`) + output, err := runCommand(http, nil, "master", `123`) eq(t, err, nil) eq(t, output.String(), "") diff --git a/pkg/cmd/pr/close/close.go b/pkg/cmd/pr/close/close.go new file mode 100644 index 000000000..2fba67e06 --- /dev/null +++ b/pkg/cmd/pr/close/close.go @@ -0,0 +1,83 @@ +package close + +import ( + "fmt" + "net/http" + + "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" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/iostreams" + "github.com/cli/cli/utils" + "github.com/spf13/cobra" +) + +type CloseOptions struct { + HttpClient func() (*http.Client, error) + Config func() (config.Config, error) + IO *iostreams.IOStreams + BaseRepo func() (ghrepo.Interface, error) + + SelectorArg string +} + +func NewCmdClose(f *cmdutil.Factory, runF func(*CloseOptions) error) *cobra.Command { + opts := &CloseOptions{ + IO: f.IOStreams, + HttpClient: f.HttpClient, + Config: f.Config, + } + + cmd := &cobra.Command{ + Use: "close { | | }", + Short: "Close a pull request", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + // support `-R, --repo` override + opts.BaseRepo = f.BaseRepo + + if len(args) > 0 { + opts.SelectorArg = args[0] + } + + if runF != nil { + return runF(opts) + } + return closeRun(opts) + }, + } + + return cmd +} + +func closeRun(opts *CloseOptions) error { + httpClient, err := opts.HttpClient() + if err != nil { + return err + } + apiClient := api.NewClientFromHTTP(httpClient) + + pr, baseRepo, err := shared.PRFromArgs(apiClient, opts.BaseRepo, nil, nil, opts.SelectorArg) + if err != nil { + return err + } + + if pr.State == "MERGED" { + fmt.Fprintf(opts.IO.ErrOut, "%s Pull request #%d (%s) can't be closed because it was already merged", utils.Red("!"), pr.Number, pr.Title) + return cmdutil.SilentError + } else if pr.Closed { + fmt.Fprintf(opts.IO.ErrOut, "%s Pull request #%d (%s) is already closed\n", utils.Yellow("!"), pr.Number, pr.Title) + return nil + } + + err = api.PullRequestClose(apiClient, baseRepo, pr) + if err != nil { + return fmt.Errorf("API call failed: %w", err) + } + + fmt.Fprintf(opts.IO.ErrOut, "%s Closed pull request #%d (%s)\n", utils.Green("✔"), pr.Number, pr.Title) + + return nil +} diff --git a/pkg/cmd/pr/close/close_test.go b/pkg/cmd/pr/close/close_test.go new file mode 100644 index 000000000..1bb530637 --- /dev/null +++ b/pkg/cmd/pr/close/close_test.go @@ -0,0 +1,101 @@ +package close + +import ( + "bytes" + "io/ioutil" + "net/http" + "regexp" + "testing" + + "github.com/cli/cli/internal/config" + "github.com/cli/cli/internal/ghrepo" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/httpmock" + "github.com/cli/cli/pkg/iostreams" + "github.com/cli/cli/test" + "github.com/google/shlex" +) + +func runCommand(rt http.RoundTripper, isTTY bool, cli string) (*test.CmdOut, error) { + io, _, stdout, stderr := iostreams.Test() + io.SetStdoutTTY(isTTY) + io.SetStdinTTY(isTTY) + io.SetStderrTTY(isTTY) + + factory := &cmdutil.Factory{ + IOStreams: io, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: rt}, nil + }, + Config: func() (config.Config, error) { + return config.NewBlankConfig(), nil + }, + BaseRepo: func() (ghrepo.Interface, error) { + return ghrepo.New("OWNER", "REPO"), nil + }, + } + + cmd := NewCmdClose(factory, nil) + + argv, err := shlex.Split(cli) + if err != nil { + return nil, err + } + cmd.SetArgs(argv) + + cmd.SetIn(&bytes.Buffer{}) + cmd.SetOut(ioutil.Discard) + cmd.SetErr(ioutil.Discard) + + _, err = cmd.ExecuteC() + return &test.CmdOut{ + OutBuf: stdout, + ErrBuf: stderr, + }, err +} + +func TestPrClose(t *testing.T) { + http := &httpmock.Registry{} + defer http.Verify(t) + + http.StubResponse(200, bytes.NewBufferString(` + { "data": { "repository": { + "pullRequest": { "number": 96, "title": "The title of the PR" } + } } } + `)) + + http.StubResponse(200, bytes.NewBufferString(`{"id": "THE-ID"}`)) + + output, err := runCommand(http, true, "96") + if err != nil { + t.Fatalf("error running command `pr close`: %v", err) + } + + r := regexp.MustCompile(`Closed pull request #96 \(The title of the PR\)`) + + if !r.MatchString(output.Stderr()) { + t.Fatalf("output did not match regexp /%s/\n> output\n%q\n", r, output.Stderr()) + } +} + +func TestPrClose_alreadyClosed(t *testing.T) { + http := &httpmock.Registry{} + defer http.Verify(t) + + http.StubResponse(200, bytes.NewBufferString(` + { "data": { "repository": { + "pullRequest": { "number": 101, "title": "The title of the PR", "closed": true } + } } } + `)) + + output, err := runCommand(http, true, "101") + if err != nil { + t.Fatalf("error running command `pr close`: %v", err) + } + + r := regexp.MustCompile(`Pull request #101 \(The title of the PR\) is already closed`) + + if !r.MatchString(output.Stderr()) { + t.Fatalf("output did not match regexp /%s/\n> output\n%q\n", r, output.Stderr()) + } +} diff --git a/command/pr_create.go b/pkg/cmd/pr/create/create.go similarity index 54% rename from command/pr_create.go rename to pkg/cmd/pr/create/create.go index b9efca0fa..c9e1fb6ab 100644 --- a/command/pr_create.go +++ b/pkg/cmd/pr/create/create.go @@ -1,9 +1,9 @@ -package command +package create import ( "errors" "fmt" - "net/url" + "net/http" "strings" "time" @@ -11,60 +11,116 @@ import ( "github.com/cli/cli/api" "github.com/cli/cli/context" "github.com/cli/cli/git" + "github.com/cli/cli/internal/config" "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/githubtemplate" + "github.com/cli/cli/pkg/iostreams" "github.com/cli/cli/utils" "github.com/spf13/cobra" ) -type defaults struct { - Title string - Body string +type CreateOptions struct { + HttpClient func() (*http.Client, error) + Config func() (config.Config, error) + IO *iostreams.IOStreams + Remotes func() (context.Remotes, error) + Branch func() (string, error) + + RepoOverride string + + Autofill bool + WebMode bool + + IsDraft bool + Title string + TitleProvided bool + Body string + BodyProvided bool + BaseBranch string + + Reviewers []string + Assignees []string + Labels []string + Projects []string + Milestone string } -func computeDefaults(baseRef, headRef string) (defaults, error) { - commits, err := git.Commits(baseRef, headRef) +func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Command { + opts := &CreateOptions{ + IO: f.IOStreams, + HttpClient: f.HttpClient, + Config: f.Config, + Remotes: f.Remotes, + Branch: f.Branch, + } + + cmd := &cobra.Command{ + Use: "create", + Short: "Create a pull request", + Example: heredoc.Doc(` + $ gh pr create --title "The bug is fixed" --body "Everything works again" + $ gh issue create --label "bug,help wanted" + $ gh issue create --label bug --label "help wanted" + $ gh pr create --reviewer monalisa,hubot + $ gh pr create --project "Roadmap" + $ gh pr create --base develop + `), + Args: cmdutil.NoArgsQuoteReminder, + RunE: func(cmd *cobra.Command, args []string) error { + opts.TitleProvided = cmd.Flags().Changed("title") + opts.BodyProvided = cmd.Flags().Changed("body") + opts.RepoOverride, _ = cmd.Flags().GetString("repo") + + isTerminal := opts.IO.IsStdinTTY() && opts.IO.IsStdoutTTY() + if !isTerminal && !opts.WebMode && !opts.TitleProvided && !opts.Autofill { + return errors.New("--title or --fill required when not attached to a terminal") + } + + if opts.IsDraft && opts.WebMode { + return errors.New("the --draft flag is not supported with --web") + } + if len(opts.Reviewers) > 0 && opts.WebMode { + return errors.New("the --reviewer flag is not supported with --web") + } + + if runF != nil { + return runF(opts) + } + return createRun(opts) + }, + } + + fl := cmd.Flags() + fl.BoolVarP(&opts.IsDraft, "draft", "d", false, "Mark pull request as a draft") + fl.StringVarP(&opts.Title, "title", "t", "", "Supply a title. Will prompt for one otherwise.") + fl.StringVarP(&opts.Body, "body", "b", "", "Supply a body. Will prompt for one otherwise.") + fl.StringVarP(&opts.BaseBranch, "base", "B", "", "The branch into which you want your code merged") + fl.BoolVarP(&opts.WebMode, "web", "w", false, "Open the web browser to create a pull request") + fl.BoolVarP(&opts.Autofill, "fill", "f", false, "Do not prompt for title/body and just use commit info") + fl.StringSliceVarP(&opts.Reviewers, "reviewer", "r", nil, "Request reviews from people by their `login`") + fl.StringSliceVarP(&opts.Assignees, "assignee", "a", nil, "Assign people by their `login`") + fl.StringSliceVarP(&opts.Labels, "label", "l", nil, "Add labels by `name`") + fl.StringSliceVarP(&opts.Projects, "project", "p", nil, "Add the pull request to projects by `name`") + fl.StringVarP(&opts.Milestone, "milestone", "m", "", "Add the pull request to a milestone by `name`") + + return cmd +} + +func createRun(opts *CreateOptions) error { + httpClient, err := opts.HttpClient() if err != nil { - return defaults{}, err + return err } + client := api.NewClientFromHTTP(httpClient) - out := defaults{} - - if len(commits) == 1 { - out.Title = commits[0].Title - body, err := git.CommitBody(commits[0].Sha) - if err != nil { - return defaults{}, err - } - out.Body = body - } else { - out.Title = utils.Humanize(headRef) - - body := "" - for i := len(commits) - 1; i >= 0; i-- { - body += fmt.Sprintf("- %s\n", commits[i].Title) - } - out.Body = body - } - - return out, nil -} - -func prCreate(cmd *cobra.Command, _ []string) error { - ctx := contextForCommand(cmd) - remotes, err := ctx.Remotes() + remotes, err := opts.Remotes() if err != nil { return err } - client, err := apiClientForContext(ctx) - if err != nil { - return fmt.Errorf("could not initialize API client: %w", err) - } - - baseRepoOverride, _ := cmd.Flags().GetString("repo") - repoContext, err := context.ResolveRemotesToRepos(remotes, client, baseRepoOverride) + repoContext, err := context.ResolveRemotesToRepos(remotes, client, opts.RepoOverride) if err != nil { return err } @@ -74,7 +130,7 @@ func prCreate(cmd *cobra.Command, _ []string) error { return fmt.Errorf("could not determine base repository: %w", err) } - headBranch, err := ctx.Branch() + headBranch, err := opts.Branch() if err != nil { return fmt.Errorf("could not determine the current branch: %w", err) } @@ -102,10 +158,7 @@ func prCreate(cmd *cobra.Command, _ []string) error { } } - baseBranch, err := cmd.Flags().GetString("base") - if err != nil { - return err - } + baseBranch := opts.BaseBranch if baseBranch == "" { baseBranch = baseRepo.DefaultBranchRef.Name } @@ -114,39 +167,12 @@ func prCreate(cmd *cobra.Command, _ []string) error { } if ucc, err := git.UncommittedChangeCount(); err == nil && ucc > 0 { - fmt.Fprintf(cmd.ErrOrStderr(), "Warning: %s\n", utils.Pluralize(ucc, "uncommitted change")) + fmt.Fprintf(opts.IO.ErrOut, "Warning: %s\n", utils.Pluralize(ucc, "uncommitted change")) } - title, err := cmd.Flags().GetString("title") - if err != nil { - return fmt.Errorf("could not parse title: %w", err) - } - body, err := cmd.Flags().GetString("body") - if err != nil { - return fmt.Errorf("could not parse body: %w", err) - } - - reviewers, err := cmd.Flags().GetStringSlice("reviewer") - if err != nil { - return fmt.Errorf("could not parse reviewers: %w", err) - } - assignees, err := cmd.Flags().GetStringSlice("assignee") - if err != nil { - return fmt.Errorf("could not parse assignees: %w", err) - } - labelNames, err := cmd.Flags().GetStringSlice("label") - if err != nil { - return fmt.Errorf("could not parse labels: %w", err) - } - projectNames, err := cmd.Flags().GetStringSlice("project") - if err != nil { - return fmt.Errorf("could not parse projects: %w", err) - } var milestoneTitles []string - if milestoneTitle, err := cmd.Flags().GetString("milestone"); err != nil { - return fmt.Errorf("could not parse milestone: %w", err) - } else if milestoneTitle != "" { - milestoneTitles = append(milestoneTitles, milestoneTitle) + if opts.Milestone != "" { + milestoneTitles = []string{opts.Milestone} } baseTrackingBranch := baseBranch @@ -155,23 +181,16 @@ func prCreate(cmd *cobra.Command, _ []string) error { } defs, defaultsErr := computeDefaults(baseTrackingBranch, headBranch) - isWeb, err := cmd.Flags().GetBool("web") - if err != nil { - return fmt.Errorf("could not parse web: %q", err) - } + title := opts.Title + body := opts.Body - autofill, err := cmd.Flags().GetBool("fill") - if err != nil { - return fmt.Errorf("could not parse fill: %q", err) - } - - action := SubmitAction - if isWeb { - action = PreviewAction + action := shared.SubmitAction + if opts.WebMode { + action = shared.PreviewAction if (title == "" || body == "") && defaultsErr != nil { return fmt.Errorf("could not compute title or body defaults: %w", defaultsErr) } - } else if autofill { + } else if opts.Autofill { if defaultsErr != nil { return fmt.Errorf("could not compute title or body defaults: %w", defaultsErr) } @@ -179,7 +198,7 @@ func prCreate(cmd *cobra.Command, _ []string) error { body = defs.Body } - if !isWeb { + if !opts.WebMode { headBranchLabel := headBranch if headRepo != nil && !ghrepo.IsSame(baseRepo, headRepo) { headBranchLabel = fmt.Sprintf("%s:%s", headRepo.RepoOwner(), headBranch) @@ -194,46 +213,37 @@ func prCreate(cmd *cobra.Command, _ []string) error { } } - isDraft, err := cmd.Flags().GetBool("draft") - if err != nil { - return fmt.Errorf("could not parse draft: %w", err) - } + isTerminal := opts.IO.IsStdinTTY() && opts.IO.IsStdoutTTY() - if !isWeb && !autofill { + if !opts.WebMode && !opts.Autofill { message := "\nCreating pull request for %s into %s in %s\n\n" - if isDraft { + if opts.IsDraft { message = "\nCreating draft pull request for %s into %s in %s\n\n" } - if connectedToTerminal(cmd) { - fmt.Fprintf(colorableErr(cmd), message, + if isTerminal { + fmt.Fprintf(opts.IO.ErrOut, message, utils.Cyan(headBranch), utils.Cyan(baseBranch), ghrepo.FullName(baseRepo)) if (title == "" || body == "") && defaultsErr != nil { - fmt.Fprintf(colorableErr(cmd), "%s warning: could not compute title or body defaults: %s\n", utils.Yellow("!"), defaultsErr) + fmt.Fprintf(opts.IO.ErrOut, "%s warning: could not compute title or body defaults: %s\n", utils.Yellow("!"), defaultsErr) } } } - tb := issueMetadataState{ - Type: prMetadata, - Reviewers: reviewers, - Assignees: assignees, - Labels: labelNames, - Projects: projectNames, + tb := shared.IssueMetadataState{ + Type: shared.PRMetadata, + Reviewers: opts.Reviewers, + Assignees: opts.Assignees, + Labels: opts.Labels, + Projects: opts.Projects, Milestones: milestoneTitles, } - if !connectedToTerminal(cmd) { - if !isWeb && (!cmd.Flags().Changed("title") && !autofill) { - return errors.New("--title or --fill required when not attached to a tty") - } - } + interactive := isTerminal && !(opts.TitleProvided && opts.BodyProvided) - interactive := connectedToTerminal(cmd) && !(cmd.Flags().Changed("title") && cmd.Flags().Changed("body")) - - if !isWeb && !autofill && interactive { + if !opts.WebMode && !opts.Autofill && interactive { var nonLegacyTemplateFiles []string var legacyTemplateFile *string if rootDir, err := git.ToplevelDir(); err == nil { @@ -241,15 +251,21 @@ func prCreate(cmd *cobra.Command, _ []string) error { nonLegacyTemplateFiles = githubtemplate.FindNonLegacy(rootDir, "PULL_REQUEST_TEMPLATE") legacyTemplateFile = githubtemplate.FindLegacy(rootDir, "PULL_REQUEST_TEMPLATE") } - err := titleBodySurvey(cmd, &tb, client, baseRepo, title, body, defs, nonLegacyTemplateFiles, legacyTemplateFile, true, baseRepo.ViewerCanTriage()) + + editorCommand, err := cmdutil.DetermineEditor(opts.Config) + if err != nil { + return err + } + + err = shared.TitleBodySurvey(opts.IO, editorCommand, &tb, client, baseRepo, title, body, defs, nonLegacyTemplateFiles, legacyTemplateFile, true, baseRepo.ViewerCanTriage()) if err != nil { return fmt.Errorf("could not collect title and/or body: %w", err) } action = tb.Action - if action == CancelAction { - fmt.Fprintln(cmd.ErrOrStderr(), "Discarding.") + if action == shared.CancelAction { + fmt.Fprintln(opts.IO.ErrOut, "Discarding.") return nil } @@ -261,17 +277,10 @@ func prCreate(cmd *cobra.Command, _ []string) error { } } - if action == SubmitAction && title == "" { + if action == shared.SubmitAction && title == "" { return errors.New("pull request title must not be blank") } - if isDraft && isWeb { - return errors.New("the --draft flag is not supported with --web") - } - if len(reviewers) > 0 && isWeb { - return errors.New("the --reviewer flag is not supported with --web") - } - didForkRepo := false // if a head repository could not be determined so far, automatically create // one by forking the base repository @@ -303,7 +312,13 @@ func prCreate(cmd *cobra.Command, _ []string) error { // In either case, we want to add the head repo as a new git remote so we // can push to it. if headRemote == nil { - headRepoURL := formatRemoteURL(cmd, headRepo) + cfg, err := opts.Config() + if err != nil { + return err + } + cloneProtocol, _ := cfg.Get(headRepo.RepoHost(), "git_protocol") + + headRepoURL := ghrepo.FormatRemoteURL(headRepo, cloneProtocol) // TODO: prevent clashes with another remote of a same name gitRemote, err := git.AddRemote("fork", headRepoURL) @@ -326,7 +341,7 @@ func prCreate(cmd *cobra.Command, _ []string) error { pushTries++ // first wait 2 seconds after forking, then 4s, then 6s waitSeconds := 2 * pushTries - fmt.Fprintf(cmd.ErrOrStderr(), "waiting %s before retrying...\n", utils.Pluralize(waitSeconds, "second")) + fmt.Fprintf(opts.IO.ErrOut, "waiting %s before retrying...\n", utils.Pluralize(waitSeconds, "second")) time.Sleep(time.Duration(waitSeconds) * time.Second) continue } @@ -336,16 +351,16 @@ func prCreate(cmd *cobra.Command, _ []string) error { } } - if action == SubmitAction { + if action == shared.SubmitAction { params := map[string]interface{}{ "title": title, "body": body, - "draft": isDraft, + "draft": opts.IsDraft, "baseRefName": baseBranch, "headRefName": headBranchLabel, } - err = addMetadataToIssueParams(client, baseRepo, params, &tb) + err = shared.AddMetadataToIssueParams(client, baseRepo, params, &tb) if err != nil { return err } @@ -355,19 +370,14 @@ func prCreate(cmd *cobra.Command, _ []string) error { return fmt.Errorf("failed to create pull request: %w", err) } - fmt.Fprintln(cmd.OutOrStdout(), pr.URL) - } else if action == PreviewAction { - milestone := "" - if len(milestoneTitles) > 0 { - milestone = milestoneTitles[0] - } - openURL, err := generateCompareURL(baseRepo, baseBranch, headBranchLabel, title, body, assignees, labelNames, projectNames, milestone) + fmt.Fprintln(opts.IO.Out, pr.URL) + } else if action == shared.PreviewAction { + openURL, err := generateCompareURL(baseRepo, baseBranch, headBranchLabel, title, body, tb.Assignees, tb.Labels, tb.Projects, tb.Milestones) if err != nil { return err } - if connectedToTerminal(cmd) { - // TODO could exceed max url length for explorer - fmt.Fprintf(cmd.ErrOrStderr(), "Opening %s in your browser.\n", utils.DisplayURL(openURL)) + if isTerminal { + fmt.Fprintf(opts.IO.ErrOut, "Opening %s in your browser.\n", utils.DisplayURL(openURL)) } return utils.OpenInBrowser(openURL) } else { @@ -377,6 +387,34 @@ func prCreate(cmd *cobra.Command, _ []string) error { return nil } +func computeDefaults(baseRef, headRef string) (shared.Defaults, error) { + out := shared.Defaults{} + + commits, err := git.Commits(baseRef, headRef) + if err != nil { + return out, err + } + + if len(commits) == 1 { + out.Title = commits[0].Title + body, err := git.CommitBody(commits[0].Sha) + if err != nil { + return out, err + } + out.Body = body + } else { + out.Title = utils.Humanize(headRef) + + body := "" + for i := len(commits) - 1; i >= 0; i-- { + body += fmt.Sprintf("- %s\n", commits[i].Title) + } + out.Body = body + } + + return out, nil +} + func determineTrackingBranch(remotes context.Remotes, headBranch string) *git.TrackingRef { refsForLookup := []string{"HEAD"} var trackingRefs []git.TrackingRef @@ -418,73 +456,11 @@ func determineTrackingBranch(remotes context.Remotes, headBranch string) *git.Tr return nil } -func withPrAndIssueQueryParams(baseURL, title, body string, assignees, labels, projects []string, milestone string) (string, error) { - u, err := url.Parse(baseURL) - if err != nil { - return "", err - } - q := u.Query() - if title != "" { - q.Set("title", title) - } - if body != "" { - q.Set("body", body) - } - if len(assignees) > 0 { - q.Set("assignees", strings.Join(assignees, ",")) - } - if len(labels) > 0 { - q.Set("labels", strings.Join(labels, ",")) - } - if len(projects) > 0 { - q.Set("projects", strings.Join(projects, ",")) - } - if milestone != "" { - q.Set("milestone", milestone) - } - u.RawQuery = q.Encode() - return u.String(), nil -} - -func generateCompareURL(r ghrepo.Interface, base, head, title, body string, assignees, labels, projects []string, milestone string) (string, error) { +func generateCompareURL(r ghrepo.Interface, base, head, title, body string, assignees, labels, projects []string, milestones []string) (string, error) { u := ghrepo.GenerateRepoURL(r, "compare/%s...%s?expand=1", base, head) - url, err := withPrAndIssueQueryParams(u, title, body, assignees, labels, projects, milestone) + url, err := shared.WithPrAndIssueQueryParams(u, title, body, assignees, labels, projects, milestones) if err != nil { return "", err } return url, nil } - -var prCreateCmd = &cobra.Command{ - Use: "create", - Short: "Create a pull request", - Args: cmdutil.NoArgsQuoteReminder, - RunE: prCreate, - Example: heredoc.Doc(` - $ gh pr create --title "The bug is fixed" --body "Everything works again" - $ gh issue create --label "bug,help wanted" - $ gh issue create --label bug --label "help wanted" - $ gh pr create --reviewer monalisa,hubot - $ gh pr create --project "Roadmap" - $ gh pr create --base develop - `), -} - -func init() { - prCreateCmd.Flags().BoolP("draft", "d", false, - "Mark pull request as a draft") - prCreateCmd.Flags().StringP("title", "t", "", - "Supply a title. Will prompt for one otherwise.") - prCreateCmd.Flags().StringP("body", "b", "", - "Supply a body. Will prompt for one otherwise.") - prCreateCmd.Flags().StringP("base", "B", "", - "The branch into which you want your code merged") - prCreateCmd.Flags().BoolP("web", "w", false, "Open the web browser to create a pull request") - prCreateCmd.Flags().BoolP("fill", "f", false, "Do not prompt for title/body and just use commit info") - - prCreateCmd.Flags().StringSliceP("reviewer", "r", nil, "Request reviews from people by their `login`") - prCreateCmd.Flags().StringSliceP("assignee", "a", nil, "Assign people by their `login`") - prCreateCmd.Flags().StringSliceP("label", "l", nil, "Add labels by `name`") - prCreateCmd.Flags().StringSliceP("project", "p", nil, "Add the pull request to projects by `name`") - prCreateCmd.Flags().StringP("milestone", "m", "", "Add the pull request to a milestone by `name`") -} diff --git a/command/pr_create_test.go b/pkg/cmd/pr/create/create_test.go similarity index 88% rename from command/pr_create_test.go rename to pkg/cmd/pr/create/create_test.go index c6daf8bef..e50a72c63 100644 --- a/command/pr_create_test.go +++ b/pkg/cmd/pr/create/create_test.go @@ -1,25 +1,93 @@ -package command +package create import ( "bytes" "encoding/json" "io/ioutil" + "net/http" + "reflect" "strings" "testing" "github.com/cli/cli/context" "github.com/cli/cli/git" + "github.com/cli/cli/internal/config" "github.com/cli/cli/internal/ghrepo" + "github.com/cli/cli/pkg/cmdutil" "github.com/cli/cli/pkg/httpmock" + "github.com/cli/cli/pkg/iostreams" "github.com/cli/cli/pkg/prompt" "github.com/cli/cli/test" + "github.com/google/shlex" "github.com/stretchr/testify/assert" + "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) { + io, _, stdout, stderr := iostreams.Test() + io.SetStdoutTTY(isTTY) + io.SetStdinTTY(isTTY) + io.SetStderrTTY(isTTY) + + factory := &cmdutil.Factory{ + IOStreams: io, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: rt}, nil + }, + Config: func() (config.Config, error) { + return config.NewBlankConfig(), nil + }, + Remotes: func() (context.Remotes, error) { + if remotes != nil { + return remotes, nil + } + return context.Remotes{ + { + Remote: &git.Remote{Name: "origin"}, + Repo: ghrepo.New("OWNER", "REPO"), + }, + }, nil + }, + Branch: func() (string, error) { + return branch, nil + }, + } + + cmd := NewCmdCreate(factory, nil) + cmd.PersistentFlags().StringP("repo", "R", "", "") + + argv, err := shlex.Split(cli) + if err != nil { + return nil, err + } + cmd.SetArgs(argv) + + cmd.SetIn(&bytes.Buffer{}) + cmd.SetOut(ioutil.Discard) + cmd.SetErr(ioutil.Discard) + + _, err = cmd.ExecuteC() + return &test.CmdOut{ + OutBuf: stdout, + ErrBuf: stderr, + }, err +} + +func initFakeHTTP() *httpmock.Registry { + return &httpmock.Registry{} +} + func TestPRCreate_nontty_web(t *testing.T) { - initBlankContext("", "OWNER/REPO", "feature") - defer stubTerminal(false)() http := initFakeHTTP() + defer http.Verify(t) + http.StubRepoResponse("OWNER", "REPO") http.StubResponse(200, bytes.NewBufferString(` { "data": { "repository": { "forks": { "nodes": [ @@ -36,8 +104,8 @@ func TestPRCreate_nontty_web(t *testing.T) { cs.Stub("") // git push cs.Stub("") // browser - output, err := RunCommand(`pr create --web`) - eq(t, err, nil) + output, err := runCommand(http, nil, "feature", false, `--web`) + require.NoError(t, err) eq(t, output.String(), "") eq(t, output.Stderr(), "") @@ -50,33 +118,23 @@ func TestPRCreate_nontty_web(t *testing.T) { } func TestPRCreate_nontty_insufficient_flags(t *testing.T) { - initBlankContext("", "OWNER/REPO", "feature") - defer stubTerminal(false)() http := initFakeHTTP() - http.StubRepoResponse("OWNER", "REPO") - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { "forks": { "nodes": [ - ] } } } } - `)) - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { "pullRequests": { "nodes" : [ - ] } } } } - `)) + defer http.Verify(t) - output, err := RunCommand("pr create") + output, err := runCommand(http, nil, "feature", false, "") if err == nil { t.Fatal("expected error") } - assert.Equal(t, "--title or --fill required when not attached to a tty", err.Error()) + assert.Equal(t, "--title or --fill required when not attached to a terminal", err.Error()) assert.Equal(t, "", output.String()) } func TestPRCreate_nontty(t *testing.T) { - initBlankContext("", "OWNER/REPO", "feature") - defer stubTerminal(false)() http := initFakeHTTP() + defer http.Verify(t) + http.StubRepoResponse("OWNER", "REPO") http.StubResponse(200, bytes.NewBufferString(` { "data": { "repository": { "forks": { "nodes": [ @@ -101,8 +159,8 @@ func TestPRCreate_nontty(t *testing.T) { cs.Stub("1234567890,commit 0\n2345678901,commit 1") // git log cs.Stub("") // git push - output, err := RunCommand(`pr create -t "my title" -b "my body"`) - eq(t, err, nil) + output, err := runCommand(http, nil, "feature", false, `-t "my title" -b "my body"`) + require.NoError(t, err) bodyBytes, _ := ioutil.ReadAll(http.Requests[3].Body) reqBody := struct { @@ -129,9 +187,9 @@ func TestPRCreate_nontty(t *testing.T) { } func TestPRCreate(t *testing.T) { - initBlankContext("", "OWNER/REPO", "feature") - defer stubTerminal(true)() http := initFakeHTTP() + defer http.Verify(t) + http.StubRepoResponse("OWNER", "REPO") http.StubResponse(200, bytes.NewBufferString(` { "data": { "repository": { "forks": { "nodes": [ @@ -156,8 +214,8 @@ func TestPRCreate(t *testing.T) { cs.Stub("1234567890,commit 0\n2345678901,commit 1") // git log cs.Stub("") // git push - output, err := RunCommand(`pr create -t "my title" -b "my body"`) - eq(t, err, nil) + output, err := runCommand(http, nil, "feature", true, `-t "my title" -b "my body"`) + require.NoError(t, err) bodyBytes, _ := ioutil.ReadAll(http.Requests[3].Body) reqBody := struct { @@ -183,8 +241,6 @@ func TestPRCreate(t *testing.T) { } func TestPRCreate_metadata(t *testing.T) { - initBlankContext("", "OWNER/REPO", "feature") - defer stubTerminal(true)() http := initFakeHTTP() defer http.Verify(t) @@ -301,16 +357,16 @@ func TestPRCreate_metadata(t *testing.T) { cs.Stub("1234567890,commit 0\n2345678901,commit 1") // git log cs.Stub("") // git push - output, err := RunCommand(`pr create -t TITLE -b BODY -a monalisa -l bug -l todo -p roadmap -m 'big one.oh' -r hubot -r monalisa -r /core -r /robots`) + output, err := runCommand(http, nil, "feature", true, `-t TITLE -b BODY -a monalisa -l bug -l todo -p roadmap -m 'big one.oh' -r hubot -r monalisa -r /core -r /robots`) eq(t, err, nil) eq(t, output.String(), "https://github.com/OWNER/REPO/pull/12\n") } func TestPRCreate_withForking(t *testing.T) { - initBlankContext("", "OWNER/REPO", "feature") - defer stubTerminal(true)() http := initFakeHTTP() + defer http.Verify(t) + http.StubRepoResponseWithPermission("OWNER", "REPO", "READ") http.StubResponse(200, bytes.NewBufferString(` { "data": { "repository": { "forks": { "nodes": [ @@ -344,17 +400,17 @@ func TestPRCreate_withForking(t *testing.T) { cs.Stub("") // git remote add cs.Stub("") // git push - output, err := RunCommand(`pr create -t title -b body`) - eq(t, err, nil) + output, err := runCommand(http, nil, "feature", true, `-t title -b body`) + require.NoError(t, err) eq(t, http.Requests[3].URL.Path, "/repos/OWNER/REPO/forks") eq(t, output.String(), "https://github.com/OWNER/REPO/pull/12\n") } func TestPRCreate_alreadyExists(t *testing.T) { - initBlankContext("", "OWNER/REPO", "feature") - defer stubTerminal(true)() http := initFakeHTTP() + defer http.Verify(t) + http.StubRepoResponse("OWNER", "REPO") http.StubResponse(200, bytes.NewBufferString(` { "data": { "repository": { "forks": { "nodes": [ @@ -376,7 +432,7 @@ func TestPRCreate_alreadyExists(t *testing.T) { cs.Stub("") // git status cs.Stub("1234567890,commit 0\n2345678901,commit 1") // git log - _, err := RunCommand(`pr create`) + _, err := runCommand(http, nil, "feature", true, ``) if err == nil { t.Fatal("error expected, got nil") } @@ -386,9 +442,9 @@ func TestPRCreate_alreadyExists(t *testing.T) { } func TestPRCreate_alreadyExistsDifferentBase(t *testing.T) { - initBlankContext("", "OWNER/REPO", "feature") - defer stubTerminal(true)() http := initFakeHTTP() + defer http.Verify(t) + http.StubRepoResponse("OWNER", "REPO") http.StubResponse(200, bytes.NewBufferString(` { "data": { "repository": { "forks": { "nodes": [ @@ -412,16 +468,16 @@ func TestPRCreate_alreadyExistsDifferentBase(t *testing.T) { cs.Stub("1234567890,commit 0\n2345678901,commit 1") // git log cs.Stub("") // git rev-parse - _, err := RunCommand(`pr create -BanotherBase -t"cool" -b"nah"`) + _, err := runCommand(http, nil, "feature", true, `-BanotherBase -t"cool" -b"nah"`) if err != nil { t.Errorf("got unexpected error %q", err) } } func TestPRCreate_web(t *testing.T) { - initBlankContext("", "OWNER/REPO", "feature") - defer stubTerminal(true)() http := initFakeHTTP() + defer http.Verify(t) + http.StubRepoResponse("OWNER", "REPO") http.StubResponse(200, bytes.NewBufferString(` { "data": { "repository": { "forks": { "nodes": [ @@ -438,8 +494,8 @@ func TestPRCreate_web(t *testing.T) { cs.Stub("") // git push cs.Stub("") // browser - output, err := RunCommand(`pr create --web`) - eq(t, err, nil) + 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") @@ -451,9 +507,8 @@ func TestPRCreate_web(t *testing.T) { } func TestPRCreate_ReportsUncommittedChanges(t *testing.T) { - initBlankContext("", "OWNER/REPO", "feature") - defer stubTerminal(true)() http := initFakeHTTP() + defer http.Verify(t) http.StubRepoResponse("OWNER", "REPO") http.StubResponse(200, bytes.NewBufferString(` @@ -479,7 +534,7 @@ func TestPRCreate_ReportsUncommittedChanges(t *testing.T) { cs.Stub("1234567890,commit 0\n2345678901,commit 1") // git log cs.Stub("") // git push - output, err := RunCommand(`pr create -t "my title" -b "my body"`) + output, err := runCommand(http, nil, "feature", true, `-t "my title" -b "my body"`) eq(t, err, nil) eq(t, output.String(), "https://github.com/OWNER/REPO/pull/12\n") @@ -487,17 +542,20 @@ func TestPRCreate_ReportsUncommittedChanges(t *testing.T) { } func TestPRCreate_cross_repo_same_branch(t *testing.T) { - defer stubTerminal(true)() - ctx := context.NewBlank() - ctx.SetBranch("default") - ctx.SetRemotes(map[string]string{ - "origin": "OWNER/REPO", - "fork": "MYSELF/REPO", - }) - initContext = func() context.Context { - return ctx + remotes := context.Remotes{ + { + Remote: &git.Remote{Name: "origin"}, + Repo: ghrepo.New("OWNER", "REPO"), + }, + { + Remote: &git.Remote{Name: "fork"}, + Repo: ghrepo.New("MYSELF", "REPO"), + }, } + http := initFakeHTTP() + defer http.Verify(t) + http.StubResponse(200, bytes.NewBufferString(` { "data": { "repo_000": { "id": "REPOID0", @@ -546,8 +604,8 @@ func TestPRCreate_cross_repo_same_branch(t *testing.T) { cs.Stub("1234567890,commit 0\n2345678901,commit 1") // git log cs.Stub("") // git push - output, err := RunCommand(`pr create -t "cross repo" -b "same branch"`) - eq(t, err, nil) + output, err := runCommand(http, remotes, "default", true, `-t "cross repo" -b "same branch"`) + require.NoError(t, err) bodyBytes, _ := ioutil.ReadAll(http.Requests[2].Body) reqBody := struct { @@ -575,9 +633,9 @@ func TestPRCreate_cross_repo_same_branch(t *testing.T) { } func TestPRCreate_survey_defaults_multicommit(t *testing.T) { - initBlankContext("", "OWNER/REPO", "cool_bug-fixes") - defer stubTerminal(true)() http := initFakeHTTP() + defer http.Verify(t) + http.StubRepoResponse("OWNER", "REPO") http.StubResponse(200, bytes.NewBufferString(` { "data": { "repository": { "forks": { "nodes": [ @@ -623,8 +681,8 @@ func TestPRCreate_survey_defaults_multicommit(t *testing.T) { }, }) - output, err := RunCommand(`pr create`) - eq(t, err, nil) + output, err := runCommand(http, nil, "cool_bug-fixes", true, ``) + require.NoError(t, err) bodyBytes, _ := ioutil.ReadAll(http.Requests[3].Body) reqBody := struct { @@ -652,10 +710,9 @@ func TestPRCreate_survey_defaults_multicommit(t *testing.T) { } func TestPRCreate_survey_defaults_monocommit(t *testing.T) { - initBlankContext("", "OWNER/REPO", "feature") - defer stubTerminal(true)() http := initFakeHTTP() defer http.Verify(t) + http.Register(httpmock.GraphQL(`query RepositoryNetwork\b`), httpmock.StringResponse(httpmock.RepoNetworkStubResponse("OWNER", "REPO", "master", "WRITE"))) http.Register(httpmock.GraphQL(`query RepositoryFindFork\b`), httpmock.StringResponse(` { "data": { "repository": { "forks": { "nodes": [ @@ -708,15 +765,15 @@ func TestPRCreate_survey_defaults_monocommit(t *testing.T) { }, }) - output, err := RunCommand(`pr create`) + output, err := runCommand(http, nil, "feature", true, ``) eq(t, err, nil) eq(t, output.String(), "https://github.com/OWNER/REPO/pull/12\n") } func TestPRCreate_survey_autofill_nontty(t *testing.T) { - initBlankContext("", "OWNER/REPO", "feature") - defer stubTerminal(false)() http := initFakeHTTP() + defer http.Verify(t) + http.StubRepoResponse("OWNER", "REPO") http.StubResponse(200, bytes.NewBufferString(` { "data": { "repository": { "forks": { "nodes": [ @@ -744,8 +801,8 @@ func TestPRCreate_survey_autofill_nontty(t *testing.T) { cs.Stub("") // git push cs.Stub("") // browser open - output, err := RunCommand(`pr create -f`) - eq(t, err, nil) + output, err := runCommand(http, nil, "feature", false, `-f`) + require.NoError(t, err) bodyBytes, _ := ioutil.ReadAll(http.Requests[3].Body) reqBody := struct { @@ -775,9 +832,9 @@ func TestPRCreate_survey_autofill_nontty(t *testing.T) { } func TestPRCreate_survey_autofill(t *testing.T) { - initBlankContext("", "OWNER/REPO", "feature") - defer stubTerminal(true)() http := initFakeHTTP() + defer http.Verify(t) + http.StubRepoResponse("OWNER", "REPO") http.StubResponse(200, bytes.NewBufferString(` { "data": { "repository": { "forks": { "nodes": [ @@ -805,8 +862,8 @@ func TestPRCreate_survey_autofill(t *testing.T) { cs.Stub("") // git push cs.Stub("") // browser open - output, err := RunCommand(`pr create -f`) - eq(t, err, nil) + output, err := runCommand(http, nil, "feature", true, `-f`) + require.NoError(t, err) bodyBytes, _ := ioutil.ReadAll(http.Requests[3].Body) reqBody := struct { @@ -834,9 +891,9 @@ func TestPRCreate_survey_autofill(t *testing.T) { } func TestPRCreate_defaults_error_autofill(t *testing.T) { - initBlankContext("", "OWNER/REPO", "feature") - defer stubTerminal(true)() http := initFakeHTTP() + defer http.Verify(t) + http.StubRepoResponse("OWNER", "REPO") cs, cmdTeardown := test.InitCmdStubber() @@ -847,15 +904,15 @@ func TestPRCreate_defaults_error_autofill(t *testing.T) { cs.Stub("") // git status cs.Stub("") // git log - _, err := RunCommand("pr create -f") + _, err := runCommand(http, nil, "feature", true, "-f") eq(t, err.Error(), "could not compute title or body defaults: could not find any commits between origin/master and feature") } func TestPRCreate_defaults_error_web(t *testing.T) { - initBlankContext("", "OWNER/REPO", "feature") - defer stubTerminal(true)() http := initFakeHTTP() + defer http.Verify(t) + http.StubRepoResponse("OWNER", "REPO") cs, cmdTeardown := test.InitCmdStubber() @@ -866,15 +923,15 @@ func TestPRCreate_defaults_error_web(t *testing.T) { cs.Stub("") // git status cs.Stub("") // git log - _, err := RunCommand("pr create -w") + _, err := runCommand(http, nil, "feature", true, "-w") eq(t, err.Error(), "could not compute title or body defaults: could not find any commits between origin/master and feature") } func TestPRCreate_defaults_error_interactive(t *testing.T) { - initBlankContext("", "OWNER/REPO", "feature") - defer stubTerminal(true)() http := initFakeHTTP() + defer http.Verify(t) + http.StubRepoResponse("OWNER", "REPO") http.StubResponse(200, bytes.NewBufferString(` { "data": { "repository": { "forks": { "nodes": [ @@ -917,8 +974,8 @@ func TestPRCreate_defaults_error_interactive(t *testing.T) { }, }) - output, err := RunCommand(`pr create`) - eq(t, err, nil) + output, err := runCommand(http, nil, "feature", true, ``) + require.NoError(t, err) stderr := string(output.Stderr()) eq(t, strings.Contains(stderr, "warning: could not compute title or body defaults: could not find any commits"), true) diff --git a/pkg/cmd/pr/diff/diff.go b/pkg/cmd/pr/diff/diff.go new file mode 100644 index 000000000..3d9d32083 --- /dev/null +++ b/pkg/cmd/pr/diff/diff.go @@ -0,0 +1,136 @@ +package diff + +import ( + "bufio" + "fmt" + "io" + "net/http" + "strings" + + "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/pkg/iostreams" + "github.com/spf13/cobra" +) + +type DiffOptions struct { + HttpClient func() (*http.Client, error) + IO *iostreams.IOStreams + BaseRepo func() (ghrepo.Interface, error) + Remotes func() (context.Remotes, error) + Branch func() (string, error) + + SelectorArg string + UseColor string +} + +func NewCmdDiff(f *cmdutil.Factory, runF func(*DiffOptions) error) *cobra.Command { + opts := &DiffOptions{ + IO: f.IOStreams, + HttpClient: f.HttpClient, + Remotes: f.Remotes, + Branch: f.Branch, + } + + cmd := &cobra.Command{ + Use: "diff [ | | ]", + Short: "View changes in a pull request", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + // support `-R, --repo` override + opts.BaseRepo = f.BaseRepo + + if len(args) > 0 { + opts.SelectorArg = args[0] + } + + if !validColorFlag(opts.UseColor) { + return &cmdutil.FlagError{Err: fmt.Errorf("did not understand color: %q. Expected one of always, never, or auto", opts.UseColor)} + } + + if opts.UseColor == "auto" && !opts.IO.IsStdoutTTY() { + opts.UseColor = "never" + } + + if runF != nil { + return runF(opts) + } + return diffRun(opts) + }, + } + + cmd.Flags().StringVar(&opts.UseColor, "color", "auto", "Use color in diff output: {always|never|auto}") + + return cmd +} + +func diffRun(opts *DiffOptions) error { + httpClient, err := opts.HttpClient() + if err != nil { + return err + } + apiClient := api.NewClientFromHTTP(httpClient) + + pr, baseRepo, err := shared.PRFromArgs(apiClient, opts.BaseRepo, opts.Branch, opts.Remotes, opts.SelectorArg) + if err != nil { + return err + } + + diff, err := apiClient.PullRequestDiff(baseRepo, pr.Number) + if err != nil { + return fmt.Errorf("could not find pull request diff: %w", err) + } + defer diff.Close() + + if opts.UseColor == "never" { + _, err = io.Copy(opts.IO.Out, diff) + return err + } + + diffLines := bufio.NewScanner(diff) + for diffLines.Scan() { + diffLine := diffLines.Text() + switch { + case isHeaderLine(diffLine): + fmt.Fprintf(opts.IO.Out, "\x1b[1;38m%s\x1b[m\n", diffLine) + case isAdditionLine(diffLine): + fmt.Fprintf(opts.IO.Out, "\x1b[32m%s\x1b[m\n", diffLine) + case isRemovalLine(diffLine): + fmt.Fprintf(opts.IO.Out, "\x1b[31m%s\x1b[m\n", diffLine) + default: + fmt.Fprintln(opts.IO.Out, diffLine) + } + } + + if err := diffLines.Err(); err != nil { + return fmt.Errorf("error reading pull request diff: %w", err) + } + + return nil +} + +var diffHeaderPrefixes = []string{"+++", "---", "diff", "index"} + +func isHeaderLine(dl string) bool { + for _, p := range diffHeaderPrefixes { + if strings.HasPrefix(dl, p) { + return true + } + } + return false +} + +func isAdditionLine(dl string) bool { + return strings.HasPrefix(dl, "+") +} + +func isRemovalLine(dl string) bool { + return strings.HasPrefix(dl, "-") +} + +func validColorFlag(c string) bool { + return c == "auto" || c == "always" || c == "never" +} diff --git a/pkg/cmd/pr/diff/diff_test.go b/pkg/cmd/pr/diff/diff_test.go new file mode 100644 index 000000000..85c74cb7f --- /dev/null +++ b/pkg/cmd/pr/diff/diff_test.go @@ -0,0 +1,183 @@ +package diff + +import ( + "bytes" + "io/ioutil" + "net/http" + "testing" + + "github.com/cli/cli/context" + "github.com/cli/cli/git" + "github.com/cli/cli/internal/config" + "github.com/cli/cli/internal/ghrepo" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/httpmock" + "github.com/cli/cli/pkg/iostreams" + "github.com/cli/cli/test" + "github.com/google/go-cmp/cmp" + "github.com/google/shlex" + "github.com/stretchr/testify/assert" +) + +func runCommand(rt http.RoundTripper, remotes context.Remotes, isTTY bool, cli string) (*test.CmdOut, error) { + io, _, stdout, stderr := iostreams.Test() + io.SetStdoutTTY(isTTY) + io.SetStdinTTY(isTTY) + io.SetStderrTTY(isTTY) + + factory := &cmdutil.Factory{ + IOStreams: io, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: rt}, nil + }, + Config: func() (config.Config, error) { + return config.NewBlankConfig(), nil + }, + BaseRepo: func() (ghrepo.Interface, error) { + return ghrepo.New("OWNER", "REPO"), nil + }, + Remotes: func() (context.Remotes, error) { + if remotes == nil { + return context.Remotes{ + { + Remote: &git.Remote{Name: "origin"}, + Repo: ghrepo.New("OWNER", "REPO"), + }, + }, nil + } + + return remotes, nil + }, + Branch: func() (string, error) { + return "feature", nil + }, + } + + cmd := NewCmdDiff(factory, nil) + + argv, err := shlex.Split(cli) + if err != nil { + return nil, err + } + cmd.SetArgs(argv) + + cmd.SetIn(&bytes.Buffer{}) + cmd.SetOut(ioutil.Discard) + cmd.SetErr(ioutil.Discard) + + _, err = cmd.ExecuteC() + return &test.CmdOut{ + OutBuf: stdout, + ErrBuf: stderr, + }, err +} + +func TestPRDiff_validation(t *testing.T) { + _, err := runCommand(nil, nil, false, "--color=doublerainbow") + if err == nil { + t.Fatal("expected error") + } + assert.Equal(t, `did not understand color: "doublerainbow". Expected one of always, never, or auto`, err.Error()) +} + +func TestPRDiff_no_current_pr(t *testing.T) { + http := &httpmock.Registry{} + defer http.Verify(t) + http.StubResponse(200, bytes.NewBufferString(` + { "data": { "repository": { "pullRequests": { "nodes": [] } } } } + `)) + _, err := runCommand(http, nil, false, "") + if err == nil { + t.Fatal("expected error") + } + assert.Equal(t, `no open pull requests found for branch "feature"`, err.Error()) +} + +func TestPRDiff_argument_not_found(t *testing.T) { + http := &httpmock.Registry{} + defer http.Verify(t) + http.StubResponse(200, bytes.NewBufferString(` + { "data": { "repository": { + "pullRequest": { "number": 123 } + } } } +`)) + http.StubResponse(404, bytes.NewBufferString("")) + _, 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()) +} + +func TestPRDiff_notty(t *testing.T) { + http := &httpmock.Registry{} + defer http.Verify(t) + http.StubResponse(200, bytes.NewBufferString(` + { "data": { "repository": { "pullRequests": { "nodes": [ + { "url": "https://github.com/OWNER/REPO/pull/123", + "number": 123, + "id": "foobar123", + "headRefName": "feature", + "baseRefName": "master" } + ] } } } }`)) + http.StubResponse(200, bytes.NewBufferString(testDiff)) + output, err := runCommand(http, nil, false, "") + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + if diff := cmp.Diff(testDiff, output.String()); diff != "" { + t.Errorf("command output did not match:\n%s", diff) + } +} + +func TestPRDiff_tty(t *testing.T) { + http := &httpmock.Registry{} + defer http.Verify(t) + http.StubResponse(200, bytes.NewBufferString(` + { "data": { "repository": { "pullRequests": { "nodes": [ + { "url": "https://github.com/OWNER/REPO/pull/123", + "number": 123, + "id": "foobar123", + "headRefName": "feature", + "baseRefName": "master" } + ] } } } }`)) + http.StubResponse(200, bytes.NewBufferString(testDiff)) + output, err := runCommand(http, nil, true, "") + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + assert.Contains(t, output.String(), "\x1b[32m+site: bin/gh\x1b[m") +} + +const testDiff = `diff --git a/.github/workflows/releases.yml b/.github/workflows/releases.yml +index 73974448..b7fc0154 100644 +--- a/.github/workflows/releases.yml ++++ b/.github/workflows/releases.yml +@@ -44,6 +44,11 @@ jobs: + token: ${{secrets.SITE_GITHUB_TOKEN}} + - name: Publish documentation site + if: "!contains(github.ref, '-')" # skip prereleases ++ env: ++ GIT_COMMITTER_NAME: cli automation ++ GIT_AUTHOR_NAME: cli automation ++ GIT_COMMITTER_EMAIL: noreply@github.com ++ GIT_AUTHOR_EMAIL: noreply@github.com + run: make site-publish + - name: Move project cards + if: "!contains(github.ref, '-')" # skip prereleases +diff --git a/Makefile b/Makefile +index f2b4805c..3d7bd0f9 100644 +--- a/Makefile ++++ b/Makefile +@@ -22,8 +22,8 @@ test: + go test ./... + .PHONY: test + +-site: +- git clone https://github.com/github/cli.github.com.git "$@" ++site: bin/gh ++ bin/gh repo clone github/cli.github.com "$@" + + site-docs: site + git -C site pull +` diff --git a/test/fixtures/prList.json b/pkg/cmd/pr/list/fixtures/prList.json similarity index 100% rename from test/fixtures/prList.json rename to pkg/cmd/pr/list/fixtures/prList.json diff --git a/test/fixtures/prListWithDuplicates.json b/pkg/cmd/pr/list/fixtures/prListWithDuplicates.json similarity index 100% rename from test/fixtures/prListWithDuplicates.json rename to pkg/cmd/pr/list/fixtures/prListWithDuplicates.json diff --git a/pkg/cmd/pr/list/list.go b/pkg/cmd/pr/list/list.go new file mode 100644 index 000000000..fd1ebb2b9 --- /dev/null +++ b/pkg/cmd/pr/list/list.go @@ -0,0 +1,170 @@ +package list + +import ( + "fmt" + "net/http" + "strconv" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/api" + "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/iostreams" + "github.com/cli/cli/pkg/text" + "github.com/cli/cli/utils" + "github.com/spf13/cobra" +) + +type ListOptions struct { + HttpClient func() (*http.Client, error) + IO *iostreams.IOStreams + BaseRepo func() (ghrepo.Interface, error) + + WebMode bool + LimitResults int + State string + BaseBranch string + Labels []string + Assignee string +} + +func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Command { + opts := &ListOptions{ + IO: f.IOStreams, + HttpClient: f.HttpClient, + } + + cmd := &cobra.Command{ + Use: "list", + Short: "List and filter pull requests in this repository", + Example: heredoc.Doc(` + $ gh pr list --limit 999 + $ gh pr list --state closed + $ gh pr list --label "priority 1" --label "bug" + $ gh pr list --web + `), + Args: cmdutil.NoArgsQuoteReminder, + RunE: func(cmd *cobra.Command, args []string) error { + // support `-R, --repo` override + opts.BaseRepo = f.BaseRepo + + if opts.LimitResults < 1 { + return &cmdutil.FlagError{Err: fmt.Errorf("invalid value for --limit: %v", opts.LimitResults)} + } + + if runF != nil { + return runF(opts) + } + return listRun(opts) + }, + } + + cmd.Flags().BoolVarP(&opts.WebMode, "web", "w", false, "Open the browser to list the pull requests") + cmd.Flags().IntVarP(&opts.LimitResults, "limit", "L", 30, "Maximum number of items to fetch") + cmd.Flags().StringVarP(&opts.State, "state", "s", "open", "Filter by state: {open|closed|merged|all}") + cmd.Flags().StringVarP(&opts.BaseBranch, "base", "B", "", "Filter by base branch") + cmd.Flags().StringSliceVarP(&opts.Labels, "label", "l", nil, "Filter by labels") + cmd.Flags().StringVarP(&opts.Assignee, "assignee", "a", "", "Filter by assignee") + + return cmd +} + +func listRun(opts *ListOptions) error { + httpClient, err := opts.HttpClient() + if err != nil { + return err + } + apiClient := api.NewClientFromHTTP(httpClient) + + baseRepo, err := opts.BaseRepo() + if err != nil { + return err + } + + if opts.WebMode { + prListURL := ghrepo.GenerateRepoURL(baseRepo, "pulls") + openURL, err := shared.ListURLWithQuery(prListURL, shared.FilterOptions{ + Entity: "pr", + State: opts.State, + Assignee: opts.Assignee, + Labels: opts.Labels, + BaseBranch: opts.BaseBranch, + }) + if err != nil { + return err + } + + if opts.IO.IsStdoutTTY() { + fmt.Fprintf(opts.IO.ErrOut, "Opening %s in your browser.\n", utils.DisplayURL(openURL)) + } + return utils.OpenInBrowser(openURL) + } + + var graphqlState []string + switch opts.State { + case "open": + graphqlState = []string{"OPEN"} + case "closed": + graphqlState = []string{"CLOSED", "MERGED"} + case "merged": + graphqlState = []string{"MERGED"} + case "all": + graphqlState = []string{"OPEN", "CLOSED", "MERGED"} + default: + return fmt.Errorf("invalid state: %s", opts.State) + } + + params := map[string]interface{}{ + "state": graphqlState, + } + if len(opts.Labels) > 0 { + params["labels"] = opts.Labels + } + if opts.BaseBranch != "" { + params["baseBranch"] = opts.BaseBranch + } + if opts.Assignee != "" { + params["assignee"] = opts.Assignee + } + + listResult, err := api.PullRequestList(apiClient, baseRepo, params, opts.LimitResults) + if err != nil { + return err + } + + if opts.IO.IsStdoutTTY() { + hasFilters := opts.State != "open" || len(opts.Labels) > 0 || opts.BaseBranch != "" || opts.Assignee != "" + title := shared.ListHeader(ghrepo.FullName(baseRepo), "pull request", len(listResult.PullRequests), listResult.TotalCount, hasFilters) + fmt.Fprintf(opts.IO.ErrOut, "\n%s\n\n", title) + } + + table := utils.NewTablePrinter(opts.IO) + for _, pr := range listResult.PullRequests { + prNum := strconv.Itoa(pr.Number) + if table.IsTTY() { + prNum = "#" + prNum + } + table.AddField(prNum, nil, shared.ColorFuncForPR(pr)) + table.AddField(text.ReplaceExcessiveWhitespace(pr.Title), nil, nil) + table.AddField(pr.HeadLabel(), nil, utils.Cyan) + if !table.IsTTY() { + table.AddField(prStateWithDraft(&pr), nil, nil) + } + table.EndRow() + } + err = table.Render() + if err != nil { + return err + } + + return nil +} + +func prStateWithDraft(pr *api.PullRequest) string { + if pr.IsDraft && pr.State == "OPEN" { + return "DRAFT" + } + + return pr.State +} diff --git a/pkg/cmd/pr/list/list_test.go b/pkg/cmd/pr/list/list_test.go new file mode 100644 index 000000000..5faf42b57 --- /dev/null +++ b/pkg/cmd/pr/list/list_test.go @@ -0,0 +1,246 @@ +package list + +import ( + "bytes" + "io/ioutil" + "net/http" + "os/exec" + "reflect" + "regexp" + "strings" + "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" +) + +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) + io.SetStdinTTY(isTTY) + io.SetStderrTTY(isTTY) + + factory := &cmdutil.Factory{ + IOStreams: io, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: rt}, nil + }, + BaseRepo: func() (ghrepo.Interface, error) { + return ghrepo.New("OWNER", "REPO"), nil + }, + } + + cmd := NewCmdList(factory, nil) + + argv, err := shlex.Split(cli) + if err != nil { + return nil, err + } + cmd.SetArgs(argv) + + cmd.SetIn(&bytes.Buffer{}) + cmd.SetOut(ioutil.Discard) + cmd.SetErr(ioutil.Discard) + + _, err = cmd.ExecuteC() + return &test.CmdOut{ + OutBuf: stdout, + ErrBuf: stderr, + }, err +} + +func initFakeHTTP() *httpmock.Registry { + return &httpmock.Registry{} +} + +func TestPRList(t *testing.T) { + http := initFakeHTTP() + defer http.Verify(t) + + http.Register(httpmock.GraphQL(`query PullRequestList\b`), httpmock.FileResponse("./fixtures/prList.json")) + + output, err := runCommand(http, true, "") + if err != nil { + t.Fatal(err) + } + + assert.Equal(t, ` +Showing 3 of 3 open pull requests in OWNER/REPO + +`, output.Stderr()) + + lines := strings.Split(output.String(), "\n") + res := []*regexp.Regexp{ + regexp.MustCompile(`#32.*New feature.*feature`), + regexp.MustCompile(`#29.*Fixed bad bug.*hubot:bug-fix`), + regexp.MustCompile(`#28.*Improve documentation.*docs`), + } + + for i, r := range res { + if !r.MatchString(lines[i]) { + t.Errorf("%s did not match %s", lines[i], r) + } + } +} + +func TestPRList_nontty(t *testing.T) { + http := initFakeHTTP() + defer http.Verify(t) + + http.Register(httpmock.GraphQL(`query PullRequestList\b`), httpmock.FileResponse("./fixtures/prList.json")) + + output, err := runCommand(http, false, "") + if err != nil { + t.Fatal(err) + } + + assert.Equal(t, "", output.Stderr()) + + assert.Equal(t, `32 New feature feature DRAFT +29 Fixed bad bug hubot:bug-fix OPEN +28 Improve documentation docs MERGED +`, output.String()) +} + +func TestPRList_filtering(t *testing.T) { + http := initFakeHTTP() + defer http.Verify(t) + + http.Register( + httpmock.GraphQL(`query PullRequestList\b`), + httpmock.GraphQLQuery(`{}`, func(_ string, params map[string]interface{}) { + assert.Equal(t, []interface{}{"OPEN", "CLOSED", "MERGED"}, params["state"].([]interface{})) + assert.Equal(t, []interface{}{"one", "two", "three"}, params["labels"].([]interface{})) + })) + + output, err := runCommand(http, true, `-s all -l one,two -l three`) + if err != nil { + t.Fatal(err) + } + + eq(t, output.String(), "") + eq(t, output.Stderr(), ` +No pull requests match your search in OWNER/REPO + +`) +} + +func TestPRList_filteringRemoveDuplicate(t *testing.T) { + http := initFakeHTTP() + defer http.Verify(t) + + http.Register( + httpmock.GraphQL(`query PullRequestList\b`), + httpmock.FileResponse("./fixtures/prListWithDuplicates.json")) + + output, err := runCommand(http, true, "-l one,two") + if err != nil { + t.Fatal(err) + } + + lines := strings.Split(output.String(), "\n") + + res := []*regexp.Regexp{ + regexp.MustCompile(`#32.*New feature.*feature`), + regexp.MustCompile(`#29.*Fixed bad bug.*hubot:bug-fix`), + regexp.MustCompile(`#28.*Improve documentation.*docs`), + } + + for i, r := range res { + if !r.MatchString(lines[i]) { + t.Errorf("%s did not match %s", lines[i], r) + } + } +} + +func TestPRList_filteringClosed(t *testing.T) { + http := initFakeHTTP() + defer http.Verify(t) + + http.Register( + httpmock.GraphQL(`query PullRequestList\b`), + httpmock.GraphQLQuery(`{}`, func(_ string, params map[string]interface{}) { + assert.Equal(t, []interface{}{"CLOSED", "MERGED"}, params["state"].([]interface{})) + })) + + _, err := runCommand(http, true, `-s closed`) + if err != nil { + t.Fatal(err) + } +} + +func TestPRList_filteringAssignee(t *testing.T) { + http := initFakeHTTP() + defer http.Verify(t) + + http.Register( + httpmock.GraphQL(`query PullRequestList\b`), + httpmock.GraphQLQuery(`{}`, func(_ string, params map[string]interface{}) { + assert.Equal(t, `repo:OWNER/REPO assignee:hubot is:pr sort:created-desc is:merged label:"needs tests" base:"develop"`, params["q"].(string)) + })) + + _, err := runCommand(http, true, `-s merged -l "needs tests" -a hubot -B develop`) + if err != nil { + t.Fatal(err) + } +} + +func TestPRList_filteringAssigneeLabels(t *testing.T) { + http := initFakeHTTP() + defer http.Verify(t) + + _, err := runCommand(http, true, `-l one,two -a hubot`) + if err == nil && err.Error() != "multiple labels with --assignee are not supported" { + t.Fatal(err) + } +} + +func TestPRList_withInvalidLimitFlag(t *testing.T) { + http := initFakeHTTP() + defer http.Verify(t) + + _, err := runCommand(http, true, `--limit=0`) + if err == nil && err.Error() != "invalid limit: 0" { + t.Errorf("error running command `issue list`: %v", err) + } +} + +func TestPRList_web(t *testing.T) { + http := initFakeHTTP() + defer http.Verify(t) + + var seenCmd *exec.Cmd + restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable { + seenCmd = cmd + return &test.OutputStub{} + }) + defer restoreCmd() + + output, err := runCommand(http, true, "--web -a peter -l bug -l docs -L 10 -s merged -B trunk") + if err != nil { + t.Errorf("error running command `pr list` with `--web` flag: %v", err) + } + + 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") + + if seenCmd == nil { + t.Fatal("expected a command to run") + } + url := seenCmd.Args[len(seenCmd.Args)-1] + eq(t, url, expectedURL) +} diff --git a/pkg/cmd/pr/merge/merge.go b/pkg/cmd/pr/merge/merge.go new file mode 100644 index 000000000..b3c20b208 --- /dev/null +++ b/pkg/cmd/pr/merge/merge.go @@ -0,0 +1,274 @@ +package merge + +import ( + "errors" + "fmt" + "net/http" + + "github.com/AlecAivazis/survey/v2" + "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" + "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/iostreams" + "github.com/cli/cli/pkg/prompt" + "github.com/cli/cli/utils" + "github.com/spf13/cobra" +) + +type MergeOptions struct { + HttpClient func() (*http.Client, error) + Config func() (config.Config, error) + IO *iostreams.IOStreams + BaseRepo func() (ghrepo.Interface, error) + Remotes func() (context.Remotes, error) + Branch func() (string, error) + + SelectorArg string + DeleteBranch bool + DeleteLocalBranch bool + MergeMethod api.PullRequestMergeMethod + InteractiveMode bool +} + +func NewCmdMerge(f *cmdutil.Factory, runF func(*MergeOptions) error) *cobra.Command { + opts := &MergeOptions{ + IO: f.IOStreams, + HttpClient: f.HttpClient, + Config: f.Config, + Remotes: f.Remotes, + Branch: f.Branch, + } + + var ( + flagMerge bool + flagSquash bool + flagRebase bool + ) + + cmd := &cobra.Command{ + Use: "merge [ | | ]", + Short: "Merge a pull request", + Long: heredoc.Doc(` + Merge a pull request on GitHub. + + By default, the head branch of the pull request will get deleted on both remote and local repositories. + To retain the branch, use '--delete-branch=false'. + `), + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + // support `-R, --repo` override + opts.BaseRepo = f.BaseRepo + + if len(args) > 0 { + opts.SelectorArg = args[0] + } + + methodFlags := 0 + if flagMerge { + opts.MergeMethod = api.PullRequestMergeMethodMerge + methodFlags++ + } + if flagRebase { + opts.MergeMethod = api.PullRequestMergeMethodRebase + methodFlags++ + } + if flagSquash { + opts.MergeMethod = api.PullRequestMergeMethodSquash + methodFlags++ + } + if methodFlags == 0 { + if !opts.IO.IsStdoutTTY() || !opts.IO.IsStdinTTY() { + return &cmdutil.FlagError{Err: errors.New("--merge, --rebase, or --squash required when not attached to a terminal")} + } + opts.InteractiveMode = true + } else if methodFlags > 1 { + return &cmdutil.FlagError{Err: errors.New("only one of --merge, --rebase, or --squash can be enabled")} + } + + opts.DeleteLocalBranch = !cmd.Flags().Changed("repo") + + if runF != nil { + return runF(opts) + } + return mergeRun(opts) + }, + } + + cmd.Flags().BoolVarP(&opts.DeleteBranch, "delete-branch", "d", true, "Delete the local and remote branch after merge") + cmd.Flags().BoolVarP(&flagMerge, "merge", "m", false, "Merge the commits with the base branch") + cmd.Flags().BoolVarP(&flagRebase, "rebase", "r", false, "Rebase the commits onto the base branch") + cmd.Flags().BoolVarP(&flagSquash, "squash", "s", false, "Squash the commits into one commit and merge it into the base branch") + + return cmd +} + +func mergeRun(opts *MergeOptions) error { + httpClient, err := opts.HttpClient() + if err != nil { + return err + } + apiClient := api.NewClientFromHTTP(httpClient) + + pr, baseRepo, err := shared.PRFromArgs(apiClient, opts.BaseRepo, opts.Branch, opts.Remotes, opts.SelectorArg) + if err != nil { + return err + } + + if pr.Mergeable == "CONFLICTING" { + err := fmt.Errorf("%s Pull request #%d (%s) has conflicts and isn't mergeable ", utils.Red("!"), pr.Number, pr.Title) + return err + } else if pr.Mergeable == "UNKNOWN" { + err := fmt.Errorf("%s Pull request #%d (%s) can't be merged right now; try again in a few seconds", utils.Red("!"), pr.Number, pr.Title) + return err + } else if pr.State == "MERGED" { + err := fmt.Errorf("%s Pull request #%d (%s) was already merged", utils.Red("!"), pr.Number, pr.Title) + return err + } + + mergeMethod := opts.MergeMethod + deleteBranch := opts.DeleteBranch + crossRepoPR := pr.HeadRepositoryOwner.Login != baseRepo.RepoOwner() + + if opts.InteractiveMode { + mergeMethod, deleteBranch, err = prInteractiveMerge(opts.DeleteLocalBranch, crossRepoPR) + if err != nil { + return nil + } + } + + var action string + if mergeMethod == api.PullRequestMergeMethodRebase { + action = "Rebased and merged" + err = api.PullRequestMerge(apiClient, baseRepo, pr, api.PullRequestMergeMethodRebase) + } else if mergeMethod == api.PullRequestMergeMethodSquash { + action = "Squashed and merged" + err = api.PullRequestMerge(apiClient, baseRepo, pr, api.PullRequestMergeMethodSquash) + } else if mergeMethod == api.PullRequestMergeMethodMerge { + action = "Merged" + err = api.PullRequestMerge(apiClient, baseRepo, pr, api.PullRequestMergeMethodMerge) + } else { + err = fmt.Errorf("unknown merge method (%d) used", mergeMethod) + return err + } + + if err != nil { + return fmt.Errorf("API call failed: %w", err) + } + + isTerminal := opts.IO.IsStdoutTTY() + + if isTerminal { + fmt.Fprintf(opts.IO.ErrOut, "%s %s pull request #%d (%s)\n", utils.Magenta("✔"), action, pr.Number, pr.Title) + } + + if deleteBranch { + branchSwitchString := "" + + if opts.DeleteLocalBranch && !crossRepoPR { + currentBranch, err := opts.Branch() + if err != nil { + return err + } + + var branchToSwitchTo string + if currentBranch == pr.HeadRefName { + branchToSwitchTo, err = api.RepoDefaultBranch(apiClient, baseRepo) + if err != nil { + return err + } + err = git.CheckoutBranch(branchToSwitchTo) + if err != nil { + return err + } + } + + localBranchExists := git.HasLocalBranch(pr.HeadRefName) + if localBranchExists { + err = git.DeleteLocalBranch(pr.HeadRefName) + if err != nil { + err = fmt.Errorf("failed to delete local branch %s: %w", utils.Cyan(pr.HeadRefName), err) + return err + } + } + + if branchToSwitchTo != "" { + branchSwitchString = fmt.Sprintf(" and switched to branch %s", utils.Cyan(branchToSwitchTo)) + } + } + + if !crossRepoPR { + err = api.BranchDeleteRemote(apiClient, baseRepo, pr.HeadRefName) + var httpErr api.HTTPError + // The ref might have already been deleted by GitHub + if err != nil && (!errors.As(err, &httpErr) || httpErr.StatusCode != 422) { + err = fmt.Errorf("failed to delete remote branch %s: %w", utils.Cyan(pr.HeadRefName), err) + return err + } + } + + if isTerminal { + fmt.Fprintf(opts.IO.ErrOut, "%s Deleted branch %s%s\n", utils.Red("✔"), utils.Cyan(pr.HeadRefName), branchSwitchString) + } + } + + return nil +} + +func prInteractiveMerge(deleteLocalBranch bool, 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", + }, + } + + qs := []*survey.Question{mergeMethodQuestion} + + if !crossRepoPR { + var message string + if deleteLocalBranch { + message = "Delete the branch locally and on GitHub?" + } else { + message = "Delete the branch on GitHub?" + } + + deleteBranchQuestion := &survey.Question{ + Name: "deleteBranch", + Prompt: &survey.Confirm{ + Message: message, + Default: true, + }, + } + qs = append(qs, deleteBranchQuestion) + } + + answers := struct { + MergeMethod int + DeleteBranch bool + }{} + + err := prompt.SurveyAsk(qs, &answers) + if err != nil { + return 0, false, fmt.Errorf("could not prompt: %w", err) + } + + var mergeMethod api.PullRequestMergeMethod + switch answers.MergeMethod { + case 0: + mergeMethod = api.PullRequestMergeMethodMerge + case 1: + mergeMethod = api.PullRequestMergeMethodRebase + case 2: + mergeMethod = api.PullRequestMergeMethodSquash + } + + deleteBranch := answers.DeleteBranch + return mergeMethod, deleteBranch, nil +} diff --git a/pkg/cmd/pr/merge/merge_test.go b/pkg/cmd/pr/merge/merge_test.go new file mode 100644 index 000000000..b479e36a9 --- /dev/null +++ b/pkg/cmd/pr/merge/merge_test.go @@ -0,0 +1,505 @@ +package merge + +import ( + "bytes" + "io/ioutil" + "net/http" + "regexp" + "strings" + "testing" + + "github.com/cli/cli/api" + "github.com/cli/cli/context" + "github.com/cli/cli/git" + "github.com/cli/cli/internal/config" + "github.com/cli/cli/internal/ghrepo" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/httpmock" + "github.com/cli/cli/pkg/iostreams" + "github.com/cli/cli/pkg/prompt" + "github.com/cli/cli/test" + "github.com/google/shlex" + "github.com/stretchr/testify/assert" +) + +func runCommand(rt http.RoundTripper, branch string, isTTY bool, cli string) (*test.CmdOut, error) { + io, _, stdout, stderr := iostreams.Test() + io.SetStdoutTTY(isTTY) + io.SetStdinTTY(isTTY) + io.SetStderrTTY(isTTY) + + factory := &cmdutil.Factory{ + IOStreams: io, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: rt}, nil + }, + Config: func() (config.Config, error) { + return config.NewBlankConfig(), nil + }, + BaseRepo: func() (ghrepo.Interface, error) { + return api.InitRepoHostname(&api.Repository{ + Name: "REPO", + Owner: api.RepositoryOwner{Login: "OWNER"}, + DefaultBranchRef: api.BranchRef{Name: "master"}, + }, "github.com"), nil + }, + Remotes: func() (context.Remotes, error) { + return context.Remotes{ + { + Remote: &git.Remote{Name: "origin"}, + Repo: ghrepo.New("OWNER", "REPO"), + }, + }, nil + }, + Branch: func() (string, error) { + return branch, nil + }, + } + + cmd := NewCmdMerge(factory, nil) + cmd.PersistentFlags().StringP("repo", "R", "", "") + + cli = strings.TrimPrefix(cli, "pr merge") + argv, err := shlex.Split(cli) + if err != nil { + return nil, err + } + cmd.SetArgs(argv) + + cmd.SetIn(&bytes.Buffer{}) + cmd.SetOut(ioutil.Discard) + cmd.SetErr(ioutil.Discard) + + _, err = cmd.ExecuteC() + return &test.CmdOut{ + OutBuf: stdout, + ErrBuf: stderr, + }, err +} + +func initFakeHTTP() *httpmock.Registry { + return &httpmock.Registry{} +} + +func TestPrMerge(t *testing.T) { + http := initFakeHTTP() + defer http.Verify(t) + http.Register( + httpmock.GraphQL(`query PullRequestByNumber\b`), + httpmock.StringResponse(` + { "data": { "repository": { "pullRequest": { + "id": "THE-ID", + "number": 1, + "title": "The title of the PR", + "state": "OPEN", + "headRefName": "blueberries", + "headRepositoryOwner": {"login": "OWNER"} + } } } }`)) + http.Register( + httpmock.GraphQL(`mutation PullRequestMerge\b`), + httpmock.GraphQLMutation(`{}`, func(input map[string]interface{}) { + assert.Equal(t, "THE-ID", input["pullRequestId"].(string)) + assert.Equal(t, "MERGE", input["mergeMethod"].(string)) + })) + http.Register( + httpmock.REST("DELETE", "repos/OWNER/REPO/git/refs/heads/blueberries"), + httpmock.StringResponse(`{}`)) + + 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("") + + output, err := runCommand(http, "master", true, "pr merge 1 --merge") + if err != nil { + t.Fatalf("error running command `pr merge`: %v", err) + } + + r := regexp.MustCompile(`Merged pull request #1 \(The title of the PR\)`) + + if !r.MatchString(output.Stderr()) { + t.Fatalf("output did not match regexp /%s/\n> output\n%q\n", r, output.Stderr()) + } +} + +func TestPrMerge_nontty(t *testing.T) { + http := initFakeHTTP() + defer http.Verify(t) + http.Register( + httpmock.GraphQL(`query PullRequestByNumber\b`), + httpmock.StringResponse(` + { "data": { "repository": { "pullRequest": { + "id": "THE-ID", + "number": 1, + "title": "The title of the PR", + "state": "OPEN", + "headRefName": "blueberries", + "headRepositoryOwner": {"login": "OWNER"} + } } } }`)) + http.Register( + httpmock.GraphQL(`mutation PullRequestMerge\b`), + httpmock.GraphQLMutation(`{}`, func(input map[string]interface{}) { + assert.Equal(t, "THE-ID", input["pullRequestId"].(string)) + assert.Equal(t, "MERGE", input["mergeMethod"].(string)) + })) + http.Register( + httpmock.REST("DELETE", "repos/OWNER/REPO/git/refs/heads/blueberries"), + httpmock.StringResponse(`{}`)) + + 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("") + + output, err := runCommand(http, "master", false, "pr merge 1 --merge") + if err != nil { + t.Fatalf("error running command `pr merge`: %v", err) + } + + assert.Equal(t, "", output.String()) + assert.Equal(t, "", output.Stderr()) +} + +func TestPrMerge_nontty_insufficient_flags(t *testing.T) { + output, err := runCommand(nil, "master", false, "pr merge 1") + if err == nil { + t.Fatal("expected error") + } + + assert.Equal(t, "--merge, --rebase, or --squash required when not attached to a terminal", err.Error()) + assert.Equal(t, "", output.String()) +} + +func TestPrMerge_withRepoFlag(t *testing.T) { + http := initFakeHTTP() + defer http.Verify(t) + http.Register( + httpmock.GraphQL(`query PullRequestByNumber\b`), + httpmock.StringResponse(` + { "data": { "repository": { "pullRequest": { + "id": "THE-ID", + "number": 1, + "title": "The title of the PR", + "state": "OPEN", + "headRefName": "blueberries", + "headRepositoryOwner": {"login": "OWNER"} + } } } }`)) + http.Register( + httpmock.GraphQL(`mutation PullRequestMerge\b`), + httpmock.GraphQLMutation(`{}`, func(input map[string]interface{}) { + assert.Equal(t, "THE-ID", input["pullRequestId"].(string)) + assert.Equal(t, "MERGE", input["mergeMethod"].(string)) + })) + http.Register( + httpmock.REST("DELETE", "repos/OWNER/REPO/git/refs/heads/blueberries"), + httpmock.StringResponse(`{}`)) + + cs, cmdTeardown := test.InitCmdStubber() + defer cmdTeardown() + + 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()) { + t.Fatalf("output did not match regexp /%s/\n> output\n%q\n", r, output.Stderr()) + } +} + +func TestPrMerge_deleteBranch(t *testing.T) { + http := initFakeHTTP() + defer http.Verify(t) + http.Register( + httpmock.GraphQL(`query PullRequestForBranch\b`), + // FIXME: references fixture from another package + httpmock.FileResponse("../view/fixtures/prViewPreviewWithMetadataByBranch.json")) + http.Register( + httpmock.GraphQL(`mutation PullRequestMerge\b`), + httpmock.GraphQLMutation(`{}`, func(input map[string]interface{}) { + assert.Equal(t, "PR_10", input["pullRequestId"].(string)) + assert.Equal(t, "MERGE", input["mergeMethod"].(string)) + })) + http.Register( + httpmock.REST("DELETE", "repos/OWNER/REPO/git/refs/heads/blueberries"), + httpmock.StringResponse(`{}`)) + + cs, cmdTeardown := test.InitCmdStubber() + defer cmdTeardown() + + cs.Stub("") // git config --get-regexp ^branch\.blueberries\.(remote|merge)$ + cs.Stub("") // git checkout master + cs.Stub("") // git rev-parse --verify blueberries` + cs.Stub("") // git branch -d + cs.Stub("") // git push origin --delete blueberries + + output, err := runCommand(http, "blueberries", true, `pr merge --merge --delete-branch`) + if err != nil { + t.Fatalf("Got unexpected error running `pr merge` %s", err) + } + + test.ExpectLines(t, output.Stderr(), `Merged pull request #10 \(Blueberries are a good fruit\)`, `Deleted branch.*blueberries`) +} + +func TestPrMerge_deleteNonCurrentBranch(t *testing.T) { + http := initFakeHTTP() + defer http.Verify(t) + http.Register( + httpmock.GraphQL(`query PullRequestForBranch\b`), + // FIXME: references fixture from another package + httpmock.FileResponse("../view/fixtures/prViewPreviewWithMetadataByBranch.json")) + http.Register( + httpmock.GraphQL(`mutation PullRequestMerge\b`), + httpmock.GraphQLMutation(`{}`, func(input map[string]interface{}) { + assert.Equal(t, "PR_10", input["pullRequestId"].(string)) + assert.Equal(t, "MERGE", input["mergeMethod"].(string)) + })) + http.Register( + httpmock.REST("DELETE", "repos/OWNER/REPO/git/refs/heads/blueberries"), + httpmock.StringResponse(`{}`)) + + cs, cmdTeardown := test.InitCmdStubber() + defer cmdTeardown() + // 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 + + 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) + } + + test.ExpectLines(t, output.Stderr(), `Merged pull request #10 \(Blueberries are a good fruit\)`, `Deleted branch.*blueberries`) +} + +func TestPrMerge_noPrNumberGiven(t *testing.T) { + http := initFakeHTTP() + defer http.Verify(t) + http.Register( + httpmock.GraphQL(`query PullRequestForBranch\b`), + // FIXME: references fixture from another package + httpmock.FileResponse("../view/fixtures/prViewPreviewWithMetadataByBranch.json")) + http.Register( + httpmock.GraphQL(`mutation PullRequestMerge\b`), + httpmock.GraphQLMutation(`{}`, func(input map[string]interface{}) { + assert.Equal(t, "PR_10", input["pullRequestId"].(string)) + assert.Equal(t, "MERGE", input["mergeMethod"].(string)) + })) + http.Register( + httpmock.REST("DELETE", "repos/OWNER/REPO/git/refs/heads/blueberries"), + httpmock.StringResponse(`{}`)) + + 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("") // git branch -d + + output, err := runCommand(http, "blueberries", true, "pr merge --merge") + if err != nil { + t.Fatalf("error running command `pr merge`: %v", err) + } + + r := regexp.MustCompile(`Merged pull request #10 \(Blueberries are a good fruit\)`) + + if !r.MatchString(output.Stderr()) { + t.Fatalf("output did not match regexp /%s/\n> output\n%q\n", r, output.Stderr()) + } +} + +func TestPrMerge_rebase(t *testing.T) { + http := initFakeHTTP() + defer http.Verify(t) + http.Register( + httpmock.GraphQL(`query PullRequestByNumber\b`), + httpmock.StringResponse(` + { "data": { "repository": { "pullRequest": { + "id": "THE-ID", + "number": 2, + "title": "The title of the PR", + "state": "OPEN", + "headRefName": "blueberries", + "headRepositoryOwner": {"login": "OWNER"} + } } } }`)) + http.Register( + httpmock.GraphQL(`mutation PullRequestMerge\b`), + httpmock.GraphQLMutation(`{}`, func(input map[string]interface{}) { + assert.Equal(t, "THE-ID", input["pullRequestId"].(string)) + assert.Equal(t, "REBASE", input["mergeMethod"].(string)) + })) + http.Register( + httpmock.REST("DELETE", "repos/OWNER/REPO/git/refs/heads/blueberries"), + httpmock.StringResponse(`{}`)) + + cs, cmdTeardown := test.InitCmdStubber() + defer cmdTeardown() + + cs.Stub("") // git config --get-regexp ^branch\.blueberries\.(remote|merge)$ + cs.Stub("") // git symbolic-ref --quiet --short HEAD + cs.Stub("") // git checkout master + cs.Stub("") // git branch -d + + output, err := runCommand(http, "master", true, "pr merge 2 --rebase") + if err != nil { + t.Fatalf("error running command `pr merge`: %v", err) + } + + r := regexp.MustCompile(`Rebased and merged pull request #2 \(The title of the PR\)`) + + if !r.MatchString(output.Stderr()) { + t.Fatalf("output did not match regexp /%s/\n> output\n%q\n", r, output.Stderr()) + } +} + +func TestPrMerge_squash(t *testing.T) { + http := initFakeHTTP() + defer http.Verify(t) + http.Register( + httpmock.GraphQL(`query PullRequestByNumber\b`), + httpmock.StringResponse(` + { "data": { "repository": { "pullRequest": { + "id": "THE-ID", + "number": 3, + "title": "The title of the PR", + "state": "OPEN", + "headRefName": "blueberries", + "headRepositoryOwner": {"login": "OWNER"} + } } } }`)) + http.Register( + httpmock.GraphQL(`mutation PullRequestMerge\b`), + httpmock.GraphQLMutation(`{}`, func(input map[string]interface{}) { + assert.Equal(t, "THE-ID", input["pullRequestId"].(string)) + assert.Equal(t, "SQUASH", input["mergeMethod"].(string)) + })) + http.Register( + httpmock.REST("DELETE", "repos/OWNER/REPO/git/refs/heads/blueberries"), + httpmock.StringResponse(`{}`)) + + cs, cmdTeardown := test.InitCmdStubber() + defer cmdTeardown() + + cs.Stub("") // git config --get-regexp ^branch\.blueberries\.(remote|merge)$ + cs.Stub("") // git symbolic-ref --quiet --short HEAD + cs.Stub("") // git checkout master + cs.Stub("") // git branch -d + + output, err := runCommand(http, "master", true, "pr merge 3 --squash") + if err != nil { + t.Fatalf("error running command `pr merge`: %v", err) + } + + test.ExpectLines(t, output.Stderr(), "Squashed and merged pull request #3", `Deleted branch.*blueberries`) +} + +func TestPrMerge_alreadyMerged(t *testing.T) { + http := initFakeHTTP() + defer http.Verify(t) + http.Register( + httpmock.GraphQL(`query PullRequestByNumber\b`), + httpmock.StringResponse(` + { "data": { "repository": { + "pullRequest": { "number": 4, "title": "The title of the PR", "state": "MERGED"} + } } }`)) + + 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 + + output, err := runCommand(http, "master", true, "pr merge 4") + if err == nil { + t.Fatalf("expected an error running command `pr merge`: %v", err) + } + + r := regexp.MustCompile(`Pull request #4 \(The title of the PR\) was already merged`) + + if !r.MatchString(err.Error()) { + t.Fatalf("output did not match regexp /%s/\n> output\n%q\n", r, output.Stderr()) + } +} + +func TestPRMerge_interactive(t *testing.T) { + http := initFakeHTTP() + defer http.Verify(t) + http.Register( + httpmock.GraphQL(`query PullRequestForBranch\b`), + httpmock.StringResponse(` + { "data": { "repository": { "pullRequests": { "nodes": [{ + "headRefName": "blueberries", + "headRepositoryOwner": {"login": "OWNER"}, + "id": "THE-ID", + "number": 3 + }] } } } }`)) + http.Register( + httpmock.GraphQL(`mutation PullRequestMerge\b`), + httpmock.GraphQLMutation(`{}`, func(input map[string]interface{}) { + assert.Equal(t, "THE-ID", input["pullRequestId"].(string)) + assert.Equal(t, "MERGE", input["mergeMethod"].(string)) + })) + http.Register( + httpmock.REST("DELETE", "repos/OWNER/REPO/git/refs/heads/blueberries"), + httpmock.StringResponse(`{}`)) + + cs, cmdTeardown := test.InitCmdStubber() + defer cmdTeardown() + + cs.Stub("") // git config --get-regexp ^branch\.blueberries\.(remote|merge)$ + cs.Stub("") // git symbolic-ref --quiet --short HEAD + cs.Stub("") // git checkout master + cs.Stub("") // git push origin --delete blueberries + cs.Stub("") // git branch -d + + as, surveyTeardown := prompt.InitAskStubber() + defer surveyTeardown() + + as.Stub([]*prompt.QuestionStub{ + { + Name: "mergeMethod", + Value: 0, + }, + { + Name: "deleteBranch", + Value: true, + }, + }) + + output, err := runCommand(http, "blueberries", true, "") + if err != nil { + t.Fatalf("Got unexpected error running `pr merge` %s", err) + } + + test.ExpectLines(t, output.Stderr(), "Merged pull request #3", `Deleted branch.*blueberries`) +} + +func TestPrMerge_multipleMergeMethods(t *testing.T) { + _, err := runCommand(nil, "master", true, "1 --merge --squash") + if err == nil { + t.Fatal("expected error running `pr merge` with multiple merge methods") + } +} + +func TestPrMerge_multipleMergeMethods_nontty(t *testing.T) { + _, err := runCommand(nil, "master", false, "1 --merge --squash") + if err == nil { + t.Fatal("expected error running `pr merge` with multiple merge methods") + } +} diff --git a/pkg/cmd/pr/pr.go b/pkg/cmd/pr/pr.go new file mode 100644 index 000000000..599b01758 --- /dev/null +++ b/pkg/cmd/pr/pr.go @@ -0,0 +1,66 @@ +package pr + +import ( + "github.com/cli/cli/internal/ghrepo" + cmdCheckout "github.com/cli/cli/pkg/cmd/pr/checkout" + cmdClose "github.com/cli/cli/pkg/cmd/pr/close" + 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" + cmdMerge "github.com/cli/cli/pkg/cmd/pr/merge" + cmdReady "github.com/cli/cli/pkg/cmd/pr/ready" + cmdReopen "github.com/cli/cli/pkg/cmd/pr/reopen" + cmdReview "github.com/cli/cli/pkg/cmd/pr/review" + cmdStatus "github.com/cli/cli/pkg/cmd/pr/status" + cmdView "github.com/cli/cli/pkg/cmd/pr/view" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/pkg/cmdutil" + "github.com/spf13/cobra" +) + +func NewCmdPR(f *cmdutil.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "pr ", + Short: "Manage pull requests", + Long: "Work with GitHub pull requests", + Example: heredoc.Doc(` + $ gh pr checkout 353 + $ gh pr create --fill + $ gh pr view --web + `), + Annotations: map[string]string{ + "IsCore": "true", + "help:arguments": heredoc.Doc(` + A pull request can be supplied as argument in any of the following formats: + - by number, e.g. "123"; + - by URL, e.g. "https://github.com/OWNER/REPO/pull/123"; or + - by the name of its head branch, e.g. "patch-1" or "OWNER:patch-1". + `), + }, + PersistentPreRun: func(cmd *cobra.Command, args []string) { + if repo, _ := cmd.Flags().GetString("repo"); repo != "" { + // NOTE: this mutates the factory + f.BaseRepo = func() (ghrepo.Interface, error) { + return ghrepo.FromFullName(repo) + } + } + }, + } + + cmd.PersistentFlags().StringP("repo", "R", "", "Select another repository using the `OWNER/REPO` format") + + cmd.AddCommand(cmdCheckout.NewCmdCheckout(f, nil)) + cmd.AddCommand(cmdClose.NewCmdClose(f, nil)) + cmd.AddCommand(cmdCreate.NewCmdCreate(f, nil)) + cmd.AddCommand(cmdDiff.NewCmdDiff(f, nil)) + cmd.AddCommand(cmdList.NewCmdList(f, nil)) + cmd.AddCommand(cmdMerge.NewCmdMerge(f, nil)) + cmd.AddCommand(cmdReady.NewCmdReady(f, nil)) + cmd.AddCommand(cmdReopen.NewCmdReopen(f, nil)) + cmd.AddCommand(cmdReview.NewCmdReview(f, nil)) + cmd.AddCommand(cmdStatus.NewCmdStatus(f, nil)) + cmd.AddCommand(cmdView.NewCmdView(f, nil)) + + return cmd +} diff --git a/pkg/cmd/pr/ready/ready.go b/pkg/cmd/pr/ready/ready.go new file mode 100644 index 000000000..c5a248f19 --- /dev/null +++ b/pkg/cmd/pr/ready/ready.go @@ -0,0 +1,88 @@ +package ready + +import ( + "fmt" + "net/http" + + "github.com/cli/cli/api" + "github.com/cli/cli/context" + "github.com/cli/cli/internal/config" + "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/iostreams" + "github.com/cli/cli/utils" + "github.com/spf13/cobra" +) + +type ReadyOptions struct { + HttpClient func() (*http.Client, error) + Config func() (config.Config, error) + IO *iostreams.IOStreams + BaseRepo func() (ghrepo.Interface, error) + Remotes func() (context.Remotes, error) + Branch func() (string, error) + + SelectorArg string +} + +func NewCmdReady(f *cmdutil.Factory, runF func(*ReadyOptions) error) *cobra.Command { + opts := &ReadyOptions{ + IO: f.IOStreams, + HttpClient: f.HttpClient, + Config: f.Config, + Remotes: f.Remotes, + Branch: f.Branch, + } + + cmd := &cobra.Command{ + Use: "ready [ | | ]", + Short: "Mark a pull request as ready for review", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + // support `-R, --repo` override + opts.BaseRepo = f.BaseRepo + + if len(args) > 0 { + opts.SelectorArg = args[0] + } + + if runF != nil { + return runF(opts) + } + return readyRun(opts) + }, + } + + return cmd +} + +func readyRun(opts *ReadyOptions) error { + httpClient, err := opts.HttpClient() + if err != nil { + return err + } + apiClient := api.NewClientFromHTTP(httpClient) + + pr, baseRepo, err := shared.PRFromArgs(apiClient, opts.BaseRepo, opts.Branch, opts.Remotes, opts.SelectorArg) + if err != nil { + return err + } + + if pr.Closed { + fmt.Fprintf(opts.IO.ErrOut, "%s Pull request #%d is closed. Only draft pull requests can be marked as \"ready for review\"", utils.Red("!"), pr.Number) + return cmdutil.SilentError + } else if !pr.IsDraft { + fmt.Fprintf(opts.IO.ErrOut, "%s Pull request #%d is already \"ready for review\"\n", utils.Yellow("!"), pr.Number) + return nil + } + + err = api.PullRequestReady(apiClient, baseRepo, pr) + if err != nil { + return fmt.Errorf("API call failed: %w", err) + } + + fmt.Fprintf(opts.IO.ErrOut, "%s Pull request #%d is marked as \"ready for review\"\n", utils.Green("✔"), pr.Number) + + return nil +} diff --git a/pkg/cmd/pr/ready/ready_test.go b/pkg/cmd/pr/ready/ready_test.go new file mode 100644 index 000000000..8f5df5832 --- /dev/null +++ b/pkg/cmd/pr/ready/ready_test.go @@ -0,0 +1,135 @@ +package ready + +import ( + "bytes" + "io/ioutil" + "net/http" + "regexp" + "testing" + + "github.com/cli/cli/context" + "github.com/cli/cli/git" + "github.com/cli/cli/internal/config" + "github.com/cli/cli/internal/ghrepo" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/httpmock" + "github.com/cli/cli/pkg/iostreams" + "github.com/cli/cli/test" + "github.com/google/shlex" +) + +func runCommand(rt http.RoundTripper, isTTY bool, cli string) (*test.CmdOut, error) { + io, _, stdout, stderr := iostreams.Test() + io.SetStdoutTTY(isTTY) + io.SetStdinTTY(isTTY) + io.SetStderrTTY(isTTY) + + factory := &cmdutil.Factory{ + IOStreams: io, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: rt}, nil + }, + Config: func() (config.Config, error) { + return config.NewBlankConfig(), nil + }, + BaseRepo: func() (ghrepo.Interface, error) { + return ghrepo.New("OWNER", "REPO"), nil + }, + Remotes: func() (context.Remotes, error) { + return context.Remotes{ + { + Remote: &git.Remote{Name: "origin"}, + Repo: ghrepo.New("OWNER", "REPO"), + }, + }, nil + }, + Branch: func() (string, error) { + return "main", nil + }, + } + + cmd := NewCmdReady(factory, nil) + + argv, err := shlex.Split(cli) + if err != nil { + return nil, err + } + cmd.SetArgs(argv) + + cmd.SetIn(&bytes.Buffer{}) + cmd.SetOut(ioutil.Discard) + cmd.SetErr(ioutil.Discard) + + _, err = cmd.ExecuteC() + return &test.CmdOut{ + OutBuf: stdout, + ErrBuf: stderr, + }, err +} + +func TestPRReady(t *testing.T) { + http := &httpmock.Registry{} + defer http.Verify(t) + + http.StubResponse(200, bytes.NewBufferString(` + { "data": { "repository": { + "pullRequest": { "number": 444, "closed": false, "isDraft": true} + } } } + `)) + http.StubResponse(200, bytes.NewBufferString(`{"id": "THE-ID"}`)) + + output, err := runCommand(http, true, "444") + if err != nil { + t.Fatalf("error running command `pr ready`: %v", err) + } + + r := regexp.MustCompile(`Pull request #444 is marked as "ready for review"`) + + if !r.MatchString(output.Stderr()) { + t.Fatalf("output did not match regexp /%s/\n> output\n%q\n", r, output.Stderr()) + } +} + +func TestPRReady_alreadyReady(t *testing.T) { + http := &httpmock.Registry{} + defer http.Verify(t) + + http.StubResponse(200, bytes.NewBufferString(` + { "data": { "repository": { + "pullRequest": { "number": 445, "closed": false, "isDraft": false} + } } } + `)) + + output, err := runCommand(http, true, "445") + if err != nil { + t.Fatalf("error running command `pr ready`: %v", err) + } + + r := regexp.MustCompile(`Pull request #445 is already "ready for review"`) + + if !r.MatchString(output.Stderr()) { + t.Fatalf("output did not match regexp /%s/\n> output\n%q\n", r, output.Stderr()) + } +} + +func TestPRReady_closed(t *testing.T) { + http := &httpmock.Registry{} + defer http.Verify(t) + + http.StubResponse(200, bytes.NewBufferString(` + { "data": { "repository": { + "pullRequest": { "number": 446, "closed": true, "isDraft": true} + } } } + `)) + + output, err := runCommand(http, true, "446") + if err == nil { + t.Fatalf("expected an error running command `pr ready` on a review that is closed!: %v", err) + } + + r := regexp.MustCompile(`Pull request #446 is closed. Only draft pull requests can be marked as "ready for review"`) + + if !r.MatchString(output.Stderr()) { + t.Fatalf("output did not match regexp /%s/\n> output\n%q\n", r, output.Stderr()) + } +} diff --git a/pkg/cmd/pr/reopen/reopen.go b/pkg/cmd/pr/reopen/reopen.go new file mode 100644 index 000000000..78f4a36ec --- /dev/null +++ b/pkg/cmd/pr/reopen/reopen.go @@ -0,0 +1,85 @@ +package reopen + +import ( + "fmt" + "net/http" + + "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" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/iostreams" + "github.com/cli/cli/utils" + "github.com/spf13/cobra" +) + +type ReopenOptions struct { + HttpClient func() (*http.Client, error) + Config func() (config.Config, error) + IO *iostreams.IOStreams + BaseRepo func() (ghrepo.Interface, error) + + SelectorArg string +} + +func NewCmdReopen(f *cmdutil.Factory, runF func(*ReopenOptions) error) *cobra.Command { + opts := &ReopenOptions{ + IO: f.IOStreams, + HttpClient: f.HttpClient, + Config: f.Config, + } + + cmd := &cobra.Command{ + Use: "reopen { | | }", + Short: "Reopen a pull request", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + // support `-R, --repo` override + opts.BaseRepo = f.BaseRepo + + if len(args) > 0 { + opts.SelectorArg = args[0] + } + + if runF != nil { + return runF(opts) + } + return reopenRun(opts) + }, + } + + return cmd +} + +func reopenRun(opts *ReopenOptions) error { + httpClient, err := opts.HttpClient() + if err != nil { + return err + } + apiClient := api.NewClientFromHTTP(httpClient) + + pr, baseRepo, err := shared.PRFromArgs(apiClient, opts.BaseRepo, nil, nil, opts.SelectorArg) + if err != nil { + return err + } + + if pr.State == "MERGED" { + fmt.Fprintf(opts.IO.ErrOut, "%s Pull request #%d (%s) can't be reopened because it was already merged", utils.Red("!"), pr.Number, pr.Title) + return cmdutil.SilentError + } + + if !pr.Closed { + fmt.Fprintf(opts.IO.ErrOut, "%s Pull request #%d (%s) is already open\n", utils.Yellow("!"), pr.Number, pr.Title) + return nil + } + + err = api.PullRequestReopen(apiClient, baseRepo, pr) + if err != nil { + return fmt.Errorf("API call failed: %w", err) + } + + fmt.Fprintf(opts.IO.ErrOut, "%s Reopened pull request #%d (%s)\n", utils.Green("✔"), pr.Number, pr.Title) + + return nil +} diff --git a/pkg/cmd/pr/reopen/reopen_test.go b/pkg/cmd/pr/reopen/reopen_test.go new file mode 100644 index 000000000..24dfa488f --- /dev/null +++ b/pkg/cmd/pr/reopen/reopen_test.go @@ -0,0 +1,123 @@ +package reopen + +import ( + "bytes" + "io/ioutil" + "net/http" + "regexp" + "testing" + + "github.com/cli/cli/internal/config" + "github.com/cli/cli/internal/ghrepo" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/httpmock" + "github.com/cli/cli/pkg/iostreams" + "github.com/cli/cli/test" + "github.com/google/shlex" +) + +func runCommand(rt http.RoundTripper, isTTY bool, cli string) (*test.CmdOut, error) { + io, _, stdout, stderr := iostreams.Test() + io.SetStdoutTTY(isTTY) + io.SetStdinTTY(isTTY) + io.SetStderrTTY(isTTY) + + factory := &cmdutil.Factory{ + IOStreams: io, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: rt}, nil + }, + Config: func() (config.Config, error) { + return config.NewBlankConfig(), nil + }, + BaseRepo: func() (ghrepo.Interface, error) { + return ghrepo.New("OWNER", "REPO"), nil + }, + } + + cmd := NewCmdReopen(factory, nil) + + argv, err := shlex.Split(cli) + if err != nil { + return nil, err + } + cmd.SetArgs(argv) + + cmd.SetIn(&bytes.Buffer{}) + cmd.SetOut(ioutil.Discard) + cmd.SetErr(ioutil.Discard) + + _, err = cmd.ExecuteC() + return &test.CmdOut{ + OutBuf: stdout, + ErrBuf: stderr, + }, err +} + +func TestPRReopen(t *testing.T) { + http := &httpmock.Registry{} + defer http.Verify(t) + + http.StubResponse(200, bytes.NewBufferString(` + { "data": { "repository": { + "pullRequest": { "number": 666, "title": "The title of the PR", "closed": true} + } } } + `)) + + http.StubResponse(200, bytes.NewBufferString(`{"id": "THE-ID"}`)) + + output, err := runCommand(http, true, "666") + if err != nil { + t.Fatalf("error running command `pr reopen`: %v", err) + } + + r := regexp.MustCompile(`Reopened pull request #666 \(The title of the PR\)`) + + if !r.MatchString(output.Stderr()) { + t.Fatalf("output did not match regexp /%s/\n> output\n%q\n", r, output.Stderr()) + } +} + +func TestPRReopen_alreadyOpen(t *testing.T) { + http := &httpmock.Registry{} + defer http.Verify(t) + + http.StubResponse(200, bytes.NewBufferString(` + { "data": { "repository": { + "pullRequest": { "number": 666, "title": "The title of the PR", "closed": false} + } } } + `)) + + output, err := runCommand(http, true, "666") + if err != nil { + t.Fatalf("error running command `pr reopen`: %v", err) + } + + r := regexp.MustCompile(`Pull request #666 \(The title of the PR\) is already open`) + + if !r.MatchString(output.Stderr()) { + t.Fatalf("output did not match regexp /%s/\n> output\n%q\n", r, output.Stderr()) + } +} + +func TestPRReopen_alreadyMerged(t *testing.T) { + http := &httpmock.Registry{} + defer http.Verify(t) + + http.StubResponse(200, bytes.NewBufferString(` + { "data": { "repository": { + "pullRequest": { "number": 666, "title": "The title of the PR", "closed": true, "state": "MERGED"} + } } } + `)) + + output, err := runCommand(http, true, "666") + if err == nil { + t.Fatalf("expected an error running command `pr reopen`: %v", err) + } + + r := regexp.MustCompile(`Pull request #666 \(The title of the PR\) can't be reopened because it was already merged`) + + if !r.MatchString(output.Stderr()) { + t.Fatalf("output did not match regexp /%s/\n> output\n%q\n", r, output.Stderr()) + } +} diff --git a/pkg/cmd/pr/review/review.go b/pkg/cmd/pr/review/review.go new file mode 100644 index 000000000..dba5dff10 --- /dev/null +++ b/pkg/cmd/pr/review/review.go @@ -0,0 +1,291 @@ +package review + +import ( + "errors" + "fmt" + "net/http" + + "github.com/AlecAivazis/survey/v2" + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/api" + "github.com/cli/cli/context" + "github.com/cli/cli/internal/config" + "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/iostreams" + "github.com/cli/cli/pkg/prompt" + "github.com/cli/cli/pkg/surveyext" + "github.com/cli/cli/utils" + "github.com/spf13/cobra" +) + +type ReviewOptions struct { + HttpClient func() (*http.Client, error) + Config func() (config.Config, error) + IO *iostreams.IOStreams + BaseRepo func() (ghrepo.Interface, error) + Remotes func() (context.Remotes, error) + Branch func() (string, error) + + SelectorArg string + Approve bool + RequestChanges bool + Comment bool + Body string +} + +func NewCmdReview(f *cmdutil.Factory, runF func(*ReviewOptions) error) *cobra.Command { + opts := &ReviewOptions{ + IO: f.IOStreams, + HttpClient: f.HttpClient, + Config: f.Config, + Remotes: f.Remotes, + Branch: f.Branch, + } + + cmd := &cobra.Command{ + Use: "review [ | | ]", + Short: "Add a review to a pull request", + Long: heredoc.Doc(` + Add a review to a pull request. + + Without an argument, the pull request that belongs to the current branch is reviewed. + `), + 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" + `), + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + // support `-R, --repo` override + opts.BaseRepo = f.BaseRepo + + if len(args) > 0 { + opts.SelectorArg = args[0] + } + + if runF != nil { + return runF(opts) + } + return reviewRun(opts) + }, + } + + cmd.Flags().BoolVarP(&opts.Approve, "approve", "a", false, "Approve pull request") + cmd.Flags().BoolVarP(&opts.RequestChanges, "request-changes", "r", false, "Request changes on a pull request") + cmd.Flags().BoolVarP(&opts.Comment, "comment", "c", false, "Comment on a pull request") + cmd.Flags().StringVarP(&opts.Body, "body", "b", "", "Specify the body of a review") + + return cmd +} + +func reviewRun(opts *ReviewOptions) error { + httpClient, err := opts.HttpClient() + if err != nil { + return err + } + apiClient := api.NewClientFromHTTP(httpClient) + + reviewData, err := processReviewOpt(opts) + if err != nil { + return fmt.Errorf("did not understand desired review action: %w", err) + } + + pr, baseRepo, err := shared.PRFromArgs(apiClient, opts.BaseRepo, opts.Branch, opts.Remotes, opts.SelectorArg) + if err != nil { + return err + } + + if reviewData == nil { + editorCommand, err := cmdutil.DetermineEditor(opts.Config) + if err != nil { + return err + } + reviewData, err = reviewSurvey(opts.IO, editorCommand) + if err != nil { + return err + } + if reviewData == nil && err == nil { + fmt.Fprint(opts.IO.ErrOut, "Discarding.\n") + return nil + } + } + + err = api.AddReview(apiClient, baseRepo, pr, reviewData) + if err != nil { + return fmt.Errorf("failed to create review: %w", err) + } + + if !opts.IO.IsStdoutTTY() || !opts.IO.IsStderrTTY() { + return nil + } + + switch reviewData.State { + case api.ReviewComment: + fmt.Fprintf(opts.IO.ErrOut, "%s Reviewed pull request #%d\n", utils.Gray("-"), pr.Number) + case api.ReviewApprove: + fmt.Fprintf(opts.IO.ErrOut, "%s Approved pull request #%d\n", utils.Green("✓"), pr.Number) + case api.ReviewRequestChanges: + fmt.Fprintf(opts.IO.ErrOut, "%s Requested changes to pull request #%d\n", utils.Red("+"), pr.Number) + } + + return nil +} + +// TODO: move to Command.Args, raise FlagError +func processReviewOpt(opts *ReviewOptions) (*api.PullRequestReviewInput, error) { + found := 0 + flag := "" + var state api.PullRequestReviewState + + if opts.Approve { + found++ + flag = "approve" + state = api.ReviewApprove + } + if opts.RequestChanges { + found++ + flag = "request-changes" + state = api.ReviewRequestChanges + } + if opts.Comment { + found++ + flag = "comment" + state = api.ReviewComment + } + + body := opts.Body + + if found == 0 && body == "" { + if opts.IO.IsStdoutTTY() && opts.IO.IsStderrTTY() { + return nil, nil // signal interactive mode + } + return nil, errors.New("--approve, --request-changes, or --comment required when not attached to a tty") + } else if found == 0 && body != "" { + return nil, errors.New("--body unsupported without --approve, --request-changes, or --comment") + } else if found > 1 { + return nil, errors.New("need exactly one of --approve, --request-changes, or --comment") + } + + if (flag == "request-changes" || flag == "comment") && body == "" { + return nil, fmt.Errorf("body cannot be blank for %s review", flag) + } + + return &api.PullRequestReviewInput{ + Body: body, + State: state, + }, nil +} + +func reviewSurvey(io *iostreams.IOStreams, editorCommand string) (*api.PullRequestReviewInput, error) { + typeAnswers := struct { + ReviewType string + }{} + typeQs := []*survey.Question{ + { + Name: "reviewType", + Prompt: &survey.Select{ + Message: "What kind of review do you want to give?", + Options: []string{ + "Comment", + "Approve", + "Request changes", + }, + }, + }, + } + + err := prompt.SurveyAsk(typeQs, &typeAnswers) + if err != nil { + return nil, err + } + + var reviewState api.PullRequestReviewState + + switch typeAnswers.ReviewType { + case "Approve": + reviewState = api.ReviewApprove + case "Request changes": + reviewState = api.ReviewRequestChanges + case "Comment": + reviewState = api.ReviewComment + default: + panic("unreachable state") + } + + bodyAnswers := struct { + Body string + }{} + + blankAllowed := false + if reviewState == api.ReviewApprove { + blankAllowed = true + } + + bodyQs := []*survey.Question{ + { + Name: "body", + Prompt: &surveyext.GhEditor{ + BlankAllowed: blankAllowed, + EditorCommand: editorCommand, + Editor: &survey.Editor{ + Message: "Review body", + FileName: "*.md", + }, + }, + }, + } + + err = prompt.SurveyAsk(bodyQs, &bodyAnswers) + if err != nil { + return nil, err + } + + if bodyAnswers.Body == "" && (reviewState == api.ReviewComment || reviewState == api.ReviewRequestChanges) { + return nil, errors.New("this type of review cannot be blank") + } + + if len(bodyAnswers.Body) > 0 { + renderedBody, err := utils.RenderMarkdown(bodyAnswers.Body) + if err != nil { + return nil, err + } + + fmt.Fprintf(io.Out, "Got:\n%s", renderedBody) + } + + confirm := false + confirmQs := []*survey.Question{ + { + Name: "confirm", + Prompt: &survey.Confirm{ + Message: "Submit?", + Default: true, + }, + }, + } + + err = prompt.SurveyAsk(confirmQs, &confirm) + if err != nil { + return nil, err + } + + if !confirm { + return nil, nil + } + + return &api.PullRequestReviewInput{ + Body: bodyAnswers.Body, + State: reviewState, + }, nil +} diff --git a/command/pr_review_test.go b/pkg/cmd/pr/review/review_test.go similarity index 55% rename from command/pr_review_test.go rename to pkg/cmd/pr/review/review_test.go index b2bb36536..fa033d0a5 100644 --- a/command/pr_review_test.go +++ b/pkg/cmd/pr/review/review_test.go @@ -1,58 +1,112 @@ -package command +package review import ( "bytes" "encoding/json" "io/ioutil" + "net/http" "regexp" "testing" + "github.com/cli/cli/context" + "github.com/cli/cli/git" + "github.com/cli/cli/internal/config" + "github.com/cli/cli/internal/ghrepo" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/httpmock" + "github.com/cli/cli/pkg/iostreams" "github.com/cli/cli/pkg/prompt" "github.com/cli/cli/test" + "github.com/google/shlex" "github.com/stretchr/testify/assert" ) +func runCommand(rt http.RoundTripper, remotes context.Remotes, isTTY bool, cli string) (*test.CmdOut, error) { + io, _, stdout, stderr := iostreams.Test() + io.SetStdoutTTY(isTTY) + io.SetStdinTTY(isTTY) + io.SetStderrTTY(isTTY) + + factory := &cmdutil.Factory{ + IOStreams: io, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: rt}, nil + }, + Config: func() (config.Config, error) { + return config.NewBlankConfig(), nil + }, + BaseRepo: func() (ghrepo.Interface, error) { + return ghrepo.New("OWNER", "REPO"), nil + }, + Remotes: func() (context.Remotes, error) { + if remotes == nil { + return context.Remotes{ + { + Remote: &git.Remote{Name: "origin"}, + Repo: ghrepo.New("OWNER", "REPO"), + }, + }, nil + } + + return remotes, nil + }, + Branch: func() (string, error) { + return "feature", nil + }, + } + + cmd := NewCmdReview(factory, nil) + + argv, err := shlex.Split(cli) + if err != nil { + return nil, err + } + cmd.SetArgs(argv) + + cmd.SetIn(&bytes.Buffer{}) + cmd.SetOut(ioutil.Discard) + cmd.SetErr(ioutil.Discard) + + _, err = cmd.ExecuteC() + return &test.CmdOut{ + OutBuf: stdout, + ErrBuf: stderr, + }, err +} + func TestPRReview_validation(t *testing.T) { - initBlankContext("", "OWNER/REPO", "master") - http := initFakeHTTP() for _, cmd := range []string{ - `pr review --approve --comment 123`, - `pr review --approve --comment -b"hey" 123`, + `--approve --comment 123`, + `--approve --comment -b"hey" 123`, } { - http.StubRepoResponse("OWNER", "REPO") - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { - "pullRequest": { "number": 123 } - } } } - `)) - _, err := RunCommand(cmd) - if err == nil { - t.Fatal("expected error") - } - eq(t, err.Error(), "did not understand desired review action: need exactly one of --approve, --request-changes, or --comment") + t.Run(cmd, func(t *testing.T) { + http := &httpmock.Registry{} + defer http.Verify(t) + + _, err := runCommand(http, nil, false, cmd) + if err == nil { + t.Fatal("expected error") + } + assert.Equal(t, "did not understand desired review action: need exactly one of --approve, --request-changes, or --comment", err.Error()) + }) + } } func TestPRReview_bad_body(t *testing.T) { - initBlankContext("", "OWNER/REPO", "master") - http := initFakeHTTP() - http.StubRepoResponse("OWNER", "REPO") - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { - "pullRequest": { "number": 123 } - } } } - `)) - _, err := RunCommand(`pr review 123 -b "radical"`) + http := &httpmock.Registry{} + defer http.Verify(t) + + _, err := runCommand(http, nil, false, `123 -b "radical"`) if err == nil { t.Fatal("expected error") } - eq(t, err.Error(), "did not understand desired review action: --body unsupported without --approve, --request-changes, or --comment") + assert.Equal(t, "did not understand desired review action: --body unsupported without --approve, --request-changes, or --comment", err.Error()) } func TestPRReview_url_arg(t *testing.T) { - initBlankContext("", "OWNER/REPO", "master") - defer stubTerminal(true)() - http := initFakeHTTP() + http := &httpmock.Registry{} + defer http.Verify(t) http.StubResponse(200, bytes.NewBufferString(` { "data": { "repository": { "pullRequest": { "id": "foobar123", @@ -72,7 +126,7 @@ func TestPRReview_url_arg(t *testing.T) { } } } } `)) http.StubResponse(200, bytes.NewBufferString(`{"data": {} }`)) - output, err := RunCommand("pr review --approve https://github.com/OWNER/REPO/pull/123") + output, err := runCommand(http, nil, true, "--approve https://github.com/OWNER/REPO/pull/123") if err != nil { t.Fatalf("error running pr review: %s", err) } @@ -91,16 +145,14 @@ func TestPRReview_url_arg(t *testing.T) { }{} _ = json.Unmarshal(bodyBytes, &reqBody) - eq(t, reqBody.Variables.Input.PullRequestID, "foobar123") - eq(t, reqBody.Variables.Input.Event, "APPROVE") - eq(t, reqBody.Variables.Input.Body, "") + assert.Equal(t, "foobar123", reqBody.Variables.Input.PullRequestID) + assert.Equal(t, "APPROVE", reqBody.Variables.Input.Event) + assert.Equal(t, "", reqBody.Variables.Input.Body) } func TestPRReview_number_arg(t *testing.T) { - initBlankContext("", "OWNER/REPO", "master") - defer stubTerminal(true)() - http := initFakeHTTP() - http.StubRepoResponse("OWNER", "REPO") + http := &httpmock.Registry{} + defer http.Verify(t) http.StubResponse(200, bytes.NewBufferString(` { "data": { "repository": { "pullRequest": { "id": "foobar123", @@ -120,14 +172,14 @@ func TestPRReview_number_arg(t *testing.T) { } } } } `)) http.StubResponse(200, bytes.NewBufferString(`{"data": {} }`)) - output, err := RunCommand("pr review --approve 123") + output, err := runCommand(http, nil, true, "--approve 123") if err != nil { t.Fatalf("error running pr review: %s", err) } test.ExpectLines(t, output.Stderr(), "Approved pull request #123") - bodyBytes, _ := ioutil.ReadAll(http.Requests[2].Body) + bodyBytes, _ := ioutil.ReadAll(http.Requests[1].Body) reqBody := struct { Variables struct { Input struct { @@ -139,16 +191,14 @@ func TestPRReview_number_arg(t *testing.T) { }{} _ = json.Unmarshal(bodyBytes, &reqBody) - eq(t, reqBody.Variables.Input.PullRequestID, "foobar123") - eq(t, reqBody.Variables.Input.Event, "APPROVE") - eq(t, reqBody.Variables.Input.Body, "") + assert.Equal(t, "foobar123", reqBody.Variables.Input.PullRequestID) + assert.Equal(t, "APPROVE", reqBody.Variables.Input.Event) + assert.Equal(t, "", reqBody.Variables.Input.Body) } func TestPRReview_no_arg(t *testing.T) { - initBlankContext("", "OWNER/REPO", "feature") - defer stubTerminal(true)() - http := initFakeHTTP() - http.StubRepoResponse("OWNER", "REPO") + http := &httpmock.Registry{} + defer http.Verify(t) http.StubResponse(200, bytes.NewBufferString(` { "data": { "repository": { "pullRequests": { "nodes": [ { "url": "https://github.com/OWNER/REPO/pull/123", @@ -159,14 +209,14 @@ func TestPRReview_no_arg(t *testing.T) { ] } } } }`)) http.StubResponse(200, bytes.NewBufferString(`{"data": {} }`)) - output, err := RunCommand(`pr review --comment -b "cool story"`) + output, err := runCommand(http, nil, true, `--comment -b "cool story"`) if err != nil { t.Fatalf("error running pr review: %s", err) } test.ExpectLines(t, output.Stderr(), "Reviewed pull request #123") - bodyBytes, _ := ioutil.ReadAll(http.Requests[2].Body) + bodyBytes, _ := ioutil.ReadAll(http.Requests[1].Body) reqBody := struct { Variables struct { Input struct { @@ -178,37 +228,25 @@ func TestPRReview_no_arg(t *testing.T) { }{} _ = json.Unmarshal(bodyBytes, &reqBody) - eq(t, reqBody.Variables.Input.PullRequestID, "foobar123") - eq(t, reqBody.Variables.Input.Event, "COMMENT") - eq(t, reqBody.Variables.Input.Body, "cool story") + assert.Equal(t, "foobar123", reqBody.Variables.Input.PullRequestID) + assert.Equal(t, "COMMENT", reqBody.Variables.Input.Event) + assert.Equal(t, "cool story", reqBody.Variables.Input.Body) } func TestPRReview_blank_comment(t *testing.T) { - initBlankContext("", "OWNER/REPO", "master") - http := initFakeHTTP() - http.StubRepoResponse("OWNER", "REPO") - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { - "pullRequest": { "number": 123 } - } } } - `)) + http := &httpmock.Registry{} + defer http.Verify(t) - _, err := RunCommand(`pr review --comment 123`) - eq(t, err.Error(), "did not understand desired review action: body cannot be blank for comment review") + _, err := runCommand(http, nil, false, `--comment 123`) + assert.Equal(t, "did not understand desired review action: body cannot be blank for comment review", err.Error()) } func TestPRReview_blank_request_changes(t *testing.T) { - initBlankContext("", "OWNER/REPO", "master") - http := initFakeHTTP() - http.StubRepoResponse("OWNER", "REPO") - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { - "pullRequest": { "number": 123 } - } } } - `)) + http := &httpmock.Registry{} + defer http.Verify(t) - _, err := RunCommand(`pr review -r 123`) - eq(t, err.Error(), "did not understand desired review action: body cannot be blank for request-changes review") + _, err := runCommand(http, nil, false, `-r 123`) + assert.Equal(t, "did not understand desired review action: body cannot be blank for request-changes review", err.Error()) } func TestPRReview(t *testing.T) { @@ -218,63 +256,53 @@ func TestPRReview(t *testing.T) { ExpectedBody string } cases := []c{ - {`pr review --request-changes -b"bad"`, "REQUEST_CHANGES", "bad"}, - {`pr review --approve`, "APPROVE", ""}, - {`pr review --approve -b"hot damn"`, "APPROVE", "hot damn"}, - {`pr review --comment --body "i donno"`, "COMMENT", "i donno"}, + {`--request-changes -b"bad"`, "REQUEST_CHANGES", "bad"}, + {`--approve`, "APPROVE", ""}, + {`--approve -b"hot damn"`, "APPROVE", "hot damn"}, + {`--comment --body "i donno"`, "COMMENT", "i donno"}, } for _, kase := range cases { - initBlankContext("", "OWNER/REPO", "feature") - http := initFakeHTTP() - http.StubRepoResponse("OWNER", "REPO") - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { "pullRequests": { "nodes": [ - { "url": "https://github.com/OWNER/REPO/pull/123", - "id": "foobar123", - "headRefName": "feature", - "baseRefName": "master" } - ] } } } } - `)) - http.StubResponse(200, bytes.NewBufferString(`{"data": {} }`)) + t.Run(kase.Cmd, func(t *testing.T) { + http := &httpmock.Registry{} + defer http.Verify(t) + http.StubResponse(200, bytes.NewBufferString(` + { "data": { "repository": { "pullRequests": { "nodes": [ + { "url": "https://github.com/OWNER/REPO/pull/123", + "id": "foobar123", + "headRefName": "feature", + "baseRefName": "master" } + ] } } } } + `)) + http.StubResponse(200, bytes.NewBufferString(`{"data": {} }`)) - _, err := RunCommand(kase.Cmd) - if err != nil { - t.Fatalf("got unexpected error running %s: %s", kase.Cmd, err) - } - - bodyBytes, _ := ioutil.ReadAll(http.Requests[2].Body) - reqBody := struct { - Variables struct { - Input struct { - Event string - Body string - } + _, err := runCommand(http, nil, false, kase.Cmd) + if err != nil { + t.Fatalf("got unexpected error running %s: %s", kase.Cmd, err) } - }{} - _ = json.Unmarshal(bodyBytes, &reqBody) - eq(t, reqBody.Variables.Input.Event, kase.ExpectedEvent) - eq(t, reqBody.Variables.Input.Body, kase.ExpectedBody) + bodyBytes, _ := ioutil.ReadAll(http.Requests[1].Body) + reqBody := struct { + Variables struct { + Input struct { + Event string + Body string + } + } + }{} + _ = json.Unmarshal(bodyBytes, &reqBody) + + assert.Equal(t, kase.ExpectedEvent, reqBody.Variables.Input.Event) + assert.Equal(t, kase.ExpectedBody, reqBody.Variables.Input.Body) + }) } } func TestPRReview_nontty_insufficient_flags(t *testing.T) { - initBlankContext("", "OWNER/REPO", "feature") - defer stubTerminal(false)() - http := initFakeHTTP() - http.StubRepoResponse("OWNER", "REPO") - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { "pullRequests": { "nodes": [ - { "url": "https://github.com/OWNER/REPO/pull/123", - "number": 123, - "id": "foobar123", - "headRefName": "feature", - "baseRefName": "master" } - ] } } } } - `)) + http := &httpmock.Registry{} + defer http.Verify(t) - output, err := RunCommand("pr review") + output, err := runCommand(http, nil, false, "") if err == nil { t.Fatal("expected error") } @@ -285,10 +313,8 @@ func TestPRReview_nontty_insufficient_flags(t *testing.T) { } func TestPRReview_nontty(t *testing.T) { - initBlankContext("", "OWNER/REPO", "feature") - defer stubTerminal(false)() - http := initFakeHTTP() - http.StubRepoResponse("OWNER", "REPO") + http := &httpmock.Registry{} + defer http.Verify(t) http.StubResponse(200, bytes.NewBufferString(` { "data": { "repository": { "pullRequests": { "nodes": [ { "url": "https://github.com/OWNER/REPO/pull/123", @@ -300,7 +326,7 @@ func TestPRReview_nontty(t *testing.T) { `)) http.StubResponse(200, bytes.NewBufferString(`{"data": {} }`)) - output, err := RunCommand("pr review -c -bcool") + output, err := runCommand(http, nil, false, "-c -bcool") if err != nil { t.Fatalf("unexpected error running command: %s", err) } @@ -308,7 +334,7 @@ func TestPRReview_nontty(t *testing.T) { assert.Equal(t, "", output.String()) assert.Equal(t, "", output.Stderr()) - bodyBytes, _ := ioutil.ReadAll(http.Requests[2].Body) + bodyBytes, _ := ioutil.ReadAll(http.Requests[1].Body) reqBody := struct { Variables struct { Input struct { @@ -324,10 +350,8 @@ func TestPRReview_nontty(t *testing.T) { } func TestPRReview_interactive(t *testing.T) { - initBlankContext("", "OWNER/REPO", "feature") - defer stubTerminal(true)() - http := initFakeHTTP() - http.StubRepoResponse("OWNER", "REPO") + http := &httpmock.Registry{} + defer http.Verify(t) http.StubResponse(200, bytes.NewBufferString(` { "data": { "repository": { "pullRequests": { "nodes": [ { "url": "https://github.com/OWNER/REPO/pull/123", @@ -360,7 +384,7 @@ func TestPRReview_interactive(t *testing.T) { }, }) - output, err := RunCommand(`pr review`) + output, err := runCommand(http, nil, true, "") if err != nil { t.Fatalf("got unexpected error running pr review: %s", err) } @@ -371,7 +395,7 @@ func TestPRReview_interactive(t *testing.T) { "Got:", "cool.*story") - bodyBytes, _ := ioutil.ReadAll(http.Requests[2].Body) + bodyBytes, _ := ioutil.ReadAll(http.Requests[1].Body) reqBody := struct { Variables struct { Input struct { @@ -382,15 +406,13 @@ func TestPRReview_interactive(t *testing.T) { }{} _ = json.Unmarshal(bodyBytes, &reqBody) - eq(t, reqBody.Variables.Input.Event, "APPROVE") - eq(t, reqBody.Variables.Input.Body, "cool story") + assert.Equal(t, "APPROVE", reqBody.Variables.Input.Event) + assert.Equal(t, "cool story", reqBody.Variables.Input.Body) } func TestPRReview_interactive_no_body(t *testing.T) { - initBlankContext("", "OWNER/REPO", "feature") - defer stubTerminal(true)() - http := initFakeHTTP() - http.StubRepoResponse("OWNER", "REPO") + http := &httpmock.Registry{} + defer http.Verify(t) http.StubResponse(200, bytes.NewBufferString(` { "data": { "repository": { "pullRequests": { "nodes": [ { "url": "https://github.com/OWNER/REPO/pull/123", @@ -399,7 +421,7 @@ func TestPRReview_interactive_no_body(t *testing.T) { "baseRefName": "master" } ] } } } } `)) - http.StubResponse(200, bytes.NewBufferString(`{"data": {} }`)) + as, teardown := prompt.InitAskStubber() defer teardown() @@ -422,18 +444,16 @@ func TestPRReview_interactive_no_body(t *testing.T) { }, }) - _, err := RunCommand(`pr review`) + _, err := runCommand(http, nil, true, "") if err == nil { t.Fatal("expected error") } - eq(t, err.Error(), "this type of review cannot be blank") + assert.Equal(t, "this type of review cannot be blank", err.Error()) } func TestPRReview_interactive_blank_approve(t *testing.T) { - initBlankContext("", "OWNER/REPO", "feature") - defer stubTerminal(true)() - http := initFakeHTTP() - http.StubRepoResponse("OWNER", "REPO") + http := &httpmock.Registry{} + defer http.Verify(t) http.StubResponse(200, bytes.NewBufferString(` { "data": { "repository": { "pullRequests": { "nodes": [ { "url": "https://github.com/OWNER/REPO/pull/123", @@ -466,7 +486,7 @@ func TestPRReview_interactive_blank_approve(t *testing.T) { }, }) - output, err := RunCommand(`pr review`) + output, err := runCommand(http, nil, true, "") if err != nil { t.Fatalf("got unexpected error running pr review: %s", err) } @@ -478,7 +498,7 @@ func TestPRReview_interactive_blank_approve(t *testing.T) { test.ExpectLines(t, output.Stderr(), "Approved pull request #123") - bodyBytes, _ := ioutil.ReadAll(http.Requests[2].Body) + bodyBytes, _ := ioutil.ReadAll(http.Requests[1].Body) reqBody := struct { Variables struct { Input struct { @@ -489,7 +509,6 @@ func TestPRReview_interactive_blank_approve(t *testing.T) { }{} _ = json.Unmarshal(bodyBytes, &reqBody) - eq(t, reqBody.Variables.Input.Event, "APPROVE") - eq(t, reqBody.Variables.Input.Body, "") - + assert.Equal(t, "APPROVE", reqBody.Variables.Input.Event) + assert.Equal(t, "", reqBody.Variables.Input.Body) } diff --git a/pkg/cmd/pr/shared/display.go b/pkg/cmd/pr/shared/display.go new file mode 100644 index 000000000..31e7228b6 --- /dev/null +++ b/pkg/cmd/pr/shared/display.go @@ -0,0 +1,66 @@ +package shared + +import ( + "fmt" + "io" + "strings" + + "github.com/cli/cli/api" + "github.com/cli/cli/utils" +) + +func StateTitleWithColor(pr api.PullRequest) string { + prStateColorFunc := ColorFuncForPR(pr) + if pr.State == "OPEN" && pr.IsDraft { + return prStateColorFunc(strings.Title(strings.ToLower("Draft"))) + } + return prStateColorFunc(strings.Title(strings.ToLower(pr.State))) +} + +func ColorFuncForPR(pr api.PullRequest) func(string) string { + if pr.State == "OPEN" && pr.IsDraft { + return utils.Gray + } + return ColorFuncForState(pr.State) +} + +// ColorFuncForState returns a color function for a PR/Issue state +func ColorFuncForState(state string) func(string) string { + switch state { + case "OPEN": + return utils.Green + case "CLOSED": + return utils.Red + case "MERGED": + return utils.Magenta + default: + return nil + } +} + +func PrintHeader(w io.Writer, s string) { + fmt.Fprintln(w, utils.Bold(s)) +} + +func PrintMessage(w io.Writer, s string) { + fmt.Fprintln(w, utils.Gray(s)) +} + +func ListHeader(repoName string, itemName string, matchCount int, totalMatchCount int, hasFilters bool) string { + if totalMatchCount == 0 { + if hasFilters { + return fmt.Sprintf("No %ss match your search in %s", itemName, repoName) + } + return fmt.Sprintf("There are no open %ss in %s", itemName, repoName) + } + + if hasFilters { + matchVerb := "match" + if totalMatchCount == 1 { + matchVerb = "matches" + } + return fmt.Sprintf("Showing %d of %s in %s that %s your search", matchCount, utils.Pluralize(totalMatchCount, itemName), repoName, matchVerb) + } + + return fmt.Sprintf("Showing %d of %s in %s", matchCount, utils.Pluralize(totalMatchCount, fmt.Sprintf("open %s", itemName)), repoName) +} diff --git a/pkg/cmd/pr/shared/display_test.go b/pkg/cmd/pr/shared/display_test.go new file mode 100644 index 000000000..d958d2265 --- /dev/null +++ b/pkg/cmd/pr/shared/display_test.go @@ -0,0 +1,114 @@ +package shared + +import "testing" + +func Test_listHeader(t *testing.T) { + type args struct { + repoName string + itemName string + matchCount int + totalMatchCount int + hasFilters bool + } + tests := []struct { + name string + args args + want string + }{ + { + name: "no results", + args: args{ + repoName: "REPO", + itemName: "table", + matchCount: 0, + totalMatchCount: 0, + hasFilters: false, + }, + want: "There are no open tables in REPO", + }, + { + name: "no matches after filters", + args: args{ + repoName: "REPO", + itemName: "Luftballon", + matchCount: 0, + totalMatchCount: 0, + hasFilters: true, + }, + want: "No Luftballons match your search in REPO", + }, + { + name: "one result", + args: args{ + repoName: "REPO", + itemName: "genie", + matchCount: 1, + totalMatchCount: 23, + hasFilters: false, + }, + want: "Showing 1 of 23 open genies in REPO", + }, + { + name: "one result after filters", + args: args{ + repoName: "REPO", + itemName: "tiny cup", + matchCount: 1, + totalMatchCount: 23, + hasFilters: true, + }, + want: "Showing 1 of 23 tiny cups in REPO that match your search", + }, + { + name: "one result in total", + args: args{ + repoName: "REPO", + itemName: "chip", + matchCount: 1, + totalMatchCount: 1, + hasFilters: false, + }, + want: "Showing 1 of 1 open chip in REPO", + }, + { + name: "one result in total after filters", + args: args{ + repoName: "REPO", + itemName: "spicy noodle", + matchCount: 1, + totalMatchCount: 1, + hasFilters: true, + }, + want: "Showing 1 of 1 spicy noodle in REPO that matches your search", + }, + { + name: "multiple results", + args: args{ + repoName: "REPO", + itemName: "plant", + matchCount: 4, + totalMatchCount: 23, + hasFilters: false, + }, + want: "Showing 4 of 23 open plants in REPO", + }, + { + name: "multiple results after filters", + args: args{ + repoName: "REPO", + itemName: "boomerang", + matchCount: 4, + totalMatchCount: 23, + hasFilters: true, + }, + want: "Showing 4 of 23 boomerangs in REPO that match your search", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := ListHeader(tt.args.repoName, tt.args.itemName, tt.args.matchCount, tt.args.totalMatchCount, tt.args.hasFilters); got != tt.want { + t.Errorf("listHeader() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/command/pr_lookup.go b/pkg/cmd/pr/shared/lookup.go similarity index 65% rename from command/pr_lookup.go rename to pkg/cmd/pr/shared/lookup.go index cf59f0717..9583b854f 100644 --- a/command/pr_lookup.go +++ b/pkg/cmd/pr/shared/lookup.go @@ -1,4 +1,4 @@ -package command +package shared import ( "fmt" @@ -11,44 +11,44 @@ import ( "github.com/cli/cli/context" "github.com/cli/cli/git" "github.com/cli/cli/internal/ghrepo" - "github.com/spf13/cobra" ) -func prFromArgs(ctx context.Context, apiClient *api.Client, cmd *cobra.Command, args []string) (*api.PullRequest, ghrepo.Interface, error) { - if len(args) == 1 { +// PRFromArgs looks up the pull request from either the number/branch/URL argument or one belonging to the current branch +// +// NOTE: this API isn't great, but is here as a compatibility layer between old-style and new-style commands +func PRFromArgs(apiClient *api.Client, baseRepoFn func() (ghrepo.Interface, error), branchFn func() (string, error), remotesFn func() (context.Remotes, error), arg string) (*api.PullRequest, ghrepo.Interface, error) { + if arg != "" { // First check to see if the prString is a url, return repo from url if found. This // is run first because we don't need to run determineBaseRepo for this path - prString := args[0] - pr, r, err := prFromURL(ctx, apiClient, prString) + pr, r, err := prFromURL(apiClient, arg) if pr != nil || err != nil { return pr, r, err } } - repo, err := determineBaseRepo(apiClient, cmd, ctx) + repo, err := baseRepoFn() if err != nil { return nil, nil, fmt.Errorf("could not determine base repo: %w", err) } // If there are no args see if we can guess the PR from the current branch - if len(args) == 0 { - pr, err := prForCurrentBranch(ctx, apiClient, repo) + if arg == "" { + pr, err := prForCurrentBranch(apiClient, repo, branchFn, remotesFn) return pr, repo, err } else { - prString := args[0] // Next see if the prString is a number and use that to look up the url - pr, err := prFromNumberString(ctx, apiClient, repo, prString) + pr, err := prFromNumberString(apiClient, repo, arg) if pr != nil || err != nil { return pr, repo, err } // Last see if it is a branch name - pr, err = api.PullRequestForBranch(apiClient, repo, "", prString) + pr, err = api.PullRequestForBranch(apiClient, repo, "", arg) return pr, repo, err } } -func prFromNumberString(ctx context.Context, apiClient *api.Client, repo ghrepo.Interface, s string) (*api.PullRequest, error) { +func prFromNumberString(apiClient *api.Client, repo ghrepo.Interface, s string) (*api.PullRequest, error) { if prNumber, err := strconv.Atoi(strings.TrimPrefix(s, "#")); err == nil { return api.PullRequestByNumber(apiClient, repo, prNumber) } @@ -58,7 +58,7 @@ func prFromNumberString(ctx context.Context, apiClient *api.Client, repo ghrepo. var pullURLRE = regexp.MustCompile(`^/([^/]+)/([^/]+)/pull/(\d+)`) -func prFromURL(ctx context.Context, apiClient *api.Client, s string) (*api.PullRequest, ghrepo.Interface, error) { +func prFromURL(apiClient *api.Client, s string) (*api.PullRequest, ghrepo.Interface, error) { u, err := url.Parse(s) if err != nil { return nil, nil, nil @@ -75,12 +75,12 @@ func prFromURL(ctx context.Context, apiClient *api.Client, s string) (*api.PullR repo := ghrepo.NewWithHost(m[1], m[2], u.Hostname()) prNumberString := m[3] - pr, err := prFromNumberString(ctx, apiClient, repo, prNumberString) + pr, err := prFromNumberString(apiClient, repo, prNumberString) return pr, repo, err } -func prForCurrentBranch(ctx context.Context, apiClient *api.Client, repo ghrepo.Interface) (*api.PullRequest, error) { - prHeadRef, err := ctx.Branch() +func prForCurrentBranch(apiClient *api.Client, repo ghrepo.Interface, branchFn func() (string, error), remotesFn func() (context.Remotes, error)) (*api.PullRequest, error) { + prHeadRef, err := branchFn() if err != nil { return nil, err } @@ -90,7 +90,7 @@ func prForCurrentBranch(ctx context.Context, apiClient *api.Client, repo ghrepo. // the branch is configured to merge a special PR head ref prHeadRE := regexp.MustCompile(`^refs/pull/(\d+)/head$`) if m := prHeadRE.FindStringSubmatch(branchConfig.MergeRef); m != nil { - return prFromNumberString(ctx, apiClient, repo, m[1]) + return prFromNumberString(apiClient, repo, m[1]) } var branchOwner string @@ -101,7 +101,7 @@ func prForCurrentBranch(ctx context.Context, apiClient *api.Client, repo ghrepo. } } else if branchConfig.RemoteName != "" { // the branch merges from a remote specified by name - rem, _ := ctx.Remotes() + rem, _ := remotesFn() if r, err := rem.FindByName(branchConfig.RemoteName); err == nil { branchOwner = r.RepoOwner() } diff --git a/pkg/cmd/pr/shared/params.go b/pkg/cmd/pr/shared/params.go new file mode 100644 index 000000000..616b4b358 --- /dev/null +++ b/pkg/cmd/pr/shared/params.go @@ -0,0 +1,165 @@ +package shared + +import ( + "fmt" + "net/url" + "strings" + + "github.com/cli/cli/api" + "github.com/cli/cli/internal/ghrepo" +) + +func WithPrAndIssueQueryParams(baseURL, title, body string, assignees, labels, projects []string, milestones []string) (string, error) { + u, err := url.Parse(baseURL) + if err != nil { + return "", err + } + q := u.Query() + if title != "" { + q.Set("title", title) + } + if body != "" { + q.Set("body", body) + } + if len(assignees) > 0 { + q.Set("assignees", strings.Join(assignees, ",")) + } + if len(labels) > 0 { + q.Set("labels", strings.Join(labels, ",")) + } + if len(projects) > 0 { + q.Set("projects", strings.Join(projects, ",")) + } + if len(milestones) > 0 { + q.Set("milestone", milestones[0]) + } + u.RawQuery = q.Encode() + return u.String(), nil +} + +func AddMetadataToIssueParams(client *api.Client, baseRepo ghrepo.Interface, params map[string]interface{}, tb *IssueMetadataState) error { + if !tb.HasMetadata() { + return nil + } + + if tb.MetadataResult == nil { + resolveInput := api.RepoResolveInput{ + Reviewers: tb.Reviewers, + Assignees: tb.Assignees, + Labels: tb.Labels, + Projects: tb.Projects, + Milestones: tb.Milestones, + } + + var err error + tb.MetadataResult, err = api.RepoResolveMetadataIDs(client, baseRepo, resolveInput) + if err != nil { + return err + } + } + + assigneeIDs, err := tb.MetadataResult.MembersToIDs(tb.Assignees) + if err != nil { + return fmt.Errorf("could not assign user: %w", err) + } + params["assigneeIds"] = assigneeIDs + + labelIDs, err := tb.MetadataResult.LabelsToIDs(tb.Labels) + if err != nil { + return fmt.Errorf("could not add label: %w", err) + } + params["labelIds"] = labelIDs + + projectIDs, err := tb.MetadataResult.ProjectsToIDs(tb.Projects) + if err != nil { + return fmt.Errorf("could not add to project: %w", err) + } + params["projectIds"] = projectIDs + + if len(tb.Milestones) > 0 { + milestoneID, err := tb.MetadataResult.MilestoneToID(tb.Milestones[0]) + if err != nil { + return fmt.Errorf("could not add to milestone '%s': %w", tb.Milestones[0], err) + } + params["milestoneId"] = milestoneID + } + + if len(tb.Reviewers) == 0 { + return nil + } + + var userReviewers []string + var teamReviewers []string + for _, r := range tb.Reviewers { + if strings.ContainsRune(r, '/') { + teamReviewers = append(teamReviewers, r) + } else { + userReviewers = append(userReviewers, r) + } + } + + userReviewerIDs, err := tb.MetadataResult.MembersToIDs(userReviewers) + if err != nil { + return fmt.Errorf("could not request reviewer: %w", err) + } + params["userReviewerIds"] = userReviewerIDs + + teamReviewerIDs, err := tb.MetadataResult.TeamsToIDs(teamReviewers) + if err != nil { + return fmt.Errorf("could not request reviewer: %w", err) + } + params["teamReviewerIds"] = teamReviewerIDs + + return nil +} + +type FilterOptions struct { + Entity string + State string + Assignee string + Labels []string + Author string + BaseBranch string + Mention string + Milestone string +} + +func ListURLWithQuery(listURL string, options FilterOptions) (string, error) { + u, err := url.Parse(listURL) + if err != nil { + return "", err + } + query := fmt.Sprintf("is:%s ", options.Entity) + if options.State != "all" { + query += fmt.Sprintf("is:%s ", options.State) + } + if options.Assignee != "" { + query += fmt.Sprintf("assignee:%s ", options.Assignee) + } + for _, label := range options.Labels { + query += fmt.Sprintf("label:%s ", quoteValueForQuery(label)) + } + if options.Author != "" { + query += fmt.Sprintf("author:%s ", options.Author) + } + if options.BaseBranch != "" { + query += fmt.Sprintf("base:%s ", options.BaseBranch) + } + if options.Mention != "" { + query += fmt.Sprintf("mentions:%s ", options.Mention) + } + if options.Milestone != "" { + query += fmt.Sprintf("milestone:%s ", quoteValueForQuery(options.Milestone)) + } + q := u.Query() + q.Set("q", strings.TrimSuffix(query, " ")) + u.RawQuery = q.Encode() + return u.String(), nil +} + +func quoteValueForQuery(v string) string { + if strings.ContainsAny(v, " \"\t\r\n") { + return fmt.Sprintf("%q", v) + } + return v +} diff --git a/pkg/cmd/pr/shared/params_test.go b/pkg/cmd/pr/shared/params_test.go new file mode 100644 index 000000000..177ae0148 --- /dev/null +++ b/pkg/cmd/pr/shared/params_test.go @@ -0,0 +1,71 @@ +package shared + +import "testing" + +func Test_listURLWithQuery(t *testing.T) { + type args struct { + listURL string + options FilterOptions + } + tests := []struct { + name string + args args + want string + wantErr bool + }{ + { + name: "blank", + args: args{ + listURL: "https://example.com/path?a=b", + options: FilterOptions{ + Entity: "issue", + State: "open", + }, + }, + want: "https://example.com/path?a=b&q=is%3Aissue+is%3Aopen", + wantErr: false, + }, + { + name: "all", + args: args{ + listURL: "https://example.com/path", + options: FilterOptions{ + Entity: "issue", + State: "open", + Assignee: "bo", + Author: "ka", + BaseBranch: "trunk", + Mention: "nu", + }, + }, + want: "https://example.com/path?q=is%3Aissue+is%3Aopen+assignee%3Abo+author%3Aka+base%3Atrunk+mentions%3Anu", + wantErr: false, + }, + { + name: "spaces in values", + args: args{ + listURL: "https://example.com/path", + options: FilterOptions{ + Entity: "pr", + State: "open", + Labels: []string{"docs", "help wanted"}, + Milestone: `Codename "What Was Missing"`, + }, + }, + want: "https://example.com/path?q=is%3Apr+is%3Aopen+label%3Adocs+label%3A%22help+wanted%22+milestone%3A%22Codename+%5C%22What+Was+Missing%5C%22%22", + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ListURLWithQuery(tt.args.listURL, tt.args.options) + if (err != nil) != tt.wantErr { + t.Errorf("listURLWithQuery() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("listURLWithQuery() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/command/title_body_survey.go b/pkg/cmd/pr/shared/title_body_survey.go similarity index 88% rename from command/title_body_survey.go rename to pkg/cmd/pr/shared/title_body_survey.go index 94ec89914..3afeba803 100644 --- a/command/title_body_survey.go +++ b/pkg/cmd/pr/shared/title_body_survey.go @@ -1,4 +1,4 @@ -package command +package shared import ( "fmt" @@ -7,21 +7,26 @@ import ( "github.com/cli/cli/api" "github.com/cli/cli/internal/ghrepo" "github.com/cli/cli/pkg/githubtemplate" + "github.com/cli/cli/pkg/iostreams" "github.com/cli/cli/pkg/prompt" "github.com/cli/cli/pkg/surveyext" "github.com/cli/cli/utils" - "github.com/spf13/cobra" ) +type Defaults struct { + Title string + Body string +} + type Action int type metadataStateType int const ( - issueMetadata metadataStateType = iota - prMetadata + IssueMetadata metadataStateType = iota + PRMetadata ) -type issueMetadataState struct { +type IssueMetadataState struct { Type metadataStateType Body string @@ -38,7 +43,7 @@ type issueMetadataState struct { MetadataResult *api.RepoMetadataResult } -func (tb *issueMetadataState) HasMetadata() bool { +func (tb *IssueMetadataState) HasMetadata() bool { return len(tb.Reviewers) > 0 || len(tb.Assignees) > 0 || len(tb.Labels) > 0 || @@ -112,9 +117,9 @@ func selectTemplate(nonLegacyTemplatePaths []string, legacyTemplatePath *string, for _, p := range nonLegacyTemplatePaths { templateNames = append(templateNames, githubtemplate.ExtractName(p)) } - if metadataType == issueMetadata { + if metadataType == IssueMetadata { templateNames = append(templateNames, "Open a blank issue") - } else if metadataType == prMetadata { + } else if metadataType == PRMetadata { templateNames = append(templateNames, "Open a blank pull request") } @@ -143,12 +148,8 @@ func selectTemplate(nonLegacyTemplatePaths []string, legacyTemplatePath *string, return string(templateContents), nil } -func titleBodySurvey(cmd *cobra.Command, issueState *issueMetadataState, apiClient *api.Client, repo ghrepo.Interface, providedTitle, providedBody string, defs defaults, nonLegacyTemplatePaths []string, legacyTemplatePath *string, allowReviewers, allowMetadata bool) error { - editorCommand, err := determineEditor(cmd) - if err != nil { - return err - } - +// FIXME: this command has too many parameters and responsibilities +func TitleBodySurvey(io *iostreams.IOStreams, editorCommand string, issueState *IssueMetadataState, apiClient *api.Client, repo ghrepo.Interface, providedTitle, providedBody string, defs Defaults, nonLegacyTemplatePaths []string, legacyTemplatePath *string, allowReviewers, allowMetadata bool) error { issueState.Title = defs.Title templateContents := "" @@ -198,7 +199,7 @@ func titleBodySurvey(cmd *cobra.Command, issueState *issueMetadataState, apiClie qs = append(qs, bodyQuestion) } - err = prompt.SurveyAsk(qs, issueState) + err := prompt.SurveyAsk(qs, issueState) if err != nil { return fmt.Errorf("could not prompt: %w", err) } @@ -249,7 +250,7 @@ func titleBodySurvey(cmd *cobra.Command, issueState *issueMetadataState, apiClie Projects: isChosen("Projects"), Milestones: isChosen("Milestone"), } - s := utils.Spinner(cmd.OutOrStderr()) + s := utils.Spinner(io.ErrOut) utils.StartSpinner(s) issueState.MetadataResult, err = api.RepoMetadata(apiClient, repo, metadataInput) utils.StopSpinner(s) @@ -297,7 +298,7 @@ func titleBodySurvey(cmd *cobra.Command, issueState *issueMetadataState, apiClie }, }) } else { - cmd.PrintErrln("warning: no available reviewers") + fmt.Fprintln(io.ErrOut, "warning: no available reviewers") } } if isChosen("Assignees") { @@ -311,7 +312,7 @@ func titleBodySurvey(cmd *cobra.Command, issueState *issueMetadataState, apiClie }, }) } else { - cmd.PrintErrln("warning: no assignable users") + fmt.Fprintln(io.ErrOut, "warning: no assignable users") } } if isChosen("Labels") { @@ -325,7 +326,7 @@ func titleBodySurvey(cmd *cobra.Command, issueState *issueMetadataState, apiClie }, }) } else { - cmd.PrintErrln("warning: no labels in the repository") + fmt.Fprintln(io.ErrOut, "warning: no labels in the repository") } } if isChosen("Projects") { @@ -339,7 +340,7 @@ func titleBodySurvey(cmd *cobra.Command, issueState *issueMetadataState, apiClie }, }) } else { - cmd.PrintErrln("warning: no projects to choose from") + fmt.Fprintln(io.ErrOut, "warning: no projects to choose from") } } if isChosen("Milestone") { @@ -357,7 +358,7 @@ func titleBodySurvey(cmd *cobra.Command, issueState *issueMetadataState, apiClie }, }) } else { - cmd.PrintErrln("warning: no milestones in the repository") + fmt.Fprintln(io.ErrOut, "warning: no milestones in the repository") } } values := metadataValues{} diff --git a/test/fixtures/prStatus.json b/pkg/cmd/pr/status/fixtures/prStatus.json similarity index 100% rename from test/fixtures/prStatus.json rename to pkg/cmd/pr/status/fixtures/prStatus.json diff --git a/test/fixtures/prStatusChecks.json b/pkg/cmd/pr/status/fixtures/prStatusChecks.json similarity index 100% rename from test/fixtures/prStatusChecks.json rename to pkg/cmd/pr/status/fixtures/prStatusChecks.json diff --git a/test/fixtures/prStatusCurrentBranch.json b/pkg/cmd/pr/status/fixtures/prStatusCurrentBranch.json similarity index 100% rename from test/fixtures/prStatusCurrentBranch.json rename to pkg/cmd/pr/status/fixtures/prStatusCurrentBranch.json diff --git a/test/fixtures/prStatusCurrentBranchClosed.json b/pkg/cmd/pr/status/fixtures/prStatusCurrentBranchClosed.json similarity index 100% rename from test/fixtures/prStatusCurrentBranchClosed.json rename to pkg/cmd/pr/status/fixtures/prStatusCurrentBranchClosed.json diff --git a/test/fixtures/prStatusCurrentBranchClosedOnDefaultBranch.json b/pkg/cmd/pr/status/fixtures/prStatusCurrentBranchClosedOnDefaultBranch.json similarity index 100% rename from test/fixtures/prStatusCurrentBranchClosedOnDefaultBranch.json rename to pkg/cmd/pr/status/fixtures/prStatusCurrentBranchClosedOnDefaultBranch.json diff --git a/test/fixtures/prStatusCurrentBranchMerged.json b/pkg/cmd/pr/status/fixtures/prStatusCurrentBranchMerged.json similarity index 100% rename from test/fixtures/prStatusCurrentBranchMerged.json rename to pkg/cmd/pr/status/fixtures/prStatusCurrentBranchMerged.json diff --git a/test/fixtures/prStatusCurrentBranchMergedOnDefaultBranch.json b/pkg/cmd/pr/status/fixtures/prStatusCurrentBranchMergedOnDefaultBranch.json similarity index 100% rename from test/fixtures/prStatusCurrentBranchMergedOnDefaultBranch.json rename to pkg/cmd/pr/status/fixtures/prStatusCurrentBranchMergedOnDefaultBranch.json diff --git a/pkg/cmd/pr/status/status.go b/pkg/cmd/pr/status/status.go new file mode 100644 index 000000000..dffd2b412 --- /dev/null +++ b/pkg/cmd/pr/status/status.go @@ -0,0 +1,239 @@ +package status + +import ( + "errors" + "fmt" + "io" + "net/http" + "regexp" + "strconv" + "strings" + + "github.com/cli/cli/api" + "github.com/cli/cli/context" + "github.com/cli/cli/git" + "github.com/cli/cli/internal/config" + "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/iostreams" + "github.com/cli/cli/pkg/text" + "github.com/cli/cli/utils" + "github.com/spf13/cobra" +) + +type StatusOptions struct { + HttpClient func() (*http.Client, error) + Config func() (config.Config, error) + IO *iostreams.IOStreams + BaseRepo func() (ghrepo.Interface, error) + Remotes func() (context.Remotes, error) + Branch func() (string, error) + + HasRepoOverride bool +} + +func NewCmdStatus(f *cmdutil.Factory, runF func(*StatusOptions) error) *cobra.Command { + opts := &StatusOptions{ + IO: f.IOStreams, + HttpClient: f.HttpClient, + Config: f.Config, + Remotes: f.Remotes, + Branch: f.Branch, + } + + cmd := &cobra.Command{ + Use: "status", + Short: "Show status of relevant pull requests", + Args: cmdutil.NoArgsQuoteReminder, + RunE: func(cmd *cobra.Command, args []string) error { + // support `-R, --repo` override + opts.BaseRepo = f.BaseRepo + opts.HasRepoOverride = cmd.Flags().Changed("repo") + + if runF != nil { + return runF(opts) + } + return statusRun(opts) + }, + } + + return cmd +} + +func statusRun(opts *StatusOptions) error { + httpClient, err := opts.HttpClient() + if err != nil { + return err + } + apiClient := api.NewClientFromHTTP(httpClient) + + baseRepo, err := opts.BaseRepo() + if err != nil { + return err + } + + var currentBranch string + var currentPRNumber int + var currentPRHeadRef string + + if !opts.HasRepoOverride { + currentBranch, err = opts.Branch() + if err != nil && !errors.Is(err, git.ErrNotOnAnyBranch) { + return fmt.Errorf("could not query for pull request for current branch: %w", err) + } + + remotes, _ := opts.Remotes() + currentPRNumber, currentPRHeadRef, err = prSelectorForCurrentBranch(baseRepo, currentBranch, remotes) + if err != nil { + return fmt.Errorf("could not query for pull request for current branch: %w", err) + } + } + + // the `@me` macro is available because the API lookup is ElasticSearch-based + currentUser := "@me" + prPayload, err := api.PullRequests(apiClient, baseRepo, currentPRNumber, currentPRHeadRef, currentUser) + if err != nil { + return err + } + + out := opts.IO.Out + + fmt.Fprintln(out, "") + fmt.Fprintf(out, "Relevant pull requests in %s\n", ghrepo.FullName(baseRepo)) + fmt.Fprintln(out, "") + + shared.PrintHeader(out, "Current branch") + currentPR := prPayload.CurrentPR + if currentPR != nil && currentPR.State != "OPEN" && prPayload.DefaultBranch == currentBranch { + currentPR = nil + } + if currentPR != nil { + printPrs(out, 1, *currentPR) + } else if currentPRHeadRef == "" { + shared.PrintMessage(out, " There is no current branch") + } else { + shared.PrintMessage(out, fmt.Sprintf(" There is no pull request associated with %s", utils.Cyan("["+currentPRHeadRef+"]"))) + } + fmt.Fprintln(out) + + shared.PrintHeader(out, "Created by you") + if prPayload.ViewerCreated.TotalCount > 0 { + printPrs(out, prPayload.ViewerCreated.TotalCount, prPayload.ViewerCreated.PullRequests...) + } else { + shared.PrintMessage(out, " You have no open pull requests") + } + fmt.Fprintln(out) + + shared.PrintHeader(out, "Requesting a code review from you") + if prPayload.ReviewRequested.TotalCount > 0 { + printPrs(out, prPayload.ReviewRequested.TotalCount, prPayload.ReviewRequested.PullRequests...) + } else { + shared.PrintMessage(out, " You have no pull requests to review") + } + fmt.Fprintln(out) + + return nil +} + +func prSelectorForCurrentBranch(baseRepo ghrepo.Interface, prHeadRef string, rem context.Remotes) (prNumber int, selector string, err error) { + selector = prHeadRef + branchConfig := git.ReadBranchConfig(prHeadRef) + + // the branch is configured to merge a special PR head ref + prHeadRE := regexp.MustCompile(`^refs/pull/(\d+)/head$`) + if m := prHeadRE.FindStringSubmatch(branchConfig.MergeRef); m != nil { + prNumber, _ = strconv.Atoi(m[1]) + return + } + + var branchOwner string + if branchConfig.RemoteURL != nil { + // the branch merges from a remote specified by URL + if r, err := ghrepo.FromURL(branchConfig.RemoteURL); err == nil { + branchOwner = r.RepoOwner() + } + } else if branchConfig.RemoteName != "" { + // the branch merges from a remote specified by name + if r, err := rem.FindByName(branchConfig.RemoteName); err == nil { + branchOwner = r.RepoOwner() + } + } + + if branchOwner != "" { + if strings.HasPrefix(branchConfig.MergeRef, "refs/heads/") { + selector = strings.TrimPrefix(branchConfig.MergeRef, "refs/heads/") + } + // prepend `OWNER:` if this branch is pushed to a fork + if !strings.EqualFold(branchOwner, baseRepo.RepoOwner()) { + selector = fmt.Sprintf("%s:%s", branchOwner, prHeadRef) + } + } + + return +} + +func printPrs(w io.Writer, totalCount int, prs ...api.PullRequest) { + for _, pr := range prs { + prNumber := fmt.Sprintf("#%d", pr.Number) + + prStateColorFunc := utils.Green + if pr.IsDraft { + prStateColorFunc = utils.Gray + } else if pr.State == "MERGED" { + prStateColorFunc = utils.Magenta + } else if pr.State == "CLOSED" { + prStateColorFunc = utils.Red + } + + fmt.Fprintf(w, " %s %s %s", prStateColorFunc(prNumber), text.Truncate(50, text.ReplaceExcessiveWhitespace(pr.Title)), utils.Cyan("["+pr.HeadLabel()+"]")) + + checks := pr.ChecksStatus() + reviews := pr.ReviewStatus() + + if pr.State == "OPEN" { + reviewStatus := reviews.ChangesRequested || reviews.Approved || reviews.ReviewRequired + if checks.Total > 0 || reviewStatus { + // show checks & reviews on their own line + fmt.Fprintf(w, "\n ") + } + + if checks.Total > 0 { + var summary string + if checks.Failing > 0 { + if checks.Failing == checks.Total { + summary = utils.Red("× All checks failing") + } else { + summary = utils.Red(fmt.Sprintf("× %d/%d checks failing", checks.Failing, checks.Total)) + } + } else if checks.Pending > 0 { + summary = utils.Yellow("- Checks pending") + } else if checks.Passing == checks.Total { + summary = utils.Green("✓ Checks passing") + } + fmt.Fprint(w, summary) + } + + if checks.Total > 0 && reviewStatus { + // add padding between checks & reviews + fmt.Fprint(w, " ") + } + + if reviews.ChangesRequested { + fmt.Fprint(w, utils.Red("+ Changes requested")) + } else if reviews.ReviewRequired { + fmt.Fprint(w, utils.Yellow("- Review required")) + } else if reviews.Approved { + fmt.Fprint(w, utils.Green("✓ Approved")) + } + } else { + fmt.Fprintf(w, " - %s", shared.StateTitleWithColor(pr)) + } + + fmt.Fprint(w, "\n") + } + remaining := totalCount - len(prs) + if remaining > 0 { + fmt.Fprintf(w, utils.Gray(" And %d more\n"), remaining) + } +} diff --git a/pkg/cmd/pr/status/status_test.go b/pkg/cmd/pr/status/status_test.go new file mode 100644 index 000000000..bc4f4e4f9 --- /dev/null +++ b/pkg/cmd/pr/status/status_test.go @@ -0,0 +1,310 @@ +package status + +import ( + "bytes" + "io/ioutil" + "net/http" + "regexp" + "strings" + "testing" + + "github.com/cli/cli/context" + "github.com/cli/cli/git" + "github.com/cli/cli/internal/config" + "github.com/cli/cli/internal/ghrepo" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/httpmock" + "github.com/cli/cli/pkg/iostreams" + "github.com/cli/cli/test" + "github.com/google/shlex" +) + +func runCommand(rt http.RoundTripper, branch string, isTTY bool, cli string) (*test.CmdOut, error) { + io, _, stdout, stderr := iostreams.Test() + io.SetStdoutTTY(isTTY) + io.SetStdinTTY(isTTY) + io.SetStderrTTY(isTTY) + + factory := &cmdutil.Factory{ + IOStreams: io, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: rt}, nil + }, + Config: func() (config.Config, error) { + return config.NewBlankConfig(), nil + }, + BaseRepo: func() (ghrepo.Interface, error) { + return ghrepo.New("OWNER", "REPO"), nil + }, + Remotes: func() (context.Remotes, error) { + return context.Remotes{ + { + Remote: &git.Remote{Name: "origin"}, + Repo: ghrepo.New("OWNER", "REPO"), + }, + }, nil + }, + Branch: func() (string, error) { + if branch == "" { + return "", git.ErrNotOnAnyBranch + } + return branch, nil + }, + } + + cmd := NewCmdStatus(factory, nil) + cmd.PersistentFlags().StringP("repo", "R", "", "") + + argv, err := shlex.Split(cli) + if err != nil { + return nil, err + } + cmd.SetArgs(argv) + + cmd.SetIn(&bytes.Buffer{}) + cmd.SetOut(ioutil.Discard) + cmd.SetErr(ioutil.Discard) + + _, err = cmd.ExecuteC() + return &test.CmdOut{ + OutBuf: stdout, + ErrBuf: stderr, + }, err +} + +func initFakeHTTP() *httpmock.Registry { + return &httpmock.Registry{} +} + +func TestPRStatus(t *testing.T) { + http := initFakeHTTP() + defer http.Verify(t) + http.Register(httpmock.GraphQL(`query PullRequestStatus\b`), httpmock.FileResponse("./fixtures/prStatus.json")) + + output, err := runCommand(http, "blueberries", true, "") + if err != nil { + t.Errorf("error running command `pr status`: %v", err) + } + + expectedPrs := []*regexp.Regexp{ + regexp.MustCompile(`#8.*\[strawberries\]`), + regexp.MustCompile(`#9.*\[apples\]`), + regexp.MustCompile(`#10.*\[blueberries\]`), + regexp.MustCompile(`#11.*\[figs\]`), + } + + for _, r := range expectedPrs { + if !r.MatchString(output.String()) { + t.Errorf("output did not match regexp /%s/", r) + } + } +} + +func TestPRStatus_reviewsAndChecks(t *testing.T) { + http := initFakeHTTP() + defer http.Verify(t) + http.Register(httpmock.GraphQL(`query PullRequestStatus\b`), httpmock.FileResponse("./fixtures/prStatusChecks.json")) + + output, err := runCommand(http, "blueberries", true, "") + if err != nil { + t.Errorf("error running command `pr status`: %v", err) + } + + expected := []string{ + "✓ Checks passing + Changes requested", + "- Checks pending ✓ Approved", + "× 1/3 checks failing - Review required", + } + + for _, line := range expected { + if !strings.Contains(output.String(), line) { + t.Errorf("output did not contain %q: %q", line, output.String()) + } + } +} + +func TestPRStatus_currentBranch_showTheMostRecentPR(t *testing.T) { + http := initFakeHTTP() + defer http.Verify(t) + http.Register(httpmock.GraphQL(`query PullRequestStatus\b`), httpmock.FileResponse("./fixtures/prStatusCurrentBranch.json")) + + output, err := runCommand(http, "blueberries", true, "") + if err != nil { + t.Errorf("error running command `pr status`: %v", err) + } + + expectedLine := regexp.MustCompile(`#10 Blueberries are certainly a good fruit \[blueberries\]`) + if !expectedLine.MatchString(output.String()) { + t.Errorf("output did not match regexp /%s/\n> output\n%s\n", expectedLine, output) + return + } + + unexpectedLines := []*regexp.Regexp{ + regexp.MustCompile(`#9 Blueberries are a good fruit \[blueberries\] - Merged`), + regexp.MustCompile(`#8 Blueberries are probably a good fruit \[blueberries\] - Closed`), + } + for _, r := range unexpectedLines { + if r.MatchString(output.String()) { + t.Errorf("output unexpectedly match regexp /%s/\n> output\n%s\n", r, output) + return + } + } +} + +func TestPRStatus_currentBranch_defaultBranch(t *testing.T) { + http := initFakeHTTP() + defer http.Verify(t) + http.Register(httpmock.GraphQL(`query PullRequestStatus\b`), httpmock.FileResponse("./fixtures/prStatusCurrentBranch.json")) + + output, err := runCommand(http, "blueberries", true, "") + if err != nil { + t.Errorf("error running command `pr status`: %v", err) + } + + expectedLine := regexp.MustCompile(`#10 Blueberries are certainly a good fruit \[blueberries\]`) + if !expectedLine.MatchString(output.String()) { + t.Errorf("output did not match regexp /%s/\n> output\n%s\n", expectedLine, output) + return + } +} + +func TestPRStatus_currentBranch_defaultBranch_repoFlag(t *testing.T) { + http := initFakeHTTP() + defer http.Verify(t) + http.Register(httpmock.GraphQL(`query PullRequestStatus\b`), httpmock.FileResponse("./fixtures/prStatusCurrentBranchClosedOnDefaultBranch.json")) + + output, err := runCommand(http, "blueberries", true, "-R OWNER/REPO") + if err != nil { + t.Errorf("error running command `pr status`: %v", err) + } + + expectedLine := regexp.MustCompile(`#8 Blueberries are a good fruit \[blueberries\]`) + if expectedLine.MatchString(output.String()) { + t.Errorf("output not expected to match regexp /%s/\n> output\n%s\n", expectedLine, output) + return + } +} + +func TestPRStatus_currentBranch_Closed(t *testing.T) { + http := initFakeHTTP() + defer http.Verify(t) + http.Register(httpmock.GraphQL(`query PullRequestStatus\b`), httpmock.FileResponse("./fixtures/prStatusCurrentBranchClosed.json")) + + output, err := runCommand(http, "blueberries", true, "") + if err != nil { + t.Errorf("error running command `pr status`: %v", err) + } + + expectedLine := regexp.MustCompile(`#8 Blueberries are a good fruit \[blueberries\] - Closed`) + if !expectedLine.MatchString(output.String()) { + t.Errorf("output did not match regexp /%s/\n> output\n%s\n", expectedLine, output) + return + } +} + +func TestPRStatus_currentBranch_Closed_defaultBranch(t *testing.T) { + http := initFakeHTTP() + defer http.Verify(t) + http.Register(httpmock.GraphQL(`query PullRequestStatus\b`), httpmock.FileResponse("./fixtures/prStatusCurrentBranchClosedOnDefaultBranch.json")) + + output, err := runCommand(http, "blueberries", true, "") + if err != nil { + t.Errorf("error running command `pr status`: %v", err) + } + + expectedLine := regexp.MustCompile(`There is no pull request associated with \[blueberries\]`) + if !expectedLine.MatchString(output.String()) { + t.Errorf("output did not match regexp /%s/\n> output\n%s\n", expectedLine, output) + return + } +} + +func TestPRStatus_currentBranch_Merged(t *testing.T) { + http := initFakeHTTP() + defer http.Verify(t) + http.Register(httpmock.GraphQL(`query PullRequestStatus\b`), httpmock.FileResponse("./fixtures/prStatusCurrentBranchMerged.json")) + + output, err := runCommand(http, "blueberries", true, "") + if err != nil { + t.Errorf("error running command `pr status`: %v", err) + } + + expectedLine := regexp.MustCompile(`#8 Blueberries are a good fruit \[blueberries\] - Merged`) + if !expectedLine.MatchString(output.String()) { + t.Errorf("output did not match regexp /%s/\n> output\n%s\n", expectedLine, output) + return + } +} + +func TestPRStatus_currentBranch_Merged_defaultBranch(t *testing.T) { + http := initFakeHTTP() + defer http.Verify(t) + http.Register(httpmock.GraphQL(`query PullRequestStatus\b`), httpmock.FileResponse("./fixtures/prStatusCurrentBranchMergedOnDefaultBranch.json")) + + output, err := runCommand(http, "blueberries", true, "") + if err != nil { + t.Errorf("error running command `pr status`: %v", err) + } + + expectedLine := regexp.MustCompile(`There is no pull request associated with \[blueberries\]`) + if !expectedLine.MatchString(output.String()) { + t.Errorf("output did not match regexp /%s/\n> output\n%s\n", expectedLine, output) + return + } +} + +func TestPRStatus_blankSlate(t *testing.T) { + http := initFakeHTTP() + defer http.Verify(t) + http.Register(httpmock.GraphQL(`query PullRequestStatus\b`), httpmock.StringResponse(`{"data": {}}`)) + + output, err := runCommand(http, "blueberries", true, "") + if err != nil { + t.Errorf("error running command `pr status`: %v", err) + } + + expected := ` +Relevant pull requests in OWNER/REPO + +Current branch + There is no pull request associated with [blueberries] + +Created by you + You have no open pull requests + +Requesting a code review from you + You have no pull requests to review + +` + if output.String() != expected { + t.Errorf("expected %q, got %q", expected, output.String()) + } +} + +func TestPRStatus_detachedHead(t *testing.T) { + http := initFakeHTTP() + defer http.Verify(t) + http.Register(httpmock.GraphQL(`query PullRequestStatus\b`), httpmock.StringResponse(`{"data": {}}`)) + + output, err := runCommand(http, "", true, "") + if err != nil { + t.Errorf("error running command `pr status`: %v", err) + } + + expected := ` +Relevant pull requests in OWNER/REPO + +Current branch + There is no current branch + +Created by you + You have no open pull requests + +Requesting a code review from you + You have no pull requests to review + +` + if output.String() != expected { + t.Errorf("expected %q, got %q", expected, output.String()) + } +} diff --git a/test/fixtures/prView.json b/pkg/cmd/pr/view/fixtures/prView.json similarity index 100% rename from test/fixtures/prView.json rename to pkg/cmd/pr/view/fixtures/prView.json diff --git a/test/fixtures/prViewPreview.json b/pkg/cmd/pr/view/fixtures/prViewPreview.json similarity index 100% rename from test/fixtures/prViewPreview.json rename to pkg/cmd/pr/view/fixtures/prViewPreview.json diff --git a/test/fixtures/prViewPreviewClosedState.json b/pkg/cmd/pr/view/fixtures/prViewPreviewClosedState.json similarity index 100% rename from test/fixtures/prViewPreviewClosedState.json rename to pkg/cmd/pr/view/fixtures/prViewPreviewClosedState.json diff --git a/test/fixtures/prViewPreviewDraftState.json b/pkg/cmd/pr/view/fixtures/prViewPreviewDraftState.json similarity index 100% rename from test/fixtures/prViewPreviewDraftState.json rename to pkg/cmd/pr/view/fixtures/prViewPreviewDraftState.json diff --git a/test/fixtures/prViewPreviewDraftStatebyBranch.json b/pkg/cmd/pr/view/fixtures/prViewPreviewDraftStatebyBranch.json similarity index 100% rename from test/fixtures/prViewPreviewDraftStatebyBranch.json rename to pkg/cmd/pr/view/fixtures/prViewPreviewDraftStatebyBranch.json diff --git a/test/fixtures/prViewPreviewMergedState.json b/pkg/cmd/pr/view/fixtures/prViewPreviewMergedState.json similarity index 100% rename from test/fixtures/prViewPreviewMergedState.json rename to pkg/cmd/pr/view/fixtures/prViewPreviewMergedState.json diff --git a/test/fixtures/prViewPreviewWithMetadataByBranch.json b/pkg/cmd/pr/view/fixtures/prViewPreviewWithMetadataByBranch.json similarity index 100% rename from test/fixtures/prViewPreviewWithMetadataByBranch.json rename to pkg/cmd/pr/view/fixtures/prViewPreviewWithMetadataByBranch.json diff --git a/test/fixtures/prViewPreviewWithMetadataByNumber.json b/pkg/cmd/pr/view/fixtures/prViewPreviewWithMetadataByNumber.json similarity index 100% rename from test/fixtures/prViewPreviewWithMetadataByNumber.json rename to pkg/cmd/pr/view/fixtures/prViewPreviewWithMetadataByNumber.json diff --git a/test/fixtures/prViewPreviewWithReviewersByNumber.json b/pkg/cmd/pr/view/fixtures/prViewPreviewWithReviewersByNumber.json similarity index 100% rename from test/fixtures/prViewPreviewWithReviewersByNumber.json rename to pkg/cmd/pr/view/fixtures/prViewPreviewWithReviewersByNumber.json diff --git a/test/fixtures/prView_EmptyBody.json b/pkg/cmd/pr/view/fixtures/prView_EmptyBody.json similarity index 100% rename from test/fixtures/prView_EmptyBody.json rename to pkg/cmd/pr/view/fixtures/prView_EmptyBody.json diff --git a/test/fixtures/prView_NoActiveBranch.json b/pkg/cmd/pr/view/fixtures/prView_NoActiveBranch.json similarity index 100% rename from test/fixtures/prView_NoActiveBranch.json rename to pkg/cmd/pr/view/fixtures/prView_NoActiveBranch.json diff --git a/pkg/cmd/pr/view/view.go b/pkg/cmd/pr/view/view.go new file mode 100644 index 000000000..169d989f1 --- /dev/null +++ b/pkg/cmd/pr/view/view.go @@ -0,0 +1,357 @@ +package view + +import ( + "fmt" + "io" + "net/http" + "sort" + "strings" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/api" + "github.com/cli/cli/context" + "github.com/cli/cli/internal/config" + "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/iostreams" + "github.com/cli/cli/utils" + "github.com/spf13/cobra" +) + +type ViewOptions struct { + HttpClient func() (*http.Client, error) + Config func() (config.Config, error) + IO *iostreams.IOStreams + BaseRepo func() (ghrepo.Interface, error) + Remotes func() (context.Remotes, error) + Branch func() (string, error) + + SelectorArg string + BrowserMode bool +} + +func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Command { + opts := &ViewOptions{ + IO: f.IOStreams, + HttpClient: f.HttpClient, + Config: f.Config, + Remotes: f.Remotes, + Branch: f.Branch, + } + + cmd := &cobra.Command{ + Use: "view [ | | ]", + Short: "View a pull request", + Long: heredoc.Doc(` + Display the title, body, and other information about a pull request. + + Without an argument, the pull request that belongs to the current branch + is displayed. + + With '--web', open the pull request in a web browser instead. + `), + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + // support `-R, --repo` override + opts.BaseRepo = f.BaseRepo + + if len(args) > 0 { + opts.SelectorArg = args[0] + } + + if runF != nil { + return runF(opts) + } + return viewRun(opts) + }, + } + + cmd.Flags().BoolVarP(&opts.BrowserMode, "web", "w", false, "Open a pull request in the browser") + + return cmd +} + +func viewRun(opts *ViewOptions) error { + httpClient, err := opts.HttpClient() + if err != nil { + return err + } + apiClient := api.NewClientFromHTTP(httpClient) + + pr, _, err := shared.PRFromArgs(apiClient, opts.BaseRepo, opts.Branch, opts.Remotes, opts.SelectorArg) + if err != nil { + return err + } + + openURL := pr.URL + connectedToTerminal := opts.IO.IsStdoutTTY() && opts.IO.IsStderrTTY() + + if opts.BrowserMode { + if connectedToTerminal { + fmt.Fprintf(opts.IO.ErrOut, "Opening %s in your browser.\n", openURL) + } + return utils.OpenInBrowser(openURL) + } + + if connectedToTerminal { + return printHumanPrPreview(opts.IO.Out, pr) + } + return printRawPrPreview(opts.IO.Out, pr) +} + +func printRawPrPreview(out io.Writer, pr *api.PullRequest) error { + reviewers := prReviewerList(*pr) + assignees := prAssigneeList(*pr) + labels := prLabelList(*pr) + projects := prProjectList(*pr) + + fmt.Fprintf(out, "title:\t%s\n", pr.Title) + fmt.Fprintf(out, "state:\t%s\n", prStateWithDraft(pr)) + fmt.Fprintf(out, "author:\t%s\n", pr.Author.Login) + fmt.Fprintf(out, "labels:\t%s\n", labels) + fmt.Fprintf(out, "assignees:\t%s\n", assignees) + fmt.Fprintf(out, "reviewers:\t%s\n", reviewers) + fmt.Fprintf(out, "projects:\t%s\n", projects) + fmt.Fprintf(out, "milestone:\t%s\n", pr.Milestone.Title) + + fmt.Fprintln(out, "--") + fmt.Fprintln(out, pr.Body) + + return nil +} + +func printHumanPrPreview(out io.Writer, pr *api.PullRequest) error { + // Header (Title and State) + fmt.Fprintln(out, utils.Bold(pr.Title)) + fmt.Fprintf(out, "%s", shared.StateTitleWithColor(*pr)) + fmt.Fprintln(out, utils.Gray(fmt.Sprintf( + " • %s wants to merge %s into %s from %s", + pr.Author.Login, + utils.Pluralize(pr.Commits.TotalCount, "commit"), + pr.BaseRefName, + pr.HeadRefName, + ))) + fmt.Fprintln(out) + + // Metadata + if reviewers := prReviewerList(*pr); reviewers != "" { + fmt.Fprint(out, utils.Bold("Reviewers: ")) + fmt.Fprintln(out, reviewers) + } + if assignees := prAssigneeList(*pr); assignees != "" { + fmt.Fprint(out, utils.Bold("Assignees: ")) + fmt.Fprintln(out, assignees) + } + if labels := prLabelList(*pr); labels != "" { + fmt.Fprint(out, utils.Bold("Labels: ")) + fmt.Fprintln(out, labels) + } + if projects := prProjectList(*pr); projects != "" { + fmt.Fprint(out, utils.Bold("Projects: ")) + fmt.Fprintln(out, projects) + } + if pr.Milestone.Title != "" { + fmt.Fprint(out, utils.Bold("Milestone: ")) + fmt.Fprintln(out, pr.Milestone.Title) + } + + // Body + if pr.Body != "" { + fmt.Fprintln(out) + md, err := utils.RenderMarkdown(pr.Body) + if err != nil { + return err + } + fmt.Fprintln(out, md) + } + fmt.Fprintln(out) + + // Footer + fmt.Fprintf(out, utils.Gray("View this pull request on GitHub: %s\n"), pr.URL) + return nil +} + +// Ref. https://developer.github.com/v4/enum/pullrequestreviewstate/ +const ( + requestedReviewState = "REQUESTED" // This is our own state for review request + approvedReviewState = "APPROVED" + changesRequestedReviewState = "CHANGES_REQUESTED" + commentedReviewState = "COMMENTED" + dismissedReviewState = "DISMISSED" + pendingReviewState = "PENDING" +) + +type reviewerState struct { + Name string + State string +} + +// colorFuncForReviewerState returns a color function for a reviewer state +func colorFuncForReviewerState(state string) func(string) string { + switch state { + case requestedReviewState: + return utils.Yellow + case approvedReviewState: + return utils.Green + case changesRequestedReviewState: + return utils.Red + case commentedReviewState: + return func(str string) string { return str } // Do nothing + default: + return nil + } +} + +// formattedReviewerState formats a reviewerState with state color +func formattedReviewerState(reviewer *reviewerState) string { + state := reviewer.State + if state == dismissedReviewState { + // Show "DISMISSED" review as "COMMENTED", since "dimissed" only makes + // sense when displayed in an events timeline but not in the final tally. + state = commentedReviewState + } + stateColorFunc := colorFuncForReviewerState(state) + return fmt.Sprintf("%s (%s)", reviewer.Name, stateColorFunc(strings.ReplaceAll(strings.Title(strings.ToLower(state)), "_", " "))) +} + +// prReviewerList generates a reviewer list with their last state +func prReviewerList(pr api.PullRequest) string { + reviewerStates := parseReviewers(pr) + reviewers := make([]string, 0, len(reviewerStates)) + + sortReviewerStates(reviewerStates) + + for _, reviewer := range reviewerStates { + reviewers = append(reviewers, formattedReviewerState(reviewer)) + } + + reviewerList := strings.Join(reviewers, ", ") + + return reviewerList +} + +// Ref. https://developer.github.com/v4/union/requestedreviewer/ +const teamTypeName = "Team" + +const ghostName = "ghost" + +// parseReviewers parses given Reviews and ReviewRequests +func parseReviewers(pr api.PullRequest) []*reviewerState { + reviewerStates := make(map[string]*reviewerState) + + for _, review := range pr.Reviews.Nodes { + if review.Author.Login != pr.Author.Login { + name := review.Author.Login + if name == "" { + name = ghostName + } + reviewerStates[name] = &reviewerState{ + Name: name, + State: review.State, + } + } + } + + // Overwrite reviewer's state if a review request for the same reviewer exists. + for _, reviewRequest := range pr.ReviewRequests.Nodes { + name := reviewRequest.RequestedReviewer.Login + if reviewRequest.RequestedReviewer.TypeName == teamTypeName { + name = reviewRequest.RequestedReviewer.Name + } + reviewerStates[name] = &reviewerState{ + Name: name, + State: requestedReviewState, + } + } + + // Convert map to slice for ease of sort + result := make([]*reviewerState, 0, len(reviewerStates)) + for _, reviewer := range reviewerStates { + if reviewer.State == pendingReviewState { + continue + } + result = append(result, reviewer) + } + + return result +} + +// sortReviewerStates puts completed reviews before review requests and sorts names alphabetically +func sortReviewerStates(reviewerStates []*reviewerState) { + sort.Slice(reviewerStates, func(i, j int) bool { + if reviewerStates[i].State == requestedReviewState && + reviewerStates[j].State != requestedReviewState { + return false + } + if reviewerStates[j].State == requestedReviewState && + reviewerStates[i].State != requestedReviewState { + return true + } + + return reviewerStates[i].Name < reviewerStates[j].Name + }) +} + +func prAssigneeList(pr api.PullRequest) string { + if len(pr.Assignees.Nodes) == 0 { + return "" + } + + AssigneeNames := make([]string, 0, len(pr.Assignees.Nodes)) + for _, assignee := range pr.Assignees.Nodes { + AssigneeNames = append(AssigneeNames, assignee.Login) + } + + list := strings.Join(AssigneeNames, ", ") + if pr.Assignees.TotalCount > len(pr.Assignees.Nodes) { + list += ", …" + } + return list +} + +func prLabelList(pr api.PullRequest) string { + if len(pr.Labels.Nodes) == 0 { + return "" + } + + labelNames := make([]string, 0, len(pr.Labels.Nodes)) + for _, label := range pr.Labels.Nodes { + labelNames = append(labelNames, label.Name) + } + + list := strings.Join(labelNames, ", ") + if pr.Labels.TotalCount > len(pr.Labels.Nodes) { + list += ", …" + } + return list +} + +func prProjectList(pr api.PullRequest) string { + if len(pr.ProjectCards.Nodes) == 0 { + return "" + } + + projectNames := make([]string, 0, len(pr.ProjectCards.Nodes)) + for _, project := range pr.ProjectCards.Nodes { + colName := project.Column.Name + if colName == "" { + colName = "Awaiting triage" + } + projectNames = append(projectNames, fmt.Sprintf("%s (%s)", project.Project.Name, colName)) + } + + list := strings.Join(projectNames, ", ") + if pr.ProjectCards.TotalCount > len(pr.ProjectCards.Nodes) { + list += ", …" + } + return list +} + +func prStateWithDraft(pr *api.PullRequest) string { + if pr.IsDraft && pr.State == "OPEN" { + return "DRAFT" + } + + return pr.State +} diff --git a/pkg/cmd/pr/view/view_test.go b/pkg/cmd/pr/view/view_test.go new file mode 100644 index 000000000..fa540fa62 --- /dev/null +++ b/pkg/cmd/pr/view/view_test.go @@ -0,0 +1,620 @@ +package view + +import ( + "bytes" + "io/ioutil" + "net/http" + "os/exec" + "reflect" + "strings" + "testing" + + "github.com/cli/cli/context" + "github.com/cli/cli/git" + "github.com/cli/cli/internal/config" + "github.com/cli/cli/internal/ghrepo" + "github.com/cli/cli/internal/run" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/httpmock" + "github.com/cli/cli/pkg/iostreams" + "github.com/cli/cli/test" + "github.com/google/shlex" +) + +func eq(t *testing.T, got interface{}, expected interface{}) { + t.Helper() + if !reflect.DeepEqual(got, expected) { + t.Errorf("expected: %v, got: %v", expected, got) + } +} + +func runCommand(rt http.RoundTripper, branch string, isTTY bool, cli string) (*test.CmdOut, error) { + io, _, stdout, stderr := iostreams.Test() + io.SetStdoutTTY(isTTY) + io.SetStdinTTY(isTTY) + io.SetStderrTTY(isTTY) + + factory := &cmdutil.Factory{ + IOStreams: io, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: rt}, nil + }, + Config: func() (config.Config, error) { + return config.NewBlankConfig(), nil + }, + BaseRepo: func() (ghrepo.Interface, error) { + return ghrepo.New("OWNER", "REPO"), nil + }, + Remotes: func() (context.Remotes, error) { + return context.Remotes{ + { + Remote: &git.Remote{Name: "origin"}, + Repo: ghrepo.New("OWNER", "REPO"), + }, + }, nil + }, + Branch: func() (string, error) { + return branch, nil + }, + } + + cmd := NewCmdView(factory, nil) + + argv, err := shlex.Split(cli) + if err != nil { + return nil, err + } + cmd.SetArgs(argv) + + cmd.SetIn(&bytes.Buffer{}) + cmd.SetOut(ioutil.Discard) + cmd.SetErr(ioutil.Discard) + + _, err = cmd.ExecuteC() + return &test.CmdOut{ + OutBuf: stdout, + ErrBuf: stderr, + }, err +} + +func TestPRView_Preview_nontty(t *testing.T) { + tests := map[string]struct { + branch string + args string + fixture string + expectedOutputs []string + }{ + "Open PR without metadata": { + branch: "master", + args: "12", + fixture: "./fixtures/prViewPreview.json", + expectedOutputs: []string{ + `title:\tBlueberries are from a fork\n`, + `state:\tOPEN\n`, + `author:\tnobody\n`, + `labels:\t\n`, + `assignees:\t\n`, + `reviewers:\t\n`, + `projects:\t\n`, + `milestone:\t\n`, + `blueberries taste good`, + }, + }, + "Open PR with metadata by number": { + branch: "master", + args: "12", + fixture: "./fixtures/prViewPreviewWithMetadataByNumber.json", + expectedOutputs: []string{ + `title:\tBlueberries are from a fork\n`, + `reviewers:\t2 \(Approved\), 3 \(Commented\), 1 \(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`, + `milestone:\tuluru\n`, + `\*\*blueberries taste good\*\*`, + }, + }, + "Open PR with reviewers by number": { + branch: "master", + args: "12", + fixture: "./fixtures/prViewPreviewWithReviewersByNumber.json", + expectedOutputs: []string{ + `title:\tBlueberries are from a fork\n`, + `state:\tOPEN\n`, + `author:\tnobody\n`, + `labels:\t\n`, + `assignees:\t\n`, + `projects:\t\n`, + `milestone:\t\n`, + `reviewers:\tDEF \(Commented\), def \(Changes requested\), ghost \(Approved\), hubot \(Commented\), xyz \(Approved\), 123 \(Requested\), Team 1 \(Requested\), abc \(Requested\)\n`, + `\*\*blueberries taste good\*\*`, + }, + }, + "Open PR with metadata by branch": { + branch: "master", + args: "blueberries", + fixture: "./fixtures/prViewPreviewWithMetadataByBranch.json", + expectedOutputs: []string{ + `title:\tBlueberries are a good fruit`, + `state:\tOPEN`, + `author:\tnobody`, + `assignees:\tmarseilles, monaco\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`, + `blueberries taste good`, + }, + }, + "Open PR for the current branch": { + branch: "blueberries", + args: "", + fixture: "./fixtures/prView.json", + expectedOutputs: []string{ + `title:\tBlueberries are a good fruit`, + `state:\tOPEN`, + `author:\tnobody`, + `assignees:\t\n`, + `labels:\t\n`, + `projects:\t\n`, + `milestone:\t\n`, + `\*\*blueberries taste good\*\*`, + }, + }, + "Open PR wth empty body for the current branch": { + branch: "blueberries", + args: "", + fixture: "./fixtures/prView_EmptyBody.json", + expectedOutputs: []string{ + `title:\tBlueberries are a good fruit`, + `state:\tOPEN`, + `author:\tnobody`, + `assignees:\t\n`, + `labels:\t\n`, + `projects:\t\n`, + `milestone:\t\n`, + }, + }, + "Closed PR": { + branch: "master", + args: "12", + fixture: "./fixtures/prViewPreviewClosedState.json", + expectedOutputs: []string{ + `state:\tCLOSED\n`, + `author:\tnobody\n`, + `labels:\t\n`, + `assignees:\t\n`, + `reviewers:\t\n`, + `projects:\t\n`, + `milestone:\t\n`, + `\*\*blueberries taste good\*\*`, + }, + }, + "Merged PR": { + branch: "master", + args: "12", + fixture: "./fixtures/prViewPreviewMergedState.json", + expectedOutputs: []string{ + `state:\tMERGED\n`, + `author:\tnobody\n`, + `labels:\t\n`, + `assignees:\t\n`, + `reviewers:\t\n`, + `projects:\t\n`, + `milestone:\t\n`, + `\*\*blueberries taste good\*\*`, + }, + }, + "Draft PR": { + branch: "master", + args: "12", + fixture: "./fixtures/prViewPreviewDraftState.json", + expectedOutputs: []string{ + `title:\tBlueberries are from a fork\n`, + `state:\tDRAFT\n`, + `author:\tnobody\n`, + `labels:`, + `assignees:`, + `projects:`, + `milestone:`, + `\*\*blueberries taste good\*\*`, + }, + }, + "Draft PR by branch": { + branch: "master", + args: "blueberries", + fixture: "./fixtures/prViewPreviewDraftStatebyBranch.json", + expectedOutputs: []string{ + `title:\tBlueberries are a good fruit\n`, + `state:\tDRAFT\n`, + `author:\tnobody\n`, + `labels:`, + `assignees:`, + `projects:`, + `milestone:`, + `\*\*blueberries taste good\*\*`, + }, + }, + } + + for name, tc := range tests { + 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)) + + output, err := runCommand(http, tc.branch, false, tc.args) + if err != nil { + t.Errorf("error running command `%v`: %v", tc.args, err) + } + + eq(t, output.Stderr(), "") + + test.ExpectLines(t, output.String(), tc.expectedOutputs...) + }) + } +} + +func TestPRView_Preview(t *testing.T) { + tests := map[string]struct { + branch string + args string + fixture string + expectedOutputs []string + }{ + "Open PR without metadata": { + branch: "master", + args: "12", + fixture: "./fixtures/prViewPreview.json", + expectedOutputs: []string{ + `Blueberries are from a fork`, + `Open.*nobody wants to merge 12 commits into master from blueberries`, + `blueberries taste good`, + `View this pull request on GitHub: https://github.com/OWNER/REPO/pull/12`, + }, + }, + "Open PR with metadata by number": { + branch: "master", + args: "12", + fixture: "./fixtures/prViewPreviewWithMetadataByNumber.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`, + `Assignees:.*marseilles, monaco\n`, + `Labels:.*one, two, three, four, five\n`, + `Projects:.*Project 1 \(column A\), Project 2 \(column B\), Project 3 \(column C\), Project 4 \(Awaiting triage\)\n`, + `Milestone:.*uluru\n`, + `blueberries taste good`, + `View this pull request on GitHub: https://github.com/OWNER/REPO/pull/12\n`, + }, + }, + "Open PR with reviewers by number": { + branch: "master", + args: "12", + fixture: "./fixtures/prViewPreviewWithReviewersByNumber.json", + expectedOutputs: []string{ + `Blueberries are from a fork`, + `Reviewers:.*DEF \(.*Commented.*\), def \(.*Changes requested.*\), ghost \(.*Approved.*\), hubot \(Commented\), xyz \(.*Approved.*\), 123 \(.*Requested.*\), Team 1 \(.*Requested.*\), abc \(.*Requested.*\)\n`, + `blueberries taste good`, + `View this pull request on GitHub: https://github.com/OWNER/REPO/pull/12\n`, + }, + }, + "Open PR with metadata by branch": { + branch: "master", + args: "blueberries", + fixture: "./fixtures/prViewPreviewWithMetadataByBranch.json", + expectedOutputs: []string{ + `Blueberries are a good fruit`, + `Open.*nobody wants to merge 8 commits into master from blueberries`, + `Assignees:.*marseilles, monaco\n`, + `Labels:.*one, two, three, four, five\n`, + `Projects:.*Project 1 \(column A\), Project 2 \(column B\), Project 3 \(column C\)\n`, + `Milestone:.*uluru\n`, + `blueberries taste good`, + `View this pull request on GitHub: https://github.com/OWNER/REPO/pull/10\n`, + }, + }, + "Open PR for the current branch": { + branch: "blueberries", + args: "", + fixture: "./fixtures/prView.json", + expectedOutputs: []string{ + `Blueberries are a good fruit`, + `Open.*nobody wants to merge 8 commits into master from blueberries`, + `blueberries taste good`, + `View this pull request on GitHub: https://github.com/OWNER/REPO/pull/10`, + }, + }, + "Open PR wth empty body for the current branch": { + branch: "blueberries", + args: "", + fixture: "./fixtures/prView_EmptyBody.json", + expectedOutputs: []string{ + `Blueberries are a good fruit`, + `Open.*nobody wants to merge 8 commits into master from blueberries`, + `View this pull request on GitHub: https://github.com/OWNER/REPO/pull/10`, + }, + }, + "Closed PR": { + branch: "master", + args: "12", + fixture: "./fixtures/prViewPreviewClosedState.json", + expectedOutputs: []string{ + `Blueberries are from a fork`, + `Closed.*nobody wants to merge 12 commits into master from blueberries`, + `blueberries taste good`, + `View this pull request on GitHub: https://github.com/OWNER/REPO/pull/12`, + }, + }, + "Merged PR": { + branch: "master", + args: "12", + fixture: "./fixtures/prViewPreviewMergedState.json", + expectedOutputs: []string{ + `Blueberries are from a fork`, + `Merged.*nobody wants to merge 12 commits into master from blueberries`, + `blueberries taste good`, + `View this pull request on GitHub: https://github.com/OWNER/REPO/pull/12`, + }, + }, + "Draft PR": { + branch: "master", + args: "12", + fixture: "./fixtures/prViewPreviewDraftState.json", + expectedOutputs: []string{ + `Blueberries are from a fork`, + `Draft.*nobody wants to merge 12 commits into master from blueberries`, + `blueberries taste good`, + `View this pull request on GitHub: https://github.com/OWNER/REPO/pull/12`, + }, + }, + "Draft PR by branch": { + branch: "master", + args: "blueberries", + fixture: "./fixtures/prViewPreviewDraftStatebyBranch.json", + expectedOutputs: []string{ + `Blueberries are a good fruit`, + `Draft.*nobody wants to merge 8 commits into master from blueberries`, + `blueberries taste good`, + `View this pull request on GitHub: https://github.com/OWNER/REPO/pull/10`, + }, + }, + } + + for name, tc := range tests { + 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)) + + output, err := runCommand(http, tc.branch, true, tc.args) + if err != nil { + t.Errorf("error running command `%v`: %v", tc.args, err) + } + + eq(t, output.Stderr(), "") + + test.ExpectLines(t, output.String(), tc.expectedOutputs...) + }) + } +} + +func TestPRView_web_currentBranch(t *testing.T) { + http := &httpmock.Registry{} + defer http.Verify(t) + http.Register(httpmock.GraphQL(`query PullRequestForBranch\b`), httpmock.FileResponse("./fixtures/prView.json")) + + var seenCmd *exec.Cmd + restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable { + switch strings.Join(cmd.Args, " ") { + case `git config --get-regexp ^branch\.blueberries\.(remote|merge)$`: + return &test.OutputStub{} + default: + seenCmd = cmd + return &test.OutputStub{} + } + }) + defer restoreCmd() + + output, err := runCommand(http, "blueberries", true, "-w") + if err != nil { + t.Errorf("error running command `pr view`: %v", err) + } + + eq(t, output.String(), "") + eq(t, output.Stderr(), "Opening https://github.com/OWNER/REPO/pull/10 in your browser.\n") + + if seenCmd == nil { + t.Fatal("expected a command to run") + } + url := seenCmd.Args[len(seenCmd.Args)-1] + if url != "https://github.com/OWNER/REPO/pull/10" { + t.Errorf("got: %q", url) + } +} + +func TestPRView_web_noResultsForBranch(t *testing.T) { + http := &httpmock.Registry{} + defer http.Verify(t) + http.Register(httpmock.GraphQL(`query PullRequestForBranch\b`), httpmock.FileResponse("./fixtures/prView_NoActiveBranch.json")) + + var seenCmd *exec.Cmd + restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable { + switch strings.Join(cmd.Args, " ") { + case `git config --get-regexp ^branch\.blueberries\.(remote|merge)$`: + return &test.OutputStub{} + default: + seenCmd = cmd + return &test.OutputStub{} + } + }) + defer restoreCmd() + + _, err := runCommand(http, "blueberries", true, "-w") + if err == nil || err.Error() != `no open pull requests found for branch "blueberries"` { + t.Errorf("error running command `pr view`: %v", err) + } + + if seenCmd != nil { + t.Fatalf("unexpected command: %v", seenCmd.Args) + } +} + +func TestPRView_web_numberArg(t *testing.T) { + http := &httpmock.Registry{} + defer http.Verify(t) + + http.StubResponse(200, bytes.NewBufferString(` + { "data": { "repository": { "pullRequest": { + "url": "https://github.com/OWNER/REPO/pull/23" + } } } } + `)) + + var seenCmd *exec.Cmd + restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable { + seenCmd = cmd + return &test.OutputStub{} + }) + defer restoreCmd() + + output, err := runCommand(http, "master", true, "-w 23") + if err != nil { + t.Errorf("error running command `pr view`: %v", err) + } + + eq(t, output.String(), "") + + if seenCmd == nil { + t.Fatal("expected a command to run") + } + url := seenCmd.Args[len(seenCmd.Args)-1] + eq(t, url, "https://github.com/OWNER/REPO/pull/23") +} + +func TestPRView_web_numberArgWithHash(t *testing.T) { + http := &httpmock.Registry{} + defer http.Verify(t) + + http.StubResponse(200, bytes.NewBufferString(` + { "data": { "repository": { "pullRequest": { + "url": "https://github.com/OWNER/REPO/pull/23" + } } } } + `)) + + var seenCmd *exec.Cmd + restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable { + seenCmd = cmd + return &test.OutputStub{} + }) + defer restoreCmd() + + output, err := runCommand(http, "master", true, `-w "#23"`) + if err != nil { + t.Errorf("error running command `pr view`: %v", err) + } + + eq(t, output.String(), "") + + if seenCmd == nil { + t.Fatal("expected a command to run") + } + url := seenCmd.Args[len(seenCmd.Args)-1] + eq(t, url, "https://github.com/OWNER/REPO/pull/23") +} + +func TestPRView_web_urlArg(t *testing.T) { + http := &httpmock.Registry{} + defer http.Verify(t) + http.StubResponse(200, bytes.NewBufferString(` + { "data": { "repository": { "pullRequest": { + "url": "https://github.com/OWNER/REPO/pull/23" + } } } } + `)) + + var seenCmd *exec.Cmd + restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable { + seenCmd = cmd + return &test.OutputStub{} + }) + defer restoreCmd() + + output, err := runCommand(http, "master", true, "-w https://github.com/OWNER/REPO/pull/23/files") + if err != nil { + t.Errorf("error running command `pr view`: %v", err) + } + + eq(t, output.String(), "") + + if seenCmd == nil { + t.Fatal("expected a command to run") + } + url := seenCmd.Args[len(seenCmd.Args)-1] + eq(t, url, "https://github.com/OWNER/REPO/pull/23") +} + +func TestPRView_web_branchArg(t *testing.T) { + http := &httpmock.Registry{} + defer http.Verify(t) + + http.StubResponse(200, bytes.NewBufferString(` + { "data": { "repository": { "pullRequests": { "nodes": [ + { "headRefName": "blueberries", + "isCrossRepository": false, + "url": "https://github.com/OWNER/REPO/pull/23" } + ] } } } } + `)) + + var seenCmd *exec.Cmd + restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable { + seenCmd = cmd + return &test.OutputStub{} + }) + defer restoreCmd() + + output, err := runCommand(http, "master", true, "-w blueberries") + if err != nil { + t.Errorf("error running command `pr view`: %v", err) + } + + eq(t, output.String(), "") + + if seenCmd == nil { + t.Fatal("expected a command to run") + } + url := seenCmd.Args[len(seenCmd.Args)-1] + eq(t, url, "https://github.com/OWNER/REPO/pull/23") +} + +func TestPRView_web_branchWithOwnerArg(t *testing.T) { + http := &httpmock.Registry{} + defer http.Verify(t) + + http.StubResponse(200, bytes.NewBufferString(` + { "data": { "repository": { "pullRequests": { "nodes": [ + { "headRefName": "blueberries", + "isCrossRepository": true, + "headRepositoryOwner": { "login": "hubot" }, + "url": "https://github.com/hubot/REPO/pull/23" } + ] } } } } + `)) + + var seenCmd *exec.Cmd + restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable { + seenCmd = cmd + return &test.OutputStub{} + }) + defer restoreCmd() + + output, err := runCommand(http, "master", true, "-w hubot:blueberries") + if err != nil { + t.Errorf("error running command `pr view`: %v", err) + } + + eq(t, output.String(), "") + + if seenCmd == nil { + t.Fatal("expected a command to run") + } + url := seenCmd.Args[len(seenCmd.Args)-1] + eq(t, url, "https://github.com/hubot/REPO/pull/23") +} diff --git a/pkg/cmd/repo/clone/clone.go b/pkg/cmd/repo/clone/clone.go index 88988968d..1b474111a 100644 --- a/pkg/cmd/repo/clone/clone.go +++ b/pkg/cmd/repo/clone/clone.go @@ -9,6 +9,7 @@ import ( "github.com/cli/cli/api" "github.com/cli/cli/git" "github.com/cli/cli/internal/config" + "github.com/cli/cli/internal/ghinstance" "github.com/cli/cli/internal/ghrepo" "github.com/cli/cli/pkg/cmdutil" "github.com/cli/cli/pkg/iostreams" @@ -78,6 +79,7 @@ func cloneRun(opts *CloneOptions) error { if err != nil { return err } + // TODO: GHE support protocol, err := cfg.Get("", "git_protocol") if err != nil { return err @@ -87,7 +89,8 @@ func cloneRun(opts *CloneOptions) error { cloneURL := opts.Repository if !strings.Contains(cloneURL, ":") { if !strings.Contains(cloneURL, "/") { - currentUser, err := api.CurrentLoginName(apiClient) + // TODO: GHE compat + currentUser, err := api.CurrentLoginName(apiClient, ghinstance.Default()) if err != nil { return err } diff --git a/pkg/cmd/repo/create/create.go b/pkg/cmd/repo/create/create.go index 313f9a91e..4bac5d6bd 100644 --- a/pkg/cmd/repo/create/create.go +++ b/pkg/cmd/repo/create/create.go @@ -133,11 +133,10 @@ func createRun(opts *CreateOptions) error { stderr := opts.IO.ErrOut stdout := opts.IO.Out - greenCheck := utils.Green("✓") isTTY := opts.IO.IsStdoutTTY() if isTTY { - fmt.Fprintf(stderr, "%s Created repository %s on GitHub\n", greenCheck, ghrepo.FullName(repo)) + fmt.Fprintf(stderr, "%s Created repository %s on GitHub\n", utils.GreenCheck(), ghrepo.FullName(repo)) } else { fmt.Fprintln(stdout, repo.URL) } @@ -147,6 +146,7 @@ func createRun(opts *CreateOptions) error { if err != nil { return err } + // TODO: GHE support protocol, err := cfg.Get("", "git_protocol") if err != nil { return err @@ -159,7 +159,7 @@ func createRun(opts *CreateOptions) error { return err } if isTTY { - fmt.Fprintf(stderr, "%s Added remote %s\n", greenCheck, remoteURL) + fmt.Fprintf(stderr, "%s Added remote %s\n", utils.GreenCheck(), remoteURL) } } else if isTTY { doSetup := false @@ -186,7 +186,7 @@ func createRun(opts *CreateOptions) error { return err } - fmt.Fprintf(stderr, "%s Initialized repository in './%s/'\n", greenCheck, path) + fmt.Fprintf(stderr, "%s Initialized repository in './%s/'\n", utils.GreenCheck(), path) } } return nil diff --git a/pkg/cmd/repo/create/http.go b/pkg/cmd/repo/create/http.go index 3d79ea99d..113ca6b0a 100644 --- a/pkg/cmd/repo/create/http.go +++ b/pkg/cmd/repo/create/http.go @@ -5,6 +5,7 @@ import ( "net/http" "github.com/cli/cli/api" + "github.com/cli/cli/internal/ghinstance" ) // repoCreateInput represents input parameters for repoCreate @@ -50,7 +51,10 @@ func repoCreate(client *http.Client, input repoCreateInput) (*api.Repository, er "input": input, } - err := apiClient.GraphQL(` + // TODO: GHE support + hostname := ghinstance.Default() + + err := apiClient.GraphQL(hostname, ` mutation RepositoryCreate($input: CreateRepositoryInput!) { createRepository(input: $input) { repository { @@ -66,8 +70,7 @@ func repoCreate(client *http.Client, input repoCreateInput) (*api.Repository, er return nil, err } - // FIXME: support Enterprise hosts - return api.InitRepoHostname(&response.CreateRepository.Repository, "github.com"), nil + return api.InitRepoHostname(&response.CreateRepository.Repository, hostname), nil } // using API v3 here because the equivalent in GraphQL needs `read:org` scope @@ -75,7 +78,8 @@ func resolveOrganization(client *api.Client, orgName string) (string, error) { var response struct { NodeID string `json:"node_id"` } - err := client.REST("GET", fmt.Sprintf("users/%s", orgName), nil, &response) + // TODO: GHE support + err := client.REST(ghinstance.Default(), "GET", fmt.Sprintf("users/%s", orgName), nil, &response) return response.NodeID, err } @@ -87,6 +91,7 @@ func resolveOrganizationTeam(client *api.Client, orgName, teamSlug string) (stri NodeID string `json:"node_id"` } } - err := client.REST("GET", fmt.Sprintf("orgs/%s/teams/%s", orgName, teamSlug), nil, &response) + // TODO: GHE support + err := client.REST(ghinstance.Default(), "GET", fmt.Sprintf("orgs/%s/teams/%s", orgName, teamSlug), nil, &response) return response.Organization.NodeID, response.NodeID, err } diff --git a/pkg/cmd/repo/credits/credits.go b/pkg/cmd/repo/credits/credits.go index 763cee197..40aff56d1 100644 --- a/pkg/cmd/repo/credits/credits.go +++ b/pkg/cmd/repo/credits/credits.go @@ -120,21 +120,17 @@ func creditsRun(opts *CreditsOptions) error { client := api.NewClientFromHTTP(httpClient) - var owner string - var repo string - + var baseRepo ghrepo.Interface if opts.Repository == "" { - baseRepo, err := opts.BaseRepo() + baseRepo, err = opts.BaseRepo() if err != nil { return err } - - owner = baseRepo.RepoOwner() - repo = baseRepo.RepoName() } else { - parts := strings.SplitN(opts.Repository, "/", 2) - owner = parts[0] - repo = parts[1] + baseRepo, err = ghrepo.FromFullName(opts.Repository) + if err != nil { + return err + } } type Contributor struct { @@ -145,9 +141,9 @@ func creditsRun(opts *CreditsOptions) error { result := Result{} body := bytes.NewBufferString("") - path := fmt.Sprintf("repos/%s/%s/contributors", owner, repo) + path := fmt.Sprintf("repos/%s/%s/contributors", baseRepo.RepoOwner(), baseRepo.RepoName()) - err = client.REST("GET", path, body, &result) + err = client.REST(baseRepo.RepoHost(), "GET", path, body, &result) if err != nil { return err } diff --git a/pkg/cmd/repo/fork/fork.go b/pkg/cmd/repo/fork/fork.go index d443e52e8..6a7131a32 100644 --- a/pkg/cmd/repo/fork/fork.go +++ b/pkg/cmd/repo/fork/fork.go @@ -125,7 +125,6 @@ func forkRun(opts *ForkOptions) error { connectedToTerminal := opts.IO.IsStdoutTTY() && opts.IO.IsStderrTTY() && opts.IO.IsStdinTTY() - greenCheck := utils.Green("✓") stderr := opts.IO.ErrOut s := utils.Spinner(stderr) stopSpinner := func() {} @@ -173,7 +172,7 @@ func forkRun(opts *ForkOptions) error { } } else { if connectedToTerminal { - fmt.Fprintf(stderr, "%s Created fork %s\n", greenCheck, utils.Bold(ghrepo.FullName(forkedRepo))) + fmt.Fprintf(stderr, "%s Created fork %s\n", utils.GreenCheck(), utils.Bold(ghrepo.FullName(forkedRepo))) } } @@ -186,6 +185,7 @@ func forkRun(opts *ForkOptions) error { if err != nil { return err } + // TODO: GHE support protocol, err := cfg.Get("", "git_protocol") if err != nil { return err @@ -198,7 +198,7 @@ func forkRun(opts *ForkOptions) error { } if remote, err := remotes.FindByRepo(forkedRepo.RepoOwner(), forkedRepo.RepoName()); err == nil { if connectedToTerminal { - fmt.Fprintf(stderr, "%s Using existing remote %s\n", greenCheck, utils.Bold(remote.Name)) + fmt.Fprintf(stderr, "%s Using existing remote %s\n", utils.GreenCheck(), utils.Bold(remote.Name)) } return nil } @@ -225,7 +225,7 @@ func forkRun(opts *ForkOptions) error { return err } if connectedToTerminal { - fmt.Fprintf(stderr, "%s Renamed %s remote to %s\n", greenCheck, utils.Bold(remoteName), utils.Bold(renameTarget)) + fmt.Fprintf(stderr, "%s Renamed %s remote to %s\n", utils.GreenCheck(), utils.Bold(remoteName), utils.Bold(renameTarget)) } } @@ -237,7 +237,7 @@ func forkRun(opts *ForkOptions) error { } if connectedToTerminal { - fmt.Fprintf(stderr, "%s Added remote %s\n", greenCheck, utils.Bold(remoteName)) + fmt.Fprintf(stderr, "%s Added remote %s\n", utils.GreenCheck(), utils.Bold(remoteName)) } } } else { @@ -262,7 +262,7 @@ func forkRun(opts *ForkOptions) error { } if connectedToTerminal { - fmt.Fprintf(stderr, "%s Cloned fork\n", greenCheck) + fmt.Fprintf(stderr, "%s Cloned fork\n", utils.GreenCheck()) } } } diff --git a/pkg/cmd/repo/view/http.go b/pkg/cmd/repo/view/http.go index 343efe7af..9567dc1de 100644 --- a/pkg/cmd/repo/view/http.go +++ b/pkg/cmd/repo/view/http.go @@ -24,7 +24,7 @@ func RepositoryReadme(client *http.Client, repo ghrepo.Interface) (*RepoReadme, Content string } - err := apiClient.REST("GET", fmt.Sprintf("repos/%s/readme", ghrepo.FullName(repo)), nil, &response) + err := apiClient.REST(repo.RepoHost(), "GET", fmt.Sprintf("repos/%s/readme", ghrepo.FullName(repo)), nil, &response) if err != nil { var httpError api.HTTPError if errors.As(err, &httpError) && httpError.StatusCode == 404 { diff --git a/pkg/cmdutil/factory.go b/pkg/cmdutil/factory.go index 9a6cd5c6f..62ed5d802 100644 --- a/pkg/cmdutil/factory.go +++ b/pkg/cmdutil/factory.go @@ -15,4 +15,5 @@ type Factory struct { BaseRepo func() (ghrepo.Interface, error) Remotes func() (context.Remotes, error) Config func() (config.Config, error) + Branch func() (string, error) } diff --git a/pkg/cmdutil/legacy.go b/pkg/cmdutil/legacy.go new file mode 100644 index 000000000..57cdf855b --- /dev/null +++ b/pkg/cmdutil/legacy.go @@ -0,0 +1,23 @@ +package cmdutil + +import ( + "fmt" + "os" + + "github.com/cli/cli/internal/config" +) + +// TODO: consider passing via Factory +// TODO: support per-hostname settings +func DetermineEditor(cf func() (config.Config, error)) (string, error) { + editorCommand := os.Getenv("GH_EDITOR") + if editorCommand == "" { + cfg, err := cf() + if err != nil { + return "", fmt.Errorf("could not read config: %w", err) + } + editorCommand, _ = cfg.Get("", "editor") + } + + return editorCommand, nil +} diff --git a/pkg/httpmock/stub.go b/pkg/httpmock/stub.go index 0a25beac8..5f4ca58ec 100644 --- a/pkg/httpmock/stub.go +++ b/pkg/httpmock/stub.go @@ -43,7 +43,7 @@ func GraphQL(q string) Matcher { if !strings.EqualFold(req.Method, "POST") { return false } - if req.URL.Path != "/graphql" { + if req.URL.Path != "/graphql" && req.URL.Path != "/api/graphql" { return false } diff --git a/pkg/iostreams/iostreams.go b/pkg/iostreams/iostreams.go index a1ffdad32..cf69ecd8c 100644 --- a/pkg/iostreams/iostreams.go +++ b/pkg/iostreams/iostreams.go @@ -2,12 +2,17 @@ package iostreams import ( "bytes" + "fmt" "io" "io/ioutil" "os" + "os/exec" + "strconv" + "strings" "github.com/mattn/go-colorable" "github.com/mattn/go-isatty" + "golang.org/x/crypto/ssh/terminal" ) type IOStreams struct { @@ -74,6 +79,29 @@ func (s *IOStreams) IsStderrTTY() bool { return false } +func (s *IOStreams) TerminalWidth() int { + defaultWidth := 80 + if s.stdoutTTYOverride { + return defaultWidth + } + + if w, _, err := terminalSize(s.Out); err == nil { + return w + } + + if isCygwinTerminal(s.Out) { + tputCmd := exec.Command("tput", "cols") + tputCmd.Stdin = os.Stdin + if out, err := tputCmd.Output(); err == nil { + if w, err := strconv.Atoi(strings.TrimSpace(string(out))); err == nil { + return w + } + } + } + + return defaultWidth +} + func System() *IOStreams { var out io.Writer = os.Stdout var colorEnabled bool @@ -104,3 +132,17 @@ func Test() (*IOStreams, *bytes.Buffer, *bytes.Buffer, *bytes.Buffer) { func isTerminal(f *os.File) bool { return isatty.IsTerminal(f.Fd()) || isatty.IsCygwinTerminal(f.Fd()) } + +func isCygwinTerminal(w io.Writer) bool { + if f, isFile := w.(*os.File); isFile { + return isatty.IsCygwinTerminal(f.Fd()) + } + return false +} + +func terminalSize(w io.Writer) (int, int, error) { + if f, isFile := w.(*os.File); isFile { + return terminal.GetSize(int(f.Fd())) + } + return 0, 0, fmt.Errorf("%v is not a file", w) +} diff --git a/pkg/prompt/prompt.go b/pkg/prompt/prompt.go index 05683640e..169b0f42c 100644 --- a/pkg/prompt/prompt.go +++ b/pkg/prompt/prompt.go @@ -21,6 +21,10 @@ var Confirm = func(prompt string, result *bool) error { return survey.AskOne(p, result) } +var SurveyAskOne = func(p survey.Prompt, response interface{}, opts ...survey.AskOpt) error { + return survey.AskOne(p, response, opts...) +} + var SurveyAsk = func(qs []*survey.Question, response interface{}, opts ...survey.AskOpt) error { return survey.Ask(qs, response, opts...) } diff --git a/pkg/prompt/stubber.go b/pkg/prompt/stubber.go index e988962d5..77d37b350 100644 --- a/pkg/prompt/stubber.go +++ b/pkg/prompt/stubber.go @@ -8,15 +8,38 @@ import ( "github.com/AlecAivazis/survey/v2/core" ) -type askStubber struct { - Asks [][]*survey.Question - Count int - Stubs [][]*QuestionStub +type AskStubber struct { + Asks [][]*survey.Question + AskOnes []*survey.Prompt + Count int + OneCount int + Stubs [][]*QuestionStub + StubOnes []*PromptStub } -func InitAskStubber() (*askStubber, func()) { +func InitAskStubber() (*AskStubber, func()) { origSurveyAsk := SurveyAsk - as := askStubber{} + origSurveyAskOne := SurveyAskOne + as := AskStubber{} + + SurveyAskOne = func(p survey.Prompt, response interface{}, opts ...survey.AskOpt) error { + as.AskOnes = append(as.AskOnes, &p) + count := as.OneCount + as.OneCount += 1 + if count > len(as.StubOnes) { + panic(fmt.Sprintf("more asks than stubs. most recent call: %v", p)) + } + stubbedPrompt := as.StubOnes[count] + if stubbedPrompt.Default { + defaultValue := reflect.ValueOf(p).Elem().FieldByName("Default") + _ = core.WriteAnswer(response, "", defaultValue) + } else { + _ = core.WriteAnswer(response, "", stubbedPrompt.Value) + } + + return nil + } + SurveyAsk = func(qs []*survey.Question, response interface{}, opts ...survey.AskOpt) error { as.Asks = append(as.Asks, qs) count := as.Count @@ -44,17 +67,35 @@ func InitAskStubber() (*askStubber, func()) { } teardown := func() { SurveyAsk = origSurveyAsk + SurveyAskOne = origSurveyAskOne } return &as, teardown } +type PromptStub struct { + Value interface{} + Default bool +} + type QuestionStub struct { Name string Value interface{} Default bool } -func (as *askStubber) Stub(stubbedQuestions []*QuestionStub) { +func (as *AskStubber) StubOne(value interface{}) { + as.StubOnes = append(as.StubOnes, &PromptStub{ + Value: value, + }) +} + +func (as *AskStubber) StubOneDefault() { + as.StubOnes = append(as.StubOnes, &PromptStub{ + Default: true, + }) +} + +func (as *AskStubber) Stub(stubbedQuestions []*QuestionStub) { // A call to .Ask takes a list of questions; a stub is then a list of questions in the same order. as.Stubs = append(as.Stubs, stubbedQuestions) } diff --git a/pkg/text/sanitize.go b/pkg/text/sanitize.go new file mode 100644 index 000000000..16bb902dc --- /dev/null +++ b/pkg/text/sanitize.go @@ -0,0 +1,12 @@ +package text + +import ( + "regexp" + "strings" +) + +var ws = regexp.MustCompile(`\s+`) + +func ReplaceExcessiveWhitespace(s string) string { + return ws.ReplaceAllString(strings.TrimSpace(s), " ") +} diff --git a/pkg/text/sanitize_test.go b/pkg/text/sanitize_test.go new file mode 100644 index 000000000..1c03362d9 --- /dev/null +++ b/pkg/text/sanitize_test.go @@ -0,0 +1,29 @@ +package text + +import "testing" + +func TestReplaceExcessiveWhitespace(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + { + name: "no replacements", + input: "one two three", + want: "one two three", + }, + { + name: "whitespace b-gone", + input: "\n one\n\t two three\r\n ", + want: "one two three", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := ReplaceExcessiveWhitespace(tt.input); got != tt.want { + t.Errorf("ReplaceExcessiveWhitespace() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/test/fixtures/prStatusFork.json b/test/fixtures/prStatusFork.json deleted file mode 100644 index c9a7a5b3a..000000000 --- a/test/fixtures/prStatusFork.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "data": { - "repository": { - "pullRequests": { - "totalCount": 1, - "edges": [ - { - "node": { - "number": 10, - "title": "Blueberries are a good fruit", - "state": "OPEN", - "url": "https://github.com/PARENT/REPO/pull/10", - "headRefName": "blueberries", - "isDraft": false, - "headRepositoryOwner": { - "login": "OWNER" - }, - "isCrossRepository": true - } - } - ] - } - }, - "viewerCreated": { - "totalCount": 0, - "edges": [] - }, - "reviewRequested": { - "totalCount": 0, - "edges": [] - } - } -} diff --git a/update/update.go b/update/update.go index 60abff73c..bf89a12e8 100644 --- a/update/update.go +++ b/update/update.go @@ -6,6 +6,7 @@ import ( "time" "github.com/cli/cli/api" + "github.com/cli/cli/internal/ghinstance" "github.com/hashicorp/go-version" "gopkg.in/yaml.v3" ) @@ -42,7 +43,7 @@ func getLatestReleaseInfo(client *api.Client, stateFilePath, repo, currentVersio } var latestRelease ReleaseInfo - err = client.REST("GET", fmt.Sprintf("repos/%s/releases/latest", repo), nil, &latestRelease) + err = client.REST(ghinstance.Default(), "GET", fmt.Sprintf("repos/%s/releases/latest", repo), nil, &latestRelease) if err != nil { return nil, err } diff --git a/utils/table_printer.go b/utils/table_printer.go index 823a4b533..3047e2d81 100644 --- a/utils/table_printer.go +++ b/utils/table_printer.go @@ -3,11 +3,9 @@ package utils import ( "fmt" "io" - "os" - "os/exec" - "strconv" "strings" + "github.com/cli/cli/pkg/iostreams" "github.com/cli/cli/pkg/text" ) @@ -18,28 +16,15 @@ type TablePrinter interface { Render() error } -func NewTablePrinter(w io.Writer) TablePrinter { - if IsTerminal(w) { - isCygwin := IsCygwinTerminal(w) - ttyWidth := 80 - if termWidth, _, err := TerminalSize(w); err == nil { - ttyWidth = termWidth - } else if isCygwin { - tputCmd := exec.Command("tput", "cols") - tputCmd.Stdin = os.Stdin - if out, err := tputCmd.Output(); err == nil { - if w, err := strconv.Atoi(strings.TrimSpace(string(out))); err == nil { - ttyWidth = w - } - } - } +func NewTablePrinter(io *iostreams.IOStreams) TablePrinter { + if io.IsStdoutTTY() { return &ttyTablePrinter{ - out: NewColorable(w), - maxWidth: ttyWidth, + out: io.Out, + maxWidth: io.TerminalWidth(), } } return &tsvTablePrinter{ - out: w, + out: io.Out, } } diff --git a/utils/utils.go b/utils/utils.go index 20a5e55a8..cee56bf58 100644 --- a/utils/utils.go +++ b/utils/utils.go @@ -114,3 +114,7 @@ func DisplayURL(urlStr string) string { } return u.Hostname() + u.Path } + +func GreenCheck() string { + return Green("✓") +}