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