diff --git a/internal/codespaces/api/api.go b/internal/codespaces/api/api.go index c45eafb87..d66aff065 100644 --- a/internal/codespaces/api/api.go +++ b/internal/codespaces/api/api.go @@ -192,6 +192,15 @@ type Codespace struct { PendingOperationDisabledReason string `json:"pending_operation_disabled_reason"` IdleTimeoutNotice string `json:"idle_timeout_notice"` WebURL string `json:"web_url"` + DevContainerPath string `json:"devcontainer_path"` + Prebuild bool `json:"prebuild"` + Location string `json:"location"` + IdleTimeoutMinutes int `json:"idle_timeout_minutes"` + RetentionPeriodMinutes int `json:"retention_period_minutes"` + RetentionExpiresAt string `json:"retention_expires_at"` + RecentFolders []string `json:"recent_folders"` + BillableOwner User `json:"billable_owner"` + EnvironmentId string `json:"environment_id"` } type CodespaceGitStatus struct { @@ -230,8 +239,8 @@ type CodespaceConnection struct { HostPublicKeys []string `json:"hostPublicKeys"` } -// CodespaceFields is the list of exportable fields for a codespace. -var CodespaceFields = []string{ +// ListCodespaceFields is the list of exportable fields for a codespace when using the `gh cs list` command. +var ListCodespaceFields = []string{ "displayName", "name", "owner", @@ -244,6 +253,30 @@ var CodespaceFields = []string{ "vscsTarget", } +// ViewCodespaceFields is the list of exportable fields for a codespace when using the `gh cs view` command. +var ViewCodespaceFields = []string{ + "name", + "displayName", + "state", + "owner", + "billableOwner", + "location", + "repository", + "gitStatus", + "devcontainerPath", + "machineName", + "machineDisplayName", + "prebuild", + "createdAt", + "lastUsedAt", + "idleTimeoutMinutes", + "retentionPeriodDays", + "retentionExpiresAt", + "recentFolders", + "vscsTarget", + "environmentId", +} + func (c *Codespace) ExportData(fields []string) map[string]interface{} { v := reflect.ValueOf(c).Elem() data := map[string]interface{}{} @@ -256,11 +289,17 @@ func (c *Codespace) ExportData(fields []string) map[string]interface{} { data[f] = c.Repository.FullName case "machineName": data[f] = c.Machine.Name + case "machineDisplayName": + data[f] = c.Machine.DisplayName + case "retentionPeriodDays": + data[f] = c.RetentionPeriodMinutes / 1440 case "gitStatus": data[f] = map[string]interface{}{ "ref": c.GitStatus.Ref, "hasUnpushedChanges": c.GitStatus.HasUnpushedChanges, "hasUncommittedChanges": c.GitStatus.HasUncommittedChanges, + "ahead": c.GitStatus.Ahead, + "behind": c.GitStatus.Behind, } case "vscsTarget": if c.VSCSTarget != "" && c.VSCSTarget != VSCSTargetProduction { diff --git a/pkg/cmd/codespace/list.go b/pkg/cmd/codespace/list.go index 6b1cd1cc0..bba34ff45 100644 --- a/pkg/cmd/codespace/list.go +++ b/pkg/cmd/codespace/list.go @@ -70,7 +70,7 @@ func newListCmd(app *App) *cobra.Command { listCmd.Flags().StringVarP(&opts.orgName, "org", "o", "", "The `login` handle of the organization to list codespaces for (admin-only)") listCmd.Flags().StringVarP(&opts.userName, "user", "u", "", "The `username` to list codespaces for (used with --org)") - cmdutil.AddJSONFlags(listCmd, &exporter, api.CodespaceFields) + cmdutil.AddJSONFlags(listCmd, &exporter, api.ListCodespaceFields) listCmd.Flags().BoolVarP(&opts.useWeb, "web", "w", false, "List codespaces in the web browser, cannot be used with --user or --org") diff --git a/pkg/cmd/codespace/root.go b/pkg/cmd/codespace/root.go index 8439430aa..28a80edae 100644 --- a/pkg/cmd/codespace/root.go +++ b/pkg/cmd/codespace/root.go @@ -16,6 +16,7 @@ func NewRootCmd(app *App) *cobra.Command { root.AddCommand(newDeleteCmd(app)) root.AddCommand(newJupyterCmd(app)) root.AddCommand(newListCmd(app)) + root.AddCommand(newViewCmd(app)) root.AddCommand(newLogsCmd(app)) root.AddCommand(newPortsCmd(app)) root.AddCommand(newSSHCmd(app)) diff --git a/pkg/cmd/codespace/view.go b/pkg/cmd/codespace/view.go new file mode 100644 index 000000000..11f772b71 --- /dev/null +++ b/pkg/cmd/codespace/view.go @@ -0,0 +1,143 @@ +package codespace + +import ( + "context" + "fmt" + "os" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/v2/internal/codespaces/api" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/utils" + "github.com/spf13/cobra" +) + +const ( + minutesInDay = 1440 +) + +type viewOptions struct { + selector *CodespaceSelector + exporter cmdutil.Exporter +} + +func newViewCmd(app *App) *cobra.Command { + opts := &viewOptions{} + + viewCmd := &cobra.Command{ + Use: "view", + Short: "View details about a codespace", + Long: heredoc.Doc(` + View details about a codespace. + + For more fine-grained details, you can add the "--json" flag to view the full list of fields available. + + If this command doesn't provide enough information, you can use the "gh api" command to view the full JSON response: + $ gh api /user/codespaces/ + `), + Example: heredoc.Doc(` + # select a codespace from a list of all codespaces you own + $ gh cs view + + # view the details of a specific codespace + $ gh cs view -c + + # select a codespace from a list of codespaces in a repository + $ gh cs view --repo / + + # select a codespace from a list of codespaces with a specific repository owner + $ gh cs view --repo-owner + + # view the list of all available fields for a codespace + $ gh cs view --json + + # view specific fields for a codespace + $ gh cs view --json displayName,machineDisplayName,state + `), + Args: noArgsConstraint, + RunE: func(cmd *cobra.Command, args []string) error { + return app.ViewCodespace(cmd.Context(), opts) + }, + } + opts.selector = AddCodespaceSelector(viewCmd, app.apiClient) + cmdutil.AddJSONFlags(viewCmd, &opts.exporter, api.ViewCodespaceFields) + + return viewCmd +} + +func (a *App) ViewCodespace(ctx context.Context, opts *viewOptions) error { + // If we are in a codespace and a codespace name wasn't provided, show the details for the codespace we are connected to + if (os.Getenv("CODESPACES") == "true") && opts.selector.codespaceName == "" { + codespaceName := os.Getenv("CODESPACE_NAME") + opts.selector.codespaceName = codespaceName + } + + selectedCodespace, err := opts.selector.Select(ctx) + if err != nil { + return err + } + + if err := a.io.StartPager(); err != nil { + a.errLogger.Printf("error starting pager: %v", err) + } + defer a.io.StopPager() + + if opts.exporter != nil { + return opts.exporter.Write(a.io, selectedCodespace) + } + + //nolint:staticcheck // SA1019: utils.NewTablePrinter is deprecated: use internal/tableprinter + tp := utils.NewTablePrinter(a.io) + c := codespace{selectedCodespace} + formattedName := formatNameForVSCSTarget(c.Name, c.VSCSTarget) + + // Create an array of fields to display in the table with their values + fields := []struct { + name string + value string + }{ + {"Name", formattedName}, + {"State", c.State}, + {"Repository", c.Repository.FullName}, + {"Git Status", formatGitStatus(c)}, + {"Devcontainer Path", c.DevContainerPath}, + {"Machine Display Name", c.Machine.DisplayName}, + {"Idle Timeout", fmt.Sprintf("%d minutes", c.IdleTimeoutMinutes)}, + {"Created At", c.CreatedAt}, + {"Retention Period", formatRetentionPeriodDays(c)}, + } + + for _, field := range fields { + // Only display the field if it has a value. + if field.value != "" { + tp.AddField(field.name, nil, nil) + tp.AddField(field.value, nil, nil) + tp.EndRow() + } + } + + err = tp.Render() + if err != nil { + return err + } + + return nil +} + +func formatGitStatus(codespace codespace) string { + branchWithGitStatus := codespace.branchWithGitStatus() + // u2193 and u2191 are the unicode arrows for down and up + return fmt.Sprintf("%s - %d\u2193 %d\u2191", branchWithGitStatus, codespace.GitStatus.Behind, codespace.GitStatus.Ahead) +} + +func formatRetentionPeriodDays(codespace codespace) string { + days := codespace.RetentionPeriodMinutes / minutesInDay + // Don't display the retention period if it is 0 days + if days == 0 { + return "" + } else if days == 1 { + return "1 day" + } + + return fmt.Sprintf("%d days", days) +} diff --git a/pkg/cmd/codespace/view_test.go b/pkg/cmd/codespace/view_test.go new file mode 100644 index 000000000..edcfefef6 --- /dev/null +++ b/pkg/cmd/codespace/view_test.go @@ -0,0 +1,125 @@ +package codespace + +import ( + "context" + "fmt" + "testing" + + "github.com/cli/cli/v2/internal/codespaces/api" + "github.com/cli/cli/v2/pkg/iostreams" +) + +func Test_NewCmdView(t *testing.T) { + tests := []struct { + tName string + codespaceName string + opts *viewOptions + cliArgs []string + wantErr bool + wantStdout string + errMsg string + }{ + { + tName: "selector throws because no terminal found", + opts: &viewOptions{}, + wantErr: true, + errMsg: "choosing codespace: error getting answers: no terminal", + }, + { + tName: "command fails because provided codespace doesn't exist", + codespaceName: "i-dont-exist", + opts: &viewOptions{}, + wantErr: true, + errMsg: "getting full codespace details: codespace not found", + }, + { + tName: "command succeeds because codespace exists (no details)", + codespaceName: "monalisa-cli-cli-abcdef", + opts: &viewOptions{}, + wantErr: false, + wantStdout: "Name\tmonalisa-cli-cli-abcdef\nGit Status\t - 0↓ 0↑\nIdle Timeout\t0 minutes\n", + }, + { + tName: "command succeeds because codespace exists (with details)", + codespaceName: "monalisa-cli-cli-hijklm", + opts: &viewOptions{}, + wantErr: false, + wantStdout: "Name\tmonalisa-cli-cli-hijklm\nGit Status\tmain* - 2↓ 1↑\nIdle Timeout\t30 minutes\nRetention Period\t1 day\n", + }, + } + + for _, tt := range tests { + t.Run(tt.tName, func(t *testing.T) { + ios, _, stdout, _ := iostreams.Test() + a := &App{ + apiClient: testViewApiMock(), + io: ios, + } + selector := &CodespaceSelector{api: a.apiClient, codespaceName: tt.codespaceName} + tt.opts.selector = selector + + var err error + if tt.cliArgs == nil { + if tt.opts.selector == nil { + t.Fatalf("selector must be set in opts if cliArgs are not provided") + } + + err = a.ViewCodespace(context.Background(), tt.opts) + } else { + cmd := newViewCmd(a) + cmd.SilenceUsage = true + cmd.SilenceErrors = true + cmd.SetOut(ios.ErrOut) + cmd.SetErr(ios.ErrOut) + cmd.SetArgs(tt.cliArgs) + _, err = cmd.ExecuteC() + } + + if tt.wantErr { + if err == nil { + t.Error("Edit() expected error, got nil") + } else if err.Error() != tt.errMsg { + t.Errorf("Edit() error = %q, want %q", err, tt.errMsg) + } + } else if err != nil { + t.Errorf("Edit() expected no error, got %v", err) + } + + if out := stdout.String(); out != tt.wantStdout { + t.Errorf("stdout = %q, want %q", out, tt.wantStdout) + } + }) + } +} + +func testViewApiMock() *apiClientMock { + codespaceWithNoDetails := &api.Codespace{ + Name: "monalisa-cli-cli-abcdef", + } + codespaceWithDetails := &api.Codespace{ + Name: "monalisa-cli-cli-hijklm", + GitStatus: api.CodespaceGitStatus{ + Ahead: 1, + Behind: 2, + Ref: "main", + HasUnpushedChanges: true, + HasUncommittedChanges: true, + }, + IdleTimeoutMinutes: 30, + RetentionPeriodMinutes: 1440, + } + return &apiClientMock{ + GetCodespaceFunc: func(_ context.Context, name string, _ bool) (*api.Codespace, error) { + if name == codespaceWithDetails.Name { + return codespaceWithDetails, nil + } else if name == codespaceWithNoDetails.Name { + return codespaceWithNoDetails, nil + } + + return nil, fmt.Errorf("codespace not found") + }, + ListCodespacesFunc: func(ctx context.Context, opts api.ListCodespacesOptions) ([]*api.Codespace, error) { + return []*api.Codespace{codespaceWithNoDetails, codespaceWithDetails}, nil + }, + } +}