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/pull_request_test.go b/api/pull_request_test.go new file mode 100644 index 000000000..82386108d --- /dev/null +++ b/api/pull_request_test.go @@ -0,0 +1,39 @@ +package api + +import ( + "encoding/json" + "testing" +) + +func TestPullRequest_ChecksStatus(t *testing.T) { + pr := PullRequest{} + payload := ` + { "commits": { "nodes": [{ "commit": { + "statusCheckRollup": { + "contexts": { + "nodes": [ + { "state": "SUCCESS" }, + { "state": "PENDING" }, + { "state": "FAILURE" }, + { "status": "IN_PROGRESS", + "conclusion": null }, + { "status": "COMPLETED", + "conclusion": "SUCCESS" }, + { "status": "COMPLETED", + "conclusion": "FAILURE" }, + { "status": "COMPLETED", + "conclusion": "ACTION_REQUIRED" } + ] + } + } + } }] } } + ` + err := json.Unmarshal([]byte(payload), &pr) + eq(t, err, nil) + + checks := pr.ChecksStatus() + eq(t, checks.Total, 7) + eq(t, checks.Pending, 2) + eq(t, checks.Failing, 3) + eq(t, checks.Passing, 2) +} diff --git a/api/queries_issue.go b/api/queries_issue.go index 56a235c89..31793e608 100644 --- a/api/queries_issue.go +++ b/api/queries_issue.go @@ -1,5 +1,59 @@ package api +import ( + "fmt" + "time" +) + +type IssuesPayload struct { + Assigned []Issue + Mentioned []Issue + Recent []Issue +} + +type Issue struct { + Number int + Title string + URL string + Labels []string + TotalLabelCount int +} + +type apiIssues struct { + Issues struct { + Edges []struct { + Node struct { + Number int + Title string + URL string + Labels struct { + Edges []struct { + Node struct { + Name string + } + } + TotalCount int + } + } + } + } +} + +const fragments = ` + fragment issue on Issue { + number + title + labels(first: 3) { + edges { + node { + name + } + } + totalCount + } + } +` + func IssueCreate(client *Client, ghRepo Repo, params map[string]interface{}) (*Issue, error) { repoID, err := GitHubRepoId(client, ghRepo) if err != nil { @@ -38,3 +92,156 @@ func IssueCreate(client *Client, ghRepo Repo, params map[string]interface{}) (*I return &result.CreateIssue.Issue, nil } + +func IssueStatus(client *Client, ghRepo Repo, currentUsername string) (*IssuesPayload, error) { + type response struct { + Assigned apiIssues + Mentioned apiIssues + Recent apiIssues + } + + query := fragments + ` + query($owner: String!, $repo: String!, $since: DateTime!, $viewer: String!, $per_page: Int = 10) { + assigned: repository(owner: $owner, name: $repo) { + issues(filterBy: {assignee: $viewer, states: OPEN}, first: $per_page, orderBy: {field: CREATED_AT, direction: DESC}) { + edges { + node { + ...issue + } + } + } + } + mentioned: repository(owner: $owner, name: $repo) { + issues(filterBy: {mentioned: $viewer, states: OPEN}, first: $per_page, orderBy: {field: CREATED_AT, direction: DESC}) { + edges { + node { + ...issue + } + } + } + } + recent: repository(owner: $owner, name: $repo) { + issues(filterBy: {since: $since, states: OPEN}, first: $per_page, orderBy: {field: CREATED_AT, direction: DESC}) { + edges { + node { + ...issue + } + } + } + } + } + ` + + owner := ghRepo.RepoOwner() + repo := ghRepo.RepoName() + since := time.Now().UTC().Add(time.Hour * -24).Format("2006-01-02T15:04:05-0700") + variables := map[string]interface{}{ + "owner": owner, + "repo": repo, + "viewer": currentUsername, + "since": since, + } + + var resp response + err := client.GraphQL(query, variables, &resp) + if err != nil { + return nil, err + } + + assigned := convertAPIToIssues(resp.Assigned) + mentioned := convertAPIToIssues(resp.Mentioned) + recent := convertAPIToIssues(resp.Recent) + + payload := IssuesPayload{ + assigned, + mentioned, + recent, + } + + return &payload, nil +} + +func IssueList(client *Client, ghRepo Repo, state string, labels []string, assigneeString string, limit int) ([]Issue, error) { + var states []string + switch state { + case "open", "": + states = []string{"OPEN"} + case "closed": + states = []string{"CLOSED"} + case "all": + states = []string{"OPEN", "CLOSED"} + default: + return nil, fmt.Errorf("invalid state: %s", state) + } + + // If you don't want to filter by lables, graphql requires you need + // to send nil instead of an empty array. + if len(labels) == 0 { + labels = nil + } + + var assignee interface{} + if len(assigneeString) > 0 { + assignee = assigneeString + } else { + assignee = nil + } + + query := fragments + ` + query($owner: String!, $repo: String!, $limit: Int, $states: [IssueState!] = OPEN, $labels: [String!], $assignee: String) { + repository(owner: $owner, name: $repo) { + issues(first: $limit, orderBy: {field: CREATED_AT, direction: DESC}, states: $states, labels: $labels, filterBy: {assignee: $assignee}) { + edges { + node { + ...issue + } + } + } + } + } + ` + + owner := ghRepo.RepoOwner() + repo := ghRepo.RepoName() + variables := map[string]interface{}{ + "limit": limit, + "owner": owner, + "repo": repo, + "states": states, + "labels": labels, + "assignee": assignee, + } + + var resp struct { + Repository apiIssues + } + + err := client.GraphQL(query, variables, &resp) + if err != nil { + return nil, err + } + + issues := convertAPIToIssues(resp.Repository) + return issues, nil +} + +func convertAPIToIssues(i apiIssues) []Issue { + var issues []Issue + for _, edge := range i.Issues.Edges { + var labels []string + for _, labelEdge := range edge.Node.Labels.Edges { + labels = append(labels, labelEdge.Node.Name) + } + + issue := Issue{ + Number: edge.Node.Number, + Title: edge.Node.Title, + URL: edge.Node.URL, + Labels: labels, + TotalLabelCount: edge.Node.Labels.TotalCount, + } + issues = append(issues, issue) + } + + return issues +} diff --git a/api/queries.go b/api/queries_pr.go similarity index 67% rename from api/queries.go rename to api/queries_pr.go index 4f1f74ed5..2be64e46c 100644 --- a/api/queries.go +++ b/api/queries_pr.go @@ -2,7 +2,6 @@ package api import ( "fmt" - "time" ) type PullRequestsPayload struct { @@ -18,34 +17,29 @@ type PullRequest struct { URL string HeadRefName string - IsCrossRepository bool HeadRepositoryOwner struct { Login string } - - Reviews struct { - Nodes []struct { - State string - Author struct { - Login string - } + HeadRepository struct { + Name string + DefaultBranchRef struct { + Name string } } + IsCrossRepository bool + MaintainerCanModify bool + + ReviewDecision string Commits struct { Nodes []struct { Commit struct { - Status struct { - Contexts []struct { - State string - } - } - CheckSuites struct { - Nodes []struct { - CheckRuns struct { - Nodes []struct { - Conclusion string - } + StatusCheckRollup struct { + Contexts struct { + Nodes []struct { + State string + Status string + Conclusion string } } } @@ -64,23 +58,18 @@ func (pr PullRequest) HeadLabel() string { type PullRequestReviewStatus struct { ChangesRequested bool Approved bool + ReviewRequired bool } func (pr *PullRequest) ReviewStatus() PullRequestReviewStatus { status := PullRequestReviewStatus{} - reviewMap := map[string]string{} - // Reviews will include every review on record, including consecutive ones - // from the same actor. Consolidate them into latest state per reviewer. - for _, review := range pr.Reviews.Nodes { - reviewMap[review.Author.Login] = review.State - } - for _, state := range reviewMap { - switch state { - case "CHANGES_REQUESTED": - status.ChangesRequested = true - case "APPROVED": - status.Approved = true - } + switch pr.ReviewDecision { + case "CHANGES_REQUESTED": + status.ChangesRequested = true + case "APPROVED": + status.Approved = true + case "REVIEW_REQUIRED": + status.ReviewRequired = true } return status } @@ -97,32 +86,28 @@ func (pr *PullRequest) ChecksStatus() (summary PullRequestChecksStatus) { return } commit := pr.Commits.Nodes[0].Commit - for _, status := range commit.Status.Contexts { - switch status.State { - case "SUCCESS": + for _, c := range commit.StatusCheckRollup.Contexts.Nodes { + state := c.State // StatusContext + if state == "" { + // CheckRun + if c.Status == "COMPLETED" { + state = c.Conclusion + } else { + state = c.Status + } + } + switch state { + case "SUCCESS", "NEUTRAL", "SKIPPED": summary.Passing++ - case "EXPECTED", "ERROR", "FAILURE": + case "ERROR", "FAILURE", "CANCELLED", "TIMED_OUT", "ACTION_REQUIRED": summary.Failing++ - case "PENDING": + case "EXPECTED", "REQUESTED", "QUEUED", "PENDING", "IN_PROGRESS": summary.Pending++ default: - panic(fmt.Errorf("unsupported status: %q", status.State)) + panic(fmt.Errorf("unsupported status: %q", state)) } summary.Total++ } - for _, checkSuite := range commit.CheckSuites.Nodes { - for _, checkRun := range checkSuite.CheckRuns.Nodes { - switch checkRun.Conclusion { - case "SUCCESS", "NEUTRAL": - summary.Passing++ - case "FAILURE", "CANCELLED", "TIMED_OUT", "ACTION_REQUIRED": - summary.Failing++ - default: - panic(fmt.Errorf("unsupported check conclusion: %q", checkRun.Conclusion)) - } - summary.Total++ - } - } return } @@ -131,109 +116,6 @@ type Repo interface { RepoOwner() string } -type IssuesPayload struct { - Assigned []Issue - Mentioned []Issue - Recent []Issue -} - -type Issue struct { - Number int - Title string - URL string -} - -func Issues(client *Client, ghRepo Repo, currentUsername string) (*IssuesPayload, error) { - type issues struct { - Issues struct { - Edges []struct { - Node Issue - } - } - } - - type response struct { - Assigned issues - Mentioned issues - Recent issues - } - - query := ` - fragment issue on Issue { - number - title - } - 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}) { - edges { - node { - ...issue - } - } - } - } - mentioned: repository(owner: $owner, name: $repo) { - issues(filterBy: {mentioned: $viewer}, first: $per_page, orderBy: {field: CREATED_AT, direction: DESC}) { - edges { - node { - ...issue - } - } - } - } - recent: repository(owner: $owner, name: $repo) { - issues(filterBy: {since: $since}, first: $per_page, orderBy: {field: CREATED_AT, direction: DESC}) { - edges { - node { - ...issue - } - } - } - } - } - ` - - owner := ghRepo.RepoOwner() - repo := ghRepo.RepoName() - since := time.Now().UTC().Add(time.Hour * -24).Format("2006-01-02T15:04:05-0700") - variables := map[string]interface{}{ - "owner": owner, - "repo": repo, - "viewer": currentUsername, - "since": since, - } - - var resp response - err := client.GraphQL(query, variables, &resp) - if err != nil { - return nil, err - } - - var assigned []Issue - for _, edge := range resp.Assigned.Issues.Edges { - assigned = append(assigned, edge.Node) - } - - var mentioned []Issue - for _, edge := range resp.Mentioned.Issues.Edges { - mentioned = append(mentioned, edge.Node) - } - - var recent []Issue - for _, edge := range resp.Recent.Issues.Edges { - recent = append(recent, edge.Node) - } - - payload := IssuesPayload{ - assigned, - mentioned, - recent, - } - - return &payload, nil -} - func PullRequests(client *Client, ghRepo Repo, currentBranch, currentUsername string) (*PullRequestsPayload, error) { type edges struct { Edges []struct { @@ -267,15 +149,14 @@ func PullRequests(client *Client, ghRepo Repo, currentBranch, currentUsername st commits(last: 1) { nodes { commit { - status { - contexts { - state - } - } - checkSuites(first: 50) { - nodes { - checkRuns(first: 50) { - nodes { + statusCheckRollup { + contexts(last: 100) { + nodes { + ...on StatusContext { + state + } + ...on CheckRun { + status conclusion } } @@ -287,16 +168,8 @@ func PullRequests(client *Client, ghRepo Repo, currentBranch, currentUsername st } fragment prWithReviews on PullRequest { ...pr - reviews(last: 20) { - nodes { - state - author { - login - } - } - } + 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) { @@ -374,6 +247,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 { diff --git a/command/issue.go b/command/issue.go index 0ecffc90b..6c4b0597e 100644 --- a/command/issue.go +++ b/command/issue.go @@ -19,7 +19,7 @@ func init() { &cobra.Command{ Use: "status", Short: "Show status of relevant issues", - RunE: issueList, + RunE: issueStatus, }, &cobra.Command{ Use: "view ", @@ -31,12 +31,23 @@ func init() { issueCmd.AddCommand(issueCreateCmd) issueCreateCmd.Flags().StringArrayP("message", "m", nil, "set title and body") issueCreateCmd.Flags().BoolP("web", "w", false, "open the web browser to create an issue") + + issueListCmd := &cobra.Command{ + Use: "list", + Short: "List and filter issues in this repository", + RunE: issueList, + } + issueListCmd.Flags().StringP("assignee", "a", "", "filter by assignee") + issueListCmd.Flags().StringSliceP("label", "l", nil, "filter by label") + issueListCmd.Flags().StringP("state", "s", "", "filter by state (open|closed|all)") + issueListCmd.Flags().IntP("limit", "L", 30, "maximum number of issues to fetch") + issueCmd.AddCommand((issueListCmd)) } var issueCmd = &cobra.Command{ Use: "issue", - Short: "Work with GitHub issues", - Long: `Helps you work with issues.`, + Short: "Work with issues", + Long: `Work with GitHub issues`, } var issueCreateCmd = &cobra.Command{ Use: "create", @@ -56,19 +67,65 @@ func issueList(cmd *cobra.Command, args []string) error { return err } + state, err := cmd.Flags().GetString("state") + if err != nil { + return err + } + + labels, err := cmd.Flags().GetStringSlice("label") + if err != nil { + return err + } + + assignee, err := cmd.Flags().GetString("assignee") + if err != nil { + return err + } + + limit, err := cmd.Flags().GetInt("limit") + if err != nil { + return err + } + + issues, err := api.IssueList(apiClient, baseRepo, state, labels, assignee, limit) + if err != nil { + return err + } + + if len(issues) > 0 { + printIssues("", issues...) + } else { + message := fmt.Sprintf("There are no open issues") + printMessage(message) + } + return nil +} + +func issueStatus(cmd *cobra.Command, args []string) error { + ctx := contextForCommand(cmd) + apiClient, err := apiClientForContext(ctx) + if err != nil { + return err + } + + baseRepo, err := ctx.BaseRepo() + if err != nil { + return err + } + currentUser, err := ctx.AuthLogin() if err != nil { return err } - issuePayload, err := api.Issues(apiClient, baseRepo, currentUser) + issuePayload, err := api.IssueStatus(apiClient, baseRepo, currentUser) if err != nil { return err } printHeader("Issues assigned to you") if issuePayload.Assigned != nil { - printIssues(issuePayload.Assigned...) + printIssues(" ", issuePayload.Assigned...) } else { message := fmt.Sprintf(" There are no issues assgined to you") printMessage(message) @@ -77,7 +134,7 @@ func issueList(cmd *cobra.Command, args []string) error { printHeader("Issues mentioning you") if len(issuePayload.Mentioned) > 0 { - printIssues(issuePayload.Mentioned...) + printIssues(" ", issuePayload.Mentioned...) } else { printMessage(" There are no issues mentioning you") } @@ -85,7 +142,7 @@ func issueList(cmd *cobra.Command, args []string) error { printHeader("Recent issues") if len(issuePayload.Recent) > 0 { - printIssues(issuePayload.Recent...) + printIssues(" ", issuePayload.Recent...) } else { printMessage(" There are no recent issues") } @@ -185,8 +242,17 @@ func issueCreate(cmd *cobra.Command, args []string) error { return nil } -func printIssues(issues ...api.Issue) { +func printIssues(prefix string, issues ...api.Issue) { for _, issue := range issues { - fmt.Printf(" #%d %s\n", issue.Number, truncate(70, issue.Title)) + number := utils.Green("#" + strconv.Itoa(issue.Number)) + var coloredLabels string + if len(issue.Labels) > 0 { + var ellipse string + if issue.TotalLabelCount > len(issue.Labels) { + ellipse = "…" + } + coloredLabels = utils.Gray(fmt.Sprintf(" (%s%s)", strings.Join(issue.Labels, ", "), ellipse)) + } + fmt.Printf("%s%s %s %s\n", prefix, number, truncate(70, issue.Title), coloredLabels) } } diff --git a/command/issue_test.go b/command/issue_test.go index 29d494663..09a55609f 100644 --- a/command/issue_test.go +++ b/command/issue_test.go @@ -35,11 +35,64 @@ func TestIssueStatus(t *testing.T) { for _, r := range expectedIssues { if !r.MatchString(output) { - t.Errorf("output did not match regexp /%s/", r) + t.Errorf("output did not match regexp /%s/\n> output\n%s\n", r, output) + return } } } +func TestIssueList(t *testing.T) { + initBlankContext("OWNER/REPO", "master") + http := initFakeHTTP() + + jsonFile, _ := os.Open("../test/fixtures/issueList.json") + defer jsonFile.Close() + http.StubResponse(200, jsonFile) + + output, err := test.RunCommand(RootCmd, "issue list") + if err != nil { + t.Errorf("error running command `issue list`: %v", err) + } + + expectedIssues := []*regexp.Regexp{ + regexp.MustCompile(`#1.*won`), + regexp.MustCompile(`#2.*too`), + regexp.MustCompile(`#4.*fore`), + } + + for _, r := range expectedIssues { + if !r.MatchString(output) { + t.Errorf("output did not match regexp /%s/\n> output\n%s\n", r, output) + return + } + } +} + +func TestIssueList_withFlags(t *testing.T) { + http := initFakeHTTP() + + http.StubResponse(200, bytes.NewBufferString(`{"data": {}}`)) // Since we are testing that the flags are passed, we don't care about the response + + _, err := test.RunCommand(RootCmd, "issue list -a probablyCher -l web,bug -s open") + if err != nil { + t.Errorf("error running command `issue list`: %v", err) + } + + bodyBytes, _ := ioutil.ReadAll(http.Requests[0].Body) + reqBody := struct { + Variables struct { + Assignee string + Labels []string + States []string + } + }{} + json.Unmarshal(bodyBytes, &reqBody) + + eq(t, reqBody.Variables.Assignee, "probablyCher") + eq(t, reqBody.Variables.Labels, []string{"web", "bug"}) + eq(t, reqBody.Variables.States, []string{"OPEN"}) +} + func TestIssueView(t *testing.T) { initBlankContext("OWNER/REPO", "master") http := initFakeHTTP() diff --git a/command/pr.go b/command/pr.go index b7cca14a1..7135bbc55 100644 --- a/command/pr.go +++ b/command/pr.go @@ -2,15 +2,19 @@ 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" ) func init() { RootCmd.AddCommand(prCmd) + prCmd.AddCommand(prCheckoutCmd) prCmd.AddCommand(prCreateCmd) prCmd.AddCommand(prListCmd) prCmd.AddCommand(prStatusCmd) @@ -25,11 +29,17 @@ func init() { var prCmd = &cobra.Command{ Use: "pr", Short: "Work with pull requests", - Long: `Helps you work with pull requests.`, + Long: `Work with GitHub 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", + Short: "List and filter pull requests in this repository", RunE: prList, } var prStatusCmd = &cobra.Command{ @@ -222,6 +232,100 @@ 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 { prNumber := fmt.Sprintf("#%d", pr.Number) @@ -250,6 +354,8 @@ func printPrs(prs ...api.PullRequest) { 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")) } 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 index c8d86f53a..eebd7258d 100644 --- a/command/pr_create.go +++ b/command/pr_create.go @@ -68,14 +68,14 @@ func prCreate(cmd *cobra.Command, _ []string) error { titleQuestion := &survey.Question{ Name: "title", Prompt: &survey.Input{ - Message: "PR Title", + Message: "Pull request title", Default: inProgress.Title, }, } bodyQuestion := &survey.Question{ Name: "body", Prompt: &survey.Editor{ - Message: fmt.Sprintf("PR Body (%s)", editor), + Message: fmt.Sprintf("Pull request body (%s)", editor), FileName: "*.md", Default: inProgress.Body, AppendDefault: true, diff --git a/command/root.go b/command/root.go index 22c0122a1..f2a73430d 100644 --- a/command/root.go +++ b/command/root.go @@ -37,7 +37,7 @@ type FlagError struct { var RootCmd = &cobra.Command{ Use: "gh", Short: "GitHub CLI", - Long: `Do things with GitHub from your terminal`, + Long: `Work with GitHub from your terminal`, SilenceErrors: true, SilenceUsage: true, @@ -75,6 +75,7 @@ var apiClientForContext = func(ctx context.Context) (*api.Client, error) { // 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/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 411863b31..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) { diff --git a/test/fixtures/issueList.json b/test/fixtures/issueList.json index 784c0cc8f..17e92c743 100644 --- a/test/fixtures/issueList.json +++ b/test/fixtures/issueList.json @@ -1,47 +1,61 @@ { "data": { - "assigned": { + "repository": { "issues": { "edges": [ { "node": { - "number": 9, - "title": "corey thinks squash tastes bad" + "number": 1, + "title": "number won", + "url": "https://wow.com", + "labels": { + "edges": [ + { + "node": { + "name": "label" + } + } + ], + "totalCount": 1 + } } }, { "node": { - "number": 10, - "title": "broccoli is a superfood" - } - } - ] - } - }, - "mentioned": { - "issues": { - "edges": [ - { - "node": { - "number": 8, - "title": "rabbits eat carrots" + "number": 2, + "title": "number too", + "url": "https://wow.com", + "labels": { + "edges": [ + { + "node": { + "name": "label" + } + } + ], + "totalCount": 1 + } } }, { "node": { - "number": 11, - "title": "swiss chard is neutral" + "number": 4, + "title": "number fore", + "url": "https://wow.com", + "labels": { + "edges": [ + { + "node": { + "name": "label" + } + } + ], + "totalCount": 1 + } } } ] } - }, - "recent": { - "issues": { - "edges": [] - } - }, - - "pageInfo": { "hasNextPage": false } + } } } 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()