Merge pull request #7496 from dmgardiner25/add-view-cmd

Add `gh cs view` command
This commit is contained in:
David Gardiner 2023-05-30 14:37:34 -07:00 committed by GitHub
commit 06e40dfddb
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 311 additions and 3 deletions

View file

@ -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 {

View file

@ -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")

View file

@ -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
View 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)
}

View 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
},
}
}