diff --git a/internal/featuredetection/detector_mock.go b/internal/featuredetection/detector_mock.go index b0ca81f40..a2dd8c2f8 100644 --- a/internal/featuredetection/detector_mock.go +++ b/internal/featuredetection/detector_mock.go @@ -28,6 +28,10 @@ func (md *DisabledDetectorMock) ReleaseFeatures() (ReleaseFeatures, error) { return ReleaseFeatures{}, nil } +func (md *DisabledDetectorMock) ActionsFeatures() (ActionsFeatures, error) { + return ActionsFeatures{}, nil +} + type EnabledDetectorMock struct{} func (md *EnabledDetectorMock) IssueFeatures() (IssueFeatures, error) { @@ -56,6 +60,12 @@ func (md *EnabledDetectorMock) ReleaseFeatures() (ReleaseFeatures, error) { }, nil } +func (md *EnabledDetectorMock) ActionsFeatures() (ActionsFeatures, error) { + return ActionsFeatures{ + DispatchRunDetails: true, + }, nil +} + type AdvancedIssueSearchDetectorMock struct { EnabledDetectorMock searchFeatures SearchFeatures diff --git a/internal/featuredetection/feature_detection.go b/internal/featuredetection/feature_detection.go index 2aff20d98..b2fb6d65d 100644 --- a/internal/featuredetection/feature_detection.go +++ b/internal/featuredetection/feature_detection.go @@ -18,6 +18,7 @@ type Detector interface { ProjectsV1() gh.ProjectsV1Support SearchFeatures() (SearchFeatures, error) ReleaseFeatures() (ReleaseFeatures, error) + ActionsFeatures() (ActionsFeatures, error) } type IssueFeatures struct { @@ -98,6 +99,16 @@ type ReleaseFeatures struct { ImmutableReleases bool } +type ActionsFeatures struct { + // DispatchRunDetails indicates whether the API supports the `return_run_details` + // field in workflow dispatches that, when set to true, will return the details + // of the created workflow run in the response (with status code 200). + // + // On older API versions (e.g. GHES 3.20 or earlier), this new field is not + // supported and setting it will cause an error. + DispatchRunDetails bool +} + type detector struct { host string httpClient *http.Client @@ -393,6 +404,54 @@ func (d *detector) ReleaseFeatures() (ReleaseFeatures, error) { return ReleaseFeatures{}, nil } +const ( + enterpriseWorkflowDispatchRunDetailsSupport = "3.21.0" +) + +func (d *detector) ActionsFeatures() (ActionsFeatures, error) { + // TODO workflowDispatchRunDetailsCleanup + // Once GHES 3.20 support ends, we don't need feature detection for workflow dispatch (i.e. run details support). + // + // On github.com, workflow dispatch API now supports a new field named `return_run_details` that enabling it will + // result in a 200 OK response with the details of the created workflow run. If not set (or set to false), the API + // will keep the old behavior of returning a 204 No Content response. + // + // On GHES (current latest at 3.20), this new field is not available, and setting it will cause a 400 response. + // + // Once GHES 3.20 support ends, we can remove the feature detection and start using the new field in API calls. + // + // IMPORTANT: In the future REST API versions (i.e. breaking changes), the workflow dispatch endpoint is going to + // always return the details of the created workflow run in the response, and the `return_run_details` field is + // going to be ignored/removed. So, once we are migrating to the new API version we should double check the status + // of the API. + + if !ghauth.IsEnterprise(d.host) { + return ActionsFeatures{ + DispatchRunDetails: true, + }, nil + } + + minSupportedVersion, err := version.NewVersion(enterpriseWorkflowDispatchRunDetailsSupport) + if err != nil { + return ActionsFeatures{}, err + } + + hostVersion, err := resolveEnterpriseVersion(d.httpClient, d.host) + if err != nil { + return ActionsFeatures{}, err + } + + if hostVersion.GreaterThanOrEqual(minSupportedVersion) { + return ActionsFeatures{ + DispatchRunDetails: true, + }, nil + } + + return ActionsFeatures{ + DispatchRunDetails: false, + }, nil +} + func resolveEnterpriseVersion(httpClient *http.Client, host string) (*version.Version, error) { var metaResponse struct { InstalledVersion string `json:"installed_version"` diff --git a/internal/featuredetection/feature_detection_test.go b/internal/featuredetection/feature_detection_test.go index d3ee1a7e9..f4eeb5962 100644 --- a/internal/featuredetection/feature_detection_test.go +++ b/internal/featuredetection/feature_detection_test.go @@ -696,3 +696,71 @@ func TestReleaseFeatures(t *testing.T) { }) } } + +func TestActionsFeatures(t *testing.T) { + tests := []struct { + name string + hostname string + httpStubs func(*httpmock.Registry) + wantFeatures ActionsFeatures + }{ + { + name: "github.com, workflow dispatch run details supported", + hostname: "github.com", + wantFeatures: ActionsFeatures{ + DispatchRunDetails: true, + }, + }, + { + name: "ghec data residency (ghe.com), workflow dispatch run details supported", + hostname: "stampname.ghe.com", + wantFeatures: ActionsFeatures{ + DispatchRunDetails: true, + }, + }, + { + name: "GHE 3.20, workflow dispatch run details not supported", + hostname: "git.my.org", + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "api/v3/meta"), + httpmock.StringResponse(`{"installed_version":"3.20.999"}`), + ) + }, + wantFeatures: ActionsFeatures{ + DispatchRunDetails: false, + }, + }, + { + name: "GHE 3.21, workflow dispatch run details supported", + hostname: "git.my.org", + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "api/v3/meta"), + httpmock.StringResponse(`{"installed_version":"3.21.0"}`), + ) + }, + wantFeatures: ActionsFeatures{ + DispatchRunDetails: true, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + reg := &httpmock.Registry{} + if tt.httpStubs != nil { + tt.httpStubs(reg) + } + httpClient := &http.Client{} + httpmock.ReplaceTripper(httpClient, reg) + + detector := NewDetector(httpClient, tt.hostname) + + features, err := detector.ActionsFeatures() + require.NoError(t, err) + require.Equal(t, tt.wantFeatures, features) + }) + } +} diff --git a/pkg/cmd/workflow/run/run.go b/pkg/cmd/workflow/run/run.go index 2acd1d4cc..386227bf1 100644 --- a/pkg/cmd/workflow/run/run.go +++ b/pkg/cmd/workflow/run/run.go @@ -7,12 +7,15 @@ import ( "fmt" "io" "net/http" + "net/url" "reflect" "sort" "strings" + "time" "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/api" + fd "github.com/cli/cli/v2/internal/featuredetection" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/pkg/cmd/workflow/shared" "github.com/cli/cli/v2/pkg/cmdutil" @@ -25,6 +28,7 @@ type RunOptions struct { HttpClient func() (*http.Client, error) IO *iostreams.IOStreams BaseRepo func() (ghrepo.Interface, error) + Detector fd.Detector Prompter iprompter Selector string @@ -64,6 +68,8 @@ func NewCmdRun(f *cmdutil.Factory, runF func(*RunOptions) error) *cobra.Command - Interactively - Via %[1]s-f/--raw-field%[1]s or %[1]s-F/--field%[1]s flags - As JSON, via standard input + + The created workflow run URL will be returned if available. `, "`"), Example: heredoc.Doc(` # Have gh prompt you for what workflow you'd like to run and interactively collect inputs @@ -260,6 +266,11 @@ func runRun(opts *RunOptions) error { return err } + if opts.Detector == nil { + cachedClient := api.NewCachedHTTPClient(c, time.Hour*24) + opts.Detector = fd.NewDetector(cachedClient, repo.RepoHost()) + } + ref := opts.Ref if ref == "" { @@ -303,34 +314,77 @@ func runRun(opts *RunOptions) error { } } - path := fmt.Sprintf("repos/%s/actions/workflows/%d/dispatches", - ghrepo.FullName(repo), workflow.ID) + features, err := opts.Detector.ActionsFeatures() + if err != nil { + return err + } - requestByte, err := json.Marshal(map[string]interface{}{ + path := fmt.Sprintf("repos/%s/%s/actions/workflows/%d/dispatches", url.PathEscape(repo.RepoOwner()), url.PathEscape(repo.RepoName()), workflow.ID) + + requestBody := map[string]interface{}{ "ref": ref, "inputs": providedInputs, - }) + } + + // TODO workflowDispatchRunDetailsCleanup + // We will have to always set the `return_run_details` field to true, unless + // we opt into the the new REST API version, which will probably return the + // details by default. + if features.DispatchRunDetails { + requestBody["return_run_details"] = true + } + + requestByte, err := json.Marshal(requestBody) if err != nil { return fmt.Errorf("failed to serialize workflow inputs: %w", err) } body := bytes.NewReader(requestByte) - err = client.REST(repo.RepoHost(), "POST", path, body, nil) + var response struct { + WorkflowRunID int64 `json:"workflow_run_id"` + RunURL string `json:"run_url"` + HtmlURL string `json:"html_url"` + } + + // Note that the workflow dispatch endpoint used to return 204 No Content + // (with no body, obviously). Now it's possible for the endpoint to also + // return 200 OK with created run details. So, we have to handle both cases + // because old GHE versions still return 204. Even on github.com, we + // may still get 204 for any reason. + // + // Our REST client library is smart enough to ignore JSON unmarshal when it + // receives 204, so we're safe here anyway. + // + // As a related note, the new REST API version (which will come with breaking + // changes) will probably default to return 200 + run details. + err = client.REST(repo.RepoHost(), "POST", path, body, &response) if err != nil { return fmt.Errorf("could not create workflow dispatch event: %w", err) } if opts.IO.IsStdoutTTY() { - out := opts.IO.Out cs := opts.IO.ColorScheme() - fmt.Fprintf(out, "%s Created workflow_dispatch event for %s at %s\n", + fmt.Fprintf(opts.IO.Out, "%s Created workflow_dispatch event for %s at %s\n", cs.SuccessIcon(), cs.Cyan(workflow.Base()), cs.Bold(ref)) - fmt.Fprintln(out) + if response.HtmlURL != "" { + fmt.Fprintln(opts.IO.Out, response.HtmlURL) + } - fmt.Fprintf(out, "To see runs for this workflow, try: %s\n", + fmt.Fprintln(opts.IO.Out) + + if response.WorkflowRunID != 0 { + fmt.Fprintf(opts.IO.Out, "To see the created workflow run, try: %s\n", + cs.Boldf("gh run view %d", response.WorkflowRunID)) + } + + fmt.Fprintf(opts.IO.Out, "To see runs for this workflow, try: %s\n", cs.Boldf("gh run list --workflow=%q", workflow.Base())) + } else { + if response.HtmlURL != "" { + fmt.Fprintln(opts.IO.Out, response.HtmlURL) + } } return nil diff --git a/pkg/cmd/workflow/run/run_test.go b/pkg/cmd/workflow/run/run_test.go index b121a573d..a4e44e5da 100644 --- a/pkg/cmd/workflow/run/run_test.go +++ b/pkg/cmd/workflow/run/run_test.go @@ -10,7 +10,9 @@ import ( "os" "testing" + "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/api" + fd "github.com/cli/cli/v2/internal/featuredetection" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/internal/prompter" "github.com/cli/cli/v2/pkg/cmd/workflow/shared" @@ -394,6 +396,7 @@ jobs: run: echo "${{ github.event.inputs.message }} ${{ fromJSON('["", "🥳"]')[github.event.inputs.use-emoji == 'true'] }} ${{ github.event.inputs.name }}"`) encodedYAMLContentMissingChoiceIp := base64.StdEncoding.EncodeToString(yamlContentMissingChoiceIp) + // Old GitHub API servers return 204 No Content for successful workflow dispatches. stubs := func(reg *httpmock.Registry) { reg.Register( httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/workflow.yml"), @@ -406,6 +409,24 @@ jobs: httpmock.StatusStringResponse(204, "cool")) } + // Current GitHub API servers return 200 OK with run info for successful workflow dispatches, + // if `return_run_details` is enabled in the request body. + stubsWithRunInfo := func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/workflow.yml"), + httpmock.JSONResponse(shared.Workflow{ + Path: ".github/workflows/workflow.yml", + ID: 12345, + })) + reg.Register( + httpmock.REST("POST", "repos/OWNER/REPO/actions/workflows/12345/dispatches"), + httpmock.StatusJSONResponse(200, map[string]interface{}{ + "workflow_run_id": int64(6789), + "run_url": "https://api.github.com/repos/OWNER/REPO/actions/runs/6789", + "html_url": "https://github.com/OWNER/REPO/actions/runs/6789", + })) + } + tests := []struct { name string opts *RunOptions @@ -434,11 +455,14 @@ jobs: errOut: "could not parse provided JSON: unexpected end of JSON input", }, { - name: "good JSON", + // TODO workflowDispatchRunDetailsCleanup + // To be deleted + name: "good JSON without run info (204)", tty: true, opts: &RunOptions{ Selector: "workflow.yml", JSONInput: `{"name":"scully"}`, + Detector: &fd.DisabledDetectorMock{}, }, wantBody: map[string]interface{}{ "inputs": map[string]interface{}{ @@ -447,13 +471,44 @@ jobs: "ref": "trunk", }, httpStubs: stubs, - wantOut: "✓ Created workflow_dispatch event for workflow.yml at trunk\n\nTo see runs for this workflow, try: gh run list --workflow=\"workflow.yml\"\n", + wantOut: heredoc.Doc(` + ✓ Created workflow_dispatch event for workflow.yml at trunk + + To see runs for this workflow, try: gh run list --workflow="workflow.yml" + `), }, { - name: "nontty good JSON", + name: "good JSON with run info", + tty: true, opts: &RunOptions{ Selector: "workflow.yml", JSONInput: `{"name":"scully"}`, + Detector: &fd.EnabledDetectorMock{}, + }, + wantBody: map[string]interface{}{ + "inputs": map[string]interface{}{ + "name": "scully", + }, + "ref": "trunk", + "return_run_details": true, + }, + httpStubs: stubsWithRunInfo, + wantOut: heredoc.Doc(` + ✓ Created workflow_dispatch event for workflow.yml at trunk + https://github.com/OWNER/REPO/actions/runs/6789 + + To see the created workflow run, try: gh run view 6789 + To see runs for this workflow, try: gh run list --workflow="workflow.yml" + `), + }, + { + // TODO workflowDispatchRunDetailsCleanup + // To be deleted + name: "nontty good JSON without run info (204)", + opts: &RunOptions{ + Selector: "workflow.yml", + JSONInput: `{"name":"scully"}`, + Detector: &fd.DisabledDetectorMock{}, }, wantBody: map[string]interface{}{ "inputs": map[string]interface{}{ @@ -464,11 +519,31 @@ jobs: httpStubs: stubs, }, { - name: "nontty good input fields", + name: "nontty good JSON with run info", + opts: &RunOptions{ + Selector: "workflow.yml", + JSONInput: `{"name":"scully"}`, + Detector: &fd.EnabledDetectorMock{}, + }, + wantBody: map[string]interface{}{ + "inputs": map[string]interface{}{ + "name": "scully", + }, + "ref": "trunk", + "return_run_details": true, + }, + httpStubs: stubsWithRunInfo, + wantOut: "https://github.com/OWNER/REPO/actions/runs/6789\n", + }, + { + // TODO workflowDispatchRunDetailsCleanup + // To be deleted + name: "nontty good input fields without run info (204)", opts: &RunOptions{ Selector: "workflow.yml", RawFields: []string{`name=scully`}, MagicFields: []string{`greeting=hey`}, + Detector: &fd.DisabledDetectorMock{}, }, wantBody: map[string]interface{}{ "inputs": map[string]interface{}{ @@ -480,12 +555,34 @@ jobs: httpStubs: stubs, }, { - name: "respects ref", + name: "nontty good input fields with run info", + opts: &RunOptions{ + Selector: "workflow.yml", + RawFields: []string{`name=scully`}, + MagicFields: []string{`greeting=hey`}, + Detector: &fd.EnabledDetectorMock{}, + }, + wantBody: map[string]interface{}{ + "inputs": map[string]interface{}{ + "name": "scully", + "greeting": "hey", + }, + "ref": "trunk", + "return_run_details": true, + }, + httpStubs: stubsWithRunInfo, + wantOut: "https://github.com/OWNER/REPO/actions/runs/6789\n", + }, + { + // TODO workflowDispatchRunDetailsCleanup + // To be deleted + name: "respects ref, without run info (204)", tty: true, opts: &RunOptions{ Selector: "workflow.yml", JSONInput: `{"name":"scully"}`, Ref: "good-branch", + Detector: &fd.DisabledDetectorMock{}, }, wantBody: map[string]interface{}{ "inputs": map[string]interface{}{ @@ -494,7 +591,36 @@ jobs: "ref": "good-branch", }, httpStubs: stubs, - wantOut: "✓ Created workflow_dispatch event for workflow.yml at good-branch\n\nTo see runs for this workflow, try: gh run list --workflow=\"workflow.yml\"\n", + wantOut: heredoc.Doc(` + ✓ Created workflow_dispatch event for workflow.yml at good-branch + + To see runs for this workflow, try: gh run list --workflow="workflow.yml" + `), + }, + { + name: "respects ref, with run info", + tty: true, + opts: &RunOptions{ + Selector: "workflow.yml", + JSONInput: `{"name":"scully"}`, + Ref: "good-branch", + Detector: &fd.EnabledDetectorMock{}, + }, + wantBody: map[string]interface{}{ + "inputs": map[string]interface{}{ + "name": "scully", + }, + "ref": "good-branch", + "return_run_details": true, + }, + httpStubs: stubsWithRunInfo, + wantOut: heredoc.Doc(` + ✓ Created workflow_dispatch event for workflow.yml at good-branch + https://github.com/OWNER/REPO/actions/runs/6789 + + To see the created workflow run, try: gh run view 6789 + To see runs for this workflow, try: gh run list --workflow="workflow.yml" + `), }, { // TODO this test is somewhat silly; it's more of a placeholder in case I decide to handle the API error more elegantly @@ -503,6 +629,7 @@ jobs: opts: &RunOptions{ Selector: "workflow.yml", JSONInput: `{"greeting":"hello there"}`, + Detector: &fd.EnabledDetectorMock{}, }, httpStubs: func(reg *httpmock.Registry) { reg.Register( @@ -515,6 +642,13 @@ jobs: httpmock.REST("POST", "repos/OWNER/REPO/actions/workflows/12345/dispatches"), httpmock.StatusStringResponse(422, "missing something")) }, + wantBody: map[string]interface{}{ + "inputs": map[string]interface{}{ + "greeting": "hello there", + }, + "ref": "trunk", + "return_run_details": true, + }, wantErr: true, errOut: "could not create workflow dispatch event: HTTP 422 (https://api.github.com/repos/OWNER/REPO/actions/workflows/12345/dispatches)", }, @@ -523,6 +657,7 @@ jobs: tty: false, opts: &RunOptions{ Selector: "workflow.yaml", + Detector: &fd.EnabledDetectorMock{}, }, httpStubs: func(reg *httpmock.Registry) { reg.Register( @@ -530,13 +665,19 @@ jobs: httpmock.StatusStringResponse(200, `{"id": 12345}`)) reg.Register( httpmock.REST("POST", "repos/OWNER/REPO/actions/workflows/12345/dispatches"), - httpmock.StatusStringResponse(204, "")) + httpmock.StatusJSONResponse(200, map[string]interface{}{ + "workflow_run_id": int64(6789), + "run_url": "https://api.github.com/repos/OWNER/REPO/actions/runs/6789", + "html_url": "https://github.com/OWNER/REPO/actions/runs/6789", + })) }, wantBody: map[string]interface{}{ - "inputs": map[string]interface{}{}, - "ref": "trunk", + "inputs": map[string]interface{}{}, + "ref": "trunk", + "return_run_details": true, }, wantErr: false, + wantOut: "https://github.com/OWNER/REPO/actions/runs/6789\n", }, { // TODO this test is somewhat silly; it's more of a placeholder in case I decide to handle the API error more elegantly @@ -544,6 +685,7 @@ jobs: opts: &RunOptions{ Selector: "workflow.yml", RawFields: []string{`greeting="hello there"`}, + Detector: &fd.EnabledDetectorMock{}, }, httpStubs: func(reg *httpmock.Registry) { reg.Register( @@ -563,7 +705,8 @@ jobs: name: "prompt, no workflows enabled", tty: true, opts: &RunOptions{ - Prompt: true, + Prompt: true, + Detector: &fd.EnabledDetectorMock{}, }, httpStubs: func(reg *httpmock.Registry) { reg.Register( @@ -585,7 +728,8 @@ jobs: name: "prompt, no workflows", tty: true, opts: &RunOptions{ - Prompt: true, + Prompt: true, + Detector: &fd.EnabledDetectorMock{}, }, httpStubs: func(reg *httpmock.Registry) { reg.Register( @@ -598,10 +742,13 @@ jobs: errOut: "could not fetch workflows for OWNER/REPO: no workflows are enabled", }, { - name: "prompt, minimal yaml", + // TODO workflowDispatchRunDetailsCleanup + // To be deleted + name: "prompt, minimal yaml, without run info (204)", tty: true, opts: &RunOptions{ - Prompt: true, + Prompt: true, + Detector: &fd.DisabledDetectorMock{}, }, httpStubs: func(reg *httpmock.Registry) { reg.Register( @@ -634,13 +781,71 @@ jobs: "inputs": map[string]interface{}{}, "ref": "trunk", }, - wantOut: "✓ Created workflow_dispatch event for minimal.yml at trunk\n\nTo see runs for this workflow, try: gh run list --workflow=\"minimal.yml\"\n", + wantOut: heredoc.Doc(` + ✓ Created workflow_dispatch event for minimal.yml at trunk + + To see runs for this workflow, try: gh run list --workflow="minimal.yml" + `), }, { - name: "prompt", + name: "prompt, minimal yaml, with run info", tty: true, opts: &RunOptions{ - Prompt: true, + Prompt: true, + Detector: &fd.EnabledDetectorMock{}, + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows"), + httpmock.JSONResponse(shared.WorkflowsPayload{ + Workflows: []shared.Workflow{ + { + Name: "minimal workflow", + ID: 1, + State: shared.Active, + Path: ".github/workflows/minimal.yml", + }, + }, + })) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/contents/.github/workflows/minimal.yml"), + httpmock.JSONResponse(struct{ Content string }{ + Content: encodedNoInputsYAMLContent, + })) + reg.Register( + httpmock.REST("POST", "repos/OWNER/REPO/actions/workflows/1/dispatches"), + httpmock.StatusJSONResponse(200, map[string]interface{}{ + "workflow_run_id": int64(6789), + "run_url": "https://api.github.com/repos/OWNER/REPO/actions/runs/6789", + "html_url": "https://github.com/OWNER/REPO/actions/runs/6789", + })) + }, + promptStubs: func(pm *prompter.MockPrompter) { + pm.RegisterSelect("Select a workflow", []string{"minimal workflow (minimal.yml)"}, func(_, _ string, opts []string) (int, error) { + return 0, nil + }) + }, + wantBody: map[string]interface{}{ + "inputs": map[string]interface{}{}, + "ref": "trunk", + "return_run_details": true, + }, + wantOut: heredoc.Doc(` + ✓ Created workflow_dispatch event for minimal.yml at trunk + https://github.com/OWNER/REPO/actions/runs/6789 + + To see the created workflow run, try: gh run view 6789 + To see runs for this workflow, try: gh run list --workflow="minimal.yml" + `), + }, + { + // TODO workflowDispatchRunDetailsCleanup + // To be deleted + name: "prompt without run info (204)", + tty: true, + opts: &RunOptions{ + Prompt: true, + Detector: &fd.DisabledDetectorMock{}, }, httpStubs: func(reg *httpmock.Registry) { reg.Register( @@ -682,13 +887,80 @@ jobs: }, "ref": "trunk", }, - wantOut: "✓ Created workflow_dispatch event for workflow.yml at trunk\n\nTo see runs for this workflow, try: gh run list --workflow=\"workflow.yml\"\n", + wantOut: heredoc.Doc(` + ✓ Created workflow_dispatch event for workflow.yml at trunk + + To see runs for this workflow, try: gh run list --workflow="workflow.yml" + `), }, { - name: "prompt, workflow choice input", + name: "prompt with run info", tty: true, opts: &RunOptions{ - Prompt: true, + Prompt: true, + Detector: &fd.EnabledDetectorMock{}, + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows"), + httpmock.JSONResponse(shared.WorkflowsPayload{ + Workflows: []shared.Workflow{ + { + Name: "a workflow", + ID: 12345, + State: shared.Active, + Path: ".github/workflows/workflow.yml", + }, + }, + })) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/contents/.github/workflows/workflow.yml"), + httpmock.JSONResponse(struct{ Content string }{ + Content: encodedYAMLContent, + })) + reg.Register( + httpmock.REST("POST", "repos/OWNER/REPO/actions/workflows/12345/dispatches"), + httpmock.StatusJSONResponse(200, map[string]interface{}{ + "workflow_run_id": int64(6789), + "run_url": "https://api.github.com/repos/OWNER/REPO/actions/runs/6789", + "html_url": "https://github.com/OWNER/REPO/actions/runs/6789", + })) + }, + promptStubs: func(pm *prompter.MockPrompter) { + pm.RegisterSelect("Select a workflow", []string{"a workflow (workflow.yml)"}, func(_, _ string, opts []string) (int, error) { + return 0, nil + }) + pm.RegisterInput("greeting", func(_, _ string) (string, error) { + return "hi", nil + }) + pm.RegisterInput("name (required)", func(_, _ string) (string, error) { + return "scully", nil + }) + }, + wantBody: map[string]interface{}{ + "inputs": map[string]interface{}{ + "name": "scully", + "greeting": "hi", + }, + "ref": "trunk", + "return_run_details": true, + }, + wantOut: heredoc.Doc(` + ✓ Created workflow_dispatch event for workflow.yml at trunk + https://github.com/OWNER/REPO/actions/runs/6789 + + To see the created workflow run, try: gh run view 6789 + To see runs for this workflow, try: gh run list --workflow="workflow.yml" + `), + }, + { + // TODO workflowDispatchRunDetailsCleanup + // To be deleted + name: "prompt, workflow choice input without run info (204)", + tty: true, + opts: &RunOptions{ + Prompt: true, + Detector: &fd.DisabledDetectorMock{}, }, httpStubs: func(reg *httpmock.Registry) { reg.Register( @@ -731,13 +1003,79 @@ jobs: }, "ref": "trunk", }, - wantOut: "✓ Created workflow_dispatch event for workflow.yml at trunk\n\nTo see runs for this workflow, try: gh run list --workflow=\"workflow.yml\"\n", + wantOut: heredoc.Doc(` + ✓ Created workflow_dispatch event for workflow.yml at trunk + + To see runs for this workflow, try: gh run list --workflow="workflow.yml" + `), + }, + { + name: "prompt, workflow choice input with run info", + tty: true, + opts: &RunOptions{ + Prompt: true, + Detector: &fd.EnabledDetectorMock{}, + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows"), + httpmock.JSONResponse(shared.WorkflowsPayload{ + Workflows: []shared.Workflow{ + { + Name: "choice inputs", + ID: 12345, + State: shared.Active, + Path: ".github/workflows/workflow.yml", + }, + }, + })) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/contents/.github/workflows/workflow.yml"), + httpmock.JSONResponse(struct{ Content string }{ + Content: encodedYAMLContentChoiceIp, + })) + reg.Register( + httpmock.REST("POST", "repos/OWNER/REPO/actions/workflows/12345/dispatches"), + httpmock.StatusJSONResponse(200, map[string]interface{}{ + "workflow_run_id": int64(6789), + "run_url": "https://api.github.com/repos/OWNER/REPO/actions/runs/6789", + "html_url": "https://github.com/OWNER/REPO/actions/runs/6789", + })) + }, + promptStubs: func(pm *prompter.MockPrompter) { + pm.RegisterSelect("Select a workflow", []string{"choice inputs (workflow.yml)"}, func(_, _ string, opts []string) (int, error) { + return 0, nil + }) + pm.RegisterSelect("favourite-animal (required)", []string{"dog", "cat"}, func(_, _ string, opts []string) (int, error) { + return 0, nil + }) + pm.RegisterSelect("name", []string{"monalisa", "cschleiden"}, func(_, _ string, opts []string) (int, error) { + return 0, nil + }) + + }, + wantBody: map[string]interface{}{ + "inputs": map[string]interface{}{ + "name": "monalisa", + "favourite-animal": "dog", + }, + "ref": "trunk", + "return_run_details": true, + }, + wantOut: heredoc.Doc(` + ✓ Created workflow_dispatch event for workflow.yml at trunk + https://github.com/OWNER/REPO/actions/runs/6789 + + To see the created workflow run, try: gh run view 6789 + To see runs for this workflow, try: gh run list --workflow="workflow.yml" + `), }, { name: "prompt, workflow choice missing input", tty: true, opts: &RunOptions{ - Prompt: true, + Prompt: true, + Detector: &fd.EnabledDetectorMock{}, }, httpStubs: func(reg *httpmock.Registry) { reg.Register( @@ -757,9 +1095,6 @@ jobs: httpmock.JSONResponse(struct{ Content string }{ Content: encodedYAMLContentMissingChoiceIp, })) - reg.Register( - httpmock.REST("POST", "repos/OWNER/REPO/actions/workflows/12345/dispatches"), - httpmock.StatusStringResponse(204, "cool")) }, promptStubs: func(pm *prompter.MockPrompter) { pm.RegisterSelect("Select a workflow", []string{"choice missing inputs (workflow.yml)"}, func(_, _ string, opts []string) (int, error) { @@ -775,27 +1110,28 @@ jobs: } for _, tt := range tests { - reg := &httpmock.Registry{} - if tt.httpStubs != nil { - tt.httpStubs(reg) - } - tt.opts.HttpClient = func() (*http.Client, error) { - return &http.Client{Transport: reg}, nil - } - - ios, _, stdout, _ := iostreams.Test() - ios.SetStdinTTY(tt.tty) - ios.SetStdoutTTY(tt.tty) - tt.opts.IO = ios - tt.opts.BaseRepo = func() (ghrepo.Interface, error) { - return api.InitRepoHostname(&api.Repository{ - Name: "REPO", - Owner: api.RepositoryOwner{Login: "OWNER"}, - DefaultBranchRef: api.BranchRef{Name: "trunk"}, - }, "github.com"), nil - } - t.Run(tt.name, func(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + if tt.httpStubs != nil { + tt.httpStubs(reg) + } + tt.opts.HttpClient = func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + } + + ios, _, stdout, _ := iostreams.Test() + ios.SetStdinTTY(tt.tty) + ios.SetStdoutTTY(tt.tty) + tt.opts.IO = ios + tt.opts.BaseRepo = func() (ghrepo.Interface, error) { + return api.InitRepoHostname(&api.Repository{ + Name: "REPO", + Owner: api.RepositoryOwner{Login: "OWNER"}, + DefaultBranchRef: api.BranchRef{Name: "trunk"}, + }, "github.com"), nil + } + pm := prompter.NewMockPrompter(t) tt.opts.Prompter = pm if tt.promptStubs != nil { @@ -810,7 +1146,6 @@ jobs: } assert.NoError(t, err) assert.Equal(t, tt.wantOut, stdout.String()) - reg.Verify(t) if len(reg.Requests) > 0 { lastRequest := reg.Requests[len(reg.Requests)-1]