diff --git a/README.md b/README.md index ca23a060d..47f80bd8b 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,25 @@ The #ce-cli team is working on a publicly available CLI tool to reduce the frict This tool is an endeavor separate from [github/hub](https://github.com/github/hub), which acts as a proxy to `git`, since our aim is to reimagine from scratch the kind of command line interface to GitHub that would serve our users' interests best. +# Installation + +_warning, gh is in a very alpha phase_ + +`brew install github/gh/gh` + +That's it. You are now ready to use `gh` on the command line. 🥳 + # Process - [Demo planning doc](https://docs.google.com/document/d/18ym-_xjFTSXe0-xzgaBn13Su7MEhWfLE5qSNPJV4M0A/edit) - [Weekly tracking issue](https://github.com/github/gh-cli/labels/tracking%20issue) - [Weekly sync notes](https://docs.google.com/document/d/1eUo9nIzXbC1DG26Y3dk9hOceLua2yFlwlvFPZ82MwHg/edit) + +# How to create a release + +This can all be done from your local terminal. + +1. `git tag 'vVERSION_NUMBER' # example git tag 'v0.0.1'` +2. `git push origin vVERSION_NUMBER` +3. Wait a few minutes for the build to run and CI to pass. Look at the [actions tab](https://github.com/github/gh-cli/actions) to check the progress. +4. Go to https://github.com/github/homebrew-gh/releases and look at the release diff --git a/api/queries.go b/api/queries.go index 68a9d14ba..4168113a8 100644 --- a/api/queries.go +++ b/api/queries.go @@ -16,6 +16,93 @@ type PullRequest struct { State string URL string HeadRefName string + + HeadRepositoryOwner struct { + Login string + } + HeadRepository struct { + Name string + DefaultBranchRef struct { + Name string + } + } + IsCrossRepository bool + MaintainerCanModify bool + + ReviewDecision string + + Commits struct { + Nodes []struct { + Commit struct { + StatusCheckRollup struct { + Contexts struct { + Nodes []struct { + State string + Conclusion string + } + } + } + } + } + } +} + +func (pr PullRequest) HeadLabel() string { + if pr.IsCrossRepository { + return fmt.Sprintf("%s:%s", pr.HeadRepositoryOwner.Login, pr.HeadRefName) + } + return pr.HeadRefName +} + +type PullRequestReviewStatus struct { + ChangesRequested bool + Approved bool + ReviewRequired bool +} + +func (pr *PullRequest) ReviewStatus() PullRequestReviewStatus { + status := PullRequestReviewStatus{} + switch pr.ReviewDecision { + case "CHANGES_REQUESTED": + status.ChangesRequested = true + case "APPROVED": + status.Approved = true + case "REVIEW_REQUIRED": + status.ReviewRequired = true + } + return status +} + +type PullRequestChecksStatus struct { + Pending int + Failing int + Passing int + Total int +} + +func (pr *PullRequest) ChecksStatus() (summary PullRequestChecksStatus) { + if len(pr.Commits.Nodes) == 0 { + return + } + commit := pr.Commits.Nodes[0].Commit + for _, c := range commit.StatusCheckRollup.Contexts.Nodes { + state := c.State + if state == "" { + state = c.Conclusion + } + switch state { + case "SUCCESS", "NEUTRAL", "SKIPPED": + summary.Passing++ + case "ERROR", "FAILURE", "CANCELLED", "TIMED_OUT", "ACTION_REQUIRED": + summary.Failing++ + case "EXPECTED", "QUEUED", "PENDING", "IN_PROGRESS": + summary.Pending++ + default: + panic(fmt.Errorf("unsupported status: %q", state)) + } + summary.Total++ + } + return } type Repo interface { @@ -43,19 +130,46 @@ func PullRequests(client *Client, ghRepo Repo, currentBranch, currentUsername st } query := ` - fragment pr on PullRequest { - number - title - url - headRefName - } + fragment pr on PullRequest { + number + title + url + headRefName + headRefName + headRepositoryOwner { + login + } + isCrossRepository + commits(last: 1) { + nodes { + commit { + statusCheckRollup { + contexts(last: 100) { + nodes { + ...on StatusContext { + state + } + ...on CheckRun { + conclusion + } + } + } + } + } + } + } + } + fragment prWithReviews on PullRequest { + ...pr + reviewDecision + } query($owner: String!, $repo: String!, $headRefName: String!, $viewerQuery: String!, $reviewerQuery: String!, $per_page: Int = 10) { repository(owner: $owner, name: $repo) { pullRequests(headRefName: $headRefName, states: OPEN, first: 1) { edges { node { - ...pr + ...prWithReviews } } } @@ -63,7 +177,7 @@ func PullRequests(client *Client, ghRepo Repo, currentBranch, currentUsername st viewerCreated: search(query: $viewerQuery, type: ISSUE, first: $per_page) { edges { node { - ...pr + ...prWithReviews } } pageInfo { @@ -127,6 +241,48 @@ func PullRequests(client *Client, ghRepo Repo, currentBranch, currentUsername st return &payload, nil } +func PullRequestByNumber(client *Client, ghRepo Repo, number int) (*PullRequest, error) { + type response struct { + Repository struct { + PullRequest PullRequest + } + } + + query := ` + query($owner: String!, $repo: String!, $pr_number: Int!) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $pr_number) { + headRefName + headRepositoryOwner { + login + } + headRepository { + name + defaultBranchRef { + name + } + } + isCrossRepository + maintainerCanModify + } + } + }` + + variables := map[string]interface{}{ + "owner": ghRepo.RepoOwner(), + "repo": ghRepo.RepoName(), + "pr_number": number, + } + + var resp response + err := client.GraphQL(query, variables, &resp) + if err != nil { + return nil, err + } + + return &resp.Repository.PullRequest, nil +} + func PullRequestsForBranch(client *Client, ghRepo Repo, branch string) ([]PullRequest, error) { type response struct { Repository struct { @@ -173,6 +329,45 @@ func PullRequestsForBranch(client *Client, ghRepo Repo, branch string) ([]PullRe return prs, nil } +func CreatePullRequest(client *Client, ghRepo Repo, params map[string]interface{}) (*PullRequest, error) { + repoID, err := GitHubRepoId(client, ghRepo) + if err != nil { + return nil, err + } + + query := ` + mutation CreatePullRequest($input: CreatePullRequestInput!) { + createPullRequest(input: $input) { + pullRequest { + url + } + } + }` + + inputParams := map[string]interface{}{ + "repositoryId": repoID, + } + for key, val := range params { + inputParams[key] = val + } + variables := map[string]interface{}{ + "input": inputParams, + } + + result := struct { + CreatePullRequest struct { + PullRequest PullRequest + } + }{} + + err = client.GraphQL(query, variables, &result) + if err != nil { + return nil, err + } + + return &result.CreatePullRequest.PullRequest, nil +} + func PullRequestList(client *Client, vars map[string]interface{}, limit int) ([]PullRequest, error) { type response struct { Repository struct { @@ -214,6 +409,10 @@ func PullRequestList(client *Client, vars map[string]interface{}, limit int) ([] state url headRefName + headRepositoryOwner { + login + } + isCrossRepository } } pageInfo { diff --git a/api/queries_issue.go b/api/queries_issue.go index bbf9b276c..5f3d243ec 100644 --- a/api/queries_issue.go +++ b/api/queries_issue.go @@ -92,7 +92,7 @@ func IssueStatus(client *Client, ghRepo Repo, currentUsername string) (*IssuesPa } query($owner: String!, $repo: String!, $since: DateTime!, $viewer: String!, $per_page: Int = 10) { assigned: repository(owner: $owner, name: $repo) { - issues(filterBy: {assignee: $viewer}, first: $per_page, orderBy: {field: CREATED_AT, direction: DESC}) { + issues(filterBy: {assignee: $viewer, states: OPEN}, first: $per_page, orderBy: {field: CREATED_AT, direction: DESC}) { edges { node { ...issue @@ -101,7 +101,7 @@ func IssueStatus(client *Client, ghRepo Repo, currentUsername string) (*IssuesPa } } mentioned: repository(owner: $owner, name: $repo) { - issues(filterBy: {mentioned: $viewer}, first: $per_page, orderBy: {field: CREATED_AT, direction: DESC}) { + issues(filterBy: {mentioned: $viewer, states: OPEN}, first: $per_page, orderBy: {field: CREATED_AT, direction: DESC}) { edges { node { ...issue @@ -110,7 +110,7 @@ func IssueStatus(client *Client, ghRepo Repo, currentUsername string) (*IssuesPa } } recent: repository(owner: $owner, name: $repo) { - issues(filterBy: {since: $since}, first: $per_page, orderBy: {field: CREATED_AT, direction: DESC}) { + issues(filterBy: {since: $since, states: OPEN}, first: $per_page, orderBy: {field: CREATED_AT, direction: DESC}) { edges { node { ...issue diff --git a/command/issue.go b/command/issue.go index 9dfcd502c..ace7ac2e3 100644 --- a/command/issue.go +++ b/command/issue.go @@ -24,7 +24,7 @@ func init() { &cobra.Command{ Use: "view ", Args: cobra.MinimumNArgs(1), - Short: "Open an issue in the browser", + Short: "View an issue in the browser", RunE: issueView, }, ) diff --git a/command/pr.go b/command/pr.go index efbf8d995..1ce061dc4 100644 --- a/command/pr.go +++ b/command/pr.go @@ -3,9 +3,11 @@ package command import ( "fmt" "os" + "os/exec" "strconv" "github.com/github/gh-cli/api" + "github.com/github/gh-cli/git" "github.com/github/gh-cli/utils" "github.com/spf13/cobra" "golang.org/x/crypto/ssh/terminal" @@ -13,6 +15,8 @@ import ( func init() { RootCmd.AddCommand(prCmd) + prCmd.AddCommand(prCheckoutCmd) + prCmd.AddCommand(prCreateCmd) prCmd.AddCommand(prListCmd) prCmd.AddCommand(prStatusCmd) prCmd.AddCommand(prViewCmd) @@ -28,6 +32,12 @@ var prCmd = &cobra.Command{ Short: "Work with pull requests", Long: `Helps you work with pull requests.`, } +var prCheckoutCmd = &cobra.Command{ + Use: "checkout ", + Short: "check out a pull request in git", + Args: cobra.MinimumNArgs(1), + RunE: prCheckout, +} var prListCmd = &cobra.Command{ Use: "list", Short: "List pull requests", @@ -40,7 +50,7 @@ var prStatusCmd = &cobra.Command{ } var prViewCmd = &cobra.Command{ Use: "view [pr-number]", - Short: "Open a pull request in the browser", + Short: "View a pull request in the browser", RunE: prView, } @@ -199,10 +209,10 @@ func prList(cmd *cobra.Command, args []string) error { case "MERGED": prNum = utils.Magenta(prNum) } - prBranch := utils.Cyan(truncate(branchWidth, pr.HeadRefName)) + prBranch := utils.Cyan(truncate(branchWidth, pr.HeadLabel())) fmt.Fprintf(out, "%s %-*s %s\n", prNum, titleWidth, truncate(titleWidth, pr.Title), prBranch) } else { - fmt.Fprintf(out, "%d\t%s\t%s\n", pr.Number, pr.Title, pr.HeadRefName) + fmt.Fprintf(out, "%d\t%s\t%s\n", pr.Number, pr.Title, pr.HeadLabel()) } } return nil @@ -246,9 +256,135 @@ func prView(cmd *cobra.Command, args []string) error { return utils.OpenInBrowser(openURL) } +func prCheckout(cmd *cobra.Command, args []string) error { + prNumber, err := strconv.Atoi(args[0]) + if err != nil { + return err + } + + ctx := contextForCommand(cmd) + currentBranch, _ := ctx.Branch() + remotes, err := ctx.Remotes() + if err != nil { + return err + } + // FIXME: duplicates logic from fsContext.BaseRepo + baseRemote, err := remotes.FindByName("upstream", "github", "origin", "*") + if err != nil { + return err + } + apiClient, err := apiClientForContext(ctx) + if err != nil { + return err + } + + pr, err := api.PullRequestByNumber(apiClient, baseRemote, prNumber) + if err != nil { + return err + } + + headRemote := baseRemote + if pr.IsCrossRepository { + headRemote, _ = remotes.FindByRepo(pr.HeadRepositoryOwner.Login, pr.HeadRepository.Name) + } + + cmdQueue := [][]string{} + + newBranchName := pr.HeadRefName + if headRemote != nil { + // there is an existing git remote for PR head + remoteBranch := fmt.Sprintf("%s/%s", headRemote.Name, pr.HeadRefName) + refSpec := fmt.Sprintf("+refs/heads/%s:refs/remotes/%s", pr.HeadRefName, remoteBranch) + + cmdQueue = append(cmdQueue, []string{"git", "fetch", headRemote.Name, refSpec}) + + // local branch already exists + if git.VerifyRef("refs/heads/" + newBranchName) { + cmdQueue = append(cmdQueue, []string{"git", "checkout", newBranchName}) + cmdQueue = append(cmdQueue, []string{"git", "merge", "--ff-only", fmt.Sprintf("refs/remotes/%s", remoteBranch)}) + } else { + cmdQueue = append(cmdQueue, []string{"git", "checkout", "-b", newBranchName, "--no-track", remoteBranch}) + cmdQueue = append(cmdQueue, []string{"git", "config", fmt.Sprintf("branch.%s.remote", newBranchName), headRemote.Name}) + cmdQueue = append(cmdQueue, []string{"git", "config", fmt.Sprintf("branch.%s.merge", newBranchName), "refs/heads/" + pr.HeadRefName}) + } + } else { + // no git remote for PR head + + // avoid naming the new branch the same as the default branch + if newBranchName == pr.HeadRepository.DefaultBranchRef.Name { + newBranchName = fmt.Sprintf("%s/%s", pr.HeadRepositoryOwner.Login, newBranchName) + } + + ref := fmt.Sprintf("refs/pull/%d/head", prNumber) + if newBranchName == currentBranch { + // PR head matches currently checked out branch + cmdQueue = append(cmdQueue, []string{"git", "fetch", baseRemote.Name, ref}) + cmdQueue = append(cmdQueue, []string{"git", "merge", "--ff-only", "FETCH_HEAD"}) + } else { + // create a new branch + cmdQueue = append(cmdQueue, []string{"git", "fetch", baseRemote.Name, fmt.Sprintf("%s:%s", ref, newBranchName)}) + cmdQueue = append(cmdQueue, []string{"git", "checkout", newBranchName}) + } + + remote := baseRemote.Name + mergeRef := ref + if pr.MaintainerCanModify { + remote = fmt.Sprintf("https://github.com/%s/%s.git", pr.HeadRepositoryOwner.Login, pr.HeadRepository.Name) + mergeRef = fmt.Sprintf("refs/heads/%s", pr.HeadRefName) + } + if mc, err := git.Config(fmt.Sprintf("branch.%s.merge", newBranchName)); err != nil || mc == "" { + cmdQueue = append(cmdQueue, []string{"git", "config", fmt.Sprintf("branch.%s.remote", newBranchName), remote}) + cmdQueue = append(cmdQueue, []string{"git", "config", fmt.Sprintf("branch.%s.merge", newBranchName), mergeRef}) + } + } + + for _, args := range cmdQueue { + cmd := exec.Command(args[0], args[1:]...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := utils.PrepareCmd(cmd).Run(); err != nil { + return err + } + } + + return nil +} + func printPrs(prs ...api.PullRequest) { for _, pr := range prs { - fmt.Printf(" #%d %s %s\n", pr.Number, truncate(50, pr.Title), utils.Cyan("["+pr.HeadRefName+"]")) + prNumber := fmt.Sprintf("#%d", pr.Number) + fmt.Printf(" %s %s %s", utils.Yellow(prNumber), truncate(50, pr.Title), utils.Cyan("["+pr.HeadLabel()+"]")) + + checks := pr.ChecksStatus() + reviews := pr.ReviewStatus() + if checks.Total > 0 || reviews.ChangesRequested || reviews.Approved { + fmt.Printf("\n ") + } + + if checks.Total > 0 { + var ratio string + if checks.Failing > 0 { + ratio = fmt.Sprintf("%d/%d", checks.Passing, checks.Total) + ratio = utils.Red(ratio) + } else if checks.Pending > 0 { + ratio = fmt.Sprintf("%d/%d", checks.Passing, checks.Total) + ratio = utils.Yellow(ratio) + } else if checks.Passing == checks.Total { + ratio = fmt.Sprintf("%d", checks.Total) + ratio = utils.Green(ratio) + } + fmt.Printf(" - checks: %s", ratio) + } + + if reviews.ChangesRequested { + fmt.Printf(" - %s", utils.Red("changes requested")) + } else if reviews.ReviewRequired { + fmt.Printf(" - %s", utils.Yellow("review required")) + } else if reviews.Approved { + fmt.Printf(" - %s", utils.Green("approved")) + } + + fmt.Printf("\n") } } diff --git a/command/pr_checkout_test.go b/command/pr_checkout_test.go new file mode 100644 index 000000000..930d2b4d8 --- /dev/null +++ b/command/pr_checkout_test.go @@ -0,0 +1,364 @@ +package command + +import ( + "bytes" + "os/exec" + "strings" + "testing" + + "github.com/github/gh-cli/context" + "github.com/github/gh-cli/utils" +) + +func TestPRCheckout_sameRepo(t *testing.T) { + ctx := context.NewBlank() + ctx.SetBranch("master") + ctx.SetRemotes(map[string]string{ + "origin": "OWNER/REPO", + }) + initContext = func() context.Context { + return ctx + } + http := initFakeHTTP() + + http.StubResponse(200, bytes.NewBufferString(` + { "data": { "repository": { "pullRequest": { + "headRefName": "feature", + "headRepositoryOwner": { + "login": "hubot" + }, + "headRepository": { + "name": "REPO", + "defaultBranchRef": { + "name": "master" + } + }, + "isCrossRepository": false, + "maintainerCanModify": false + } } } } + `)) + + ranCommands := [][]string{} + restoreCmd := utils.SetPrepareCmd(func(cmd *exec.Cmd) utils.Runnable { + switch strings.Join(cmd.Args, " ") { + case "git show-ref --verify --quiet refs/heads/feature": + return &errorStub{"exit status: 1"} + default: + ranCommands = append(ranCommands, cmd.Args) + return &outputStub{} + } + }) + defer restoreCmd() + + RootCmd.SetArgs([]string{"pr", "checkout", "123"}) + _, err := prCheckoutCmd.ExecuteC() + eq(t, err, nil) + + eq(t, len(ranCommands), 4) + eq(t, strings.Join(ranCommands[0], " "), "git fetch origin +refs/heads/feature:refs/remotes/origin/feature") + eq(t, strings.Join(ranCommands[1], " "), "git checkout -b feature --no-track origin/feature") + eq(t, strings.Join(ranCommands[2], " "), "git config branch.feature.remote origin") + eq(t, strings.Join(ranCommands[3], " "), "git config branch.feature.merge refs/heads/feature") +} + +func TestPRCheckout_existingBranch(t *testing.T) { + ctx := context.NewBlank() + ctx.SetBranch("master") + ctx.SetRemotes(map[string]string{ + "origin": "OWNER/REPO", + }) + initContext = func() context.Context { + return ctx + } + http := initFakeHTTP() + + http.StubResponse(200, bytes.NewBufferString(` + { "data": { "repository": { "pullRequest": { + "headRefName": "feature", + "headRepositoryOwner": { + "login": "hubot" + }, + "headRepository": { + "name": "REPO", + "defaultBranchRef": { + "name": "master" + } + }, + "isCrossRepository": false, + "maintainerCanModify": false + } } } } + `)) + + ranCommands := [][]string{} + restoreCmd := utils.SetPrepareCmd(func(cmd *exec.Cmd) utils.Runnable { + switch strings.Join(cmd.Args, " ") { + case "git show-ref --verify --quiet refs/heads/feature": + return &outputStub{} + default: + ranCommands = append(ranCommands, cmd.Args) + return &outputStub{} + } + }) + defer restoreCmd() + + RootCmd.SetArgs([]string{"pr", "checkout", "123"}) + _, err := prCheckoutCmd.ExecuteC() + eq(t, err, nil) + + eq(t, len(ranCommands), 3) + eq(t, strings.Join(ranCommands[0], " "), "git fetch origin +refs/heads/feature:refs/remotes/origin/feature") + eq(t, strings.Join(ranCommands[1], " "), "git checkout feature") + eq(t, strings.Join(ranCommands[2], " "), "git merge --ff-only refs/remotes/origin/feature") +} + +func TestPRCheckout_differentRepo_remoteExists(t *testing.T) { + ctx := context.NewBlank() + ctx.SetBranch("master") + ctx.SetRemotes(map[string]string{ + "origin": "OWNER/REPO", + "robot-fork": "hubot/REPO", + }) + initContext = func() context.Context { + return ctx + } + http := initFakeHTTP() + + http.StubResponse(200, bytes.NewBufferString(` + { "data": { "repository": { "pullRequest": { + "headRefName": "feature", + "headRepositoryOwner": { + "login": "hubot" + }, + "headRepository": { + "name": "REPO", + "defaultBranchRef": { + "name": "master" + } + }, + "isCrossRepository": true, + "maintainerCanModify": false + } } } } + `)) + + ranCommands := [][]string{} + restoreCmd := utils.SetPrepareCmd(func(cmd *exec.Cmd) utils.Runnable { + switch strings.Join(cmd.Args, " ") { + case "git show-ref --verify --quiet refs/heads/feature": + return &errorStub{"exit status: 1"} + default: + ranCommands = append(ranCommands, cmd.Args) + return &outputStub{} + } + }) + defer restoreCmd() + + RootCmd.SetArgs([]string{"pr", "checkout", "123"}) + _, err := prCheckoutCmd.ExecuteC() + eq(t, err, nil) + + eq(t, len(ranCommands), 4) + eq(t, strings.Join(ranCommands[0], " "), "git fetch robot-fork +refs/heads/feature:refs/remotes/robot-fork/feature") + eq(t, strings.Join(ranCommands[1], " "), "git checkout -b feature --no-track robot-fork/feature") + eq(t, strings.Join(ranCommands[2], " "), "git config branch.feature.remote robot-fork") + eq(t, strings.Join(ranCommands[3], " "), "git config branch.feature.merge refs/heads/feature") +} + +func TestPRCheckout_differentRepo(t *testing.T) { + ctx := context.NewBlank() + ctx.SetBranch("master") + ctx.SetRemotes(map[string]string{ + "origin": "OWNER/REPO", + }) + initContext = func() context.Context { + return ctx + } + http := initFakeHTTP() + + http.StubResponse(200, bytes.NewBufferString(` + { "data": { "repository": { "pullRequest": { + "headRefName": "feature", + "headRepositoryOwner": { + "login": "hubot" + }, + "headRepository": { + "name": "REPO", + "defaultBranchRef": { + "name": "master" + } + }, + "isCrossRepository": true, + "maintainerCanModify": false + } } } } + `)) + + ranCommands := [][]string{} + restoreCmd := utils.SetPrepareCmd(func(cmd *exec.Cmd) utils.Runnable { + switch strings.Join(cmd.Args, " ") { + case "git config branch.feature.merge": + return &errorStub{"exit status 1"} + default: + ranCommands = append(ranCommands, cmd.Args) + return &outputStub{} + } + }) + defer restoreCmd() + + RootCmd.SetArgs([]string{"pr", "checkout", "123"}) + _, err := prCheckoutCmd.ExecuteC() + eq(t, err, nil) + + eq(t, len(ranCommands), 4) + eq(t, strings.Join(ranCommands[0], " "), "git fetch origin refs/pull/123/head:feature") + eq(t, strings.Join(ranCommands[1], " "), "git checkout feature") + eq(t, strings.Join(ranCommands[2], " "), "git config branch.feature.remote origin") + eq(t, strings.Join(ranCommands[3], " "), "git config branch.feature.merge refs/pull/123/head") +} + +func TestPRCheckout_differentRepo_existingBranch(t *testing.T) { + ctx := context.NewBlank() + ctx.SetBranch("master") + ctx.SetRemotes(map[string]string{ + "origin": "OWNER/REPO", + }) + initContext = func() context.Context { + return ctx + } + http := initFakeHTTP() + + http.StubResponse(200, bytes.NewBufferString(` + { "data": { "repository": { "pullRequest": { + "headRefName": "feature", + "headRepositoryOwner": { + "login": "hubot" + }, + "headRepository": { + "name": "REPO", + "defaultBranchRef": { + "name": "master" + } + }, + "isCrossRepository": true, + "maintainerCanModify": false + } } } } + `)) + + ranCommands := [][]string{} + restoreCmd := utils.SetPrepareCmd(func(cmd *exec.Cmd) utils.Runnable { + switch strings.Join(cmd.Args, " ") { + case "git config branch.feature.merge": + return &outputStub{[]byte("refs/heads/feature\n")} + default: + ranCommands = append(ranCommands, cmd.Args) + return &outputStub{} + } + }) + defer restoreCmd() + + RootCmd.SetArgs([]string{"pr", "checkout", "123"}) + _, err := prCheckoutCmd.ExecuteC() + eq(t, err, nil) + + eq(t, len(ranCommands), 2) + eq(t, strings.Join(ranCommands[0], " "), "git fetch origin refs/pull/123/head:feature") + eq(t, strings.Join(ranCommands[1], " "), "git checkout feature") +} + +func TestPRCheckout_differentRepo_currentBranch(t *testing.T) { + ctx := context.NewBlank() + ctx.SetBranch("feature") + ctx.SetRemotes(map[string]string{ + "origin": "OWNER/REPO", + }) + initContext = func() context.Context { + return ctx + } + http := initFakeHTTP() + + http.StubResponse(200, bytes.NewBufferString(` + { "data": { "repository": { "pullRequest": { + "headRefName": "feature", + "headRepositoryOwner": { + "login": "hubot" + }, + "headRepository": { + "name": "REPO", + "defaultBranchRef": { + "name": "master" + } + }, + "isCrossRepository": true, + "maintainerCanModify": false + } } } } + `)) + + ranCommands := [][]string{} + restoreCmd := utils.SetPrepareCmd(func(cmd *exec.Cmd) utils.Runnable { + switch strings.Join(cmd.Args, " ") { + case "git config branch.feature.merge": + return &outputStub{[]byte("refs/heads/feature\n")} + default: + ranCommands = append(ranCommands, cmd.Args) + return &outputStub{} + } + }) + defer restoreCmd() + + RootCmd.SetArgs([]string{"pr", "checkout", "123"}) + _, err := prCheckoutCmd.ExecuteC() + eq(t, err, nil) + + eq(t, len(ranCommands), 2) + eq(t, strings.Join(ranCommands[0], " "), "git fetch origin refs/pull/123/head") + eq(t, strings.Join(ranCommands[1], " "), "git merge --ff-only FETCH_HEAD") +} + +func TestPRCheckout_maintainerCanModify(t *testing.T) { + ctx := context.NewBlank() + ctx.SetBranch("master") + ctx.SetRemotes(map[string]string{ + "origin": "OWNER/REPO", + }) + initContext = func() context.Context { + return ctx + } + http := initFakeHTTP() + + http.StubResponse(200, bytes.NewBufferString(` + { "data": { "repository": { "pullRequest": { + "headRefName": "feature", + "headRepositoryOwner": { + "login": "hubot" + }, + "headRepository": { + "name": "REPO", + "defaultBranchRef": { + "name": "master" + } + }, + "isCrossRepository": true, + "maintainerCanModify": true + } } } } + `)) + + ranCommands := [][]string{} + restoreCmd := utils.SetPrepareCmd(func(cmd *exec.Cmd) utils.Runnable { + switch strings.Join(cmd.Args, " ") { + case "git config branch.feature.merge": + return &errorStub{"exit status 1"} + default: + ranCommands = append(ranCommands, cmd.Args) + return &outputStub{} + } + }) + defer restoreCmd() + + RootCmd.SetArgs([]string{"pr", "checkout", "123"}) + _, err := prCheckoutCmd.ExecuteC() + eq(t, err, nil) + + eq(t, len(ranCommands), 4) + eq(t, strings.Join(ranCommands[0], " "), "git fetch origin refs/pull/123/head:feature") + eq(t, strings.Join(ranCommands[1], " "), "git checkout feature") + eq(t, strings.Join(ranCommands[2], " "), "git config branch.feature.remote https://github.com/hubot/REPO.git") + eq(t, strings.Join(ranCommands[3], " "), "git config branch.feature.merge refs/heads/feature") +} diff --git a/command/pr_create.go b/command/pr_create.go new file mode 100644 index 000000000..c8d86f53a --- /dev/null +++ b/command/pr_create.go @@ -0,0 +1,223 @@ +package command + +import ( + "fmt" + "os" + "runtime" + + "github.com/AlecAivazis/survey/v2" + "github.com/github/gh-cli/api" + "github.com/github/gh-cli/context" + "github.com/github/gh-cli/git" + "github.com/pkg/errors" + "github.com/spf13/cobra" +) + +func prCreate(cmd *cobra.Command, _ []string) error { + ctx := contextForCommand(cmd) + + ucc, err := git.UncommittedChangeCount() + if err != nil { + return err + } + if ucc > 0 { + noun := "change" + if ucc > 1 { + // TODO: use pluralize helper + noun = noun + "s" + } + + cmd.Printf("Warning: %d uncommitted %s\n", ucc, noun) + } + + head, err := ctx.Branch() + if err != nil { + return errors.Wrap(err, "could not determine current branch") + } + + remote, err := guessRemote(ctx) + if err != nil { + return err + } + + if err = git.Push(remote, fmt.Sprintf("HEAD:%s", head)); err != nil { + return fmt.Errorf("was not able to push to remote '%s': %s", remote, err) + } + + title, err := cmd.Flags().GetString("title") + if err != nil { + return errors.Wrap(err, "could not parse title") + } + body, err := cmd.Flags().GetString("body") + if err != nil { + return errors.Wrap(err, "could not parse body") + } + + interactive := title == "" || body == "" + + inProgress := struct { + Body string + Title string + }{} + + if interactive { + confirmed := false + editor := determineEditor() + + for !confirmed { + titleQuestion := &survey.Question{ + Name: "title", + Prompt: &survey.Input{ + Message: "PR Title", + Default: inProgress.Title, + }, + } + bodyQuestion := &survey.Question{ + Name: "body", + Prompt: &survey.Editor{ + Message: fmt.Sprintf("PR Body (%s)", editor), + FileName: "*.md", + Default: inProgress.Body, + AppendDefault: true, + Editor: editor, + }, + } + + qs := []*survey.Question{} + if title == "" { + qs = append(qs, titleQuestion) + } + if body == "" { + qs = append(qs, bodyQuestion) + } + + err := survey.Ask(qs, &inProgress) + if err != nil { + return errors.Wrap(err, "could not prompt") + } + confirmAnswers := struct { + Confirmation string + }{} + confirmQs := []*survey.Question{ + { + Name: "confirmation", + Prompt: &survey.Select{ + Message: "Submit?", + Options: []string{ + "Yes", + "Edit", + "Cancel", + }, + }, + }, + } + + err = survey.Ask(confirmQs, &confirmAnswers) + if err != nil { + return errors.Wrap(err, "could not prompt") + } + + switch confirmAnswers.Confirmation { + case "Yes": + confirmed = true + case "Edit": + continue + case "Cancel": + cmd.Println("Discarding pull request") + return nil + } + } + } + + if title == "" { + title = inProgress.Title + } + if body == "" { + body = inProgress.Body + } + base, err := cmd.Flags().GetString("base") + if err != nil { + return errors.Wrap(err, "could not parse base") + } + if base == "" { + // TODO: use default branch for the repo + base = "master" + } + + client, err := apiClientForContext(ctx) + if err != nil { + return errors.Wrap(err, "could not initialize api client") + } + + repo, err := ctx.BaseRepo() + if err != nil { + return errors.Wrap(err, "could not determine GitHub repo") + } + + isDraft, err := cmd.Flags().GetBool("draft") + if err != nil { + return errors.Wrap(err, "could not parse draft") + } + + params := map[string]interface{}{ + "title": title, + "body": body, + "draft": isDraft, + "baseRefName": base, + "headRefName": head, + } + + pr, err := api.CreatePullRequest(client, repo, params) + if err != nil { + return errors.Wrap(err, "failed to create PR") + } + + fmt.Fprintln(cmd.OutOrStdout(), pr.URL) + return nil +} + +func guessRemote(ctx context.Context) (string, error) { + remotes, err := ctx.Remotes() + if err != nil { + return "", errors.Wrap(err, "could not read git remotes") + } + + // TODO: consolidate logic with fsContext.BaseRepo + // TODO: check if the GH repo that the remote points to is writeable + remote, err := remotes.FindByName("upstream", "github", "origin", "*") + if err != nil { + return "", errors.Wrap(err, "could not determine suitable remote") + } + + return remote.Name, nil +} + +func determineEditor() string { + if runtime.GOOS == "windows" { + return "notepad" + } + if v := os.Getenv("VISUAL"); v != "" { + return v + } + if e := os.Getenv("EDITOR"); e != "" { + return e + } + return "nano" +} + +var prCreateCmd = &cobra.Command{ + Use: "create", + Short: "Create a pull request", + RunE: prCreate, +} + +func init() { + prCreateCmd.Flags().BoolP("draft", "d", false, + "Mark PR as a draft") + prCreateCmd.Flags().StringP("title", "t", "", + "Supply a title. Will prompt for one otherwise.") + prCreateCmd.Flags().StringP("body", "b", "", + "Supply a body. Will prompt for one otherwise.") + prCreateCmd.Flags().StringP("base", "T", "", + "The branch into which you want your code merged") +} diff --git a/command/pr_create_test.go b/command/pr_create_test.go new file mode 100644 index 000000000..191043be3 --- /dev/null +++ b/command/pr_create_test.go @@ -0,0 +1,136 @@ +package command + +import ( + "bytes" + "encoding/json" + "fmt" + "io/ioutil" + "os" + "testing" + + "github.com/github/gh-cli/context" + "github.com/github/gh-cli/git" + "github.com/github/gh-cli/test" +) + +func TestPrCreateHelperProcess(*testing.T) { + if test.SkipTestHelperProcess() { + return + } + + args := test.GetTestHelperProcessArgs() + switch args[1] { + case "status": + switch args[0] { + case "clean": + case "dirty": + fmt.Println(" M git/git.go") + default: + fmt.Fprintf(os.Stderr, "unknown scenario: %q", args[0]) + os.Exit(1) + } + case "push": + default: + fmt.Fprintf(os.Stderr, "unknown command: %q", args[1]) + os.Exit(1) + } + os.Exit(0) +} + +func TestPRCreate(t *testing.T) { + ctx := context.NewBlank() + ctx.SetBranch("feature") + ctx.SetRemotes(map[string]string{ + "origin": "OWNER/REPO", + }) + initContext = func() context.Context { + return ctx + } + http := initFakeHTTP() + + http.StubResponse(200, bytes.NewBufferString(` + { "data": { "repository": { + "id": "REPOID" + } } } + `)) + http.StubResponse(200, bytes.NewBufferString(` + { "data": { "createPullRequest": { "pullRequest": { + "URL": "https://github.com/OWNER/REPO/pull/12" + } } } } + `)) + + origGitCommand := git.GitCommand + git.GitCommand = test.StubExecCommand("TestPrCreateHelperProcess", "clean") + defer func() { + git.GitCommand = origGitCommand + }() + + out := bytes.Buffer{} + prCreateCmd.SetOut(&out) + + RootCmd.SetArgs([]string{"pr", "create", "-t", "mytitle", "-b", "mybody"}) + _, err := prCreateCmd.ExecuteC() + eq(t, err, nil) + + bodyBytes, _ := ioutil.ReadAll(http.Requests[1].Body) + reqBody := struct { + Variables struct { + Input struct { + RepositoryID string + Title string + Body string + BaseRefName string + HeadRefName string + } + } + }{} + json.Unmarshal(bodyBytes, &reqBody) + + eq(t, reqBody.Variables.Input.RepositoryID, "REPOID") + eq(t, reqBody.Variables.Input.Title, "mytitle") + eq(t, reqBody.Variables.Input.Body, "mybody") + eq(t, reqBody.Variables.Input.BaseRefName, "master") + eq(t, reqBody.Variables.Input.HeadRefName, "feature") + + eq(t, out.String(), "https://github.com/OWNER/REPO/pull/12\n") +} + +func TestPRCreate_ReportsUncommittedChanges(t *testing.T) { + ctx := context.NewBlank() + ctx.SetBranch("feature") + ctx.SetRemotes(map[string]string{ + "origin": "OWNER/REPO", + }) + initContext = func() context.Context { + return ctx + } + http := initFakeHTTP() + + http.StubResponse(200, bytes.NewBufferString(` + { "data": { "repository": { + "id": "REPOID" + } } } + `)) + http.StubResponse(200, bytes.NewBufferString(` + { "data": { "createPullRequest": { "pullRequest": { + "URL": "https://github.com/OWNER/REPO/pull/12" + } } } } + `)) + + origGitCommand := git.GitCommand + git.GitCommand = test.StubExecCommand("TestPrCreateHelperProcess", "dirty") + defer func() { + git.GitCommand = origGitCommand + }() + + out := bytes.Buffer{} + prCreateCmd.SetOut(&out) + + RootCmd.SetArgs([]string{"pr", "create", "-t", "mytitle", "-b", "mybody"}) + _, err := prCreateCmd.ExecuteC() + eq(t, err, nil) + + eq(t, out.String(), `Warning: 1 uncommitted change +https://github.com/OWNER/REPO/pull/12 +`) +} diff --git a/command/pr_test.go b/command/pr_test.go index 863632ee9..47851a5e8 100644 --- a/command/pr_test.go +++ b/command/pr_test.go @@ -66,7 +66,7 @@ func TestPRList(t *testing.T) { } eq(t, out.String(), `32 New feature feature -29 Fixed bad bug bug-fix +29 Fixed bad bug hubot:bug-fix 28 Improve documentation docs `) } diff --git a/command/root.go b/command/root.go index 1e9c449b6..4fe921391 100644 --- a/command/root.go +++ b/command/root.go @@ -72,6 +72,10 @@ var apiClientForContext = func(ctx context.Context) (*api.Client, error) { opts := []api.ClientOption{ api.AddHeader("Authorization", fmt.Sprintf("token %s", token)), api.AddHeader("User-Agent", fmt.Sprintf("GitHub CLI %s", Version)), + // antiope-preview: Checks + // shadow-cat-preview: Draft pull requests + api.AddHeader("Accept", "application/vnd.github.antiope-preview+json, application/vnd.github.shadow-cat-preview"), + api.AddHeader("GraphQL-Features", "pe_mobile"), } if verbose := os.Getenv("DEBUG"); verbose != "" { opts = append(opts, api.VerboseLog(os.Stderr)) diff --git a/command/testing.go b/command/testing.go index 2e1cf9505..7758d4a8b 100644 --- a/command/testing.go +++ b/command/testing.go @@ -1,6 +1,8 @@ package command import ( + "errors" + "github.com/github/gh-cli/api" "github.com/github/gh-cli/context" ) @@ -34,3 +36,15 @@ func (s outputStub) Output() ([]byte, error) { func (s outputStub) Run() error { return nil } + +type errorStub struct { + message string +} + +func (s errorStub) Output() ([]byte, error) { + return nil, errors.New(s.message) +} + +func (s errorStub) Run() error { + return errors.New(s.message) +} diff --git a/context/blank_context.go b/context/blank_context.go index d5ad2cafe..733ef38a8 100644 --- a/context/blank_context.go +++ b/context/blank_context.go @@ -3,10 +3,12 @@ package context import ( "fmt" "strings" + + "github.com/github/gh-cli/git" ) // NewBlank initializes a blank Context suitable for testing -func NewBlank() Context { +func NewBlank() *blankContext { return &blankContext{} } @@ -16,6 +18,7 @@ type blankContext struct { authLogin string branch string baseRepo GitHubRepository + remotes Remotes } type ghRepo struct { @@ -54,14 +57,36 @@ func (c *blankContext) SetBranch(b string) { } func (c *blankContext) Remotes() (Remotes, error) { - return Remotes{}, nil + if c.remotes == nil { + return nil, fmt.Errorf("remotes were not initialized") + } + return c.remotes, nil +} + +func (c *blankContext) SetRemotes(stubs map[string]string) { + c.remotes = Remotes{} + for remoteName, repo := range stubs { + ownerWithName := strings.SplitN(repo, "/", 2) + c.remotes = append(c.remotes, &Remote{ + Remote: &git.Remote{Name: remoteName}, + Owner: ownerWithName[0], + Repo: ownerWithName[1], + }) + } } func (c *blankContext) BaseRepo() (GitHubRepository, error) { - if c.baseRepo == nil { - return nil, fmt.Errorf("base repo was not initialized") + if c.baseRepo != nil { + return c.baseRepo, nil } - return c.baseRepo, nil + remotes, err := c.Remotes() + if err != nil { + return nil, err + } + if len(remotes) < 1 { + return nil, fmt.Errorf("remotes are empty") + } + return remotes[0], nil } func (c *blankContext) SetBaseRepo(nwo string) { diff --git a/context/remote.go b/context/remote.go index 9f3b228dc..6c41eeaf1 100644 --- a/context/remote.go +++ b/context/remote.go @@ -25,6 +25,16 @@ func (r Remotes) FindByName(names ...string) (*Remote, error) { return nil, fmt.Errorf("no GitHub remotes found") } +// FindByRepo returns the first Remote that points to a specific GitHub repository +func (r Remotes) FindByRepo(owner, name string) (*Remote, error) { + for _, rem := range r { + if strings.EqualFold(rem.RepoOwner(), owner) && strings.EqualFold(rem.RepoName(), name) { + return rem, nil + } + } + return nil, fmt.Errorf("no matching remote found") +} + // Remote represents a git remote mapped to a GitHub repository type Remote struct { *git.Remote diff --git a/git/git.go b/git/git.go index a2a385f7f..8824872a4 100644 --- a/git/git.go +++ b/git/git.go @@ -33,7 +33,6 @@ func Dir() (string, error) { func WorkdirName() (string, error) { toplevelCmd := exec.Command("git", "rev-parse", "--show-toplevel") - toplevelCmd.Stderr = nil output, err := utils.PrepareCmd(toplevelCmd).Output() dir := firstLine(output) if dir == "" { @@ -42,31 +41,10 @@ func WorkdirName() (string, error) { return dir, err } -func HasFile(segments ...string) bool { - // The blessed way to resolve paths within git dir since Git 2.5.0 - pathCmd := exec.Command("git", "rev-parse", "-q", "--git-path", filepath.Join(segments...)) - if output, err := utils.PrepareCmd(pathCmd).Output(); err == nil { - if lines := outputLines(output); len(lines) == 1 { - if _, err := os.Stat(lines[0]); err == nil { - return true - } - } - } - - // Fallback for older git versions - dir, err := Dir() - if err != nil { - return false - } - - s := []string{dir} - s = append(s, segments...) - path := filepath.Join(s...) - if _, err := os.Stat(path); err == nil { - return true - } - - return false +func VerifyRef(ref string) bool { + showRef := exec.Command("git", "show-ref", "--verify", "--quiet", ref) + err := utils.PrepareCmd(showRef).Run() + return err == nil } func BranchAtRef(paths ...string) (name string, err error) { @@ -206,6 +184,34 @@ func LocalBranches() ([]string, error) { return branches, nil } +var GitCommand = func(args ...string) *exec.Cmd { + return exec.Command("git", args...) +} + +func UncommittedChangeCount() (int, error) { + statusCmd := GitCommand("status", "--porcelain") + output, err := utils.PrepareCmd(statusCmd).Output() + if err != nil { + return 0, err + } + lines := strings.Split(string(output), "\n") + + count := 0 + + for _, l := range lines { + if l != "" { + count++ + } + } + + return count, nil +} + +func Push(remote string, ref string) error { + cmd := GitCommand("push", "--set-upstream", remote, ref) + return cmd.Run() +} + func outputLines(output []byte) []string { lines := strings.TrimSuffix(string(output), "\n") return strings.Split(lines, "\n") diff --git a/git/git_test.go b/git/git_test.go new file mode 100644 index 000000000..dc1446c33 --- /dev/null +++ b/git/git_test.go @@ -0,0 +1,57 @@ +package git + +import ( + "fmt" + "os" + "strings" + "testing" + + "github.com/github/gh-cli/test" +) + +func TestGitStatusHelperProcess(*testing.T) { + if test.SkipTestHelperProcess() { + return + } + + args := test.GetTestHelperProcessArgs() + switch args[0] { + case "no changes": + case "one change": + fmt.Println(" M poem.txt") + case "untracked file": + fmt.Println(" M poem.txt") + fmt.Println("?? new.txt") + case "boom": + os.Exit(1) + } + os.Exit(0) +} + +func Test_UncommittedChangeCount(t *testing.T) { + origGitCommand := GitCommand + defer func() { + GitCommand = origGitCommand + }() + + cases := map[string]int{ + "no changes": 0, + "one change": 1, + "untracked file": 2, + } + + for k, v := range cases { + GitCommand = test.StubExecCommand("TestGitStatusHelperProcess", k) + ucc, _ := UncommittedChangeCount() + + if ucc != v { + t.Errorf("got unexpected ucc value: %d for case %s", ucc, k) + } + } + + GitCommand = test.StubExecCommand("TestGitStatusHelperProcess", "boom") + _, err := UncommittedChangeCount() + if !strings.HasSuffix(err.Error(), "git.test: exit status 1") { + t.Errorf("got unexpected error message: %s", err) + } +} diff --git a/go.mod b/go.mod index 9c89ff7b1..f4a0ea38b 100644 --- a/go.mod +++ b/go.mod @@ -3,12 +3,15 @@ module github.com/github/gh-cli go 1.13 require ( + github.com/AlecAivazis/survey/v2 v2.0.4 github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 github.com/mattn/go-colorable v0.1.2 github.com/mattn/go-isatty v0.0.9 github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b github.com/mitchellh/go-homedir v1.1.0 + github.com/pkg/errors v0.8.1 github.com/spf13/cobra v0.0.5 - golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9 + github.com/stretchr/testify v1.3.0 // indirect + golang.org/x/crypto v0.0.0-20190530122614-20be4c3c3ed5 gopkg.in/yaml.v3 v3.0.0-20191010095647-fc94e3f71652 ) diff --git a/go.sum b/go.sum index af7d46f56..b2f2e063e 100644 --- a/go.sum +++ b/go.sum @@ -1,18 +1,27 @@ +github.com/AlecAivazis/survey/v2 v2.0.4 h1:qzXnJSzXEvmUllWqMBWpZndvT2YfoAUzAMvZUax3L2M= +github.com/AlecAivazis/survey/v2 v2.0.4/go.mod h1:WYBhg6f0y/fNYUuesWQc0PKbJcEliGcYHB9sNT3Bg74= github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/Netflix/go-expect v0.0.0-20180615182759-c93bf25de8e8 h1:xzYJEypr/85nBpB11F9br+3HUrpgb+fcm5iADzXXYEw= +github.com/Netflix/go-expect v0.0.0-20180615182759-c93bf25de8e8/go.mod h1:oX5x61PbNXchhh0oikYAH+4Pcfw5LKv21+Jnpr6r6Pc= github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8= github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk= github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/hinshun/vt10x v0.0.0-20180616224451-1954e6464174 h1:WlZsjVhE8Af9IcZDGgJGQpNflI3+MJSBhsgT5PCtzBQ= +github.com/hinshun/vt10x v0.0.0-20180616224451-1954e6464174/go.mod h1:DqJ97dSdRW1W22yXSB90986pcOyQ7r45iio1KN2ez1A= github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM= github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= +github.com/kr/pty v1.1.4 h1:5Myjjh3JY/NaAi4IsUbHADytDyl1VE1Y9PXDlL+P/VQ= +github.com/kr/pty v1.1.4/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= github.com/mattn/go-colorable v0.1.2 h1:/bC9yWikZXAL9uJdulbSfyVNIR3n3trXl+v8+1sx8mU= github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= @@ -25,6 +34,8 @@ github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= +github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g= @@ -36,13 +47,24 @@ github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb6 github.com/spf13/pflag v1.0.3 h1:zPAT6CGy6wXeQ7NtTnaTerfKOsV6V6F8agHXFiazDkg= github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.2.1/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0= github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q= golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9 h1:mKdxBk7AujPs8kU4m80U72y/zjbZ3UcXC7dClwKbUI0= golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190530122614-20be4c3c3ed5 h1:8dUaAV7K4uHsF56JQWkprecIQKdPHtR9jCHF5nB8uzc= +golang.org/x/crypto v0.0.0-20190530122614-20be4c3c3ed5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190530182044-ad28b68e88f1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a h1:aYOabOQFp6Vj6W1F80affTUvO9UxmJRx8K0gsfABByQ= golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= diff --git a/test/fixtures/createPr.json b/test/fixtures/createPr.json new file mode 100644 index 000000000..47be76948 --- /dev/null +++ b/test/fixtures/createPr.json @@ -0,0 +1,9 @@ +{ + "data": { + "createPullRequest": { + "pullRequest": { + "url": "https://github.com/vilmibm/testing/pull/14" + } + } + } +} diff --git a/test/fixtures/prList.json b/test/fixtures/prList.json index 7ff9502a0..2808a5a8e 100644 --- a/test/fixtures/prList.json +++ b/test/fixtures/prList.json @@ -16,7 +16,11 @@ "number": 29, "title": "Fixed bad bug", "url": "https://github.com/monalisa/hello/pull/29", - "headRefName": "bug-fix" + "headRefName": "bug-fix", + "isCrossRepository": true, + "headRepositoryOwner": { + "login": "hubot" + } } }, { diff --git a/test/fixtures/repoId.json b/test/fixtures/repoId.json new file mode 100644 index 000000000..965b39f3d --- /dev/null +++ b/test/fixtures/repoId.json @@ -0,0 +1,7 @@ +{ + "data": { + "repository": { + "id": "a repo id" + } + } +} diff --git a/test/helpers.go b/test/helpers.go index 162befa56..dca930be5 100644 --- a/test/helpers.go +++ b/test/helpers.go @@ -11,6 +11,38 @@ import ( "github.com/spf13/cobra" ) +func GetTestHelperProcessArgs() []string { + args := os.Args + for len(args) > 0 { + if args[0] == "--" { + args = args[1:] + break + } + args = args[1:] + } + return args +} + +func SkipTestHelperProcess() bool { + return os.Getenv("GO_WANT_HELPER_PROCESS") != "1" +} + +func StubExecCommand(testHelper string, desiredOutput string) func(...string) *exec.Cmd { + return func(args ...string) *exec.Cmd { + cs := []string{ + fmt.Sprintf("-test.run=%s", testHelper), + "--", desiredOutput} + cs = append(cs, args...) + env := []string{ + "GO_WANT_HELPER_PROCESS=1", + } + + cmd := exec.Command(os.Args[0], cs...) + cmd.Env = append(env, os.Environ()...) + return cmd + } +} + type TempGitRepo struct { Remote string TearDown func() diff --git a/utils/color.go b/utils/color.go index 6fe6f2fc5..fb8479734 100644 --- a/utils/color.go +++ b/utils/color.go @@ -1,24 +1,39 @@ package utils -import "github.com/mgutz/ansi" +import ( + "github.com/mattn/go-isatty" + "github.com/mgutz/ansi" + "os" +) -var Black = ansi.ColorFunc("black") -var White = ansi.ColorFunc("white") +func makeColorFunc(color string) func(string) string { + return func(arg string) string { + output := arg + if isatty.IsTerminal(os.Stdout.Fd()) { + output = ansi.Color(color+arg+ansi.Reset, "") + } -func Gray(arg string) string { - return ansi.Color(ansi.LightBlack+arg, "") + return output + } } -var Red = ansi.ColorFunc("red") -var Green = ansi.ColorFunc("green") -var Yellow = ansi.ColorFunc("yellow") -var Blue = ansi.ColorFunc("blue") -var Magenta = ansi.ColorFunc("magenta") -var Cyan = ansi.ColorFunc("cyan") +var Black = makeColorFunc(ansi.Black) +var White = makeColorFunc(ansi.White) +var Magenta = makeColorFunc(ansi.Magenta) +var Cyan = makeColorFunc(ansi.Cyan) +var Red = makeColorFunc(ansi.Red) +var Yellow = makeColorFunc(ansi.Yellow) +var Blue = makeColorFunc(ansi.Blue) +var Green = makeColorFunc(ansi.Green) +var Gray = makeColorFunc(ansi.LightBlack) func Bold(arg string) string { - // This is really annoying. If you just define Bold as ColorFunc("+b") it will properly bold but - // will not use the default color, resulting in black and probably unreadable text. This forces - // the default color before bolding. - return ansi.Color(ansi.DefaultFG+arg, "+b") + output := arg + if isatty.IsTerminal(os.Stdout.Fd()) { + // This is really annoying. If you just define Bold as ColorFunc("+b") it will properly bold but + // will not use the default color, resulting in black and probably unreadable text. This forces + // the default color before bolding. + output = ansi.Color(ansi.DefaultFG+arg+ansi.Reset, "+b") + } + return output } diff --git a/utils/prepare_cmd.go b/utils/prepare_cmd.go index 0129d1a5b..6b354cb80 100644 --- a/utils/prepare_cmd.go +++ b/utils/prepare_cmd.go @@ -38,6 +38,9 @@ func (c cmdWithStderr) Output() ([]byte, error) { if os.Getenv("DEBUG") != "" { fmt.Fprintf(os.Stderr, "%v\n", c.Cmd.Args) } + if c.Cmd.Stderr != nil { + return c.Cmd.Output() + } errStream := &bytes.Buffer{} c.Cmd.Stderr = errStream out, err := c.Cmd.Output() @@ -51,6 +54,9 @@ func (c cmdWithStderr) Run() error { if os.Getenv("DEBUG") != "" { fmt.Fprintf(os.Stderr, "%v\n", c.Cmd.Args) } + if c.Cmd.Stderr != nil { + return c.Cmd.Run() + } errStream := &bytes.Buffer{} c.Cmd.Stderr = errStream err := c.Cmd.Run()