Merge remote-tracking branch 'origin' into writeorg-oauth-scope

This commit is contained in:
Mislav Marohnić 2021-02-17 17:11:24 +01:00
commit 4a49e3526c
23 changed files with 1338 additions and 595 deletions

View file

@ -142,6 +142,14 @@ type HTTPError struct {
RequestURL *url.URL
Message string
OAuthScopes string
Errors []HTTPErrorItem
}
type HTTPErrorItem struct {
Message string
Resource string
Field string
Code string
}
func (err HTTPError) Error() string {
@ -153,79 +161,6 @@ func (err HTTPError) Error() string {
return fmt.Sprintf("HTTP %d (%s)", err.StatusCode, err.RequestURL)
}
type MissingScopesError struct {
MissingScopes []string
}
func (e MissingScopesError) Error() string {
var missing []string
for _, s := range e.MissingScopes {
missing = append(missing, fmt.Sprintf("'%s'", s))
}
scopes := strings.Join(missing, ", ")
if len(e.MissingScopes) == 1 {
return "missing required scope " + scopes
}
return "missing required scopes " + scopes
}
func (c Client) HasMinimumScopes(hostname string) error {
apiEndpoint := ghinstance.RESTPrefix(hostname)
req, err := http.NewRequest("GET", apiEndpoint, nil)
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json; charset=utf-8")
res, err := c.http.Do(req)
if err != nil {
return err
}
defer func() {
// Ensure the response body is fully read and closed
// before we reconnect, so that we reuse the same TCPconnection.
_, _ = io.Copy(ioutil.Discard, res.Body)
res.Body.Close()
}()
if res.StatusCode != 200 {
return HandleHTTPError(res)
}
scopesHeader := res.Header.Get("X-Oauth-Scopes")
if scopesHeader == "" {
// if the token reports no scopes, assume that it's an integration token and give up on
// detecting its capabilities
return nil
}
search := map[string]bool{
"repo": false,
"read:org": false,
"admin:org": false,
}
for _, s := range strings.Split(scopesHeader, ",") {
search[strings.TrimSpace(s)] = true
}
var missingScopes []string
if !search["repo"] {
missingScopes = append(missingScopes, "repo")
}
if !search["read:org"] && !search["write:org"] && !search["admin:org"] {
missingScopes = append(missingScopes, "read:org")
}
if len(missingScopes) > 0 {
return &MissingScopesError{MissingScopes: missingScopes}
}
return nil
}
// GraphQL performs a GraphQL request and parses the response
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})
@ -341,13 +276,6 @@ func HandleHTTPError(resp *http.Response) error {
return httpError
}
type errorObject struct {
Message string
Resource string
Field string
Code string
}
messages := []string{parsedBody.Message}
for _, raw := range parsedBody.Errors {
switch raw[0] {
@ -355,8 +283,9 @@ func HandleHTTPError(resp *http.Response) error {
var errString string
_ = json.Unmarshal(raw, &errString)
messages = append(messages, errString)
httpError.Errors = append(httpError.Errors, HTTPErrorItem{Message: errString})
case '{':
var errInfo errorObject
var errInfo HTTPErrorItem
_ = json.Unmarshal(raw, &errInfo)
msg := errInfo.Message
if errInfo.Code != "custom" {
@ -365,6 +294,7 @@ func HandleHTTPError(resp *http.Response) error {
if msg != "" {
messages = append(messages, msg)
}
httpError.Errors = append(httpError.Errors, errInfo)
}
}
httpError.Message = strings.Join(messages, "\n")

View file

@ -109,72 +109,3 @@ func TestRESTError(t *testing.T) {
}
}
func Test_HasMinimumScopes(t *testing.T) {
tests := []struct {
name string
header string
wantErr string
}{
{
name: "no scopes",
header: "",
wantErr: "",
},
{
name: "default scopes",
header: "repo, read:org",
wantErr: "",
},
{
name: "admin:org satisfies read:org",
header: "repo, admin:org",
wantErr: "",
},
{
name: "write:org satisfies read:org",
header: "repo, write:org",
wantErr: "",
},
{
name: "insufficient scope",
header: "repo",
wantErr: "missing required scope 'read:org'",
},
{
name: "insufficient scopes",
header: "gist",
wantErr: "missing required scopes 'repo', 'read:org'",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
fakehttp := &httpmock.Registry{}
client := NewClient(ReplaceTripper(fakehttp))
fakehttp.Register(httpmock.REST("GET", ""), func(req *http.Request) (*http.Response, error) {
return &http.Response{
Request: req,
StatusCode: 200,
Body: ioutil.NopCloser(&bytes.Buffer{}),
Header: map[string][]string{
"X-Oauth-Scopes": {tt.header},
},
}, nil
})
err := client.HasMinimumScopes("github.com")
if tt.wantErr == "" {
if err != nil {
t.Errorf("error: %v", err)
}
return
}
if err.Error() != tt.wantErr {
t.Errorf("want %q, got %q", tt.wantErr, err.Error())
}
})
}
}

View file

@ -16,10 +16,11 @@ import (
)
type PullRequestsPayload struct {
ViewerCreated PullRequestAndTotalCount
ReviewRequested PullRequestAndTotalCount
CurrentPR *PullRequest
DefaultBranch string
ViewerCreated PullRequestAndTotalCount
ReviewRequested PullRequestAndTotalCount
CurrentPR *PullRequest
DefaultBranch string
StrictProtection bool
}
type PullRequestAndTotalCount struct {
@ -28,16 +29,17 @@ type PullRequestAndTotalCount struct {
}
type PullRequest struct {
ID string
Number int
Title string
State string
Closed bool
URL string
BaseRefName string
HeadRefName string
Body string
Mergeable string
ID string
Number int
Title string
State string
Closed bool
URL string
BaseRefName string
HeadRefName string
Body string
Mergeable string
MergeStateStatus string
Author struct {
Login string
@ -138,14 +140,6 @@ type PullRequestReviewStatus struct {
ReviewRequired bool
}
type PullRequestMergeMethod int
const (
PullRequestMergeMethodMerge PullRequestMergeMethod = iota
PullRequestMergeMethodRebase
PullRequestMergeMethodSquash
)
func (pr *PullRequest) ReviewStatus() PullRequestReviewStatus {
var status PullRequestReviewStatus
switch pr.ReviewDecision {
@ -289,7 +283,10 @@ func PullRequests(client *Client, repo ghrepo.Interface, currentPRNumber int, cu
type response struct {
Repository struct {
DefaultBranchRef struct {
Name string
Name string
BranchProtectionRule struct {
RequiresStrictStatusChecks bool
}
}
PullRequests edges
PullRequest *PullRequest
@ -341,6 +338,7 @@ func PullRequests(client *Client, repo ghrepo.Interface, currentPRNumber int, cu
state
url
headRefName
mergeStateStatus
headRepositoryOwner {
login
}
@ -357,7 +355,12 @@ func PullRequests(client *Client, repo ghrepo.Interface, currentPRNumber int, cu
queryPrefix := `
query PullRequestStatus($owner: String!, $repo: String!, $headRefName: String!, $viewerQuery: String!, $reviewerQuery: String!, $per_page: Int = 10) {
repository(owner: $owner, name: $repo) {
defaultBranchRef { name }
defaultBranchRef {
name
branchProtectionRule {
requiresStrictStatusChecks
}
}
pullRequests(headRefName: $headRefName, first: $per_page, orderBy: { field: CREATED_AT, direction: DESC }) {
totalCount
edges {
@ -372,7 +375,12 @@ func PullRequests(client *Client, repo ghrepo.Interface, currentPRNumber int, cu
queryPrefix = `
query PullRequestStatus($owner: String!, $repo: String!, $number: Int!, $viewerQuery: String!, $reviewerQuery: String!, $per_page: Int = 10) {
repository(owner: $owner, name: $repo) {
defaultBranchRef { name }
defaultBranchRef {
name
branchProtectionRule {
requiresStrictStatusChecks
}
}
pullRequest(number: $number) {
...prWithReviews
}
@ -459,8 +467,9 @@ func PullRequests(client *Client, repo ghrepo.Interface, currentPRNumber int, cu
PullRequests: reviewRequested,
TotalCount: resp.ReviewRequested.TotalCount,
},
CurrentPR: currentPR,
DefaultBranch: resp.Repository.DefaultBranchRef.Name,
CurrentPR: currentPR,
DefaultBranch: resp.Repository.DefaultBranchRef.Name,
StrictProtection: resp.Repository.DefaultBranchRef.BranchProtectionRule.RequiresStrictStatusChecks,
}
return &payload, nil
@ -1071,47 +1080,6 @@ func PullRequestReopen(client *Client, repo ghrepo.Interface, pr *PullRequest) e
return err
}
func PullRequestMerge(client *Client, repo ghrepo.Interface, pr *PullRequest, m PullRequestMergeMethod, body *string) error {
mergeMethod := githubv4.PullRequestMergeMethodMerge
switch m {
case PullRequestMergeMethodRebase:
mergeMethod = githubv4.PullRequestMergeMethodRebase
case PullRequestMergeMethodSquash:
mergeMethod = githubv4.PullRequestMergeMethodSquash
}
var mutation struct {
MergePullRequest struct {
PullRequest struct {
ID githubv4.ID
}
} `graphql:"mergePullRequest(input: $input)"`
}
input := githubv4.MergePullRequestInput{
PullRequestID: pr.ID,
MergeMethod: &mergeMethod,
}
if m == PullRequestMergeMethodSquash {
commitHeadline := githubv4.String(fmt.Sprintf("%s (#%d)", pr.Title, pr.Number))
input.CommitHeadline = &commitHeadline
}
if body != nil {
commitBody := githubv4.String(*body)
input.CommitBody = &commitBody
}
variables := map[string]interface{}{
"input": input,
}
gql := graphQLClient(client.http, repo.RepoHost())
err := gql.MutateNamed(context.Background(), "PullRequestMerge", &mutation, variables)
return err
}
func PullRequestReady(client *Client, repo ghrepo.Interface, pr *PullRequest) error {
var mutation struct {
MarkPullRequestReadyForReview struct {

View file

@ -9,7 +9,6 @@ import (
"strings"
"github.com/cli/cli/api"
"github.com/cli/cli/internal/config"
"github.com/cli/cli/internal/ghinstance"
"github.com/cli/cli/pkg/browser"
"github.com/cli/cli/pkg/iostreams"
@ -23,7 +22,12 @@ var (
oauthClientSecret = "34ddeff2b558a23d38fba8a6de74f086ede1cc0b"
)
func AuthFlowWithConfig(cfg config.Config, IO *iostreams.IOStreams, hostname, notice string, additionalScopes []string) (string, error) {
type iconfig interface {
Set(string, string, string) error
Write() error
}
func AuthFlowWithConfig(cfg iconfig, IO *iostreams.IOStreams, hostname, notice string, additionalScopes []string) (string, error) {
// TODO this probably shouldn't live in this package. It should probably be in a new package that
// depends on both iostreams and config.
stderr := IO.ErrOut
@ -65,7 +69,7 @@ func authFlow(oauthHost string, IO *iostreams.IOStreams, notice string, addition
httpClient.Transport = api.VerboseLog(IO.ErrOut, logTraffic, IO.ColorEnabled())(httpClient.Transport)
}
minimumScopes := []string{"repo", "read:org", "gist", "workflow"}
minimumScopes := []string{"repo", "read:org", "gist"}
scopes := append(minimumScopes, additionalScopes...)
callbackURI := "http://127.0.0.1/callback"

View file

@ -4,12 +4,11 @@ import (
"errors"
"fmt"
"io/ioutil"
"net/http"
"strings"
"github.com/AlecAivazis/survey/v2"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/api"
"github.com/cli/cli/internal/authflow"
"github.com/cli/cli/internal/config"
"github.com/cli/cli/internal/ghinstance"
"github.com/cli/cli/pkg/cmd/auth/shared"
@ -20,8 +19,9 @@ import (
)
type LoginOptions struct {
IO *iostreams.IOStreams
Config func() (config.Config, error)
IO *iostreams.IOStreams
Config func() (config.Config, error)
HttpClient func() (*http.Client, error)
Interactive bool
@ -33,8 +33,9 @@ type LoginOptions struct {
func NewCmdLogin(f *cmdutil.Factory, runF func(*LoginOptions) error) *cobra.Command {
opts := &LoginOptions{
IO: f.IOStreams,
Config: f.Config,
IO: f.IOStreams,
Config: f.Config,
HttpClient: f.HttpClient,
}
var tokenStdin bool
@ -143,18 +144,18 @@ func loginRun(opts *LoginOptions) error {
return err
}
httpClient, err := opts.HttpClient()
if err != nil {
return err
}
if opts.Token != "" {
err := cfg.Set(hostname, "oauth_token", opts.Token)
if err != nil {
return err
}
apiClient, err := shared.ClientFromCfg(hostname, cfg)
if err != nil {
return err
}
if err := apiClient.HasMinimumScopes(hostname); err != nil {
if err := shared.HasMinimumScopes(httpClient, hostname, opts.Token); err != nil {
return fmt.Errorf("error validating token: %w", err)
}
@ -162,146 +163,33 @@ func loginRun(opts *LoginOptions) error {
}
existingToken, _ := cfg.Get(hostname, "oauth_token")
if existingToken != "" && opts.Interactive {
apiClient, err := shared.ClientFromCfg(hostname, cfg)
if err != nil {
return err
}
if err := apiClient.HasMinimumScopes(hostname); err == nil {
username, err := api.CurrentLoginName(apiClient, hostname)
if err != nil {
return fmt.Errorf("error using api: %w", err)
}
if err := shared.HasMinimumScopes(httpClient, hostname, existingToken); err == nil {
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),
"You're already logged into %s. Do you want to re-authenticate?",
hostname),
Default: false,
}, &keepGoing)
if err != nil {
return fmt.Errorf("could not prompt: %w", err)
}
if !keepGoing {
return nil
}
}
}
var authMode int
if opts.Web {
authMode = 0
} else {
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)
}
}
userValidated := false
if authMode == 0 {
_, err := authflow.AuthFlowWithConfig(cfg, opts.IO, hostname, "", opts.Scopes)
if err != nil {
return fmt.Errorf("failed to authenticate via web browser: %w", err)
}
userValidated = true
} else {
fmt.Fprintln(opts.IO.ErrOut)
fmt.Fprintln(opts.IO.ErrOut, heredoc.Doc(getAccessTokenTip(hostname)))
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)
}
err = cfg.Set(hostname, "oauth_token", token)
if err != nil {
return err
}
apiClient, err := shared.ClientFromCfg(hostname, cfg)
if err != nil {
return err
}
if err := apiClient.HasMinimumScopes(hostname); err != nil {
return fmt.Errorf("error validating token: %w", err)
}
}
cs := opts.IO.ColorScheme()
var gitProtocol string
if opts.Interactive {
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", cs.SuccessIcon())
}
var username string
if userValidated {
username, _ = cfg.Get(hostname, "user")
} else {
apiClient, err := shared.ClientFromCfg(hostname, cfg)
if err != nil {
return err
}
username, err = api.CurrentLoginName(apiClient, hostname)
if err != nil {
return fmt.Errorf("error using api: %w", err)
}
err = cfg.Set(hostname, "user", username)
if err != nil {
return err
}
}
err = cfg.Write()
if err != nil {
return err
}
if opts.Interactive && gitProtocol == "https" {
err := shared.GitCredentialSetup(cfg, hostname, username)
if err != nil {
return err
}
}
fmt.Fprintf(opts.IO.ErrOut, "%s Logged in as %s\n", cs.SuccessIcon(), cs.Bold(username))
return nil
return shared.Login(&shared.LoginOptions{
IO: opts.IO,
Config: cfg,
HTTPClient: httpClient,
Hostname: hostname,
Interactive: opts.Interactive,
Web: opts.Web,
Scopes: opts.Scopes,
})
}
func promptForHostname() (string, error) {
@ -332,13 +220,3 @@ func promptForHostname() (string, error) {
return hostname, nil
}
func getAccessTokenTip(hostname string) string {
ghHostname := hostname
if ghHostname == "" {
ghHostname = ghinstance.OverridableDefault()
}
return fmt.Sprintf(`
Tip: you can generate a Personal Access Token here https://%s/settings/tokens
The minimum required scopes are 'repo' and 'read:org'.`, ghHostname)
}

View file

@ -8,9 +8,8 @@ import (
"testing"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/api"
"github.com/cli/cli/internal/config"
"github.com/cli/cli/pkg/cmd/auth/shared"
"github.com/cli/cli/internal/run"
"github.com/cli/cli/pkg/cmdutil"
"github.com/cli/cli/pkg/httpmock"
"github.com/cli/cli/pkg/iostreams"
@ -300,13 +299,8 @@ func Test_loginRun_nontty(t *testing.T) {
tt.opts.IO = io
t.Run(tt.name, func(t *testing.T) {
reg := &httpmock.Registry{}
origClientFromCfg := shared.ClientFromCfg
defer func() {
shared.ClientFromCfg = origClientFromCfg
}()
shared.ClientFromCfg = func(_ string, _ config.Config) (*api.Client, error) {
httpClient := &http.Client{Transport: reg}
return api.NewClientFromHTTP(httpClient), nil
tt.opts.HttpClient = func() (*http.Client, error) {
return &http.Client{Transport: reg}, nil
}
old_GH_TOKEN := os.Getenv("GH_TOKEN")
@ -328,6 +322,9 @@ func Test_loginRun_nontty(t *testing.T) {
tt.httpStubs(reg)
}
_, restoreRun := run.Stub()
defer restoreRun(t)
mainBuf := bytes.Buffer{}
hostsBuf := bytes.Buffer{}
defer config.StubWriteConfig(&mainBuf, &hostsBuf)()
@ -353,6 +350,7 @@ func Test_loginRun_Survey(t *testing.T) {
opts *LoginOptions
httpStubs func(*httpmock.Registry)
askStubs func(*prompt.AskStubber)
runStubs func(*run.CommandStubber)
wantHosts string
wantErrOut *regexp.Regexp
cfg func(config.Config)
@ -367,9 +365,9 @@ func Test_loginRun_Survey(t *testing.T) {
},
httpStubs: func(reg *httpmock.Registry) {
reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,read:org"))
reg.Register(
httpmock.GraphQL(`query UserCurrent\b`),
httpmock.StringResponse(`{"data":{"viewer":{"login":"jillv"}}}`))
// 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
@ -384,12 +382,21 @@ func Test_loginRun_Survey(t *testing.T) {
Hostname: "rebecca.chambers",
Interactive: true,
},
wantHosts: "rebecca.chambers:\n oauth_token: def456\n git_protocol: https\n user: jillv\n",
wantHosts: heredoc.Doc(`
rebecca.chambers:
oauth_token: def456
user: jillv
git_protocol: https
`),
askStubs: func(as *prompt.AskStubber) {
as.StubOne(1) // auth mode: token
as.StubOne("def456") // auth token
as.StubOne("HTTPS") // git_protocol
as.StubOne(false) // cache credentials
as.StubOne(1) // auth mode: token
as.StubOne("def456") // auth token
},
runStubs: func(rs *run.CommandStubber) {
rs.Register(`git config credential\.https:/`, 1, "")
rs.Register(`git config credential\.helper`, 1, "")
},
httpStubs: func(reg *httpmock.Registry) {
reg.Register(httpmock.REST("GET", "api/v3/"), httpmock.ScopesResponder("repo,read:org"))
@ -400,18 +407,27 @@ func Test_loginRun_Survey(t *testing.T) {
wantErrOut: regexp.MustCompile("Tip: you can generate a Personal Access Token here https://rebecca.chambers/settings/tokens"),
},
{
name: "choose enterprise",
wantHosts: "brad.vickers:\n oauth_token: def456\n git_protocol: https\n user: jillv\n",
name: "choose enterprise",
wantHosts: heredoc.Doc(`
brad.vickers:
oauth_token: def456
user: jillv
git_protocol: https
`),
opts: &LoginOptions{
Interactive: true,
},
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
as.StubOne(false) // cache credentials
as.StubOne(1) // auth mode: token
as.StubOne("def456") // auth token
},
runStubs: func(rs *run.CommandStubber) {
rs.Register(`git config credential\.https:/`, 1, "")
rs.Register(`git config credential\.helper`, 1, "")
},
httpStubs: func(reg *httpmock.Registry) {
reg.Register(httpmock.REST("GET", "api/v3/"), httpmock.ScopesResponder("repo,read:org,read:public_key"))
@ -422,31 +438,46 @@ func Test_loginRun_Survey(t *testing.T) {
wantErrOut: regexp.MustCompile("Tip: you can generate a Personal Access Token here https://brad.vickers/settings/tokens"),
},
{
name: "choose github.com",
wantHosts: "github.com:\n oauth_token: def456\n git_protocol: https\n user: jillv\n",
name: "choose github.com",
wantHosts: heredoc.Doc(`
github.com:
oauth_token: def456
user: jillv
git_protocol: https
`),
opts: &LoginOptions{
Interactive: true,
},
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
as.StubOne(false) // cache credentials
as.StubOne(1) // auth mode: token
as.StubOne("def456") // auth token
},
runStubs: func(rs *run.CommandStubber) {
rs.Register(`git config credential\.https:/`, 1, "")
rs.Register(`git config credential\.helper`, 1, "")
},
wantErrOut: regexp.MustCompile("Tip: you can generate a Personal Access Token here https://github.com/settings/tokens"),
},
{
name: "sets git_protocol",
wantHosts: "github.com:\n oauth_token: def456\n git_protocol: ssh\n user: jillv\n",
name: "sets git_protocol",
wantHosts: heredoc.Doc(`
github.com:
oauth_token: def456
user: jillv
git_protocol: ssh
`),
opts: &LoginOptions{
Interactive: true,
},
askStubs: func(as *prompt.AskStubber) {
as.StubOne(0) // host type github.com
as.StubOne("SSH") // git_protocol
as.StubOne(10) // TODO: SSH key selection
as.StubOne(1) // auth mode: token
as.StubOne("def456") // auth token
as.StubOne("SSH") // git_protocol
},
wantErrOut: regexp.MustCompile("Tip: you can generate a Personal Access Token here https://github.com/settings/tokens"),
},
@ -476,13 +507,8 @@ func Test_loginRun_Survey(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
reg := &httpmock.Registry{}
origClientFromCfg := shared.ClientFromCfg
defer func() {
shared.ClientFromCfg = origClientFromCfg
}()
shared.ClientFromCfg = func(_ string, _ config.Config) (*api.Client, error) {
httpClient := &http.Client{Transport: reg}
return api.NewClientFromHTTP(httpClient), nil
tt.opts.HttpClient = func() (*http.Client, error) {
return &http.Client{Transport: reg}, nil
}
if tt.httpStubs != nil {
tt.httpStubs(reg)
@ -503,6 +529,12 @@ func Test_loginRun_Survey(t *testing.T) {
tt.askStubs(as)
}
rs, restoreRun := run.Stub()
defer restoreRun(t)
if tt.runStubs != nil {
tt.runStubs(rs)
}
err := loginRun(tt.opts)
if err != nil {
t.Fatalf("unexpected error: %s", err)

View file

@ -121,14 +121,25 @@ func refreshRun(opts *RefreshOptions) error {
return err
}
if err := opts.AuthFlow(cfg, opts.IO, hostname, opts.Scopes); err != nil {
var additionalScopes []string
credentialFlow := &shared.GitCredentialFlow{}
gitProtocol, _ := cfg.Get(hostname, "git_protocol")
if opts.Interactive && gitProtocol == "https" {
if err := credentialFlow.Prompt(hostname); err != nil {
return err
}
additionalScopes = append(additionalScopes, credentialFlow.Scopes()...)
}
if err := opts.AuthFlow(cfg, opts.IO, hostname, append(opts.Scopes, additionalScopes...)); err != nil {
return err
}
protocol, _ := cfg.Get(hostname, "git_protocol")
if opts.Interactive && protocol == "https" {
if credentialFlow.ShouldSetup() {
username, _ := cfg.Get(hostname, "user")
if err := shared.GitCredentialSetup(cfg, hostname, username); err != nil {
password, _ := cfg.Get(hostname, "oauth_token")
if err := credentialFlow.Setup(hostname, username, password); err != nil {
return err
}
}

View file

@ -1,34 +0,0 @@
package shared
import (
"fmt"
"net/http"
"github.com/cli/cli/api"
"github.com/cli/cli/internal/config"
)
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
}

View file

@ -14,29 +14,46 @@ import (
"github.com/google/shlex"
)
type configReader interface {
Get(string, string) (string, error)
type GitCredentialFlow struct {
shouldSetup bool
helper string
scopes []string
}
func GitCredentialSetup(cfg configReader, hostname, username string) error {
helper, _ := gitCredentialHelper(hostname)
if isOurCredentialHelper(helper) {
func (flow *GitCredentialFlow) Prompt(hostname string) error {
flow.helper, _ = gitCredentialHelper(hostname)
if isOurCredentialHelper(flow.helper) {
flow.scopes = append(flow.scopes, "workflow")
return nil
}
var primeCredentials bool
err := prompt.SurveyAskOne(&survey.Confirm{
Message: "Authenticate Git with your GitHub credentials?",
Default: true,
}, &primeCredentials)
}, &flow.shouldSetup)
if err != nil {
return fmt.Errorf("could not prompt: %w", err)
}
if !primeCredentials {
return nil
if flow.shouldSetup {
flow.scopes = append(flow.scopes, "workflow")
}
return nil
}
func (flow *GitCredentialFlow) Scopes() []string {
return flow.scopes
}
func (flow *GitCredentialFlow) ShouldSetup() bool {
return flow.shouldSetup
}
func (flow *GitCredentialFlow) Setup(hostname, username, authToken string) error {
return GitCredentialSetup(hostname, username, authToken, flow.helper)
}
func GitCredentialSetup(hostname, username, password, helper string) error {
if helper == "" {
// use GitHub CLI as a credential helper (for this host only)
configureCmd, err := git.GitCommand("config", "--global", gitCredentialHelperKey(hostname), "!gh auth git-credential")
@ -67,7 +84,6 @@ func GitCredentialSetup(cfg configReader, hostname, username string) error {
return err
}
password, _ := cfg.Get(hostname, "oauth_token")
approveCmd.Stdin = bytes.NewBufferString(heredoc.Docf(`
protocol=https
host=%s

View file

@ -1,88 +1,73 @@
package shared
import (
"fmt"
"testing"
"github.com/cli/cli/internal/run"
"github.com/cli/cli/pkg/prompt"
)
type tinyConfig map[string]string
func (c tinyConfig) Get(host, key string) (string, error) {
return c[fmt.Sprintf("%s:%s", host, key)], nil
}
func TestGitCredentialSetup_configureExisting(t *testing.T) {
cfg := tinyConfig{"example.com:oauth_token": "OTOKEN"}
cs, restoreRun := run.Stub()
defer restoreRun(t)
cs.Register(`git config credential\.https://example\.com\.helper`, 1, "")
cs.Register(`git config credential\.helper`, 0, "osxkeychain\n")
cs.Register(`git credential reject`, 0, "")
cs.Register(`git credential approve`, 0, "")
as, restoreAsk := prompt.InitAskStubber()
defer restoreAsk()
as.StubOne(true)
if err := GitCredentialSetup(cfg, "example.com", "monalisa"); err != nil {
if err := GitCredentialSetup("example.com", "monalisa", "PASSWD", "osxkeychain"); err != nil {
t.Errorf("GitCredentialSetup() error = %v", err)
}
}
func TestGitCredentialSetup_setOurs(t *testing.T) {
cfg := tinyConfig{"example.com:oauth_token": "OTOKEN"}
cs, restoreRun := run.Stub()
defer restoreRun(t)
cs.Register(`git config credential\.https://example\.com\.helper`, 1, "")
cs.Register(`git config credential\.helper`, 1, "")
cs.Register(`git config --global credential\.https://example\.com\.helper`, 0, "", func(args []string) {
if val := args[len(args)-1]; val != "!gh auth git-credential" {
t.Errorf("global credential helper configured to %q", val)
}
})
as, restoreAsk := prompt.InitAskStubber()
defer restoreAsk()
as.StubOne(true)
if err := GitCredentialSetup(cfg, "example.com", "monalisa"); err != nil {
if err := GitCredentialSetup("example.com", "monalisa", "PASSWD", ""); err != nil {
t.Errorf("GitCredentialSetup() error = %v", err)
}
}
func TestGitCredentialSetup_promptDeny(t *testing.T) {
cfg := tinyConfig{"example.com:oauth_token": "OTOKEN"}
cs, restoreRun := run.Stub()
defer restoreRun(t)
cs.Register(`git config credential\.https://example\.com\.helper`, 1, "")
cs.Register(`git config credential\.helper`, 1, "")
as, restoreAsk := prompt.InitAskStubber()
defer restoreAsk()
as.StubOne(false)
if err := GitCredentialSetup(cfg, "example.com", "monalisa"); err != nil {
t.Errorf("GitCredentialSetup() error = %v", err)
}
}
func TestGitCredentialSetup_isOurs(t *testing.T) {
cfg := tinyConfig{"example.com:oauth_token": "OTOKEN"}
cs, restoreRun := run.Stub()
defer restoreRun(t)
cs.Register(`git config credential\.https://example\.com\.helper`, 0, "!/path/to/gh auth\n")
_, restoreAsk := prompt.InitAskStubber()
defer restoreAsk()
if err := GitCredentialSetup(cfg, "example.com", "monalisa"); err != nil {
t.Errorf("GitCredentialSetup() error = %v", err)
func Test_isOurCredentialHelper(t *testing.T) {
tests := []struct {
name string
arg string
want bool
}{
{
name: "blank",
arg: "",
want: false,
},
{
name: "invalid",
arg: "!",
want: false,
},
{
name: "osxkeychain",
arg: "osxkeychain",
want: false,
},
{
name: "looks like gh but isn't",
arg: "gh auth",
want: false,
},
{
name: "ours",
arg: "!/path/to/gh auth",
want: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := isOurCredentialHelper(tt.arg); got != tt.want {
t.Errorf("isOurCredentialHelper() = %v, want %v", got, tt.want)
}
})
}
}

View file

@ -0,0 +1,203 @@
package shared
import (
"fmt"
"net/http"
"strings"
"github.com/AlecAivazis/survey/v2"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/api"
"github.com/cli/cli/internal/authflow"
"github.com/cli/cli/pkg/iostreams"
"github.com/cli/cli/pkg/prompt"
)
type iconfig interface {
Get(string, string) (string, error)
Set(string, string, string) error
Write() error
}
type LoginOptions struct {
IO *iostreams.IOStreams
Config iconfig
HTTPClient *http.Client
Hostname string
Interactive bool
Web bool
Scopes []string
sshContext sshContext
}
func Login(opts *LoginOptions) error {
cfg := opts.Config
hostname := opts.Hostname
httpClient := opts.HTTPClient
cs := opts.IO.ColorScheme()
var gitProtocol string
if opts.Interactive {
var proto string
err := prompt.SurveyAskOne(&survey.Select{
Message: "What is your preferred protocol for Git operations?",
Options: []string{
"HTTPS",
"SSH",
},
}, &proto)
if err != nil {
return fmt.Errorf("could not prompt: %w", err)
}
gitProtocol = strings.ToLower(proto)
}
var additionalScopes []string
credentialFlow := &GitCredentialFlow{}
if opts.Interactive && gitProtocol == "https" {
if err := credentialFlow.Prompt(hostname); err != nil {
return err
}
additionalScopes = append(additionalScopes, credentialFlow.Scopes()...)
}
var keyToUpload string
if opts.Interactive && gitProtocol == "ssh" {
pubKeys, err := opts.sshContext.localPublicKeys()
if err != nil {
return err
}
if len(pubKeys) > 0 {
var keyChoice int
err := prompt.SurveyAskOne(&survey.Select{
Message: "Upload your SSH public key to your GitHub account?",
Options: append(pubKeys, "Skip"),
}, &keyChoice)
if err != nil {
return fmt.Errorf("could not prompt: %w", err)
}
if keyChoice < len(pubKeys) {
keyToUpload = pubKeys[keyChoice]
}
} else {
var err error
keyToUpload, err = opts.sshContext.generateSSHKey()
if err != nil {
return err
}
}
}
if keyToUpload != "" {
additionalScopes = append(additionalScopes, "admin:public_key")
}
var authMode int
if opts.Web {
authMode = 0
} else {
err := prompt.SurveyAskOne(&survey.Select{
Message: "How would you like to authenticate GitHub CLI?",
Options: []string{
"Login with a web browser",
"Paste an authentication token",
},
}, &authMode)
if err != nil {
return fmt.Errorf("could not prompt: %w", err)
}
}
var authToken string
userValidated := false
if authMode == 0 {
var err error
authToken, err = authflow.AuthFlowWithConfig(cfg, opts.IO, hostname, "", append(opts.Scopes, additionalScopes...))
if err != nil {
return fmt.Errorf("failed to authenticate via web browser: %w", err)
}
userValidated = true
} else {
minimumScopes := append([]string{"repo", "read:org"}, additionalScopes...)
fmt.Fprint(opts.IO.ErrOut, heredoc.Docf(`
Tip: you can generate a Personal Access Token here https://%s/settings/tokens
The minimum required scopes are %s.
`, hostname, scopesSentence(minimumScopes)))
err := prompt.SurveyAskOne(&survey.Password{
Message: "Paste your authentication token:",
}, &authToken, survey.WithValidator(survey.Required))
if err != nil {
return fmt.Errorf("could not prompt: %w", err)
}
if err := HasMinimumScopes(httpClient, hostname, authToken); err != nil {
return fmt.Errorf("error validating token: %w", err)
}
if err := cfg.Set(hostname, "oauth_token", authToken); err != nil {
return err
}
}
var username string
if userValidated {
username, _ = cfg.Get(hostname, "user")
} else {
apiClient := api.NewClientFromHTTP(httpClient)
var err error
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
}
}
if 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", cs.SuccessIcon())
}
err := cfg.Write()
if err != nil {
return err
}
if credentialFlow.ShouldSetup() {
err := credentialFlow.Setup(hostname, username, authToken)
if err != nil {
return err
}
}
if keyToUpload != "" {
err := sshKeyUpload(httpClient, hostname, keyToUpload)
if err != nil {
return err
}
fmt.Fprintf(opts.IO.ErrOut, "%s Uploaded the SSH key to your GitHub account: %s\n", cs.SuccessIcon(), cs.Bold(keyToUpload))
}
fmt.Fprintf(opts.IO.ErrOut, "%s Logged in as %s\n", cs.SuccessIcon(), cs.Bold(username))
return nil
}
func scopesSentence(scopes []string) string {
quoted := make([]string, len(scopes))
for i, s := range scopes {
quoted[i] = fmt.Sprintf("'%s'", s)
}
// TODO: insert an "and" before the last element
return strings.Join(quoted, ", ")
}

View file

@ -0,0 +1,103 @@
package shared
import (
"fmt"
"io/ioutil"
"net/http"
"path/filepath"
"testing"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/internal/run"
"github.com/cli/cli/pkg/httpmock"
"github.com/cli/cli/pkg/iostreams"
"github.com/cli/cli/pkg/prompt"
"github.com/stretchr/testify/assert"
)
type tinyConfig map[string]string
func (c tinyConfig) Get(host, key string) (string, error) {
return c[fmt.Sprintf("%s:%s", host, key)], nil
}
func (c tinyConfig) Set(host string, key string, value string) error {
c[fmt.Sprintf("%s:%s", host, key)] = value
return nil
}
func (c tinyConfig) Write() error {
return nil
}
func TestLogin_ssh(t *testing.T) {
dir := t.TempDir()
io, _, stdout, stderr := iostreams.Test()
tr := httpmock.Registry{}
defer tr.Verify(t)
tr.Register(
httpmock.REST("GET", "api/v3/"),
httpmock.ScopesResponder("repo,read:org"))
tr.Register(
httpmock.GraphQL(`query UserCurrent\b`),
httpmock.StringResponse(`{"data":{"viewer":{ "login": "monalisa" }}}`))
tr.Register(
httpmock.REST("POST", "api/v3/user/keys"),
httpmock.StringResponse(`{}`))
ask, askRestore := prompt.InitAskStubber()
defer askRestore()
ask.StubOne("SSH") // preferred protocol
ask.StubOne(true) // generate a new key
ask.StubOne("monkey") // enter a passphrase
ask.StubOne(1) // paste a token
ask.StubOne("ATOKEN") // token
rs, runRestore := run.Stub()
defer runRestore(t)
keyFile := filepath.Join(dir, "id_ed25519")
rs.Register(`ssh-keygen`, 0, "", func(args []string) {
expected := []string{
"ssh-keygen", "-t", "ed25519",
"-C", "",
"-N", "monkey",
"-f", keyFile,
}
assert.Equal(t, expected, args)
// simulate that the public key file has been generated
_ = ioutil.WriteFile(keyFile+".pub", []byte("PUBKEY"), 0600)
})
cfg := tinyConfig{}
err := Login(&LoginOptions{
IO: io,
Config: &cfg,
HTTPClient: &http.Client{Transport: &tr},
Hostname: "example.com",
Interactive: true,
sshContext: sshContext{
configDir: dir,
keygenExe: "ssh-keygen",
},
})
assert.NoError(t, err)
assert.Equal(t, "", stdout.String())
assert.Equal(t, heredoc.Docf(`
Tip: you can generate a Personal Access Token here https://example.com/settings/tokens
The minimum required scopes are 'repo', 'read:org', 'admin:public_key'.
- gh config set -h example.com git_protocol ssh
Configured git protocol
Uploaded the SSH key to your GitHub account: %s.pub
Logged in as monalisa
`, keyFile), stderr.String())
assert.Equal(t, "monalisa", cfg["example.com:user"])
assert.Equal(t, "ATOKEN", cfg["example.com:oauth_token"])
assert.Equal(t, "ssh", cfg["example.com:git_protocol"])
}

View file

@ -0,0 +1,90 @@
package shared
import (
"fmt"
"io"
"io/ioutil"
"net/http"
"strings"
"github.com/cli/cli/api"
"github.com/cli/cli/internal/ghinstance"
)
type MissingScopesError struct {
MissingScopes []string
}
func (e MissingScopesError) Error() string {
var missing []string
for _, s := range e.MissingScopes {
missing = append(missing, fmt.Sprintf("'%s'", s))
}
scopes := strings.Join(missing, ", ")
if len(e.MissingScopes) == 1 {
return "missing required scope " + scopes
}
return "missing required scopes " + scopes
}
type httpClient interface {
Do(*http.Request) (*http.Response, error)
}
func HasMinimumScopes(httpClient httpClient, hostname, authToken string) error {
apiEndpoint := ghinstance.RESTPrefix(hostname)
req, err := http.NewRequest("GET", apiEndpoint, nil)
if err != nil {
return err
}
req.Header.Set("Autorization", "token "+authToken)
res, err := httpClient.Do(req)
if err != nil {
return err
}
defer func() {
// Ensure the response body is fully read and closed
// before we reconnect, so that we reuse the same TCPconnection.
_, _ = io.Copy(ioutil.Discard, res.Body)
res.Body.Close()
}()
if res.StatusCode != 200 {
return api.HandleHTTPError(res)
}
scopesHeader := res.Header.Get("X-Oauth-Scopes")
if scopesHeader == "" {
// if the token reports no scopes, assume that it's an integration token and give up on
// detecting its capabilities
return nil
}
search := map[string]bool{
"repo": false,
"read:org": false,
"admin:org": false,
}
for _, s := range strings.Split(scopesHeader, ",") {
search[strings.TrimSpace(s)] = true
}
var missingScopes []string
if !search["repo"] {
missingScopes = append(missingScopes, "repo")
}
if !search["read:org"] && !search["write:org"] && !search["admin:org"] {
missingScopes = append(missingScopes, "read:org")
}
if len(missingScopes) > 0 {
return &MissingScopesError{MissingScopes: missingScopes}
}
return nil
}

View file

@ -0,0 +1,76 @@
package shared
import (
"bytes"
"io/ioutil"
"net/http"
"testing"
"github.com/cli/cli/pkg/httpmock"
"github.com/stretchr/testify/assert"
)
func Test_HasMinimumScopes(t *testing.T) {
tests := []struct {
name string
header string
wantErr string
}{
{
name: "no scopes",
header: "",
wantErr: "",
},
{
name: "default scopes",
header: "repo, read:org",
wantErr: "",
},
{
name: "admin:org satisfies read:org",
header: "repo, admin:org",
wantErr: "",
},
{
name: "write:org satisfies read:org",
header: "repo, write:org",
wantErr: "",
},
{
name: "insufficient scope",
header: "repo",
wantErr: "missing required scope 'read:org'",
},
{
name: "insufficient scopes",
header: "gist",
wantErr: "missing required scopes 'repo', 'read:org'",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
fakehttp := &httpmock.Registry{}
defer fakehttp.Verify(t)
fakehttp.Register(httpmock.REST("GET", ""), func(req *http.Request) (*http.Response, error) {
return &http.Response{
Request: req,
StatusCode: 200,
Body: ioutil.NopCloser(&bytes.Buffer{}),
Header: map[string][]string{
"X-Oauth-Scopes": {tt.header},
},
}, nil
})
client := http.Client{Transport: fakehttp}
err := HasMinimumScopes(&client, "github.com", "ATOKEN")
if tt.wantErr != "" {
assert.EqualError(t, err, tt.wantErr)
} else {
assert.NoError(t, err)
}
})
}
}

View file

@ -0,0 +1,171 @@
package shared
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"io/ioutil"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"github.com/AlecAivazis/survey/v2"
"github.com/cli/cli/api"
"github.com/cli/cli/internal/config"
"github.com/cli/cli/internal/ghinstance"
"github.com/cli/cli/internal/run"
"github.com/cli/cli/pkg/prompt"
"github.com/cli/safeexec"
)
type sshContext struct {
configDir string
keygenExe string
}
func (c *sshContext) sshDir() (string, error) {
if c.configDir != "" {
return c.configDir, nil
}
dir, err := config.HomeDirPath(".ssh")
if err == nil {
c.configDir = dir
}
return dir, err
}
func (c *sshContext) localPublicKeys() ([]string, error) {
sshDir, err := c.sshDir()
if err != nil {
return nil, err
}
return filepath.Glob(filepath.Join(sshDir, "*.pub"))
}
func (c *sshContext) findKeygen() (string, error) {
if c.keygenExe != "" {
return c.keygenExe, nil
}
keygenExe, err := safeexec.LookPath("ssh-keygen")
if err != nil && runtime.GOOS == "windows" {
// We can try and find ssh-keygen in a Git for Windows install
if gitPath, err := safeexec.LookPath("git"); err == nil {
gitKeygen := filepath.Join(filepath.Dir(gitPath), "..", "usr", "bin", "ssh-keygen.exe")
if _, err = os.Stat(gitKeygen); err == nil {
return gitKeygen, nil
}
}
}
if err == nil {
c.keygenExe = keygenExe
}
return keygenExe, err
}
func (c *sshContext) generateSSHKey() (string, error) {
keygenExe, err := c.findKeygen()
if err != nil {
// give up silently if `ssh-keygen` is not available
return "", nil
}
var sshChoice bool
err = prompt.SurveyAskOne(&survey.Confirm{
Message: "Generate a new SSH key to add to your GitHub account?",
Default: true,
}, &sshChoice)
if err != nil {
return "", fmt.Errorf("could not prompt: %w", err)
}
if !sshChoice {
return "", nil
}
sshDir, err := c.sshDir()
if err != nil {
return "", err
}
keyFile := filepath.Join(sshDir, "id_ed25519")
if _, err := os.Stat(keyFile); err == nil {
return "", fmt.Errorf("refusing to overwrite file %s", keyFile)
}
if err := os.MkdirAll(filepath.Dir(keyFile), 0711); err != nil {
return "", err
}
var sshLabel string
var sshPassphrase string
err = prompt.SurveyAskOne(&survey.Password{
Message: "Enter a passphrase for your new SSH key (Optional)",
}, &sshPassphrase)
if err != nil {
return "", fmt.Errorf("could not prompt: %w", err)
}
keygenCmd := exec.Command(keygenExe, "-t", "ed25519", "-C", sshLabel, "-N", sshPassphrase, "-f", keyFile)
return keyFile + ".pub", run.PrepareCmd(keygenCmd).Run()
}
func sshKeyUpload(httpClient *http.Client, hostname, keyFile string) error {
url := ghinstance.RESTPrefix(hostname) + "user/keys"
f, err := os.Open(keyFile)
if err != nil {
return err
}
defer f.Close()
keyBytes, err := ioutil.ReadAll(f)
if err != nil {
return err
}
payload := map[string]string{
"title": "GitHub CLI",
"key": string(keyBytes),
}
payloadBytes, err := json.Marshal(payload)
if err != nil {
return err
}
req, err := http.NewRequest("POST", url, bytes.NewBuffer(payloadBytes))
if err != nil {
return err
}
resp, err := httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode > 299 {
var httpError api.HTTPError
err := api.HandleHTTPError(resp)
if errors.As(err, &httpError) && isDuplicateError(&httpError) {
return nil
}
return err
}
_, err = io.Copy(ioutil.Discard, resp.Body)
if err != nil {
return err
}
return nil
}
func isDuplicateError(err *api.HTTPError) bool {
return err.StatusCode == 422 && len(err.Errors) == 1 &&
err.Errors[0].Field == "key" && err.Errors[0].Message == "key is already in use"
}

View file

@ -8,6 +8,7 @@ import (
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/api"
"github.com/cli/cli/internal/config"
"github.com/cli/cli/pkg/cmd/auth/shared"
"github.com/cli/cli/pkg/cmdutil"
"github.com/cli/cli/pkg/iostreams"
"github.com/spf13/cobra"
@ -78,7 +79,6 @@ func statusRun(opts *StatusOptions) error {
if err != nil {
return err
}
apiClient := api.NewClientFromHTTP(httpClient)
var failed bool
var isHostnameFound bool
@ -97,9 +97,8 @@ func statusRun(opts *StatusOptions) error {
statusInfo[hostname] = append(statusInfo[hostname], fmt.Sprintf(x, ys...))
}
err = apiClient.HasMinimumScopes(hostname)
if err != nil {
var missingScopes *api.MissingScopesError
if err := shared.HasMinimumScopes(httpClient, hostname, token); err != nil {
var missingScopes *shared.MissingScopesError
if errors.As(err, &missingScopes) {
addMsg("%s %s: the token in %s is %s", cs.Red("X"), hostname, tokenSource, err)
if tokenIsWriteable {
@ -119,6 +118,7 @@ func statusRun(opts *StatusOptions) error {
}
failed = true
} else {
apiClient := api.NewClientFromHTTP(httpClient)
username, err := api.CurrentLoginName(apiClient, hostname)
if err != nil {
addMsg("%s %s: api call failed: %s", cs.Red("X"), hostname, err)

View file

@ -6,9 +6,7 @@ import (
"regexp"
"testing"
"github.com/cli/cli/api"
"github.com/cli/cli/internal/config"
"github.com/cli/cli/pkg/cmd/auth/shared"
"github.com/cli/cli/pkg/cmdutil"
"github.com/cli/cli/pkg/httpmock"
"github.com/cli/cli/pkg/iostreams"
@ -228,14 +226,6 @@ func Test_statusRun(t *testing.T) {
}
reg := &httpmock.Registry{}
origClientFromCfg := shared.ClientFromCfg
defer func() {
shared.ClientFromCfg = origClientFromCfg
}()
shared.ClientFromCfg = func(_ string, _ config.Config) (*api.Client, error) {
httpClient := &http.Client{Transport: reg}
return api.NewClientFromHTTP(httpClient), nil
}
tt.opts.HttpClient = func() (*http.Client, error) {
return &http.Client{Transport: reg}, nil
}

View file

@ -87,6 +87,8 @@ func NewHTTPClient(io *iostreams.IOStreams, cfg config.Config, appVersion string
api.AddHeaderFunc("Accept", func(req *http.Request) (string, error) {
// antiope-preview: Checks
accept := "application/vnd.github.antiope-preview+json"
// introduced for #2952: pr branch up to date status
accept += ", application/vnd.github.merge-info-preview+json"
if ghinstance.IsEnterprise(req.URL.Hostname()) {
// shadow-cat-preview: Draft pull requests
accept += ", application/vnd.github.shadow-cat-preview"

138
pkg/cmd/pr/merge/http.go Normal file
View file

@ -0,0 +1,138 @@
package merge
import (
"context"
"net/http"
"strings"
"github.com/cli/cli/internal/ghinstance"
"github.com/cli/cli/internal/ghrepo"
"github.com/shurcooL/githubv4"
"github.com/shurcooL/graphql"
)
type PullRequestMergeMethod int
const (
PullRequestMergeMethodMerge PullRequestMergeMethod = iota
PullRequestMergeMethodRebase
PullRequestMergeMethodSquash
)
type mergePayload struct {
repo ghrepo.Interface
pullRequestID string
method PullRequestMergeMethod
auto bool
commitSubject string
setCommitSubject bool
commitBody string
setCommitBody bool
}
// TODO: drop after githubv4 gets updated
type EnablePullRequestAutoMergeInput struct {
githubv4.MergePullRequestInput
}
func mergePullRequest(client *http.Client, payload mergePayload) error {
input := githubv4.MergePullRequestInput{
PullRequestID: githubv4.ID(payload.pullRequestID),
}
switch payload.method {
case PullRequestMergeMethodMerge:
m := githubv4.PullRequestMergeMethodMerge
input.MergeMethod = &m
case PullRequestMergeMethodRebase:
m := githubv4.PullRequestMergeMethodRebase
input.MergeMethod = &m
case PullRequestMergeMethodSquash:
m := githubv4.PullRequestMergeMethodSquash
input.MergeMethod = &m
}
if payload.setCommitSubject {
commitHeadline := githubv4.String(payload.commitSubject)
input.CommitHeadline = &commitHeadline
}
if payload.setCommitBody {
commitBody := githubv4.String(payload.commitBody)
input.CommitBody = &commitBody
}
variables := map[string]interface{}{
"input": input,
}
gql := graphql.NewClient(ghinstance.GraphQLEndpoint(payload.repo.RepoHost()), client)
if payload.auto {
var mutation struct {
EnablePullRequestAutoMerge struct {
ClientMutationId string
} `graphql:"enablePullRequestAutoMerge(input: $input)"`
}
variables["input"] = EnablePullRequestAutoMergeInput{input}
return gql.MutateNamed(context.Background(), "PullRequestAutoMerge", &mutation, variables)
}
var mutation struct {
MergePullRequest struct {
ClientMutationId string
} `graphql:"mergePullRequest(input: $input)"`
}
return gql.MutateNamed(context.Background(), "PullRequestMerge", &mutation, variables)
}
func disableAutoMerge(client *http.Client, repo ghrepo.Interface, prID string) error {
var mutation struct {
DisablePullRequestAutoMerge struct {
ClientMutationId string
} `graphql:"disablePullRequestAutoMerge(input: {pullRequestId: $prID})"`
}
variables := map[string]interface{}{
"prID": githubv4.ID(prID),
}
gql := graphql.NewClient(ghinstance.GraphQLEndpoint(repo.RepoHost()), client)
return gql.MutateNamed(context.Background(), "PullRequestAutoMergeDisable", &mutation, variables)
}
func getMergeText(client *http.Client, repo ghrepo.Interface, prID string, mergeMethod PullRequestMergeMethod) (string, error) {
var method githubv4.PullRequestMergeMethod
switch mergeMethod {
case PullRequestMergeMethodMerge:
method = githubv4.PullRequestMergeMethodMerge
case PullRequestMergeMethodRebase:
method = githubv4.PullRequestMergeMethodRebase
case PullRequestMergeMethodSquash:
method = githubv4.PullRequestMergeMethodSquash
}
var query struct {
Node struct {
PullRequest struct {
ViewerMergeBodyText string `graphql:"viewerMergeBodyText(mergeType: $method)"`
} `graphql:"...on PullRequest"`
} `graphql:"node(id: $prID)"`
}
variables := map[string]interface{}{
"prID": githubv4.ID(prID),
"method": method,
}
gql := graphql.NewClient(ghinstance.GraphQLEndpoint(repo.RepoHost()), client)
err := gql.QueryNamed(context.Background(), "PullRequestMergeText", &query, variables)
if err != nil {
// Tolerate this API missing in older GitHub Enterprise
if strings.Contains(err.Error(), "Field 'viewerMergeBodyText' doesn't exist") {
return "", nil
}
return "", err
}
return query.Node.PullRequest.ViewerMergeBodyText, nil
}

View file

@ -16,6 +16,7 @@ import (
"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/spf13/cobra"
)
@ -29,9 +30,13 @@ type MergeOptions struct {
SelectorArg string
DeleteBranch bool
MergeMethod api.PullRequestMergeMethod
MergeMethod PullRequestMergeMethod
Body *string
AutoMergeEnable bool
AutoMergeDisable bool
Body string
BodySet bool
IsDeleteBranchIndicated bool
CanDeleteLocalBranch bool
@ -74,15 +79,15 @@ func NewCmdMerge(f *cmdutil.Factory, runF func(*MergeOptions) error) *cobra.Comm
methodFlags := 0
if flagMerge {
opts.MergeMethod = api.PullRequestMergeMethodMerge
opts.MergeMethod = PullRequestMergeMethodMerge
methodFlags++
}
if flagRebase {
opts.MergeMethod = api.PullRequestMergeMethodRebase
opts.MergeMethod = PullRequestMergeMethodRebase
methodFlags++
}
if flagSquash {
opts.MergeMethod = api.PullRequestMergeMethodSquash
opts.MergeMethod = PullRequestMergeMethodSquash
methodFlags++
}
if methodFlags == 0 {
@ -96,11 +101,7 @@ func NewCmdMerge(f *cmdutil.Factory, runF func(*MergeOptions) error) *cobra.Comm
opts.IsDeleteBranchIndicated = cmd.Flags().Changed("delete-branch")
opts.CanDeleteLocalBranch = !cmd.Flags().Changed("repo")
if cmd.Flags().Changed("body") {
bodyStr, _ := cmd.Flags().GetString("body")
opts.Body = &bodyStr
}
opts.BodySet = cmd.Flags().Changed("body")
if runF != nil {
return runF(opts)
@ -110,10 +111,12 @@ func NewCmdMerge(f *cmdutil.Factory, runF func(*MergeOptions) error) *cobra.Comm
}
cmd.Flags().BoolVarP(&opts.DeleteBranch, "delete-branch", "d", false, "Delete the local and remote branch after merge")
cmd.Flags().StringP("body", "b", "", "Body for merge commit")
cmd.Flags().StringVarP(&opts.Body, "body", "b", "", "Body `text` for the merge commit")
cmd.Flags().BoolVarP(&flagMerge, "merge", "m", false, "Merge the commits with the base branch")
cmd.Flags().BoolVarP(&flagRebase, "rebase", "r", false, "Rebase the commits onto the base branch")
cmd.Flags().BoolVarP(&flagSquash, "squash", "s", false, "Squash the commits into one commit and merge it into the base branch")
cmd.Flags().BoolVar(&opts.AutoMergeEnable, "auto", false, "Automatically merge only after necessary requirements are met")
cmd.Flags().BoolVar(&opts.AutoMergeDisable, "disable-auto", false, "Disable auto-merge for this pull request")
return cmd
}
@ -131,6 +134,19 @@ func mergeRun(opts *MergeOptions) error {
return err
}
isTerminal := opts.IO.IsStdoutTTY()
if opts.AutoMergeDisable {
err := disableAutoMerge(httpClient, baseRepo, pr.ID)
if err != nil {
return err
}
if isTerminal {
fmt.Fprintf(opts.IO.ErrOut, "%s Auto-merge disabled for pull request #%d\n", cs.SuccessIconWithColor(cs.Green), pr.Number)
}
return nil
}
if opts.SelectorArg == "" {
localBranchLastCommit, err := git.LastCommit()
if err == nil {
@ -148,18 +164,24 @@ func mergeRun(opts *MergeOptions) error {
deleteBranch := opts.DeleteBranch
crossRepoPR := pr.HeadRepositoryOwner.Login != baseRepo.RepoOwner()
isTerminal := opts.IO.IsStdoutTTY()
isPRAlreadyMerged := pr.State == "MERGED"
if !isPRAlreadyMerged {
mergeMethod := opts.MergeMethod
payload := mergePayload{
repo: baseRepo,
pullRequestID: pr.ID,
method: opts.MergeMethod,
auto: opts.AutoMergeEnable,
commitBody: opts.Body,
setCommitBody: opts.BodySet,
}
if opts.InteractiveMode {
r, err := api.GitHubRepo(apiClient, baseRepo)
if err != nil {
return err
}
mergeMethod, err = mergeMethodSurvey(r)
payload.method, err = mergeMethodSurvey(r)
if err != nil {
return err
}
@ -167,32 +189,72 @@ func mergeRun(opts *MergeOptions) error {
if err != nil {
return err
}
confirm, err := confirmSurvey()
allowEditMsg := payload.method != PullRequestMergeMethodRebase
action, err := confirmSurvey(allowEditMsg)
if err != nil {
return err
return fmt.Errorf("unable to confirm: %w", err)
}
if !confirm {
if action == shared.EditCommitMessageAction {
var editorCommand string
editorCommand, err = cmdutil.DetermineEditor(opts.Config)
if err != nil {
return err
}
if !payload.setCommitBody {
payload.commitBody, err = getMergeText(httpClient, baseRepo, pr.ID, payload.method)
if err != nil {
return err
}
}
payload.commitBody, err = commitMsgSurvey(payload.commitBody, editorCommand)
if err != nil {
return err
}
payload.setCommitBody = true
action, err = confirmSurvey(false)
if err != nil {
return fmt.Errorf("unable to confirm: %w", err)
}
}
if action == shared.CancelAction {
fmt.Fprintln(opts.IO.ErrOut, "Cancelled.")
return cmdutil.SilentError
}
}
err = api.PullRequestMerge(apiClient, baseRepo, pr, mergeMethod, opts.Body)
err = mergePullRequest(httpClient, payload)
if err != nil {
return err
}
if isTerminal {
action := "Merged"
switch mergeMethod {
case api.PullRequestMergeMethodRebase:
action = "Rebased and merged"
case api.PullRequestMergeMethodSquash:
action = "Squashed and merged"
if payload.auto {
method := ""
switch payload.method {
case PullRequestMergeMethodRebase:
method = " via rebase"
case PullRequestMergeMethodSquash:
method = " via squash"
}
fmt.Fprintf(opts.IO.ErrOut, "%s Pull request #%d will be automatically merged%s when all requirements are met\n", cs.SuccessIconWithColor(cs.Green), pr.Number, method)
} else {
action := "Merged"
switch payload.method {
case PullRequestMergeMethodRebase:
action = "Rebased and merged"
case PullRequestMergeMethodSquash:
action = "Squashed and merged"
}
fmt.Fprintf(opts.IO.ErrOut, "%s %s pull request #%d (%s)\n", cs.SuccessIconWithColor(cs.Magenta), action, pr.Number, pr.Title)
}
fmt.Fprintf(opts.IO.ErrOut, "%s %s pull request #%d (%s)\n", cs.SuccessIconWithColor(cs.Magenta), action, pr.Number, pr.Title)
}
} else if !opts.IsDeleteBranchIndicated && opts.InteractiveMode && !crossRepoPR {
} else if !opts.IsDeleteBranchIndicated && opts.InteractiveMode && !crossRepoPR && !opts.AutoMergeEnable {
err := prompt.SurveyAskOne(&survey.Confirm{
Message: fmt.Sprintf("Pull request #%d was already merged. Delete the branch locally?", pr.Number),
Default: false,
@ -204,7 +266,7 @@ func mergeRun(opts *MergeOptions) error {
fmt.Fprintf(opts.IO.ErrOut, "%s Pull request #%d was already merged\n", cs.WarningIcon(), pr.Number)
}
if !deleteBranch || crossRepoPR {
if !deleteBranch || crossRepoPR || opts.AutoMergeEnable {
return nil
}
@ -259,23 +321,23 @@ func mergeRun(opts *MergeOptions) error {
return nil
}
func mergeMethodSurvey(baseRepo *api.Repository) (api.PullRequestMergeMethod, error) {
func mergeMethodSurvey(baseRepo *api.Repository) (PullRequestMergeMethod, error) {
type mergeOption struct {
title string
method api.PullRequestMergeMethod
method PullRequestMergeMethod
}
var mergeOpts []mergeOption
if baseRepo.MergeCommitAllowed {
opt := mergeOption{title: "Create a merge commit", method: api.PullRequestMergeMethodMerge}
opt := mergeOption{title: "Create a merge commit", method: PullRequestMergeMethodMerge}
mergeOpts = append(mergeOpts, opt)
}
if baseRepo.RebaseMergeAllowed {
opt := mergeOption{title: "Rebase and merge", method: api.PullRequestMergeMethodRebase}
opt := mergeOption{title: "Rebase and merge", method: PullRequestMergeMethodRebase}
mergeOpts = append(mergeOpts, opt)
}
if baseRepo.SquashMergeAllowed {
opt := mergeOption{title: "Squash and merge", method: api.PullRequestMergeMethodSquash}
opt := mergeOption{title: "Squash and merge", method: PullRequestMergeMethodSquash}
mergeOpts = append(mergeOpts, opt)
}
@ -287,7 +349,6 @@ func mergeMethodSurvey(baseRepo *api.Repository) (api.PullRequestMergeMethod, er
mergeQuestion := &survey.Select{
Message: "What merge method would you like to use?",
Options: surveyOpts,
Default: "Create a merge commit",
}
var result int
@ -316,12 +377,50 @@ func deleteBranchSurvey(opts *MergeOptions, crossRepoPR bool) (bool, error) {
return opts.DeleteBranch, nil
}
func confirmSurvey() (bool, error) {
var confirm bool
submit := &survey.Confirm{
Message: "Submit?",
Default: true,
func confirmSurvey(allowEditMsg bool) (shared.Action, error) {
const (
submitLabel = "Submit"
editCommitMsgLabel = "Edit commit message"
cancelLabel = "Cancel"
)
options := []string{submitLabel}
if allowEditMsg {
options = append(options, editCommitMsgLabel)
}
options = append(options, cancelLabel)
var result string
submit := &survey.Select{
Message: "What's next?",
Options: options,
}
err := prompt.SurveyAskOne(submit, &result)
if err != nil {
return shared.CancelAction, fmt.Errorf("could not prompt: %w", err)
}
switch result {
case submitLabel:
return shared.SubmitAction, nil
case editCommitMsgLabel:
return shared.EditCommitMessageAction, nil
default:
return shared.CancelAction, nil
}
err := prompt.SurveyAskOne(submit, &confirm)
return confirm, err
}
func commitMsgSurvey(msg string, editorCommand string) (string, error) {
var result string
q := &surveyext.GhEditor{
EditorCommand: editorCommand,
Editor: &survey.Editor{
Message: "Body",
AppendDefault: true,
Default: msg,
FileName: "*.md",
},
}
err := prompt.SurveyAskOne(q, &result)
return result, err
}

View file

@ -27,12 +27,11 @@ import (
func Test_NewCmdMerge(t *testing.T) {
tests := []struct {
name string
args string
isTTY bool
want MergeOptions
wantBody string
wantErr string
name string
args string
isTTY bool
want MergeOptions
wantErr string
}{
{
name: "number argument",
@ -43,8 +42,10 @@ func Test_NewCmdMerge(t *testing.T) {
DeleteBranch: false,
IsDeleteBranchIndicated: false,
CanDeleteLocalBranch: true,
MergeMethod: api.PullRequestMergeMethodMerge,
MergeMethod: PullRequestMergeMethodMerge,
InteractiveMode: true,
Body: "",
BodySet: false,
},
},
{
@ -56,8 +57,10 @@ func Test_NewCmdMerge(t *testing.T) {
DeleteBranch: false,
IsDeleteBranchIndicated: true,
CanDeleteLocalBranch: true,
MergeMethod: api.PullRequestMergeMethodMerge,
MergeMethod: PullRequestMergeMethodMerge,
InteractiveMode: true,
Body: "",
BodySet: false,
},
},
{
@ -69,10 +72,11 @@ func Test_NewCmdMerge(t *testing.T) {
DeleteBranch: false,
IsDeleteBranchIndicated: false,
CanDeleteLocalBranch: true,
MergeMethod: api.PullRequestMergeMethodMerge,
MergeMethod: PullRequestMergeMethodMerge,
InteractiveMode: true,
Body: "cool",
BodySet: true,
},
wantBody: "cool",
},
{
name: "no argument with --repo override",
@ -138,12 +142,8 @@ func Test_NewCmdMerge(t *testing.T) {
assert.Equal(t, tt.want.CanDeleteLocalBranch, opts.CanDeleteLocalBranch)
assert.Equal(t, tt.want.MergeMethod, opts.MergeMethod)
assert.Equal(t, tt.want.InteractiveMode, opts.InteractiveMode)
if tt.wantBody == "" {
assert.Nil(t, opts.Body)
} else {
assert.Equal(t, tt.wantBody, *opts.Body)
}
assert.Equal(t, tt.want.Body, opts.Body)
assert.Equal(t, tt.want.BodySet, opts.BodySet)
})
}
}
@ -506,7 +506,7 @@ func TestPrMerge_squash(t *testing.T) {
httpmock.GraphQLMutation(`{}`, func(input map[string]interface{}) {
assert.Equal(t, "THE-ID", input["pullRequestId"].(string))
assert.Equal(t, "SQUASH", input["mergeMethod"].(string))
assert.Equal(t, "The title of the PR (#3)", input["commitHeadline"].(string))
assert.NotContains(t, input, "commitHeadline")
}))
_, cmdTeardown := run.Stub()
@ -610,25 +610,19 @@ func TestPRMerge_interactive(t *testing.T) {
assert.Equal(t, "MERGE", input["mergeMethod"].(string))
assert.NotContains(t, input, "commitHeadline")
}))
http.Register(
httpmock.REST("DELETE", "repos/OWNER/REPO/git/refs/heads/blueberries"),
httpmock.StringResponse(`{}`))
cs, cmdTeardown := run.Stub()
defer cmdTeardown(t)
cs.Register("git -c log.ShowSignature=false log --pretty=format:%H,%s -1", 0, "")
cs.Register(`git config --get-regexp.+branch\\\.blueberries\\\.`, 0, "")
cs.Register(`git checkout master`, 0, "")
cs.Register(`git rev-parse --verify refs/heads/blueberries`, 0, "")
cs.Register(`git branch -D blueberries`, 0, "")
as, surveyTeardown := prompt.InitAskStubber()
defer surveyTeardown()
as.StubOne(0) // Merge method survey
as.StubOne(true) // Delete branch survey
as.StubOne(true) // Confirm submit survey
as.StubOne(0) // Merge method survey
as.StubOne(false) // Delete branch survey
as.StubOne("Submit") // Confirm submit survey
output, err := runCommand(http, "blueberries", true, "")
if err != nil {
@ -682,8 +676,8 @@ func TestPRMerge_interactiveWithDeleteBranch(t *testing.T) {
as, surveyTeardown := prompt.InitAskStubber()
defer surveyTeardown()
as.StubOne(0) // Merge method survey
as.StubOne(true) // Confirm submit survey
as.StubOne(0) // Merge method survey
as.StubOne("Submit") // Confirm submit survey
output, err := runCommand(http, "blueberries", true, "-d")
if err != nil {
@ -694,6 +688,64 @@ func TestPRMerge_interactiveWithDeleteBranch(t *testing.T) {
test.ExpectLines(t, output.Stderr(), "Merged pull request #3", "Deleted branch blueberries and switched to branch master")
}
func TestPRMerge_interactiveSquashEditCommitMsg(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,
"title": "title"
}] } } } }`))
http.Register(
httpmock.GraphQL(`query RepositoryInfo\b`),
httpmock.StringResponse(`
{ "data": { "repository": {
"mergeCommitAllowed": true,
"rebaseMergeAllowed": true,
"squashMergeAllowed": true
} } }`))
http.Register(
httpmock.GraphQL(`query PullRequestMergeText\b`),
httpmock.StringResponse(`
{ "data": { "node": {
"viewerMergeBodyText": ""
} } }`))
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))
assert.Equal(t, "cool story", input["commitBody"].(string))
}))
cs, cmdTeardown := run.Stub()
defer cmdTeardown(t)
cs.Register("git -c log.ShowSignature=false log --pretty=format:%H,%s -1", 0, "")
cs.Register(`git config --get-regexp.+branch\\\.blueberries\\\.`, 0, "")
as, surveyTeardown := prompt.InitAskStubber()
defer surveyTeardown()
as.StubOne(2) // Merge method survey
as.StubOne(false) // Delete branch survey
as.StubOne("Edit commit message") // Confirm submit survey
as.StubOne("cool story") // Edit commit message survey
as.StubOne("Submit") // Confirm submit survey
output, err := runCommand(http, "blueberries", true, "")
if err != nil {
t.Fatalf("Got unexpected error running `pr merge` %s", err)
}
assert.Equal(t, "✓ Squashed and merged pull request #3 (title)\n", output.Stderr())
}
func TestPRMerge_interactiveCancelled(t *testing.T) {
http := initFakeHTTP()
defer http.Verify(t)
@ -724,9 +776,9 @@ func TestPRMerge_interactiveCancelled(t *testing.T) {
as, surveyTeardown := prompt.InitAskStubber()
defer surveyTeardown()
as.StubOne(0) // Merge method survey
as.StubOne(true) // Delete branch survey
as.StubOne(false) // Confirm submit survey
as.StubOne(0) // Merge method survey
as.StubOne(true) // Delete branch survey
as.StubOne("Cancel") // Confirm submit survey
output, err := runCommand(http, "blueberries", true, "")
if !errors.Is(err, cmdutil.SilentError) {
@ -747,5 +799,89 @@ func Test_mergeMethodSurvey(t *testing.T) {
as.StubOne(0) // Select first option which is rebase merge
method, err := mergeMethodSurvey(repo)
assert.Nil(t, err)
assert.Equal(t, api.PullRequestMergeMethodRebase, method)
assert.Equal(t, PullRequestMergeMethodRebase, method)
}
func TestMergeRun_autoMerge(t *testing.T) {
io, _, stdout, stderr := iostreams.Test()
io.SetStdoutTTY(true)
io.SetStderrTTY(true)
tr := initFakeHTTP()
defer tr.Verify(t)
tr.Register(
httpmock.GraphQL(`query PullRequestByNumber\b`),
httpmock.StringResponse(`
{ "data": { "repository": { "pullRequest": {
"id": "THE-ID",
"number": 123,
"title": "The title of the PR",
"state": "OPEN",
"headRefName": "blueberries",
"headRepositoryOwner": {"login": "OWNER"}
} } } }`))
tr.Register(
httpmock.GraphQL(`mutation PullRequestAutoMerge\b`),
httpmock.GraphQLMutation(`{}`, func(input map[string]interface{}) {
assert.Equal(t, "THE-ID", input["pullRequestId"].(string))
assert.Equal(t, "SQUASH", input["mergeMethod"].(string))
}))
_, cmdTeardown := run.Stub()
defer cmdTeardown(t)
err := mergeRun(&MergeOptions{
IO: io,
HttpClient: func() (*http.Client, error) {
return &http.Client{Transport: tr}, nil
},
SelectorArg: "https://github.com/OWNER/REPO/pull/123",
AutoMergeEnable: true,
MergeMethod: PullRequestMergeMethodSquash,
})
assert.NoError(t, err)
assert.Equal(t, "", stdout.String())
assert.Equal(t, "✓ Pull request #123 will be automatically merged via squash when all requirements are met\n", stderr.String())
}
func TestMergeRun_disableAutoMerge(t *testing.T) {
io, _, stdout, stderr := iostreams.Test()
io.SetStdoutTTY(true)
io.SetStderrTTY(true)
tr := initFakeHTTP()
defer tr.Verify(t)
tr.Register(
httpmock.GraphQL(`query PullRequestByNumber\b`),
httpmock.StringResponse(`
{ "data": { "repository": { "pullRequest": {
"id": "THE-ID",
"number": 123,
"title": "The title of the PR",
"state": "OPEN",
"headRefName": "blueberries",
"headRepositoryOwner": {"login": "OWNER"}
} } } }`))
tr.Register(
httpmock.GraphQL(`mutation PullRequestAutoMergeDisable\b`),
httpmock.StringResponse(`{}`))
_, cmdTeardown := run.Stub()
defer cmdTeardown(t)
err := mergeRun(&MergeOptions{
IO: io,
HttpClient: func() (*http.Client, error) {
return &http.Client{Transport: tr}, nil
},
SelectorArg: "https://github.com/OWNER/REPO/pull/123",
AutoMergeDisable: true,
})
assert.NoError(t, err)
assert.Equal(t, "", stdout.String())
assert.Equal(t, "✓ Auto-merge disabled for pull request #123\n", stderr.String())
}

View file

@ -21,6 +21,7 @@ const (
PreviewAction
CancelAction
MetadataAction
EditCommitMessageAction
noMilestone = "(none)"
)

View file

@ -114,7 +114,7 @@ func statusRun(opts *StatusOptions) error {
currentPR = nil
}
if currentPR != nil {
printPrs(opts.IO, 1, *currentPR)
printPrs(opts.IO, 1, prPayload.StrictProtection, *currentPR)
} else if currentPRHeadRef == "" {
shared.PrintMessage(opts.IO, " There is no current branch")
} else {
@ -124,7 +124,7 @@ func statusRun(opts *StatusOptions) error {
shared.PrintHeader(opts.IO, "Created by you")
if prPayload.ViewerCreated.TotalCount > 0 {
printPrs(opts.IO, prPayload.ViewerCreated.TotalCount, prPayload.ViewerCreated.PullRequests...)
printPrs(opts.IO, prPayload.ViewerCreated.TotalCount, prPayload.StrictProtection, prPayload.ViewerCreated.PullRequests...)
} else {
shared.PrintMessage(opts.IO, " You have no open pull requests")
}
@ -132,7 +132,7 @@ func statusRun(opts *StatusOptions) error {
shared.PrintHeader(opts.IO, "Requesting a code review from you")
if prPayload.ReviewRequested.TotalCount > 0 {
printPrs(opts.IO, prPayload.ReviewRequested.TotalCount, prPayload.ReviewRequested.PullRequests...)
printPrs(opts.IO, prPayload.ReviewRequested.TotalCount, prPayload.StrictProtection, prPayload.ReviewRequested.PullRequests...)
} else {
shared.PrintMessage(opts.IO, " You have no pull requests to review")
}
@ -178,7 +178,7 @@ func prSelectorForCurrentBranch(baseRepo ghrepo.Interface, prHeadRef string, rem
return
}
func printPrs(io *iostreams.IOStreams, totalCount int, prs ...api.PullRequest) {
func printPrs(io *iostreams.IOStreams, totalCount int, strictProtection bool, prs ...api.PullRequest) {
w := io.Out
cs := io.ColorScheme()
@ -227,6 +227,19 @@ func printPrs(io *iostreams.IOStreams, totalCount int, prs ...api.PullRequest) {
} else if reviews.Approved {
fmt.Fprint(w, cs.Green("✓ Approved"))
}
// only check if the "up to date" setting is checked in repo settings
if strictProtection {
// add padding between reviews & merge status
fmt.Fprint(w, " ")
if pr.MergeStateStatus == "BEHIND" {
fmt.Fprint(w, cs.Yellow("- Not up to date"))
} else {
fmt.Fprint(w, cs.Green("✓ Up to date"))
}
}
} else {
fmt.Fprintf(w, " - %s", shared.StateTitleWithColor(cs, pr))
}