package browse import ( "context" "fmt" "net/http" "net/url" "os" "path" "path/filepath" "regexp" "strconv" "strings" "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/browser" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/internal/text" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" "github.com/spf13/cobra" ) const ( emptyCommitFlag = "last" ) type BrowseOptions struct { BaseRepo func() (ghrepo.Interface, error) Browser browser.Browser HttpClient func() (*http.Client, error) IO *iostreams.IOStreams PathFromRepoRoot func() string GitClient gitClient SelectorArg string Branch string Commit string ProjectsFlag bool ReleasesFlag bool SettingsFlag bool WikiFlag bool ActionsFlag bool BlameFlag bool NoBrowserFlag bool HasRepoOverride bool } func NewCmdBrowse(f *cmdutil.Factory, runF func(*BrowseOptions) error) *cobra.Command { opts := &BrowseOptions{ Browser: f.Browser, HttpClient: f.HttpClient, IO: f.IOStreams, PathFromRepoRoot: func() string { return f.GitClient.PathFromRoot(context.Background()) }, GitClient: &localGitClient{client: f.GitClient}, } cmd := &cobra.Command{ Short: "Open repositories, issues, pull requests, and more in the browser", Long: heredoc.Doc(` Transition from the terminal to the web browser to view and interact with: - Issues - Pull requests - Repository content - Repository home page - Repository settings `), Use: "browse [ | | ]", Args: cobra.MaximumNArgs(1), Example: heredoc.Doc(` # Open the home page of the current repository $ gh browse # Open the script directory of the current repository $ gh browse script/ # Open issue or pull request 217 $ gh browse 217 # Open commit page $ gh browse 77507cd94ccafcf568f8560cfecde965fcfa63 # Open repository settings $ gh browse --settings # Open main.go at line 312 $ gh browse main.go:312 # Open blame view for main.go at line 312 $ gh browse main.go:312 --blame # Open main.go with the repository at head of bug-fix branch $ gh browse main.go --branch bug-fix # Open main.go with the repository at commit 775007cd $ gh browse main.go --commit=77507cd94ccafcf568f8560cfecde965fcfa63 `), Annotations: map[string]string{ "help:arguments": heredoc.Doc(` A browser location can be specified using arguments in the following format: - by number for issue or pull request, e.g. "123"; or - by path for opening folders and files, e.g. "cmd/gh/main.go"; or - by commit SHA `), "help:environment": heredoc.Doc(` To configure a web browser other than the default, use the BROWSER environment variable. `), }, GroupID: "core", RunE: func(cmd *cobra.Command, args []string) error { opts.BaseRepo = f.BaseRepo if len(args) > 0 { opts.SelectorArg = args[0] } if err := cmdutil.MutuallyExclusive( "arguments not supported when using `--projects`, `--releases`, `--settings`, `--actions` or `--wiki`", opts.SelectorArg != "", opts.ProjectsFlag, opts.ReleasesFlag, opts.SettingsFlag, opts.WikiFlag, opts.ActionsFlag, ); err != nil { return err } if err := cmdutil.MutuallyExclusive( "specify only one of `--branch`, `--commit`, `--projects`, `--releases`, `--settings`, `--actions` or `--wiki`", opts.Branch != "", opts.Commit != "", opts.ProjectsFlag, opts.ReleasesFlag, opts.SettingsFlag, opts.WikiFlag, opts.ActionsFlag, ); err != nil { return err } if opts.BlameFlag && opts.SelectorArg == "" { return cmdutil.FlagErrorf("`--blame` requires a file path argument") } if (isNumber(opts.SelectorArg) || isCommit(opts.SelectorArg)) && (opts.Branch != "" || opts.Commit != "") { return cmdutil.FlagErrorf("%q is an invalid argument when using `--branch` or `--commit`", opts.SelectorArg) } if cmd.Flags().Changed("repo") || os.Getenv("GH_REPO") != "" { opts.GitClient = &remoteGitClient{opts.BaseRepo, opts.HttpClient} opts.HasRepoOverride = true } if runF != nil { return runF(opts) } return runBrowse(opts) }, } cmdutil.EnableRepoOverride(cmd, f) cmd.Flags().BoolVarP(&opts.ProjectsFlag, "projects", "p", false, "Open repository projects") cmd.Flags().BoolVarP(&opts.ReleasesFlag, "releases", "r", false, "Open repository releases") cmd.Flags().BoolVarP(&opts.WikiFlag, "wiki", "w", false, "Open repository wiki") cmd.Flags().BoolVarP(&opts.ActionsFlag, "actions", "a", false, "Open repository actions") cmd.Flags().BoolVarP(&opts.SettingsFlag, "settings", "s", false, "Open repository settings") cmd.Flags().BoolVar(&opts.BlameFlag, "blame", false, "Open blame view for a file") cmd.Flags().BoolVarP(&opts.NoBrowserFlag, "no-browser", "n", false, "Print destination URL instead of opening the browser") cmd.Flags().StringVarP(&opts.Commit, "commit", "c", "", "Select another commit by passing in the commit SHA, default is the last commit") cmd.Flags().StringVarP(&opts.Branch, "branch", "b", "", "Select another branch by passing in the branch name") _ = cmdutil.RegisterBranchCompletionFlags(f.GitClient, cmd, "branch") // Preserve backwards compatibility for when commit flag used to be a boolean flag. cmd.Flags().Lookup("commit").NoOptDefVal = emptyCommitFlag return cmd } func runBrowse(opts *BrowseOptions) error { baseRepo, err := opts.BaseRepo() if err != nil { return fmt.Errorf("unable to determine base repository: %w", err) } if opts.Commit != "" && opts.Commit == emptyCommitFlag { commit, err := opts.GitClient.LastCommit() if err != nil { return err } opts.Commit = commit.Sha } section, err := parseSection(baseRepo, opts) if err != nil { return err } url := ghrepo.GenerateRepoURL(baseRepo, "%s", section) if opts.NoBrowserFlag { client, err := opts.HttpClient() if err != nil { return err } exist, err := api.RepoExists(api.NewClientFromHTTP(client), baseRepo) if err != nil { return err } if !exist { return fmt.Errorf("%s doesn't exist", text.DisplayURL(url)) } _, err = fmt.Fprintln(opts.IO.Out, url) return err } if opts.IO.IsStdoutTTY() { fmt.Fprintf(opts.IO.Out, "Opening %s in your browser.\n", text.DisplayURL(url)) } return opts.Browser.Browse(url) } func parseSection(baseRepo ghrepo.Interface, opts *BrowseOptions) (string, error) { if opts.ProjectsFlag { return "projects", nil } else if opts.ReleasesFlag { return "releases", nil } else if opts.SettingsFlag { return "settings", nil } else if opts.WikiFlag { return "wiki", nil } else if opts.ActionsFlag { return "actions", nil } ref := opts.Branch if opts.Commit != "" { ref = opts.Commit } if ref == "" { if opts.SelectorArg == "" { return "", nil } if isNumber(opts.SelectorArg) { return fmt.Sprintf("issues/%s", strings.TrimPrefix(opts.SelectorArg, "#")), nil } if isCommit(opts.SelectorArg) { return fmt.Sprintf("commit/%s", opts.SelectorArg), nil } } if ref == "" { httpClient, err := opts.HttpClient() if err != nil { return "", err } apiClient := api.NewClientFromHTTP(httpClient) ref, err = api.RepoDefaultBranch(apiClient, baseRepo) if err != nil { return "", fmt.Errorf("error determining the default branch: %w", err) } } filePath, rangeStart, rangeEnd, err := parseFile(*opts, opts.SelectorArg) if err != nil { return "", err } if rangeStart > 0 { var rangeFragment string if rangeEnd > 0 && rangeStart != rangeEnd { rangeFragment = fmt.Sprintf("L%d-L%d", rangeStart, rangeEnd) } else { rangeFragment = fmt.Sprintf("L%d", rangeStart) } if opts.BlameFlag { return fmt.Sprintf("blame/%s/%s#%s", escapePath(ref), escapePath(filePath), rangeFragment), nil } return fmt.Sprintf("blob/%s/%s?plain=1#%s", escapePath(ref), escapePath(filePath), rangeFragment), nil } if opts.BlameFlag { return fmt.Sprintf("blame/%s/%s", escapePath(ref), escapePath(filePath)), nil } return strings.TrimSuffix(fmt.Sprintf("tree/%s/%s", escapePath(ref), escapePath(filePath)), "/"), nil } // escapePath URL-encodes special characters but leaves slashes unchanged func escapePath(p string) string { return strings.ReplaceAll(url.PathEscape(p), "%2F", "/") } func parseFile(opts BrowseOptions, f string) (p string, start int, end int, err error) { if f == "" { return } parts := strings.SplitN(f, ":", 3) if len(parts) > 2 { err = fmt.Errorf("invalid file argument: %q", f) return } p = filepath.ToSlash(parts[0]) if !path.IsAbs(p) && !opts.HasRepoOverride { p = path.Join(opts.PathFromRepoRoot(), p) if p == "." || strings.HasPrefix(p, "..") { p = "" } } if len(parts) < 2 { return } if idx := strings.IndexRune(parts[1], '-'); idx >= 0 { start, err = strconv.Atoi(parts[1][:idx]) if err != nil { err = fmt.Errorf("invalid file argument: %q", f) return } end, err = strconv.Atoi(parts[1][idx+1:]) if err != nil { err = fmt.Errorf("invalid file argument: %q", f) } return } start, err = strconv.Atoi(parts[1]) if err != nil { err = fmt.Errorf("invalid file argument: %q", f) } end = start return } func isNumber(arg string) bool { _, err := strconv.Atoi(strings.TrimPrefix(arg, "#")) return err == nil } // sha1 and sha256 are supported var commitHash = regexp.MustCompile(`\A[a-f0-9]{7,64}\z`) func isCommit(arg string) bool { return commitHash.MatchString(arg) } // gitClient is used to implement functions that can be performed on both local and remote git repositories type gitClient interface { LastCommit() (*git.Commit, error) } type localGitClient struct { client *git.Client } type remoteGitClient struct { repo func() (ghrepo.Interface, error) httpClient func() (*http.Client, error) } func (gc *localGitClient) LastCommit() (*git.Commit, error) { return gc.client.LastCommit(context.Background()) } func (gc *remoteGitClient) LastCommit() (*git.Commit, error) { httpClient, err := gc.httpClient() if err != nil { return nil, err } repo, err := gc.repo() if err != nil { return nil, err } commit, err := api.LastCommit(api.NewClientFromHTTP(httpClient), repo) if err != nil { return nil, err } return &git.Commit{Sha: commit.OID}, nil }