-
Notifications
You must be signed in to change notification settings - Fork 979
feat(cli): add exp task create command #19492
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
8c55100
14ee5c9
6271b22
9189ea6
27e86e3
873456e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,127 @@ | ||
package cli | ||
|
||
import ( | ||
"fmt" | ||
"strings" | ||
"time" | ||
|
||
"github.com/google/uuid" | ||
"golang.org/x/xerrors" | ||
|
||
"github.com/coder/coder/v2/cli/cliui" | ||
"github.com/coder/coder/v2/codersdk" | ||
"github.com/coder/serpent" | ||
) | ||
|
||
func (r *RootCmd) taskCreate() *serpent.Command { | ||
var ( | ||
orgContext = NewOrganizationContext() | ||
client = new(codersdk.Client) | ||
|
||
templateName string | ||
templateVersionName string | ||
presetName string | ||
taskInput string | ||
) | ||
|
||
return &serpent.Command{ | ||
Use: "create [template]", | ||
Short: "Create an experimental task", | ||
Middleware: serpent.Chain( | ||
serpent.RequireRangeArgs(0, 1), | ||
r.InitClient(client), | ||
), | ||
Options: serpent.OptionSet{ | ||
{ | ||
Flag: "input", | ||
Env: "CODER_TASK_INPUT", | ||
Value: serpent.StringOf(&taskInput), | ||
Required: true, | ||
}, | ||
{ | ||
Env: "CODER_TASK_TEMPLATE_NAME", | ||
Value: serpent.StringOf(&templateName), | ||
}, | ||
{ | ||
Env: "CODER_TASK_TEMPLATE_VERSION", | ||
Value: serpent.StringOf(&templateVersionName), | ||
}, | ||
{ | ||
Flag: "preset", | ||
Env: "CODER_TASK_PRESET_NAME", | ||
Value: serpent.StringOf(&presetName), | ||
Default: PresetNone, | ||
}, | ||
}, | ||
Handler: func(inv *serpent.Invocation) error { | ||
var ( | ||
ctx = inv.Context() | ||
expClient = codersdk.NewExperimentalClient(client) | ||
|
||
templateVersionID uuid.UUID | ||
templateVersionPresetID uuid.UUID | ||
) | ||
|
||
organization, err := orgContext.Selected(inv, client) | ||
if err != nil { | ||
return xerrors.Errorf("get current organization: %w", err) | ||
} | ||
|
||
if len(inv.Args) > 0 { | ||
templateName, templateVersionName, _ = strings.Cut(inv.Args[0], "@") | ||
} | ||
|
||
if templateName == "" { | ||
return xerrors.Errorf("template name not provided") | ||
} | ||
|
||
if templateVersionName != "" { | ||
templateVersion, err := client.TemplateVersionByOrganizationAndName(ctx, organization.ID, templateName, templateVersionName) | ||
if err != nil { | ||
return xerrors.Errorf("get template version: %w", err) | ||
} | ||
|
||
templateVersionID = templateVersion.ID | ||
} else { | ||
template, err := client.TemplateByName(ctx, organization.ID, templateName) | ||
if err != nil { | ||
return xerrors.Errorf("get template: %w", err) | ||
} | ||
|
||
templateVersionID = template.ActiveVersionID | ||
} | ||
|
||
if presetName != PresetNone { | ||
templatePresets, err := client.TemplateVersionPresets(ctx, templateVersionID) | ||
if err != nil { | ||
return xerrors.Errorf("get template presets: %w", err) | ||
} | ||
|
||
preset, err := resolvePreset(templatePresets, presetName) | ||
if err != nil { | ||
return xerrors.Errorf("resolve preset: %w", err) | ||
} | ||
|
||
templateVersionPresetID = preset.ID | ||
} | ||
|
||
workspace, err := expClient.CreateTask(ctx, codersdk.Me, codersdk.CreateTaskRequest{ | ||
TemplateVersionID: templateVersionID, | ||
TemplateVersionPresetID: templateVersionPresetID, | ||
Prompt: taskInput, | ||
}) | ||
if err != nil { | ||
return xerrors.Errorf("create task: %w", err) | ||
} | ||
|
||
_, _ = fmt.Fprintf( | ||
inv.Stdout, | ||
"The task %s has been created at %s!\n", | ||
cliui.Keyword(workspace.Name), | ||
cliui.Timestamp(time.Now()), | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The way I've implemented it is how we currently do it for workspace creation but I'm happy to create a quick follow up PR to address this? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Up to you tbh, I'm good with either just thought it made sense so threw it out there. 👍🏻 |
||
) | ||
|
||
return nil | ||
}, | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,227 @@ | ||
package cli_test | ||
|
||
import ( | ||
"context" | ||
"fmt" | ||
"net/http" | ||
"net/http/httptest" | ||
"net/url" | ||
"strings" | ||
"testing" | ||
|
||
"github.com/google/uuid" | ||
"github.com/stretchr/testify/assert" | ||
"github.com/stretchr/testify/require" | ||
|
||
"github.com/coder/coder/v2/cli/clitest" | ||
"github.com/coder/coder/v2/cli/cliui" | ||
"github.com/coder/coder/v2/coderd/httpapi" | ||
"github.com/coder/coder/v2/codersdk" | ||
"github.com/coder/coder/v2/testutil" | ||
"github.com/coder/serpent" | ||
) | ||
|
||
func TestTaskCreate(t *testing.T) { | ||
t.Parallel() | ||
|
||
var ( | ||
organizationID = uuid.New() | ||
templateID = uuid.New() | ||
templateVersionID = uuid.New() | ||
templateVersionPresetID = uuid.New() | ||
) | ||
|
||
templateAndVersionFoundHandler := func(t *testing.T, ctx context.Context, templateName, templateVersionName, presetName, prompt string) http.HandlerFunc { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Decided to create a small helper here instead of writing the same handler over-and-over again. This is following in the footsteps of #19533 by using a http handler to mock There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We might want to add a single "integration"-style test at the end in a separate PR so we know we've all our bases covered. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Sounds good to me |
||
t.Helper() | ||
|
||
return func(w http.ResponseWriter, r *http.Request) { | ||
switch r.URL.Path { | ||
case "/api/v2/users/me/organizations": | ||
httpapi.Write(ctx, w, http.StatusOK, []codersdk.Organization{ | ||
{MinimalOrganization: codersdk.MinimalOrganization{ | ||
ID: organizationID, | ||
}}, | ||
}) | ||
case fmt.Sprintf("/api/v2/organizations/%s/templates/my-template/versions/my-template-version", organizationID): | ||
httpapi.Write(ctx, w, http.StatusOK, codersdk.TemplateVersion{ | ||
ID: templateVersionID, | ||
}) | ||
case fmt.Sprintf("/api/v2/organizations/%s/templates/my-template", organizationID): | ||
httpapi.Write(ctx, w, http.StatusOK, codersdk.Template{ | ||
ID: templateID, | ||
ActiveVersionID: templateVersionID, | ||
}) | ||
case fmt.Sprintf("/api/v2/templateversions/%s/presets", templateVersionID): | ||
httpapi.Write(ctx, w, http.StatusOK, []codersdk.Preset{ | ||
{ | ||
ID: templateVersionPresetID, | ||
Name: presetName, | ||
}, | ||
}) | ||
case "/api/experimental/tasks/me": | ||
var req codersdk.CreateTaskRequest | ||
if !httpapi.Read(ctx, w, r, &req) { | ||
return | ||
} | ||
|
||
assert.Equal(t, prompt, req.Prompt, "prompt mismatch") | ||
assert.Equal(t, templateVersionID, req.TemplateVersionID, "template version mismatch") | ||
|
||
if presetName == "" { | ||
assert.Equal(t, uuid.Nil, req.TemplateVersionPresetID, "expected no template preset id") | ||
} else { | ||
assert.Equal(t, templateVersionPresetID, req.TemplateVersionPresetID, "template version preset id mismatch") | ||
} | ||
|
||
httpapi.Write(ctx, w, http.StatusCreated, codersdk.Workspace{ | ||
Name: "task-wild-goldfish-27", | ||
}) | ||
default: | ||
t.Errorf("unexpected path: %s", r.URL.Path) | ||
} | ||
} | ||
} | ||
|
||
tests := []struct { | ||
args []string | ||
env []string | ||
expectError string | ||
expectOutput string | ||
handler func(t *testing.T, ctx context.Context) http.HandlerFunc | ||
}{ | ||
{ | ||
args: []string{"my-template@my-template-version", "--input", "my custom prompt"}, | ||
expectOutput: fmt.Sprintf("The task %s has been created", cliui.Keyword("task-wild-goldfish-27")), | ||
handler: func(t *testing.T, ctx context.Context) http.HandlerFunc { | ||
return templateAndVersionFoundHandler(t, ctx, "my-template", "my-template-version", "", "my custom prompt") | ||
}, | ||
}, | ||
{ | ||
args: []string{"my-template", "--input", "my custom prompt"}, | ||
env: []string{"CODER_TASK_TEMPLATE_VERSION=my-template-version"}, | ||
expectOutput: fmt.Sprintf("The task %s has been created", cliui.Keyword("task-wild-goldfish-27")), | ||
handler: func(t *testing.T, ctx context.Context) http.HandlerFunc { | ||
return templateAndVersionFoundHandler(t, ctx, "my-template", "my-template-version", "", "my custom prompt") | ||
}, | ||
}, | ||
{ | ||
args: []string{"--input", "my custom prompt"}, | ||
env: []string{"CODER_TASK_TEMPLATE_NAME=my-template", "CODER_TASK_TEMPLATE_VERSION=my-template-version"}, | ||
expectOutput: fmt.Sprintf("The task %s has been created", cliui.Keyword("task-wild-goldfish-27")), | ||
handler: func(t *testing.T, ctx context.Context) http.HandlerFunc { | ||
return templateAndVersionFoundHandler(t, ctx, "my-template", "my-template-version", "", "my custom prompt") | ||
}, | ||
}, | ||
{ | ||
env: []string{"CODER_TASK_TEMPLATE_NAME=my-template", "CODER_TASK_TEMPLATE_VERSION=my-template-version", "CODER_TASK_INPUT=my custom prompt"}, | ||
expectOutput: fmt.Sprintf("The task %s has been created", cliui.Keyword("task-wild-goldfish-27")), | ||
handler: func(t *testing.T, ctx context.Context) http.HandlerFunc { | ||
return templateAndVersionFoundHandler(t, ctx, "my-template", "my-template-version", "", "my custom prompt") | ||
}, | ||
}, | ||
{ | ||
args: []string{"my-template", "--input", "my custom prompt"}, | ||
expectOutput: fmt.Sprintf("The task %s has been created", cliui.Keyword("task-wild-goldfish-27")), | ||
handler: func(t *testing.T, ctx context.Context) http.HandlerFunc { | ||
return templateAndVersionFoundHandler(t, ctx, "my-template", "", "", "my custom prompt") | ||
}, | ||
}, | ||
{ | ||
args: []string{"my-template", "--input", "my custom prompt", "--preset", "my-preset"}, | ||
expectOutput: fmt.Sprintf("The task %s has been created", cliui.Keyword("task-wild-goldfish-27")), | ||
handler: func(t *testing.T, ctx context.Context) http.HandlerFunc { | ||
return templateAndVersionFoundHandler(t, ctx, "my-template", "", "my-preset", "my custom prompt") | ||
}, | ||
}, | ||
{ | ||
args: []string{"my-template", "--input", "my custom prompt"}, | ||
env: []string{"CODER_TASK_PRESET_NAME=my-preset"}, | ||
expectOutput: fmt.Sprintf("The task %s has been created", cliui.Keyword("task-wild-goldfish-27")), | ||
handler: func(t *testing.T, ctx context.Context) http.HandlerFunc { | ||
return templateAndVersionFoundHandler(t, ctx, "my-template", "", "my-preset", "my custom prompt") | ||
}, | ||
}, | ||
{ | ||
args: []string{"my-template", "--input", "my custom prompt", "--preset", "not-real-preset"}, | ||
expectError: `preset "not-real-preset" not found`, | ||
handler: func(t *testing.T, ctx context.Context) http.HandlerFunc { | ||
return templateAndVersionFoundHandler(t, ctx, "my-template", "", "my-preset", "my custom prompt") | ||
}, | ||
}, | ||
{ | ||
args: []string{"my-template@not-real-template-version", "--input", "my custom prompt"}, | ||
expectError: httpapi.ResourceNotFoundResponse.Message, | ||
handler: func(t *testing.T, ctx context.Context) http.HandlerFunc { | ||
return func(w http.ResponseWriter, r *http.Request) { | ||
switch r.URL.Path { | ||
case "/api/v2/users/me/organizations": | ||
httpapi.Write(ctx, w, http.StatusOK, []codersdk.Organization{ | ||
{MinimalOrganization: codersdk.MinimalOrganization{ | ||
ID: organizationID, | ||
}}, | ||
}) | ||
case fmt.Sprintf("/api/v2/organizations/%s/templates/my-template/versions/not-real-template-version", organizationID): | ||
httpapi.ResourceNotFound(w) | ||
default: | ||
t.Errorf("unexpected path: %s", r.URL.Path) | ||
} | ||
} | ||
}, | ||
}, | ||
{ | ||
args: []string{"not-real-template", "--input", "my custom prompt"}, | ||
expectError: httpapi.ResourceNotFoundResponse.Message, | ||
handler: func(t *testing.T, ctx context.Context) http.HandlerFunc { | ||
return func(w http.ResponseWriter, r *http.Request) { | ||
switch r.URL.Path { | ||
case "/api/v2/users/me/organizations": | ||
httpapi.Write(ctx, w, http.StatusOK, []codersdk.Organization{ | ||
{MinimalOrganization: codersdk.MinimalOrganization{ | ||
ID: organizationID, | ||
}}, | ||
}) | ||
case fmt.Sprintf("/api/v2/organizations/%s/templates/not-real-template", organizationID): | ||
httpapi.ResourceNotFound(w) | ||
default: | ||
t.Errorf("unexpected path: %s", r.URL.Path) | ||
} | ||
} | ||
}, | ||
}, | ||
} | ||
|
||
for _, tt := range tests { | ||
t.Run(strings.Join(tt.args, ","), func(t *testing.T) { | ||
t.Parallel() | ||
|
||
var ( | ||
ctx = testutil.Context(t, testutil.WaitShort) | ||
srv = httptest.NewServer(tt.handler(t, ctx)) | ||
client = new(codersdk.Client) | ||
args = []string{"exp", "task", "create"} | ||
sb strings.Builder | ||
err error | ||
) | ||
|
||
t.Cleanup(srv.Close) | ||
|
||
client.URL, err = url.Parse(srv.URL) | ||
require.NoError(t, err) | ||
|
||
inv, root := clitest.New(t, append(args, tt.args...)...) | ||
inv.Environ = serpent.ParseEnviron(tt.env, "") | ||
inv.Stdout = &sb | ||
inv.Stderr = &sb | ||
clitest.SetupConfig(t, client, root) | ||
|
||
err = inv.WithContext(ctx).Run() | ||
if tt.expectError == "" { | ||
assert.NoError(t, err) | ||
} else { | ||
assert.ErrorContains(t, err, tt.expectError) | ||
} | ||
|
||
assert.Contains(t, sb.String(), tt.expectOutput) | ||
}) | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Suggestion: Not sure if we're too far gone to do this, but potential for rename: Prompt -> Input depending on how we want to standardize this.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
As it is all experimental we technically still have time