Skip to content

Commit 5cee9e1

Browse files
committed
Add interactive prompt to choose from list of available devcontainer files
1 parent e7f888a commit 5cee9e1

File tree

5 files changed

+240
-2
lines changed

5 files changed

+240
-2
lines changed

internal/codespaces/api/api.go

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -605,6 +605,64 @@ func (a *API) DeleteCodespace(ctx context.Context, codespaceName string) error {
605605
return nil
606606
}
607607

608+
// ListDevContainers returns a list of valid devcontainer.json files for the repo. Pass a negative limit to request all pages from
609+
// the API until all devcontainer.json files have been fetched.
610+
func (a *API) ListDevContainers(ctx context.Context, repoID int, branch string, limit int) (devcontainers []string, err error) {
611+
perPage := 100
612+
if limit > 0 && limit < 100 {
613+
perPage = limit
614+
}
615+
616+
listURL := fmt.Sprintf("%s/repositories/%d/codespaces/devcontainers?per_page=%d", a.githubAPI, repoID, perPage)
617+
if branch != "" {
618+
listURL += "&ref=" + branch
619+
}
620+
for {
621+
req, err := http.NewRequest(http.MethodGet, listURL, nil)
622+
if err != nil {
623+
return nil, fmt.Errorf("error creating request: %w", err)
624+
}
625+
a.setHeaders(req)
626+
627+
resp, err := a.do(ctx, req, fmt.Sprintf("/repositories/%d/codespaces/devcontainers", repoID))
628+
if err != nil {
629+
return nil, fmt.Errorf("error making request: %w", err)
630+
}
631+
defer resp.Body.Close()
632+
633+
if resp.StatusCode != http.StatusOK {
634+
return nil, api.HandleHTTPError(resp)
635+
}
636+
637+
var response struct {
638+
Devcontainers []string `json:"devcontainers"`
639+
}
640+
dec := json.NewDecoder(resp.Body)
641+
if err := dec.Decode(&response); err != nil {
642+
return nil, fmt.Errorf("error unmarshaling response: %w", err)
643+
}
644+
645+
nextURL := findNextPage(resp.Header.Get("Link"))
646+
devcontainers = append(devcontainers, response.Devcontainers...)
647+
648+
if nextURL == "" || (limit > 0 && len(devcontainers) >= limit) {
649+
break
650+
}
651+
652+
if newPerPage := limit - len(devcontainers); limit > 0 && newPerPage < 100 {
653+
u, _ := url.Parse(nextURL)
654+
q := u.Query()
655+
q.Set("per_page", strconv.Itoa(newPerPage))
656+
u.RawQuery = q.Encode()
657+
listURL = u.String()
658+
} else {
659+
listURL = nextURL
660+
}
661+
}
662+
663+
return devcontainers, nil
664+
}
665+
608666
type getCodespaceRepositoryContentsResponse struct {
609667
Content string `json:"content"`
610668
}

pkg/cmd/codespace/common.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ type apiClient interface {
6767
GetCodespaceRegionLocation(ctx context.Context) (string, error)
6868
GetCodespacesMachines(ctx context.Context, repoID int, branch, location string) ([]*api.Machine, error)
6969
GetCodespaceRepositoryContents(ctx context.Context, codespace *api.Codespace, path string) ([]byte, error)
70+
ListDevContainers(ctx context.Context, repoID int, branch string, limit int) (devcontainers []string, err error)
7071
}
7172

7273
var errNoCodespaces = errors.New("you have no codespaces")

pkg/cmd/codespace/create.go

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,37 @@ func (a *App) Create(ctx context.Context, opts createOptions) error {
9191
branch = repository.DefaultBranch
9292
}
9393

94+
devContainerPath := opts.devContainerPath
95+
96+
// now that we have repo+branch, we can list available devcontainer.json files (if any)
97+
if len(opts.devContainerPath) < 1 {
98+
a.StartProgressIndicatorWithLabel("Fetching devcontainer.json files")
99+
devContainerPaths, err := a.apiClient.ListDevContainers(ctx, repository.ID, branch, 100)
100+
if err != nil {
101+
return fmt.Errorf("error getting devcontainer.json paths: %w", err)
102+
}
103+
a.StopProgressIndicator()
104+
105+
if len(devContainerPaths) > 0 {
106+
devContainerPathQuestion := &survey.Question{
107+
Name: "devContainerPath",
108+
Prompt: &survey.Select{
109+
Message: "Devcontainer.json file:",
110+
Options: append([]string{"none"}, devContainerPaths...),
111+
},
112+
}
113+
114+
if err := ask([]*survey.Question{devContainerPathQuestion}, &devContainerPath); err != nil {
115+
return fmt.Errorf("failed to prompt: %w", err)
116+
}
117+
}
118+
119+
if devContainerPath == "none" {
120+
// special arg allows users to opt out of devcontainer.json selection
121+
devContainerPath = ""
122+
}
123+
}
124+
94125
locationResult := <-locationCh
95126
if locationResult.Err != nil {
96127
return fmt.Errorf("error getting codespace region location: %w", locationResult.Err)
@@ -111,7 +142,7 @@ func (a *App) Create(ctx context.Context, opts createOptions) error {
111142
Machine: machine,
112143
Location: locationResult.Location,
113144
IdleTimeoutMinutes: int(opts.idleTimeout.Minutes()),
114-
DevContainerPath: opts.devContainerPath,
145+
DevContainerPath: devContainerPath,
115146
})
116147
a.StopProgressIndicator()
117148
if err != nil {

pkg/cmd/codespace/create_test.go

Lines changed: 88 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ func TestApp_Create(t *testing.T) {
1919
fields fields
2020
opts createOptions
2121
wantErr bool
22+
wantErrMsg string
2223
wantStdout string
2324
wantStderr string
2425
}{
@@ -70,17 +71,103 @@ func TestApp_Create(t *testing.T) {
7071
},
7172
wantStdout: "monalisa-dotfiles-abcd1234\n",
7273
},
74+
{
75+
name: "create codespace with default branch with default devcontainer if no path provided",
76+
fields: fields{
77+
apiClient: &apiClientMock{
78+
GetCodespaceRegionLocationFunc: func(ctx context.Context) (string, error) {
79+
return "EUROPE", nil
80+
},
81+
GetRepositoryFunc: func(ctx context.Context, nwo string) (*api.Repository, error) {
82+
return &api.Repository{
83+
ID: 1234,
84+
FullName: nwo,
85+
DefaultBranch: "main",
86+
}, nil
87+
},
88+
ListDevContainersFunc: func(ctx context.Context, repoID int, branch string, limit int) ([]string, error) {
89+
return []string{}, nil
90+
},
91+
GetCodespacesMachinesFunc: func(ctx context.Context, repoID int, branch, location string) ([]*api.Machine, error) {
92+
return []*api.Machine{
93+
{
94+
Name: "GIGA",
95+
DisplayName: "Gigabits of a machine",
96+
},
97+
}, nil
98+
},
99+
CreateCodespaceFunc: func(ctx context.Context, params *api.CreateCodespaceParams) (*api.Codespace, error) {
100+
if params.Branch != "main" {
101+
return nil, fmt.Errorf("got branch %q, want %q", params.Branch, "main")
102+
}
103+
if params.IdleTimeoutMinutes != 30 {
104+
return nil, fmt.Errorf("idle timeout minutes was %v", params.IdleTimeoutMinutes)
105+
}
106+
if params.DevContainerPath != "" {
107+
return nil, fmt.Errorf("got dev container path %q, want %q", params.DevContainerPath, ".devcontainer/foobar/devcontainer.json")
108+
}
109+
return &api.Codespace{
110+
Name: "monalisa-dotfiles-abcd1234",
111+
}, nil
112+
},
113+
},
114+
},
115+
opts: createOptions{
116+
repo: "monalisa/dotfiles",
117+
branch: "",
118+
machine: "GIGA",
119+
showStatus: false,
120+
idleTimeout: 30 * time.Minute,
121+
},
122+
wantStdout: "monalisa-dotfiles-abcd1234\n",
123+
},
124+
{
125+
name: "returns error when getting devcontainer paths fails",
126+
fields: fields{
127+
apiClient: &apiClientMock{
128+
GetCodespaceRegionLocationFunc: func(ctx context.Context) (string, error) {
129+
return "EUROPE", nil
130+
},
131+
GetRepositoryFunc: func(ctx context.Context, nwo string) (*api.Repository, error) {
132+
return &api.Repository{
133+
ID: 1234,
134+
FullName: nwo,
135+
DefaultBranch: "main",
136+
}, nil
137+
},
138+
ListDevContainersFunc: func(ctx context.Context, repoID int, branch string, limit int) ([]string, error) {
139+
return nil, fmt.Errorf("some error")
140+
},
141+
},
142+
},
143+
opts: createOptions{
144+
repo: "monalisa/dotfiles",
145+
branch: "",
146+
machine: "GIGA",
147+
showStatus: false,
148+
idleTimeout: 30 * time.Minute,
149+
},
150+
wantErr: true,
151+
wantErrMsg: "error getting devcontainer.json paths: some error",
152+
},
73153
}
74154
for _, tt := range tests {
75155
t.Run(tt.name, func(t *testing.T) {
76156
io, _, stdout, stderr := iostreams.Test()
157+
io.SetStdinTTY(true)
158+
io.SetStdoutTTY(true)
159+
77160
a := &App{
78161
io: io,
79162
apiClient: tt.fields.apiClient,
80163
}
81-
if err := a.Create(context.Background(), tt.opts); (err != nil) != tt.wantErr {
164+
err := a.Create(context.Background(), tt.opts)
165+
if (err != nil) != tt.wantErr {
82166
t.Errorf("App.Create() error = %v, wantErr %v", err, tt.wantErr)
83167
}
168+
if tt.wantErrMsg != "" && err.Error() != tt.wantErrMsg {
169+
t.Errorf("err message = %v, wantErrMsg %v", err.Error(), tt.wantErrMsg)
170+
}
84171
if got := stdout.String(); got != tt.wantStdout {
85172
t.Errorf("stdout = %v, want %v", got, tt.wantStdout)
86173
}

pkg/cmd/codespace/mock_api.go

Lines changed: 61 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)