cli/pkg/cmd/browse/browse.go
Benjamin Chadwick c581a093ed
Encode segments of gh browse resulting URL (#4663)
- URLs are now always generated without a trailing slash
- Windows-style paths are normalized before processing
- Special characters in branch names are escaped

Co-authored-by: Mislav Marohnić <mislav@github.com>
2021-11-18 13:10:55 +00:00

247 lines
6.2 KiB
Go

package browse
import (
"fmt"
"net/http"
"net/url"
"path"
"path/filepath"
"strconv"
"strings"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/v2/api"
"github.com/cli/cli/v2/git"
"github.com/cli/cli/v2/internal/ghrepo"
"github.com/cli/cli/v2/pkg/cmdutil"
"github.com/cli/cli/v2/pkg/iostreams"
"github.com/cli/cli/v2/utils"
"github.com/spf13/cobra"
)
type browser interface {
Browse(string) error
}
type BrowseOptions struct {
BaseRepo func() (ghrepo.Interface, error)
Browser browser
HttpClient func() (*http.Client, error)
IO *iostreams.IOStreams
PathFromRepoRoot func() string
SelectorArg string
Branch string
CommitFlag bool
ProjectsFlag bool
SettingsFlag bool
WikiFlag bool
NoBrowserFlag bool
}
func NewCmdBrowse(f *cmdutil.Factory, runF func(*BrowseOptions) error) *cobra.Command {
opts := &BrowseOptions{
Browser: f.Browser,
HttpClient: f.HttpClient,
IO: f.IOStreams,
PathFromRepoRoot: git.PathFromRepoRoot,
}
cmd := &cobra.Command{
Long: "Open the GitHub repository in the web browser.",
Short: "Open the repository in the browser",
Use: "browse [<number> | <path>]",
Args: cobra.MaximumNArgs(1),
Example: heredoc.Doc(`
$ gh browse
#=> Open the home page of the current repository
$ gh browse 217
#=> Open issue or pull request 217
$ gh browse --settings
#=> Open repository settings
$ gh browse main.go:312
#=> Open main.go at line 312
$ gh browse main.go --branch main
#=> Open main.go in the main branch
`),
Annotations: map[string]string{
"IsCore": "true",
"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"
`),
"help:environment": heredoc.Doc(`
To configure a web browser other than the default, use the BROWSER environment variable.
`),
},
RunE: func(cmd *cobra.Command, args []string) error {
opts.BaseRepo = f.BaseRepo
if len(args) > 0 {
opts.SelectorArg = args[0]
}
if err := cmdutil.MutuallyExclusive(
"specify only one of `--branch`, `--commit`, `--projects`, `--wiki`, or `--settings`",
opts.Branch != "",
opts.CommitFlag,
opts.WikiFlag,
opts.SettingsFlag,
opts.ProjectsFlag,
); err != nil {
return err
}
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.WikiFlag, "wiki", "w", false, "Open repository wiki")
cmd.Flags().BoolVarP(&opts.SettingsFlag, "settings", "s", false, "Open repository settings")
cmd.Flags().BoolVarP(&opts.NoBrowserFlag, "no-browser", "n", false, "Print destination URL instead of opening the browser")
cmd.Flags().BoolVarP(&opts.CommitFlag, "commit", "c", false, "Open the last commit")
cmd.Flags().StringVarP(&opts.Branch, "branch", "b", "", "Select another branch by passing in the branch name")
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.CommitFlag {
commit, err := git.LastCommit()
if err == nil {
opts.Branch = commit.Sha
}
}
section, err := parseSection(baseRepo, opts)
if err != nil {
return err
}
url := ghrepo.GenerateRepoURL(baseRepo, "%s", section)
if opts.NoBrowserFlag {
_, err := fmt.Fprintln(opts.IO.Out, url)
return err
}
if opts.IO.IsStdoutTTY() {
fmt.Fprintf(opts.IO.Out, "Opening %s in your browser.\n", utils.DisplayURL(url))
}
return opts.Browser.Browse(url)
}
func parseSection(baseRepo ghrepo.Interface, opts *BrowseOptions) (string, error) {
if opts.SelectorArg == "" {
if opts.ProjectsFlag {
return "projects", nil
} else if opts.SettingsFlag {
return "settings", nil
} else if opts.WikiFlag {
return "wiki", nil
} else if opts.Branch == "" {
return "", nil
}
}
if isNumber(opts.SelectorArg) {
return fmt.Sprintf("issues/%s", opts.SelectorArg), nil
}
filePath, rangeStart, rangeEnd, err := parseFile(*opts, opts.SelectorArg)
if err != nil {
return "", err
}
branchName := opts.Branch
if branchName == "" {
httpClient, err := opts.HttpClient()
if err != nil {
return "", err
}
apiClient := api.NewClientFromHTTP(httpClient)
branchName, err = api.RepoDefaultBranch(apiClient, baseRepo)
if err != nil {
return "", fmt.Errorf("error determining the default branch: %w", 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)
}
return fmt.Sprintf("blob/%s/%s?plain=1#%s", escapePath(branchName), escapePath(filePath), rangeFragment), nil
}
return strings.TrimSuffix(fmt.Sprintf("tree/%s/%s", escapePath(branchName), 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) {
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(arg)
return err == nil
}