From e60bc87d44e31cfa797dba59a827576cdf6c156f Mon Sep 17 00:00:00 2001 From: AvivGuiser Date: Sun, 16 Aug 2026 08:56:54 +0300 Subject: [PATCH] feat: add Webhook Deliveries query type Lists deliveries of an organization or repository webhook, one row per delivery, so dashboards can track webhook success rates and failures. The deliveries endpoint has no time filter and is cursor paginated, so the query pages from the most recent delivery until it reaches the start of the time range, capped at 5000 deliveries. Results that do not cover the whole time range, either because of that cap or because of GitHub's 3-day retention window, carry a warning notice on the frame. Closes #488 Co-Authored-By: Claude Opus 5 (1M context) --- .changeset/wild-webhooks-report.md | 5 + docs/sources/query-editor.md | 53 +++ pkg/github/client/client.go | 18 + pkg/github/codescanning_test.go | 8 + pkg/github/commit_files_test.go | 8 + pkg/github/commits_test.go | 8 + pkg/github/datasource.go | 12 + pkg/github/deployments_test.go | 8 + pkg/github/query_handler.go | 1 + pkg/github/webhook_deliveries.go | 196 +++++++++ pkg/github/webhook_deliveries_handler.go | 24 ++ pkg/github/webhook_deliveries_test.go | 371 ++++++++++++++++++ pkg/models/client.go | 2 + pkg/models/query.go | 8 + pkg/models/webhook_deliveries.go | 33 ++ pkg/testutil/client.go | 10 + src/components/selectors.ts | 4 + src/constants.ts | 7 + src/types/query.ts | 14 +- src/validation.ts | 5 + src/views/QueryEditor.tsx | 6 + .../QueryEditorWebhookDeliveries.test.tsx | 38 ++ src/views/QueryEditorWebhookDeliveries.tsx | 62 +++ 23 files changed, 899 insertions(+), 2 deletions(-) create mode 100644 .changeset/wild-webhooks-report.md create mode 100644 pkg/github/webhook_deliveries.go create mode 100644 pkg/github/webhook_deliveries_handler.go create mode 100644 pkg/github/webhook_deliveries_test.go create mode 100644 pkg/models/webhook_deliveries.go create mode 100644 src/views/QueryEditorWebhookDeliveries.test.tsx create mode 100644 src/views/QueryEditorWebhookDeliveries.tsx 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 })} + /> + + + ); +};