Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cli/exp_task.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ func (r *RootCmd) tasksCommand() *serpent.Command {
},
Children: []*serpent.Command{
r.taskList(),
r.taskCreate(),
},
}
return cmd
Expand Down
127 changes: 127 additions & 0 deletions cli/exp_taskcreate.go
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,
Copy link
Member

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.

Copy link
Contributor Author

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

})
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()),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

workspace.CreatedAt?

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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?

Copy link
Member

Choose a reason for hiding this comment

The 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
},
}
}
227 changes: 227 additions & 0 deletions cli/exp_taskcreate_test.go
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 {
Copy link
Contributor Author

Choose a reason for hiding this comment

The 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 coderd behavior instead of using coderdtest.

Copy link
Member

Choose a reason for hiding this comment

The 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.

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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)
})
}
}
Loading