diff --git a/go.mod b/go.mod index c65e7341b..f28c0fe8a 100644 --- a/go.mod +++ b/go.mod @@ -26,7 +26,6 @@ require ( github.com/muesli/reflow v0.2.1-0.20210502190812-c80126ec2ad5 github.com/muesli/termenv v0.9.0 github.com/muhammadmuzzammil1998/jsonc v0.0.0-20201229145248-615b0916ca38 - github.com/olekukonko/tablewriter v0.0.5 github.com/opentracing/opentracing-go v1.1.0 github.com/shurcooL/githubv4 v0.0.0-20200928013246-d292edc3691b github.com/shurcooL/graphql v0.0.0-20181231061246-d48a9a75455f diff --git a/internal/codespaces/api/api.go b/internal/codespaces/api/api.go index ab294ef4a..5020843e2 100644 --- a/internal/codespaces/api/api.go +++ b/internal/codespaces/api/api.go @@ -35,6 +35,7 @@ import ( "io/ioutil" "net/http" "net/url" + "reflect" "regexp" "strconv" "strings" @@ -177,6 +178,44 @@ type CodespaceConnection struct { HostPublicKeys []string `json:"hostPublicKeys"` } +// CodespaceFields is the list of exportable fields for a codespace. +var CodespaceFields = []string{ + "name", + "owner", + "repository", + "state", + "gitStatus", + "createdAt", + "lastUsedAt", +} + +func (c *Codespace) ExportData(fields []string) *map[string]interface{} { + v := reflect.ValueOf(c).Elem() + data := map[string]interface{}{} + + for _, f := range fields { + switch f { + case "owner": + data[f] = c.Owner.Login + case "repository": + data[f] = c.Repository.FullName + case "gitStatus": + data[f] = map[string]interface{}{ + "ref": c.GitStatus.Ref, + "hasUnpushedChanges": c.GitStatus.HasUnpushedChanges, + "hasUncommitedChanges": c.GitStatus.HasUncommitedChanges, + } + default: + sf := v.FieldByNameFunc(func(s string) bool { + return strings.EqualFold(f, s) + }) + data[f] = sf.Interface() + } + } + + return &data +} + // ListCodespaces returns a list of codespaces for the user. Pass a negative limit to request all pages from // the API until all codespaces have been fetched. func (a *API) ListCodespaces(ctx context.Context, limit int) (codespaces []*Codespace, err error) { diff --git a/pkg/cmd/codespace/list.go b/pkg/cmd/codespace/list.go index ba573a226..7fe71d6fa 100644 --- a/pkg/cmd/codespace/list.go +++ b/pkg/cmd/codespace/list.go @@ -12,8 +12,8 @@ import ( ) func newListCmd(app *App) *cobra.Command { - var asJSON bool var limit int + var exporter cmdutil.Exporter listCmd := &cobra.Command{ Use: "list", @@ -24,17 +24,17 @@ func newListCmd(app *App) *cobra.Command { return cmdutil.FlagErrorf("invalid limit: %v", limit) } - return app.List(cmd.Context(), asJSON, limit) + return app.List(cmd.Context(), limit, exporter) }, } - listCmd.Flags().BoolVar(&asJSON, "json", false, "Output as JSON") listCmd.Flags().IntVarP(&limit, "limit", "L", 30, "Maximum number of codespaces to list") + cmdutil.AddJSONFlags(listCmd, &exporter, api.CodespaceFields) return listCmd } -func (a *App) List(ctx context.Context, asJSON bool, limit int) error { +func (a *App) List(ctx context.Context, limit int, exporter cmdutil.Exporter) error { a.StartProgressIndicatorWithLabel("Fetching codespaces") codespaces, err := a.apiClient.ListCodespaces(ctx, limit) a.StopProgressIndicator() @@ -47,6 +47,10 @@ func (a *App) List(ctx context.Context, asJSON bool, limit int) error { } defer a.io.StopPager() + if exporter != nil { + return exporter.Write(a.io, codespaces) + } + tp := utils.NewTablePrinter(a.io) if tp.IsTTY() { tp.AddField("NAME", nil, nil) diff --git a/pkg/cmd/codespace/output/format_json.go b/pkg/cmd/codespace/output/format_json.go deleted file mode 100644 index 8488e8dfa..000000000 --- a/pkg/cmd/codespace/output/format_json.go +++ /dev/null @@ -1,55 +0,0 @@ -package output - -import ( - "encoding/json" - "io" - "strings" - "unicode" -) - -type jsonwriter struct { - w io.Writer - pretty bool - cols []string - data []interface{} -} - -func (j *jsonwriter) SetHeader(cols []string) { - j.cols = cols -} - -func (j *jsonwriter) Append(values []string) { - row := make(map[string]string) - for i, v := range values { - row[camelize(j.cols[i])] = v - } - j.data = append(j.data, row) -} - -func (j *jsonwriter) Render() { - enc := json.NewEncoder(j.w) - if j.pretty { - enc.SetIndent("", " ") - } - _ = enc.Encode(j.data) -} - -func camelize(s string) string { - var b strings.Builder - capitalizeNext := false - for i, r := range s { - if r == ' ' { - capitalizeNext = true - continue - } - if capitalizeNext { - b.WriteRune(unicode.ToUpper(r)) - capitalizeNext = false - } else if i == 0 { - b.WriteRune(unicode.ToLower(r)) - } else { - b.WriteRune(r) - } - } - return b.String() -} diff --git a/pkg/cmd/codespace/output/format_table.go b/pkg/cmd/codespace/output/format_table.go deleted file mode 100644 index e0345672d..000000000 --- a/pkg/cmd/codespace/output/format_table.go +++ /dev/null @@ -1,31 +0,0 @@ -package output - -import ( - "io" - "os" - - "github.com/olekukonko/tablewriter" - "golang.org/x/term" -) - -type Table interface { - SetHeader([]string) - Append([]string) - Render() -} - -func NewTable(w io.Writer, asJSON bool) Table { - isTTY := isTTY(w) - if asJSON { - return &jsonwriter{w: w, pretty: isTTY} - } - if isTTY { - return tablewriter.NewWriter(w) - } - return &tabwriter{w: w} -} - -func isTTY(w io.Writer) bool { - f, ok := w.(*os.File) - return ok && term.IsTerminal(int(f.Fd())) -} diff --git a/pkg/cmd/codespace/output/format_tsv.go b/pkg/cmd/codespace/output/format_tsv.go deleted file mode 100644 index 3f1d226ca..000000000 --- a/pkg/cmd/codespace/output/format_tsv.go +++ /dev/null @@ -1,25 +0,0 @@ -package output - -import ( - "fmt" - "io" -) - -type tabwriter struct { - w io.Writer -} - -func (j *tabwriter) SetHeader([]string) {} - -func (j *tabwriter) Append(values []string) { - var sep string - for i, v := range values { - if i == 1 { - sep = "\t" - } - fmt.Fprintf(j.w, "%s%s", sep, v) - } - fmt.Fprint(j.w, "\n") -} - -func (j *tabwriter) Render() {} diff --git a/pkg/cmd/codespace/output/logger.go b/pkg/cmd/codespace/output/logger.go deleted file mode 100644 index fdefcad0f..000000000 --- a/pkg/cmd/codespace/output/logger.go +++ /dev/null @@ -1,78 +0,0 @@ -package output - -import ( - "fmt" - "io" - "sync" -) - -// NewLogger returns a Logger that will write to the given stdout/stderr writers. -// Disable the Logger to prevent it from writing to stdout in a TTY environment. -func NewLogger(stdout, stderr io.Writer, disabled bool) *Logger { - enabled := !disabled - if isTTY(stdout) && !enabled { - enabled = false - } - return &Logger{ - out: stdout, - errout: stderr, - enabled: enabled, - } -} - -// Logger writes to the given stdout/stderr writers. -// If not enabled, Print functions will noop but Error functions will continue -// to write to the stderr writer. -type Logger struct { - mu sync.Mutex // guards the writers - out io.Writer - errout io.Writer - enabled bool -} - -// Print writes the arguments to the stdout writer. -func (l *Logger) Print(v ...interface{}) (int, error) { - if !l.enabled { - return 0, nil - } - - l.mu.Lock() - defer l.mu.Unlock() - return fmt.Fprint(l.out, v...) -} - -// Println writes the arguments to the stdout writer with a newline at the end. -func (l *Logger) Println(v ...interface{}) (int, error) { - if !l.enabled { - return 0, nil - } - - l.mu.Lock() - defer l.mu.Unlock() - return fmt.Fprintln(l.out, v...) -} - -// Printf writes the formatted arguments to the stdout writer. -func (l *Logger) Printf(f string, v ...interface{}) (int, error) { - if !l.enabled { - return 0, nil - } - - l.mu.Lock() - defer l.mu.Unlock() - return fmt.Fprintf(l.out, f, v...) -} - -// Errorf writes the formatted arguments to the stderr writer. -func (l *Logger) Errorf(f string, v ...interface{}) (int, error) { - l.mu.Lock() - defer l.mu.Unlock() - return fmt.Fprintf(l.errout, f, v...) -} - -// Errorln writes the arguments to the stderr writer with a newline at the end. -func (l *Logger) Errorln(v ...interface{}) (int, error) { - l.mu.Lock() - defer l.mu.Unlock() - return fmt.Fprintln(l.errout, v...) -} diff --git a/pkg/cmd/codespace/ports.go b/pkg/cmd/codespace/ports.go index f3e380451..19fbb4135 100644 --- a/pkg/cmd/codespace/ports.go +++ b/pkg/cmd/codespace/ports.go @@ -12,8 +12,9 @@ import ( "github.com/cli/cli/v2/internal/codespaces" "github.com/cli/cli/v2/internal/codespaces/api" - "github.com/cli/cli/v2/pkg/cmd/codespace/output" + "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/liveshare" + "github.com/cli/cli/v2/utils" "github.com/muhammadmuzzammil1998/jsonc" "github.com/spf13/cobra" "golang.org/x/sync/errgroup" @@ -22,22 +23,20 @@ import ( // newPortsCmd returns a Cobra "ports" command that displays a table of available ports, // according to the specified flags. func newPortsCmd(app *App) *cobra.Command { - var ( - codespace string - asJSON bool - ) + var codespace string + var exporter cmdutil.Exporter portsCmd := &cobra.Command{ Use: "ports", Short: "List ports in a codespace", Args: noArgsConstraint, RunE: func(cmd *cobra.Command, args []string) error { - return app.ListPorts(cmd.Context(), codespace, asJSON) + return app.ListPorts(cmd.Context(), codespace, exporter) }, } portsCmd.PersistentFlags().StringVarP(&codespace, "codespace", "c", "", "Name of the codespace") - portsCmd.Flags().BoolVar(&asJSON, "json", false, "Output as JSON") + cmdutil.AddJSONFlags(portsCmd, &exporter, portFields) portsCmd.AddCommand(newPortsForwardCmd(app)) portsCmd.AddCommand(newPortsVisibilityCmd(app)) @@ -46,7 +45,7 @@ func newPortsCmd(app *App) *cobra.Command { } // ListPorts lists known ports in a codespace. -func (a *App) ListPorts(ctx context.Context, codespaceName string, asJSON bool) (err error) { +func (a *App) ListPorts(ctx context.Context, codespaceName string, exporter cmdutil.Exporter) (err error) { codespace, err := getOrChooseCodespace(ctx, a.apiClient, codespaceName) if err != nil { // TODO(josebalius): remove special handling of this error here and it other places @@ -74,30 +73,97 @@ func (a *App) ListPorts(ctx context.Context, codespaceName string, asJSON bool) devContainerResult := <-devContainerCh if devContainerResult.err != nil { // Warn about failure to read the devcontainer file. Not a codespace command error. - a.errLogger.Printf("Failed to get port names: %v\n", devContainerResult.err.Error()) + a.errLogger.Printf("Failed to get port names: %v", devContainerResult.err.Error()) } - table := output.NewTable(a.io.Out, asJSON) - table.SetHeader([]string{"Label", "Port", "Visibility", "Browse URL"}) - for _, port := range ports { - sourcePort := strconv.Itoa(port.SourcePort) - var portName string - if devContainerResult.devContainer != nil { - if attributes, ok := devContainerResult.devContainer.PortAttributes[sourcePort]; ok { - portName = attributes.Label - } + portInfos := make([]*portInfo, len(ports)) + for i, p := range ports { + portInfos[i] = &portInfo{ + Port: p, + codespace: codespace, + devContainer: devContainerResult.devContainer, } - - table.Append([]string{ - portName, - sourcePort, - port.Privacy, - fmt.Sprintf("https://%s-%s.githubpreview.dev/", codespace.Name, sourcePort), - }) } - table.Render() - return nil + if err := a.io.StartPager(); err != nil { + a.errLogger.Printf("error starting pager: %v", err) + } + defer a.io.StopPager() + + if exporter != nil { + return exporter.Write(a.io, portInfos) + } + + cs := a.io.ColorScheme() + tp := utils.NewTablePrinter(a.io) + + if tp.IsTTY() { + tp.AddField("LABEL", nil, nil) + tp.AddField("PORT", nil, nil) + tp.AddField("VISIBILITY", nil, nil) + tp.AddField("BROWSE URL", nil, nil) + tp.EndRow() + } + + for _, port := range portInfos { + tp.AddField(port.Label(), nil, nil) + tp.AddField(strconv.Itoa(port.SourcePort), nil, cs.Yellow) + tp.AddField(port.Privacy, nil, nil) + tp.AddField(port.BrowseURL(), nil, nil) + tp.EndRow() + } + return tp.Render() +} + +type portInfo struct { + *liveshare.Port + codespace *api.Codespace + devContainer *devContainer +} + +func (pi *portInfo) BrowseURL() string { + return fmt.Sprintf("https://%s-%d.githubpreview.dev", pi.codespace.Name, pi.Port.SourcePort) +} + +func (pi *portInfo) Label() string { + if pi.devContainer != nil { + portStr := strconv.Itoa(pi.Port.SourcePort) + if attributes, ok := pi.devContainer.PortAttributes[portStr]; ok { + return attributes.Label + } + } + return "" +} + +var portFields = []string{ + "sourcePort", + // "destinationPort", // TODO(mislav): this appears to always be blank? + "visibility", + "label", + "browseUrl", +} + +func (pi *portInfo) ExportData(fields []string) *map[string]interface{} { + data := map[string]interface{}{} + + for _, f := range fields { + switch f { + case "sourcePort": + data[f] = pi.Port.SourcePort + case "destinationPort": + data[f] = pi.Port.DestinationPort + case "visibility": + data[f] = pi.Port.Privacy + case "label": + data[f] = pi.Label() + case "browseUrl": + data[f] = pi.BrowseURL() + default: + panic("unkown field: " + f) + } + } + + return &data } type devContainerResult struct {