Skip to content

Commit 027adc7

Browse files
authored
Merge pull request cli#12695 from cli/babakks/retrieve-workflow-dispatch-run-id
feat(workflow run): retrieve workflow dispatch run details
2 parents 40dcfd6 + 31f3756 commit 027adc7

5 files changed

Lines changed: 580 additions & 54 deletions

File tree

internal/featuredetection/detector_mock.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@ func (md *DisabledDetectorMock) ReleaseFeatures() (ReleaseFeatures, error) {
2828
return ReleaseFeatures{}, nil
2929
}
3030

31+
func (md *DisabledDetectorMock) ActionsFeatures() (ActionsFeatures, error) {
32+
return ActionsFeatures{}, nil
33+
}
34+
3135
type EnabledDetectorMock struct{}
3236

3337
func (md *EnabledDetectorMock) IssueFeatures() (IssueFeatures, error) {
@@ -56,6 +60,12 @@ func (md *EnabledDetectorMock) ReleaseFeatures() (ReleaseFeatures, error) {
5660
}, nil
5761
}
5862

63+
func (md *EnabledDetectorMock) ActionsFeatures() (ActionsFeatures, error) {
64+
return ActionsFeatures{
65+
DispatchRunDetails: true,
66+
}, nil
67+
}
68+
5969
type AdvancedIssueSearchDetectorMock struct {
6070
EnabledDetectorMock
6171
searchFeatures SearchFeatures

internal/featuredetection/feature_detection.go

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ type Detector interface {
1818
ProjectsV1() gh.ProjectsV1Support
1919
SearchFeatures() (SearchFeatures, error)
2020
ReleaseFeatures() (ReleaseFeatures, error)
21+
ActionsFeatures() (ActionsFeatures, error)
2122
}
2223

2324
type IssueFeatures struct {
@@ -98,6 +99,16 @@ type ReleaseFeatures struct {
9899
ImmutableReleases bool
99100
}
100101

102+
type ActionsFeatures struct {
103+
// DispatchRunDetails indicates whether the API supports the `return_run_details`
104+
// field in workflow dispatches that, when set to true, will return the details
105+
// of the created workflow run in the response (with status code 200).
106+
//
107+
// On older API versions (e.g. GHES 3.20 or earlier), this new field is not
108+
// supported and setting it will cause an error.
109+
DispatchRunDetails bool
110+
}
111+
101112
type detector struct {
102113
host string
103114
httpClient *http.Client
@@ -393,6 +404,54 @@ func (d *detector) ReleaseFeatures() (ReleaseFeatures, error) {
393404
return ReleaseFeatures{}, nil
394405
}
395406

407+
const (
408+
enterpriseWorkflowDispatchRunDetailsSupport = "3.21.0"
409+
)
410+
411+
func (d *detector) ActionsFeatures() (ActionsFeatures, error) {
412+
// TODO workflowDispatchRunDetailsCleanup
413+
// Once GHES 3.20 support ends, we don't need feature detection for workflow dispatch (i.e. run details support).
414+
//
415+
// On github.com, workflow dispatch API now supports a new field named `return_run_details` that enabling it will
416+
// result in a 200 OK response with the details of the created workflow run. If not set (or set to false), the API
417+
// will keep the old behavior of returning a 204 No Content response.
418+
//
419+
// On GHES (current latest at 3.20), this new field is not available, and setting it will cause a 400 response.
420+
//
421+
// Once GHES 3.20 support ends, we can remove the feature detection and start using the new field in API calls.
422+
//
423+
// IMPORTANT: In the future REST API versions (i.e. breaking changes), the workflow dispatch endpoint is going to
424+
// always return the details of the created workflow run in the response, and the `return_run_details` field is
425+
// going to be ignored/removed. So, once we are migrating to the new API version we should double check the status
426+
// of the API.
427+
428+
if !ghauth.IsEnterprise(d.host) {
429+
return ActionsFeatures{
430+
DispatchRunDetails: true,
431+
}, nil
432+
}
433+
434+
minSupportedVersion, err := version.NewVersion(enterpriseWorkflowDispatchRunDetailsSupport)
435+
if err != nil {
436+
return ActionsFeatures{}, err
437+
}
438+
439+
hostVersion, err := resolveEnterpriseVersion(d.httpClient, d.host)
440+
if err != nil {
441+
return ActionsFeatures{}, err
442+
}
443+
444+
if hostVersion.GreaterThanOrEqual(minSupportedVersion) {
445+
return ActionsFeatures{
446+
DispatchRunDetails: true,
447+
}, nil
448+
}
449+
450+
return ActionsFeatures{
451+
DispatchRunDetails: false,
452+
}, nil
453+
}
454+
396455
func resolveEnterpriseVersion(httpClient *http.Client, host string) (*version.Version, error) {
397456
var metaResponse struct {
398457
InstalledVersion string `json:"installed_version"`

internal/featuredetection/feature_detection_test.go

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -696,3 +696,71 @@ func TestReleaseFeatures(t *testing.T) {
696696
})
697697
}
698698
}
699+
700+
func TestActionsFeatures(t *testing.T) {
701+
tests := []struct {
702+
name string
703+
hostname string
704+
httpStubs func(*httpmock.Registry)
705+
wantFeatures ActionsFeatures
706+
}{
707+
{
708+
name: "github.com, workflow dispatch run details supported",
709+
hostname: "github.com",
710+
wantFeatures: ActionsFeatures{
711+
DispatchRunDetails: true,
712+
},
713+
},
714+
{
715+
name: "ghec data residency (ghe.com), workflow dispatch run details supported",
716+
hostname: "stampname.ghe.com",
717+
wantFeatures: ActionsFeatures{
718+
DispatchRunDetails: true,
719+
},
720+
},
721+
{
722+
name: "GHE 3.20, workflow dispatch run details not supported",
723+
hostname: "git.my.org",
724+
httpStubs: func(reg *httpmock.Registry) {
725+
reg.Register(
726+
httpmock.REST("GET", "api/v3/meta"),
727+
httpmock.StringResponse(`{"installed_version":"3.20.999"}`),
728+
)
729+
},
730+
wantFeatures: ActionsFeatures{
731+
DispatchRunDetails: false,
732+
},
733+
},
734+
{
735+
name: "GHE 3.21, workflow dispatch run details supported",
736+
hostname: "git.my.org",
737+
httpStubs: func(reg *httpmock.Registry) {
738+
reg.Register(
739+
httpmock.REST("GET", "api/v3/meta"),
740+
httpmock.StringResponse(`{"installed_version":"3.21.0"}`),
741+
)
742+
},
743+
wantFeatures: ActionsFeatures{
744+
DispatchRunDetails: true,
745+
},
746+
},
747+
}
748+
749+
for _, tt := range tests {
750+
t.Run(tt.name, func(t *testing.T) {
751+
t.Parallel()
752+
reg := &httpmock.Registry{}
753+
if tt.httpStubs != nil {
754+
tt.httpStubs(reg)
755+
}
756+
httpClient := &http.Client{}
757+
httpmock.ReplaceTripper(httpClient, reg)
758+
759+
detector := NewDetector(httpClient, tt.hostname)
760+
761+
features, err := detector.ActionsFeatures()
762+
require.NoError(t, err)
763+
require.Equal(t, tt.wantFeatures, features)
764+
})
765+
}
766+
}

pkg/cmd/workflow/run/run.go

Lines changed: 63 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,15 @@ import (
77
"fmt"
88
"io"
99
"net/http"
10+
"net/url"
1011
"reflect"
1112
"sort"
1213
"strings"
14+
"time"
1315

1416
"github.com/MakeNowJust/heredoc"
1517
"github.com/cli/cli/v2/api"
18+
fd "github.com/cli/cli/v2/internal/featuredetection"
1619
"github.com/cli/cli/v2/internal/ghrepo"
1720
"github.com/cli/cli/v2/pkg/cmd/workflow/shared"
1821
"github.com/cli/cli/v2/pkg/cmdutil"
@@ -25,6 +28,7 @@ type RunOptions struct {
2528
HttpClient func() (*http.Client, error)
2629
IO *iostreams.IOStreams
2730
BaseRepo func() (ghrepo.Interface, error)
31+
Detector fd.Detector
2832
Prompter iprompter
2933

3034
Selector string
@@ -64,6 +68,8 @@ func NewCmdRun(f *cmdutil.Factory, runF func(*RunOptions) error) *cobra.Command
6468
- Interactively
6569
- Via %[1]s-f/--raw-field%[1]s or %[1]s-F/--field%[1]s flags
6670
- As JSON, via standard input
71+
72+
The created workflow run URL will be returned if available.
6773
`, "`"),
6874
Example: heredoc.Doc(`
6975
# 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 {
260266
return err
261267
}
262268

269+
if opts.Detector == nil {
270+
cachedClient := api.NewCachedHTTPClient(c, time.Hour*24)
271+
opts.Detector = fd.NewDetector(cachedClient, repo.RepoHost())
272+
}
273+
263274
ref := opts.Ref
264275

265276
if ref == "" {
@@ -303,34 +314,77 @@ func runRun(opts *RunOptions) error {
303314
}
304315
}
305316

306-
path := fmt.Sprintf("repos/%s/actions/workflows/%d/dispatches",
307-
ghrepo.FullName(repo), workflow.ID)
317+
features, err := opts.Detector.ActionsFeatures()
318+
if err != nil {
319+
return err
320+
}
321+
322+
path := fmt.Sprintf("repos/%s/%s/actions/workflows/%d/dispatches", url.PathEscape(repo.RepoOwner()), url.PathEscape(repo.RepoName()), workflow.ID)
308323

309-
requestByte, err := json.Marshal(map[string]interface{}{
324+
requestBody := map[string]interface{}{
310325
"ref": ref,
311326
"inputs": providedInputs,
312-
})
327+
}
328+
329+
// TODO workflowDispatchRunDetailsCleanup
330+
// We will have to always set the `return_run_details` field to true, unless
331+
// we opt into the the new REST API version, which will probably return the
332+
// details by default.
333+
if features.DispatchRunDetails {
334+
requestBody["return_run_details"] = true
335+
}
336+
337+
requestByte, err := json.Marshal(requestBody)
313338
if err != nil {
314339
return fmt.Errorf("failed to serialize workflow inputs: %w", err)
315340
}
316341

317342
body := bytes.NewReader(requestByte)
318343

319-
err = client.REST(repo.RepoHost(), "POST", path, body, nil)
344+
var response struct {
345+
WorkflowRunID int64 `json:"workflow_run_id"`
346+
RunURL string `json:"run_url"`
347+
HtmlURL string `json:"html_url"`
348+
}
349+
350+
// Note that the workflow dispatch endpoint used to return 204 No Content
351+
// (with no body, obviously). Now it's possible for the endpoint to also
352+
// return 200 OK with created run details. So, we have to handle both cases
353+
// because old GHE versions still return 204. Even on github.com, we
354+
// may still get 204 for any reason.
355+
//
356+
// Our REST client library is smart enough to ignore JSON unmarshal when it
357+
// receives 204, so we're safe here anyway.
358+
//
359+
// As a related note, the new REST API version (which will come with breaking
360+
// changes) will probably default to return 200 + run details.
361+
err = client.REST(repo.RepoHost(), "POST", path, body, &response)
320362
if err != nil {
321363
return fmt.Errorf("could not create workflow dispatch event: %w", err)
322364
}
323365

324366
if opts.IO.IsStdoutTTY() {
325-
out := opts.IO.Out
326367
cs := opts.IO.ColorScheme()
327-
fmt.Fprintf(out, "%s Created workflow_dispatch event for %s at %s\n",
368+
fmt.Fprintf(opts.IO.Out, "%s Created workflow_dispatch event for %s at %s\n",
328369
cs.SuccessIcon(), cs.Cyan(workflow.Base()), cs.Bold(ref))
329370

330-
fmt.Fprintln(out)
371+
if response.HtmlURL != "" {
372+
fmt.Fprintln(opts.IO.Out, response.HtmlURL)
373+
}
331374

332-
fmt.Fprintf(out, "To see runs for this workflow, try: %s\n",
375+
fmt.Fprintln(opts.IO.Out)
376+
377+
if response.WorkflowRunID != 0 {
378+
fmt.Fprintf(opts.IO.Out, "To see the created workflow run, try: %s\n",
379+
cs.Boldf("gh run view %d", response.WorkflowRunID))
380+
}
381+
382+
fmt.Fprintf(opts.IO.Out, "To see runs for this workflow, try: %s\n",
333383
cs.Boldf("gh run list --workflow=%q", workflow.Base()))
384+
} else {
385+
if response.HtmlURL != "" {
386+
fmt.Fprintln(opts.IO.Out, response.HtmlURL)
387+
}
334388
}
335389

336390
return nil

0 commit comments

Comments
 (0)