1package browse 2 3import ( 4 "fmt" 5 "net/http" 6 "net/url" 7 "path" 8 "path/filepath" 9 "strconv" 10 "strings" 11 12 "github.com/MakeNowJust/heredoc" 13 "github.com/cli/cli/v2/api" 14 "github.com/cli/cli/v2/git" 15 "github.com/cli/cli/v2/internal/ghrepo" 16 "github.com/cli/cli/v2/pkg/cmdutil" 17 "github.com/cli/cli/v2/pkg/iostreams" 18 "github.com/cli/cli/v2/utils" 19 "github.com/spf13/cobra" 20) 21 22type browser interface { 23 Browse(string) error 24} 25 26type BrowseOptions struct { 27 BaseRepo func() (ghrepo.Interface, error) 28 Browser browser 29 HttpClient func() (*http.Client, error) 30 IO *iostreams.IOStreams 31 PathFromRepoRoot func() string 32 GitClient gitClient 33 34 SelectorArg string 35 36 Branch string 37 CommitFlag bool 38 ProjectsFlag bool 39 SettingsFlag bool 40 WikiFlag bool 41 NoBrowserFlag bool 42} 43 44func NewCmdBrowse(f *cmdutil.Factory, runF func(*BrowseOptions) error) *cobra.Command { 45 opts := &BrowseOptions{ 46 Browser: f.Browser, 47 HttpClient: f.HttpClient, 48 IO: f.IOStreams, 49 PathFromRepoRoot: git.PathFromRepoRoot, 50 GitClient: &localGitClient{}, 51 } 52 53 cmd := &cobra.Command{ 54 Long: "Open the GitHub repository in the web browser.", 55 Short: "Open the repository in the browser", 56 Use: "browse [<number> | <path>]", 57 Args: cobra.MaximumNArgs(1), 58 Example: heredoc.Doc(` 59 $ gh browse 60 #=> Open the home page of the current repository 61 62 $ gh browse 217 63 #=> Open issue or pull request 217 64 65 $ gh browse --settings 66 #=> Open repository settings 67 68 $ gh browse main.go:312 69 #=> Open main.go at line 312 70 71 $ gh browse main.go --branch main 72 #=> Open main.go in the main branch 73 `), 74 Annotations: map[string]string{ 75 "IsCore": "true", 76 "help:arguments": heredoc.Doc(` 77 A browser location can be specified using arguments in the following format: 78 - by number for issue or pull request, e.g. "123"; or 79 - by path for opening folders and files, e.g. "cmd/gh/main.go" 80 `), 81 "help:environment": heredoc.Doc(` 82 To configure a web browser other than the default, use the BROWSER environment variable. 83 `), 84 }, 85 RunE: func(cmd *cobra.Command, args []string) error { 86 opts.BaseRepo = f.BaseRepo 87 88 if len(args) > 0 { 89 opts.SelectorArg = args[0] 90 } 91 92 if err := cmdutil.MutuallyExclusive( 93 "specify only one of `--branch`, `--commit`, `--projects`, `--wiki`, or `--settings`", 94 opts.Branch != "", 95 opts.CommitFlag, 96 opts.WikiFlag, 97 opts.SettingsFlag, 98 opts.ProjectsFlag, 99 ); err != nil { 100 return err 101 } 102 if cmd.Flags().Changed("repo") { 103 opts.GitClient = &remoteGitClient{opts.BaseRepo, opts.HttpClient} 104 } 105 106 if runF != nil { 107 return runF(opts) 108 } 109 return runBrowse(opts) 110 }, 111 } 112 113 cmdutil.EnableRepoOverride(cmd, f) 114 cmd.Flags().BoolVarP(&opts.ProjectsFlag, "projects", "p", false, "Open repository projects") 115 cmd.Flags().BoolVarP(&opts.WikiFlag, "wiki", "w", false, "Open repository wiki") 116 cmd.Flags().BoolVarP(&opts.SettingsFlag, "settings", "s", false, "Open repository settings") 117 cmd.Flags().BoolVarP(&opts.NoBrowserFlag, "no-browser", "n", false, "Print destination URL instead of opening the browser") 118 cmd.Flags().BoolVarP(&opts.CommitFlag, "commit", "c", false, "Open the last commit") 119 cmd.Flags().StringVarP(&opts.Branch, "branch", "b", "", "Select another branch by passing in the branch name") 120 121 return cmd 122} 123 124func runBrowse(opts *BrowseOptions) error { 125 baseRepo, err := opts.BaseRepo() 126 if err != nil { 127 return fmt.Errorf("unable to determine base repository: %w", err) 128 } 129 130 if opts.CommitFlag { 131 commit, err := opts.GitClient.LastCommit() 132 if err != nil { 133 return err 134 } 135 opts.Branch = commit.Sha 136 } 137 138 section, err := parseSection(baseRepo, opts) 139 if err != nil { 140 return err 141 } 142 url := ghrepo.GenerateRepoURL(baseRepo, "%s", section) 143 144 if opts.NoBrowserFlag { 145 _, err := fmt.Fprintln(opts.IO.Out, url) 146 return err 147 } 148 149 if opts.IO.IsStdoutTTY() { 150 fmt.Fprintf(opts.IO.Out, "Opening %s in your browser.\n", utils.DisplayURL(url)) 151 } 152 return opts.Browser.Browse(url) 153} 154 155func parseSection(baseRepo ghrepo.Interface, opts *BrowseOptions) (string, error) { 156 if opts.SelectorArg == "" { 157 if opts.ProjectsFlag { 158 return "projects", nil 159 } else if opts.SettingsFlag { 160 return "settings", nil 161 } else if opts.WikiFlag { 162 return "wiki", nil 163 } else if opts.Branch == "" { 164 return "", nil 165 } 166 } 167 168 if isNumber(opts.SelectorArg) { 169 return fmt.Sprintf("issues/%s", opts.SelectorArg), nil 170 } 171 172 filePath, rangeStart, rangeEnd, err := parseFile(*opts, opts.SelectorArg) 173 if err != nil { 174 return "", err 175 } 176 177 branchName := opts.Branch 178 if branchName == "" { 179 httpClient, err := opts.HttpClient() 180 if err != nil { 181 return "", err 182 } 183 apiClient := api.NewClientFromHTTP(httpClient) 184 branchName, err = api.RepoDefaultBranch(apiClient, baseRepo) 185 if err != nil { 186 return "", fmt.Errorf("error determining the default branch: %w", err) 187 } 188 } 189 190 if rangeStart > 0 { 191 var rangeFragment string 192 if rangeEnd > 0 && rangeStart != rangeEnd { 193 rangeFragment = fmt.Sprintf("L%d-L%d", rangeStart, rangeEnd) 194 } else { 195 rangeFragment = fmt.Sprintf("L%d", rangeStart) 196 } 197 return fmt.Sprintf("blob/%s/%s?plain=1#%s", escapePath(branchName), escapePath(filePath), rangeFragment), nil 198 } 199 return strings.TrimSuffix(fmt.Sprintf("tree/%s/%s", escapePath(branchName), escapePath(filePath)), "/"), nil 200} 201 202// escapePath URL-encodes special characters but leaves slashes unchanged 203func escapePath(p string) string { 204 return strings.ReplaceAll(url.PathEscape(p), "%2F", "/") 205} 206 207func parseFile(opts BrowseOptions, f string) (p string, start int, end int, err error) { 208 if f == "" { 209 return 210 } 211 212 parts := strings.SplitN(f, ":", 3) 213 if len(parts) > 2 { 214 err = fmt.Errorf("invalid file argument: %q", f) 215 return 216 } 217 218 p = filepath.ToSlash(parts[0]) 219 if !path.IsAbs(p) { 220 p = path.Join(opts.PathFromRepoRoot(), p) 221 if p == "." || strings.HasPrefix(p, "..") { 222 p = "" 223 } 224 } 225 if len(parts) < 2 { 226 return 227 } 228 229 if idx := strings.IndexRune(parts[1], '-'); idx >= 0 { 230 start, err = strconv.Atoi(parts[1][:idx]) 231 if err != nil { 232 err = fmt.Errorf("invalid file argument: %q", f) 233 return 234 } 235 end, err = strconv.Atoi(parts[1][idx+1:]) 236 if err != nil { 237 err = fmt.Errorf("invalid file argument: %q", f) 238 } 239 return 240 } 241 242 start, err = strconv.Atoi(parts[1]) 243 if err != nil { 244 err = fmt.Errorf("invalid file argument: %q", f) 245 } 246 end = start 247 return 248} 249 250func isNumber(arg string) bool { 251 _, err := strconv.Atoi(arg) 252 return err == nil 253} 254 255// gitClient is used to implement functions that can be performed on both local and remote git repositories 256type gitClient interface { 257 LastCommit() (*git.Commit, error) 258} 259 260type localGitClient struct{} 261 262type remoteGitClient struct { 263 repo func() (ghrepo.Interface, error) 264 httpClient func() (*http.Client, error) 265} 266 267func (gc *localGitClient) LastCommit() (*git.Commit, error) { return git.LastCommit() } 268 269func (gc *remoteGitClient) LastCommit() (*git.Commit, error) { 270 httpClient, err := gc.httpClient() 271 if err != nil { 272 return nil, err 273 } 274 repo, err := gc.repo() 275 if err != nil { 276 return nil, err 277 } 278 commit, err := api.LastCommit(api.NewClientFromHTTP(httpClient), repo) 279 if err != nil { 280 return nil, err 281 } 282 return &git.Commit{Sha: commit.OID}, nil 283} 284