diff --git a/.changeset/rare-planes-shave.md b/.changeset/rare-planes-shave.md new file mode 100644 index 00000000..6a12d957 --- /dev/null +++ b/.changeset/rare-planes-shave.md @@ -0,0 +1,5 @@ +--- +'grafana-github-datasource': minor +--- + +adds a Check Runs query type that lists the check runs reported against a commit, branch, or tag, including status, conclusion, and duration diff --git a/docs/sources/query-editor.md b/docs/sources/query-editor.md index 3d39cd88..96724638 100644 --- a/docs/sources/query-editor.md +++ b/docs/sources/query-editor.md @@ -32,6 +32,7 @@ This document describes the query types and response fields available in the Git Select a query type from the **Query Type** drop-down in the query editor: - [**Branches**](#branches): List branches for a repository, with optional name filtering. +- [**Check runs**](#check-runs): List check runs for a commit, branch, or tag, including status, conclusion, and duration. - [**Code scanning**](#code-scanning): Query code scanning alerts for a repository or organization. - [**Commit files**](#commit-files): List files changed in a specific commit. - [**Commits**](#commits): Retrieve a list of commits for a branch or ref within a repository, including commit message, author, and timestamp. @@ -203,6 +204,48 @@ The response includes one row per changed file: | status | Change type: `added`, `modified`, `renamed`, etc. | | previous_filename | Original path for renamed files | +### Check runs + +List check runs for a git reference. Check runs are the individual checks reported against a commit by GitHub Apps, such as GitHub Actions jobs, and include the status, conclusion, and how long each check took. + +{{< admonition type="note" >}} +The reference can be a commit SHA, a branch formatted as `heads/`, or a tag formatted as `tags/`. +{{< /admonition >}} + +#### Query options + +| Name | Description | Required | +|------|-------------|----------| +| Owner | The GitHub user or organization that owns the repository | Yes | +| Repository | The name of the repository | Yes | +| Ref | The commit SHA, branch, or tag to list check runs for | Yes | +| Check name | Only return check runs with this exact name | No | +| Status | Only return check runs with the given status: `queued`, `in_progress`, or `completed` | No | +| Filter | `latest` returns the most recent check run for each name, `all` returns every check run. Defaults to `latest` | No | + +#### Response + +The response includes one row per check run: + +| Name | Description | +|------|-------------| +| id | Unique identifier of the check run | +| name | Name of the check run | +| head_sha | SHA of the commit the check run is associated with | +| status | Current status: `queued`, `in_progress`, or `completed` | +| conclusion | Result of a completed check run, such as `success`, `failure`, `neutral`, `cancelled`, `skipped`, `timed_out`, or `action_required` | +| started_at | Time the check run started | +| completed_at | Time the check run completed, empty while it is still running | +| duration_seconds | How long the check run took in seconds, empty while it is still running | +| html_url | URL of the check run on GitHub | +| details_url | URL of the external site with the full check run details | +| app_name | Name of the GitHub App that created the check run | +| app_slug | Slug of the GitHub App that created the check run | +| check_suite_id | Identifier of the check suite the check run belongs to | +| output_title | Title of the check run output | +| output_summary | Summary of the check run output | +| annotations_count | Number of annotations attached to the check run | + ### Contributors Get a list of contributors to an organization or repository. diff --git a/pkg/github/checkruns.go b/pkg/github/checkruns.go new file mode 100644 index 00000000..a969af27 --- /dev/null +++ b/pkg/github/checkruns.go @@ -0,0 +1,128 @@ +package github + +import ( + "context" + "fmt" + "time" + + googlegithub "github.com/google/go-github/v84/github" + "github.com/grafana/grafana-plugin-sdk-go/data" + + "github.com/grafana/github-datasource/pkg/models" +) + +// CheckRunsWrapper is a list of check runs returned by the GitHub API +type CheckRunsWrapper []*googlegithub.CheckRun + +// Frames converts the list of check runs to a Grafana DataFrame +func (checkRuns CheckRunsWrapper) Frames() data.Frames { + frame := data.NewFrame( + "check_runs", + data.NewField("id", nil, []int64{}), + data.NewField("name", nil, []string{}), + data.NewField("head_sha", nil, []string{}), + data.NewField("status", nil, []string{}), + data.NewField("conclusion", nil, []string{}), + data.NewField("started_at", nil, []*time.Time{}), + data.NewField("completed_at", nil, []*time.Time{}), + data.NewField("duration_seconds", nil, []*int64{}), + data.NewField("html_url", nil, []string{}), + data.NewField("details_url", nil, []string{}), + data.NewField("app_name", nil, []string{}), + data.NewField("app_slug", nil, []string{}), + data.NewField("check_suite_id", nil, []int64{}), + data.NewField("output_title", nil, []string{}), + data.NewField("output_summary", nil, []string{}), + data.NewField("annotations_count", nil, []int64{}), + ) + + for _, checkRun := range checkRuns { + startedAt := checkRunTimestamp(checkRun.StartedAt) + completedAt := checkRunTimestamp(checkRun.CompletedAt) + + frame.AppendRow( + checkRun.GetID(), + checkRun.GetName(), + checkRun.GetHeadSHA(), + checkRun.GetStatus(), + checkRun.GetConclusion(), + startedAt, + completedAt, + checkRunDuration(startedAt, completedAt), + checkRun.GetHTMLURL(), + checkRun.GetDetailsURL(), + checkRun.GetApp().GetName(), + checkRun.GetApp().GetSlug(), + checkRun.GetCheckSuite().GetID(), + checkRun.GetOutput().GetTitle(), + checkRun.GetOutput().GetSummary(), + int64(checkRun.GetOutput().GetAnnotationsCount()), + ) + } + + frame.Meta = &data.FrameMeta{PreferredVisualization: data.VisTypeTable} + return data.Frames{frame} +} + +// checkRunTimestamp converts an optional GitHub timestamp into a nullable time +func checkRunTimestamp(ts *googlegithub.Timestamp) *time.Time { + if ts == nil || ts.IsZero() { + return nil + } + return nullableTime(ts.Time) +} + +// checkRunDuration returns how long a check run took in seconds. +// It returns nil when the check run has not completed yet, or when either timestamp is missing. +func checkRunDuration(startedAt, completedAt *time.Time) *int64 { + if startedAt == nil || completedAt == nil { + return nil + } + seconds := int64(completedAt.Sub(*startedAt).Seconds()) + return &seconds +} + +// GetCheckRuns lists the check runs for a git reference in a repository, handling pagination. +// GET /repos/{owner}/{repo}/commits/{ref}/check-runs +// https://docs.github.com/en/rest/checks/runs#list-check-runs-for-a-git-reference +func GetCheckRuns(ctx context.Context, client models.Client, opts models.CheckRunsOptions) (CheckRunsWrapper, error) { + if opts.Owner == "" || opts.Repository == "" || opts.Ref == "" { + return nil, nil + } + + listOpts := &googlegithub.ListCheckRunsOptions{ + ListOptions: googlegithub.ListOptions{PerPage: 100}, + } + if opts.CheckName != "" { + listOpts.CheckName = &opts.CheckName + } + if opts.Status != "" { + listOpts.Status = &opts.Status + } + if opts.Filter != "" { + listOpts.Filter = &opts.Filter + } + + var checkRuns []*googlegithub.CheckRun + page := 1 + + for { + listOpts.ListOptions.Page = page + + results, resp, err := client.ListCheckRunsForRef(ctx, opts.Owner, opts.Repository, opts.Ref, listOpts) + if err != nil { + return nil, fmt.Errorf("listing check runs: owner=%s repo=%s ref=%s page=%d: %w", opts.Owner, opts.Repository, opts.Ref, page, err) + } + + if results != nil { + checkRuns = append(checkRuns, results.CheckRuns...) + } + + if resp == nil || resp.NextPage == 0 { + break + } + page = resp.NextPage + } + + return CheckRunsWrapper(checkRuns), nil +} diff --git a/pkg/github/checkruns_handler.go b/pkg/github/checkruns_handler.go new file mode 100644 index 00000000..fba064e8 --- /dev/null +++ b/pkg/github/checkruns_handler.go @@ -0,0 +1,25 @@ +package github + +import ( + "context" + + "github.com/grafana/grafana-plugin-sdk-go/backend" + + "github.com/grafana/github-datasource/pkg/dfutil" + "github.com/grafana/github-datasource/pkg/models" +) + +func (s *QueryHandler) handleCheckRunsRequests(ctx context.Context, q backend.DataQuery) backend.DataResponse { + query := &models.CheckRunsQuery{} + if err := UnmarshalQuery(q.JSON, query); err != nil { + return *err + } + return dfutil.FrameResponseWithError(s.Datasource.HandleCheckRunsQuery(ctx, query, q)) +} + +// HandleCheckRuns handles the plugin query for github check runs +func (s *QueryHandler) HandleCheckRuns(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) { + return &backend.QueryDataResponse{ + Responses: processQueries(ctx, req, s.handleCheckRunsRequests), + }, nil +} diff --git a/pkg/github/checkruns_test.go b/pkg/github/checkruns_test.go new file mode 100644 index 00000000..8b582737 --- /dev/null +++ b/pkg/github/checkruns_test.go @@ -0,0 +1,324 @@ +package github + +import ( + "context" + "testing" + "time" + + googlegithub "github.com/google/go-github/v84/github" + "github.com/grafana/grafana-plugin-sdk-go/backend" + + "github.com/grafana/github-datasource/pkg/models" + "github.com/grafana/github-datasource/pkg/testutil" +) + +// checkRunPage holds one page of check runs for the mock client +type checkRunPage struct { + checkRuns []*googlegithub.CheckRun + nextPage int +} + +// checkRunsMockClient satisfies models.Client for check run tests +type checkRunsMockClient struct { + pages []checkRunPage + pageIdx int + lastOpts *googlegithub.ListCheckRunsOptions + expectedOwner string + expectedRepo string + expectedRef string + t *testing.T +} + +func (m *checkRunsMockClient) Query(_ context.Context, _ interface{}, _ map[string]interface{}) error { + return nil +} +func (m *checkRunsMockClient) ListWorkflows(_ context.Context, _, _ string, _ *googlegithub.ListOptions) (*googlegithub.Workflows, *googlegithub.Response, error) { + return nil, nil, nil +} +func (m *checkRunsMockClient) GetWorkflowUsage(_ context.Context, _, _, _ string, _ backend.TimeRange) (models.WorkflowUsage, error) { + return models.WorkflowUsage{}, nil +} +func (m *checkRunsMockClient) GetWorkflowRuns(_ context.Context, _, _, _, _ string, _ backend.TimeRange) ([]*googlegithub.WorkflowRun, error) { + return nil, nil +} +func (m *checkRunsMockClient) ListAlertsForRepo(_ context.Context, _, _ string, _ *googlegithub.AlertListOptions) ([]*googlegithub.Alert, *googlegithub.Response, error) { + return nil, nil, nil +} +func (m *checkRunsMockClient) ListAlertsForOrg(_ context.Context, _ string, _ *googlegithub.AlertListOptions) ([]*googlegithub.Alert, *googlegithub.Response, error) { + return nil, nil, nil +} +func (m *checkRunsMockClient) ListAllOrgRepositories(_ context.Context, _ *googlegithub.ListOptions) ([]*googlegithub.Repository, *googlegithub.Response, error) { + return nil, nil, nil +} +func (m *checkRunsMockClient) ListDeployments(_ context.Context, _, _ string, _ *googlegithub.DeploymentsListOptions) ([]*googlegithub.Deployment, *googlegithub.Response, error) { + return nil, nil, nil +} +func (m *checkRunsMockClient) GetCommitFiles(_ context.Context, _, _, _ string, _ *googlegithub.ListOptions) ([]*googlegithub.CommitFile, *googlegithub.Response, error) { + return nil, nil, nil +} +func (m *checkRunsMockClient) ListPullRequestFiles(_ context.Context, _, _ string, _ int, _ *googlegithub.ListOptions) ([]*googlegithub.CommitFile, *googlegithub.Response, error) { + return nil, nil, nil +} + +func (m *checkRunsMockClient) ListCheckRunsForRef(_ context.Context, owner, repo, ref string, opts *googlegithub.ListCheckRunsOptions) (*googlegithub.ListCheckRunsResults, *googlegithub.Response, error) { + if owner != m.expectedOwner || repo != m.expectedRepo { + m.t.Errorf("ListCheckRunsForRef: expected owner/repo=%s/%s got=%s/%s", m.expectedOwner, m.expectedRepo, owner, repo) + } + if ref != m.expectedRef { + m.t.Errorf("ListCheckRunsForRef: expected ref=%s got=%s", m.expectedRef, ref) + } + m.lastOpts = opts + + if m.pageIdx >= len(m.pages) { + m.t.Fatalf("ListCheckRunsForRef: unexpected call %d (only %d pages configured)", m.pageIdx, len(m.pages)) + return nil, nil, nil + } + + p := m.pages[m.pageIdx] + m.pageIdx++ + + resp := &googlegithub.Response{} + resp.NextPage = p.nextPage + + total := len(p.checkRuns) + return &googlegithub.ListCheckRunsResults{Total: &total, CheckRuns: p.checkRuns}, resp, nil +} + +func TestGetCheckRuns(t *testing.T) { + ctx := context.Background() + opts := models.CheckRunsOptions{ + Owner: "grafana", + Repository: "github-datasource", + Ref: "abc123def456", + } + + client := &checkRunsMockClient{ + pages: []checkRunPage{ + { + checkRuns: []*googlegithub.CheckRun{ + {Name: googlegithub.Ptr("build"), Status: googlegithub.Ptr("completed")}, + }, + nextPage: 0, + }, + }, + expectedOwner: "grafana", + expectedRepo: "github-datasource", + expectedRef: "abc123def456", + t: t, + } + + result, err := GetCheckRuns(ctx, client, opts) + if err != nil { + t.Fatal(err) + } + if len(result) != 1 { + t.Errorf("expected 1 check run, got %d", len(result)) + } +} + +func TestGetCheckRunsEmptyRef(t *testing.T) { + ctx := context.Background() + + for _, tc := range []struct { + name string + opts models.CheckRunsOptions + }{ + {"empty ref", models.CheckRunsOptions{Owner: "grafana", Repository: "github-datasource"}}, + {"empty owner", models.CheckRunsOptions{Repository: "github-datasource", Ref: "abc123"}}, + {"empty repository", models.CheckRunsOptions{Owner: "grafana", Ref: "abc123"}}, + } { + t.Run(tc.name, func(t *testing.T) { + client := &checkRunsMockClient{t: t} + result, err := GetCheckRuns(ctx, client, tc.opts) + if err != nil { + t.Fatal(err) + } + if result != nil { + t.Errorf("expected nil result, got %v", result) + } + if client.pageIdx != 0 { + t.Errorf("expected no API calls, got %d", client.pageIdx) + } + }) + } +} + +func TestGetCheckRunsPagination(t *testing.T) { + ctx := context.Background() + opts := models.CheckRunsOptions{ + Owner: "grafana", + Repository: "github-datasource", + Ref: "abc123def456", + } + + client := &checkRunsMockClient{ + pages: []checkRunPage{ + { + checkRuns: []*googlegithub.CheckRun{{Name: googlegithub.Ptr("build")}}, + nextPage: 2, + }, + { + checkRuns: []*googlegithub.CheckRun{{Name: googlegithub.Ptr("lint")}}, + nextPage: 0, + }, + }, + expectedOwner: "grafana", + expectedRepo: "github-datasource", + expectedRef: "abc123def456", + t: t, + } + + result, err := GetCheckRuns(ctx, client, opts) + if err != nil { + t.Fatal(err) + } + if len(result) != 2 { + t.Errorf("expected 2 check runs across 2 pages, got %d", len(result)) + } + if client.pageIdx != 2 { + t.Errorf("expected 2 API calls, got %d", client.pageIdx) + } +} + +func TestGetCheckRunsFilterOptions(t *testing.T) { + ctx := context.Background() + opts := models.CheckRunsOptions{ + Owner: "grafana", + Repository: "github-datasource", + Ref: "abc123def456", + CheckName: "build", + Status: "completed", + Filter: "all", + } + + client := &checkRunsMockClient{ + pages: []checkRunPage{{checkRuns: nil, nextPage: 0}}, + expectedOwner: "grafana", + expectedRepo: "github-datasource", + expectedRef: "abc123def456", + t: t, + } + + if _, err := GetCheckRuns(ctx, client, opts); err != nil { + t.Fatal(err) + } + + if client.lastOpts == nil { + t.Fatal("expected list options to be passed to the client") + } + if client.lastOpts.GetCheckName() != "build" { + t.Errorf("expected check name 'build', got %q", client.lastOpts.GetCheckName()) + } + if client.lastOpts.GetStatus() != "completed" { + t.Errorf("expected status 'completed', got %q", client.lastOpts.GetStatus()) + } + if client.lastOpts.GetFilter() != "all" { + t.Errorf("expected filter 'all', got %q", client.lastOpts.GetFilter()) + } + if client.lastOpts.ListOptions.PerPage != 100 { + t.Errorf("expected per page 100, got %d", client.lastOpts.ListOptions.PerPage) + } +} + +func TestGetCheckRunsUnsetFilterOptions(t *testing.T) { + ctx := context.Background() + opts := models.CheckRunsOptions{ + Owner: "grafana", + Repository: "github-datasource", + Ref: "abc123def456", + } + + client := &checkRunsMockClient{ + pages: []checkRunPage{{checkRuns: nil, nextPage: 0}}, + expectedOwner: "grafana", + expectedRepo: "github-datasource", + expectedRef: "abc123def456", + t: t, + } + + if _, err := GetCheckRuns(ctx, client, opts); err != nil { + t.Fatal(err) + } + + if client.lastOpts.CheckName != nil { + t.Errorf("expected check name to be unset, got %q", client.lastOpts.GetCheckName()) + } + if client.lastOpts.Status != nil { + t.Errorf("expected status to be unset, got %q", client.lastOpts.GetStatus()) + } + if client.lastOpts.Filter != nil { + t.Errorf("expected filter to be unset, got %q", client.lastOpts.GetFilter()) + } +} + +func TestCheckRunsFramesNilSafety(t *testing.T) { + checkRuns := CheckRunsWrapper([]*googlegithub.CheckRun{ + {}, + {Name: googlegithub.Ptr("build"), Status: googlegithub.Ptr("in_progress")}, + }) + + frames := checkRuns.Frames() + if len(frames) != 1 { + t.Fatalf("expected 1 frame, got %d", len(frames)) + } + if frames[0].Rows() != 2 { + t.Errorf("expected 2 rows, got %d", frames[0].Rows()) + } +} + +func TestCheckRunDuration(t *testing.T) { + started := time.Date(2024, 1, 1, 10, 0, 0, 0, time.UTC) + completed := started.Add(90 * time.Second) + + if got := checkRunDuration(nil, &completed); got != nil { + t.Errorf("expected nil duration when started_at is missing, got %d", *got) + } + if got := checkRunDuration(&started, nil); got != nil { + t.Errorf("expected nil duration when completed_at is missing, got %d", *got) + } + got := checkRunDuration(&started, &completed) + if got == nil { + t.Fatal("expected a duration, got nil") + } + if *got != 90 { + t.Errorf("expected duration of 90 seconds, got %d", *got) + } +} + +func TestCheckRunsFrames(t *testing.T) { + started := googlegithub.Timestamp{Time: time.Date(2024, 1, 1, 10, 0, 0, 0, time.UTC)} + completed := googlegithub.Timestamp{Time: time.Date(2024, 1, 1, 10, 5, 0, 0, time.UTC)} + + checkRuns := CheckRunsWrapper([]*googlegithub.CheckRun{ + { + ID: googlegithub.Ptr(int64(1)), + Name: googlegithub.Ptr("build"), + HeadSHA: googlegithub.Ptr("abc123def456"), + Status: googlegithub.Ptr("completed"), + Conclusion: googlegithub.Ptr("success"), + StartedAt: &started, + CompletedAt: &completed, + HTMLURL: googlegithub.Ptr("https://github.com/grafana/github-datasource/runs/1"), + DetailsURL: googlegithub.Ptr("https://github.com/grafana/github-datasource/actions/runs/1"), + App: &googlegithub.App{ + Name: googlegithub.Ptr("GitHub Actions"), + Slug: googlegithub.Ptr("github-actions"), + }, + CheckSuite: &googlegithub.CheckSuite{ID: googlegithub.Ptr(int64(10))}, + Output: &googlegithub.CheckRunOutput{ + Title: googlegithub.Ptr("Build succeeded"), + Summary: googlegithub.Ptr("All targets built"), + AnnotationsCount: googlegithub.Ptr(0), + }, + }, + { + ID: googlegithub.Ptr(int64(2)), + Name: googlegithub.Ptr("lint"), + HeadSHA: googlegithub.Ptr("abc123def456"), + Status: googlegithub.Ptr("in_progress"), + StartedAt: &started, + }, + }) + + testutil.CheckGoldenFramer(t, "check_runs", checkRuns) +} diff --git a/pkg/github/client/checkruns_test.go b/pkg/github/client/checkruns_test.go new file mode 100644 index 00000000..7fef91f7 --- /dev/null +++ b/pkg/github/client/checkruns_test.go @@ -0,0 +1,85 @@ +package githubclient + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + googlegithub "github.com/google/go-github/v84/github" + "github.com/stretchr/testify/require" +) + +func TestListCheckRunsForRef(t *testing.T) { + var gotPath, gotQuery string + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotQuery = r.URL.RawQuery + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "total_count": 1, + "check_runs": [ + { + "id": 4, + "name": "build", + "head_sha": "abc123", + "status": "completed", + "conclusion": "success", + "started_at": "2024-01-01T10:00:00Z", + "completed_at": "2024-01-01T10:05:00Z", + "app": {"name": "GitHub Actions", "slug": "github-actions"}, + "check_suite": {"id": 42}, + "output": {"title": "Build succeeded", "annotations_count": 0} + } + ] + }`)) + })) + defer server.Close() + + restClient, err := googlegithub.NewClient(nil).WithEnterpriseURLs(server.URL, server.URL) + require.NoError(t, err) + + client := &Client{restClient: restClient} + + results, resp, err := client.ListCheckRunsForRef(context.Background(), "grafana", "github-datasource", "abc123", &googlegithub.ListCheckRunsOptions{ + CheckName: googlegithub.Ptr("build"), + Status: googlegithub.Ptr("completed"), + Filter: googlegithub.Ptr("all"), + ListOptions: googlegithub.ListOptions{PerPage: 100}, + }) + require.NoError(t, err) + require.NotNil(t, resp) + require.NotNil(t, results) + + require.Equal(t, "/api/v3/repos/grafana/github-datasource/commits/abc123/check-runs", gotPath) + require.Contains(t, gotQuery, "check_name=build") + require.Contains(t, gotQuery, "status=completed") + require.Contains(t, gotQuery, "filter=all") + require.Contains(t, gotQuery, "per_page=100") + + require.Len(t, results.CheckRuns, 1) + require.Equal(t, int64(4), results.CheckRuns[0].GetID()) + require.Equal(t, "build", results.CheckRuns[0].GetName()) + require.Equal(t, "success", results.CheckRuns[0].GetConclusion()) + require.Equal(t, "GitHub Actions", results.CheckRuns[0].GetApp().GetName()) + require.Equal(t, int64(42), results.CheckRuns[0].GetCheckSuite().GetID()) +} + +func TestListCheckRunsForRefError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message": "Not Found"}`)) + })) + defer server.Close() + + restClient, err := googlegithub.NewClient(nil).WithEnterpriseURLs(server.URL, server.URL) + require.NoError(t, err) + + client := &Client{restClient: restClient} + + results, resp, err := client.ListCheckRunsForRef(context.Background(), "grafana", "github-datasource", "missing", nil) + require.Error(t, err) + require.Nil(t, results) + require.Nil(t, resp) +} diff --git a/pkg/github/client/client.go b/pkg/github/client/client.go index c7464e75..2c87fd66 100644 --- a/pkg/github/client/client.go +++ b/pkg/github/client/client.go @@ -244,6 +244,15 @@ func (client *Client) ListPullRequestFiles(ctx context.Context, owner, repo stri return files, resp, nil } +// ListCheckRunsForRef sends a request to the GitHub rest API to list the check runs for a git reference. +func (client *Client) ListCheckRunsForRef(ctx context.Context, owner, repo, ref string, opts *googlegithub.ListCheckRunsOptions) (*googlegithub.ListCheckRunsResults, *googlegithub.Response, error) { + results, resp, err := client.restClient.Checks.ListCheckRunsForRef(ctx, owner, repo, ref, opts) + if err != nil { + return nil, nil, addErrorSourceToError(err, resp) + } + return results, resp, nil +} + // GetWorkflowUsage returns the workflow usage for a specific workflow. func (client *Client) GetWorkflowUsage(ctx context.Context, owner, repo, workflow string, timeRange backend.TimeRange) (models.WorkflowUsage, error) { actors := make(map[string]struct{}, 0) diff --git a/pkg/github/codescanning_test.go b/pkg/github/codescanning_test.go index 30dccea6..15f12ad9 100644 --- a/pkg/github/codescanning_test.go +++ b/pkg/github/codescanning_test.go @@ -97,6 +97,10 @@ func (m *mockClient) ListPullRequestFiles(ctx context.Context, owner, repo strin return nil, nil, nil } +func (m *mockClient) ListCheckRunsForRef(ctx context.Context, owner, repo, ref string, opts *googlegithub.ListCheckRunsOptions) (*googlegithub.ListCheckRunsResults, *googlegithub.Response, error) { + return nil, nil, nil +} + func TestGetCodeScanningAlerts(t *testing.T) { var ( ctx = context.Background() diff --git a/pkg/github/commit_files_test.go b/pkg/github/commit_files_test.go index e3386255..6fba446a 100644 --- a/pkg/github/commit_files_test.go +++ b/pkg/github/commit_files_test.go @@ -74,6 +74,10 @@ func (m *commitFilesMockClient) ListPullRequestFiles(_ context.Context, owner, r return p.files, resp, nil } +func (m *commitFilesMockClient) ListCheckRunsForRef(_ context.Context, _, _, _ string, _ *googlegithub.ListCheckRunsOptions) (*googlegithub.ListCheckRunsResults, *googlegithub.Response, error) { + return nil, nil, nil +} + func TestGetCommitFiles(t *testing.T) { ctx := context.Background() opts := models.CommitFilesOptions{ diff --git a/pkg/github/commits_test.go b/pkg/github/commits_test.go index 1ed08d78..91ecdad6 100644 --- a/pkg/github/commits_test.go +++ b/pkg/github/commits_test.go @@ -57,6 +57,9 @@ func (m *commitsWithFilesMockClient) ListDeployments(_ context.Context, _, _ str func (m *commitsWithFilesMockClient) ListPullRequestFiles(_ context.Context, _, _ string, _ int, _ *googlegithub.ListOptions) ([]*googlegithub.CommitFile, *googlegithub.Response, error) { panic("unimplemented") } +func (m *commitsWithFilesMockClient) ListCheckRunsForRef(_ context.Context, _, _, _ string, _ *googlegithub.ListCheckRunsOptions) (*googlegithub.ListCheckRunsResults, *googlegithub.Response, error) { + panic("unimplemented") +} func TestGetAllCommits(t *testing.T) { var ( diff --git a/pkg/github/datasource.go b/pkg/github/datasource.go index f69142fd..2758c848 100644 --- a/pkg/github/datasource.go +++ b/pkg/github/datasource.go @@ -61,6 +61,12 @@ func (d *Datasource) HandlePullRequestFilesQuery(ctx context.Context, query *mod return GetPullRequestFiles(ctx, d.client, opt) } +// HandleCheckRunsQuery is the query handler for listing check runs for a git reference in a GitHub repository +func (d *Datasource) HandleCheckRunsQuery(ctx context.Context, query *models.CheckRunsQuery, req backend.DataQuery) (dfutil.Framer, error) { + opt := models.CheckRunsOptionsWithRepo(query.Options, query.Owner, query.Repository) + return GetCheckRuns(ctx, d.client, opt) +} + // HandleCodeScanningQuery is the query handler for listing code scanning alerts of a GitHub repository func (d *Datasource) HandleCodeScanningQuery(ctx context.Context, query *models.CodeScanningQuery, req backend.DataQuery) (dfutil.Framer, error) { opt := models.CodeScanningOptionsWithRepo(query.Options, query.Owner, query.Repository) diff --git a/pkg/github/deployments_test.go b/pkg/github/deployments_test.go index 93a8238e..d4af5f64 100644 --- a/pkg/github/deployments_test.go +++ b/pkg/github/deployments_test.go @@ -59,6 +59,9 @@ func (m *mockDeploymentsClient) GetCommitFiles(_ context.Context, _, _, _ string func (m *mockDeploymentsClient) ListPullRequestFiles(_ context.Context, _, _ string, _ int, _ *googlegithub.ListOptions) ([]*googlegithub.CommitFile, *googlegithub.Response, error) { return nil, nil, nil } +func (m *mockDeploymentsClient) ListCheckRunsForRef(_ context.Context, _, _, _ string, _ *googlegithub.ListCheckRunsOptions) (*googlegithub.ListCheckRunsResults, *googlegithub.Response, error) { + return nil, nil, nil +} func TestGetAllDeployments(t *testing.T) { var ( diff --git a/pkg/github/query_handler.go b/pkg/github/query_handler.go index e90cc21e..d03aa0c9 100644 --- a/pkg/github/query_handler.go +++ b/pkg/github/query_handler.go @@ -69,6 +69,7 @@ func GetQueryHandlers(s *QueryHandler) *datasource.QueryTypeMux { register(models.QueryTypeOrganizations, s.HandleOrganizations) register(models.QueryTypeCommitFiles, s.HandleCommitFiles) register(models.QueryTypePullRequestFiles, s.HandlePullRequestFiles) + register(models.QueryTypeCheckRuns, s.HandleCheckRuns) return mux } diff --git a/pkg/github/testdata/check_runs.golden.jsonc b/pkg/github/testdata/check_runs.golden.jsonc new file mode 100644 index 00000000..c76ee993 --- /dev/null +++ b/pkg/github/testdata/check_runs.golden.jsonc @@ -0,0 +1,224 @@ +// 🌟 This was machine generated. Do not edit. 🌟 +// +// Frame[0] { +// "typeVersion": [ +// 0, +// 0 +// ], +// "preferredVisualisationType": "table" +// } +// Name: check_runs +// Dimensions: 16 Fields by 2 Rows +// +---------------+----------------+----------------+----------------+------------------+-------------------------------+-------------------------------+------------------------+-----------------------------------------------------+-------------------------------------------------------------+----------------+----------------+----------------------+--------------------+----------------------+-------------------------+ +// | Name: id | Name: name | Name: head_sha | Name: status | Name: conclusion | Name: started_at | Name: completed_at | Name: duration_seconds | Name: html_url | Name: details_url | Name: app_name | Name: app_slug | Name: check_suite_id | Name: output_title | Name: output_summary | Name: annotations_count | +// | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | +// | Type: []int64 | Type: []string | Type: []string | Type: []string | Type: []string | Type: []*time.Time | Type: []*time.Time | Type: []*int64 | Type: []string | Type: []string | Type: []string | Type: []string | Type: []int64 | Type: []string | Type: []string | Type: []int64 | +// +---------------+----------------+----------------+----------------+------------------+-------------------------------+-------------------------------+------------------------+-----------------------------------------------------+-------------------------------------------------------------+----------------+----------------+----------------------+--------------------+----------------------+-------------------------+ +// | 1 | build | abc123def456 | completed | success | 2024-01-01 10:00:00 +0000 UTC | 2024-01-01 10:05:00 +0000 UTC | 300 | https://github.com/grafana/github-datasource/runs/1 | https://github.com/grafana/github-datasource/actions/runs/1 | GitHub Actions | github-actions | 10 | Build succeeded | All targets built | 0 | +// | 2 | lint | abc123def456 | in_progress | | 2024-01-01 10:00:00 +0000 UTC | null | null | | | | | 0 | | | 0 | +// +---------------+----------------+----------------+----------------+------------------+-------------------------------+-------------------------------+------------------------+-----------------------------------------------------+-------------------------------------------------------------+----------------+----------------+----------------------+--------------------+----------------------+-------------------------+ +// +// +// 🌟 This was machine generated. Do not edit. 🌟 +{ + "status": 200, + "frames": [ + { + "schema": { + "name": "check_runs", + "meta": { + "typeVersion": [ + 0, + 0 + ], + "preferredVisualisationType": "table" + }, + "fields": [ + { + "name": "id", + "type": "number", + "typeInfo": { + "frame": "int64" + } + }, + { + "name": "name", + "type": "string", + "typeInfo": { + "frame": "string" + } + }, + { + "name": "head_sha", + "type": "string", + "typeInfo": { + "frame": "string" + } + }, + { + "name": "status", + "type": "string", + "typeInfo": { + "frame": "string" + } + }, + { + "name": "conclusion", + "type": "string", + "typeInfo": { + "frame": "string" + } + }, + { + "name": "started_at", + "type": "time", + "typeInfo": { + "frame": "time.Time", + "nullable": true + } + }, + { + "name": "completed_at", + "type": "time", + "typeInfo": { + "frame": "time.Time", + "nullable": true + } + }, + { + "name": "duration_seconds", + "type": "number", + "typeInfo": { + "frame": "int64", + "nullable": true + } + }, + { + "name": "html_url", + "type": "string", + "typeInfo": { + "frame": "string" + } + }, + { + "name": "details_url", + "type": "string", + "typeInfo": { + "frame": "string" + } + }, + { + "name": "app_name", + "type": "string", + "typeInfo": { + "frame": "string" + } + }, + { + "name": "app_slug", + "type": "string", + "typeInfo": { + "frame": "string" + } + }, + { + "name": "check_suite_id", + "type": "number", + "typeInfo": { + "frame": "int64" + } + }, + { + "name": "output_title", + "type": "string", + "typeInfo": { + "frame": "string" + } + }, + { + "name": "output_summary", + "type": "string", + "typeInfo": { + "frame": "string" + } + }, + { + "name": "annotations_count", + "type": "number", + "typeInfo": { + "frame": "int64" + } + } + ] + }, + "data": { + "values": [ + [ + 1, + 2 + ], + [ + "build", + "lint" + ], + [ + "abc123def456", + "abc123def456" + ], + [ + "completed", + "in_progress" + ], + [ + "success", + "" + ], + [ + 1704103200000, + 1704103200000 + ], + [ + 1704103500000, + null + ], + [ + 300, + null + ], + [ + "https://github.com/grafana/github-datasource/runs/1", + "" + ], + [ + "https://github.com/grafana/github-datasource/actions/runs/1", + "" + ], + [ + "GitHub Actions", + "" + ], + [ + "github-actions", + "" + ], + [ + 10, + 0 + ], + [ + "Build succeeded", + "" + ], + [ + "All targets built", + "" + ], + [ + 0, + 0 + ] + ] + } + } + ] +} \ No newline at end of file diff --git a/pkg/models/checkruns.go b/pkg/models/checkruns.go new file mode 100644 index 00000000..ca671259 --- /dev/null +++ b/pkg/models/checkruns.go @@ -0,0 +1,36 @@ +package models + +// CheckRunsOptions is the options for listing check runs for a git reference +type CheckRunsOptions struct { + // Owner is the owner of the repository (ex: grafana) + Owner string `json:"owner"` + + // Repository is the name of the repository being queried (ex: grafana) + Repository string `json:"repository"` + + // Ref is the git reference to list check runs for. It can be a commit SHA, + // a branch name formatted as heads/, or a tag name formatted as tags/. + Ref string `json:"gitRef"` + + // CheckName filters the results to check runs with the given name. + CheckName string `json:"checkName"` + + // Status filters the results by check run status. Can be one of: queued, in_progress, completed. + Status string `json:"status"` + + // Filter filters check runs by their completed_at timestamp. + // Can be one of: latest (the most recent check run for each name) or all. Defaults to latest. + Filter string `json:"filter"` +} + +// CheckRunsOptionsWithRepo adds Owner and Repo to a CheckRunsOptions. This is just for convenience +func CheckRunsOptionsWithRepo(opt CheckRunsOptions, owner string, repo string) CheckRunsOptions { + return CheckRunsOptions{ + Owner: owner, + Repository: repo, + Ref: opt.Ref, + CheckName: opt.CheckName, + Status: opt.Status, + Filter: opt.Filter, + } +} diff --git a/pkg/models/client.go b/pkg/models/client.go index 6cc06685..9775e56f 100644 --- a/pkg/models/client.go +++ b/pkg/models/client.go @@ -20,4 +20,5 @@ type Client interface { ListDeployments(ctx context.Context, owner, repo string, opts *googlegithub.DeploymentsListOptions) ([]*googlegithub.Deployment, *googlegithub.Response, error) GetCommitFiles(ctx context.Context, owner, repo, sha string, opts *googlegithub.ListOptions) ([]*googlegithub.CommitFile, *googlegithub.Response, error) ListPullRequestFiles(ctx context.Context, owner, repo string, prNumber int, opts *googlegithub.ListOptions) ([]*googlegithub.CommitFile, *googlegithub.Response, error) + ListCheckRunsForRef(ctx context.Context, owner, repo, ref string, opts *googlegithub.ListCheckRunsOptions) (*googlegithub.ListCheckRunsResults, *googlegithub.Response, error) } diff --git a/pkg/models/query.go b/pkg/models/query.go index a010485f..b1e17bf2 100644 --- a/pkg/models/query.go +++ b/pkg/models/query.go @@ -55,6 +55,8 @@ const ( QueryTypePullRequestFiles QueryType = "Pull_Request_Files" // QueryTypeBranches is used when querying branches in a GitHub repository QueryTypeBranches QueryType = "Branches" + // QueryTypeCheckRuns is used when querying check runs for a git reference in a GitHub repository + QueryTypeCheckRuns QueryType = "Check_Runs" ) // Query refers to the structure of a query built using the QueryEditor. @@ -193,3 +195,9 @@ type BranchesQuery struct { Query Options ListBranchesOptions `json:"options"` } + +// CheckRunsQuery is used when querying check runs for a git reference in a GitHub repository +type CheckRunsQuery struct { + Query + Options CheckRunsOptions `json:"options"` +} diff --git a/pkg/plugin/datasource.go b/pkg/plugin/datasource.go index 68f111b3..538f198d 100644 --- a/pkg/plugin/datasource.go +++ b/pkg/plugin/datasource.go @@ -17,6 +17,7 @@ type Datasource interface { HandleCodeScanningQuery(context.Context, *models.CodeScanningQuery, backend.DataQuery) (dfutil.Framer, error) HandleCommitFilesQuery(context.Context, *models.CommitFilesQuery, backend.DataQuery) (dfutil.Framer, error) HandlePullRequestFilesQuery(context.Context, *models.PullRequestFilesQuery, backend.DataQuery) (dfutil.Framer, error) + HandleCheckRunsQuery(context.Context, *models.CheckRunsQuery, backend.DataQuery) (dfutil.Framer, error) HandleTagsQuery(context.Context, *models.TagsQuery, backend.DataQuery) (dfutil.Framer, error) HandleReleasesQuery(context.Context, *models.ReleasesQuery, backend.DataQuery) (dfutil.Framer, error) HandleContributorsQuery(context.Context, *models.ContributorsQuery, backend.DataQuery) (dfutil.Framer, error) diff --git a/pkg/plugin/datasource_caching.go b/pkg/plugin/datasource_caching.go index 87ab4045..f935a815 100644 --- a/pkg/plugin/datasource_caching.go +++ b/pkg/plugin/datasource_caching.go @@ -302,6 +302,16 @@ func (c *CachedDatasource) HandlePullRequestFilesQuery(ctx context.Context, q *m return c.saveCache(req, f, err) } +// HandleCheckRunsQuery is the cache wrapper for the check runs query handler +func (c *CachedDatasource) HandleCheckRunsQuery(ctx context.Context, q *models.CheckRunsQuery, req backend.DataQuery) (dfutil.Framer, error) { + if value, err := c.getCache(req); err == nil { + return value, err + } + + f, err := c.datasource.HandleCheckRunsQuery(ctx, q, req) + return c.saveCache(req, f, err) +} + // CheckHealth forwards the request to the datasource and does not perform any caching func (c *CachedDatasource) CheckHealth(ctx context.Context, req *backend.CheckHealthRequest) (*backend.CheckHealthResult, error) { return c.datasource.CheckHealth(ctx, req) diff --git a/pkg/testutil/client.go b/pkg/testutil/client.go index fdde92db..01fc37da 100644 --- a/pkg/testutil/client.go +++ b/pkg/testutil/client.go @@ -96,3 +96,8 @@ func (c *TestClient) GetCommitFiles(ctx context.Context, owner, repo, sha string func (c *TestClient) ListPullRequestFiles(ctx context.Context, owner, repo string, prNumber int, opts *googlegithub.ListOptions) ([]*googlegithub.CommitFile, *googlegithub.Response, error) { panic("unimplemented") } + +// ListCheckRunsForRef is not implemented because it is not being used in tests at the moment. +func (c *TestClient) ListCheckRunsForRef(ctx context.Context, owner, repo, ref string, opts *googlegithub.ListCheckRunsOptions) (*googlegithub.ListCheckRunsResults, *googlegithub.Response, error) { + panic("unimplemented") +} diff --git a/src/constants.ts b/src/constants.ts index 7814d5a1..4c1185f0 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -1,6 +1,7 @@ import { QueryType } from './types/query' export const QueryTypes = [ + 'Check_Runs', 'Code_Scanning', 'Commits', 'Commit_Files', diff --git a/src/types/query.ts b/src/types/query.ts index d4e171b8..e61a729b 100644 --- a/src/types/query.ts +++ b/src/types/query.ts @@ -177,7 +177,18 @@ export type DeploymentsOptions = Options & { type DeploymentsQuery = BaseQuery<'Deployments', DeploymentsOptions> //#endregion +//#region Check_Runs Query +export type CheckRunsOptions = Options & { + gitRef?: string; + checkName?: string; + status?: string; + filter?: string; +} +type Check_RunsQuery = BaseQuery<'Check_Runs', CheckRunsOptions> +//#endregion + export type GitHubQuery = + Check_RunsQuery | Code_ScanningQuery | CommitsQuery | Commit_FilesQuery | diff --git a/src/validation.ts b/src/validation.ts index 3741f8c9..a8a3375a 100644 --- a/src/validation.ts +++ b/src/validation.ts @@ -11,6 +11,7 @@ export const isValid = (query: GitHubQuery): boolean => { if ( query.queryType === "Commits" || query.queryType === 'Commit_Files' || + query.queryType === 'Check_Runs' || query.queryType === "Contributors" || query.queryType === "Tags" || query.queryType === "Releases" || diff --git a/src/views/QueryEditor.tsx b/src/views/QueryEditor.tsx index 99ccf05c..fcc0654a 100644 --- a/src/views/QueryEditor.tsx +++ b/src/views/QueryEditor.tsx @@ -23,6 +23,7 @@ import { QueryEditorWorkflows } from './QueryEditorWorkflows'; import { QueryEditorWorkflowUsage } from './QueryEditorWorkflowUsage'; import { QueryEditorWorkflowRuns } from './QueryEditorWorkflowRuns'; import { QueryEditorCodeScanning } from './QueryEditorCodeScanning'; +import { QueryEditorCheckRuns } from './QueryEditorCheckRuns'; import { QueryEditorDeployments } from './QueryEditorDeployments'; import { QueryEditorBranches } from './QueryEditorBranches'; @@ -66,6 +67,11 @@ const queryEditors: Record ), }, + ['Check_Runs']: { + component: (props: Props, onChange: (val: any) => void) => ( + + ), + }, ['Commits']: { component: (props: Props, onChange: (val: any) => void) => ( diff --git a/src/views/QueryEditorCheckRuns.test.tsx b/src/views/QueryEditorCheckRuns.test.tsx new file mode 100644 index 00000000..7e1afbc2 --- /dev/null +++ b/src/views/QueryEditorCheckRuns.test.tsx @@ -0,0 +1,30 @@ +import React from 'react'; +import { QueryEditorCheckRuns } from './QueryEditorCheckRuns'; +import { render, screen, fireEvent } from '@testing-library/react'; + +describe('QueryEditorCheckRuns', () => { + it('renders the ref and check name fields', () => { + render(); + expect(screen.getByText('Ref')).toBeInTheDocument(); + expect(screen.getByText('Check name')).toBeInTheDocument(); + expect(screen.getByText('Status')).toBeInTheDocument(); + expect(screen.getByText('Filter')).toBeInTheDocument(); + }); + + it('shows existing option values', () => { + render(); + expect(screen.getByDisplayValue('abc123')).toBeInTheDocument(); + expect(screen.getByDisplayValue('build')).toBeInTheDocument(); + }); + + it('propagates the ref on blur', () => { + const onChange = jest.fn(); + render(); + + const refInput = screen.getByLabelText('Query editor ref'); + fireEvent.change(refInput, { target: { value: 'heads/main' } }); + fireEvent.blur(refInput); + + expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ gitRef: 'heads/main' })); + }); +}); diff --git a/src/views/QueryEditorCheckRuns.tsx b/src/views/QueryEditorCheckRuns.tsx new file mode 100644 index 00000000..e0e60cb7 --- /dev/null +++ b/src/views/QueryEditorCheckRuns.tsx @@ -0,0 +1,68 @@ +import React, { useState } from 'react'; +import { Combobox, type ComboboxOption, Input } from '@grafana/ui'; +import { EditorField, EditorRow } from '@grafana/plugin-ui'; +import { RightColumnWidth } from './QueryEditor'; +import { components } from '../components/selectors'; +import type { CheckRunsOptions } from '../types/query'; + +interface Props extends CheckRunsOptions { + onChange: (value: CheckRunsOptions) => void; +} + +const statusOptions: Array> = [ + { label: 'Any', value: '' }, + { label: 'Queued', value: 'queued' }, + { label: 'In progress', value: 'in_progress' }, + { label: 'Completed', value: 'completed' }, +]; + +const filterOptions: Array> = [ + { label: 'Latest', value: 'latest' }, + { label: 'All', value: 'all' }, +]; + +export const QueryEditorCheckRuns = (props: Props) => { + const [gitRef, setGitRef] = useState(props.gitRef || ''); + const [checkName, setCheckName] = useState(props.checkName || ''); + + return ( + + + setGitRef(el.currentTarget.value)} + onBlur={(el) => props.onChange({ ...props, checkName, gitRef: el.currentTarget.value })} + /> + + + setCheckName(el.currentTarget.value)} + onBlur={(el) => props.onChange({ ...props, gitRef, checkName: el.currentTarget.value })} + /> + + + + width={RightColumnWidth} + options={statusOptions} + value={props.status || ''} + onChange={(value) => props.onChange({ ...props, gitRef, checkName, status: value?.value })} + /> + + + + width={RightColumnWidth} + options={filterOptions} + value={props.filter || 'latest'} + onChange={(value) => props.onChange({ ...props, gitRef, checkName, filter: value?.value })} + /> + + + ); +};