Merge remote-tracking branch 'origin' into issue-1456__clone-flag-help
This commit is contained in:
commit
d876cf15b0
106 changed files with 7089 additions and 4690 deletions
1
.github/workflows/releases.yml
vendored
1
.github/workflows/releases.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 <alias>",
|
||||
Short: "Delete an alias.",
|
||||
Short: "Delete an alias",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: aliasDelete,
|
||||
}
|
||||
|
|
|
|||
226
command/issue.go
226
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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
1047
command/pr.go
1047
command/pr.go
File diff suppressed because it is too large
Load diff
|
|
@ -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 {<number> | <url>}",
|
||||
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"
|
||||
}
|
||||
|
|
@ -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
|
||||
`
|
||||
|
|
@ -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 [<number> | <url> | <branch>]",
|
||||
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
|
||||
}
|
||||
1731
command/pr_test.go
1731
command/pr_test.go
File diff suppressed because it is too large
Load diff
189
command/root.go
189
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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
41
internal/ghinstance/host.go
Normal file
41
internal/ghinstance/host.go
Normal file
|
|
@ -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/"
|
||||
}
|
||||
121
internal/ghinstance/host_test.go
Normal file
121
internal/ghinstance/host_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
18
pkg/cmd/auth/auth.go
Normal file
18
pkg/cmd/auth/auth.go
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var Cmd = &cobra.Command{
|
||||
Use: "auth <command>",
|
||||
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
|
||||
//`),
|
||||
}
|
||||
48
pkg/cmd/auth/login/client.go
Normal file
48
pkg/cmd/auth/login/client.go
Normal file
|
|
@ -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
|
||||
}
|
||||
289
pkg/cmd/auth/login/login.go
Normal file
289
pkg/cmd/auth/login/login.go
Normal file
|
|
@ -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
|
||||
}
|
||||
436
pkg/cmd/auth/login/login_test.go
Normal file
436
pkg/cmd/auth/login/login_test.go
Normal file
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
157
pkg/cmd/auth/logout/logout.go
Normal file
157
pkg/cmd/auth/logout/logout.go
Normal file
|
|
@ -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
|
||||
}
|
||||
259
pkg/cmd/auth/logout/logout_test.go
Normal file
259
pkg/cmd/auth/logout/logout_test.go
Normal file
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {<number> | <url> | <branch>}",
|
||||
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 {<number> | <url> | <branch>}",
|
||||
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,
|
||||
}
|
||||
|
|
@ -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(), "")
|
||||
|
||||
83
pkg/cmd/pr/close/close.go
Normal file
83
pkg/cmd/pr/close/close.go
Normal file
|
|
@ -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 {<number> | <url> | <branch>}",
|
||||
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
|
||||
}
|
||||
101
pkg/cmd/pr/close/close_test.go
Normal file
101
pkg/cmd/pr/close/close_test.go
Normal file
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
|
@ -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`")
|
||||
}
|
||||
|
|
@ -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)
|
||||
136
pkg/cmd/pr/diff/diff.go
Normal file
136
pkg/cmd/pr/diff/diff.go
Normal file
|
|
@ -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 [<number> | <url> | <branch>]",
|
||||
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"
|
||||
}
|
||||
183
pkg/cmd/pr/diff/diff_test.go
Normal file
183
pkg/cmd/pr/diff/diff_test.go
Normal file
|
|
@ -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
|
||||
`
|
||||
170
pkg/cmd/pr/list/list.go
Normal file
170
pkg/cmd/pr/list/list.go
Normal file
|
|
@ -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
|
||||
}
|
||||
246
pkg/cmd/pr/list/list_test.go
Normal file
246
pkg/cmd/pr/list/list_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
274
pkg/cmd/pr/merge/merge.go
Normal file
274
pkg/cmd/pr/merge/merge.go
Normal file
|
|
@ -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 [<number> | <url> | <branch>]",
|
||||
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
|
||||
}
|
||||
505
pkg/cmd/pr/merge/merge_test.go
Normal file
505
pkg/cmd/pr/merge/merge_test.go
Normal file
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
66
pkg/cmd/pr/pr.go
Normal file
66
pkg/cmd/pr/pr.go
Normal file
|
|
@ -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 <command>",
|
||||
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
|
||||
}
|
||||
88
pkg/cmd/pr/ready/ready.go
Normal file
88
pkg/cmd/pr/ready/ready.go
Normal file
|
|
@ -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 [<number> | <url> | <branch>]",
|
||||
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
|
||||
}
|
||||
135
pkg/cmd/pr/ready/ready_test.go
Normal file
135
pkg/cmd/pr/ready/ready_test.go
Normal file
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
85
pkg/cmd/pr/reopen/reopen.go
Normal file
85
pkg/cmd/pr/reopen/reopen.go
Normal file
|
|
@ -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 {<number> | <url> | <branch>}",
|
||||
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
|
||||
}
|
||||
123
pkg/cmd/pr/reopen/reopen_test.go
Normal file
123
pkg/cmd/pr/reopen/reopen_test.go
Normal file
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
291
pkg/cmd/pr/review/review.go
Normal file
291
pkg/cmd/pr/review/review.go
Normal file
|
|
@ -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 [<number> | <url> | <branch>]",
|
||||
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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
66
pkg/cmd/pr/shared/display.go
Normal file
66
pkg/cmd/pr/shared/display.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
114
pkg/cmd/pr/shared/display_test.go
Normal file
114
pkg/cmd/pr/shared/display_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
165
pkg/cmd/pr/shared/params.go
Normal file
165
pkg/cmd/pr/shared/params.go
Normal file
|
|
@ -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
|
||||
}
|
||||
71
pkg/cmd/pr/shared/params_test.go
Normal file
71
pkg/cmd/pr/shared/params_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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{}
|
||||
239
pkg/cmd/pr/status/status.go
Normal file
239
pkg/cmd/pr/status/status.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
310
pkg/cmd/pr/status/status_test.go
Normal file
310
pkg/cmd/pr/status/status_test.go
Normal file
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
357
pkg/cmd/pr/view/view.go
Normal file
357
pkg/cmd/pr/view/view.go
Normal file
|
|
@ -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 [<number> | <url> | <branch>]",
|
||||
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
|
||||
}
|
||||
620
pkg/cmd/pr/view/view_test.go
Normal file
620
pkg/cmd/pr/view/view_test.go
Normal file
|
|
@ -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")
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
23
pkg/cmdutil/legacy.go
Normal file
23
pkg/cmdutil/legacy.go
Normal file
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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...)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue