diff --git a/.changeset/cozy-guests-bake.md b/.changeset/cozy-guests-bake.md new file mode 100644 index 00000000..13bd16b0 --- /dev/null +++ b/.changeset/cozy-guests-bake.md @@ -0,0 +1,5 @@ +--- +'grafana-github-datasource': patch +--- + +Add deployments query type diff --git a/docs/sources/query/_index.md b/docs/sources/query/_index.md index c0c9b9c5..290ab570 100644 --- a/docs/sources/query/_index.md +++ b/docs/sources/query/_index.md @@ -40,6 +40,7 @@ The data source supports the following query types, which you can select from th - [**Workflows**](#workflows): List GitHub Actions workflows defined in a repository. - [**Workflow usage**](#workflow-usage): Retrieve usage statistics for a workflow, such as run counts and durations. - [**Workflow runs**](#workflow-runs): List runs for a specific workflow, including status, conclusion, and timing information. +- [**Deployments**](#deployments): List deployments for a repository, including environment, ref, and task information. ### Commits @@ -665,4 +666,53 @@ Show all completed runs for the `Levitate` workflow in the `grafana/grafana` rep | conclusion | Final conclusion of the workflow run, can be: `success`, `failure`, `neutral`, `cancelled`, `skipped`, `timed_out`, or `action_required` | | event | Event that triggered the workflow run (e.g., `push`, `pull_request`) - see [Events that trigger workflows](https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows) | | workflow_id | Unique identifier for the workflow definition | -| run_number | The run number for this workflow run in the repository | \ No newline at end of file +| run_number | The run number for this workflow run in the repository | +### Deployments + +List deployments for a repository, including environment, ref, and task information. Deployments track deployment requests for specific refs (branches, tags, or SHAs) to different environments. + +#### Query options + +| Name | Description | Required | +| ---------- | ------------------------------------------------------------------------------- | -------- | +| Owner | GitHub user or organization that owns the repository | Yes | +| Repository | Name of the repository | Yes | +| SHA | Filter deployments by the SHA recorded at creation time | No | +| Ref | Filter by ref name (branch, tag, or SHA) | No | +| Task | Filter by task name (e.g., "deploy", "deploy:migrations") | No | +| Environment | Filter by environment name (e.g., "production", "staging", "qa") | No | + +##### Sample queries + +Show all deployments for the `grafana/grafana` repository: + +- Owner: `grafana` +- Repository: `grafana` + +Show all deployments to the production environment: + +- Owner: `grafana` +- Repository: `grafana` +- Environment: `production` + +Show all deployments for a specific branch: + +- Owner: `grafana` +- Repository: `grafana` +- Ref: `main` + +#### Response + +| Name | Description | +| ------------ | ------------------------------------------------------------------------ | +| id | Unique identifier for the deployment | +| sha | SHA of the commit that was deployed | +| ref | Ref (branch, tag, or SHA) that was deployed | +| task | Task name (e.g., "deploy", "deploy:migrations") | +| environment | Environment name (e.g., "production", "staging") | +| description | Description of the deployment | +| creator | GitHub handle of the user who created the deployment | +| created_at | When the deployment was created: YYYY-MM-DD HH:MM:SS | +| updated_at | When the deployment was last updated: YYYY-MM-DD HH:MM:SS | +| url | API URL for the deployment | +| statuses_url | API URL for the deployment statuses | diff --git a/pkg/github/client/client.go b/pkg/github/client/client.go index bdbcff00..7f50e63f 100644 --- a/pkg/github/client/client.go +++ b/pkg/github/client/client.go @@ -177,6 +177,15 @@ func (client *Client) ListAlertsForOrg(ctx context.Context, owner string, opts * return alerts, resp, err } +// ListDeployments sends a request to the GitHub rest API to list the deployments in a specific repository. +func (client *Client) ListDeployments(ctx context.Context, owner, repo string, opts *googlegithub.DeploymentsListOptions) ([]*googlegithub.Deployment, *googlegithub.Response, error) { + deployments, resp, err := client.restClient.Repositories.ListDeployments(ctx, owner, repo, opts) + if err != nil { + return nil, nil, addErrorSourceToError(err, resp) + } + return deployments, resp, err +} + // 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 50f19f5e..0c6abbdc 100644 --- a/pkg/github/codescanning_test.go +++ b/pkg/github/codescanning_test.go @@ -52,6 +52,10 @@ func (m *mockClient) ListAlertsForOrg(ctx context.Context, owner string, opts *g return m.mockAlerts, m.mockResponse, nil } +func (m *mockClient) ListDeployments(ctx context.Context, owner, repo string, opts *googlegithub.DeploymentsListOptions) ([]*googlegithub.Deployment, *googlegithub.Response, error) { + return nil, nil, nil +} + func TestGetCodeScanningAlerts(t *testing.T) { var ( ctx = context.Background() diff --git a/pkg/github/datasource.go b/pkg/github/datasource.go index bd8a4196..95171985 100644 --- a/pkg/github/datasource.go +++ b/pkg/github/datasource.go @@ -212,6 +212,23 @@ func (d *Datasource) HandleWorkflowRunsQuery(ctx context.Context, query *models. return GetWorkflowRuns(ctx, d.client, opt, req.TimeRange) } +// HandleDeploymentsQuery is the query handler for listing GitHub Deployments +func (d *Datasource) HandleDeploymentsQuery(ctx context.Context, query *models.DeploymentsQuery, req backend.DataQuery) (dfutil.Framer, error) { + opt := models.ListDeploymentsOptions{ + Repository: query.Repository, + Owner: query.Owner, + SHA: query.Options.SHA, + Ref: query.Options.Ref, + Task: query.Options.Task, + Environment: query.Options.Environment, + } + + if req.TimeRange.From.Unix() <= 0 && req.TimeRange.To.Unix() <= 0 { + return GetAllDeployments(ctx, d.client, opt) + } + return GetDeploymentsInRange(ctx, d.client, opt, req.TimeRange.From, req.TimeRange.To) +} + // CheckHealth is the health check for GitHub func (d *Datasource) CheckHealth(ctx context.Context, req *backend.CheckHealthRequest) (*backend.CheckHealthResult, error) { _, err := GetAllRepositories(ctx, d.client, models.ListRepositoriesOptions{ diff --git a/pkg/github/deployments.go b/pkg/github/deployments.go new file mode 100644 index 00000000..b6367521 --- /dev/null +++ b/pkg/github/deployments.go @@ -0,0 +1,124 @@ +package github + +import ( + "context" + "fmt" + "time" + + googlegithub "github.com/google/go-github/v81/github" + "github.com/grafana/grafana-plugin-sdk-go/data" + + "github.com/grafana/github-datasource/pkg/models" +) + +// DeploymentsWrapper is a list of GitHub deployments +type DeploymentsWrapper []*googlegithub.Deployment + +// Frames converts the list of deployments to a Grafana DataFrame +func (deployments DeploymentsWrapper) Frames() data.Frames { + frame := data.NewFrame( + "deployments", + data.NewField("id", nil, []*int64{}), + data.NewField("sha", nil, []*string{}), + data.NewField("ref", nil, []*string{}), + data.NewField("task", nil, []*string{}), + data.NewField("environment", nil, []*string{}), + data.NewField("description", nil, []*string{}), + data.NewField("creator", nil, []*string{}), + data.NewField("created_at", nil, []*time.Time{}), + data.NewField("updated_at", nil, []*time.Time{}), + data.NewField("url", nil, []*string{}), + data.NewField("statuses_url", nil, []*string{}), + ) + + for _, deployment := range deployments { + var creator *string + if deployment.Creator != nil { + creatorLogin := deployment.Creator.GetLogin() + if creatorLogin != "" { + creator = &creatorLogin + } + } + + frame.AppendRow( + deployment.ID, + deployment.SHA, + deployment.Ref, + deployment.Task, + deployment.Environment, + deployment.Description, + creator, + deployment.CreatedAt.GetTime(), + deployment.UpdatedAt.GetTime(), + deployment.URL, + deployment.StatusesURL, + ) + } + + frame.Meta = &data.FrameMeta{PreferredVisualization: data.VisTypeTable} + return data.Frames{frame} +} + +// GetAllDeployments retrieves every deployment from a repository +func GetAllDeployments(ctx context.Context, client models.Client, opts models.ListDeploymentsOptions) (DeploymentsWrapper, error) { + if opts.Owner == "" || opts.Repository == "" { + return nil, nil + } + + deployments := []*googlegithub.Deployment{} + + // Build the list options with filters + listOpts := &googlegithub.DeploymentsListOptions{ + ListOptions: googlegithub.ListOptions{PerPage: 100}, + } + + if opts.SHA != "" { + listOpts.SHA = opts.SHA + } + if opts.Ref != "" { + listOpts.Ref = opts.Ref + } + if opts.Task != "" { + listOpts.Task = opts.Task + } + if opts.Environment != "" { + listOpts.Environment = opts.Environment + } + + page := 1 + for page != 0 { + listOpts.Page = page + deploymentsPage, resp, err := client.ListDeployments(ctx, opts.Owner, opts.Repository, listOpts) + if err != nil { + return nil, fmt.Errorf("listing deployments: opts=%+v %w", opts, err) + } + + deployments = append(deployments, deploymentsPage...) + + if resp == nil || resp.NextPage == 0 { + break + } + page = resp.NextPage + } + + return DeploymentsWrapper(deployments), nil +} + +// GetDeploymentsInRange retrieves every deployment from the repository and then returns the ones that fall within the given time range. +func GetDeploymentsInRange(ctx context.Context, client models.Client, opts models.ListDeploymentsOptions, from time.Time, to time.Time) (DeploymentsWrapper, error) { + deployments, err := GetAllDeployments(ctx, client, opts) + if err != nil { + return nil, err + } + + filtered := []*googlegithub.Deployment{} + + for _, deployment := range deployments { + createdAt := deployment.CreatedAt.GetTime() + if createdAt != nil && !createdAt.Before(from) && !createdAt.After(to) { + filtered = append(filtered, deployment) + } + } + + return DeploymentsWrapper(filtered), nil +} diff --git a/pkg/github/deployments_handler.go b/pkg/github/deployments_handler.go new file mode 100644 index 00000000..f521f677 --- /dev/null +++ b/pkg/github/deployments_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) handleDeploymentsQuery(ctx context.Context, q backend.DataQuery) backend.DataResponse { + query := &models.DeploymentsQuery{} + if err := UnmarshalQuery(q.JSON, query); err != nil { + return *err + } + return dfutil.FrameResponseWithError(s.Datasource.HandleDeploymentsQuery(ctx, query, q)) +} + +// HandleDeployments handles the plugin query for github Deployments +func (s *QueryHandler) HandleDeployments(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) { + return &backend.QueryDataResponse{ + Responses: processQueries(ctx, req, s.handleDeploymentsQuery), + }, nil +} diff --git a/pkg/github/deployments_test.go b/pkg/github/deployments_test.go new file mode 100644 index 00000000..721089b8 --- /dev/null +++ b/pkg/github/deployments_test.go @@ -0,0 +1,329 @@ +package github + +import ( + "context" + "testing" + "time" + + googlegithub "github.com/google/go-github/v81/github" + "github.com/grafana/github-datasource/pkg/models" + "github.com/grafana/grafana-plugin-sdk-go/backend" +) + +type mockDeploymentsClient struct { + mockDeployments []*googlegithub.Deployment + mockResponse *googlegithub.Response + expectedOwner string + expectedRepo string + t *testing.T +} + +func (m *mockDeploymentsClient) Query(ctx context.Context, q interface{}, variables map[string]interface{}) error { + return nil +} + +func (m *mockDeploymentsClient) ListWorkflows(ctx context.Context, owner, repo string, opts *googlegithub.ListOptions) (*googlegithub.Workflows, *googlegithub.Response, error) { + return nil, nil, nil +} + +func (m *mockDeploymentsClient) GetWorkflowUsage(ctx context.Context, owner, repo, workflow string, timeRange backend.TimeRange) (models.WorkflowUsage, error) { + return models.WorkflowUsage{}, nil +} + +func (m *mockDeploymentsClient) GetWorkflowRuns(ctx context.Context, owner, repo, workflow string, branch string, timeRange backend.TimeRange) ([]*googlegithub.WorkflowRun, error) { + return nil, nil +} + +func (m *mockDeploymentsClient) ListAlertsForRepo(ctx context.Context, owner, repo string, opts *googlegithub.AlertListOptions) ([]*googlegithub.Alert, *googlegithub.Response, error) { + return nil, nil, nil +} + +func (m *mockDeploymentsClient) ListAlertsForOrg(ctx context.Context, owner string, opts *googlegithub.AlertListOptions) ([]*googlegithub.Alert, *googlegithub.Response, error) { + return nil, nil, nil +} + +func (m *mockDeploymentsClient) ListDeployments(ctx context.Context, owner, repo string, opts *googlegithub.DeploymentsListOptions) ([]*googlegithub.Deployment, *googlegithub.Response, error) { + if owner != m.expectedOwner || repo != m.expectedRepo { + m.t.Errorf("Expected owner/repo to be %s/%s, got %s/%s", m.expectedOwner, m.expectedRepo, owner, repo) + } + + return m.mockDeployments, m.mockResponse, nil +} + +func TestGetAllDeployments(t *testing.T) { + var ( + ctx = context.Background() + opts = models.ListDeploymentsOptions{ + Repository: "grafana", + Owner: "grafana", + } + ) + + // Mock response data + mockDeployments := []*googlegithub.Deployment{ + { + ID: googlegithub.Ptr(int64(1)), + SHA: googlegithub.Ptr("abc123"), + Ref: googlegithub.Ptr("main"), + Task: googlegithub.Ptr("deploy"), + Environment: googlegithub.Ptr("production"), + Description: googlegithub.Ptr("Test deployment"), + }, + } + mockResponse := &googlegithub.Response{ + NextPage: 0, + } + + client := &mockDeploymentsClient{ + mockDeployments: mockDeployments, + mockResponse: mockResponse, + expectedOwner: "grafana", + expectedRepo: "grafana", + t: t, + } + + // Call the function + deployments, err := GetAllDeployments(ctx, client, opts) + if err != nil { + t.Fatal(err) + } + + // Verify result + if len(deployments) != len(mockDeployments) { + t.Errorf("Expected %d deployments, got %d", len(mockDeployments), len(deployments)) + } +} + +func TestGetAllDeploymentsWithFilters(t *testing.T) { + var ( + ctx = context.Background() + opts = models.ListDeploymentsOptions{ + Repository: "grafana", + Owner: "grafana", + SHA: "abc123", + Ref: "main", + Task: "deploy", + Environment: "production", + } + ) + + // Mock response data + mockDeployments := []*googlegithub.Deployment{ + { + ID: googlegithub.Ptr(int64(1)), + SHA: googlegithub.Ptr("abc123"), + Ref: googlegithub.Ptr("main"), + Task: googlegithub.Ptr("deploy"), + Environment: googlegithub.Ptr("production"), + }, + } + mockResponse := &googlegithub.Response{ + NextPage: 0, + } + + client := &mockDeploymentsClient{ + mockDeployments: mockDeployments, + mockResponse: mockResponse, + expectedOwner: "grafana", + expectedRepo: "grafana", + t: t, + } + + // Call the function + deployments, err := GetAllDeployments(ctx, client, opts) + if err != nil { + t.Fatal(err) + } + + // Verify result + if len(deployments) != len(mockDeployments) { + t.Errorf("Expected %d deployments, got %d", len(mockDeployments), len(deployments)) + } +} + +func TestGetAllDeploymentsEmptyOwnerOrRepo(t *testing.T) { + var ( + ctx = context.Background() + opts = models.ListDeploymentsOptions{ + Repository: "", + Owner: "", + } + ) + + client := &mockDeploymentsClient{ + mockDeployments: []*googlegithub.Deployment{}, + mockResponse: &googlegithub.Response{}, + expectedOwner: "", + expectedRepo: "", + t: t, + } + + // Call the function + deployments, err := GetAllDeployments(ctx, client, opts) + if err != nil { + t.Fatal(err) + } + + // Should return nil when owner or repo is empty + if deployments != nil { + t.Errorf("Expected nil when owner or repo is empty, got %v", deployments) + } +} + +func TestGetDeploymentsInRange(t *testing.T) { + var ( + ctx = context.Background() + opts = models.ListDeploymentsOptions{ + Repository: "grafana", + Owner: "grafana", + } + from = time.Now().Add(-30 * 24 * time.Hour) + to = time.Now() + ) + + // Create deployments with different timestamps + now := time.Now() + createdAt1 := &googlegithub.Timestamp{Time: now.Add(-10 * 24 * time.Hour)} // Within range + createdAt2 := &googlegithub.Timestamp{Time: now.Add(-40 * 24 * time.Hour)} // Outside range (too old) + createdAt3 := &googlegithub.Timestamp{Time: now.Add(1 * 24 * time.Hour)} // Outside range (future) + + mockDeployments := []*googlegithub.Deployment{ + { + ID: googlegithub.Ptr(int64(1)), + CreatedAt: createdAt1, + }, + { + ID: googlegithub.Ptr(int64(2)), + CreatedAt: createdAt2, + }, + { + ID: googlegithub.Ptr(int64(3)), + CreatedAt: createdAt3, + }, + } + mockResponse := &googlegithub.Response{ + NextPage: 0, + } + + client := &mockDeploymentsClient{ + mockDeployments: mockDeployments, + mockResponse: mockResponse, + expectedOwner: "grafana", + expectedRepo: "grafana", + t: t, + } + + // Call the function + deployments, err := GetDeploymentsInRange(ctx, client, opts, from, to) + if err != nil { + t.Fatal(err) + } + + // Should only return deployment 1 (within range) + if len(deployments) != 1 { + t.Errorf("Expected 1 deployment in range, got %d", len(deployments)) + } + + if deployments[0].GetID() != 1 { + t.Errorf("Expected deployment ID 1, got %d", deployments[0].GetID()) + } +} + +func TestDeploymentsWrapperFrames(t *testing.T) { + // Create test data + createdAt := &googlegithub.Timestamp{Time: time.Now().Add(-48 * time.Hour)} + updatedAt := &googlegithub.Timestamp{Time: time.Now().Add(-24 * time.Hour)} + creatorLogin := "username" + + deployments := DeploymentsWrapper{ + &googlegithub.Deployment{ + ID: googlegithub.Ptr(int64(1)), + SHA: googlegithub.Ptr("abc123def456"), + Ref: googlegithub.Ptr("main"), + Task: googlegithub.Ptr("deploy"), + Environment: googlegithub.Ptr("production"), + Description: googlegithub.Ptr("Test deployment"), + Creator: &googlegithub.User{ + Login: googlegithub.Ptr(creatorLogin), + }, + CreatedAt: createdAt, + UpdatedAt: updatedAt, + URL: googlegithub.Ptr("https://api.github.com/repos/grafana/grafana/deployments/1"), + StatusesURL: googlegithub.Ptr("https://api.github.com/repos/grafana/grafana/deployments/1/statuses"), + }, + &googlegithub.Deployment{ + ID: googlegithub.Ptr(int64(2)), + SHA: googlegithub.Ptr("def456ghi789"), + Ref: googlegithub.Ptr("develop"), + Task: googlegithub.Ptr("deploy:migrations"), + Environment: googlegithub.Ptr("staging"), + Description: googlegithub.Ptr("Another deployment"), + Creator: nil, // Test nil creator + CreatedAt: createdAt, + UpdatedAt: updatedAt, + URL: googlegithub.Ptr("https://api.github.com/repos/grafana/grafana/deployments/2"), + StatusesURL: googlegithub.Ptr("https://api.github.com/repos/grafana/grafana/deployments/2/statuses"), + }, + } + + // Get data frames + frames := deployments.Frames() + + // Verify frames + if len(frames) != 1 { + t.Fatalf("Expected 1 frame, got %d", len(frames)) + } + + frame := frames[0] + if frame.Name != "deployments" { + t.Errorf("Expected frame name to be 'deployments', got '%s'", frame.Name) + } + + // Check number of rows + if frame.Rows() != 2 { + t.Errorf("Expected 2 rows, got %d", frame.Rows()) + } + + // Check fields + expectedFields := 11 + if len(frame.Fields) != expectedFields { + t.Errorf("Expected %d fields, got %d", expectedFields, len(frame.Fields)) + } + + // Verify field names + expectedFieldNames := []string{"id", "sha", "ref", "task", "environment", "description", "creator", "created_at", "updated_at", "url", "statuses_url"} + for i, expectedName := range expectedFieldNames { + if i >= len(frame.Fields) { + t.Fatalf("Field index %d out of range", i) + } + if frame.Fields[i].Name != expectedName { + t.Errorf("Expected field name '%s' at index %d, got '%s'", expectedName, i, frame.Fields[i].Name) + } + } + + // Verify first deployment data - ID field is *int64 + idField := frame.Fields[0] + idValue := idField.At(0).(*int64) + if *idValue != int64(1) { + t.Errorf("Expected first deployment ID to be 1, got %d", *idValue) + } + + // Verify creator field - first deployment has creator, second doesn't + creatorField := frame.Fields[6] // creator is at index 6 + creatorValue0 := creatorField.At(0) + if creatorValue0 == nil { + t.Error("Expected first deployment to have a creator") + } else { + creatorStr := creatorValue0.(*string) + if *creatorStr != creatorLogin { + t.Errorf("Expected creator to be '%s', got '%s'", creatorLogin, *creatorStr) + } + } + creatorValue1 := creatorField.At(1) + if creatorValue1 != nil { + // The frame might store a nil pointer differently, so let's check if it's actually nil or an empty string pointer + if strPtr, ok := creatorValue1.(*string); ok && strPtr != nil { + t.Errorf("Expected second deployment to have nil creator, got '%s'", *strPtr) + } + } +} diff --git a/pkg/github/query_handler.go b/pkg/github/query_handler.go index 0cf972c5..d520aca9 100644 --- a/pkg/github/query_handler.go +++ b/pkg/github/query_handler.go @@ -62,6 +62,7 @@ func GetQueryHandlers(s *QueryHandler) *datasource.QueryTypeMux { mux.HandleFunc(models.QueryTypeWorkflowUsage, s.HandleWorkflowUsage) mux.HandleFunc(models.QueryTypeWorkflowRuns, s.HandleWorkflowRuns) mux.HandleFunc(models.QueryTypeCodeScanning, s.HandleCodeScanning) + mux.HandleFunc(models.QueryTypeDeployments, s.HandleDeployments) return mux } diff --git a/pkg/models/client.go b/pkg/models/client.go index 5c27005d..e4cef4ab 100644 --- a/pkg/models/client.go +++ b/pkg/models/client.go @@ -16,4 +16,5 @@ type Client interface { GetWorkflowRuns(ctx context.Context, owner, repo, workflow string, branch string, timeRange backend.TimeRange) ([]*googlegithub.WorkflowRun, error) ListAlertsForRepo(ctx context.Context, owner, repo string, opts *googlegithub.AlertListOptions) ([]*googlegithub.Alert, *googlegithub.Response, error) ListAlertsForOrg(ctx context.Context, owner string, opts *googlegithub.AlertListOptions) ([]*googlegithub.Alert, *googlegithub.Response, error) + ListDeployments(ctx context.Context, owner, repo string, opts *googlegithub.DeploymentsListOptions) ([]*googlegithub.Deployment, *googlegithub.Response, error) } diff --git a/pkg/models/deployments.go b/pkg/models/deployments.go new file mode 100644 index 00000000..72e0b4f9 --- /dev/null +++ b/pkg/models/deployments.go @@ -0,0 +1,22 @@ +package models + +// ListDeploymentsOptions are the available options when listing deployments +type ListDeploymentsOptions struct { + // Repository is the name of the repository being queried (ex: grafana) + Repository string `json:"repository"` + + // Owner is the owner of the repository (ex: grafana) + Owner string `json:"owner"` + + // SHA is the SHA recorded at creation time to filter by + SHA string `json:"sha,omitempty"` + + // Ref is the name of the ref (branch, tag, or SHA) to filter by + Ref string `json:"ref,omitempty"` + + // Task is the name of the task (e.g., "deploy", "deploy:migrations") to filter by + Task string `json:"task,omitempty"` + + // Environment is the name of the environment (e.g., "production", "staging") to filter by + Environment string `json:"environment,omitempty"` +} diff --git a/pkg/models/query.go b/pkg/models/query.go index 88292805..04496845 100644 --- a/pkg/models/query.go +++ b/pkg/models/query.go @@ -43,6 +43,8 @@ const ( QueryTypeWorkflowRuns = "Workflow_Runs" // QueryTypeCodeScanning is used when querying code scanning alerts for a repository QueryTypeCodeScanning = "Code_Scanning" + // QueryTypeDeployments is used when querying deployments for a repository + QueryTypeDeployments = "Deployments" ) // Query refers to the structure of a query built using the QueryEditor. @@ -153,3 +155,9 @@ type CodeScanningQuery struct { Query Options CodeScanningOptions `json:"options"` } + +// DeploymentsQuery is used when querying deployments for a repository +type DeploymentsQuery struct { + Query + Options ListDeploymentsOptions `json:"options"` +} diff --git a/pkg/plugin/datasource.go b/pkg/plugin/datasource.go index ee7ce469..72173934 100644 --- a/pkg/plugin/datasource.go +++ b/pkg/plugin/datasource.go @@ -28,6 +28,7 @@ type Datasource interface { HandleWorkflowsQuery(context.Context, *models.WorkflowsQuery, backend.DataQuery) (dfutil.Framer, error) HandleWorkflowUsageQuery(context.Context, *models.WorkflowUsageQuery, backend.DataQuery) (dfutil.Framer, error) HandleWorkflowRunsQuery(context.Context, *models.WorkflowRunsQuery, backend.DataQuery) (dfutil.Framer, error) + HandleDeploymentsQuery(context.Context, *models.DeploymentsQuery, backend.DataQuery) (dfutil.Framer, error) CheckHealth(ctx context.Context, req *backend.CheckHealthRequest) (*backend.CheckHealthResult, error) QueryData(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) } diff --git a/pkg/plugin/datasource_caching.go b/pkg/plugin/datasource_caching.go index 1c19970f..a78f5d30 100644 --- a/pkg/plugin/datasource_caching.go +++ b/pkg/plugin/datasource_caching.go @@ -262,6 +262,16 @@ func (c *CachedDatasource) HandleWorkflowRunsQuery(ctx context.Context, q *model return c.saveCache(req, f, err) } +// HandleDeploymentsQuery is the cache wrapper for the deployments query handler +func (c *CachedDatasource) HandleDeploymentsQuery(ctx context.Context, q *models.DeploymentsQuery, req backend.DataQuery) (dfutil.Framer, error) { + if value, err := c.getCache(req); err == nil { + return value, err + } + + f, err := c.datasource.HandleDeploymentsQuery(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 1a4cf2da..0bf03cd4 100644 --- a/pkg/testutil/client.go +++ b/pkg/testutil/client.go @@ -76,3 +76,8 @@ func (c *TestClient) ListAlertsForRepo(ctx context.Context, owner, repo string, func (c *TestClient) ListAlertsForOrg(ctx context.Context, owner string, opts *googlegithub.AlertListOptions) ([]*googlegithub.Alert, *googlegithub.Response, error) { panic("unimplemented") } + +// ListDeployments is not implemented because it is not being used in tests at the moment. +func (c *TestClient) ListDeployments(ctx context.Context, owner, repo string, opts *googlegithub.DeploymentsListOptions) ([]*googlegithub.Deployment, *googlegithub.Response, error) { + panic("unimplemented") +} diff --git a/src/constants.ts b/src/constants.ts index 4f86d8ef..c7fbeb96 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -20,6 +20,7 @@ export enum QueryType { Workflows = 'Workflows', Workflow_Usage = 'Workflow_Usage', Workflow_Runs = 'Workflow_Runs', + Deployments = 'Deployments', } export const DefaultQueryType = QueryType.Issues; diff --git a/src/types/query.ts b/src/types/query.ts index 1694020f..7faf2805 100644 --- a/src/types/query.ts +++ b/src/types/query.ts @@ -20,7 +20,8 @@ export interface GitHubQuery extends Indexable, DataQuery, RepositoryOptions { | ProjectsOptions | WorkflowsOptions | WorkflowUsageOptions - | WorkflowRunsOptions; + | WorkflowRunsOptions + | DeploymentsOptions; } export interface Label { @@ -83,6 +84,13 @@ export interface WorkflowRunsOptions extends Indexable { branch?: string; } +export interface DeploymentsOptions extends Indexable { + sha?: string; + ref?: string; + task?: string; + environment?: string; +} + export interface PackagesOptions extends Indexable { names?: string; packageType?: PackageType; diff --git a/src/views/QueryEditor.tsx b/src/views/QueryEditor.tsx index f94f60cd..2bc064d1 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 QueryEditorDeployments from './QueryEditorDeployments'; import { QueryType, DefaultQueryType } from '../constants'; import type { GitHubQuery } from '../types/query'; import type { GitHubDataSourceOptions } from '../types/config'; @@ -117,6 +118,11 @@ const queryEditors: { ), }, + [QueryType.Deployments]: { + component: (props: Props, onChange: (val: any) => void) => ( + + ), + }, }; /* eslint-enable react/display-name */ diff --git a/src/views/QueryEditorDeployments.tsx b/src/views/QueryEditorDeployments.tsx new file mode 100644 index 00000000..db6f2af6 --- /dev/null +++ b/src/views/QueryEditorDeployments.tsx @@ -0,0 +1,92 @@ +import React, { useState } from 'react'; +import { Input, InlineField } from '@grafana/ui'; +import { RightColumnWidth, LeftColumnWidth } from './QueryEditor'; +import type { DeploymentsOptions } from 'types/query'; + +interface Props extends DeploymentsOptions { + onChange: (value: DeploymentsOptions) => void; +} + +const QueryEditorDeployments = (props: Props) => { + const { sha: initialSha, ref: initialRef, task: initialTask, environment: initialEnvironment } = props; + const [sha, setSha] = useState(initialSha); + // eslint-disable-next-line react-hooks/refs -- 'ref' is a prop name from DeploymentsOptions, not a React ref + const [gitRef, setGitRef] = useState(initialRef); + const [task, setTask] = useState(initialTask); + const [environment, setEnvironment] = useState(initialEnvironment); + + return ( + <> + + setSha(el.currentTarget.value)} + onBlur={(el) => + props.onChange({ + ...props, + sha: el.currentTarget.value || undefined, + }) + } + /> + + + setGitRef(el.currentTarget.value)} + onBlur={(el) => + props.onChange({ + ...props, + ref: el.currentTarget.value || undefined, + }) + } + /> + + + setTask(el.currentTarget.value)} + onBlur={(el) => + props.onChange({ + ...props, + task: el.currentTarget.value || undefined, + }) + } + /> + + + setEnvironment(el.currentTarget.value)} + onBlur={(el) => + props.onChange({ + ...props, + environment: el.currentTarget.value || undefined, + }) + } + /> + + + ); +}; + +export default QueryEditorDeployments;