diff --git a/.changeset/wild-webhooks-report.md b/.changeset/wild-webhooks-report.md new file mode 100644 index 00000000..08503d08 --- /dev/null +++ b/.changeset/wild-webhooks-report.md @@ -0,0 +1,5 @@ +--- +'grafana-github-datasource': minor +--- + +🚀 Add Webhook Deliveries query type for monitoring webhook delivery success rates and failures diff --git a/docs/sources/query-editor.md b/docs/sources/query-editor.md index 3d39cd88..b49124cc 100644 --- a/docs/sources/query-editor.md +++ b/docs/sources/query-editor.md @@ -50,6 +50,7 @@ Select a query type from the **Query Type** drop-down in the query editor: - [**Stargazers**](#stargazers): Get a list of users who have starred a repository, including the ability to plot a total count over time. - [**Tags**](#tags): List created tags for a repository. - [**Vulnerabilities**](#vulnerabilities): Query security vulnerabilities detected in a repository. +- [**Webhook deliveries**](#webhook-deliveries): List deliveries of an organization or repository webhook, including the response status code. - [**Workflows**](#workflows): List GitHub Actions workflows defined in a repository. - [**Workflow runs**](#workflow-runs): List runs for a specific workflow, including status, conclusion, and timing information. - [**Workflow usage**](#workflow-usage): Retrieve usage statistics for a workflow, such as run counts and durations. @@ -875,6 +876,58 @@ Show all security advisories for the `grafana/grafana` repository: | severity | Severity level: `LOW`, `MODERATE`, `HIGH`, or `CRITICAL` | | state | State of the vulnerability alert: `OPEN`, `FIXED`, or `DISMISSED` | +### Webhook deliveries + +List deliveries of an organization or repository webhook, one row per delivery. Useful for tracking webhook reliability: plot the success rate of an automation webhook over time, or alert when failures pass a threshold. + +{{< admonition type="note" >}} +GitHub only keeps webhook deliveries for the past 3 days, so a longer dashboard time range returns only the deliveries that are left. This query also requires a token with the `admin:org_hook` scope for organization webhooks, or `admin:repo_hook` for repository webhooks. +{{< /admonition >}} + +To find the ID of a webhook, run `gh api /orgs//hooks` for an organization webhook, or `gh api /repos///hooks` for a repository webhook. + +#### Query options + +| Name | Description | Required | +|------|-------------|----------| +| Owner | Organization the webhook belongs to, or GitHub user or organization that owns the repository | Yes | +| Repository | Name of the repository. Leave it empty to query an organization webhook | No | +| Hook ID | Numeric ID of the webhook | Yes | +| Event | Only return deliveries triggered by this event (for example, `pull_request`) | No | +| Status | Filter deliveries by outcome: `All`, `Success` (a 2xx response), or `Failure` (any other response, including deliveries that never got one) | No | + +##### Sample queries + +Show all deliveries of an organization webhook: + +- Owner: `grafana` +- Hook ID: `12345678` + +Show failed `pull_request` deliveries of a repository webhook: + +- Owner: `grafana` +- Repository: `grafana` +- Hook ID: `12345678` +- Event: `pull_request` +- Status: `Failure` + +#### Response + +| Name | Description | +|------|-------------| +| delivered_at | When the delivery was attempted: YYYY-MM-DD HH:MM:SS | +| id | Unique identifier for the delivery | +| guid | Identifier shared by a delivery and all of its redeliveries | +| event | Event that triggered the delivery (for example, `pull_request`) | +| action | Action of the event, if applicable (for example, `opened`) | +| status | Description of the response (for example, `OK`) | +| status_code | HTTP status code the webhook endpoint responded with | +| duration | How long the delivery took, in seconds | +| redelivery | Whether the delivery is a redelivery of an earlier one | +| repository_id | ID of the repository the event came from. The delivery does not include the repository name | +| installation_id | ID of the GitHub App installation, if the webhook belongs to one | +| success | Whether the endpoint responded with a 2xx status code | + ### Workflows List GitHub Actions workflows defined in a repository. diff --git a/pkg/github/client/client.go b/pkg/github/client/client.go index c7464e75..34382f22 100644 --- a/pkg/github/client/client.go +++ b/pkg/github/client/client.go @@ -225,6 +225,24 @@ func (client *Client) ListDeployments(ctx context.Context, owner, repo string, o return deployments, resp, err } +// ListOrgHookDeliveries sends a request to the GitHub rest API to list the deliveries of an organization webhook. +func (client *Client) ListOrgHookDeliveries(ctx context.Context, org string, hookID int64, opts *googlegithub.ListCursorOptions) ([]*googlegithub.HookDelivery, *googlegithub.Response, error) { + deliveries, resp, err := client.restClient.Organizations.ListHookDeliveries(ctx, org, hookID, opts) + if err != nil { + return nil, nil, addErrorSourceToError(err, resp) + } + return deliveries, resp, err +} + +// ListRepoHookDeliveries sends a request to the GitHub rest API to list the deliveries of a repository webhook. +func (client *Client) ListRepoHookDeliveries(ctx context.Context, owner, repo string, hookID int64, opts *googlegithub.ListCursorOptions) ([]*googlegithub.HookDelivery, *googlegithub.Response, error) { + deliveries, resp, err := client.restClient.Repositories.ListHookDeliveries(ctx, owner, repo, hookID, opts) + if err != nil { + return nil, nil, addErrorSourceToError(err, resp) + } + return deliveries, resp, err +} + // GetCommitFiles returns the list of files changed in a specific commit. // Note: the GitHub API returns at most 300 files for a single commit. func (client *Client) GetCommitFiles(ctx context.Context, owner, repo, sha string, opts *googlegithub.ListOptions) ([]*googlegithub.CommitFile, *googlegithub.Response, error) { diff --git a/pkg/github/codescanning_test.go b/pkg/github/codescanning_test.go index 30dccea6..335dd577 100644 --- a/pkg/github/codescanning_test.go +++ b/pkg/github/codescanning_test.go @@ -297,3 +297,11 @@ func equalInts(a, b []int) bool { } return true } + +func (m *mockClient) ListOrgHookDeliveries(_ context.Context, _ string, _ int64, _ *googlegithub.ListCursorOptions) ([]*googlegithub.HookDelivery, *googlegithub.Response, error) { + return nil, nil, nil +} + +func (m *mockClient) ListRepoHookDeliveries(_ context.Context, _, _ string, _ int64, _ *googlegithub.ListCursorOptions) ([]*googlegithub.HookDelivery, *googlegithub.Response, error) { + return nil, nil, nil +} diff --git a/pkg/github/commit_files_test.go b/pkg/github/commit_files_test.go index e3386255..60dbcbba 100644 --- a/pkg/github/commit_files_test.go +++ b/pkg/github/commit_files_test.go @@ -256,3 +256,11 @@ func TestCommitFilesFrames(t *testing.T) { testutil.CheckGoldenFramer(t, "commit_files", files) } + +func (m *commitFilesMockClient) ListOrgHookDeliveries(_ context.Context, _ string, _ int64, _ *googlegithub.ListCursorOptions) ([]*googlegithub.HookDelivery, *googlegithub.Response, error) { + return nil, nil, nil +} + +func (m *commitFilesMockClient) ListRepoHookDeliveries(_ context.Context, _, _ string, _ int64, _ *googlegithub.ListCursorOptions) ([]*googlegithub.HookDelivery, *googlegithub.Response, error) { + return nil, nil, nil +} diff --git a/pkg/github/commits_test.go b/pkg/github/commits_test.go index 1ed08d78..8b8d861f 100644 --- a/pkg/github/commits_test.go +++ b/pkg/github/commits_test.go @@ -251,3 +251,11 @@ func TestCommitsWithFilesDataframe(t *testing.T) { testutil.CheckGoldenFramer(t, "commits_with_files", cwf) } + +func (m *commitsWithFilesMockClient) ListOrgHookDeliveries(_ context.Context, _ string, _ int64, _ *googlegithub.ListCursorOptions) ([]*googlegithub.HookDelivery, *googlegithub.Response, error) { + return nil, nil, nil +} + +func (m *commitsWithFilesMockClient) ListRepoHookDeliveries(_ context.Context, _, _ string, _ int64, _ *googlegithub.ListCursorOptions) ([]*googlegithub.HookDelivery, *googlegithub.Response, error) { + return nil, nil, nil +} diff --git a/pkg/github/datasource.go b/pkg/github/datasource.go index f69142fd..ab368a4c 100644 --- a/pkg/github/datasource.go +++ b/pkg/github/datasource.go @@ -91,6 +91,18 @@ func (d *Datasource) HandleBranchesQuery(ctx context.Context, query *models.Bran return GetAllBranches(ctx, d.client, opt) } +// HandleWebhookDeliveriesQuery is the query handler for listing the deliveries of a GitHub webhook +func (d *Datasource) HandleWebhookDeliveriesQuery(ctx context.Context, query *models.WebhookDeliveriesQuery, req backend.DataQuery) (dfutil.Framer, error) { + opt := models.ListWebhookDeliveriesOptions{ + Repository: query.Repository, + Owner: query.Owner, + HookID: query.Options.HookID, + Event: query.Options.Event, + Status: query.Options.Status, + } + return GetWebhookDeliveries(ctx, d.client, opt, req.TimeRange) +} + // HandleReleasesQuery is the query handler for listing GitHub Releases func (d *Datasource) HandleReleasesQuery(ctx context.Context, query *models.ReleasesQuery, req backend.DataQuery) (dfutil.Framer, error) { opt := models.ListReleasesOptions{ diff --git a/pkg/github/deployments_test.go b/pkg/github/deployments_test.go index 93a8238e..fe5101fa 100644 --- a/pkg/github/deployments_test.go +++ b/pkg/github/deployments_test.go @@ -337,3 +337,11 @@ func TestDeploymentsWrapperFrames(t *testing.T) { } } } + +func (m *mockDeploymentsClient) ListOrgHookDeliveries(_ context.Context, _ string, _ int64, _ *googlegithub.ListCursorOptions) ([]*googlegithub.HookDelivery, *googlegithub.Response, error) { + return nil, nil, nil +} + +func (m *mockDeploymentsClient) ListRepoHookDeliveries(_ context.Context, _, _ string, _ int64, _ *googlegithub.ListCursorOptions) ([]*googlegithub.HookDelivery, *googlegithub.Response, error) { + return nil, nil, nil +} diff --git a/pkg/github/query_handler.go b/pkg/github/query_handler.go index e90cc21e..84de4455 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.QueryTypeWebhookDeliveries, s.HandleWebhookDeliveries) return mux } diff --git a/pkg/github/webhook_deliveries.go b/pkg/github/webhook_deliveries.go new file mode 100644 index 00000000..3c7899b4 --- /dev/null +++ b/pkg/github/webhook_deliveries.go @@ -0,0 +1,196 @@ +package github + +import ( + "context" + "fmt" + "strconv" + "strings" + "time" + + googlegithub "github.com/google/go-github/v84/github" + "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/grafana/grafana-plugin-sdk-go/data" + + "github.com/grafana/github-datasource/pkg/models" +) + +const ( + // webhookDeliveriesPerPage is the maximum page size the deliveries endpoint accepts + webhookDeliveriesPerPage = 100 + + // maxWebhookDeliveryPages caps how many requests a single query can send. The endpoint has no time filter, + // so a busy webhook would otherwise page through everything GitHub kept and exhaust the rate limit. + maxWebhookDeliveryPages = 50 +) + +// WebhookDeliveries is a list of deliveries of a GitHub webhook +type WebhookDeliveries struct { + deliveries []*googlegithub.HookDelivery + + // truncated is set when the page cap was reached before the start of the time range, which means the + // deliveries only cover part of it + truncated bool + + // retentionLimited is set when GitHub ran out of deliveries before the start of the time range, which + // means the deliveries only cover the part of it that is still within GitHub's retention window + retentionLimited bool +} + +// Frames converts the list of webhook deliveries to a Grafana DataFrame +func (w WebhookDeliveries) Frames() data.Frames { + frame := data.NewFrame( + "webhook_deliveries", + data.NewField("delivered_at", nil, []*time.Time{}), + data.NewField("id", nil, []*int64{}), + data.NewField("guid", nil, []*string{}), + data.NewField("event", nil, []*string{}), + data.NewField("action", nil, []*string{}), + data.NewField("status", nil, []*string{}), + data.NewField("status_code", nil, []*int64{}), + data.NewField("duration", nil, []*float64{}), + data.NewField("redelivery", nil, []*bool{}), + data.NewField("repository_id", nil, []*int64{}), + data.NewField("installation_id", nil, []*int64{}), + data.NewField("success", nil, []bool{}), + ) + + for _, delivery := range w.deliveries { + var statusCode *int64 + if delivery.StatusCode != nil { + statusCode = googlegithub.Ptr(int64(*delivery.StatusCode)) + } + + frame.AppendRow( + delivery.DeliveredAt.GetTime(), + delivery.ID, + delivery.GUID, + delivery.Event, + delivery.Action, + delivery.Status, + statusCode, + delivery.Duration, + delivery.Redelivery, + delivery.RepositoryID, + delivery.InstallationID, + isSuccessfulDelivery(delivery), + ) + } + + frame.Meta = &data.FrameMeta{PreferredVisualization: data.VisTypeTable} + switch { + case w.truncated: + frame.Meta.Notices = []data.Notice{{ + Severity: data.NoticeSeverityWarning, + Text: fmt.Sprintf( + "Only the %d most recent deliveries are shown because they do not cover the whole time range. Narrow the time range for complete results.", + maxWebhookDeliveryPages*webhookDeliveriesPerPage, + ), + }} + case w.retentionLimited: + frame.Meta.Notices = []data.Notice{{ + Severity: data.NoticeSeverityWarning, + Text: "The deliveries do not cover the whole time range. GitHub only keeps webhook deliveries for the past 3 days.", + }} + } + + return data.Frames{frame} +} + +// GetWebhookDeliveries gets the deliveries of an organization or repository webhook within the time range. +// GitHub only keeps deliveries for the past 3 days, so longer time ranges return what is left of them. +func GetWebhookDeliveries(ctx context.Context, client models.Client, opts models.ListWebhookDeliveriesOptions, timeRange backend.TimeRange) (WebhookDeliveries, error) { + if opts.Owner == "" || strings.TrimSpace(opts.HookID) == "" { + return WebhookDeliveries{}, nil + } + + hookID, err := strconv.ParseInt(strings.TrimSpace(opts.HookID), 10, 64) + if err != nil { + return WebhookDeliveries{}, backend.DownstreamError(fmt.Errorf("hook id must be a number, got %q", opts.HookID)) + } + + var ( + result = WebhookDeliveries{} + listOpts = &googlegithub.ListCursorOptions{PerPage: webhookDeliveriesPerPage} + + // oldestDelivered is the delivery time of the last delivery seen, which is the oldest one because the + // endpoint returns them from the most recent one + oldestDelivered *time.Time + ) + + // The endpoint has no time filter, so paging always starts at the most recent delivery. A time range that + // ends in the past therefore still pages through everything that happened since. + + for page := 0; page < maxWebhookDeliveryPages; page++ { + var ( + deliveries []*googlegithub.HookDelivery + resp *googlegithub.Response + err error + ) + + if opts.Repository == "" { + deliveries, resp, err = client.ListOrgHookDeliveries(ctx, opts.Owner, hookID, listOpts) + } else { + deliveries, resp, err = client.ListRepoHookDeliveries(ctx, opts.Owner, opts.Repository, hookID, listOpts) + } + if err != nil { + return WebhookDeliveries{}, fmt.Errorf("listing webhook deliveries: opts=%+v: %w", opts, err) + } + + // Deliveries are returned from the most recent one, so the first delivery older than the time range + // means every remaining one is older too. + reachedStartOfTimeRange := false + for _, delivery := range deliveries { + deliveredAt := delivery.DeliveredAt.GetTime() + if deliveredAt != nil && deliveredAt.Before(timeRange.From) { + reachedStartOfTimeRange = true + break + } + oldestDelivered = deliveredAt + + if matchesWebhookDeliveryFilters(delivery, opts, timeRange) { + result.deliveries = append(result.deliveries, delivery) + } + } + + if reachedStartOfTimeRange { + return result, nil + } + + if resp == nil || resp.Cursor == "" { + // GitHub has nothing older to return, so the deliveries only reach as far back as its retention + // window does + result.retentionLimited = oldestDelivered != nil && oldestDelivered.After(timeRange.From) + return result, nil + } + listOpts.Cursor = resp.Cursor + } + + result.truncated = true + return result, nil +} + +func matchesWebhookDeliveryFilters(delivery *googlegithub.HookDelivery, opts models.ListWebhookDeliveriesOptions, timeRange backend.TimeRange) bool { + if deliveredAt := delivery.DeliveredAt.GetTime(); deliveredAt != nil && deliveredAt.After(timeRange.To) { + return false + } + + if opts.Event != "" && delivery.GetEvent() != opts.Event { + return false + } + + switch opts.Status { + case models.WebhookDeliveryStatusSuccess: + return isSuccessfulDelivery(delivery) + case models.WebhookDeliveryStatusFailure: + return !isSuccessfulDelivery(delivery) + } + + return true +} + +// isSuccessfulDelivery reports whether the webhook endpoint answered with a 2xx status code. Deliveries that +// never got a response, for example because of a timeout, have no status code and count as failures. +func isSuccessfulDelivery(delivery *googlegithub.HookDelivery) bool { + statusCode := delivery.GetStatusCode() + return statusCode >= 200 && statusCode < 300 +} diff --git a/pkg/github/webhook_deliveries_handler.go b/pkg/github/webhook_deliveries_handler.go new file mode 100644 index 00000000..1cb7ab6f --- /dev/null +++ b/pkg/github/webhook_deliveries_handler.go @@ -0,0 +1,24 @@ +package github + +import ( + "context" + + "github.com/grafana/github-datasource/pkg/dfutil" + "github.com/grafana/github-datasource/pkg/models" + "github.com/grafana/grafana-plugin-sdk-go/backend" +) + +func (s *QueryHandler) handleWebhookDeliveriesQuery(ctx context.Context, q backend.DataQuery) backend.DataResponse { + query := &models.WebhookDeliveriesQuery{} + if err := UnmarshalQuery(q.JSON, query); err != nil { + return *err + } + return dfutil.FrameResponseWithError(s.Datasource.HandleWebhookDeliveriesQuery(ctx, query, q)) +} + +// HandleWebhookDeliveries handles the plugin query for github webhook deliveries +func (s *QueryHandler) HandleWebhookDeliveries(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) { + return &backend.QueryDataResponse{ + Responses: processQueries(ctx, req, s.handleWebhookDeliveriesQuery), + }, nil +} diff --git a/pkg/github/webhook_deliveries_test.go b/pkg/github/webhook_deliveries_test.go new file mode 100644 index 00000000..92ab78ee --- /dev/null +++ b/pkg/github/webhook_deliveries_test.go @@ -0,0 +1,371 @@ +package github + +import ( + "context" + "strconv" + "testing" + "time" + + googlegithub "github.com/google/go-github/v84/github" + "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/grafana/grafana-plugin-sdk-go/data" + + "github.com/grafana/github-datasource/pkg/models" + "github.com/grafana/github-datasource/pkg/testutil" +) + +type mockHookDeliveriesClient struct { + *testutil.TestClient + + t *testing.T + pages [][]*googlegithub.HookDelivery + calls int + expectedOwner string + expectedRepo string + expectedHookID int64 + // endless makes every page return a cursor, which lets a test hit the page cap + endless bool +} + +func (m *mockHookDeliveriesClient) page(opts *googlegithub.ListCursorOptions) ([]*googlegithub.HookDelivery, *googlegithub.Response, error) { + if m.calls > 0 && opts.Cursor == "" { + m.t.Error("expected the cursor of the previous response to be sent with the request") + } + + page := m.pages[m.calls%len(m.pages)] + m.calls++ + + resp := &googlegithub.Response{} + if m.endless || m.calls < len(m.pages) { + resp.Cursor = "cursor-" + strconv.Itoa(m.calls) + } + + return page, resp, nil +} + +func (m *mockHookDeliveriesClient) ListOrgHookDeliveries(_ context.Context, org string, hookID int64, opts *googlegithub.ListCursorOptions) ([]*googlegithub.HookDelivery, *googlegithub.Response, error) { + if m.expectedRepo != "" { + m.t.Errorf("expected the repository webhook endpoint to be called, got the organization one") + } + if org != m.expectedOwner || hookID != m.expectedHookID { + m.t.Errorf("expected org/hook to be %s/%d, got %s/%d", m.expectedOwner, m.expectedHookID, org, hookID) + } + return m.page(opts) +} + +func (m *mockHookDeliveriesClient) ListRepoHookDeliveries(_ context.Context, owner, repo string, hookID int64, opts *googlegithub.ListCursorOptions) ([]*googlegithub.HookDelivery, *googlegithub.Response, error) { + if owner != m.expectedOwner || repo != m.expectedRepo || hookID != m.expectedHookID { + m.t.Errorf("expected owner/repo/hook to be %s/%s/%d, got %s/%s/%d", m.expectedOwner, m.expectedRepo, m.expectedHookID, owner, repo, hookID) + } + return m.page(opts) +} + +func delivery(id int64, deliveredAt time.Time, event string, statusCode *int) *googlegithub.HookDelivery { + return &googlegithub.HookDelivery{ + ID: googlegithub.Ptr(id), + GUID: googlegithub.Ptr("guid-" + strconv.FormatInt(id, 10)), + DeliveredAt: &googlegithub.Timestamp{Time: deliveredAt}, + Event: googlegithub.Ptr(event), + StatusCode: statusCode, + } +} + +func TestGetWebhookDeliveriesStopsAtTheStartOfTheTimeRange(t *testing.T) { + var ( + ctx = context.Background() + now = time.Now() + timeRange = backend.TimeRange{From: now.Add(-1 * time.Hour), To: now} + ) + + client := &mockHookDeliveriesClient{ + t: t, + expectedOwner: "grafana", + expectedHookID: 12345, + pages: [][]*googlegithub.HookDelivery{ + { + delivery(1, now.Add(-1*time.Minute), "push", googlegithub.Ptr(200)), + delivery(2, now.Add(-2*time.Minute), "push", googlegithub.Ptr(200)), + }, + { + delivery(3, now.Add(-59*time.Minute), "push", googlegithub.Ptr(200)), + // older than the time range, so paging must stop here + delivery(4, now.Add(-2*time.Hour), "push", googlegithub.Ptr(200)), + }, + { + delivery(5, now.Add(-3*time.Hour), "push", googlegithub.Ptr(200)), + }, + }, + endless: true, + } + + deliveries, err := GetWebhookDeliveries(ctx, client, models.ListWebhookDeliveriesOptions{ + Owner: "grafana", + HookID: "12345", + }, timeRange) + if err != nil { + t.Fatal(err) + } + + if len(deliveries.deliveries) != 3 { + t.Errorf("expected 3 deliveries in the time range, got %d", len(deliveries.deliveries)) + } + if client.calls != 2 { + t.Errorf("expected paging to stop after 2 requests, got %d", client.calls) + } + if deliveries.truncated { + t.Error("expected the result not to be marked as truncated") + } +} + +func TestGetWebhookDeliveriesMarksCappedResultsAsTruncated(t *testing.T) { + var ( + ctx = context.Background() + now = time.Now() + timeRange = backend.TimeRange{From: now.Add(-72 * time.Hour), To: now} + ) + + client := &mockHookDeliveriesClient{ + t: t, + expectedOwner: "grafana", + expectedHookID: 12345, + pages: [][]*googlegithub.HookDelivery{ + {delivery(1, now.Add(-1*time.Minute), "push", googlegithub.Ptr(200))}, + }, + endless: true, + } + + deliveries, err := GetWebhookDeliveries(ctx, client, models.ListWebhookDeliveriesOptions{ + Owner: "grafana", + HookID: "12345", + }, timeRange) + if err != nil { + t.Fatal(err) + } + + if client.calls != maxWebhookDeliveryPages { + t.Errorf("expected paging to stop after %d requests, got %d", maxWebhookDeliveryPages, client.calls) + } + if !deliveries.truncated { + t.Fatal("expected the result to be marked as truncated") + } + + frame := deliveries.Frames()[0] + if len(frame.Meta.Notices) != 1 || frame.Meta.Notices[0].Severity != data.NoticeSeverityWarning { + t.Errorf("expected a warning notice on the frame, got %+v", frame.Meta.Notices) + } +} + +func TestGetWebhookDeliveriesMarksRetentionLimitedResults(t *testing.T) { + var ( + ctx = context.Background() + now = time.Now() + timeRange = backend.TimeRange{From: now.Add(-7 * 24 * time.Hour), To: now} + ) + + // GitHub keeps deliveries for 3 days, so it runs out of them long before the start of the time range + client := &mockHookDeliveriesClient{ + t: t, + expectedOwner: "grafana", + expectedHookID: 12345, + pages: [][]*googlegithub.HookDelivery{ + { + delivery(1, now.Add(-1*time.Hour), "push", googlegithub.Ptr(200)), + delivery(2, now.Add(-70*time.Hour), "push", googlegithub.Ptr(200)), + }, + }, + } + + deliveries, err := GetWebhookDeliveries(ctx, client, models.ListWebhookDeliveriesOptions{ + Owner: "grafana", + HookID: "12345", + }, timeRange) + if err != nil { + t.Fatal(err) + } + + if !deliveries.retentionLimited { + t.Fatal("expected the result to be marked as limited by the retention window") + } + + frame := deliveries.Frames()[0] + if len(frame.Meta.Notices) != 1 || frame.Meta.Notices[0].Severity != data.NoticeSeverityWarning { + t.Errorf("expected a warning notice on the frame, got %+v", frame.Meta.Notices) + } +} + +func TestGetWebhookDeliveriesFilters(t *testing.T) { + var ( + ctx = context.Background() + now = time.Now() + timeRange = backend.TimeRange{From: now.Add(-1 * time.Hour), To: now} + page = []*googlegithub.HookDelivery{ + delivery(1, now.Add(-1*time.Minute), "push", googlegithub.Ptr(200)), + delivery(2, now.Add(-2*time.Minute), "pull_request", googlegithub.Ptr(503)), + delivery(3, now.Add(-3*time.Minute), "pull_request", googlegithub.Ptr(201)), + // never got a response + delivery(4, now.Add(-4*time.Minute), "pull_request", nil), + // delivered after the end of the time range + delivery(5, now.Add(1*time.Minute), "pull_request", googlegithub.Ptr(200)), + } + ) + + for _, tc := range []struct { + name string + opts models.ListWebhookDeliveriesOptions + expected []int64 + }{ + { + name: "no filters", + opts: models.ListWebhookDeliveriesOptions{}, + expected: []int64{1, 2, 3, 4}, + }, + { + name: "by event", + opts: models.ListWebhookDeliveriesOptions{Event: "pull_request"}, + expected: []int64{2, 3, 4}, + }, + { + name: "successful deliveries", + opts: models.ListWebhookDeliveriesOptions{Status: models.WebhookDeliveryStatusSuccess}, + expected: []int64{1, 3}, + }, + { + name: "failed deliveries include the ones without a response", + opts: models.ListWebhookDeliveriesOptions{Status: models.WebhookDeliveryStatusFailure}, + expected: []int64{2, 4}, + }, + } { + t.Run(tc.name, func(t *testing.T) { + client := &mockHookDeliveriesClient{ + t: t, + expectedOwner: "grafana", + expectedRepo: "grafana", + expectedHookID: 12345, + pages: [][]*googlegithub.HookDelivery{page}, + } + + opts := tc.opts + opts.Owner = "grafana" + opts.Repository = "grafana" + opts.HookID = "12345" + + deliveries, err := GetWebhookDeliveries(ctx, client, opts, timeRange) + if err != nil { + t.Fatal(err) + } + + ids := []int64{} + for _, d := range deliveries.deliveries { + ids = append(ids, d.GetID()) + } + + if len(ids) != len(tc.expected) { + t.Fatalf("expected deliveries %v, got %v", tc.expected, ids) + } + for i := range ids { + if ids[i] != tc.expected[i] { + t.Fatalf("expected deliveries %v, got %v", tc.expected, ids) + } + } + }) + } +} + +func TestGetWebhookDeliveriesWithoutRequiredOptions(t *testing.T) { + var ( + ctx = context.Background() + now = time.Now() + timeRange = backend.TimeRange{From: now.Add(-1 * time.Hour), To: now} + ) + + for _, tc := range []struct { + name string + opts models.ListWebhookDeliveriesOptions + expectError bool + }{ + {name: "no owner", opts: models.ListWebhookDeliveriesOptions{HookID: "12345"}}, + {name: "no hook id", opts: models.ListWebhookDeliveriesOptions{Owner: "grafana"}}, + { + name: "hook id is not a number", + opts: models.ListWebhookDeliveriesOptions{Owner: "grafana", HookID: "my-webhook"}, + expectError: true, + }, + } { + t.Run(tc.name, func(t *testing.T) { + client := &mockHookDeliveriesClient{t: t} + + deliveries, err := GetWebhookDeliveries(ctx, client, tc.opts, timeRange) + if tc.expectError { + if err == nil { + t.Fatal("expected an error") + } + return + } + + if err != nil { + t.Fatal(err) + } + if client.calls != 0 { + t.Errorf("expected no requests to be sent, got %d", client.calls) + } + if len(deliveries.deliveries) != 0 { + t.Errorf("expected no deliveries, got %d", len(deliveries.deliveries)) + } + }) + } +} + +func TestWebhookDeliveriesFrames(t *testing.T) { + now := time.Now() + + deliveries := WebhookDeliveries{ + deliveries: []*googlegithub.HookDelivery{ + { + ID: googlegithub.Ptr(int64(1)), + GUID: googlegithub.Ptr("guid-1"), + DeliveredAt: &googlegithub.Timestamp{Time: now}, + Redelivery: googlegithub.Ptr(false), + Duration: googlegithub.Ptr(0.42), + Status: googlegithub.Ptr("OK"), + StatusCode: googlegithub.Ptr(200), + Event: googlegithub.Ptr("push"), + Action: googlegithub.Ptr("created"), + InstallationID: googlegithub.Ptr(int64(2)), + RepositoryID: googlegithub.Ptr(int64(3)), + }, + { + ID: googlegithub.Ptr(int64(2)), + DeliveredAt: &googlegithub.Timestamp{Time: now}, + }, + }, + } + + frame := deliveries.Frames()[0] + + if frame.Meta.PreferredVisualization != data.VisTypeTable { + t.Errorf("expected the frame to prefer a table visualization, got %s", frame.Meta.PreferredVisualization) + } + if len(frame.Meta.Notices) != 0 { + t.Errorf("expected no notices on a complete result, got %+v", frame.Meta.Notices) + } + + rows, err := frame.RowLen() + if err != nil { + t.Fatal(err) + } + if rows != 2 { + t.Fatalf("expected 2 rows, got %d", rows) + } + + if frame.Fields[0].Name != "delivered_at" { + t.Errorf("expected the first field to be delivered_at, got %s", frame.Fields[0].Name) + } + + success, ok := frame.Fields[len(frame.Fields)-1].At(0).(bool) + if !ok || !success { + t.Errorf("expected the 200 delivery to be successful, got %v", frame.Fields[len(frame.Fields)-1].At(0)) + } + if success, ok := frame.Fields[len(frame.Fields)-1].At(1).(bool); !ok || success { + t.Errorf("expected the delivery without a status code to be unsuccessful, got %v", frame.Fields[len(frame.Fields)-1].At(1)) + } +} diff --git a/pkg/models/client.go b/pkg/models/client.go index 6cc06685..33bafcab 100644 --- a/pkg/models/client.go +++ b/pkg/models/client.go @@ -20,4 +20,6 @@ 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) + ListOrgHookDeliveries(ctx context.Context, org string, hookID int64, opts *googlegithub.ListCursorOptions) ([]*googlegithub.HookDelivery, *googlegithub.Response, error) + ListRepoHookDeliveries(ctx context.Context, owner, repo string, hookID int64, opts *googlegithub.ListCursorOptions) ([]*googlegithub.HookDelivery, *googlegithub.Response, error) } diff --git a/pkg/models/query.go b/pkg/models/query.go index a010485f..d3ae3ce3 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" + // QueryTypeWebhookDeliveries is used when querying deliveries of an organization or repository webhook + QueryTypeWebhookDeliveries QueryType = "Webhook_Deliveries" ) // Query refers to the structure of a query built using the QueryEditor. @@ -193,3 +195,9 @@ type BranchesQuery struct { Query Options ListBranchesOptions `json:"options"` } + +// WebhookDeliveriesQuery is used when querying for deliveries of a GitHub webhook +type WebhookDeliveriesQuery struct { + Query + Options ListWebhookDeliveriesOptions `json:"options"` +} diff --git a/pkg/models/webhook_deliveries.go b/pkg/models/webhook_deliveries.go new file mode 100644 index 00000000..ef0d8316 --- /dev/null +++ b/pkg/models/webhook_deliveries.go @@ -0,0 +1,33 @@ +package models + +// WebhookDeliveryStatus filters webhook deliveries by their outcome +// +enum +type WebhookDeliveryStatus string + +const ( + // WebhookDeliveryStatusAll returns every delivery, no matter the response status code + WebhookDeliveryStatusAll WebhookDeliveryStatus = "all" + // WebhookDeliveryStatusSuccess returns only deliveries answered with a 2xx status code + WebhookDeliveryStatusSuccess WebhookDeliveryStatus = "success" + // WebhookDeliveryStatusFailure returns every other delivery, including the ones that never got a response + WebhookDeliveryStatusFailure WebhookDeliveryStatus = "failure" +) + +// ListWebhookDeliveriesOptions are the available options when listing webhook deliveries +type ListWebhookDeliveriesOptions struct { + // Repository is the name of the repository the webhook belongs to (ex: grafana). + // When it is empty, the webhook is looked up on the organization instead. + Repository string `json:"repository"` + + // Owner is the organization the webhook belongs to, or the owner of the repository (ex: grafana) + Owner string `json:"owner"` + + // HookID is the numeric ID of the webhook (ex: 12345678) + HookID string `json:"hookId"` + + // Event filters deliveries by the event that triggered them (ex: pull_request) + Event string `json:"event"` + + // Status filters deliveries by their outcome + Status WebhookDeliveryStatus `json:"status"` +} diff --git a/pkg/testutil/client.go b/pkg/testutil/client.go index fdde92db..87e53f0e 100644 --- a/pkg/testutil/client.go +++ b/pkg/testutil/client.go @@ -96,3 +96,13 @@ 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") } + +// ListOrgHookDeliveries is not implemented because it is not being used in tests at the moment. +func (c *TestClient) ListOrgHookDeliveries(ctx context.Context, org string, hookID int64, opts *googlegithub.ListCursorOptions) ([]*googlegithub.HookDelivery, *googlegithub.Response, error) { + panic("unimplemented") +} + +// ListRepoHookDeliveries is not implemented because it is not being used in tests at the moment. +func (c *TestClient) ListRepoHookDeliveries(ctx context.Context, owner, repo string, hookID int64, opts *googlegithub.ListCursorOptions) ([]*googlegithub.HookDelivery, *googlegithub.Response, error) { + panic("unimplemented") +} diff --git a/src/components/selectors.ts b/src/components/selectors.ts index 741953c6..24b664b6 100644 --- a/src/components/selectors.ts +++ b/src/components/selectors.ts @@ -29,6 +29,10 @@ export const components = { Issues: { timeFieldInput: 'Query editor issues time field', }, + WebhookDeliveries: { + hookIdInput: 'Query editor webhook hook id', + eventInput: 'Query editor webhook event', + }, }, AnnotationEditor: { container: 'Annotation editor container', diff --git a/src/constants.ts b/src/constants.ts index 7814d5a1..1ca0d851 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -26,6 +26,7 @@ export const QueryTypes = [ 'Workflow_Usage', 'Workflow_Runs', 'Deployments', + 'Webhook_Deliveries', ] as const; @@ -65,3 +66,9 @@ export enum ProjectQueryType { ORG = 0, USER = 1, } + +export enum WebhookDeliveryStatus { + All = 'all', + Success = 'success', + Failure = 'failure', +} diff --git a/src/types/query.ts b/src/types/query.ts index d4e171b8..c450161d 100644 --- a/src/types/query.ts +++ b/src/types/query.ts @@ -1,5 +1,5 @@ import { type DataQuery } from '@grafana/schema'; -import { PullRequestTimeField, IssueTimeField, WorkflowsTimeField, PackageType, ProjectQueryType, QueryTypes } from '../constants'; +import { PullRequestTimeField, IssueTimeField, WorkflowsTimeField, PackageType, ProjectQueryType, QueryTypes, WebhookDeliveryStatus } from '../constants'; import type { Filter } from 'components/Filters'; export type QueryType = typeof QueryTypes[number] @@ -177,6 +177,15 @@ export type DeploymentsOptions = Options & { type DeploymentsQuery = BaseQuery<'Deployments', DeploymentsOptions> //#endregion +//#region Webhook_Deliveries Query +export type WebhookDeliveriesOptions = Options & { + hookId?: string; + event?: string; + status?: WebhookDeliveryStatus; +} +type Webhook_DeliveriesQuery = BaseQuery<'Webhook_Deliveries', WebhookDeliveriesOptions> +//#endregion + export type GitHubQuery = Code_ScanningQuery | CommitsQuery | @@ -202,7 +211,8 @@ export type GitHubQuery = Workflow_UsageQuery | WorkflowsQuery | DeploymentsQuery | - BranchesQuery + BranchesQuery | + Webhook_DeliveriesQuery export type GitHubVariableQuery = { key?: string; field?: string; } & GitHubQuery diff --git a/src/validation.ts b/src/validation.ts index 3741f8c9..8e9cd87c 100644 --- a/src/validation.ts +++ b/src/validation.ts @@ -24,6 +24,11 @@ export const isValid = (query: GitHubQuery): boolean => { return false; } } + if (query.queryType === "Webhook_Deliveries") { + if (isEmpty(query.owner) || isEmpty(query.options?.hookId)) { + return false; + } + } if (query.queryType === "Projects") { if (isEmpty(query.options?.user) && query.options?.kind === ProjectQueryType.USER) { return false; diff --git a/src/views/QueryEditor.tsx b/src/views/QueryEditor.tsx index 99ccf05c..86a26306 100644 --- a/src/views/QueryEditor.tsx +++ b/src/views/QueryEditor.tsx @@ -25,6 +25,7 @@ import { QueryEditorWorkflowRuns } from './QueryEditorWorkflowRuns'; import { QueryEditorCodeScanning } from './QueryEditorCodeScanning'; import { QueryEditorDeployments } from './QueryEditorDeployments'; import { QueryEditorBranches } from './QueryEditorBranches'; +import { QueryEditorWebhookDeliveries } from './QueryEditorWebhookDeliveries'; import { DefaultQueryType, QueryTypes } from '../constants'; @@ -131,6 +132,11 @@ const queryEditors: Record ), }, + ['Webhook_Deliveries']: { + component: (props: Props, onChange: (val: any) => void) => ( + + ), + }, }; const queryTypeOptions: Array> = QueryTypes.map((v) => { diff --git a/src/views/QueryEditorWebhookDeliveries.test.tsx b/src/views/QueryEditorWebhookDeliveries.test.tsx new file mode 100644 index 00000000..67a7b4f0 --- /dev/null +++ b/src/views/QueryEditorWebhookDeliveries.test.tsx @@ -0,0 +1,38 @@ +import React from 'react'; +import { QueryEditorWebhookDeliveries } from './QueryEditorWebhookDeliveries'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { components } from '../components/selectors'; +import { WebhookDeliveryStatus } from '../constants'; + +describe('QueryEditorWebhookDeliveries', () => { + it('renders the hook id, event and status fields', () => { + render(); + expect(screen.getByText('Hook ID')).toBeInTheDocument(); + expect(screen.getByText('Event')).toBeInTheDocument(); + expect(screen.getByText('Status')).toBeInTheDocument(); + }); + + it('shows the existing options', () => { + render( + + ); + expect(screen.getByLabelText(components.QueryEditor.WebhookDeliveries.hookIdInput)).toHaveValue('12345'); + expect(screen.getByLabelText(components.QueryEditor.WebhookDeliveries.eventInput)).toHaveValue('pull_request'); + }); + + it('reports the hook id when the field loses focus', () => { + const onChange = jest.fn(); + render(); + + const input = screen.getByLabelText(components.QueryEditor.WebhookDeliveries.hookIdInput); + fireEvent.change(input, { target: { value: '12345' } }); + fireEvent.blur(input); + + expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ hookId: '12345' })); + }); +}); diff --git a/src/views/QueryEditorWebhookDeliveries.tsx b/src/views/QueryEditorWebhookDeliveries.tsx new file mode 100644 index 00000000..6818d225 --- /dev/null +++ b/src/views/QueryEditorWebhookDeliveries.tsx @@ -0,0 +1,62 @@ +import React, { useState } from 'react'; +import { Input, Combobox, ComboboxOption } from '@grafana/ui'; +import { EditorField, EditorRow } from '@grafana/plugin-ui'; +import { RightColumnWidth } from './QueryEditor'; +import { components } from '../components/selectors'; +import { WebhookDeliveryStatus } from '../constants'; +import type { WebhookDeliveriesOptions } from '../types/query'; + +interface Props extends WebhookDeliveriesOptions { + onChange: (value: WebhookDeliveriesOptions) => void; +} + +const statusOptions: ComboboxOption[] = [ + { label: 'All', value: WebhookDeliveryStatus.All }, + { label: 'Success', value: WebhookDeliveryStatus.Success }, + { label: 'Failure', value: WebhookDeliveryStatus.Failure }, +]; + +export const QueryEditorWebhookDeliveries = (props: Props) => { + const [hookId, setHookId] = useState(props.hookId || ''); + const [event, setEvent] = useState(props.event || ''); + + return ( + + + setHookId(el.currentTarget.value)} + onBlur={(el) => props.onChange({ ...props, hookId: el.currentTarget.value })} + /> + + + setEvent(el.currentTarget.value)} + onBlur={(el) => props.onChange({ ...props, event: el.currentTarget.value })} + /> + + + props.onChange({ ...props, status: opt.value as WebhookDeliveryStatus })} + /> + + + ); +};