diff --git a/internal/dispatch/fixtures_test.go b/internal/dispatch/fixtures_test.go index 5259bc5..096c87c 100644 --- a/internal/dispatch/fixtures_test.go +++ b/internal/dispatch/fixtures_test.go @@ -2,11 +2,7 @@ package dispatch var ( currentUserResponse string = `{ - "data": { - "viewer": { - "login": "mdb" - } - } + "login": "mdb" }` getWorkflowsResponse string = `{ diff --git a/internal/dispatch/ghrepo.go b/internal/dispatch/ghrepo.go index d4979e4..b5ea2cc 100644 --- a/internal/dispatch/ghrepo.go +++ b/internal/dispatch/ghrepo.go @@ -23,11 +23,7 @@ func getRepoOption(cmd *cobra.Command) (*ghRepo, error) { return repo, nil } -// ghRepo satisfies the ghrepo interface. -// In the context of gh-dispatch, it enables the reuse of -// functions packaged in the upstream github.com/cli/cli -// codebase for rendering GH Actions run output. -// See github.com/cli/cli/v2/internal/ghrepo. +// ghRepo identifies a GitHub repository and the host on which it lives. type ghRepo struct { Owner string Name string diff --git a/internal/dispatch/renderutils.go b/internal/dispatch/renderutils.go index 6ecfd22..de6a13d 100644 --- a/internal/dispatch/renderutils.go +++ b/internal/dispatch/renderutils.go @@ -4,17 +4,17 @@ import ( "bytes" "fmt" "io" + "strings" "time" - cliapi "github.com/cli/cli/v2/api" - "github.com/cli/cli/v2/pkg/cmd/run/shared" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" + "github.com/cli/go-gh/v2/pkg/api" ) -func render(ios *iostreams.IOStreams, client *cliapi.Client, repo *ghRepo, run *shared.Run) error { +func render(ios *iostreams.IOStreams, client *api.RESTClient, repo *ghRepo, run *workflowRun) error { cs := ios.ColorScheme() - annotationCache := map[int64][]shared.Annotation{} + annotationCache := map[int64][]annotation{} out := &bytes.Buffer{} ios.StartAlternateScreenBuffer() @@ -42,7 +42,7 @@ func render(ios *iostreams.IOStreams, client *cliapi.Client, repo *ghRepo, run * return err } - if run.Status == shared.Completed { + if run.Status == statusCompleted { break } @@ -55,7 +55,7 @@ func render(ios *iostreams.IOStreams, client *cliapi.Client, repo *ghRepo, run * ios.StopAlternateScreenBuffer() - symbol, symbolColor := shared.Symbol(cs, run.Status, run.Conclusion) + symbol, symbolColor := renderSymbol(cs, run.Status, run.Conclusion) id := cs.Cyanf("%d", run.ID) if ios.IsStdoutTTY() { @@ -63,7 +63,7 @@ func render(ios *iostreams.IOStreams, client *cliapi.Client, repo *ghRepo, run * fmt.Fprintf(ios.Out, "%s %s (%s) completed with '%s'\n", symbolColor(symbol), cs.Bold(run.Name), id, run.Conclusion) } - if run.Conclusion != shared.Success { + if run.Conclusion != conclusionSuccess { return cmdutil.SilentError } @@ -72,33 +72,33 @@ func render(ios *iostreams.IOStreams, client *cliapi.Client, repo *ghRepo, run * // renderRun is largely an emulation of the upstream 'gh run watch' implementation... // https://github.com/cli/cli/blob/v2.20.2/pkg/cmd/run/watch/watch.go -func renderRun(out io.Writer, cs *iostreams.ColorScheme, client *cliapi.Client, repo *ghRepo, run *shared.Run, annotationCache map[int64][]shared.Annotation) (*shared.Run, error) { - run, err := shared.GetRun(client, repo, fmt.Sprintf("%d", run.ID), 0) +func renderRun(out io.Writer, cs *iostreams.ColorScheme, client *api.RESTClient, repo *ghRepo, run *workflowRun, annotationCache map[int64][]annotation) (*workflowRun, error) { + run, err := getRun(client, repo, run.ID) if err != nil { return nil, fmt.Errorf("failed to get run: %w", err) } - jobs, err := shared.GetJobs(client, repo, run, 0) + jobs, err := getJobs(client, run) if err != nil { return nil, fmt.Errorf("failed to get jobs: %w", err) } - var annotations []shared.Annotation + var annotations []annotation var annotationErr error - var as []shared.Annotation + var as []annotation for _, job := range jobs { if as, ok := annotationCache[job.ID]; ok { annotations = as continue } - as, annotationErr = shared.GetAnnotations(client, repo, job) + as, annotationErr = getAnnotations(client, repo, job) if annotationErr != nil { break } annotations = append(annotations, as...) - if job.Status != shared.InProgress { + if job.Status != statusInProgress { annotationCache[job.ID] = annotations } } @@ -107,7 +107,7 @@ func renderRun(out io.Writer, cs *iostreams.ColorScheme, client *cliapi.Client, return nil, fmt.Errorf("failed to get annotations: %w", annotationErr) } - fmt.Fprintln(out, shared.RenderRunHeader(cs, *run, "", "", 0)) + fmt.Fprintln(out, renderRunHeader(cs, run)) fmt.Fprintln(out) if len(jobs) == 0 { @@ -115,28 +115,25 @@ func renderRun(out io.Writer, cs *iostreams.ColorScheme, client *cliapi.Client, } fmt.Fprintln(out, cs.Bold("JOBS")) - fmt.Fprintln(out, shared.RenderJobs(cs, jobs, true)) + fmt.Fprintln(out, renderJobs(cs, jobs)) if len(annotations) > 0 { fmt.Fprintln(out) fmt.Fprintln(out, cs.Bold("ANNOTATIONS")) - fmt.Fprintln(out, shared.RenderAnnotations(cs, annotations)) + fmt.Fprintln(out, renderAnnotations(cs, annotations)) } return run, nil } -func getRunID(client *cliapi.Client, repo *ghRepo, event string, workflowID int64, dispatchedAt time.Time) (int64, error) { - actor, err := cliapi.CurrentLoginName(client, repo.RepoHost()) +func getRunID(client *api.RESTClient, repo *ghRepo, event string, workflowID int64, dispatchedAt time.Time) (int64, error) { + actor, err := currentLoginName(client) if err != nil { return 0, err } for { - runs, err := shared.GetRunsWithFilter(client, repo, &shared.FilterOptions{ - WorkflowID: workflowID, - Actor: actor, - }, 1, func(run shared.Run) bool { + runs, err := getRuns(client, repo, workflowID, actor, func(run workflowRun) bool { // TODO: should this try to match on a branch too? // https://github.com/cli/cli/blob/trunk/pkg/cmd/run/shared/shared.go#L281 return run.WorkflowID == workflowID && run.Event == event && !run.CreatedAt.Before(dispatchedAt) @@ -150,3 +147,72 @@ func getRunID(client *cliapi.Client, repo *ghRepo, event string, workflowID int6 } } } + +// The following render functions are adapted from the upstream +// github.com/cli/cli pkg/cmd/run/shared package. + +func renderRunHeader(cs *iostreams.ColorScheme, run *workflowRun) string { + symbol, symbolColor := renderSymbol(cs, run.Status, run.Conclusion) + title := fmt.Sprintf("%s %s", cs.Bold(run.HeadBranch), run.WorkflowName) + id := cs.Cyanf("%d", run.ID) + + return fmt.Sprintf("%s %s ยท %s\nTriggered via %s ", symbolColor(symbol), title, id, run.Event) +} + +func renderJobs(cs *iostreams.ColorScheme, jobs []job) string { + lines := []string{} + for _, j := range jobs { + elapsed := j.CompletedAt.Sub(j.StartedAt) + elapsedStr := fmt.Sprintf(" in %s", elapsed) + if elapsed < 0 { + elapsedStr = "" + } + symbol, symbolColor := renderSymbol(cs, j.Status, j.Conclusion) + id := cs.Cyanf("%d", j.ID) + lines = append(lines, fmt.Sprintf("%s %s%s (ID %s)", symbolColor(symbol), cs.Bold(j.Name), elapsedStr, id)) + for _, s := range j.Steps { + stepSymbol, stepSymbolColor := renderSymbol(cs, s.Status, s.Conclusion) + lines = append(lines, fmt.Sprintf(" %s %s", stepSymbolColor(stepSymbol), s.Name)) + } + } + + return strings.Join(lines, "\n") +} + +func renderAnnotations(cs *iostreams.ColorScheme, annotations []annotation) string { + lines := []string{} + for _, a := range annotations { + lines = append(lines, fmt.Sprintf("%s %s", renderAnnotationSymbol(cs, a), a.Message)) + // Following newline is essential for spacing between annotations + lines = append(lines, cs.Mutedf("%s: %s#%d\n", a.JobName, a.Path, a.StartLine)) + } + + return strings.Join(lines, "\n") +} + +func renderSymbol(cs *iostreams.ColorScheme, s status, c conclusion) (string, func(string) string) { + noColor := func(s string) string { return s } + if s == statusCompleted { + switch c { + case conclusionSuccess: + return cs.SuccessIconWithColor(noColor), cs.Green + case conclusionSkipped, conclusionNeutral: + return "-", cs.Muted + default: + return cs.FailureIconWithColor(noColor), cs.Red + } + } + + return "*", cs.Yellow +} + +func renderAnnotationSymbol(cs *iostreams.ColorScheme, a annotation) string { + switch a.Level { + case annotationFailure: + return cs.FailureIcon() + case annotationWarning: + return cs.WarningIcon() + default: + return "-" + } +} diff --git a/internal/dispatch/repository.go b/internal/dispatch/repository.go index 8d8837b..21f0a28 100644 --- a/internal/dispatch/repository.go +++ b/internal/dispatch/repository.go @@ -7,11 +7,8 @@ import ( "time" "github.com/MakeNowJust/heredoc" - cliapi "github.com/cli/cli/v2/api" - runShared "github.com/cli/cli/v2/pkg/cmd/run/shared" - "github.com/cli/cli/v2/pkg/cmd/workflow/shared" "github.com/cli/cli/v2/pkg/iostreams" - ghapi "github.com/cli/go-gh/v2/pkg/api" + "github.com/cli/go-gh/v2/pkg/api" "github.com/spf13/cobra" ) @@ -70,15 +67,14 @@ func NewCmdRepository() *cobra.Command { var repoClientPayload any json.Unmarshal(b, &repoClientPayload) - ios := iostreams.System() - ghClient, err := ghapi.DefaultHTTPClient() + client, err := api.NewRESTClient(api.ClientOptions{Host: repo.RepoHost()}) if err != nil { return err } dOptions := dispatchOptions{ - repo: repo, - httpClient: ghClient, - io: ios, + repo: repo, + client: client, + io: iostreams.System(), } return repositoryDispatchRun(&repositoryDispatchOptions{ @@ -101,7 +97,7 @@ func NewCmdRepository() *cobra.Command { } func repositoryDispatchRun(opts *repositoryDispatchOptions) error { - ghClient := cliapi.NewClientFromHTTP(opts.httpClient) + client := opts.client var buf bytes.Buffer err := json.NewEncoder(&buf).Encode(repositoryDispatchRequest{ @@ -114,12 +110,12 @@ func repositoryDispatchRun(opts *repositoryDispatchOptions) error { var in any dispatchedAt := time.Now() - err = ghClient.REST(opts.repo.RepoHost(), "POST", fmt.Sprintf("repos/%s/dispatches", opts.repo.RepoFullName()), &buf, &in) + err = client.Post(fmt.Sprintf("repos/%s/dispatches", opts.repo.RepoFullName()), &buf, &in) if err != nil { return err } - wfs, err := getWorkflows(ghClient, opts.repo.RepoHost(), opts.repo.RepoFullName()) + wfs, err := getWorkflows(client, opts.repo) if err != nil { return err } @@ -132,39 +128,15 @@ func repositoryDispatchRun(opts *repositoryDispatchOptions) error { } } - runID, err := getRunID(ghClient, opts.repo, "repository_dispatch", workflowID, dispatchedAt) + runID, err := getRunID(client, opts.repo, "repository_dispatch", workflowID, dispatchedAt) if err != nil { return err } - run, err := runShared.GetRun(ghClient, opts.repo, fmt.Sprintf("%d", runID), 0) + run, err := getRun(client, opts.repo, runID) if err != nil { return fmt.Errorf("failed to get run: %w", err) } - return render(opts.io, ghClient, opts.repo, run) -} - -func getWorkflows(client *cliapi.Client, repoHost string, repoFullName string) ([]shared.Workflow, error) { - perPage := 100 - page := 1 - workflows := []shared.Workflow{} - - for { - result := shared.WorkflowsPayload{} - path := fmt.Sprintf("repos/%s/actions/workflows?per_page=%d&page=%d", repoFullName, perPage, page) - err := client.REST(repoHost, "GET", path, nil, &result) - if err != nil { - return nil, err - } - - workflows = append(workflows, result.Workflows...) - if len(result.Workflows) < perPage { - break - } - - page++ - } - - return workflows, nil + return render(opts.io, client, opts.repo, run) } diff --git a/internal/dispatch/repository_test.go b/internal/dispatch/repository_test.go index dc458f1..8f78abf 100644 --- a/internal/dispatch/repository_test.go +++ b/internal/dispatch/repository_test.go @@ -3,10 +3,8 @@ package dispatch import ( "fmt" "net/http" - "net/url" "testing" - "github.com/cli/cli/v2/pkg/httpmock" "github.com/cli/cli/v2/pkg/iostreams" "github.com/stretchr/testify/assert" ) @@ -18,87 +16,53 @@ func TestRepositoryDispatchRun(t *testing.T) { } repo := ghRepo.RepoFullName() event := "repository_dispatch" + prefix := fmt.Sprintf("/api/v3/repos/%s", repo) + + registerHandlers := func(t *testing.T, mux *http.ServeMux, conclusion, jobsResponse string) { + mux.HandleFunc("POST "+prefix+"/dispatches", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, map[string]any{ + "event_type": "hello", + "client_payload": any(nil), + }, decodeBody(t, r)) + w.WriteHeader(http.StatusNoContent) + }) - createMockRegistry := func(reg *httpmock.Registry, conclusion, jobsResponse string) { - reg.Register( - httpmock.REST("POST", fmt.Sprintf("repos/%s/dispatches", repo)), - httpmock.RESTPayload(201, "{}", func(params map[string]any) { - assert.Equal(t, map[string]any{ - "event_type": "hello", - "client_payload": any(nil), - }, params) - })) - - reg.Register( - httpmock.REST("GET", fmt.Sprintf("repos/%s/actions/workflows", repo)), - httpmock.StringResponse(getWorkflowsResponse)) - - reg.Register( - httpmock.GraphQL("query UserCurrent{viewer{login}}"), - httpmock.StringResponse(currentUserResponse)) - - v := url.Values{} - v.Set("per_page", "50") - - reg.Register( - httpmock.QueryMatcher("GET", fmt.Sprintf("repos/%s/actions/workflows/456/runs", repo), v), - httpmock.StringResponse(fmt.Sprintf(getWorkflowRunsResponse, event, repo))) - - q := url.Values{} - q.Set("per_page", "100") - q.Set("page", "1") - - reg.Register( - httpmock.QueryMatcher("GET", fmt.Sprintf("repos/%s/actions/workflows", repo), q), - httpmock.StringResponse(fmt.Sprintf(getWorkflowRunsResponse, event, repo))) - - reg.Register( - httpmock.REST("GET", fmt.Sprintf("repos/%s/actions/workflows/456", repo)), - httpmock.StringResponse(getWorkflowResponse)) - - reg.Register( - httpmock.REST("GET", fmt.Sprintf("repos/%s/actions/workflows/456", repo)), - httpmock.StringResponse(getWorkflowResponse)) - - reg.Register( - httpmock.REST("GET", fmt.Sprintf("repos/%s/actions/runs/123", repo)), - httpmock.StringResponse(`{ - "id": 123, - "workflow_id": 456, - "event": "repository_dispatch" - }`)) + mux.HandleFunc("GET "+prefix+"/actions/workflows", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "100", r.URL.Query().Get("per_page")) + assert.Equal(t, "1", r.URL.Query().Get("page")) + respond(getWorkflowsResponse)(w, r) + }) + + mux.HandleFunc("GET /api/v3/user", respond(currentUserResponse)) - reg.Register( - httpmock.REST("GET", fmt.Sprintf("repos/%s/actions/workflows/456", repo)), - httpmock.StringResponse(getWorkflowResponse)) + mux.HandleFunc("GET "+prefix+"/actions/workflows/456/runs", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "mdb", r.URL.Query().Get("actor")) + respond(fmt.Sprintf(getWorkflowRunsResponse, event, repo))(w, r) + }) - reg.Register( - httpmock.REST("GET", fmt.Sprintf("repos/%s/actions/runs/123", repo)), - httpmock.StringResponse(fmt.Sprintf(`{ + mux.HandleFunc("GET "+prefix+"/actions/runs/123", func(w http.ResponseWriter, r *http.Request) { + respond(fmt.Sprintf(`{ "id": 123, "workflow_id": 456, "status": "completed", "event": "repository_dispatch", "conclusion": "%s", - "jobs_url": "https://api.github.com/repos/%s/actions/runs/123/jobs" - }`, conclusion, repo))) - - reg.Register( - httpmock.REST("GET", fmt.Sprintf("repos/%s/actions/runs/123/jobs", repo)), - httpmock.StringResponse(jobsResponse)) + "jobs_url": "https://%s%s/actions/runs/123/jobs" + }`, conclusion, r.Host, prefix))(w, r) + }) - reg.Register( - httpmock.REST("GET", fmt.Sprintf("repos/%s/check-runs/123/annotations", repo)), - httpmock.StringResponse("[]")) + mux.HandleFunc("GET "+prefix+"/actions/workflows/456", respond(getWorkflowResponse)) + mux.HandleFunc("GET "+prefix+"/actions/runs/123/jobs", respond(jobsResponse)) + mux.HandleFunc("GET "+prefix+"/check-runs/123/annotations", respond("[]")) } tests := []struct { - name string - opts *repositoryDispatchOptions - httpStubs func(*httpmock.Registry) - wantErr bool - errMsg string - wantOut string + name string + opts *repositoryDispatchOptions + handlers func(*testing.T, *http.ServeMux) + wantErr bool + errMsg string + wantOut string }{ { name: "successful workflow run", @@ -106,8 +70,8 @@ func TestRepositoryDispatchRun(t *testing.T) { eventType: "hello", workflow: "foo", }, - httpStubs: func(reg *httpmock.Registry) { - createMockRegistry(reg, "success", getJobsResponse) + handlers: func(t *testing.T, mux *http.ServeMux) { + registerHandlers(t, mux, "success", getJobsResponse) }, wantOut: `Refreshing run status every 2 seconds. Press Ctrl+C to quit. @@ -127,8 +91,8 @@ JOBS eventType: "hello", workflow: "foo", }, - httpStubs: func(reg *httpmock.Registry) { - createMockRegistry(reg, "failure", getFailingJobsResponse) + handlers: func(t *testing.T, mux *http.ServeMux) { + registerHandlers(t, mux, "failure", getFailingJobsResponse) }, wantOut: `Refreshing run status every 2 seconds. Press Ctrl+C to quit. @@ -149,10 +113,8 @@ JOBS opts: &repositoryDispatchOptions{ eventType: "hello", }, - httpStubs: func(reg *httpmock.Registry) { - reg.Register( - httpmock.REST("POST", fmt.Sprintf("repos/%s/dispatches", repo)), - httpmock.StringResponse("{")) + handlers: func(t *testing.T, mux *http.ServeMux) { + mux.HandleFunc("POST "+prefix+"/dispatches", respond("{")) }, wantOut: "", wantErr: true, @@ -160,20 +122,18 @@ JOBS }} for _, tt := range tests { - reg := &httpmock.Registry{} - tt.httpStubs(reg) + t.Run(tt.name, func(t *testing.T) { + client, mux := newTestServer(t) + tt.handlers(t, mux) - ios, _, stdout, _ := iostreams.Test() - ios.SetStdoutTTY(false) - ios.SetAlternateScreenBufferEnabled(false) + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(false) + ios.SetAlternateScreenBufferEnabled(false) - tt.opts.repo = ghRepo - tt.opts.io = ios - tt.opts.httpClient = &http.Client{ - Transport: reg, - } + tt.opts.repo = ghRepo + tt.opts.io = ios + tt.opts.client = client - t.Run(tt.name, func(t *testing.T) { err := repositoryDispatchRun(tt.opts) if tt.wantErr { @@ -185,7 +145,6 @@ JOBS if got := stdout.String(); got != tt.wantOut { t.Errorf("got stdout:\n%q\nwant:\n%q", got, tt.wantOut) } - reg.Verify(t) }) } } diff --git a/internal/dispatch/runs.go b/internal/dispatch/runs.go new file mode 100644 index 0000000..a32d658 --- /dev/null +++ b/internal/dispatch/runs.go @@ -0,0 +1,239 @@ +package dispatch + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "regexp" + "time" + + "github.com/cli/go-gh/v2/pkg/api" +) + +// The types and functions in this file are a subset of those found in the +// upstream github.com/cli/cli run/workflow shared packages, adapted to +// use the go-gh REST client. + +type status string +type conclusion string +type annotationLevel string + +const ( + statusCompleted status = "completed" + statusInProgress status = "in_progress" + + conclusionSuccess conclusion = "success" + conclusionSkipped conclusion = "skipped" + conclusionNeutral conclusion = "neutral" + + annotationFailure annotationLevel = "failure" + annotationWarning annotationLevel = "warning" +) + +var errMissingAnnotationsPermissions = errors.New("missing annotations permissions error") + +type workflow struct { + ID int64 + Name string +} + +type workflowRun struct { + ID int64 + Name string + Status status + Conclusion conclusion + Event string + CreatedAt time.Time `json:"created_at"` + WorkflowID int64 `json:"workflow_id"` + HeadBranch string `json:"head_branch"` + JobsURL string `json:"jobs_url"` + WorkflowName string `json:"-"` +} + +type job struct { + ID int64 + Status status + Conclusion conclusion + Name string + Steps []step + StartedAt time.Time `json:"started_at"` + CompletedAt time.Time `json:"completed_at"` +} + +type step struct { + Name string + Status status + Conclusion conclusion +} + +type annotation struct { + JobName string + Message string + Path string + Level annotationLevel `json:"annotation_level"` + StartLine int `json:"start_line"` +} + +func currentLoginName(client *api.RESTClient) (string, error) { + var user struct { + Login string + } + err := client.Get("user", &user) + return user.Login, err +} + +func getWorkflow(client *api.RESTClient, repo *ghRepo, workflowIDOrFile string) (*workflow, error) { + var wf workflow + path := fmt.Sprintf("repos/%s/actions/workflows/%s", repo.RepoFullName(), workflowIDOrFile) + if err := client.Get(path, &wf); err != nil { + return nil, err + } + + return &wf, nil +} + +func getWorkflows(client *api.RESTClient, repo *ghRepo) ([]workflow, error) { + perPage := 100 + page := 1 + workflows := []workflow{} + + for { + var result struct { + Workflows []workflow + } + path := fmt.Sprintf("repos/%s/actions/workflows?per_page=%d&page=%d", repo.RepoFullName(), perPage, page) + if err := client.Get(path, &result); err != nil { + return nil, err + } + + workflows = append(workflows, result.Workflows...) + if len(result.Workflows) < perPage { + break + } + + page++ + } + + return workflows, nil +} + +// getRuns fetches the 50 most recent runs of the workflow triggered by +// the actor and returns those for which filter returns true. +func getRuns(client *api.RESTClient, repo *ghRepo, workflowID int64, actor string, filter func(workflowRun) bool) ([]workflowRun, error) { + path := fmt.Sprintf("repos/%s/actions/runs", repo.RepoFullName()) + if workflowID > 0 { + path = fmt.Sprintf("repos/%s/actions/workflows/%d/runs", repo.RepoFullName(), workflowID) + } + + query := url.Values{} + query.Set("per_page", "50") + query.Set("exclude_pull_requests", "true") + if actor != "" { + query.Set("actor", actor) + } + + var result struct { + WorkflowRuns []workflowRun `json:"workflow_runs"` + } + if err := client.Get(fmt.Sprintf("%s?%s", path, query.Encode()), &result); err != nil { + return nil, err + } + + var filtered []workflowRun + for _, r := range result.WorkflowRuns { + if filter(r) { + filtered = append(filtered, r) + } + } + + return filtered, nil +} + +func getRun(client *api.RESTClient, repo *ghRepo, runID int64) (*workflowRun, error) { + var r workflowRun + path := fmt.Sprintf("repos/%s/actions/runs/%d?exclude_pull_requests=true", repo.RepoFullName(), runID) + if err := client.Get(path, &r); err != nil { + return nil, err + } + + wf, err := getWorkflow(client, repo, fmt.Sprintf("%d", r.WorkflowID)) + if err != nil { + return nil, err + } + + r.WorkflowName = wf.Name + + return &r, nil +} + +func getJobs(client *api.RESTClient, r *workflowRun) ([]job, error) { + query := url.Values{} + query.Set("per_page", "100") + path := fmt.Sprintf("%s?%s", r.JobsURL, query.Encode()) + + jobs := []job{} + for path != "" { + resp, err := client.Request(http.MethodGet, path, nil) + if err != nil { + return nil, err + } + + var payload struct { + Jobs []job + } + err = json.NewDecoder(resp.Body).Decode(&payload) + resp.Body.Close() + if err != nil { + return nil, err + } + + jobs = append(jobs, payload.Jobs...) + path = findNextPage(resp) + } + + return jobs, nil +} + +var linkRE = regexp.MustCompile(`<([^>]+)>;\s*rel="([^"]+)"`) + +func findNextPage(resp *http.Response) string { + for _, m := range linkRE.FindAllStringSubmatch(resp.Header.Get("Link"), -1) { + if len(m) > 2 && m[2] == "next" { + return m[1] + } + } + return "" +} + +// getAnnotations returns an empty slice if the job has no annotations, and +// errMissingAnnotationsPermissions if the API responds with a 403. +func getAnnotations(client *api.RESTClient, repo *ghRepo, j job) ([]annotation, error) { + var result []*annotation + path := fmt.Sprintf("repos/%s/check-runs/%d/annotations", repo.RepoFullName(), j.ID) + + if err := client.Get(path, &result); err != nil { + var httpErr *api.HTTPError + if !errors.As(err, &httpErr) { + return nil, err + } + + switch httpErr.StatusCode { + case http.StatusNotFound: + return []annotation{}, nil + case http.StatusForbidden: + return nil, errMissingAnnotationsPermissions + default: + return nil, err + } + } + + out := []annotation{} + for _, a := range result { + a.JobName = j.Name + out = append(out, *a) + } + + return out, nil +} diff --git a/internal/dispatch/server_test.go b/internal/dispatch/server_test.go new file mode 100644 index 0000000..e3124ae --- /dev/null +++ b/internal/dispatch/server_test.go @@ -0,0 +1,51 @@ +package dispatch + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/cli/go-gh/v2/pkg/api" +) + +// newTestServer starts a TLS test server and returns a go-gh REST client +// that targets it, along with the mux on which to register API handlers. +// As the server's host is not github.com, go-gh treats it as a GitHub +// Enterprise Server host and prefixes all REST paths with /api/v3. +func newTestServer(t *testing.T) (*api.RESTClient, *http.ServeMux) { + t.Helper() + t.Setenv("GH_CONFIG_DIR", t.TempDir()) + + mux := http.NewServeMux() + srv := httptest.NewTLSServer(mux) + t.Cleanup(srv.Close) + + client, err := api.NewRESTClient(api.ClientOptions{ + Host: strings.TrimPrefix(srv.URL, "https://"), + AuthToken: "token", + Transport: srv.Client().Transport, + }) + if err != nil { + t.Fatal(err) + } + + return client, mux +} + +func respond(body string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(body)) + } +} + +func decodeBody(t *testing.T, r *http.Request) map[string]any { + t.Helper() + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Errorf("failed to decode request body: %v", err) + } + return body +} diff --git a/internal/dispatch/types.go b/internal/dispatch/types.go index 57eef43..d403614 100644 --- a/internal/dispatch/types.go +++ b/internal/dispatch/types.go @@ -1,13 +1,12 @@ package dispatch import ( - "net/http" - "github.com/cli/cli/v2/pkg/iostreams" + "github.com/cli/go-gh/v2/pkg/api" ) type dispatchOptions struct { - repo *ghRepo - httpClient *http.Client - io *iostreams.IOStreams + repo *ghRepo + client *api.RESTClient + io *iostreams.IOStreams } diff --git a/internal/dispatch/workflow.go b/internal/dispatch/workflow.go index 5a2fadf..c1a1c13 100644 --- a/internal/dispatch/workflow.go +++ b/internal/dispatch/workflow.go @@ -7,11 +7,8 @@ import ( "time" "github.com/MakeNowJust/heredoc" - cliapi "github.com/cli/cli/v2/api" - runShared "github.com/cli/cli/v2/pkg/cmd/run/shared" - "github.com/cli/cli/v2/pkg/cmd/workflow/shared" "github.com/cli/cli/v2/pkg/iostreams" - ghapi "github.com/cli/go-gh/v2/pkg/api" + "github.com/cli/go-gh/v2/pkg/api" "github.com/spf13/cobra" ) @@ -75,15 +72,14 @@ func NewCmdWorkflow() *cobra.Command { var wInputs any json.Unmarshal(b, &wInputs) - ios := iostreams.System() - ghClient, err := ghapi.DefaultHTTPClient() + client, err := api.NewRESTClient(api.ClientOptions{Host: repo.RepoHost()}) if err != nil { return err } dOptions := dispatchOptions{ - repo: repo, - httpClient: ghClient, - io: ios, + repo: repo, + client: client, + io: iostreams.System(), } return workflowDispatchRun(&workflowDispatchOptions{ @@ -111,7 +107,7 @@ func NewCmdWorkflow() *cobra.Command { } func workflowDispatchRun(opts *workflowDispatchOptions) error { - ghClient := cliapi.NewClientFromHTTP(opts.httpClient) + client := opts.client var buf bytes.Buffer err := json.NewEncoder(&buf).Encode(workflowDispatchRequest{ @@ -124,26 +120,25 @@ func workflowDispatchRun(opts *workflowDispatchOptions) error { var in any dispatchedAt := time.Now() - err = ghClient.REST(opts.repo.RepoHost(), "POST", fmt.Sprintf("repos/%s/actions/workflows/%s/dispatches", opts.repo.RepoFullName(), opts.workflow), &buf, &in) + err = client.Post(fmt.Sprintf("repos/%s/actions/workflows/%s/dispatches", opts.repo.RepoFullName(), opts.workflow), &buf, &in) if err != nil { return err } - var wf shared.Workflow - err = ghClient.REST(opts.repo.RepoHost(), "GET", fmt.Sprintf("repos/%s/actions/workflows/%s", opts.repo.RepoFullName(), opts.workflow), nil, &wf) + wf, err := getWorkflow(client, opts.repo, opts.workflow) if err != nil { return err } - runID, err := getRunID(ghClient, opts.repo, "workflow_dispatch", wf.ID, dispatchedAt) + runID, err := getRunID(client, opts.repo, "workflow_dispatch", wf.ID, dispatchedAt) if err != nil { return err } - run, err := runShared.GetRun(ghClient, opts.repo, fmt.Sprintf("%d", runID), 0) + run, err := getRun(client, opts.repo, runID) if err != nil { return fmt.Errorf("failed to get run: %w", err) } - return render(opts.io, ghClient, opts.repo, run) + return render(opts.io, client, opts.repo, run) } diff --git a/internal/dispatch/workflow_test.go b/internal/dispatch/workflow_test.go index a1c2c06..eaa36d3 100644 --- a/internal/dispatch/workflow_test.go +++ b/internal/dispatch/workflow_test.go @@ -3,10 +3,8 @@ package dispatch import ( "fmt" "net/http" - "net/url" "testing" - "github.com/cli/cli/v2/pkg/httpmock" "github.com/cli/cli/v2/pkg/iostreams" "github.com/stretchr/testify/assert" ) @@ -19,87 +17,49 @@ func TestWorkflowDispatchRun(t *testing.T) { repo := ghRepo.RepoFullName() workflow := "workflow.yaml" event := "workflow_dispatch" + prefix := fmt.Sprintf("/api/v3/repos/%s", repo) + + registerHandlers := func(t *testing.T, mux *http.ServeMux, conclusion, jobsResponse string) { + mux.HandleFunc("POST "+prefix+"/actions/workflows/"+workflow+"/dispatches", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, map[string]any{ + "inputs": "{\"foo\": \"bar\"}", + "ref": "", + }, decodeBody(t, r)) + w.WriteHeader(http.StatusNoContent) + }) - createMockRegistry := func(reg *httpmock.Registry, conclusion, jobsResponse string) { - reg.Register( - httpmock.REST("POST", fmt.Sprintf("repos/%s/actions/workflows/%s/dispatches", repo, "workflow.yaml")), - httpmock.RESTPayload(201, "{}", func(params map[string]any) { - assert.Equal(t, map[string]any{ - "inputs": "{\"foo\": \"bar\"}", - "ref": "", - }, params) - })) - - reg.Register( - httpmock.REST("GET", fmt.Sprintf("repos/%s/actions/workflows/workflow.yaml", repo)), - httpmock.StringResponse(getWorkflowResponse)) - - reg.Register( - httpmock.GraphQL("query UserCurrent{viewer{login}}"), - httpmock.StringResponse(currentUserResponse)) - - v := url.Values{} - v.Set("per_page", "50") - - reg.Register( - httpmock.QueryMatcher("GET", fmt.Sprintf("repos/%s/actions/workflows/456/runs", repo), v), - httpmock.StringResponse(fmt.Sprintf(getWorkflowRunsResponse, event))) - - q := url.Values{} - q.Set("per_page", "100") - q.Set("page", "1") - - reg.Register( - httpmock.QueryMatcher("GET", fmt.Sprintf("repos/%s/actions/workflows", repo), q), - httpmock.StringResponse(fmt.Sprintf(getWorkflowRunsResponse, event))) - - reg.Register( - httpmock.REST("GET", fmt.Sprintf("repos/%s/actions/workflows/456", repo)), - httpmock.StringResponse(getWorkflowResponse)) - - reg.Register( - httpmock.REST("GET", fmt.Sprintf("repos/%s/actions/runs/123", repo)), - httpmock.StringResponse(`{ - "id": 123, - "workflow_id": 456, - "event": "workflow_dispatch" - }`)) + mux.HandleFunc("GET "+prefix+"/actions/workflows/"+workflow, respond(getWorkflowResponse)) - reg.Register( - httpmock.REST("GET", fmt.Sprintf("repos/%s/actions/workflows/456", repo)), - httpmock.StringResponse(getWorkflowResponse)) + mux.HandleFunc("GET /api/v3/user", respond(currentUserResponse)) - reg.Register( - httpmock.REST("GET", fmt.Sprintf("repos/%s/actions/workflows/456", repo)), - httpmock.StringResponse(getWorkflowResponse)) + mux.HandleFunc("GET "+prefix+"/actions/workflows/456/runs", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "mdb", r.URL.Query().Get("actor")) + respond(fmt.Sprintf(getWorkflowRunsResponse, event, repo))(w, r) + }) - reg.Register( - httpmock.REST("GET", fmt.Sprintf("repos/%s/actions/runs/123", repo)), - httpmock.StringResponse(fmt.Sprintf(`{ + mux.HandleFunc("GET "+prefix+"/actions/runs/123", func(w http.ResponseWriter, r *http.Request) { + respond(fmt.Sprintf(`{ "id": 123, "workflow_id": 456, - "event": "workflow_dispatch", "status": "completed", + "event": "workflow_dispatch", "conclusion": "%s", - "jobs_url": "https://api.github.com/repos/%s/actions/runs/123/jobs" - }`, conclusion, repo))) - - reg.Register( - httpmock.REST("GET", fmt.Sprintf("repos/%s/actions/runs/123/jobs", repo)), - httpmock.StringResponse(jobsResponse)) + "jobs_url": "https://%s%s/actions/runs/123/jobs" + }`, conclusion, r.Host, prefix))(w, r) + }) - reg.Register( - httpmock.REST("GET", fmt.Sprintf("repos/%s/check-runs/123/annotations", repo)), - httpmock.StringResponse("[]")) + mux.HandleFunc("GET "+prefix+"/actions/workflows/456", respond(getWorkflowResponse)) + mux.HandleFunc("GET "+prefix+"/actions/runs/123/jobs", respond(jobsResponse)) + mux.HandleFunc("GET "+prefix+"/check-runs/123/annotations", respond("[]")) } tests := []struct { - name string - opts *workflowDispatchOptions - httpStubs func(*httpmock.Registry) - wantErr bool - errMsg string - wantOut string + name string + opts *workflowDispatchOptions + handlers func(*testing.T, *http.ServeMux) + wantErr bool + errMsg string + wantOut string }{ { name: "successful workflow run", @@ -107,8 +67,8 @@ func TestWorkflowDispatchRun(t *testing.T) { inputs: `{"foo": "bar"}`, workflow: workflow, }, - httpStubs: func(reg *httpmock.Registry) { - createMockRegistry(reg, "success", getJobsResponse) + handlers: func(t *testing.T, mux *http.ServeMux) { + registerHandlers(t, mux, "success", getJobsResponse) }, wantOut: `Refreshing run status every 2 seconds. Press Ctrl+C to quit. @@ -128,8 +88,8 @@ JOBS inputs: `{"foo": "bar"}`, workflow: workflow, }, - httpStubs: func(reg *httpmock.Registry) { - createMockRegistry(reg, "failure", getFailingJobsResponse) + handlers: func(t *testing.T, mux *http.ServeMux) { + registerHandlers(t, mux, "failure", getFailingJobsResponse) }, wantOut: `Refreshing run status every 2 seconds. Press Ctrl+C to quit. @@ -151,10 +111,8 @@ JOBS inputs: `{"foo": "bar"}`, workflow: workflow, }, - httpStubs: func(reg *httpmock.Registry) { - reg.Register( - httpmock.REST("POST", fmt.Sprintf("repos/%s/actions/workflows/%s/dispatches", repo, "workflow.yaml")), - httpmock.StringResponse("{")) + handlers: func(t *testing.T, mux *http.ServeMux) { + mux.HandleFunc("POST "+prefix+"/actions/workflows/"+workflow+"/dispatches", respond("{")) }, wantOut: "", wantErr: true, @@ -162,20 +120,18 @@ JOBS }} for _, tt := range tests { - reg := &httpmock.Registry{} - tt.httpStubs(reg) + t.Run(tt.name, func(t *testing.T) { + client, mux := newTestServer(t) + tt.handlers(t, mux) - ios, _, stdout, _ := iostreams.Test() - ios.SetStdoutTTY(false) - ios.SetAlternateScreenBufferEnabled(false) + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(false) + ios.SetAlternateScreenBufferEnabled(false) - tt.opts.repo = ghRepo - tt.opts.io = ios - tt.opts.httpClient = &http.Client{ - Transport: reg, - } + tt.opts.repo = ghRepo + tt.opts.io = ios + tt.opts.client = client - t.Run(tt.name, func(t *testing.T) { err := workflowDispatchRun(tt.opts) if tt.wantErr { @@ -187,8 +143,6 @@ JOBS if got := stdout.String(); got != tt.wantOut { t.Errorf("got stdout:\n%q\nwant:\n%q", got, tt.wantOut) } - - reg.Verify(t) }) } }