diff --git a/api/queries_repo.go b/api/queries_repo.go index 35079095c..e2ae274ed 100644 --- a/api/queries_repo.go +++ b/api/queries_repo.go @@ -3,7 +3,6 @@ package api import ( "bytes" "context" - "encoding/base64" "encoding/json" "errors" "fmt" @@ -405,37 +404,6 @@ func RepoCreate(client *Client, input RepoCreateInput) (*Repository, error) { return initRepoHostname(&response.CreateRepository.Repository, "github.com"), nil } -type RepoReadme struct { - Filename string - Content string -} - -func RepositoryReadme(client *Client, repo ghrepo.Interface) (*RepoReadme, error) { - var response struct { - Name string - Content string - } - - err := client.REST("GET", fmt.Sprintf("repos/%s/readme", ghrepo.FullName(repo)), nil, &response) - if err != nil { - var httpError HTTPError - if errors.As(err, &httpError) && httpError.StatusCode == 404 { - return nil, &NotFoundError{err} - } - return nil, err - } - - decoded, err := base64.StdEncoding.DecodeString(response.Content) - if err != nil { - return nil, fmt.Errorf("failed to decode readme: %w", err) - } - - return &RepoReadme{ - Filename: response.Name, - Content: string(decoded), - }, nil -} - type RepoMetadataResult struct { AssignableUsers []RepoAssignee Labels []RepoLabel diff --git a/command/issue.go b/command/issue.go index 5de86da91..43049faa2 100644 --- a/command/issue.go +++ b/command/issue.go @@ -227,7 +227,7 @@ func issueList(cmd *cobra.Command, args []string) error { } if web { - issueListURL := generateRepoURL(baseRepo, "issues") + issueListURL := ghrepo.GenerateRepoURL(baseRepo, "issues") openURL, err := listURLWithQuery(issueListURL, filterOptions{ entity: "issue", state: state, @@ -240,7 +240,7 @@ func issueList(cmd *cobra.Command, args []string) error { if err != nil { return err } - fmt.Fprintf(cmd.ErrOrStderr(), "Opening %s in your browser.\n", displayURL(openURL)) + fmt.Fprintf(cmd.ErrOrStderr(), "Opening %s in your browser.\n", utils.DisplayURL(openURL)) return utils.OpenInBrowser(openURL) } @@ -504,7 +504,7 @@ func issueCreate(cmd *cobra.Command, args []string) error { } if isWeb, err := cmd.Flags().GetBool("web"); err == nil && isWeb { - openURL := generateRepoURL(baseRepo, "issues/new") + openURL := ghrepo.GenerateRepoURL(baseRepo, "issues/new") if title != "" || body != "" { milestone := "" if len(milestoneTitles) > 0 { @@ -518,7 +518,7 @@ func issueCreate(cmd *cobra.Command, args []string) error { openURL += "/choose" } if connectedToTerminal(cmd) { - cmd.Printf("Opening %s in your browser.\n", displayURL(openURL)) + cmd.Printf("Opening %s in your browser.\n", utils.DisplayURL(openURL)) } return utils.OpenInBrowser(openURL) } @@ -582,7 +582,7 @@ func issueCreate(cmd *cobra.Command, args []string) error { } if action == PreviewAction { - openURL := generateRepoURL(baseRepo, "issues/new") + openURL := ghrepo.GenerateRepoURL(baseRepo, "issues/new") milestone := "" if len(milestoneTitles) > 0 { milestone = milestoneTitles[0] @@ -592,7 +592,7 @@ func issueCreate(cmd *cobra.Command, args []string) error { return err } // TODO could exceed max url length for explorer - fmt.Fprintf(cmd.ErrOrStderr(), "Opening %s in your browser.\n", displayURL(openURL)) + fmt.Fprintf(cmd.ErrOrStderr(), "Opening %s in your browser.\n", utils.DisplayURL(openURL)) return utils.OpenInBrowser(openURL) } else if action == SubmitAction { params := map[string]interface{}{ @@ -618,14 +618,6 @@ func issueCreate(cmd *cobra.Command, args []string) error { return nil } -func generateRepoURL(repo ghrepo.Interface, p string, args ...interface{}) string { - baseURL := fmt.Sprintf("https://%s/%s/%s", repo.RepoHost(), repo.RepoOwner(), repo.RepoName()) - if p != "" { - return baseURL + "/" + fmt.Sprintf(p, args...) - } - return baseURL -} - func addMetadataToIssueParams(client *api.Client, baseRepo ghrepo.Interface, params map[string]interface{}, tb *issueMetadataState) error { if !tb.HasMetadata() { return nil @@ -844,11 +836,3 @@ func issueReopen(cmd *cobra.Command, args []string) error { return nil } - -func displayURL(urlStr string) string { - u, err := url.Parse(urlStr) - if err != nil { - return urlStr - } - return u.Hostname() + u.Path -} diff --git a/command/pr.go b/command/pr.go index 9978d18be..2ef865066 100644 --- a/command/pr.go +++ b/command/pr.go @@ -235,7 +235,7 @@ func prList(cmd *cobra.Command, args []string) error { } if web { - prListURL := generateRepoURL(baseRepo, "pulls") + prListURL := ghrepo.GenerateRepoURL(baseRepo, "pulls") openURL, err := listURLWithQuery(prListURL, filterOptions{ entity: "pr", state: state, @@ -246,7 +246,7 @@ func prList(cmd *cobra.Command, args []string) error { if err != nil { return err } - fmt.Fprintf(cmd.ErrOrStderr(), "Opening %s in your browser.\n", displayURL(openURL)) + fmt.Fprintf(cmd.ErrOrStderr(), "Opening %s in your browser.\n", utils.DisplayURL(openURL)) return utils.OpenInBrowser(openURL) } diff --git a/command/pr_create.go b/command/pr_create.go index b66a7ff5d..b9efca0fa 100644 --- a/command/pr_create.go +++ b/command/pr_create.go @@ -367,7 +367,7 @@ func prCreate(cmd *cobra.Command, _ []string) error { } if connectedToTerminal(cmd) { // TODO could exceed max url length for explorer - fmt.Fprintf(cmd.ErrOrStderr(), "Opening %s in your browser.\n", displayURL(openURL)) + fmt.Fprintf(cmd.ErrOrStderr(), "Opening %s in your browser.\n", utils.DisplayURL(openURL)) } return utils.OpenInBrowser(openURL) } else { @@ -447,7 +447,7 @@ func withPrAndIssueQueryParams(baseURL, title, body string, assignees, labels, p } func generateCompareURL(r ghrepo.Interface, base, head, title, body string, assignees, labels, projects []string, milestone string) (string, error) { - u := generateRepoURL(r, "compare/%s...%s?expand=1", base, head) + u := ghrepo.GenerateRepoURL(r, "compare/%s...%s?expand=1", base, head) url, err := withPrAndIssueQueryParams(u, title, body, assignees, labels, projects, milestone) if err != nil { return "", err diff --git a/command/repo.go b/command/repo.go index d705a4a5f..cca74d8fb 100644 --- a/command/repo.go +++ b/command/repo.go @@ -7,7 +7,6 @@ import ( "os" "path" "strings" - "text/template" "time" "github.com/AlecAivazis/survey/v2" @@ -21,9 +20,6 @@ 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") @@ -38,9 +34,6 @@ func init() { repoForkCmd.Flags().Lookup("clone").NoOptDefVal = "true" repoForkCmd.Flags().Lookup("remote").NoOptDefVal = "true" - repoCmd.AddCommand(repoViewCmd) - repoViewCmd.Flags().BoolP("web", "w", false, "Open a repository in the browser") - repoCmd.AddCommand(repoCreditsCmd) repoCreditsCmd.Flags().BoolP("static", "s", false, "Print a static version of the credits") } @@ -62,19 +55,6 @@ A repository can be supplied as an argument in any of the following formats: - by URL, e.g. "https://github.com/OWNER/REPO"`}, } -var repoCloneCmd = &cobra.Command{ - Use: "clone []", - Args: cobra.MinimumNArgs(1), - Short: "Clone a repository locally", - Long: `Clone a GitHub repository locally. - -If the "OWNER/" portion of the "OWNER/REPO" repository argument is omitted, it -defaults to the name of the authenticating user. - -To pass 'git clone' flags, separate them with '--'.`, - RunE: repoClone, -} - var repoCreateCmd = &cobra.Command{ Use: "create []", Short: "Create a new repository", @@ -104,17 +84,6 @@ With no argument, creates a fork of the current repository. Otherwise, forks the RunE: repoFork, } -var repoViewCmd = &cobra.Command{ - Use: "view []", - Short: "View a repository", - Long: `Display the description and the README of a GitHub repository. - -With no argument, the repository for the current directory is displayed. - -With '--web', open the repository in a web browser instead.`, - RunE: repoView, -} - var repoCreditsCmd = &cobra.Command{ Use: "credits []", Short: "View credits for a repository", @@ -136,104 +105,6 @@ var repoCreditsCmd = &cobra.Command{ Hidden: true, } -func parseCloneArgs(extraArgs []string) (args []string, target string) { - args = extraArgs - - if len(args) > 0 { - if !strings.HasPrefix(args[0], "-") { - target, args = args[0], args[1:] - } - } - return -} - -func runClone(cloneURL string, args []string) (target string, err error) { - cloneArgs, target := parseCloneArgs(args) - - cloneArgs = append(cloneArgs, cloneURL) - - // If the args contain an explicit target, pass it to clone - // otherwise, parse the URL to determine where git cloned it to so we can return it - if target != "" { - cloneArgs = append(cloneArgs, target) - } else { - target = path.Base(strings.TrimSuffix(cloneURL, ".git")) - } - - cloneArgs = append([]string{"clone"}, cloneArgs...) - - cloneCmd := git.GitCommand(cloneArgs...) - cloneCmd.Stdin = os.Stdin - cloneCmd.Stdout = os.Stdout - cloneCmd.Stderr = os.Stderr - - err = run.PrepareCmd(cloneCmd).Run() - return -} - -func repoClone(cmd *cobra.Command, args []string) error { - ctx := contextForCommand(cmd) - apiClient, err := apiClientForContext(ctx) - if err != nil { - return err - } - - cloneURL := args[0] - if !strings.Contains(cloneURL, ":") { - if !strings.Contains(cloneURL, "/") { - currentUser, err := api.CurrentLoginName(apiClient) - if err != nil { - return err - } - cloneURL = currentUser + "/" + cloneURL - } - repo, err := ghrepo.FromFullName(cloneURL) - if err != nil { - return err - } - cloneURL = formatRemoteURL(cmd, repo) - } - - var repo ghrepo.Interface - var parentRepo ghrepo.Interface - - // TODO: consider caching and reusing `git.ParseSSHConfig().Translator()` - // here to handle hostname aliases in SSH remotes - if u, err := git.ParseURL(cloneURL); err == nil { - repo, _ = ghrepo.FromURL(u) - } - - if repo != nil { - parentRepo, err = api.RepoParent(apiClient, repo) - if err != nil { - return err - } - } - - cloneDir, err := runClone(cloneURL, args[1:]) - if err != nil { - return err - } - - if parentRepo != nil { - err := addUpstreamRemote(cmd, parentRepo, cloneDir) - if err != nil { - return err - } - } - - return nil -} - -func addUpstreamRemote(cmd *cobra.Command, parentRepo ghrepo.Interface, cloneDir string) error { - upstreamURL := formatRemoteURL(cmd, parentRepo) - - cloneCmd := git.GitCommand("-C", cloneDir, "remote", "add", "-f", "upstream", upstreamURL) - cloneCmd.Stdout = os.Stdout - cloneCmd.Stderr = os.Stderr - return run.PrepareCmd(cloneCmd).Run() -} - func repoCreate(cmd *cobra.Command, args []string) error { projectDir, projectDirErr := git.ToplevelDir() @@ -369,10 +240,6 @@ func repoCreate(cmd *cobra.Command, args []string) error { 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) } @@ -406,7 +273,7 @@ func repoFork(cmd *cobra.Command, args []string) error { } else { repoArg := args[0] - if isURL(repoArg) { + if utils.IsURL(repoArg) { parsedURL, err := url.Parse(repoArg) if err != nil { return fmt.Errorf("did not understand argument: %w", err) @@ -549,12 +416,24 @@ func repoFork(cmd *cobra.Command, args []string) error { } if cloneDesired { forkedRepoCloneURL := formatRemoteURL(cmd, forkedRepo) - cloneDir, err := runClone(forkedRepoCloneURL, []string{}) + cloneDir, err := git.RunClone(forkedRepoCloneURL, []string{}) if err != nil { return fmt.Errorf("failed to clone fork: %w", err) } - err = addUpstreamRemote(cmd, repoToFork, cloneDir) + // TODO This is overly wordy and I'd like to streamline this. + cfg, err := ctx.Config() + if err != nil { + return err + } + protocol, err := cfg.Get("", "git_protocol") + if err != nil { + return err + } + + upstreamURL := ghrepo.FormatRemoteURL(repoToFork, protocol) + + err = git.AddUpstreamRemote(upstreamURL, cloneDir) if err != nil { return err } @@ -576,145 +455,6 @@ var Confirm = func(prompt string, result *bool) error { return survey.AskOne(p, result) } -func repoView(cmd *cobra.Command, args []string) error { - ctx := contextForCommand(cmd) - apiClient, err := apiClientForContext(ctx) - if err != nil { - return err - } - - var toView ghrepo.Interface - if len(args) == 0 { - var err error - toView, err = determineBaseRepo(apiClient, cmd, ctx) - if err != nil { - return err - } - } else { - repoArg := args[0] - if isURL(repoArg) { - parsedURL, err := url.Parse(repoArg) - if err != nil { - return fmt.Errorf("did not understand argument: %w", err) - } - - toView, err = ghrepo.FromURL(parsedURL) - if err != nil { - return fmt.Errorf("did not understand argument: %w", err) - } - } else { - var err error - toView, err = ghrepo.FromFullName(repoArg) - if err != nil { - return fmt.Errorf("argument error: %w", err) - } - } - } - - repo, err := api.GitHubRepo(apiClient, toView) - if err != nil { - return err - } - - web, err := cmd.Flags().GetBool("web") - if err != nil { - return err - } - - openURL := generateRepoURL(toView, "") - if web { - if connectedToTerminal(cmd) { - fmt.Fprintf(cmd.ErrOrStderr(), "Opening %s in your browser.\n", displayURL(openURL)) - } - return utils.OpenInBrowser(openURL) - } - - fullName := ghrepo.FullName(toView) - - if !connectedToTerminal(cmd) { - readme, err := api.RepositoryReadme(apiClient, toView) - if err != nil { - return err - } - - out := cmd.OutOrStdout() - fmt.Fprintf(out, "name:\t%s\n", fullName) - fmt.Fprintf(out, "description:\t%s\n", repo.Description) - fmt.Fprintln(out, "--") - fmt.Fprintf(out, readme.Content) - - return nil - } - - repoTmpl := ` -{{.FullName}} -{{.Description}} - -{{.Readme}} - -{{.View}} -` - - tmpl, err := template.New("repo").Parse(repoTmpl) - if err != nil { - return err - } - - readme, err := api.RepositoryReadme(apiClient, toView) - var notFound *api.NotFoundError - if err != nil && !errors.As(err, ¬Found) { - return err - } - - var readmeContent string - if readme == nil { - readmeContent = utils.Gray("This repository does not have a README") - } else if isMarkdownFile(readme.Filename) { - var err error - readmeContent, err = utils.RenderMarkdown(readme.Content) - if err != nil { - return fmt.Errorf("error rendering markdown: %w", err) - } - } else { - readmeContent = readme.Content - } - - description := repo.Description - if description == "" { - description = utils.Gray("No description provided") - } - - repoData := struct { - FullName string - Description string - Readme string - View string - }{ - FullName: utils.Bold(fullName), - Description: description, - Readme: readmeContent, - View: utils.Gray(fmt.Sprintf("View this repository on GitHub: %s", openURL)), - } - - out := colorableOut(cmd) - - err = tmpl.Execute(out, repoData) - if err != nil { - return err - } - - return nil -} - func repoCredits(cmd *cobra.Command, args []string) error { return credits(cmd, args) } - -func isMarkdownFile(filename string) bool { - // kind of gross, but i'm assuming that 90% of the time the suffix will just be .md. it didn't - // seem worth executing a regex for this given that assumption. - return strings.HasSuffix(filename, ".md") || - strings.HasSuffix(filename, ".markdown") || - strings.HasSuffix(filename, ".mdown") || - strings.HasSuffix(filename, ".mkdown") -} diff --git a/command/repo_test.go b/command/repo_test.go index 8c8e2cf31..50375dbcd 100644 --- a/command/repo_test.go +++ b/command/repo_test.go @@ -4,7 +4,6 @@ import ( "encoding/json" "io/ioutil" "os/exec" - "reflect" "regexp" "strings" "testing" @@ -437,190 +436,6 @@ func TestRepoFork_in_parent_survey_no(t *testing.T) { } } -func TestParseExtraArgs(t *testing.T) { - type Wanted struct { - args []string - dir string - } - tests := []struct { - name string - args []string - want Wanted - }{ - { - name: "args and target", - args: []string{"target_directory", "-o", "upstream", "--depth", "1"}, - want: Wanted{ - args: []string{"-o", "upstream", "--depth", "1"}, - dir: "target_directory", - }, - }, - { - name: "only args", - args: []string{"-o", "upstream", "--depth", "1"}, - want: Wanted{ - args: []string{"-o", "upstream", "--depth", "1"}, - dir: "", - }, - }, - { - name: "only target", - args: []string{"target_directory"}, - want: Wanted{ - args: []string{}, - dir: "target_directory", - }, - }, - { - name: "no args", - args: []string{}, - want: Wanted{ - args: []string{}, - dir: "", - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - args, dir := parseCloneArgs(tt.args) - got := Wanted{ - args: args, - dir: dir, - } - - if !reflect.DeepEqual(got, tt.want) { - t.Errorf("got %#v want %#v", got, tt.want) - } - }) - } - -} - -func TestRepoClone(t *testing.T) { - tests := []struct { - name string - args string - want string - }{ - { - name: "shorthand", - args: "repo clone OWNER/REPO", - want: "git clone https://github.com/OWNER/REPO.git", - }, - { - name: "shorthand with directory", - args: "repo clone OWNER/REPO target_directory", - want: "git clone https://github.com/OWNER/REPO.git target_directory", - }, - { - name: "clone arguments", - args: "repo clone OWNER/REPO -- -o upstream --depth 1", - want: "git clone -o upstream --depth 1 https://github.com/OWNER/REPO.git", - }, - { - name: "clone arguments with directory", - args: "repo clone OWNER/REPO target_directory -- -o upstream --depth 1", - want: "git clone -o upstream --depth 1 https://github.com/OWNER/REPO.git target_directory", - }, - { - name: "HTTPS URL", - args: "repo clone https://github.com/OWNER/REPO", - want: "git clone https://github.com/OWNER/REPO", - }, - { - name: "SSH URL", - args: "repo clone git@github.com:OWNER/REPO.git", - want: "git clone git@github.com:OWNER/REPO.git", - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - http := initFakeHTTP() - http.Register( - httpmock.GraphQL(`query RepositoryFindParent\b`), - httpmock.StringResponse(` - { "data": { "repository": { - "parent": null - } } } - `)) - - cs, restore := test.InitCmdStubber() - defer restore() - - cs.Stub("") // git clone - - output, err := RunCommand(tt.args) - if err != nil { - t.Fatalf("error running command `repo clone`: %v", err) - } - - eq(t, output.String(), "") - eq(t, output.Stderr(), "") - eq(t, cs.Count, 1) - eq(t, strings.Join(cs.Calls[0].Args, " "), tt.want) - }) - } -} - -func TestRepoClone_hasParent(t *testing.T) { - http := initFakeHTTP() - http.Register( - httpmock.GraphQL(`query RepositoryFindParent\b`), - httpmock.StringResponse(` - { "data": { "repository": { - "parent": { - "owner": {"login": "hubot"}, - "name": "ORIG" - } - } } } - `)) - - cs, restore := test.InitCmdStubber() - defer restore() - - cs.Stub("") // git clone - cs.Stub("") // git remote add - - _, err := RunCommand("repo clone OWNER/REPO") - if err != nil { - t.Fatalf("error running command `repo clone`: %v", err) - } - - eq(t, cs.Count, 2) - eq(t, strings.Join(cs.Calls[1].Args, " "), "git -C REPO remote add -f upstream https://github.com/hubot/ORIG.git") -} - -func TestRepo_withoutUsername(t *testing.T) { - http := initFakeHTTP() - http.Register( - httpmock.GraphQL(`query UserCurrent\b`), - httpmock.StringResponse(` - { "data": { "viewer": { - "login": "OWNER" - }}}`)) - http.Register( - httpmock.GraphQL(`query RepositoryFindParent\b`), - httpmock.StringResponse(` - { "data": { "repository": { - "parent": null - } } }`)) - - cs, restore := test.InitCmdStubber() - defer restore() - - cs.Stub("") // git clone - - output, err := RunCommand("repo clone REPO") - if err != nil { - t.Fatalf("error running command `repo clone`: %v", err) - } - - eq(t, output.String(), "") - eq(t, output.Stderr(), "") - eq(t, cs.Count, 1) - eq(t, strings.Join(cs.Calls[0].Args, " "), "git clone https://github.com/OWNER/REPO.git") -} - func TestRepoCreate(t *testing.T) { ctx := context.NewBlank() ctx.SetBranch("master") @@ -827,221 +642,3 @@ func TestRepoCreate_orgWithTeam(t *testing.T) { t.Errorf("expected %q, got %q", "TEAMID", teamID) } } - -func TestRepoView_web(t *testing.T) { - initBlankContext("", "OWNER/REPO", "master") - http := initFakeHTTP() - http.StubRepoResponse("OWNER", "REPO") - http.Register( - httpmock.GraphQL(`query RepositoryInfo\b`), - httpmock.StringResponse(` - { }`)) - - var seenCmd *exec.Cmd - restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable { - seenCmd = cmd - return &test.OutputStub{} - }) - defer restoreCmd() - - defer stubTerminal(true)() - - output, err := RunCommand("repo view -w") - if err != nil { - t.Errorf("error running command `repo view`: %v", err) - } - - eq(t, output.String(), "") - eq(t, output.Stderr(), "Opening github.com/OWNER/REPO in your browser.\n") - - if seenCmd == nil { - t.Fatal("expected a command to run") - } - url := seenCmd.Args[len(seenCmd.Args)-1] - eq(t, url, "https://github.com/OWNER/REPO") -} - -func TestRepoView_web_ownerRepo(t *testing.T) { - ctx := context.NewBlank() - ctx.SetBranch("master") - initContext = func() context.Context { - return ctx - } - http := initFakeHTTP() - http.Register( - httpmock.GraphQL(`query RepositoryInfo\b`), - httpmock.StringResponse(` - { }`)) - - var seenCmd *exec.Cmd - restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable { - seenCmd = cmd - return &test.OutputStub{} - }) - defer restoreCmd() - defer stubTerminal(true)() - - output, err := RunCommand("repo view -w cli/cli") - if err != nil { - t.Errorf("error running command `repo view`: %v", err) - } - - eq(t, output.String(), "") - eq(t, output.Stderr(), "Opening github.com/cli/cli in your browser.\n") - - if seenCmd == nil { - t.Fatal("expected a command to run") - } - url := seenCmd.Args[len(seenCmd.Args)-1] - eq(t, url, "https://github.com/cli/cli") -} - -func TestRepoView_web_fullURL(t *testing.T) { - ctx := context.NewBlank() - ctx.SetBranch("master") - initContext = func() context.Context { - return ctx - } - http := initFakeHTTP() - http.Register( - httpmock.GraphQL(`query RepositoryInfo\b`), - httpmock.StringResponse(` - { }`)) - var seenCmd *exec.Cmd - restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable { - seenCmd = cmd - return &test.OutputStub{} - }) - defer restoreCmd() - - defer stubTerminal(true)() - output, err := RunCommand("repo view -w https://github.com/cli/cli") - if err != nil { - t.Errorf("error running command `repo view`: %v", err) - } - - eq(t, output.String(), "") - eq(t, output.Stderr(), "Opening github.com/cli/cli in your browser.\n") - - if seenCmd == nil { - t.Fatal("expected a command to run") - } - url := seenCmd.Args[len(seenCmd.Args)-1] - eq(t, url, "https://github.com/cli/cli") -} - -func TestRepoView(t *testing.T) { - initBlankContext("", "OWNER/REPO", "master") - http := initFakeHTTP() - http.StubRepoResponse("OWNER", "REPO") - http.Register( - httpmock.GraphQL(`query RepositoryInfo\b`), - httpmock.StringResponse(` - { "data": { - "repository": { - "description": "social distancing" - } } }`)) - http.Register( - httpmock.REST("GET", "repos/OWNER/REPO/readme"), - httpmock.StringResponse(` - { "name": "readme.md", - "content": "IyB0cnVseSBjb29sIHJlYWRtZSBjaGVjayBpdCBvdXQ="}`)) - - defer stubTerminal(true)() - - output, err := RunCommand("repo view") - if err != nil { - t.Errorf("error running command `repo view`: %v", err) - } - - test.ExpectLines(t, output.String(), - "OWNER/REPO", - "social distancing", - "truly cool readme", - "View this repository on GitHub: https://github.com/OWNER/REPO") -} - -func TestRepoView_nonmarkdown_readme(t *testing.T) { - initBlankContext("", "OWNER/REPO", "master") - http := initFakeHTTP() - http.StubRepoResponse("OWNER", "REPO") - http.Register( - httpmock.GraphQL(`query RepositoryInfo\b`), - httpmock.StringResponse(` - { "data": { - "repository": { - "description": "social distancing" - } } }`)) - http.Register( - httpmock.REST("GET", "repos/OWNER/REPO/readme"), - httpmock.StringResponse(` - { "name": "readme.org", - "content": "IyB0cnVseSBjb29sIHJlYWRtZSBjaGVjayBpdCBvdXQ="}`)) - - defer stubTerminal(true)() - - output, err := RunCommand("repo view") - if err != nil { - t.Errorf("error running command `repo view`: %v", err) - } - - test.ExpectLines(t, output.String(), - "OWNER/REPO", - "social distancing", - "# truly cool readme", - "View this repository on GitHub: https://github.com/OWNER/REPO") -} - -func TestRepoView_blanks(t *testing.T) { - initBlankContext("", "OWNER/REPO", "master") - http := initFakeHTTP() - http.StubRepoResponse("OWNER", "REPO") - http.Register(httpmock.GraphQL(`query RepositoryInfo\b`), httpmock.StringResponse("{}")) - http.Register( - httpmock.REST("GET", "repos/OWNER/REPO/readme"), - httpmock.StatusStringResponse(404, `{}`)) - - defer stubTerminal(true)() - - output, err := RunCommand("repo view") - if err != nil { - t.Errorf("error running command `repo view`: %v", err) - } - - test.ExpectLines(t, output.String(), - "OWNER/REPO", - "No description provided", - "This repository does not have a README", - "View this repository on GitHub: https://github.com/OWNER/REPO") -} - -func TestRepoView_nontty(t *testing.T) { - initBlankContext("", "OWNER/REPO", "master") - http := initFakeHTTP() - http.StubRepoResponse("OWNER", "REPO") - http.Register( - httpmock.GraphQL(`query RepositoryInfo\b`), - httpmock.StringResponse(` - { "data": { - "repository": { - "description": "social distancing" - } } }`)) - http.Register( - httpmock.REST("GET", "repos/OWNER/REPO/readme"), - httpmock.StringResponse(` - { "name": "readme.md", - "content": "IyB0cnVseSBjb29sIHJlYWRtZSBjaGVjayBpdCBvdXQ="}`)) - - defer stubTerminal(false)() - - output, err := RunCommand("repo view") - if err != nil { - t.Errorf("error running command `repo view`: %v", err) - } - - test.ExpectLines(t, output.String(), - "OWNER/REPO", - "social distancing", - "# truly cool readme check it out", - ) -} diff --git a/command/root.go b/command/root.go index 01f7d524a..fdd1c8d11 100644 --- a/command/root.go +++ b/command/root.go @@ -21,6 +21,8 @@ import ( "github.com/cli/cli/internal/run" apiCmd "github.com/cli/cli/pkg/cmd/api" gistCreateCmd "github.com/cli/cli/pkg/cmd/gist/create" + repoCloneCmd "github.com/cli/cli/pkg/cmd/repo/clone" + repoViewCmd "github.com/cli/cli/pkg/cmd/repo/view" "github.com/cli/cli/pkg/cmdutil" "github.com/cli/cli/pkg/iostreams" "github.com/cli/cli/utils" @@ -94,6 +96,15 @@ func init() { ctx := context.New() return ctx.BaseRepo() }, + Config: func() (config.Config, error) { + cfg, err := config.ParseDefaultConfig() + if errors.Is(err, os.ErrNotExist) { + cfg = config.NewBlankConfig() + } else if err != nil { + return nil, err + } + return cfg, nil + }, } RootCmd.AddCommand(apiCmd.NewCmdApi(cmdFactory, nil)) @@ -104,6 +115,39 @@ func init() { } RootCmd.AddCommand(gistCmd) gistCmd.AddCommand(gistCreateCmd.NewCmdCreate(cmdFactory, nil)) + + resolvedBaseRepo := func() (ghrepo.Interface, error) { + httpClient, err := cmdFactory.HttpClient() + if err != nil { + return nil, err + } + + apiClient := api.NewClientFromHTTP(httpClient) + + ctx := context.New() + remotes, err := ctx.Remotes() + if err != nil { + return nil, err + } + repoContext, err := context.ResolveRemotesToRepos(remotes, apiClient, "") + if err != nil { + return nil, err + } + baseRepo, err := repoContext.BaseRepo() + if err != nil { + return nil, err + } + + return baseRepo, nil + } + + repoResolvingCmdFactory := *cmdFactory + + repoResolvingCmdFactory.BaseRepo = resolvedBaseRepo + + RootCmd.AddCommand(repoCmd) + repoCmd.AddCommand(repoViewCmd.NewCmdView(&repoResolvingCmdFactory, nil)) + repoCmd.AddCommand(repoCloneCmd.NewCmdClone(cmdFactory, nil)) } // RootCmd is the entry point of command-line execution @@ -322,6 +366,7 @@ func determineBaseRepo(apiClient *api.Client, cmd *cobra.Command, ctx context.Co return baseRepo, nil } +// TODO there is a parallel implementation for isolated commands func formatRemoteURL(cmd *cobra.Command, repo ghrepo.Interface) string { ctx := contextForCommand(cmd) diff --git a/git/git.go b/git/git.go index ff40adde3..645a64fd1 100644 --- a/git/git.go +++ b/git/git.go @@ -7,6 +7,7 @@ import ( "net/url" "os" "os/exec" + "path" "regexp" "strings" @@ -224,6 +225,48 @@ func CheckoutBranch(branch string) error { return err } +func parseCloneArgs(extraArgs []string) (args []string, target string) { + args = extraArgs + + if len(args) > 0 { + if !strings.HasPrefix(args[0], "-") { + target, args = args[0], args[1:] + } + } + return +} + +func RunClone(cloneURL string, args []string) (target string, err error) { + cloneArgs, target := parseCloneArgs(args) + + cloneArgs = append(cloneArgs, cloneURL) + + // If the args contain an explicit target, pass it to clone + // otherwise, parse the URL to determine where git cloned it to so we can return it + if target != "" { + cloneArgs = append(cloneArgs, target) + } else { + target = path.Base(strings.TrimSuffix(cloneURL, ".git")) + } + + cloneArgs = append([]string{"clone"}, cloneArgs...) + + cloneCmd := GitCommand(cloneArgs...) + cloneCmd.Stdin = os.Stdin + cloneCmd.Stdout = os.Stdout + cloneCmd.Stderr = os.Stderr + + err = run.PrepareCmd(cloneCmd).Run() + return +} + +func AddUpstreamRemote(upstreamURL, cloneDir string) error { + cloneCmd := GitCommand("-C", cloneDir, "remote", "add", "-f", "upstream", upstreamURL) + cloneCmd.Stdout = os.Stdout + cloneCmd.Stderr = os.Stderr + return run.PrepareCmd(cloneCmd).Run() +} + func isFilesystemPath(p string) bool { return p == "." || strings.HasPrefix(p, "./") || strings.HasPrefix(p, "/") } diff --git a/git/git_test.go b/git/git_test.go index c03e7153d..537054012 100644 --- a/git/git_test.go +++ b/git/git_test.go @@ -2,6 +2,7 @@ package git import ( "os/exec" + "reflect" "testing" "github.com/cli/cli/internal/run" @@ -94,3 +95,62 @@ func Test_CurrentBranch_unexpected_error(t *testing.T) { t.Errorf("expected 1 git call, saw %d", len(cs.Calls)) } } + +func TestParseExtraCloneArgs(t *testing.T) { + type Wanted struct { + args []string + dir string + } + tests := []struct { + name string + args []string + want Wanted + }{ + { + name: "args and target", + args: []string{"target_directory", "-o", "upstream", "--depth", "1"}, + want: Wanted{ + args: []string{"-o", "upstream", "--depth", "1"}, + dir: "target_directory", + }, + }, + { + name: "only args", + args: []string{"-o", "upstream", "--depth", "1"}, + want: Wanted{ + args: []string{"-o", "upstream", "--depth", "1"}, + dir: "", + }, + }, + { + name: "only target", + args: []string{"target_directory"}, + want: Wanted{ + args: []string{}, + dir: "target_directory", + }, + }, + { + name: "no args", + args: []string{}, + want: Wanted{ + args: []string{}, + dir: "", + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + args, dir := parseCloneArgs(tt.args) + got := Wanted{ + args: args, + dir: dir, + } + + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("got %#v want %#v", got, tt.want) + } + }) + } + +} diff --git a/internal/ghrepo/repo.go b/internal/ghrepo/repo.go index fbe748204..b80b09219 100644 --- a/internal/ghrepo/repo.go +++ b/internal/ghrepo/repo.go @@ -77,6 +77,23 @@ func IsSame(a, b Interface) bool { normalizeHostname(a.RepoHost()) == normalizeHostname(b.RepoHost()) } +func GenerateRepoURL(repo Interface, p string, args ...interface{}) string { + baseURL := fmt.Sprintf("https://%s/%s/%s", repo.RepoHost(), repo.RepoOwner(), repo.RepoName()) + if p != "" { + return baseURL + "/" + fmt.Sprintf(p, args...) + } + return baseURL +} + +// TODO there is a parallel implementation for non-isolated commands +func FormatRemoteURL(repo Interface, protocol string) string { + if protocol == "ssh" { + return fmt.Sprintf("git@%s:%s/%s.git", repo.RepoHost(), repo.RepoOwner(), repo.RepoName()) + } + + return fmt.Sprintf("https://%s/%s/%s.git", repo.RepoHost(), repo.RepoOwner(), repo.RepoName()) +} + type ghRepo struct { owner string name string diff --git a/pkg/cmd/gist/create/create.go b/pkg/cmd/gist/create/create.go index d4fcd276c..76cdd1463 100644 --- a/pkg/cmd/gist/create/create.go +++ b/pkg/cmd/gist/create/create.go @@ -70,8 +70,6 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co return nil }, RunE: func(c *cobra.Command, args []string) error { - opts.HttpClient = f.HttpClient - opts.Filenames = args if runF != nil { diff --git a/pkg/cmd/repo/clone/clone.go b/pkg/cmd/repo/clone/clone.go new file mode 100644 index 000000000..30209b0cc --- /dev/null +++ b/pkg/cmd/repo/clone/clone.go @@ -0,0 +1,126 @@ +package clone + +import ( + "net/http" + "strings" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/api" + "github.com/cli/cli/git" + "github.com/cli/cli/internal/config" + "github.com/cli/cli/internal/ghrepo" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/iostreams" + "github.com/spf13/cobra" +) + +type CloneOptions struct { + HttpClient func() (*http.Client, error) + Config func() (config.Config, error) + IO *iostreams.IOStreams + + GitArgs []string + Directory string + Repository string +} + +func NewCmdClone(f *cmdutil.Factory, runF func(*CloneOptions) error) *cobra.Command { + opts := &CloneOptions{ + IO: f.IOStreams, + HttpClient: f.HttpClient, + Config: f.Config, + } + + cmd := &cobra.Command{ + Use: "clone []", + Args: cobra.MinimumNArgs(1), + Short: "Clone a repository locally", + Long: heredoc.Doc( + `Clone a GitHub repository locally. + + If the "OWNER/" portion of the "OWNER/REPO" repository argument is omitted, it + defaults to the name of the authenticating user. + + To pass 'git clone' flags, separate them with '--'. + `), + RunE: func(cmd *cobra.Command, args []string) error { + opts.Repository = args[0] + opts.GitArgs = args[1:] + + if runF != nil { + return runF(opts) + } + + return cloneRun(opts) + }, + } + + return cmd +} + +func cloneRun(opts *CloneOptions) error { + httpClient, err := opts.HttpClient() + if err != nil { + return err + } + + // TODO This is overly wordy and I'd like to streamline this. + cfg, err := opts.Config() + if err != nil { + return err + } + protocol, err := cfg.Get("", "git_protocol") + if err != nil { + return err + } + + apiClient := api.NewClientFromHTTP(httpClient) + cloneURL := opts.Repository + if !strings.Contains(cloneURL, ":") { + if !strings.Contains(cloneURL, "/") { + currentUser, err := api.CurrentLoginName(apiClient) + if err != nil { + return err + } + cloneURL = currentUser + "/" + cloneURL + } + repo, err := ghrepo.FromFullName(cloneURL) + if err != nil { + return err + } + + cloneURL = ghrepo.FormatRemoteURL(repo, protocol) + } + + var repo ghrepo.Interface + var parentRepo ghrepo.Interface + + // TODO: consider caching and reusing `git.ParseSSHConfig().Translator()` + // here to handle hostname aliases in SSH remotes + if u, err := git.ParseURL(cloneURL); err == nil { + repo, _ = ghrepo.FromURL(u) + } + + if repo != nil { + parentRepo, err = api.RepoParent(apiClient, repo) + if err != nil { + return err + } + } + + cloneDir, err := git.RunClone(cloneURL, opts.GitArgs) + if err != nil { + return err + } + + if parentRepo != nil { + upstreamURL := ghrepo.FormatRemoteURL(parentRepo, protocol) + + err := git.AddUpstreamRemote(upstreamURL, cloneDir) + if err != nil { + return err + } + } + + return nil +} diff --git a/pkg/cmd/repo/clone/clone_test.go b/pkg/cmd/repo/clone/clone_test.go new file mode 100644 index 000000000..0ceb17050 --- /dev/null +++ b/pkg/cmd/repo/clone/clone_test.go @@ -0,0 +1,195 @@ +package clone + +import ( + "bytes" + "net/http" + "strings" + "testing" + + "github.com/cli/cli/internal/config" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/httpmock" + "github.com/cli/cli/pkg/iostreams" + "github.com/cli/cli/test" + "github.com/google/shlex" + "github.com/stretchr/testify/assert" +) + +// TODO copypasta from command package +type cmdOut struct { + outBuf, errBuf *bytes.Buffer +} + +func (c cmdOut) String() string { + return c.outBuf.String() +} + +func (c cmdOut) Stderr() string { + return c.errBuf.String() +} + +func runCloneCommand(httpClient *http.Client, cli string) (*cmdOut, error) { + io, stdin, stdout, stderr := iostreams.Test() + fac := &cmdutil.Factory{ + IOStreams: io, + HttpClient: func() (*http.Client, error) { + return httpClient, nil + }, + Config: func() (config.Config, error) { + return config.NewBlankConfig(), nil + }, + } + + cmd := NewCmdClone(fac, nil) + + argv, err := shlex.Split(cli) + cmd.SetArgs(argv) + + cmd.SetIn(stdin) + cmd.SetOut(stdout) + cmd.SetErr(stderr) + + if err != nil { + panic(err) + } + + _, err = cmd.ExecuteC() + + if err != nil { + return nil, err + } + + return &cmdOut{stdout, stderr}, nil +} + +func Test_RepoClone(t *testing.T) { + tests := []struct { + name string + args string + want string + }{ + { + name: "shorthand", + args: "OWNER/REPO", + want: "git clone https://github.com/OWNER/REPO.git", + }, + { + name: "shorthand with directory", + args: "OWNER/REPO target_directory", + want: "git clone https://github.com/OWNER/REPO.git target_directory", + }, + { + name: "clone arguments", + args: "OWNER/REPO -- -o upstream --depth 1", + want: "git clone -o upstream --depth 1 https://github.com/OWNER/REPO.git", + }, + { + name: "clone arguments with directory", + args: "OWNER/REPO target_directory -- -o upstream --depth 1", + want: "git clone -o upstream --depth 1 https://github.com/OWNER/REPO.git target_directory", + }, + { + name: "HTTPS URL", + args: "https://github.com/OWNER/REPO", + want: "git clone https://github.com/OWNER/REPO", + }, + { + name: "SSH URL", + args: "git@github.com:OWNER/REPO.git", + want: "git clone git@github.com:OWNER/REPO.git", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reg := &httpmock.Registry{} + reg.Register( + httpmock.GraphQL(`query RepositoryFindParent\b`), + httpmock.StringResponse(` + { "data": { "repository": { + "parent": null + } } } + `)) + + httpClient := &http.Client{Transport: reg} + + cs, restore := test.InitCmdStubber() + defer restore() + + cs.Stub("") // git clone + + output, err := runCloneCommand(httpClient, tt.args) + if err != nil { + t.Fatalf("error running command `repo clone`: %v", err) + } + + assert.Equal(t, "", output.String()) + assert.Equal(t, "", output.Stderr()) + assert.Equal(t, 1, cs.Count) + assert.Equal(t, tt.want, strings.Join(cs.Calls[0].Args, " ")) + reg.Verify(t) + }) + } +} + +func Test_RepoClone_hasParent(t *testing.T) { + reg := &httpmock.Registry{} + reg.Register( + httpmock.GraphQL(`query RepositoryFindParent\b`), + httpmock.StringResponse(` + { "data": { "repository": { + "parent": { + "owner": {"login": "hubot"}, + "name": "ORIG" + } + } } } + `)) + + httpClient := &http.Client{Transport: reg} + + cs, restore := test.InitCmdStubber() + defer restore() + + cs.Stub("") // git clone + cs.Stub("") // git remote add + + _, err := runCloneCommand(httpClient, "OWNER/REPO") + if err != nil { + t.Fatalf("error running command `repo clone`: %v", err) + } + + assert.Equal(t, 2, cs.Count) + assert.Equal(t, "git -C REPO remote add -f upstream https://github.com/hubot/ORIG.git", strings.Join(cs.Calls[1].Args, " ")) +} + +func Test_RepoClone_withoutUsername(t *testing.T) { + reg := &httpmock.Registry{} + reg.Register( + httpmock.GraphQL(`query UserCurrent\b`), + httpmock.StringResponse(` + { "data": { "viewer": { + "login": "OWNER" + }}}`)) + reg.Register( + httpmock.GraphQL(`query RepositoryFindParent\b`), + httpmock.StringResponse(` + { "data": { "repository": { + "parent": null + } } }`)) + + httpClient := &http.Client{Transport: reg} + + cs, restore := test.InitCmdStubber() + defer restore() + + cs.Stub("") // git clone + + output, err := runCloneCommand(httpClient, "REPO") + if err != nil { + t.Fatalf("error running command `repo clone`: %v", err) + } + + assert.Equal(t, "", output.String()) + assert.Equal(t, "", output.Stderr()) + assert.Equal(t, 1, cs.Count) + assert.Equal(t, "git clone https://github.com/OWNER/REPO.git", strings.Join(cs.Calls[0].Args, " ")) +} diff --git a/pkg/cmd/repo/view/http.go b/pkg/cmd/repo/view/http.go new file mode 100644 index 000000000..343efe7af --- /dev/null +++ b/pkg/cmd/repo/view/http.go @@ -0,0 +1,45 @@ +package view + +import ( + "encoding/base64" + "errors" + "fmt" + "net/http" + + "github.com/cli/cli/api" + "github.com/cli/cli/internal/ghrepo" +) + +var NotFoundError = errors.New("not found") + +type RepoReadme struct { + Filename string + Content string +} + +func RepositoryReadme(client *http.Client, repo ghrepo.Interface) (*RepoReadme, error) { + apiClient := api.NewClientFromHTTP(client) + var response struct { + Name string + Content string + } + + err := apiClient.REST("GET", fmt.Sprintf("repos/%s/readme", ghrepo.FullName(repo)), nil, &response) + if err != nil { + var httpError api.HTTPError + if errors.As(err, &httpError) && httpError.StatusCode == 404 { + return nil, NotFoundError + } + return nil, err + } + + decoded, err := base64.StdEncoding.DecodeString(response.Content) + if err != nil { + return nil, fmt.Errorf("failed to decode readme: %w", err) + } + + return &RepoReadme{ + Filename: response.Name, + Content: string(decoded), + }, nil +} diff --git a/pkg/cmd/repo/view/view.go b/pkg/cmd/repo/view/view.go new file mode 100644 index 000000000..f4741e9cd --- /dev/null +++ b/pkg/cmd/repo/view/view.go @@ -0,0 +1,188 @@ +package view + +import ( + "fmt" + "html/template" + "net/http" + "net/url" + "strings" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/api" + "github.com/cli/cli/internal/ghrepo" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/iostreams" + "github.com/cli/cli/utils" + "github.com/spf13/cobra" +) + +type ViewOptions struct { + HttpClient func() (*http.Client, error) + IO *iostreams.IOStreams + BaseRepo func() (ghrepo.Interface, error) + + RepoArg string + Web bool +} + +func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Command { + opts := ViewOptions{ + IO: f.IOStreams, + HttpClient: f.HttpClient, + BaseRepo: f.BaseRepo, + } + + cmd := &cobra.Command{ + Use: "view []", + Short: "View a repository", + Long: `Display the description and the README of a GitHub repository. + +With no argument, the repository for the current directory is displayed. + +With '--web', open the repository in a web browser instead.`, + Args: cobra.MaximumNArgs(1), + RunE: func(c *cobra.Command, args []string) error { + if len(args) > 0 { + opts.RepoArg = args[0] + } + if runF != nil { + return runF(&opts) + } + return viewRun(&opts) + }, + } + + cmd.Flags().BoolVarP(&opts.Web, "web", "w", false, "Open a repository in the browser") + + return cmd +} + +func viewRun(opts *ViewOptions) error { + httpClient, err := opts.HttpClient() + if err != nil { + return err + } + + var toView ghrepo.Interface + if opts.RepoArg == "" { + var err error + toView, err = opts.BaseRepo() + if err != nil { + return err + } + } else { + if utils.IsURL(opts.RepoArg) { + parsedURL, err := url.Parse(opts.RepoArg) + if err != nil { + return fmt.Errorf("did not understand argument: %w", err) + } + + toView, err = ghrepo.FromURL(parsedURL) + if err != nil { + return fmt.Errorf("did not understand argument: %w", err) + } + } else { + var err error + toView, err = ghrepo.FromFullName(opts.RepoArg) + if err != nil { + return fmt.Errorf("argument error: %w", err) + } + } + } + + apiClient := api.NewClientFromHTTP(httpClient) + + repo, err := api.GitHubRepo(apiClient, toView) + if err != nil { + return err + } + + openURL := ghrepo.GenerateRepoURL(toView, "") + if opts.Web { + if opts.IO.IsStdoutTTY() { + fmt.Fprintf(opts.IO.ErrOut, "Opening %s in your browser.\n", utils.DisplayURL(openURL)) + } + return utils.OpenInBrowser(openURL) + } + + fullName := ghrepo.FullName(toView) + + readme, err := RepositoryReadme(httpClient, toView) + if err != nil && err != NotFoundError { + return err + } + + stdout := opts.IO.Out + + if !opts.IO.IsStdoutTTY() { + fmt.Fprintf(stdout, "name:\t%s\n", fullName) + fmt.Fprintf(stdout, "description:\t%s\n", repo.Description) + if readme != nil { + fmt.Fprintln(stdout, "--") + fmt.Fprintf(stdout, readme.Content) + fmt.Fprintln(stdout) + } + + return nil + } + + repoTmpl := heredoc.Doc(` + {{.FullName}} + {{.Description}} + + {{.Readme}} + + {{.View}} + `) + + tmpl, err := template.New("repo").Parse(repoTmpl) + if err != nil { + return err + } + + var readmeContent string + if readme == nil { + readmeContent = utils.Gray("This repository does not have a README") + } else if isMarkdownFile(readme.Filename) { + var err error + readmeContent, err = utils.RenderMarkdown(readme.Content) + if err != nil { + return fmt.Errorf("error rendering markdown: %w", err) + } + } else { + readmeContent = readme.Content + } + + description := repo.Description + if description == "" { + description = utils.Gray("No description provided") + } + + repoData := struct { + FullName string + Description string + Readme string + View string + }{ + FullName: utils.Bold(fullName), + Description: description, + Readme: readmeContent, + View: utils.Gray(fmt.Sprintf("View this repository on GitHub: %s", openURL)), + } + + err = tmpl.Execute(stdout, repoData) + if err != nil { + return err + } + + return nil +} + +func isMarkdownFile(filename string) bool { + // kind of gross, but i'm assuming that 90% of the time the suffix will just be .md. it didn't + // seem worth executing a regex for this given that assumption. + return strings.HasSuffix(filename, ".md") || + strings.HasSuffix(filename, ".markdown") || + strings.HasSuffix(filename, ".mdown") || + strings.HasSuffix(filename, ".mkdown") +} diff --git a/pkg/cmd/repo/view/view_test.go b/pkg/cmd/repo/view/view_test.go new file mode 100644 index 000000000..5a6d08702 --- /dev/null +++ b/pkg/cmd/repo/view/view_test.go @@ -0,0 +1,471 @@ +package view + +import ( + "bytes" + "fmt" + "net/http" + "testing" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/internal/ghrepo" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/httpmock" + "github.com/cli/cli/pkg/iostreams" + "github.com/cli/cli/test" + "github.com/google/shlex" + "github.com/stretchr/testify/assert" +) + +func TestNewCmdView(t *testing.T) { + tests := []struct { + name string + cli string + wants ViewOptions + wantsErr bool + }{ + { + name: "no args", + cli: "", + wants: ViewOptions{ + RepoArg: "", + Web: false, + }, + }, + { + name: "sets repo arg", + cli: "some/repo", + wants: ViewOptions{ + RepoArg: "some/repo", + Web: false, + }, + }, + { + name: "sets web", + cli: "-w", + wants: ViewOptions{ + RepoArg: "", + Web: true, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + io, _, _, _ := iostreams.Test() + + f := &cmdutil.Factory{ + IOStreams: io, + } + + // THOUGHT: this seems ripe for cmdutil. It's almost identical to the set up for the same test + // in gist create. + argv, err := shlex.Split(tt.cli) + assert.NoError(t, err) + + var gotOpts *ViewOptions + cmd := NewCmdView(f, func(opts *ViewOptions) error { + gotOpts = opts + return nil + }) + cmd.SetArgs(argv) + cmd.SetIn(&bytes.Buffer{}) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + + _, err = cmd.ExecuteC() + if tt.wantsErr { + assert.Error(t, err) + return + } + assert.NoError(t, err) + + assert.Equal(t, tt.wants.Web, gotOpts.Web) + assert.Equal(t, tt.wants.RepoArg, gotOpts.RepoArg) + }) + } +} + +func Test_RepoView_Web(t *testing.T) { + tests := []struct { + name string + stdoutTTY bool + wantStderr string + }{ + { + name: "tty", + stdoutTTY: true, + wantStderr: "Opening github.com/OWNER/REPO in your browser.\n", + }, + { + name: "nontty", + wantStderr: "", + }, + } + + for _, tt := range tests { + reg := &httpmock.Registry{} + reg.Register( + httpmock.GraphQL(`query RepositoryInfo\b`), + httpmock.StringResponse(`{}`)) + + opts := &ViewOptions{ + Web: true, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + BaseRepo: func() (ghrepo.Interface, error) { + return ghrepo.New("OWNER", "REPO"), nil + }, + } + + io, _, stdout, stderr := iostreams.Test() + + opts.IO = io + + t.Run(tt.name, func(t *testing.T) { + io.SetStdoutTTY(tt.stdoutTTY) + + cs, teardown := test.InitCmdStubber() + defer teardown() + + cs.Stub("") // browser open + + if err := viewRun(opts); err != nil { + t.Errorf("viewRun() error = %v", err) + } + assert.Equal(t, "", stdout.String()) + assert.Equal(t, 1, len(cs.Calls)) + call := cs.Calls[0] + assert.Equal(t, "https://github.com/OWNER/REPO", call.Args[len(call.Args)-1]) + assert.Equal(t, tt.wantStderr, stderr.String()) + reg.Verify(t) + }) + } +} + +func Test_ViewRun(t *testing.T) { + tests := []struct { + name string + opts *ViewOptions + repoName string + stdoutTTY bool + wantOut string + wantStderr string + wantErr bool + }{ + { + name: "nontty", + wantOut: heredoc.Doc(` + name: OWNER/REPO + description: social distancing + -- + # truly cool readme check it out + `), + }, + { + name: "url arg", + repoName: "jill/valentine", + opts: &ViewOptions{ + RepoArg: "https://github.com/jill/valentine", + }, + stdoutTTY: true, + wantOut: heredoc.Doc(` + jill/valentine + social distancing + + + # truly cool readme check it out + + + + View this repository on GitHub: https://github.com/jill/valentine + `), + }, + { + name: "name arg", + repoName: "jill/valentine", + opts: &ViewOptions{ + RepoArg: "jill/valentine", + }, + stdoutTTY: true, + wantOut: heredoc.Doc(` + jill/valentine + social distancing + + + # truly cool readme check it out + + + + View this repository on GitHub: https://github.com/jill/valentine + `), + }, + { + name: "no args", + stdoutTTY: true, + wantOut: heredoc.Doc(` + OWNER/REPO + social distancing + + + # truly cool readme check it out + + + + View this repository on GitHub: https://github.com/OWNER/REPO + `), + }, + } + for _, tt := range tests { + if tt.opts == nil { + tt.opts = &ViewOptions{} + } + + if tt.repoName == "" { + tt.repoName = "OWNER/REPO" + } + + tt.opts.BaseRepo = func() (ghrepo.Interface, error) { + repo, _ := ghrepo.FromFullName(tt.repoName) + return repo, nil + } + + reg := &httpmock.Registry{} + reg.Register( + httpmock.GraphQL(`query RepositoryInfo\b`), + httpmock.StringResponse(` + { "data": { + "repository": { + "description": "social distancing" + } } }`)) + reg.Register( + httpmock.REST("GET", fmt.Sprintf("repos/%s/readme", tt.repoName)), + httpmock.StringResponse(` + { "name": "readme.md", + "content": "IyB0cnVseSBjb29sIHJlYWRtZSBjaGVjayBpdCBvdXQ="}`)) + + tt.opts.HttpClient = func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + } + + io, _, stdout, stderr := iostreams.Test() + tt.opts.IO = io + + t.Run(tt.name, func(t *testing.T) { + io.SetStdoutTTY(tt.stdoutTTY) + + if err := viewRun(tt.opts); (err != nil) != tt.wantErr { + t.Errorf("viewRun() error = %v, wantErr %v", err, tt.wantErr) + } + assert.Equal(t, tt.wantStderr, stderr.String()) + assert.Equal(t, tt.wantOut, stdout.String()) + reg.Verify(t) + }) + } +} + +func Test_ViewRun_NonMarkdownReadme(t *testing.T) { + tests := []struct { + name string + stdoutTTY bool + wantOut string + }{ + { + name: "tty", + wantOut: heredoc.Doc(` + OWNER/REPO + social distancing + + # truly cool readme check it out + + View this repository on GitHub: https://github.com/OWNER/REPO + `), + stdoutTTY: true, + }, + { + name: "nontty", + wantOut: heredoc.Doc(` + name: OWNER/REPO + description: social distancing + -- + # truly cool readme check it out + `), + }, + } + + for _, tt := range tests { + reg := &httpmock.Registry{} + reg.Register( + httpmock.GraphQL(`query RepositoryInfo\b`), + httpmock.StringResponse(` + { "data": { + "repository": { + "description": "social distancing" + } } }`)) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/readme"), + httpmock.StringResponse(` + { "name": "readme.org", + "content": "IyB0cnVseSBjb29sIHJlYWRtZSBjaGVjayBpdCBvdXQ="}`)) + + opts := &ViewOptions{ + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + BaseRepo: func() (ghrepo.Interface, error) { + return ghrepo.New("OWNER", "REPO"), nil + }, + } + + io, _, stdout, stderr := iostreams.Test() + + opts.IO = io + + t.Run(tt.name, func(t *testing.T) { + io.SetStdoutTTY(tt.stdoutTTY) + + if err := viewRun(opts); err != nil { + t.Errorf("viewRun() error = %v", err) + } + assert.Equal(t, tt.wantOut, stdout.String()) + assert.Equal(t, "", stderr.String()) + reg.Verify(t) + }) + } +} + +func Test_ViewRun_NoReadme(t *testing.T) { + tests := []struct { + name string + stdoutTTY bool + wantOut string + }{ + { + name: "tty", + wantOut: heredoc.Doc(` + OWNER/REPO + social distancing + + This repository does not have a README + + View this repository on GitHub: https://github.com/OWNER/REPO + `), + stdoutTTY: true, + }, + { + name: "nontty", + wantOut: heredoc.Doc(` + name: OWNER/REPO + description: social distancing + `), + }, + } + + for _, tt := range tests { + reg := &httpmock.Registry{} + reg.Register( + httpmock.GraphQL(`query RepositoryInfo\b`), + httpmock.StringResponse(` + { "data": { + "repository": { + "description": "social distancing" + } } }`)) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/readme"), + httpmock.StatusStringResponse(404, `{}`)) + + opts := &ViewOptions{ + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + BaseRepo: func() (ghrepo.Interface, error) { + return ghrepo.New("OWNER", "REPO"), nil + }, + } + + io, _, stdout, stderr := iostreams.Test() + + opts.IO = io + + t.Run(tt.name, func(t *testing.T) { + io.SetStdoutTTY(tt.stdoutTTY) + + if err := viewRun(opts); err != nil { + t.Errorf("viewRun() error = %v", err) + } + assert.Equal(t, tt.wantOut, stdout.String()) + assert.Equal(t, "", stderr.String()) + reg.Verify(t) + }) + } +} + +func Test_ViewRun_NoDescription(t *testing.T) { + tests := []struct { + name string + stdoutTTY bool + wantOut string + }{ + { + name: "tty", + wantOut: heredoc.Doc(` + OWNER/REPO + No description provided + + # truly cool readme check it out + + View this repository on GitHub: https://github.com/OWNER/REPO + `), + stdoutTTY: true, + }, + { + name: "nontty", + wantOut: heredoc.Doc(` + name: OWNER/REPO + description: + -- + # truly cool readme check it out + `), + }, + } + + for _, tt := range tests { + reg := &httpmock.Registry{} + reg.Register( + httpmock.GraphQL(`query RepositoryInfo\b`), + httpmock.StringResponse(` + { "data": { + "repository": { + "description": "" + } } }`)) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/readme"), + httpmock.StringResponse(` + { "name": "readme.org", + "content": "IyB0cnVseSBjb29sIHJlYWRtZSBjaGVjayBpdCBvdXQ="}`)) + + opts := &ViewOptions{ + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + BaseRepo: func() (ghrepo.Interface, error) { + return ghrepo.New("OWNER", "REPO"), nil + }, + } + + io, _, stdout, stderr := iostreams.Test() + + opts.IO = io + + t.Run(tt.name, func(t *testing.T) { + io.SetStdoutTTY(tt.stdoutTTY) + + if err := viewRun(opts); err != nil { + t.Errorf("viewRun() error = %v", err) + } + assert.Equal(t, tt.wantOut, stdout.String()) + assert.Equal(t, "", stderr.String()) + reg.Verify(t) + }) + } +} diff --git a/pkg/cmdutil/factory.go b/pkg/cmdutil/factory.go index ad7162415..0c66a2b10 100644 --- a/pkg/cmdutil/factory.go +++ b/pkg/cmdutil/factory.go @@ -3,6 +3,7 @@ package cmdutil import ( "net/http" + "github.com/cli/cli/internal/config" "github.com/cli/cli/internal/ghrepo" "github.com/cli/cli/pkg/iostreams" ) @@ -11,4 +12,5 @@ type Factory struct { IOStreams *iostreams.IOStreams HttpClient func() (*http.Client, error) BaseRepo func() (ghrepo.Interface, error) + Config func() (config.Config, error) } diff --git a/pkg/iostreams/iostreams.go b/pkg/iostreams/iostreams.go index 91660a135..f2e31c439 100644 --- a/pkg/iostreams/iostreams.go +++ b/pkg/iostreams/iostreams.go @@ -17,8 +17,10 @@ type IOStreams struct { colorEnabled bool - stdinTTYOverride bool - stdinIsTTY bool + stdinTTYOverride bool + stdinIsTTY bool + stdoutTTYOverride bool + stdoutIsTTY bool } func (s *IOStreams) ColorEnabled() bool { @@ -40,6 +42,21 @@ func (s *IOStreams) IsStdinTTY() bool { return false } +func (s *IOStreams) SetStdoutTTY(isTTY bool) { + s.stdoutTTYOverride = true + s.stdoutIsTTY = isTTY +} + +func (s *IOStreams) IsStdoutTTY() bool { + if s.stdoutTTYOverride { + return s.stdoutIsTTY + } + if stdout, ok := s.Out.(*os.File); ok { + return isTerminal(stdout) + } + return false +} + func System() *IOStreams { var out io.Writer = os.Stdout var colorEnabled bool diff --git a/utils/utils.go b/utils/utils.go index 2f100883f..c03d6ccf3 100644 --- a/utils/utils.go +++ b/utils/utils.go @@ -3,6 +3,7 @@ package utils import ( "fmt" "io" + "net/url" "strings" "time" @@ -102,3 +103,15 @@ var StopSpinner = func(s *spinner.Spinner) { func Spinner(w io.Writer) *spinner.Spinner { return spinner.New(spinner.CharSets[11], 400*time.Millisecond, spinner.WithWriter(w)) } + +func IsURL(s string) bool { + return strings.HasPrefix(s, "http:/") || strings.HasPrefix(s, "https:/") +} + +func DisplayURL(urlStr string) string { + u, err := url.Parse(urlStr) + if err != nil { + return urlStr + } + return u.Hostname() + u.Path +}