diff --git a/.github/workflows/releases.yml b/.github/workflows/releases.yml index f241dc1d9..e94b6a26c 100644 --- a/.github/workflows/releases.yml +++ b/.github/workflows/releases.yml @@ -26,6 +26,18 @@ jobs: args: release --release-notes=CHANGELOG.md env: GITHUB_TOKEN: ${{secrets.UPLOAD_GITHUB_TOKEN}} + - name: move cards + if: "!contains(github.ref, '-')" + env: + GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} + PENDING_COLUMN: 8189733 + DONE_COLUMN: 7110130 + shell: bash + run: | + curl -fsSL https://github.com/github/hub/raw/master/script/get | bash -s 2.14.1 + api() { bin/hub api -H 'accept: application/vnd.github.inertia-preview+json' "$@"; } + cards=$(api projects/columns/$PENDING_COLUMN/cards | jq ".[].id") + for card in $cards; do api projects/columns/cards/$card/moves --field position=top --field column_id=$DONE_COLUMN; done msi: needs: goreleaser runs-on: windows-latest diff --git a/Makefile b/Makefile index 3b8aba5ea..5a93dac9b 100644 --- a/Makefile +++ b/Makefile @@ -23,7 +23,7 @@ test: .PHONY: test site: - git worktree add site gh-pages + git clone https://github.com/github/cli.github.com.git "$@" site-docs: site git -C site pull @@ -33,5 +33,4 @@ site-docs: site rm -f site/manual/*.bak git -C site add 'manual/gh*.md' git -C site commit -m 'update help docs' - git -C site push .PHONY: site-docs diff --git a/README.md b/README.md index c98b207d0..d731a3cb6 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ And if you spot bugs or have features that you'd really like to see in `gh`, ple - `gh pr [status, list, view, checkout, create]` - `gh issue [status, list, view, create]` +- `gh repo [view, create, clone, fork]` - `gh help` Check out the [docs][] for more information. diff --git a/api/fake_http.go b/api/fake_http.go index 32b7d56eb..773567aaf 100644 --- a/api/fake_http.go +++ b/api/fake_http.go @@ -6,6 +6,9 @@ import ( "io" "io/ioutil" "net/http" + "os" + "path" + "strings" ) // FakeHTTP provides a mechanism by which to stub HTTP responses through @@ -37,6 +40,13 @@ func (f *FakeHTTP) RoundTrip(req *http.Request) (*http.Response, error) { return resp, nil } +func (f *FakeHTTP) StubWithFixture(status int, fixtureFileName string) func() { + fixturePath := path.Join("../test/fixtures/", fixtureFileName) + fixtureFile, _ := os.Open(fixturePath) + f.StubResponse(status, fixtureFile) + return func() { fixtureFile.Close() } +} + func (f *FakeHTTP) StubRepoResponse(owner, repo string) { body := bytes.NewBufferString(fmt.Sprintf(` { "data": { "repo_000": { @@ -56,3 +66,35 @@ func (f *FakeHTTP) StubRepoResponse(owner, repo string) { } f.responseStubs = append(f.responseStubs, resp) } + +func (f *FakeHTTP) StubForkedRepoResponse(forkFullName, parentFullName string) { + forkRepo := strings.SplitN(forkFullName, "/", 2) + parentRepo := strings.SplitN(parentFullName, "/", 2) + body := bytes.NewBufferString(fmt.Sprintf(` + { "data": { "repo_000": { + "id": "REPOID2", + "name": "%s", + "owner": {"login": "%s"}, + "defaultBranchRef": { + "name": "master", + "target": {"oid": "deadbeef"} + }, + "viewerPermission": "ADMIN", + "parent": { + "id": "REPOID1", + "name": "%s", + "owner": {"login": "%s"}, + "defaultBranchRef": { + "name": "master", + "target": {"oid": "deadbeef"} + }, + "viewerPermission": "READ" + } + } } } + `, forkRepo[1], forkRepo[0], parentRepo[1], parentRepo[0])) + resp := &http.Response{ + StatusCode: 200, + Body: ioutil.NopCloser(body), + } + f.responseStubs = append(f.responseStubs, resp) +} diff --git a/api/queries_org.go b/api/queries_org.go new file mode 100644 index 000000000..21d1f528c --- /dev/null +++ b/api/queries_org.go @@ -0,0 +1,24 @@ +package api + +import "fmt" + +// using API v3 here because the equivalent in GraphQL needs `read:org` scope +func resolveOrganization(client *Client, orgName string) (string, error) { + var response struct { + NodeID string `json:"node_id"` + } + err := client.REST("GET", fmt.Sprintf("users/%s", orgName), nil, &response) + return response.NodeID, err +} + +// using API v3 here because the equivalent in GraphQL needs `read:org` scope +func resolveOrganizationTeam(client *Client, orgName, teamSlug string) (string, string, error) { + var response struct { + NodeID string `json:"node_id"` + Organization struct { + NodeID string `json:"node_id"` + } + } + err := client.REST("GET", fmt.Sprintf("orgs/%s/teams/%s", orgName, teamSlug), nil, &response) + return response.Organization.NodeID, response.NodeID, err +} diff --git a/api/queries_repo.go b/api/queries_repo.go index dbe4176a3..1c5bfe826 100644 --- a/api/queries_repo.go +++ b/api/queries_repo.go @@ -6,15 +6,19 @@ import ( "fmt" "sort" "strings" + "time" "github.com/cli/cli/internal/ghrepo" ) // Repository contains information about a GitHub repo type Repository struct { - ID string - Name string - Owner RepositoryOwner + ID string + Name string + URL string + CloneURL string + CreatedAt time.Time + Owner RepositoryOwner IsPrivate bool HasIssuesEnabled bool @@ -59,7 +63,6 @@ func (r Repository) ViewerCanPush() bool { } } -// GitHubRepo looks up the node ID of a named repository func GitHubRepo(client *Client, repo ghrepo.Interface) (*Repository, error) { query := ` query($owner: String!, $name: String!) { @@ -78,12 +81,8 @@ func GitHubRepo(client *Client, repo ghrepo.Interface) (*Repository, error) { }{} err := client.GraphQL(query, variables, &result) - if err != nil || result.Repository.ID == "" { - newErr := fmt.Errorf("failed to determine repository ID for '%s'", ghrepo.FullName(repo)) - if err != nil { - newErr = fmt.Errorf("%s: %w", newErr, err) - } - return nil, newErr + if err != nil { + return nil, err } return &result.Repository, nil @@ -190,9 +189,11 @@ func RepoNetwork(client *Client, repos []ghrepo.Interface) (RepoNetworkResult, e // repositoryV3 is the repository result from GitHub API v3 type repositoryV3 struct { - NodeID string - Name string - Owner struct { + NodeID string + Name string + CreatedAt time.Time `json:"created_at"` + CloneURL string `json:"clone_url"` + Owner struct { Login string } } @@ -208,11 +209,73 @@ func ForkRepo(client *Client, repo ghrepo.Interface) (*Repository, error) { } return &Repository{ - ID: result.NodeID, - Name: result.Name, + ID: result.NodeID, + Name: result.Name, + CloneURL: result.CloneURL, + CreatedAt: result.CreatedAt, Owner: RepositoryOwner{ Login: result.Owner.Login, }, ViewerPermission: "WRITE", }, nil } + +// RepoCreateInput represents input parameters for RepoCreate +type RepoCreateInput struct { + Name string `json:"name"` + Visibility string `json:"visibility"` + Homepage string `json:"homepage,omitempty"` + Description string `json:"description,omitempty"` + + OwnerID string `json:"ownerId,omitempty"` + TeamID string `json:"teamId,omitempty"` + + HasIssuesEnabled bool `json:"hasIssuesEnabled"` + HasWikiEnabled bool `json:"hasWikiEnabled"` +} + +// RepoCreate creates a new GitHub repository +func RepoCreate(client *Client, input RepoCreateInput) (*Repository, error) { + var response struct { + CreateRepository struct { + Repository Repository + } + } + + if input.TeamID != "" { + orgID, teamID, err := resolveOrganizationTeam(client, input.OwnerID, input.TeamID) + if err != nil { + return nil, err + } + input.TeamID = teamID + input.OwnerID = orgID + } else if input.OwnerID != "" { + orgID, err := resolveOrganization(client, input.OwnerID) + if err != nil { + return nil, err + } + input.OwnerID = orgID + } + + variables := map[string]interface{}{ + "input": input, + } + + err := client.GraphQL(` + mutation($input: CreateRepositoryInput!) { + createRepository(input: $input) { + repository { + id + name + owner { login } + url + } + } + } + `, variables, &response) + if err != nil { + return nil, err + } + + return &response.CreateRepository.Repository, nil +} diff --git a/cmd/gen-docs/main.go b/cmd/gen-docs/main.go index 5369d9670..6607054a9 100644 --- a/cmd/gen-docs/main.go +++ b/cmd/gen-docs/main.go @@ -28,7 +28,7 @@ func main() { func filePrepender(filename string) string { return `--- -layout: page +layout: manual permalink: /:path/:basename --- diff --git a/command/pr.go b/command/pr.go index f7e896a19..665da0b75 100644 --- a/command/pr.go +++ b/command/pr.go @@ -70,10 +70,6 @@ func prStatus(cmd *cobra.Command, args []string) error { return err } - currentPRNumber, currentPRHeadRef, err := prSelectorForCurrentBranch(ctx) - if err != nil { - return err - } currentUser, err := ctx.AuthLogin() if err != nil { return err @@ -84,6 +80,11 @@ func prStatus(cmd *cobra.Command, args []string) error { return err } + currentPRNumber, currentPRHeadRef, err := prSelectorForCurrentBranch(ctx, baseRepo) + if err != nil { + return err + } + prPayload, err := api.PullRequests(apiClient, baseRepo, currentPRNumber, currentPRHeadRef, currentUser) if err != nil { return err @@ -263,7 +264,7 @@ func prView(cmd *cobra.Command, args []string) error { } openURL = pr.URL } else { - prNumber, branchWithOwner, err := prSelectorForCurrentBranch(ctx) + prNumber, branchWithOwner, err := prSelectorForCurrentBranch(ctx, baseRepo) if err != nil { return err } @@ -333,11 +334,7 @@ func prFromArg(apiClient *api.Client, baseRepo ghrepo.Interface, arg string) (*a return api.PullRequestForBranch(apiClient, baseRepo, arg) } -func prSelectorForCurrentBranch(ctx context.Context) (prNumber int, prHeadRef string, err error) { - baseRepo, err := ctx.BaseRepo() - if err != nil { - return - } +func prSelectorForCurrentBranch(ctx context.Context, baseRepo ghrepo.Interface) (prNumber int, prHeadRef string, err error) { prHeadRef, err = ctx.Branch() if err != nil { return diff --git a/command/pr_test.go b/command/pr_test.go index ad092a267..5fccd7583 100644 --- a/command/pr_test.go +++ b/command/pr_test.go @@ -98,6 +98,36 @@ func TestPRStatus(t *testing.T) { } } +func TestPRStatus_fork(t *testing.T) { + initBlankContext("OWNER/REPO", "blueberries") + http := initFakeHTTP() + http.StubForkedRepoResponse("OWNER/REPO", "PARENT/REPO") + + jsonFile, _ := os.Open("../test/fixtures/prStatusFork.json") + defer jsonFile.Close() + http.StubResponse(200, jsonFile) + + defer utils.SetPrepareCmd(func(cmd *exec.Cmd) utils.Runnable { + switch strings.Join(cmd.Args, " ") { + case `git config --get-regexp ^branch\.blueberries\.(remote|merge)$`: + return &outputStub{[]byte(`branch.blueberries.remote origin +branch.blueberries.merge refs/heads/blueberries`)} + default: + panic("not implemented") + } + })() + + output, err := RunCommand(prStatusCmd, "pr status") + if err != nil { + t.Fatalf("error running command `pr status`: %v", err) + } + + branchRE := regexp.MustCompile(`#10.*\[OWNER:blueberries\]`) + if !branchRE.MatchString(output.String()) { + t.Errorf("did not match current branch:\n%v", output.String()) + } +} + func TestPRStatus_reviewsAndChecks(t *testing.T) { initBlankContext("OWNER/REPO", "blueberries") http := initFakeHTTP() diff --git a/command/repo.go b/command/repo.go index 484ef609c..53b2ca8f8 100644 --- a/command/repo.go +++ b/command/repo.go @@ -2,9 +2,14 @@ package command import ( "fmt" + "net/url" "os" + "path" "strings" + "time" + "github.com/AlecAivazis/survey/v2" + "github.com/cli/cli/api" "github.com/cli/cli/git" "github.com/cli/cli/internal/ghrepo" "github.com/cli/cli/utils" @@ -14,12 +19,27 @@ import ( func init() { RootCmd.AddCommand(repoCmd) repoCmd.AddCommand(repoCloneCmd) + + repoCmd.AddCommand(repoCreateCmd) + repoCreateCmd.Flags().StringP("description", "d", "", "Description of repository") + repoCreateCmd.Flags().StringP("homepage", "h", "", "Repository home page URL") + repoCreateCmd.Flags().StringP("team", "t", "", "The name of the organization team to be granted access") + repoCreateCmd.Flags().Bool("enable-issues", true, "Enable issues in the new repository") + repoCreateCmd.Flags().Bool("enable-wiki", true, "Enable wiki in the new repository") + repoCreateCmd.Flags().Bool("public", false, "Make the new repository public") + + repoCmd.AddCommand(repoForkCmd) + repoForkCmd.Flags().String("clone", "prompt", "Clone fork: {true|false|prompt}") + repoForkCmd.Flags().String("remote", "prompt", "Add remote for fork: {true|false|prompt}") + repoForkCmd.Flags().Lookup("clone").NoOptDefVal = "true" + repoForkCmd.Flags().Lookup("remote").NoOptDefVal = "true" + repoCmd.AddCommand(repoViewCmd) } var repoCmd = &cobra.Command{ Use: "repo", - Short: "View repositories", + Short: "Create, clone, fork, and view repositories", Long: `Work with GitHub repositories. A repository can be supplied as an argument in any of the following formats: @@ -37,8 +57,26 @@ To pass 'git clone' options, separate them with '--'.`, RunE: repoClone, } +var repoCreateCmd = &cobra.Command{ + Use: "create []", + Short: "Create a new repository", + Long: `Create a new GitHub repository. + +Use the "ORG/NAME" syntax to create a repository within your organization.`, + RunE: repoCreate, +} + +var repoForkCmd = &cobra.Command{ + Use: "fork []", + Short: "Create a fork of a repository", + Long: `Create a fork of a repository. + +With no argument, creates a fork of the current repository. Otherwise, forks the specified repository.`, + RunE: repoFork, +} + var repoViewCmd = &cobra.Command{ - Use: "view []", + Use: "view []", Short: "View a repository in the browser", Long: `View a GitHub repository in the browser. @@ -63,6 +101,289 @@ func repoClone(cmd *cobra.Command, args []string) error { return utils.PrepareCmd(cloneCmd).Run() } +func repoCreate(cmd *cobra.Command, args []string) error { + projectDir, projectDirErr := git.ToplevelDir() + + orgName := "" + teamSlug, err := cmd.Flags().GetString("team") + if err != nil { + return err + } + + var name string + if len(args) > 0 { + name = args[0] + if strings.Contains(name, "/") { + newRepo := ghrepo.FromFullName(name) + orgName = newRepo.RepoOwner() + name = newRepo.RepoName() + } + } else { + if projectDirErr != nil { + return projectDirErr + } + name = path.Base(projectDir) + } + + isPublic, err := cmd.Flags().GetBool("public") + if err != nil { + return err + } + hasIssuesEnabled, err := cmd.Flags().GetBool("enable-issues") + if err != nil { + return err + } + hasWikiEnabled, err := cmd.Flags().GetBool("enable-wiki") + if err != nil { + return err + } + description, err := cmd.Flags().GetString("description") + if err != nil { + return err + } + homepage, err := cmd.Flags().GetString("homepage") + if err != nil { + return err + } + + // TODO: move this into constant within `api` + visibility := "PRIVATE" + if isPublic { + visibility = "PUBLIC" + } + + input := api.RepoCreateInput{ + Name: name, + Visibility: visibility, + OwnerID: orgName, + TeamID: teamSlug, + Description: description, + Homepage: homepage, + HasIssuesEnabled: hasIssuesEnabled, + HasWikiEnabled: hasWikiEnabled, + } + + ctx := contextForCommand(cmd) + client, err := apiClientForContext(ctx) + if err != nil { + return err + } + + repo, err := api.RepoCreate(client, input) + if err != nil { + return err + } + + out := cmd.OutOrStdout() + greenCheck := utils.Green("✓") + isTTY := false + if outFile, isFile := out.(*os.File); isFile { + isTTY = utils.IsTerminal(outFile) + if isTTY { + // FIXME: duplicates colorableOut + out = utils.NewColorable(outFile) + } + } + + if isTTY { + fmt.Fprintf(out, "%s Created repository %s on GitHub\n", greenCheck, ghrepo.FullName(repo)) + } else { + fmt.Fprintln(out, repo.URL) + } + + remoteURL := repo.URL + ".git" + + if projectDirErr == nil { + // TODO: use git.AddRemote + remoteAdd := git.GitCommand("remote", "add", "origin", remoteURL) + remoteAdd.Stdout = os.Stdout + remoteAdd.Stderr = os.Stderr + err = utils.PrepareCmd(remoteAdd).Run() + if err != nil { + return err + } + if isTTY { + fmt.Fprintf(out, "%s Added remote %s\n", greenCheck, remoteURL) + } + } else if isTTY { + doSetup := false + err := Confirm(fmt.Sprintf("Create a local project directory for %s?", ghrepo.FullName(repo)), &doSetup) + if err != nil { + return err + } + + if doSetup { + path := repo.Name + + gitInit := git.GitCommand("init", path) + gitInit.Stdout = os.Stdout + gitInit.Stderr = os.Stderr + err = utils.PrepareCmd(gitInit).Run() + if err != nil { + return err + } + gitRemoteAdd := git.GitCommand("-C", path, "remote", "add", "origin", remoteURL) + gitRemoteAdd.Stdout = os.Stdout + gitRemoteAdd.Stderr = os.Stderr + err = utils.PrepareCmd(gitRemoteAdd).Run() + if err != nil { + return err + } + + fmt.Fprintf(out, "%s Initialized repository in './%s/'\n", greenCheck, path) + } + } + + return nil +} + +func isURL(arg string) bool { + return strings.HasPrefix(arg, "http:/") || strings.HasPrefix(arg, "https:/") +} + +var Since = func(t time.Time) time.Duration { + return time.Since(t) +} + +func repoFork(cmd *cobra.Command, args []string) error { + ctx := contextForCommand(cmd) + + clonePref, err := cmd.Flags().GetString("clone") + if err != nil { + return err + } + remotePref, err := cmd.Flags().GetString("remote") + if err != nil { + return err + } + + apiClient, err := apiClientForContext(ctx) + if err != nil { + return fmt.Errorf("unable to create client: %w", err) + } + + var toFork ghrepo.Interface + inParent := false // whether or not we're forking the repo we're currently "in" + if len(args) == 0 { + baseRepo, err := determineBaseRepo(cmd, ctx) + if err != nil { + return fmt.Errorf("unable to determine base repository: %w", err) + } + inParent = true + toFork = baseRepo + } else { + repoArg := args[0] + + if isURL(repoArg) { + parsedURL, err := url.Parse(repoArg) + if err != nil { + return fmt.Errorf("did not understand argument: %w", err) + } + + toFork, err = ghrepo.FromURL(parsedURL) + if err != nil { + return fmt.Errorf("did not understand argument: %w", err) + } + + } else { + toFork = ghrepo.FromFullName(repoArg) + if toFork.RepoName() == "" || toFork.RepoOwner() == "" { + return fmt.Errorf("could not parse owner or repo name from %s", repoArg) + } + } + } + + greenCheck := utils.Green("✓") + out := colorableOut(cmd) + s := utils.Spinner() + loading := utils.Gray("Forking ") + utils.Bold(utils.Gray(ghrepo.FullName(toFork))) + utils.Gray("...") + s.Suffix = " " + loading + s.FinalMSG = utils.Gray(fmt.Sprintf("- %s\n", loading)) + s.Start() + + authLogin, err := ctx.AuthLogin() + if err != nil { + s.Stop() + return fmt.Errorf("could not determine current username: %w", err) + } + + possibleFork := ghrepo.New(authLogin, toFork.RepoName()) + + forkedRepo, err := api.ForkRepo(apiClient, toFork) + if err != nil { + s.Stop() + return fmt.Errorf("failed to fork: %w", err) + } + + s.Stop() + // This is weird. There is not an efficient way to determine via the GitHub API whether or not a + // given user has forked a given repo. We noticed, also, that the create fork API endpoint just + // returns the fork repo data even if it already exists -- with no change in status code or + // anything. We thus check the created time to see if the repo is brand new or not; if it's not, + // we assume the fork already existed and report an error. + created_ago := Since(forkedRepo.CreatedAt) + if created_ago > time.Minute { + fmt.Fprintf(out, "%s %s %s\n", + utils.Yellow("!"), + utils.Bold(ghrepo.FullName(possibleFork)), + "already exists") + } else { + fmt.Fprintf(out, "%s Created fork %s\n", greenCheck, utils.Bold(ghrepo.FullName(forkedRepo))) + } + + if (inParent && remotePref == "false") || (!inParent && clonePref == "false") { + return nil + } + + if inParent { + remoteDesired := remotePref == "true" + if remotePref == "prompt" { + err = Confirm("Would you like to add a remote for the fork?", &remoteDesired) + if err != nil { + return fmt.Errorf("failed to prompt: %w", err) + } + } + if remoteDesired { + _, err := git.AddRemote("fork", forkedRepo.CloneURL, "") + if err != nil { + return fmt.Errorf("failed to add remote: %w", err) + } + + fmt.Fprintf(out, "%s Remote added at %s\n", greenCheck, utils.Bold("fork")) + } + } else { + cloneDesired := clonePref == "true" + if clonePref == "prompt" { + err = Confirm("Would you like to clone the fork?", &cloneDesired) + if err != nil { + return fmt.Errorf("failed to prompt: %w", err) + } + } + if cloneDesired { + cloneCmd := git.GitCommand("clone", forkedRepo.CloneURL) + cloneCmd.Stdin = os.Stdin + cloneCmd.Stdout = os.Stdout + cloneCmd.Stderr = os.Stderr + err = utils.PrepareCmd(cloneCmd).Run() + if err != nil { + return fmt.Errorf("failed to clone fork: %w", err) + } + + fmt.Fprintf(out, "%s Cloned fork\n", greenCheck) + } + } + + return nil +} + +var Confirm = func(prompt string, result *bool) error { + p := &survey.Confirm{ + Message: prompt, + Default: true, + } + return survey.AskOne(p, result) +} + func repoView(cmd *cobra.Command, args []string) error { ctx := contextForCommand(cmd) @@ -75,7 +396,7 @@ func repoView(cmd *cobra.Command, args []string) error { openURL = fmt.Sprintf("https://github.com/%s", ghrepo.FullName(baseRepo)) } else { repoArg := args[0] - if strings.HasPrefix(repoArg, "http:/") || strings.HasPrefix(repoArg, "https:/") { + if isURL(repoArg) { openURL = repoArg } else { openURL = fmt.Sprintf("https://github.com/%s", repoArg) diff --git a/command/repo_test.go b/command/repo_test.go index 588ae3520..da64712b7 100644 --- a/command/repo_test.go +++ b/command/repo_test.go @@ -1,14 +1,342 @@ package command import ( + "bytes" + "encoding/json" + "io/ioutil" "os/exec" + "regexp" "strings" "testing" + "time" "github.com/cli/cli/context" "github.com/cli/cli/utils" ) +func TestRepoFork_already_forked(t *testing.T) { + initContext = func() context.Context { + ctx := context.NewBlank() + ctx.SetBaseRepo("REPO") + ctx.SetBranch("master") + ctx.SetAuthLogin("someone") + ctx.SetRemotes(map[string]string{ + "origin": "OWNER/REPO", + }) + return ctx + } + http := initFakeHTTP() + http.StubRepoResponse("OWNER", "REPO") + defer http.StubWithFixture(200, "forkResult.json")() + + output, err := RunCommand(repoForkCmd, "repo fork --remote=false") + if err != nil { + t.Errorf("got unexpected error: %v", err) + } + r := regexp.MustCompile(`someone/REPO already exists`) + if !r.MatchString(output.String()) { + t.Errorf("output did not match regexp /%s/\n> output\n%s\n", r, output) + return + } +} + +func stubSince(d time.Duration) func() { + originalSince := Since + Since = func(t time.Time) time.Duration { + return d + } + return func() { + Since = originalSince + } +} + +func TestRepoFork_in_parent(t *testing.T) { + initBlankContext("OWNER/REPO", "master") + defer stubSince(2 * time.Second)() + http := initFakeHTTP() + http.StubRepoResponse("OWNER", "REPO") + defer http.StubWithFixture(200, "forkResult.json")() + + output, err := RunCommand(repoForkCmd, "repo fork --remote=false") + if err != nil { + t.Errorf("error running command `repo fork`: %v", err) + } + + eq(t, output.Stderr(), "") + + r := regexp.MustCompile(`Created fork someone/REPO`) + if !r.MatchString(output.String()) { + t.Errorf("output did not match regexp /%s/\n> output\n%s\n", r, output) + return + } +} + +func TestRepoFork_outside(t *testing.T) { + tests := []struct { + name string + args string + }{ + { + name: "url arg", + args: "repo fork --clone=false http://github.com/OWNER/REPO.git", + }, + { + name: "full name arg", + args: "repo fork --clone=false OWNER/REPO", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + defer stubSince(2 * time.Second)() + http := initFakeHTTP() + defer http.StubWithFixture(200, "forkResult.json")() + + output, err := RunCommand(repoForkCmd, tt.args) + if err != nil { + t.Errorf("error running command `repo fork`: %v", err) + } + + eq(t, output.Stderr(), "") + + r := regexp.MustCompile(`Created fork someone/REPO`) + if !r.MatchString(output.String()) { + t.Errorf("output did not match regexp /%s/\n> output\n%s\n", r, output) + return + } + }) + } +} + +func TestRepoFork_in_parent_yes(t *testing.T) { + initBlankContext("OWNER/REPO", "master") + defer stubSince(2 * time.Second)() + http := initFakeHTTP() + http.StubRepoResponse("OWNER", "REPO") + defer http.StubWithFixture(200, "forkResult.json")() + + var seenCmds []*exec.Cmd + defer utils.SetPrepareCmd(func(cmd *exec.Cmd) utils.Runnable { + seenCmds = append(seenCmds, cmd) + return &outputStub{} + })() + + output, err := RunCommand(repoForkCmd, "repo fork --remote") + if err != nil { + t.Errorf("error running command `repo fork`: %v", err) + } + + expectedCmds := []string{ + "git remote add -f fork https://github.com/someone/repo.git", + "git fetch fork", + } + + for x, cmd := range seenCmds { + eq(t, strings.Join(cmd.Args, " "), expectedCmds[x]) + } + + eq(t, output.Stderr(), "") + + expectedLines := []*regexp.Regexp{ + regexp.MustCompile(`Created fork someone/REPO`), + regexp.MustCompile(`Remote added at fork`), + } + for _, r := range expectedLines { + if !r.MatchString(output.String()) { + t.Errorf("output did not match regexp /%s/\n> output\n%s\n", r, output) + return + } + } +} + +func TestRepoFork_outside_yes(t *testing.T) { + defer stubSince(2 * time.Second)() + http := initFakeHTTP() + defer http.StubWithFixture(200, "forkResult.json")() + + var seenCmd *exec.Cmd + defer utils.SetPrepareCmd(func(cmd *exec.Cmd) utils.Runnable { + seenCmd = cmd + return &outputStub{} + })() + + output, err := RunCommand(repoForkCmd, "repo fork --clone OWNER/REPO") + if err != nil { + t.Errorf("error running command `repo fork`: %v", err) + } + + eq(t, output.Stderr(), "") + + eq(t, strings.Join(seenCmd.Args, " "), "git clone https://github.com/someone/repo.git") + + expectedLines := []*regexp.Regexp{ + regexp.MustCompile(`Created fork someone/REPO`), + regexp.MustCompile(`Cloned fork`), + } + for _, r := range expectedLines { + if !r.MatchString(output.String()) { + t.Errorf("output did not match regexp /%s/\n> output\n%s\n", r, output) + return + } + } +} + +func TestRepoFork_outside_survey_yes(t *testing.T) { + defer stubSince(2 * time.Second)() + http := initFakeHTTP() + defer http.StubWithFixture(200, "forkResult.json")() + + var seenCmd *exec.Cmd + defer utils.SetPrepareCmd(func(cmd *exec.Cmd) utils.Runnable { + seenCmd = cmd + return &outputStub{} + })() + + oldConfirm := Confirm + Confirm = func(_ string, result *bool) error { + *result = true + return nil + } + defer func() { Confirm = oldConfirm }() + + output, err := RunCommand(repoForkCmd, "repo fork OWNER/REPO") + if err != nil { + t.Errorf("error running command `repo fork`: %v", err) + } + + eq(t, output.Stderr(), "") + + eq(t, strings.Join(seenCmd.Args, " "), "git clone https://github.com/someone/repo.git") + + expectedLines := []*regexp.Regexp{ + regexp.MustCompile(`Created fork someone/REPO`), + regexp.MustCompile(`Cloned fork`), + } + for _, r := range expectedLines { + if !r.MatchString(output.String()) { + t.Errorf("output did not match regexp /%s/\n> output\n%s\n", r, output) + return + } + } +} + +func TestRepoFork_outside_survey_no(t *testing.T) { + defer stubSince(2 * time.Second)() + http := initFakeHTTP() + defer http.StubWithFixture(200, "forkResult.json")() + + cmdRun := false + defer utils.SetPrepareCmd(func(cmd *exec.Cmd) utils.Runnable { + cmdRun = true + return &outputStub{} + })() + + oldConfirm := Confirm + Confirm = func(_ string, result *bool) error { + *result = false + return nil + } + defer func() { Confirm = oldConfirm }() + + output, err := RunCommand(repoForkCmd, "repo fork OWNER/REPO") + if err != nil { + t.Errorf("error running command `repo fork`: %v", err) + } + + eq(t, output.Stderr(), "") + + eq(t, cmdRun, false) + + r := regexp.MustCompile(`Created fork someone/REPO`) + if !r.MatchString(output.String()) { + t.Errorf("output did not match regexp /%s/\n> output\n%s\n", r, output) + return + } +} + +func TestRepoFork_in_parent_survey_yes(t *testing.T) { + initBlankContext("OWNER/REPO", "master") + defer stubSince(2 * time.Second)() + http := initFakeHTTP() + http.StubRepoResponse("OWNER", "REPO") + defer http.StubWithFixture(200, "forkResult.json")() + + var seenCmds []*exec.Cmd + defer utils.SetPrepareCmd(func(cmd *exec.Cmd) utils.Runnable { + seenCmds = append(seenCmds, cmd) + return &outputStub{} + })() + + oldConfirm := Confirm + Confirm = func(_ string, result *bool) error { + *result = true + return nil + } + defer func() { Confirm = oldConfirm }() + + output, err := RunCommand(repoForkCmd, "repo fork") + if err != nil { + t.Errorf("error running command `repo fork`: %v", err) + } + + expectedCmds := []string{ + "git remote add -f fork https://github.com/someone/repo.git", + "git fetch fork", + } + + for x, cmd := range seenCmds { + eq(t, strings.Join(cmd.Args, " "), expectedCmds[x]) + } + + eq(t, output.Stderr(), "") + + expectedLines := []*regexp.Regexp{ + regexp.MustCompile(`Created fork someone/REPO`), + regexp.MustCompile(`Remote added at fork`), + } + for _, r := range expectedLines { + if !r.MatchString(output.String()) { + t.Errorf("output did not match regexp /%s/\n> output\n%s\n", r, output) + return + } + } +} + +func TestRepoFork_in_parent_survey_no(t *testing.T) { + initBlankContext("OWNER/REPO", "master") + defer stubSince(2 * time.Second)() + http := initFakeHTTP() + http.StubRepoResponse("OWNER", "REPO") + defer http.StubWithFixture(200, "forkResult.json")() + + cmdRun := false + defer utils.SetPrepareCmd(func(cmd *exec.Cmd) utils.Runnable { + cmdRun = true + return &outputStub{} + })() + + oldConfirm := Confirm + Confirm = func(_ string, result *bool) error { + *result = false + return nil + } + defer func() { Confirm = oldConfirm }() + + output, err := RunCommand(repoForkCmd, "repo fork") + if err != nil { + t.Errorf("error running command `repo fork`: %v", err) + } + + eq(t, output.Stderr(), "") + + eq(t, cmdRun, false) + + r := regexp.MustCompile(`Created fork someone/REPO`) + if !r.MatchString(output.String()) { + t.Errorf("output did not match regexp /%s/\n> output\n%s\n", r, output) + return + } +} + func TestRepoClone(t *testing.T) { tests := []struct { name string @@ -45,7 +373,7 @@ func TestRepoClone(t *testing.T) { }) defer restoreCmd() - output, err := RunCommand(repoViewCmd, tt.args) + output, err := RunCommand(repoCloneCmd, tt.args) if err != nil { t.Fatalf("error running command `repo clone`: %v", err) } @@ -61,6 +389,196 @@ func TestRepoClone(t *testing.T) { } } +func TestRepoCreate(t *testing.T) { + ctx := context.NewBlank() + ctx.SetBranch("master") + initContext = func() context.Context { + return ctx + } + + http := initFakeHTTP() + http.StubResponse(200, bytes.NewBufferString(` + { "data": { "createRepository": { + "repository": { + "id": "REPOID", + "url": "https://github.com/OWNER/REPO" + } + } } } + `)) + + var seenCmd *exec.Cmd + restoreCmd := utils.SetPrepareCmd(func(cmd *exec.Cmd) utils.Runnable { + seenCmd = cmd + return &outputStub{} + }) + defer restoreCmd() + + output, err := RunCommand(repoCreateCmd, "repo create REPO") + if err != nil { + t.Errorf("error running command `repo create`: %v", err) + } + + eq(t, output.String(), "https://github.com/OWNER/REPO\n") + eq(t, output.Stderr(), "") + + if seenCmd == nil { + t.Fatal("expected a command to run") + } + eq(t, strings.Join(seenCmd.Args, " "), "git remote add origin https://github.com/OWNER/REPO.git") + + var reqBody struct { + Query string + Variables struct { + Input map[string]interface{} + } + } + + if len(http.Requests) != 1 { + t.Fatalf("expected 1 HTTP request, got %d", len(http.Requests)) + } + + bodyBytes, _ := ioutil.ReadAll(http.Requests[0].Body) + json.Unmarshal(bodyBytes, &reqBody) + if repoName := reqBody.Variables.Input["name"].(string); repoName != "REPO" { + t.Errorf("expected %q, got %q", "REPO", repoName) + } + if repoVisibility := reqBody.Variables.Input["visibility"].(string); repoVisibility != "PRIVATE" { + t.Errorf("expected %q, got %q", "PRIVATE", repoVisibility) + } + if _, ownerSet := reqBody.Variables.Input["ownerId"]; ownerSet { + t.Error("expected ownerId not to be set") + } +} + +func TestRepoCreate_org(t *testing.T) { + ctx := context.NewBlank() + ctx.SetBranch("master") + initContext = func() context.Context { + return ctx + } + + http := initFakeHTTP() + http.StubResponse(200, bytes.NewBufferString(` + { "node_id": "ORGID" + } + `)) + http.StubResponse(200, bytes.NewBufferString(` + { "data": { "createRepository": { + "repository": { + "id": "REPOID", + "url": "https://github.com/ORG/REPO" + } + } } } + `)) + + var seenCmd *exec.Cmd + restoreCmd := utils.SetPrepareCmd(func(cmd *exec.Cmd) utils.Runnable { + seenCmd = cmd + return &outputStub{} + }) + defer restoreCmd() + + output, err := RunCommand(repoCreateCmd, "repo create ORG/REPO") + if err != nil { + t.Errorf("error running command `repo create`: %v", err) + } + + eq(t, output.String(), "https://github.com/ORG/REPO\n") + eq(t, output.Stderr(), "") + + if seenCmd == nil { + t.Fatal("expected a command to run") + } + eq(t, strings.Join(seenCmd.Args, " "), "git remote add origin https://github.com/ORG/REPO.git") + + var reqBody struct { + Query string + Variables struct { + Input map[string]interface{} + } + } + + if len(http.Requests) != 2 { + t.Fatalf("expected 2 HTTP requests, got %d", len(http.Requests)) + } + + eq(t, http.Requests[0].URL.Path, "/users/ORG") + + bodyBytes, _ := ioutil.ReadAll(http.Requests[1].Body) + json.Unmarshal(bodyBytes, &reqBody) + if orgID := reqBody.Variables.Input["ownerId"].(string); orgID != "ORGID" { + t.Errorf("expected %q, got %q", "ORGID", orgID) + } + if _, teamSet := reqBody.Variables.Input["teamId"]; teamSet { + t.Error("expected teamId not to be set") + } +} + +func TestRepoCreate_orgWithTeam(t *testing.T) { + ctx := context.NewBlank() + ctx.SetBranch("master") + initContext = func() context.Context { + return ctx + } + + http := initFakeHTTP() + http.StubResponse(200, bytes.NewBufferString(` + { "node_id": "TEAMID", + "organization": { "node_id": "ORGID" } + } + `)) + http.StubResponse(200, bytes.NewBufferString(` + { "data": { "createRepository": { + "repository": { + "id": "REPOID", + "url": "https://github.com/ORG/REPO" + } + } } } + `)) + + var seenCmd *exec.Cmd + restoreCmd := utils.SetPrepareCmd(func(cmd *exec.Cmd) utils.Runnable { + seenCmd = cmd + return &outputStub{} + }) + defer restoreCmd() + + output, err := RunCommand(repoCreateCmd, "repo create ORG/REPO --team monkeys") + if err != nil { + t.Errorf("error running command `repo create`: %v", err) + } + + eq(t, output.String(), "https://github.com/ORG/REPO\n") + eq(t, output.Stderr(), "") + + if seenCmd == nil { + t.Fatal("expected a command to run") + } + eq(t, strings.Join(seenCmd.Args, " "), "git remote add origin https://github.com/ORG/REPO.git") + + var reqBody struct { + Query string + Variables struct { + Input map[string]interface{} + } + } + + if len(http.Requests) != 2 { + t.Fatalf("expected 2 HTTP requests, got %d", len(http.Requests)) + } + + eq(t, http.Requests[0].URL.Path, "/orgs/ORG/teams/monkeys") + + bodyBytes, _ := ioutil.ReadAll(http.Requests[1].Body) + json.Unmarshal(bodyBytes, &reqBody) + if orgID := reqBody.Variables.Input["ownerId"].(string); orgID != "ORGID" { + t.Errorf("expected %q, got %q", "ORGID", orgID) + } + if teamID := reqBody.Variables.Input["teamId"].(string); teamID != "TEAMID" { + t.Errorf("expected %q, got %q", "TEAMID", teamID) + } +} + func TestRepoView(t *testing.T) { initBlankContext("OWNER/REPO", "master") http := initFakeHTTP() diff --git a/command/root.go b/command/root.go index d61799ddc..7b0d078ef 100644 --- a/command/root.go +++ b/command/root.go @@ -14,9 +14,10 @@ import ( "github.com/cli/cli/utils" "github.com/spf13/cobra" + "github.com/spf13/pflag" ) -// Version is dynamically set by the toolchain or overriden by the Makefile. +// Version is dynamically set by the toolchain or overridden by the Makefile. var Version = "DEV" // BuildDate is dynamically set at build time in the Makefile. @@ -47,6 +48,9 @@ func init() { // RootCmd.PersistentFlags().BoolP("verbose", "V", false, "enable verbose output") RootCmd.SetFlagErrorFunc(func(cmd *cobra.Command, err error) error { + if err == pflag.ErrHelp { + return err + } return &FlagError{Err: err} }) } diff --git a/context/blank_context.go b/context/blank_context.go index 93c431611..110b43768 100644 --- a/context/blank_context.go +++ b/context/blank_context.go @@ -30,6 +30,10 @@ func (c *blankContext) SetAuthToken(t string) { c.authToken = t } +func (c *blankContext) SetAuthLogin(login string) { + c.authLogin = login +} + func (c *blankContext) AuthLogin() (string, error) { return c.authLogin, nil } diff --git a/go.mod b/go.mod index 458a7268e..63a39c1bf 100644 --- a/go.mod +++ b/go.mod @@ -3,28 +3,25 @@ module github.com/cli/cli go 1.13 require ( - github.com/AlecAivazis/survey/v2 v2.0.5 - github.com/alecthomas/chroma v0.7.1 // indirect - github.com/charmbracelet/glamour v0.1.1-0.20200210132808-8d4e3f0692f8 + github.com/AlecAivazis/survey/v2 v2.0.7 + github.com/briandowns/spinner v1.9.0 + github.com/charmbracelet/glamour v0.1.1-0.20200304134224-7e5c90143acc github.com/dlclark/regexp2 v1.2.0 // indirect github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 github.com/hashicorp/go-version v1.2.0 - github.com/henvic/httpretty v0.0.3 + github.com/henvic/httpretty v0.0.4 github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 - github.com/mattn/go-colorable v0.1.4 + github.com/mattn/go-colorable v0.1.6 github.com/mattn/go-isatty v0.0.12 github.com/mattn/go-runewidth v0.0.8 // indirect github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b github.com/mitchellh/go-homedir v1.1.0 - github.com/muesli/reflow v0.0.0-20200210202703-cf7e7eac5cb4 // indirect github.com/spf13/cobra v0.0.6 github.com/spf13/pflag v1.0.5 github.com/stretchr/testify v1.4.0 // indirect - github.com/yuin/goldmark v1.1.23 // indirect golang.org/x/crypto v0.0.0-20200219234226-1ad67e1f0ef4 golang.org/x/net v0.0.0-20200219183655-46282727080f // indirect - golang.org/x/sys v0.0.0-20200219091948-cb0a6d8edb6c // indirect - golang.org/x/text v0.3.0 + golang.org/x/text v0.3.2 gopkg.in/yaml.v2 v2.2.8 // indirect gopkg.in/yaml.v3 v3.0.0-20200121175148-a6ecf24a6d71 ) diff --git a/go.sum b/go.sum index 518f745aa..8d7665e96 100644 --- a/go.sum +++ b/go.sum @@ -1,25 +1,18 @@ cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= -github.com/AlecAivazis/survey/v2 v2.0.5 h1:xpZp+Q55wi5C7Iaze+40onHnEkex1jSc34CltJjOoPM= -github.com/AlecAivazis/survey/v2 v2.0.5/go.mod h1:WYBhg6f0y/fNYUuesWQc0PKbJcEliGcYHB9sNT3Bg74= +github.com/AlecAivazis/survey/v2 v2.0.7 h1:+f825XHLse/hWd2tE/V5df04WFGimk34Eyg/z35w/rc= +github.com/AlecAivazis/survey/v2 v2.0.7/go.mod h1:mlizQTaPjnR4jcpwRSaSlkbsRfYFEyKgLQvYTzxxiHA= github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= -github.com/GeertJohan/go.incremental v1.0.0/go.mod h1:6fAjUhbVuX1KcMD3c8TEgVUqmo4seqhv0i0kdATSkM0= -github.com/GeertJohan/go.rice v1.0.0/go.mod h1:eH6gbSOAUv07dQuZVnBmoDP8mgsM1rtixis4Tib9if0= 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/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= -github.com/akavel/rsrc v0.8.0/go.mod h1:uLoCtb9J+EyAqh+26kdrTgmzRBFPGOolLWKpdxkKq+c= github.com/alecthomas/assert v0.0.0-20170929043011-405dbfeb8e38 h1:smF2tmSOzy2Mm+0dGI2AIUHY+w0BUc+4tn40djz7+6U= github.com/alecthomas/assert v0.0.0-20170929043011-405dbfeb8e38/go.mod h1:r7bzyVFMNntcxPZXK3/+KdruV1H5KSlyVY0gc+NgInI= -github.com/alecthomas/chroma v0.7.0 h1:z+0HgTUmkpRDRz0SRSdMaqOLfJV4F+N1FPDZUZIDUzw= -github.com/alecthomas/chroma v0.7.0/go.mod h1:1U/PfCsTALWWYHDnsIQkxEBM0+6LLe0v8+RSVMOwxeY= -github.com/alecthomas/chroma v0.7.1 h1:G1i02OhUbRi2nJxcNkwJaY/J1gHXj9tt72qN6ZouLFQ= -github.com/alecthomas/chroma v0.7.1/go.mod h1:gHw09mkX1Qp80JlYbmN9L3+4R5o6DJJ3GRShh+AICNc= +github.com/alecthomas/chroma v0.7.2-0.20200304075647-34d9c7143bf5 h1:yt5ij+XTe1QL+TpFj7i547enM5YuGKp9nZ/WvOoqcsQ= +github.com/alecthomas/chroma v0.7.2-0.20200304075647-34d9c7143bf5/go.mod h1:fv5SzZPFJbwp2NXJWpFIX7DZS4HgV1K4ew4Pc2OZD9s= github.com/alecthomas/colour v0.0.0-20160524082231-60882d9e2721 h1:JHZL0hZKJ1VENNfmXvHbgYlbUOvpzYzvy2aZU5gXVeo= github.com/alecthomas/colour v0.0.0-20160524082231-60882d9e2721/go.mod h1:QO9JBoKquHd+jz9nshCh40fOfO+JzsoXy8qTHF68zU0= -github.com/alecthomas/kong v0.1.17-0.20190424132513-439c674f7ae0/go.mod h1:+inYUSluD+p4L8KdviBSgzcqEjUQOfC5fQDRFuc36lI= github.com/alecthomas/kong v0.2.1-0.20190708041108-0548c6b1afae/go.mod h1:+inYUSluD+p4L8KdviBSgzcqEjUQOfC5fQDRFuc36lI= -github.com/alecthomas/kong-hcl v0.1.8-0.20190615233001-b21fea9723c8/go.mod h1:MRgZdU3vrFd05IQ89AxUZ0aYdF39BYoNFa324SodPCA= github.com/alecthomas/repr v0.0.0-20180818092828-117648cd9897 h1:p9Sln00KOTlrYkxI1zYWl1QLnEqAqEARBEYa8FQnQcY= github.com/alecthomas/repr v0.0.0-20180818092828-117648cd9897/go.mod h1:xTS7Pm1pD1mvyM075QCDSRqH6qRLXylzS24ZTpRiSzQ= github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= @@ -27,9 +20,11 @@ github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRF github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= +github.com/briandowns/spinner v1.9.0 h1:+OMAisemaHar1hjuJ3Z2hIvNhQl9Y7GLPWUwwz2Pxo8= +github.com/briandowns/spinner v1.9.0/go.mod h1://Zf9tMcxfRUA36V23M6YGEAv+kECGfvpnLTnb8n4XQ= github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= -github.com/charmbracelet/glamour v0.1.1-0.20200210132808-8d4e3f0692f8 h1:udjWAc2bbIP0LyzEMt7BWxk6O6lIoONWoonN3C9bMtA= -github.com/charmbracelet/glamour v0.1.1-0.20200210132808-8d4e3f0692f8/go.mod h1:UsY1vFbaLp/j/SYgLfPH0n2I0jngBL+q6+mCAsESih4= +github.com/charmbracelet/glamour v0.1.1-0.20200304134224-7e5c90143acc h1:WSZ06evYgfSZYqkcv0+jFVSVCJeYsIvU+J4ILZUrha4= +github.com/charmbracelet/glamour v0.1.1-0.20200304134224-7e5c90143acc/go.mod h1:sC1EP6T+3nFnl5vwf0TYEs1inMigQxZ7n912YKoxJow= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/coreos/bbolt v1.3.2/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk= github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= @@ -38,7 +33,6 @@ github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7 github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA= github.com/cpuguy83/go-md2man/v2 v2.0.0 h1:EoUDS0afbrsXAZ9YQ9jdu/mZ2sXgT1/2yyNng4PGlyM= github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= -github.com/daaku/go.zipexe v1.0.0/go.mod h1:z8IiR6TsVLEYKwXAoE/I+8ys/sDkgTzSL0CLnGVd57E= github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964 h1:y5HC9v93H5EPKqaS1UYVg1uYah5Xf51mBfIoWehClUQ= github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964/go.mod h1:Xd9hchkHSWYkEqJwUGisez3G1QY8Ryz0sdWrLPMGjLk= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -50,6 +44,8 @@ github.com/dlclark/regexp2 v1.1.6 h1:CqB4MjHw0MFCDj+PHHjiESmHX+N7t0tJzKvC6M97BRg github.com/dlclark/regexp2 v1.1.6/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc= github.com/dlclark/regexp2 v1.2.0 h1:8sAhBGEM0dRWogWqWyQeIJnxjWO6oIjl8FKqREDsGfk= github.com/dlclark/regexp2 v1.2.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc= +github.com/fatih/color v1.7.0 h1:DkWD4oS2D8LGGgTQ6IvwJJXSL5Vp2ffcQg58nFV38Ys= +github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= @@ -69,10 +65,6 @@ github.com/google/goterm v0.0.0-20190703233501-fc88cf888a3f h1:5CjVwnuUcp5adK4gm github.com/google/goterm v0.0.0-20190703233501-fc88cf888a3f/go.mod h1:nOFQdrUlIlx6M6ODdSpBj1NVA+VgLC6kmw60mkw34H4= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= -github.com/gorilla/csrf v1.6.0/go.mod h1:7tSf8kmjNYr7IWDCYhd3U8Ck34iQ/Yw5CJu7bAkHEGI= -github.com/gorilla/handlers v1.4.1/go.mod h1:Qkdc/uu4tH4g6mTK6auzZ766c4CA0Ng8+o/OAirnOIQ= -github.com/gorilla/mux v1.7.3/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= -github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs= github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= @@ -80,13 +72,12 @@ github.com/grpc-ecosystem/grpc-gateway v1.9.0/go.mod h1:vNeuVxBJEsws4ogUvrchl83t github.com/hashicorp/go-version v1.2.0 h1:3vNe/fWF5CBgRIguda1meWhsZHy3m8gCJ5wx+dIzX/E= github.com/hashicorp/go-version v1.2.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= -github.com/henvic/httpretty v0.0.3 h1:oHTreVv2lcdRYUNm4h3cgbrGN0dTieO9H8UnxEZNlvw= -github.com/henvic/httpretty v0.0.3/go.mod h1:X38wLjWXHkXT7r2+uK8LjCMne9rsuNaBLJ+5cU2/Pmo= +github.com/henvic/httpretty v0.0.4 h1:hyMkO0HugjsmWu63Z+7chDw7+RilkKBJ1vCwlqUOvOk= +github.com/henvic/httpretty v0.0.4/go.mod h1:X38wLjWXHkXT7r2+uK8LjCMne9rsuNaBLJ+5cU2/Pmo= 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/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo= github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= @@ -108,8 +99,8 @@ github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czP github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= github.com/mattn/go-colorable v0.1.2 h1:/bC9yWikZXAL9uJdulbSfyVNIR3n3trXl+v8+1sx8mU= github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= -github.com/mattn/go-colorable v0.1.4 h1:snbPLB8fVfU9iwbbo30TPtbLRzwWu6aJS6Xh4eaaviA= -github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= +github.com/mattn/go-colorable v0.1.6 h1:6Su7aK7lXmJ/U79bYtBjLNaha4Fs1Rg9plHpcH+vvnE= +github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY= @@ -126,13 +117,11 @@ github.com/microcosm-cc/bluemonday v1.0.2/go.mod h1:iVP4YcDBq+n/5fb23BhYFvIMq/le github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= 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/muesli/reflow v0.0.0-20200210123334-eb23c6404749/go.mod h1:I9bWAt7QTg/que/qmUCJBGlj7wEq8OAFBjPNjc6xK4I= -github.com/muesli/reflow v0.0.0-20200210202703-cf7e7eac5cb4 h1:IQAzML2A961stVPyK3En5VajxyiujmTEUkJUXsXHY44= -github.com/muesli/reflow v0.0.0-20200210202703-cf7e7eac5cb4/go.mod h1:I9bWAt7QTg/que/qmUCJBGlj7wEq8OAFBjPNjc6xK4I= +github.com/muesli/reflow v0.1.0 h1:oQdpLfO56lr5pgLvqD0TcjW85rDjSYSBVdiG1Ch1ddM= +github.com/muesli/reflow v0.1.0/go.mod h1:I9bWAt7QTg/que/qmUCJBGlj7wEq8OAFBjPNjc6xK4I= github.com/muesli/termenv v0.4.0 h1:8Bz8WDF/6OoL7QENtESkAl3TlMyQq6+c3bjtJO3y9V8= github.com/muesli/termenv v0.4.0/go.mod h1:O1/I6sw+6KcrgAmcs6uiUVr7Lui+DNVbHTzt9Lm/PlI= github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= -github.com/nkovacs/streamquote v0.0.0-20170412213628-49af9bddb229/go.mod h1:0aYXnNPJ8l7uZxf45rWW1a/uME32OF0rhiYGNQ2oF2E= github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= github.com/olekukonko/tablewriter v0.0.4 h1:vHD/YYe1Wolo78koG299f7V/VAS08c6IpCLn+Ejf/w8= github.com/olekukonko/tablewriter v0.0.4/go.mod h1:zq6QwlOf5SlnkVbMSr5EoBv3636FWnp+qbPhuoO21uA= @@ -181,13 +170,10 @@ github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJy github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc= -github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= -github.com/valyala/fasttemplate v1.0.1/go.mod h1:UQGH1tvbgY+Nz5t2n7tXsz52dQxojPUpymEIMZ47gx8= github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q= -github.com/yuin/goldmark v1.1.22/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.1.23 h1:eTodJ8hwEUvwXhb9qxQNuL/q1d+xMQClrXR4mdvV7gs= -github.com/yuin/goldmark v1.1.23/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.1.24 h1:K4FemPDr4x/ZcqldoXWnexTLfdMIy2eEfXxsLnotTRI= +github.com/yuin/goldmark v1.1.24/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU= go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= @@ -223,12 +209,15 @@ golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5h 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-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200219091948-cb0a6d8edb6c h1:jceGD5YNJGgGMkJz79agzOln1K9TaZUjv5ird16qniQ= -golang.org/x/sys v0.0.0-20200219091948-cb0a6d8edb6c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae h1:/WDfKMnPU+m5M4xB+6x4kaepxRw6jWvR5iDRdvjHgy8= +golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= diff --git a/test/fixtures/forkResult.json b/test/fixtures/forkResult.json new file mode 100644 index 000000000..e3f885642 --- /dev/null +++ b/test/fixtures/forkResult.json @@ -0,0 +1,9 @@ +{ + "node_id": "123", + "name": "REPO", + "clone_url": "https://github.com/someone/repo.git", + "created_at": "2011-01-26T19:01:12Z", + "owner": { + "login": "someone" + } +} diff --git a/test/fixtures/prStatusFork.json b/test/fixtures/prStatusFork.json new file mode 100644 index 000000000..df184aaea --- /dev/null +++ b/test/fixtures/prStatusFork.json @@ -0,0 +1,31 @@ +{ + "data": { + "repository": { + "pullRequests": { + "totalCount": 1, + "edges": [ + { + "node": { + "number": 10, + "title": "Blueberries are a good fruit", + "url": "https://github.com/PARENT/REPO/pull/10", + "headRefName": "blueberries", + "headRepositoryOwner": { + "login": "OWNER" + }, + "isCrossRepository": true + } + } + ] + } + }, + "viewerCreated": { + "totalCount": 0, + "edges": [] + }, + "reviewRequested": { + "totalCount": 0, + "edges": [] + } + } +} diff --git a/utils/utils.go b/utils/utils.go index a3af92fcb..065c4988e 100644 --- a/utils/utils.go +++ b/utils/utils.go @@ -2,9 +2,9 @@ package utils import ( "fmt" - "strings" "time" + "github.com/briandowns/spinner" "github.com/charmbracelet/glamour" "github.com/cli/cli/pkg/browser" ) @@ -18,14 +18,8 @@ func OpenInBrowser(url string) error { return PrepareCmd(browseCmd).Run() } -func normalizeNewlines(s string) string { - s = strings.Replace(s, "\r\n", "\n", -1) - s = strings.Replace(s, "\r", "\n", -1) - return s -} - func RenderMarkdown(text string) (string, error) { - return glamour.Render(normalizeNewlines(text), "dark") + return glamour.Render(text, "dark") } func Pluralize(num int, thing string) string { @@ -59,3 +53,7 @@ func FuzzyAgo(ago time.Duration) string { return fmtDuration(int(ago.Hours()/24/365), "year") } + +func Spinner() *spinner.Spinner { + return spinner.New(spinner.CharSets[11], 400*time.Millisecond) +}