Merge pull request #7496 from dmgardiner25/add-view-cmd
Add `gh cs view` command
This commit is contained in:
commit
06e40dfddb
5 changed files with 311 additions and 3 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
143
pkg/cmd/codespace/view.go
Normal file
143
pkg/cmd/codespace/view.go
Normal file
|
|
@ -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/<codespace-name>
|
||||
`),
|
||||
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 <codespace-name>
|
||||
|
||||
# select a codespace from a list of codespaces in a repository
|
||||
$ gh cs view --repo <owner>/<repo>
|
||||
|
||||
# select a codespace from a list of codespaces with a specific repository owner
|
||||
$ gh cs view --repo-owner <org/username>
|
||||
|
||||
# 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)
|
||||
}
|
||||
125
pkg/cmd/codespace/view_test.go
Normal file
125
pkg/cmd/codespace/view_test.go
Normal file
|
|
@ -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
|
||||
},
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue