Merge remote-tracking branch 'origin/master' into auth-from-env

This commit is contained in:
Mislav Marohnić 2020-05-27 11:44:19 +02:00
commit ab0e43c6c8
20 changed files with 820 additions and 36 deletions

View file

@ -5,6 +5,11 @@ on:
- "**.go"
- go.mod
- go.sum
pull_request:
paths:
- "**.go"
- go.mod
- go.sum
jobs:
lint:

1
.gitignore vendored
View file

@ -1,4 +1,5 @@
/bin
/share/man/man1
/gh-cli
.envrc
/dist

View file

@ -6,6 +6,7 @@ release:
before:
hooks:
- go mod tidy
- make manpages
builds:
- <<: &build_defaults
@ -17,10 +18,12 @@ builds:
id: macos
goos: [darwin]
goarch: [amd64]
- <<: *build_defaults
id: linux
goos: [linux]
goarch: [386, amd64, arm64]
- <<: *build_defaults
id: windows
goos: [windows]
@ -35,11 +38,16 @@ archives:
replacements:
darwin: macOS
format: tar.gz
files:
- LICENSE
- ./share/man/man1/gh*.1
- id: windows
builds: [windows]
<<: *archive_defaults
wrap_in_directory: false
format: zip
files:
- LICENSE
brews:
- name: gh
@ -57,8 +65,9 @@ brews:
depends_on "go"
end
install: |
system "make" if build.head?
system "make", "bin/gh", "manpages" if build.head?
bin.install "bin/gh"
man1.install Dir["./share/man/man1/gh*.1"]
(bash_completion/"gh.sh").write `#{bin}/gh completion -s bash`
(zsh_completion/"_gh").write `#{bin}/gh completion -s zsh`
(fish_completion/"gh.fish").write `#{bin}/gh completion -s fish`
@ -76,6 +85,8 @@ nfpms:
formats:
- deb
- rpm
files:
"./share/man/man1/gh*.1": "/usr/share/man/man1"
scoop:
bucket:

View file

@ -22,13 +22,13 @@ test:
go test ./...
.PHONY: test
site: bin/gh
bin/gh repo clone github/cli.github.com "$@"
site:
git clone https://github.com/github/cli.github.com.git "$@"
site-docs: site
git -C site pull
git -C site rm 'manual/gh*.md' 2>/dev/null || true
go run ./cmd/gen-docs site/manual
go run ./cmd/gen-docs --website --doc-path site/manual
for f in site/manual/gh*.md; do sed -i.bak -e '/^### SEE ALSO/,$$d' "$$f"; done
rm -f site/manual/*.bak
git -C site add 'manual/gh*.md'
@ -44,3 +44,8 @@ endif
git -C site commit -m '$(GITHUB_REF:refs/tags/v%=%)' index.html
git -C site push
.PHONY: site-publish
.PHONY: manpages
manpages:
go run ./cmd/gen-docs --man-page --doc-path ./share/man/man1/

View file

@ -145,6 +145,41 @@ func (gr GraphQLErrorResponse) Error() string {
return fmt.Sprintf("graphql error: '%s'", strings.Join(errorMessages, ", "))
}
// 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)
if err != nil {
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
}
defer res.Body.Close()
appID := res.Header.Get("X-Oauth-Client-Id")
hasScopes := strings.Split(res.Header.Get("X-Oauth-Scopes"), ",")
found := 0
for _, s := range hasScopes {
for _, w := range wantedScopes {
if w == strings.TrimSpace(s) {
found++
}
}
}
if found == len(wantedScopes) {
return true, appID, nil
}
return false, appID, 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"

52
api/queries_gist.go Normal file
View file

@ -0,0 +1,52 @@
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
}

View file

@ -48,6 +48,7 @@ type PullRequest struct {
BaseRefName string
HeadRefName string
Body string
Mergeable string
Author struct {
Login string
@ -418,6 +419,7 @@ func PullRequestByNumber(client *Client, repo ghrepo.Interface, number int) (*Pu
state
closed
body
mergeable
author {
login
}
@ -526,6 +528,7 @@ func PullRequestForBranch(client *Client, repo ghrepo.Interface, baseBranch, hea
title
state
body
mergeable
author {
login
}
@ -1010,6 +1013,14 @@ func PullRequestReady(client *Client, repo ghrepo.Interface, pr *PullRequest) er
return err
}
func BranchDeleteRemote(client *Client, repo ghrepo.Interface, branch string) error {
var response struct {
NodeID string `json:"node_id"`
}
path := fmt.Sprintf("repos/%s/%s/git/refs/heads/%s", repo.RepoOwner(), repo.RepoName(), branch)
return client.REST("DELETE", path, nil, &response)
}
func min(a, b int) int {
if a < b {
return a

View file

@ -84,6 +84,9 @@ func GitHubRepo(client *Client, repo ghrepo.Interface) (*Repository, error) {
hasIssuesEnabled
description
viewerPermission
defaultBranchRef {
name
}
}
}`
variables := map[string]interface{}{

View file

@ -7,22 +7,57 @@ import (
"github.com/cli/cli/command"
"github.com/spf13/cobra/doc"
"github.com/spf13/pflag"
)
func main() {
if len(os.Args) < 2 {
fatal("Usage: gen-docs <destination-dir>")
}
dir := os.Args[1]
err := os.MkdirAll(dir, 0755)
var flagError pflag.ErrorHandling
docCmd := pflag.NewFlagSet("", flagError)
manPage := docCmd.BoolP("man-page", "", false, "Generate manual pages")
website := docCmd.BoolP("website", "", false, "Generate website pages")
dir := docCmd.StringP("doc-path", "", "", "Path directory where you want generate doc files")
help := docCmd.BoolP("help", "h", false, "Help about any command")
if err := docCmd.Parse(os.Args); err != nil {
os.Exit(1)
}
if *help {
_, err := fmt.Fprintf(os.Stderr, "Usage of %s:\n\n%s", os.Args[0], docCmd.FlagUsages())
if err != nil {
fatal(err)
}
os.Exit(1)
}
if *dir == "" {
fatal("no dir set")
}
err := os.MkdirAll(*dir, 0755)
if err != nil {
fatal(err)
}
err = doc.GenMarkdownTreeCustom(command.RootCmd, dir, filePrepender, linkHandler)
if err != nil {
fatal(err)
if *website {
err = doc.GenMarkdownTreeCustom(command.RootCmd, *dir, filePrepender, linkHandler)
if err != nil {
fatal(err)
}
}
if *manPage {
header := &doc.GenManHeader{
Title: "gh",
Section: "1",
Source: "", //source and manual are just put at the top of the manpage, before name
Manual: "", //if source is an empty string, it's set to "Auto generated by spf13/cobra"
}
err = doc.GenManTree(command.RootCmd, header, *dir)
if err != nil {
fatal(err)
}
}
}

150
command/gist.go Normal file
View file

@ -0,0 +1,150 @@
package command
import (
"errors"
"fmt"
"io"
"io/ioutil"
"os"
"path"
"github.com/cli/cli/api"
"github.com/cli/cli/utils"
"github.com/spf13/cobra"
)
func init() {
RootCmd.AddCommand(gistCmd)
gistCmd.AddCommand(gistCreateCmd)
gistCreateCmd.Flags().StringP("desc", "d", "", "A description for this gist")
gistCreateCmd.Flags().BoolP("public", "p", false, "When true, the gist will be public and available for anyone to see (default: private)")
}
var gistCmd = &cobra.Command{
Use: "gist",
Short: "Create gists",
Long: `Work with GitHub gists.`,
}
var gistCreateCmd = &cobra.Command{
Use: `create {<filename>|-}...`,
Short: "Create a new gist",
Long: `gh gist create: create gists
Gists can be created from one or many files. This command can also read from STDIN. By default, gists are private; use --public to change this.
Examples
gh gist create hello.py # turn file hello.py into a gist
gh gist create --public hello.py # turn file hello.py into a public gist
gh gist create -d"a file!" hello.py # turn file hello.py into a gist, with description
gh gist create hello.py world.py cool.txt # make a gist out of several files
gh gist create - # read from STDIN to create a gist
cat cool.txt | gh gist create # read the output of another command and make a gist out of it
`,
RunE: gistCreate,
}
type Opts struct {
Description string
Public bool
}
func gistCreate(cmd *cobra.Command, args []string) error {
ctx := contextForCommand(cmd)
client, err := apiClientForContext(ctx)
if err != nil {
return err
}
// This performs a dummy query, checks what scopes we have, and then asks for a user to reauth
// with expanded scopes. it introduces latency whenever this command is run: a trade-off to avoid
// having every single user reauth as a result of this feature even if they never once use gists.
//
// In the future we'd rather have the ability to detect a "reauth needed" scenario and replay
// failed requests but some short spikes indicated that that would be a fair bit of work.
client, err = ensureScopes(ctx, client, "gist")
if err != nil {
return err
}
opts, err := processOpts(cmd)
if err != nil {
return fmt.Errorf("did not understand arguments: %w", err)
}
info, err := os.Stdin.Stat()
if err != nil {
return fmt.Errorf("failed to check STDIN: %w", err)
}
if (info.Mode() & os.ModeCharDevice) != os.ModeCharDevice {
args = append(args, "-")
}
files, err := processFiles(os.Stdin, args)
if err != nil {
return fmt.Errorf("failed to collect files for posting: %w", err)
}
errOut := colorableErr(cmd)
fmt.Fprintf(errOut, "%s Creating gist...\n", utils.Gray("-"))
gist, err := api.GistCreate(client, opts.Description, opts.Public, files)
if err != nil {
return fmt.Errorf("%s Failed to create gist: %w", utils.Red("X"), err)
}
fmt.Fprintf(errOut, "%s Created gist\n", utils.Green("✓"))
fmt.Fprintln(cmd.OutOrStdout(), gist.HTMLURL)
return nil
}
func processOpts(cmd *cobra.Command) (*Opts, error) {
description, err := cmd.Flags().GetString("desc")
if err != nil {
return nil, err
}
public, err := cmd.Flags().GetBool("public")
if err != nil {
return nil, err
}
return &Opts{
Description: description,
Public: public,
}, err
}
func processFiles(stdin io.Reader, filenames []string) (map[string]string, error) {
fs := map[string]string{}
if len(filenames) == 0 {
return fs, errors.New("no filenames passed and nothing on STDIN")
}
for i, f := range filenames {
var filename string
var content []byte
var err error
if f == "-" {
filename = fmt.Sprintf("gistfile%d.txt", i)
content, err = ioutil.ReadAll(stdin)
if err != nil {
return fs, fmt.Errorf("failed to read from stdin: %w", err)
}
} else {
content, err = ioutil.ReadFile(f)
if err != nil {
return fs, fmt.Errorf("failed to read file %s: %w", f, err)
}
filename = path.Base(f)
}
fs[filename] = string(content)
}
return fs, nil
}

52
command/gist_test.go Normal file
View file

@ -0,0 +1,52 @@
package command
import (
"bytes"
"encoding/json"
"io/ioutil"
"strings"
"testing"
)
func TestGistCreate(t *testing.T) {
initBlankContext("", "OWNER/REPO", "trunk")
http := initFakeHTTP()
http.StubResponse(200, bytes.NewBufferString(`
{
"html_url": "https://gist.github.com/aa5a315d61ae9438b18d"
}
`))
output, err := RunCommand(`gist create "../test/fixtures/gistCreate.json" -d "Gist description" --public`)
eq(t, err, nil)
bodyBytes, _ := ioutil.ReadAll(http.Requests[0].Body)
reqBody := struct {
Description string
Public bool
Files map[string]struct {
Content string
}
}{}
err = json.Unmarshal(bodyBytes, &reqBody)
if err != nil {
t.Fatalf("unexpected json error: %s", err)
}
eq(t, reqBody.Description, "Gist description")
eq(t, reqBody.Public, true)
eq(t, reqBody.Files["gistCreate.json"].Content, "{}")
eq(t, output.String(), "https://gist.github.com/aa5a315d61ae9438b18d\n")
}
func TestGistCreate_stdin(t *testing.T) {
fakeStdin := strings.NewReader("hey cool how is it going")
files, err := processFiles(fakeStdin, []string{"-"})
if err != nil {
t.Fatalf("unexpected error processing files: %s", err)
}
eq(t, files["gistfile0.txt"], "hey cool how is it going")
}

View file

@ -1,6 +1,7 @@
package command
import (
"errors"
"fmt"
"io"
"regexp"
@ -8,6 +9,7 @@ import (
"strconv"
"strings"
"github.com/AlecAivazis/survey/v2"
"github.com/cli/cli/api"
"github.com/cli/cli/context"
"github.com/cli/cli/git"
@ -26,7 +28,8 @@ func init() {
prCmd.AddCommand(prCloseCmd)
prCmd.AddCommand(prReopenCmd)
prCmd.AddCommand(prMergeCmd)
prMergeCmd.Flags().BoolP("merge", "m", true, "Merge the commits with the base branch")
prMergeCmd.Flags().BoolP("delete-branch", "d", true, "Delete the local and remote branch after merge")
prMergeCmd.Flags().BoolP("merge", "m", false, "Merge the commits with the base branch")
prMergeCmd.Flags().BoolP("rebase", "r", false, "Rebase the commits onto the base branch")
prMergeCmd.Flags().BoolP("squash", "s", false, "Squash the commits into one commit and merge it into the base branch")
prCmd.AddCommand(prReadyCmd)
@ -93,7 +96,7 @@ var prMergeCmd = &cobra.Command{
}
var prReadyCmd = &cobra.Command{
Use: "ready [<number> | <url> | <branch>]",
Short: "Make a pull request as ready for review",
Short: "Mark a pull request as ready for review",
Args: cobra.MaximumNArgs(1),
RunE: prReady,
}
@ -448,7 +451,15 @@ func prMerge(cmd *cobra.Command, args []string) error {
var pr *api.PullRequest
if len(args) > 0 {
pr, err = prFromArg(apiClient, baseRepo, args[0])
var prNumber string
n, _ := prFromURL(args[0])
if n != "" {
prNumber = n
} else {
prNumber = args[0]
}
pr, err = prFromArg(apiClient, baseRepo, prNumber)
if err != nil {
return err
}
@ -468,41 +479,181 @@ func prMerge(cmd *cobra.Command, args []string) error {
}
}
if pr.State == "MERGED" {
if pr.Mergeable == "CONFLICTING" {
err := fmt.Errorf("%s Pull request #%d has conflicts and isn't mergeable ", utils.Red("!"), pr.Number)
return err
} else if pr.Mergeable == "UNKNOWN" {
err := fmt.Errorf("%s Pull request #%d can't be merged right now; try again in a few seconds", utils.Red("!"), pr.Number)
return err
} else if pr.State == "MERGED" {
err := fmt.Errorf("%s Pull request #%d was already merged", utils.Red("!"), pr.Number)
return err
}
rebase, err := cmd.Flags().GetBool("rebase")
if err != nil {
return err
}
squash, err := cmd.Flags().GetBool("squash")
var mergeMethod api.PullRequestMergeMethod
deleteBranch, err := cmd.Flags().GetBool("delete-branch")
if err != nil {
return err
}
var output string
if rebase {
output = fmt.Sprintf("%s Rebased and merged pull request #%d\n", utils.Green("✔"), pr.Number)
deleteLocalBranch := !cmd.Flags().Changed("repo")
crossRepoPR := pr.HeadRepositoryOwner.Login != baseRepo.RepoOwner()
// Ensure only one merge method is specified
enabledFlagCount := 0
isInteractive := false
if b, _ := cmd.Flags().GetBool("merge"); b {
enabledFlagCount++
mergeMethod = api.PullRequestMergeMethodMerge
}
if b, _ := cmd.Flags().GetBool("rebase"); b {
enabledFlagCount++
mergeMethod = api.PullRequestMergeMethodRebase
}
if b, _ := cmd.Flags().GetBool("squash"); b {
enabledFlagCount++
mergeMethod = api.PullRequestMergeMethodSquash
}
if enabledFlagCount == 0 {
isInteractive = true
} else if enabledFlagCount > 1 {
return errors.New("expected exactly one of --merge, --rebase, or --squash to be true")
}
if isInteractive {
mergeMethod, deleteBranch, err = prInteractiveMerge(deleteLocalBranch, crossRepoPR)
if err != nil {
return nil
}
}
var action string
if mergeMethod == api.PullRequestMergeMethodRebase {
action = "Rebased and merged"
err = api.PullRequestMerge(apiClient, baseRepo, pr, api.PullRequestMergeMethodRebase)
} else if squash {
output = fmt.Sprintf("%s Squashed and merged pull request #%d\n", utils.Green("✔"), pr.Number)
} else if mergeMethod == api.PullRequestMergeMethodSquash {
action = "Squashed and merged"
err = api.PullRequestMerge(apiClient, baseRepo, pr, api.PullRequestMergeMethodSquash)
} else {
output = fmt.Sprintf("%s Merged pull request #%d\n", utils.Green("✔"), pr.Number)
} 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)
}
fmt.Fprint(colorableOut(cmd), output)
fmt.Fprintf(colorableOut(cmd), "%s %s pull request #%d\n", utils.Magenta("✔"), action, pr.Number)
if deleteBranch {
repo, err := api.GitHubRepo(apiClient, baseRepo)
if err != nil {
return err
}
currentBranch, err := ctx.Branch()
if err != nil {
return err
}
branchSwitchString := ""
if deleteLocalBranch && !crossRepoPR {
var branchToSwitchTo string
if currentBranch == pr.HeadRefName {
branchToSwitchTo = repo.DefaultBranchRef.Name
err = git.CheckoutBranch(repo.DefaultBranchRef.Name)
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)
if err != nil {
err = fmt.Errorf("failed to delete remote branch %s: %w", utils.Cyan(pr.HeadRefName), err)
return err
}
}
fmt.Fprintf(colorableOut(cmd), "%s Deleted branch %s%s\n", utils.Red("✔"), utils.Cyan(pr.HeadRefName), branchSwitchString)
}
return nil
}
func prInteractiveMerge(deleteLocalBranch bool, crossRepoPR bool) (api.PullRequestMergeMethod, bool, error) {
mergeMethodQuestion := &survey.Question{
Name: "mergeMethod",
Prompt: &survey.Select{
Message: "What merge method would you like to use?",
Options: []string{"Create a merge commit", "Rebase and merge", "Squash and merge"},
Default: "Create a merge commit",
},
}
qs := []*survey.Question{mergeMethodQuestion}
if !crossRepoPR {
var message string
if deleteLocalBranch {
message = "Delete the branch locally and on GitHub?"
} else {
message = "Delete the branch on GitHub?"
}
deleteBranchQuestion := &survey.Question{
Name: "deleteBranch",
Prompt: &survey.Confirm{
Message: message,
Default: true,
},
}
qs = append(qs, deleteBranchQuestion)
}
answers := struct {
MergeMethod int
DeleteBranch bool
}{}
err := SurveyAsk(qs, &answers)
if err != nil {
return 0, false, fmt.Errorf("could not prompt: %w", err)
}
var mergeMethod api.PullRequestMergeMethod
switch answers.MergeMethod {
case 0:
mergeMethod = api.PullRequestMergeMethodMerge
case 1:
mergeMethod = api.PullRequestMergeMethodRebase
case 2:
mergeMethod = api.PullRequestMergeMethodSquash
}
deleteBranch := answers.DeleteBranch
return mergeMethod, deleteBranch, nil
}
func printPrPreview(out io.Writer, pr *api.PullRequest) error {
// Header (Title and State)
fmt.Fprintln(out, utils.Bold(pr.Title))
@ -620,6 +771,8 @@ const (
approvedReviewState = "APPROVED"
changesRequestedReviewState = "CHANGES_REQUESTED"
commentedReviewState = "COMMENTED"
dismissedReviewState = "DISMISSED"
pendingReviewState = "PENDING"
)
type reviewerState struct {
@ -645,8 +798,14 @@ func colorFuncForReviewerState(state string) func(string) string {
// formattedReviewerState formats a reviewerState with state color
func formattedReviewerState(reviewer *reviewerState) string {
stateColorFunc := colorFuncForReviewerState(reviewer.State)
return fmt.Sprintf("%s (%s)", reviewer.Name, stateColorFunc(strings.ReplaceAll(strings.Title(strings.ToLower(reviewer.State)), "_", " ")))
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
@ -702,6 +861,9 @@ func parseReviewers(pr api.PullRequest) []*reviewerState {
// 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)
}

View file

@ -477,7 +477,7 @@ func TestPRView_Preview(t *testing.T) {
fixture: "../test/fixtures/prViewPreviewWithReviewersByNumber.json",
expectedOutputs: []string{
`Blueberries are from a fork`,
`Reviewers: DEF \(Commented\), def \(Changes requested\), ghost \(Approved\), xyz \(Approved\), 123 \(Requested\), Team 1 \(Requested\), abc \(Requested\)\n`,
`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`,
},
@ -982,10 +982,10 @@ func initWithStubs(branch string, stubs ...stubResponse) {
initBlankContext("", "OWNER/REPO", branch)
http := initFakeHTTP()
http.StubRepoResponse("OWNER", "REPO")
for _, s := range stubs {
http.StubResponse(s.ResponseCode, s.ResponseBody)
}
http.StubRepoResponse("OWNER", "REPO")
}
func TestPrMerge(t *testing.T) {
@ -994,9 +994,19 @@ func TestPrMerge(t *testing.T) {
"pullRequest": { "number": 1, "closed": false, "state": "OPEN"}
} } }`)},
stubResponse{200, bytes.NewBufferString(`{"id": "THE-ID"}`)},
stubResponse{200, bytes.NewBufferString(`{"node_id": "THE-ID"}`)},
)
output, err := RunCommand("pr merge 1")
cs, cmdTeardown := test.InitCmdStubber()
defer cmdTeardown()
cs.Stub("branch.blueberries.remote origin\nbranch.blueberries.merge refs/heads/blueberries") // git config --get-regexp ^branch\.master\.(remote|merge)
cs.Stub("") // git config --get-regexp ^branch\.blueberries\.(remote|merge)$
cs.Stub("") // git symbolic-ref --quiet --short HEAD
cs.Stub("") // git checkout master
cs.Stub("")
output, err := RunCommand("pr merge 1 --merge")
if err != nil {
t.Fatalf("error running command `pr merge`: %v", err)
}
@ -1008,11 +1018,96 @@ func TestPrMerge(t *testing.T) {
}
}
func TestPrMerge_withRepoFlag(t *testing.T) {
initBlankContext("", "OWNER/REPO", "master")
http := initFakeHTTP()
http.StubResponse(200, bytes.NewBufferString(`{ "data": { "repository": {
"pullRequest": { "number": 1, "closed": false, "state": "OPEN"}
} } }`))
http.StubResponse(200, bytes.NewBufferString(`{ "data": {} }`))
http.StubRepoResponse("OWNER", "REPO")
http.StubResponse(200, bytes.NewBufferString(`{"node_id": "THE-ID"}`))
cs, cmdTeardown := test.InitCmdStubber()
defer cmdTeardown()
eq(t, len(cs.Calls), 0)
output, err := RunCommand("pr merge 1 --merge -R stinky/boi")
if err != nil {
t.Fatalf("error running command `pr merge`: %v", err)
}
r := regexp.MustCompile(`Merged pull request #1`)
if !r.MatchString(output.String()) {
t.Fatalf("output did not match regexp /%s/\n> output\n%q\n", r, output.Stderr())
}
}
func TestPrMerge_deleteBranch(t *testing.T) {
initWithStubs("blueberries",
stubResponse{200, bytes.NewBufferString(`
{ "data": { "repository": { "pullRequests": { "nodes": [
{ "headRefName": "blueberries", "id": "THE-ID", "number": 3}
] } } } }`)},
stubResponse{200, bytes.NewBufferString(`{ "data": {} }`)},
stubResponse{200, bytes.NewBufferString(`{"node_id": "THE-ID"}`)},
)
cs, cmdTeardown := test.InitCmdStubber()
defer cmdTeardown()
cs.Stub("") // git config --get-regexp ^branch\.blueberries\.(remote|merge)$
cs.Stub("") // git checkout master
cs.Stub("") // git rev-parse --verify blueberries`
cs.Stub("") // git branch -d
cs.Stub("") // git push origin --delete blueberries
output, err := RunCommand(`pr merge --merge --delete-branch`)
if err != nil {
t.Fatalf("Got unexpected error running `pr merge` %s", err)
}
test.ExpectLines(t, output.String(), "Merged pull request #3", "Deleted branch blueberries")
}
func TestPrMerge_deleteNonCurrentBranch(t *testing.T) {
initBlankContext("", "OWNER/REPO", "another-branch")
http := initFakeHTTP()
http.StubRepoResponse("OWNER", "REPO")
http.StubResponse(200, bytes.NewBufferString(`
{ "data": { "repository": { "pullRequests": { "nodes": [
{ "headRefName": "blueberries", "id": "THE-ID", "number": 3}
] } } } }`))
http.StubResponse(200, bytes.NewBufferString(`{ "data": {} }`))
http.StubResponse(200, bytes.NewBufferString(`{"node_id": "THE-ID"}`))
http.StubRepoResponse("OWNER", "REPO")
cs, cmdTeardown := test.InitCmdStubber()
defer cmdTeardown()
// We don't expect the default branch to be checked out, just that blueberries is deleted
cs.Stub("") // git rev-parse --verify blueberries
cs.Stub("") // git branch -d blueberries
cs.Stub("") // git push origin --delete blueberries
output, err := RunCommand(`pr merge --merge --delete-branch blueberries`)
if err != nil {
t.Fatalf("Got unexpected error running `pr merge` %s", err)
}
test.ExpectLines(t, output.String(), "Merged pull request #3", "Deleted branch blueberries")
}
func TestPrMerge_noPrNumberGiven(t *testing.T) {
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
jsonFile, _ := os.Open("../test/fixtures/prViewPreviewWithMetadataByBranch.json")
defer jsonFile.Close()
@ -1020,9 +1115,10 @@ func TestPrMerge_noPrNumberGiven(t *testing.T) {
initWithStubs("blueberries",
stubResponse{200, jsonFile},
stubResponse{200, bytes.NewBufferString(`{"id": "THE-ID"}`)},
stubResponse{200, bytes.NewBufferString(`{"node_id": "THE-ID"}`)},
)
output, err := RunCommand("pr merge")
output, err := RunCommand("pr merge --merge")
if err != nil {
t.Fatalf("error running command `pr merge`: %v", err)
}
@ -1040,8 +1136,17 @@ func TestPrMerge_rebase(t *testing.T) {
"pullRequest": { "number": 2, "closed": false, "state": "OPEN"}
} } }`)},
stubResponse{200, bytes.NewBufferString(`{"id": "THE-ID"}`)},
stubResponse{200, bytes.NewBufferString(`{"node_id": "THE-ID"}`)},
)
cs, cmdTeardown := test.InitCmdStubber()
defer cmdTeardown()
cs.Stub("") // git config --get-regexp ^branch\.blueberries\.(remote|merge)$
cs.Stub("") // git symbolic-ref --quiet --short HEAD
cs.Stub("") // git checkout master
cs.Stub("") // git branch -d
output, err := RunCommand("pr merge 2 --rebase")
if err != nil {
t.Fatalf("error running command `pr merge`: %v", err)
@ -1060,8 +1165,17 @@ func TestPrMerge_squash(t *testing.T) {
"pullRequest": { "number": 3, "closed": false, "state": "OPEN"}
} } }`)},
stubResponse{200, bytes.NewBufferString(`{"id": "THE-ID"}`)},
stubResponse{200, bytes.NewBufferString(`{"node_id": "THE-ID"}`)},
)
cs, cmdTeardown := test.InitCmdStubber()
defer cmdTeardown()
cs.Stub("") // git config --get-regexp ^branch\.blueberries\.(remote|merge)$
cs.Stub("") // git symbolic-ref --quiet --short HEAD
cs.Stub("") // git checkout master
cs.Stub("") // git branch -d
output, err := RunCommand("pr merge 3 --squash")
if err != nil {
t.Fatalf("error running command `pr merge`: %v", err)
@ -1080,8 +1194,17 @@ func TestPrMerge_alreadyMerged(t *testing.T) {
"pullRequest": { "number": 4, "closed": true, "state": "MERGED"}
} } }`)},
stubResponse{200, bytes.NewBufferString(`{"id": "THE-ID"}`)},
stubResponse{200, bytes.NewBufferString(`{"node_id": "THE-ID"}`)},
)
cs, cmdTeardown := test.InitCmdStubber()
defer cmdTeardown()
cs.Stub("") // git config --get-regexp ^branch\.blueberries\.(remote|merge)$
cs.Stub("") // git symbolic-ref --quiet --short HEAD
cs.Stub("") // git checkout master
cs.Stub("") // git branch -d
output, err := RunCommand("pr merge 4")
if err == nil {
t.Fatalf("expected an error running command `pr merge`: %v", err)
@ -1094,6 +1217,60 @@ func TestPrMerge_alreadyMerged(t *testing.T) {
}
}
func TestPRMerge_interactive(t *testing.T) {
initWithStubs("blueberries",
stubResponse{200, bytes.NewBufferString(`
{ "data": { "repository": { "pullRequests": { "nodes": [
{ "headRefName": "blueberries", "headRepositoryOwner": {"login": "OWNER"}, "id": "THE-ID", "number": 3}
] } } } }`)},
stubResponse{200, bytes.NewBufferString(`{"node_id": "THE-ID"}`)},
stubResponse{200, bytes.NewBufferString(`{ "data": {} }`)})
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 := initAskStubber()
defer surveyTeardown()
as.Stub([]*QuestionStub{
{
Name: "mergeMethod",
Value: 0,
},
{
Name: "deleteBranch",
Value: true,
},
})
output, err := RunCommand(`pr merge`)
if err != nil {
t.Fatalf("Got unexpected error running `pr merge` %s", err)
}
test.ExpectLines(t, output.String(), "Merged pull request #3", "Deleted branch blueberries")
}
func TestPrMerge_multipleMergeMethods(t *testing.T) {
initWithStubs("master",
stubResponse{200, bytes.NewBufferString(`{ "data": { "repository": {
"pullRequest": { "number": 1, "closed": false, "state": "OPEN"}
} } }`)},
stubResponse{200, bytes.NewBufferString(`{"id": "THE-ID"}`)},
)
_, err := RunCommand("pr merge 1 --merge --squash")
if err == nil {
t.Fatal("expected error running `pr merge` with multiple merge methods")
}
}
func TestPRReady(t *testing.T) {
initBlankContext("", "OWNER/REPO", "master")
http := initFakeHTTP()

View file

@ -1,6 +1,7 @@
package command
import (
"errors"
"fmt"
"io"
"os"
@ -198,6 +199,49 @@ var apiClientForContext = func(ctx context.Context) (*api.Client, error) {
return api.NewClient(opts...), nil
}
var ensureScopes = func(ctx context.Context, client *api.Client, wantedScopes ...string) (*api.Client, error) {
hasScopes, appID, err := client.HasScopes(wantedScopes...)
if err != nil {
return client, err
}
if hasScopes {
return client, nil
}
if config.IsGitHubApp(appID) && utils.IsTerminal(os.Stdin) && utils.IsTerminal(os.Stderr) {
newToken, loginHandle, err := config.AuthFlow("Notice: additional authorization required")
if err != nil {
return client, err
}
cfg, err := ctx.Config()
if err != nil {
return client, err
}
_ = cfg.Set(defaultHostname, "oauth_token", newToken)
_ = cfg.Set(defaultHostname, "user", loginHandle)
// update config file on disk
err = cfg.Write()
if err != nil {
return client, err
}
// update configuration in memory
config.AuthFlowComplete()
reloadedClient, err := apiClientForContext(ctx)
if err != nil {
return client, err
}
return reloadedClient, nil
} else {
fmt.Fprintln(os.Stderr, fmt.Sprintf("Warning: gh now requires the `%s` OAuth scope(s).", wantedScopes))
fmt.Fprintln(os.Stderr, fmt.Sprintf("Visit https://github.com/settings/tokens and edit your token to enable %s", wantedScopes))
fmt.Fprintln(os.Stderr, "or generate a new token and paste it via `gh config set -h github.com oauth_token MYTOKEN`")
return client, errors.New("Unable to reauthenticate")
}
}
func apiVerboseLog() api.ClientOption {
logTraffic := strings.Contains(os.Getenv("DEBUG"), "api")
colorize := utils.IsTerminal(os.Stderr)

View file

@ -96,6 +96,9 @@ func initBlankContext(cfg, repo, branch string) {
func initFakeHTTP() *httpmock.Registry {
http := &httpmock.Registry{}
ensureScopes = func(ctx context.Context, client *api.Client, wantedScopes ...string) (*api.Client, error) {
return client, nil
}
apiClientForContext = func(context.Context) (*api.Client, error) {
return api.NewClient(api.ReplaceTripper(http)), nil
}

View file

@ -203,6 +203,24 @@ func ReadBranchConfig(branch string) (cfg BranchConfig) {
return
}
func DeleteLocalBranch(branch string) error {
branchCmd := GitCommand("branch", "-D", branch)
err := run.PrepareCmd(branchCmd).Run()
return err
}
func HasLocalBranch(branch string) bool {
configCmd := GitCommand("rev-parse", "--verify", "refs/heads/"+branch)
_, err := run.PrepareCmd(configCmd).Output()
return err == nil
}
func CheckoutBranch(branch string) error {
configCmd := GitCommand("checkout", branch)
err := run.PrepareCmd(configCmd).Run()
return err
}
func isFilesystemPath(p string) bool {
return p == "." || strings.HasPrefix(p, "./") || strings.HasPrefix(p, "/")
}

View file

@ -41,7 +41,7 @@ func AuthFlow(notice string) (string, string, error) {
Hostname: oauthHost,
ClientID: oauthClientID,
ClientSecret: oauthClientSecret,
Scopes: []string{"repo", "read:org"},
Scopes: []string{"repo", "read:org", "gist"},
WriteSuccessHTML: func(w io.Writer) {
fmt.Fprintln(w, oauthSuccessPage)
},

1
test/fixtures/gistCreate.json vendored Normal file
View file

@ -0,0 +1 @@
{}

View file

@ -70,6 +70,24 @@
"login": ""
},
"state": "APPROVED"
},
{
"author": {
"login": "hubot"
},
"state": "CHANGES_REQUESTED"
},
{
"author": {
"login": "hubot"
},
"state": "DISMISSED"
},
{
"author": {
"login": "monalisa"
},
"state": "PENDING"
}
]
},

View file

@ -68,6 +68,7 @@ func createStubbedPrepareCmd(cs *CmdStubber) func(*exec.Cmd) run.Runnable {
if call >= len(cs.Stubs) {
panic(fmt.Sprintf("more execs than stubs. most recent call: %v", cmd))
}
// fmt.Printf("Called stub for `%v`\n", cmd) // Helpful for debugging
return cs.Stubs[call]
}
}