X Tutup
Skip to content

Commit 06c06c8

Browse files
author
Nate Smith
authored
Merge pull request cli#3833 from cristiand391/gh-run-cancel
Add `run cancel` command
2 parents dcf3f60 + a4015b7 commit 06c06c8

File tree

3 files changed

+356
-0
lines changed

3 files changed

+356
-0
lines changed

pkg/cmd/run/cancel/cancel.go

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
package cancel
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
"net/http"
7+
8+
"github.com/cli/cli/v2/api"
9+
"github.com/cli/cli/v2/internal/ghrepo"
10+
"github.com/cli/cli/v2/pkg/cmd/run/shared"
11+
"github.com/cli/cli/v2/pkg/cmdutil"
12+
"github.com/cli/cli/v2/pkg/iostreams"
13+
"github.com/spf13/cobra"
14+
)
15+
16+
type CancelOptions struct {
17+
HttpClient func() (*http.Client, error)
18+
IO *iostreams.IOStreams
19+
BaseRepo func() (ghrepo.Interface, error)
20+
21+
Prompt bool
22+
23+
RunID string
24+
}
25+
26+
func NewCmdCancel(f *cmdutil.Factory, runF func(*CancelOptions) error) *cobra.Command {
27+
opts := &CancelOptions{
28+
IO: f.IOStreams,
29+
HttpClient: f.HttpClient,
30+
}
31+
32+
cmd := &cobra.Command{
33+
Use: "cancel [<run-id>]",
34+
Short: "Cancel a workflow run",
35+
Args: cobra.MaximumNArgs(1),
36+
RunE: func(cmd *cobra.Command, args []string) error {
37+
// support `-R, --repo` override
38+
opts.BaseRepo = f.BaseRepo
39+
40+
if len(args) > 0 {
41+
opts.RunID = args[0]
42+
} else if !opts.IO.CanPrompt() {
43+
return &cmdutil.FlagError{Err: errors.New("run ID required when not running interactively")}
44+
} else {
45+
opts.Prompt = true
46+
}
47+
48+
if runF != nil {
49+
return runF(opts)
50+
}
51+
52+
return runCancel(opts)
53+
},
54+
}
55+
56+
return cmd
57+
}
58+
59+
func runCancel(opts *CancelOptions) error {
60+
httpClient, err := opts.HttpClient()
61+
if err != nil {
62+
return fmt.Errorf("failed to create http client: %w", err)
63+
}
64+
client := api.NewClientFromHTTP(httpClient)
65+
66+
cs := opts.IO.ColorScheme()
67+
68+
repo, err := opts.BaseRepo()
69+
if err != nil {
70+
return fmt.Errorf("failed to determine base repo: %w", err)
71+
}
72+
73+
runID := opts.RunID
74+
var run *shared.Run
75+
76+
if opts.Prompt {
77+
runs, err := shared.GetRunsWithFilter(client, repo, 10, func(run shared.Run) bool {
78+
return run.Status != shared.Completed
79+
})
80+
if err != nil {
81+
return fmt.Errorf("failed to get runs: %w", err)
82+
}
83+
if len(runs) == 0 {
84+
return fmt.Errorf("found no in progress runs to cancel")
85+
}
86+
runID, err = shared.PromptForRun(cs, runs)
87+
if err != nil {
88+
return err
89+
}
90+
// TODO silly stopgap until dust settles and PromptForRun can just return a run
91+
for _, r := range runs {
92+
if fmt.Sprintf("%d", r.ID) == runID {
93+
run = &r
94+
break
95+
}
96+
}
97+
} else {
98+
run, err = shared.GetRun(client, repo, runID)
99+
if err != nil {
100+
var httpErr api.HTTPError
101+
if errors.As(err, &httpErr) {
102+
if httpErr.StatusCode == http.StatusNotFound {
103+
err = fmt.Errorf("Could not find any workflow run with ID %s", opts.RunID)
104+
}
105+
}
106+
return err
107+
}
108+
}
109+
110+
err = cancelWorkflowRun(client, repo, fmt.Sprintf("%d", run.ID))
111+
if err != nil {
112+
var httpErr api.HTTPError
113+
if errors.As(err, &httpErr) {
114+
if httpErr.StatusCode == http.StatusConflict {
115+
err = fmt.Errorf("Cannot cancel a workflow run that is completed")
116+
}
117+
}
118+
119+
return err
120+
}
121+
122+
fmt.Fprintf(opts.IO.Out, "%s Request to cancel workflow submitted.\n", cs.SuccessIcon())
123+
124+
return nil
125+
}
126+
127+
func cancelWorkflowRun(client *api.Client, repo ghrepo.Interface, runID string) error {
128+
path := fmt.Sprintf("repos/%s/actions/runs/%s/cancel", ghrepo.FullName(repo), runID)
129+
130+
err := client.REST(repo.RepoHost(), "POST", path, nil, nil)
131+
if err != nil {
132+
return err
133+
}
134+
135+
return nil
136+
}

pkg/cmd/run/cancel/cancel_test.go

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
package cancel
2+
3+
import (
4+
"bytes"
5+
"io/ioutil"
6+
"net/http"
7+
"testing"
8+
9+
"github.com/cli/cli/v2/internal/ghrepo"
10+
"github.com/cli/cli/v2/pkg/cmd/run/shared"
11+
"github.com/cli/cli/v2/pkg/cmdutil"
12+
"github.com/cli/cli/v2/pkg/httpmock"
13+
"github.com/cli/cli/v2/pkg/iostreams"
14+
"github.com/cli/cli/v2/pkg/prompt"
15+
"github.com/google/shlex"
16+
"github.com/stretchr/testify/assert"
17+
)
18+
19+
func TestNewCmdCancel(t *testing.T) {
20+
tests := []struct {
21+
name string
22+
cli string
23+
tty bool
24+
wants CancelOptions
25+
wantsErr bool
26+
}{
27+
{
28+
name: "blank tty",
29+
tty: true,
30+
wants: CancelOptions{
31+
Prompt: true,
32+
},
33+
},
34+
{
35+
name: "blank nontty",
36+
wantsErr: true,
37+
},
38+
{
39+
name: "with arg",
40+
cli: "1234",
41+
wants: CancelOptions{
42+
RunID: "1234",
43+
},
44+
},
45+
}
46+
47+
for _, tt := range tests {
48+
t.Run(tt.name, func(t *testing.T) {
49+
io, _, _, _ := iostreams.Test()
50+
io.SetStdinTTY(tt.tty)
51+
io.SetStdoutTTY(tt.tty)
52+
53+
f := &cmdutil.Factory{
54+
IOStreams: io,
55+
}
56+
57+
argv, err := shlex.Split(tt.cli)
58+
assert.NoError(t, err)
59+
60+
var gotOpts *CancelOptions
61+
cmd := NewCmdCancel(f, func(opts *CancelOptions) error {
62+
gotOpts = opts
63+
return nil
64+
})
65+
66+
cmd.SetArgs(argv)
67+
cmd.SetIn(&bytes.Buffer{})
68+
cmd.SetOut(ioutil.Discard)
69+
cmd.SetErr(ioutil.Discard)
70+
71+
_, err = cmd.ExecuteC()
72+
if tt.wantsErr {
73+
assert.Error(t, err)
74+
return
75+
}
76+
77+
assert.NoError(t, err)
78+
79+
assert.Equal(t, tt.wants.RunID, gotOpts.RunID)
80+
})
81+
}
82+
}
83+
84+
func TestRunCancel(t *testing.T) {
85+
inProgressRun := shared.TestRun("more runs", 1234, shared.InProgress, "")
86+
completedRun := shared.TestRun("more runs", 4567, shared.Completed, shared.Failure)
87+
tests := []struct {
88+
name string
89+
httpStubs func(*httpmock.Registry)
90+
askStubs func(*prompt.AskStubber)
91+
opts *CancelOptions
92+
wantErr bool
93+
wantOut string
94+
errMsg string
95+
}{
96+
{
97+
name: "cancel run",
98+
opts: &CancelOptions{
99+
RunID: "1234",
100+
},
101+
wantErr: false,
102+
httpStubs: func(reg *httpmock.Registry) {
103+
reg.Register(
104+
httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/1234"),
105+
httpmock.JSONResponse(inProgressRun))
106+
reg.Register(
107+
httpmock.REST("POST", "repos/OWNER/REPO/actions/runs/1234/cancel"),
108+
httpmock.StatusStringResponse(202, "{}"))
109+
},
110+
wantOut: "✓ Request to cancel workflow submitted.\n",
111+
},
112+
{
113+
name: "not found",
114+
opts: &CancelOptions{
115+
RunID: "1234",
116+
},
117+
wantErr: true,
118+
errMsg: "Could not find any workflow run with ID 1234",
119+
httpStubs: func(reg *httpmock.Registry) {
120+
reg.Register(
121+
httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/1234"),
122+
httpmock.StatusStringResponse(404, ""))
123+
},
124+
},
125+
{
126+
name: "completed",
127+
opts: &CancelOptions{
128+
RunID: "4567",
129+
},
130+
wantErr: true,
131+
errMsg: "Cannot cancel a workflow run that is completed",
132+
httpStubs: func(reg *httpmock.Registry) {
133+
reg.Register(
134+
httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/4567"),
135+
httpmock.JSONResponse(completedRun))
136+
reg.Register(
137+
httpmock.REST("POST", "repos/OWNER/REPO/actions/runs/4567/cancel"),
138+
httpmock.StatusStringResponse(409, ""),
139+
)
140+
},
141+
},
142+
{
143+
name: "prompt, no in progress runs",
144+
opts: &CancelOptions{
145+
Prompt: true,
146+
},
147+
wantErr: true,
148+
errMsg: "found no in progress runs to cancel",
149+
httpStubs: func(reg *httpmock.Registry) {
150+
reg.Register(
151+
httpmock.REST("GET", "repos/OWNER/REPO/actions/runs"),
152+
httpmock.JSONResponse(shared.RunsPayload{
153+
WorkflowRuns: []shared.Run{
154+
completedRun,
155+
},
156+
}))
157+
},
158+
},
159+
{
160+
name: "prompt, cancel",
161+
opts: &CancelOptions{
162+
Prompt: true,
163+
},
164+
httpStubs: func(reg *httpmock.Registry) {
165+
reg.Register(
166+
httpmock.REST("GET", "repos/OWNER/REPO/actions/runs"),
167+
httpmock.JSONResponse(shared.RunsPayload{
168+
WorkflowRuns: []shared.Run{
169+
inProgressRun,
170+
},
171+
}))
172+
reg.Register(
173+
httpmock.REST("POST", "repos/OWNER/REPO/actions/runs/1234/cancel"),
174+
httpmock.StatusStringResponse(202, "{}"))
175+
},
176+
askStubs: func(as *prompt.AskStubber) {
177+
as.StubOne(0)
178+
},
179+
wantOut: "✓ Request to cancel workflow submitted.\n",
180+
},
181+
}
182+
183+
for _, tt := range tests {
184+
reg := &httpmock.Registry{}
185+
tt.httpStubs(reg)
186+
tt.opts.HttpClient = func() (*http.Client, error) {
187+
return &http.Client{Transport: reg}, nil
188+
}
189+
190+
io, _, stdout, _ := iostreams.Test()
191+
io.SetStdoutTTY(true)
192+
io.SetStdinTTY(true)
193+
tt.opts.IO = io
194+
tt.opts.BaseRepo = func() (ghrepo.Interface, error) {
195+
return ghrepo.FromFullName("OWNER/REPO")
196+
}
197+
198+
as, teardown := prompt.InitAskStubber()
199+
defer teardown()
200+
if tt.askStubs != nil {
201+
tt.askStubs(as)
202+
}
203+
204+
t.Run(tt.name, func(t *testing.T) {
205+
err := runCancel(tt.opts)
206+
if tt.wantErr {
207+
assert.Error(t, err)
208+
if tt.errMsg != "" {
209+
assert.Equal(t, tt.errMsg, err.Error())
210+
}
211+
} else {
212+
assert.NoError(t, err)
213+
}
214+
assert.Equal(t, tt.wantOut, stdout.String())
215+
reg.Verify(t)
216+
})
217+
}
218+
}

pkg/cmd/run/run.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package run
22

33
import (
4+
cmdCancel "github.com/cli/cli/v2/pkg/cmd/run/cancel"
45
cmdDownload "github.com/cli/cli/v2/pkg/cmd/run/download"
56
cmdList "github.com/cli/cli/v2/pkg/cmd/run/list"
67
cmdRerun "github.com/cli/cli/v2/pkg/cmd/run/rerun"
@@ -26,6 +27,7 @@ func NewCmdRun(f *cmdutil.Factory) *cobra.Command {
2627
cmd.AddCommand(cmdRerun.NewCmdRerun(f, nil))
2728
cmd.AddCommand(cmdDownload.NewCmdDownload(f, nil))
2829
cmd.AddCommand(cmdWatch.NewCmdWatch(f, nil))
30+
cmd.AddCommand(cmdCancel.NewCmdCancel(f, nil))
2931

3032
return cmd
3133
}

0 commit comments

Comments
 (0)
X Tutup