Skip to content

Commit 3470632

Browse files
authored
feat(cli): add coder exp task delete (coder#19644)
Fixes coder/internal#897
1 parent 3ac36b8 commit 3470632

File tree

3 files changed

+321
-0
lines changed

3 files changed

+321
-0
lines changed

cli/exp_task.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ func (r *RootCmd) tasksCommand() *serpent.Command {
1616
r.taskList(),
1717
r.taskCreate(),
1818
r.taskStatus(),
19+
r.taskDelete(),
1920
},
2021
}
2122
return cmd

cli/exp_task_delete.go

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
package cli
2+
3+
import (
4+
"fmt"
5+
"strings"
6+
"time"
7+
8+
"github.com/google/uuid"
9+
"golang.org/x/xerrors"
10+
11+
"github.com/coder/pretty"
12+
13+
"github.com/coder/coder/v2/cli/cliui"
14+
"github.com/coder/coder/v2/codersdk"
15+
"github.com/coder/serpent"
16+
)
17+
18+
func (r *RootCmd) taskDelete() *serpent.Command {
19+
client := new(codersdk.Client)
20+
21+
cmd := &serpent.Command{
22+
Use: "delete <task> [<task> ...]",
23+
Short: "Delete experimental tasks",
24+
Middleware: serpent.Chain(
25+
serpent.RequireRangeArgs(1, -1),
26+
r.InitClient(client),
27+
),
28+
Options: serpent.OptionSet{
29+
cliui.SkipPromptOption(),
30+
},
31+
Handler: func(inv *serpent.Invocation) error {
32+
ctx := inv.Context()
33+
exp := codersdk.NewExperimentalClient(client)
34+
35+
type toDelete struct {
36+
ID uuid.UUID
37+
Owner string
38+
Display string
39+
}
40+
41+
var items []toDelete
42+
for _, identifier := range inv.Args {
43+
identifier = strings.TrimSpace(identifier)
44+
if identifier == "" {
45+
return xerrors.New("task identifier cannot be empty or whitespace")
46+
}
47+
48+
// Check task identifier, try UUID first.
49+
if id, err := uuid.Parse(identifier); err == nil {
50+
task, err := exp.TaskByID(ctx, id)
51+
if err != nil {
52+
return xerrors.Errorf("resolve task %q: %w", identifier, err)
53+
}
54+
display := fmt.Sprintf("%s/%s", task.OwnerName, task.Name)
55+
items = append(items, toDelete{ID: id, Display: display, Owner: task.OwnerName})
56+
continue
57+
}
58+
59+
// Non-UUID, treat as a workspace identifier (name or owner/name).
60+
ws, err := namedWorkspace(ctx, client, identifier)
61+
if err != nil {
62+
return xerrors.Errorf("resolve task %q: %w", identifier, err)
63+
}
64+
display := ws.FullName()
65+
items = append(items, toDelete{ID: ws.ID, Display: display, Owner: ws.OwnerName})
66+
}
67+
68+
// Confirm deletion of the tasks.
69+
var displayList []string
70+
for _, it := range items {
71+
displayList = append(displayList, it.Display)
72+
}
73+
_, err := cliui.Prompt(inv, cliui.PromptOptions{
74+
Text: fmt.Sprintf("Delete these tasks: %s?", pretty.Sprint(cliui.DefaultStyles.Code, strings.Join(displayList, ", "))),
75+
IsConfirm: true,
76+
Default: cliui.ConfirmNo,
77+
})
78+
if err != nil {
79+
return err
80+
}
81+
82+
for _, item := range items {
83+
if err := exp.DeleteTask(ctx, item.Owner, item.ID); err != nil {
84+
return xerrors.Errorf("delete task %q: %w", item.Display, err)
85+
}
86+
_, _ = fmt.Fprintln(
87+
inv.Stdout, "Deleted task "+pretty.Sprint(cliui.DefaultStyles.Keyword, item.Display)+" at "+cliui.Timestamp(time.Now()),
88+
)
89+
}
90+
91+
return nil
92+
},
93+
}
94+
95+
return cmd
96+
}

cli/exp_task_delete_test.go

Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
package cli_test
2+
3+
import (
4+
"bytes"
5+
"net/http"
6+
"net/http/httptest"
7+
"strings"
8+
"sync/atomic"
9+
"testing"
10+
11+
"github.com/google/uuid"
12+
"github.com/stretchr/testify/require"
13+
"golang.org/x/xerrors"
14+
15+
"github.com/coder/coder/v2/cli/clitest"
16+
"github.com/coder/coder/v2/coderd/httpapi"
17+
"github.com/coder/coder/v2/codersdk"
18+
"github.com/coder/coder/v2/pty/ptytest"
19+
"github.com/coder/coder/v2/testutil"
20+
)
21+
22+
func TestExpTaskDelete(t *testing.T) {
23+
t.Parallel()
24+
25+
type testCounters struct {
26+
deleteCalls atomic.Int64
27+
nameResolves atomic.Int64
28+
}
29+
type handlerBuilder func(c *testCounters) http.HandlerFunc
30+
31+
type testCase struct {
32+
name string
33+
args []string
34+
promptYes bool
35+
wantErr bool
36+
wantDeleteCalls int64
37+
wantNameResolves int64
38+
wantDeletedMessage int
39+
buildHandler handlerBuilder
40+
}
41+
42+
const (
43+
id1 = "11111111-1111-1111-1111-111111111111"
44+
id2 = "22222222-2222-2222-2222-222222222222"
45+
id3 = "33333333-3333-3333-3333-333333333333"
46+
id4 = "44444444-4444-4444-4444-444444444444"
47+
id5 = "55555555-5555-5555-5555-555555555555"
48+
)
49+
50+
cases := []testCase{
51+
{
52+
name: "Prompted_ByName_OK",
53+
args: []string{"exists"},
54+
promptYes: true,
55+
buildHandler: func(c *testCounters) http.HandlerFunc {
56+
taskID := uuid.MustParse(id1)
57+
return func(w http.ResponseWriter, r *http.Request) {
58+
switch {
59+
case r.Method == http.MethodGet && r.URL.Path == "/api/v2/users/me/workspace/exists":
60+
c.nameResolves.Add(1)
61+
httpapi.Write(r.Context(), w, http.StatusOK, codersdk.Workspace{
62+
ID: taskID,
63+
Name: "exists",
64+
OwnerName: "me",
65+
})
66+
case r.Method == http.MethodDelete && r.URL.Path == "/api/experimental/tasks/me/"+id1:
67+
c.deleteCalls.Add(1)
68+
w.WriteHeader(http.StatusAccepted)
69+
default:
70+
httpapi.InternalServerError(w, xerrors.New("unwanted path: "+r.Method+" "+r.URL.Path))
71+
}
72+
}
73+
},
74+
wantDeleteCalls: 1,
75+
wantNameResolves: 1,
76+
},
77+
{
78+
name: "Prompted_ByUUID_OK",
79+
args: []string{id2},
80+
promptYes: true,
81+
buildHandler: func(c *testCounters) http.HandlerFunc {
82+
return func(w http.ResponseWriter, r *http.Request) {
83+
switch {
84+
case r.Method == http.MethodGet && r.URL.Path == "/api/experimental/tasks/me/"+id2:
85+
httpapi.Write(r.Context(), w, http.StatusOK, codersdk.Task{
86+
ID: uuid.MustParse(id2),
87+
OwnerName: "me",
88+
Name: "uuid-task",
89+
})
90+
case r.Method == http.MethodDelete && r.URL.Path == "/api/experimental/tasks/me/"+id2:
91+
c.deleteCalls.Add(1)
92+
w.WriteHeader(http.StatusAccepted)
93+
default:
94+
httpapi.InternalServerError(w, xerrors.New("unwanted path: "+r.Method+" "+r.URL.Path))
95+
}
96+
}
97+
},
98+
wantDeleteCalls: 1,
99+
},
100+
{
101+
name: "Multiple_YesFlag",
102+
args: []string{"--yes", "first", id4},
103+
buildHandler: func(c *testCounters) http.HandlerFunc {
104+
firstID := uuid.MustParse(id3)
105+
return func(w http.ResponseWriter, r *http.Request) {
106+
switch {
107+
case r.Method == http.MethodGet && r.URL.Path == "/api/v2/users/me/workspace/first":
108+
c.nameResolves.Add(1)
109+
httpapi.Write(r.Context(), w, http.StatusOK, codersdk.Workspace{
110+
ID: firstID,
111+
Name: "first",
112+
OwnerName: "me",
113+
})
114+
case r.Method == http.MethodGet && r.URL.Path == "/api/experimental/tasks/me/"+id4:
115+
httpapi.Write(r.Context(), w, http.StatusOK, codersdk.Task{
116+
ID: uuid.MustParse(id4),
117+
OwnerName: "me",
118+
Name: "uuid-task-2",
119+
})
120+
case r.Method == http.MethodDelete && r.URL.Path == "/api/experimental/tasks/me/"+id3:
121+
c.deleteCalls.Add(1)
122+
w.WriteHeader(http.StatusAccepted)
123+
case r.Method == http.MethodDelete && r.URL.Path == "/api/experimental/tasks/me/"+id4:
124+
c.deleteCalls.Add(1)
125+
w.WriteHeader(http.StatusAccepted)
126+
default:
127+
httpapi.InternalServerError(w, xerrors.New("unwanted path: "+r.Method+" "+r.URL.Path))
128+
}
129+
}
130+
},
131+
wantDeleteCalls: 2,
132+
wantNameResolves: 1,
133+
wantDeletedMessage: 2,
134+
},
135+
{
136+
name: "ResolveNameError",
137+
args: []string{"doesnotexist"},
138+
wantErr: true,
139+
buildHandler: func(_ *testCounters) http.HandlerFunc {
140+
return func(w http.ResponseWriter, r *http.Request) {
141+
switch {
142+
case r.Method == http.MethodGet && r.URL.Path == "/api/v2/users/me/workspace/doesnotexist":
143+
httpapi.ResourceNotFound(w)
144+
default:
145+
httpapi.InternalServerError(w, xerrors.New("unwanted path: "+r.Method+" "+r.URL.Path))
146+
}
147+
}
148+
},
149+
},
150+
{
151+
name: "DeleteError",
152+
args: []string{"bad"},
153+
promptYes: true,
154+
wantErr: true,
155+
buildHandler: func(c *testCounters) http.HandlerFunc {
156+
taskID := uuid.MustParse(id5)
157+
return func(w http.ResponseWriter, r *http.Request) {
158+
switch {
159+
case r.Method == http.MethodGet && r.URL.Path == "/api/v2/users/me/workspace/bad":
160+
c.nameResolves.Add(1)
161+
httpapi.Write(r.Context(), w, http.StatusOK, codersdk.Workspace{
162+
ID: taskID,
163+
Name: "bad",
164+
OwnerName: "me",
165+
})
166+
case r.Method == http.MethodDelete && r.URL.Path == "/api/experimental/tasks/me/"+id5:
167+
httpapi.InternalServerError(w, xerrors.New("boom"))
168+
default:
169+
httpapi.InternalServerError(w, xerrors.New("unwanted path: "+r.Method+" "+r.URL.Path))
170+
}
171+
}
172+
},
173+
wantNameResolves: 1,
174+
},
175+
}
176+
177+
for _, tc := range cases {
178+
t.Run(tc.name, func(t *testing.T) {
179+
t.Parallel()
180+
181+
ctx := testutil.Context(t, testutil.WaitMedium)
182+
183+
var counters testCounters
184+
srv := httptest.NewServer(tc.buildHandler(&counters))
185+
t.Cleanup(srv.Close)
186+
187+
client := codersdk.New(testutil.MustURL(t, srv.URL))
188+
189+
args := append([]string{"exp", "task", "delete"}, tc.args...)
190+
inv, root := clitest.New(t, args...)
191+
inv = inv.WithContext(ctx)
192+
clitest.SetupConfig(t, client, root)
193+
194+
var runErr error
195+
var outBuf bytes.Buffer
196+
if tc.promptYes {
197+
pty := ptytest.New(t).Attach(inv)
198+
w := clitest.StartWithWaiter(t, inv)
199+
pty.ExpectMatch("Delete these tasks:")
200+
pty.WriteLine("yes")
201+
runErr = w.Wait()
202+
outBuf.Write(pty.ReadAll())
203+
} else {
204+
inv.Stdout = &outBuf
205+
inv.Stderr = &outBuf
206+
runErr = inv.Run()
207+
}
208+
209+
if tc.wantErr {
210+
require.Error(t, runErr)
211+
} else {
212+
require.NoError(t, runErr)
213+
}
214+
215+
require.Equal(t, tc.wantDeleteCalls, counters.deleteCalls.Load(), "wrong delete call count")
216+
require.Equal(t, tc.wantNameResolves, counters.nameResolves.Load(), "wrong name resolve count")
217+
218+
if tc.wantDeletedMessage > 0 {
219+
output := outBuf.String()
220+
require.GreaterOrEqual(t, strings.Count(output, "Deleted task"), tc.wantDeletedMessage)
221+
}
222+
})
223+
}
224+
}

0 commit comments

Comments
 (0)