diff --git a/api/repository_files.go b/api/repository_files.go new file mode 100644 index 00000000..62742297 --- /dev/null +++ b/api/repository_files.go @@ -0,0 +1,21 @@ +package api + +import "github.com/xanzy/go-gitlab" + +// GetFile retrieves a file from repository. Note that file content is Base64 encoded. +var GetFile = func(client *gitlab.Client, projectID interface{}, path string, ref string) (*gitlab.File, error) { + if client == nil { + client = apiClient.Lab() + } + + fileOpts := &gitlab.GetFileOptions{ + Ref: &ref, + } + file, _, err := client.RepositoryFiles.GetFile(projectID, path, fileOpts) + + if err != nil { + return nil, err + } + + return file, nil +} diff --git a/commands/project/repo.go b/commands/project/repo.go index 283469a2..641d41e6 100644 --- a/commands/project/repo.go +++ b/commands/project/repo.go @@ -9,6 +9,7 @@ import ( repoCmdDelete "github.com/profclems/glab/commands/project/delete" repoCmdFork "github.com/profclems/glab/commands/project/fork" repoCmdSearch "github.com/profclems/glab/commands/project/search" + repoCmdView "github.com/profclems/glab/commands/project/view" "github.com/spf13/cobra" ) @@ -28,6 +29,7 @@ func NewCmdRepo(f *cmdutils.Factory) *cobra.Command { repoCmd.AddCommand(repoCmdDelete.NewCmdDelete(f)) repoCmd.AddCommand(repoCmdFork.NewCmdFork(f, nil)) repoCmd.AddCommand(repoCmdSearch.NewCmdSearch(f)) + repoCmd.AddCommand(repoCmdView.NewCmdView(f)) return repoCmd } diff --git a/commands/project/view/project_view.go b/commands/project/view/project_view.go new file mode 100644 index 00000000..8c99d052 --- /dev/null +++ b/commands/project/view/project_view.go @@ -0,0 +1,245 @@ +package view + +import ( + "encoding/base64" + "fmt" + "strings" + + "github.com/MakeNowJust/heredoc" + "github.com/profclems/glab/api" + "github.com/profclems/glab/commands/cmdutils" + "github.com/profclems/glab/internal/glrepo" + "github.com/profclems/glab/pkg/iostreams" + "github.com/profclems/glab/pkg/utils" + "github.com/spf13/cobra" + "github.com/xanzy/go-gitlab" +) + +type ViewOptions struct { + ProjectID string + APIClient *gitlab.Client + Web bool + Branch string + Browser string + GlamourStyle string + + IO *iostreams.IOStreams + Repo glrepo.Interface +} + +func NewCmdView(f *cmdutils.Factory) *cobra.Command { + opts := ViewOptions{ + IO: f.IO, + } + + var projectViewCmd = &cobra.Command{ + Use: "view [repository] [flags]", + Short: "View a project/repository", + Long: `Display the description and README of a project or open it in the browser.`, + Args: cobra.MaximumNArgs(1), + Example: heredoc.Doc(` + # view project information for the current directory + $ glab repo view + + # view project information of specified name + $ glab repo view my-project + $ glab repo view user/repo + $ glab repo view group/namespace/repo + + # specify repo by full [git] URL + $ glab repo view git@gitlab.com:user/repo.git + $ glab repo view https://gitlab.company.org/user/repo + $ glab repo view https://gitlab.company.org/user/repo.git + `), + RunE: func(cmd *cobra.Command, args []string) error { + var err error + + cfg, err := f.Config() + if err != nil { + return err + } + + if len(args) == 1 { + opts.ProjectID = args[0] + } + + apiClient, err := f.HttpClient() + if err != nil { + return err + } + opts.APIClient = apiClient + + if opts.ProjectID == "" { + opts.Repo, err = f.BaseRepo() + if err != nil { + return cmdutils.WrapError(err, "`repository` is required when not running in a git repository") + } + opts.ProjectID = opts.Repo.FullName() + } + + if opts.ProjectID != "" { + if !strings.Contains(opts.ProjectID, "/") { + currentUser, err := api.CurrentUser(opts.APIClient) + if err != nil { + return cmdutils.WrapError(err, "Failed to retrieve your current user") + } + + opts.ProjectID = currentUser.Username + "/" + opts.ProjectID + } + + repo, err := glrepo.FromFullName(opts.ProjectID) + if err != nil { + return err + } + + if !glrepo.IsSame(repo, opts.Repo) { + client, err := api.NewClientWithCfg(repo.RepoHost(), cfg, false) + if err != nil { + return err + } + opts.APIClient = client.Lab() + } + opts.Repo = repo + opts.ProjectID = repo.FullName() + } + + if opts.Branch == "" { + opts.Branch, _ = f.Branch() + } + + browser, _ := cfg.Get(opts.Repo.RepoHost(), "browser") + opts.Browser = browser + + opts.GlamourStyle, _ = cfg.Get(opts.Repo.RepoHost(), "glamour_style") + + return runViewProject(&opts) + }, + } + + projectViewCmd.Flags().BoolVarP(&opts.Web, "web", "w", false, "Open a project in the browser") + projectViewCmd.Flags().StringVarP(&opts.Branch, "branch", "b", "", "View a specific branch of the repository") + + return projectViewCmd +} + +func runViewProject(opts *ViewOptions) error { + project, err := opts.Repo.Project(opts.APIClient) + if err != nil { + return cmdutils.WrapError(err, "Failed to retrieve project information") + } + + readmeFile, err := getReadmeFile(opts, project) + if err != nil { + return err + } + + if opts.Web { + openURL := generateProjectURL(project, opts.Branch) + + if opts.IO.IsaTTY { + fmt.Fprintf(opts.IO.StdOut, "Opening %s in your browser.\n", utils.DisplayURL(openURL)) + } + + return utils.OpenInBrowser(openURL, opts.Browser) + } else { + if opts.IO.IsaTTY { + if err := opts.IO.StartPager(); err != nil { + return err + } + defer opts.IO.StopPager() + + printProjectContentTTY(opts, project, readmeFile) + } else { + printProjectContentRaw(opts, project, readmeFile) + } + } + + return nil +} + +func getReadmeFile(opts *ViewOptions, project *gitlab.Project) (*gitlab.File, error) { + if project.ReadmeURL == "" { + return nil, nil + } + + readmePath := strings.Replace(project.ReadmeURL, project.WebURL+"/-/blob/", "", 1) + readmePathComponents := strings.Split(readmePath, "/") + readmeRef := readmePathComponents[0] + readmeFileName := readmePathComponents[1] + readmeFile, err := api.GetFile(opts.APIClient, project.PathWithNamespace, readmeFileName, readmeRef) + + if err != nil { + return nil, cmdutils.WrapError(err, "Failed to retrieve README file") + } + + decoded, err := base64.StdEncoding.DecodeString(readmeFile.Content) + if err != nil { + return nil, cmdutils.WrapError(err, "Failed to decode README file") + } + + readmeFile.Content = string(decoded) + + return readmeFile, nil +} + +func generateProjectURL(project *gitlab.Project, branch string) string { + if project.DefaultBranch != branch { + return project.WebURL + "/-/tree/" + branch + } + + return project.WebURL +} + +func printProjectContentTTY(opts *ViewOptions, project *gitlab.Project, readme *gitlab.File) { + var description string + var readmeContent string + var err error + + fullName := project.NameWithNamespace + if project.Description != "" { + description, err = utils.RenderMarkdownWithoutIndentations(project.Description, opts.GlamourStyle) + + if err != nil { + description = project.Description + } + } else { + description = "\n(No description provided)\n\n" + } + + if readme != nil { + readmeContent, err = utils.RenderMarkdown(readme.Content, opts.GlamourStyle) + + if err != nil { + readmeContent = readme.Content + } + } + + c := opts.IO.Color() + // Header + fmt.Fprint(opts.IO.StdOut, c.Bold(fullName)) + fmt.Fprint(opts.IO.StdOut, c.Gray(description)) + + // Readme + if readme != nil { + fmt.Fprint(opts.IO.StdOut, readmeContent) + } else { + fmt.Fprintln(opts.IO.StdOut, c.Gray("(This repository does not have a README file)")) + } + + fmt.Fprintln(opts.IO.StdOut) + fmt.Fprintf(opts.IO.StdOut, c.Gray("View this project on GitLab: %s\n"), project.WebURL) +} + +func printProjectContentRaw(opts *ViewOptions, project *gitlab.Project, readme *gitlab.File) { + fullName := project.NameWithNamespace + description := project.Description + + fmt.Fprintf(opts.IO.StdOut, "name:\t%s\n", fullName) + fmt.Fprintf(opts.IO.StdOut, "description:\t%s\n", description) + + if readme != nil { + fmt.Fprintln(opts.IO.StdOut, "---") + fmt.Fprintf(opts.IO.StdOut, readme.Content) + fmt.Fprintln(opts.IO.StdOut) + } +} diff --git a/internal/glrepo/repo.go b/internal/glrepo/repo.go index ff81ef6f..07e0ab7c 100644 --- a/internal/glrepo/repo.go +++ b/internal/glrepo/repo.go @@ -190,8 +190,10 @@ func normalizeHostname(h string) string { // IsSame compares two GitLab repositories func IsSame(a, b Interface) bool { - return strings.EqualFold(a.RepoOwner(), b.RepoOwner()) && - strings.EqualFold(a.RepoName(), b.RepoName()) && + if a == nil || b == nil { + return false + } + return strings.EqualFold(a.FullName(), b.FullName()) && normalizeHostname(a.RepoHost()) == normalizeHostname(b.RepoHost()) } diff --git a/pkg/utils/utils.go b/pkg/utils/utils.go index bab122e9..56536b18 100644 --- a/pkg/utils/utils.go +++ b/pkg/utils/utils.go @@ -11,6 +11,8 @@ import ( "github.com/profclems/glab/pkg/browser" ) +type MarkdownRenderOpts []glamour.TermRendererOption + // OpenInBrowser opens the url in a web browser based on OS and $BROWSER environment variable func OpenInBrowser(url, browserType string) error { browseCmd, err := browser.Command(url, browserType) @@ -21,13 +23,28 @@ func OpenInBrowser(url, browserType string) error { } func RenderMarkdown(text, glamourStyle string) (string, error) { + opts := MarkdownRenderOpts{ + glamour.WithStylePath(getStyle(glamourStyle)), + } + + return renderMarkdown(text, opts) +} + +func RenderMarkdownWithoutIndentations(text, glamourStyle string) (string, error) { + opts := MarkdownRenderOpts{ + glamour.WithStylePath(getStyle(glamourStyle)), + markdownWithoutIndentation(), + } + + return renderMarkdown(text, opts) +} + +func renderMarkdown(text string, opts MarkdownRenderOpts) (string, error) { // Glamour rendering preserves carriage return characters in code blocks, but // we need to ensure that no such characters are present in the output. text = strings.ReplaceAll(text, "\r\n", "\n") - tr, err := glamour.NewTermRenderer( - glamour.WithStylePath(getStyle(glamourStyle)), - ) + tr, err := glamour.NewTermRenderer(opts...) if err != nil { return "", err } @@ -35,6 +52,20 @@ func RenderMarkdown(text, glamourStyle string) (string, error) { return tr.Render(text) } +func markdownWithoutIndentation() glamour.TermRendererOption { + overrides := []byte(` + { + "document": { + "margin": 0 + }, + "code_block": { + "margin": 0 + } + }`) + + return glamour.WithStylesFromJSONBytes(overrides) +} + func getStyle(glamourStyle string) string { if glamourStyle == "" || glamourStyle == "none" { return "notty"