Merge remote-tracking branch 'origin/master' into table-output

This commit is contained in:
Mislav Marohnić 2019-11-20 12:12:09 +01:00
commit 02b1f60a24
15 changed files with 1031 additions and 241 deletions

View file

@ -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

39
api/pull_request_test.go Normal file
View file

@ -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)
}

View file

@ -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
}

View file

@ -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 {

View file

@ -19,7 +19,7 @@ func init() {
&cobra.Command{
Use: "status",
Short: "Show status of relevant issues",
RunE: issueList,
RunE: issueStatus,
},
&cobra.Command{
Use: "view <issue-number>",
@ -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)
}
}

View file

@ -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()

View file

@ -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 <pr-number>",
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"))
}

364
command/pr_checkout_test.go Normal file
View file

@ -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")
}

View file

@ -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,

View file

@ -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))

View file

@ -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)
}

View file

@ -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

View file

@ -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) {

View file

@ -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 }
}
}
}

View file

@ -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()