diff --git a/pkg/github/client/client.go b/pkg/github/client/client.go index ae0444f0..a44cff0c 100644 --- a/pkg/github/client/client.go +++ b/pkg/github/client/client.go @@ -177,7 +177,7 @@ func (client *Client) GetWorkflowUsage(ctx context.Context, owner, repo, workflo } var workflowRuns []*googlegithub.WorkflowRun var err error - workflowRuns, page, err = client.getWorkflowRuns(ctx, owner, repo, workflow, timeRange, page) + workflowRuns, page, err = client.getWorkflowRuns(ctx, owner, repo, workflow, "", timeRange, page) if err != nil { return models.WorkflowUsage{}, fmt.Errorf("fetching workflow runs: %w", err) } @@ -280,7 +280,29 @@ func (client *Client) getWorkflowUsage(ctx context.Context, owner, repo string, return client.restClient.Actions.GetWorkflowUsageByFileName(ctx, owner, repo, workflow) } -func (client *Client) getWorkflowRuns(ctx context.Context, owner, repo, workflow string, timeRange backend.TimeRange, page int) ([]*googlegithub.WorkflowRun, int, error) { +func (client *Client) GetWorkflowRuns(ctx context.Context, owner, repo, workflow string, branch string, timeRange backend.TimeRange) ([]*googlegithub.WorkflowRun, error) { + workflowRuns := []*googlegithub.WorkflowRun{} + + page := 1 + for { + if page == 0 { + break + } + + workflowRunsPage, nextPage, err := client.getWorkflowRuns(ctx, owner, repo, workflow, branch, timeRange, page) + if err != nil { + return nil, fmt.Errorf("fetching workflow runs: %w", err) + } + + workflowRuns = append(workflowRuns, workflowRunsPage...) + + page = nextPage + } + + return workflowRuns, nil +} + +func (client *Client) getWorkflowRuns(ctx context.Context, owner, repo, workflow string, branch string, timeRange backend.TimeRange, page int) ([]*googlegithub.WorkflowRun, int, error) { workflowID, _ := strconv.ParseInt(workflow, 10, 64) workflowRuns := []*googlegithub.WorkflowRun{} @@ -298,11 +320,13 @@ func (client *Client) getWorkflowRuns(ctx context.Context, owner, repo, workflow runs, response, err = client.restClient.Actions.ListWorkflowRunsByID(ctx, owner, repo, workflowID, &googlegithub.ListWorkflowRunsOptions{ Created: created, ListOptions: googlegithub.ListOptions{Page: page, PerPage: 100}, + Branch: branch, }) } else { runs, response, err = client.restClient.Actions.ListWorkflowRunsByFileName(ctx, owner, repo, workflow, &googlegithub.ListWorkflowRunsOptions{ Created: created, ListOptions: googlegithub.ListOptions{Page: page, PerPage: 100}, + Branch: branch, }) } diff --git a/pkg/github/datasource.go b/pkg/github/datasource.go index cedf52e5..3da6452c 100644 --- a/pkg/github/datasource.go +++ b/pkg/github/datasource.go @@ -180,6 +180,18 @@ func (d *Datasource) HandleWorkflowUsageQuery(ctx context.Context, query *models return GetWorkflowUsage(ctx, d.client, opt, req.TimeRange) } +// HandleWorkflowRunsQuery is the query handler for listing workflow runs of a GitHub repository +func (d *Datasource) HandleWorkflowRunsQuery(ctx context.Context, query *models.WorkflowRunsQuery, req backend.DataQuery) (dfutil.Framer, error) { + opt := models.WorkflowRunsOptions{ + Repository: query.Repository, + Owner: query.Owner, + Workflow: query.Options.Workflow, + Branch: query.Options.Branch, + } + + return GetWorkflowRuns(ctx, d.client, opt, req.TimeRange) +} + // 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/query_handler.go b/pkg/github/query_handler.go index 333b207e..68b0a333 100644 --- a/pkg/github/query_handler.go +++ b/pkg/github/query_handler.go @@ -57,6 +57,7 @@ func GetQueryHandlers(s *QueryHandler) *datasource.QueryTypeMux { mux.HandleFunc(models.QueryTypeStargazers, s.HandleStargazers) mux.HandleFunc(models.QueryTypeWorkflows, s.HandleWorkflows) mux.HandleFunc(models.QueryTypeWorkflowUsage, s.HandleWorkflowUsage) + mux.HandleFunc(models.QueryTypeWorkflowRuns, s.HandleWorkflowRuns) return mux } diff --git a/pkg/github/testdata/workflowRuns.golden.jsonc b/pkg/github/testdata/workflowRuns.golden.jsonc new file mode 100644 index 00000000..ace5deab --- /dev/null +++ b/pkg/github/testdata/workflowRuns.golden.jsonc @@ -0,0 +1,200 @@ +// 🌟 This was machine generated. Do not edit. 🌟 +// +// Frame[0] { +// "typeVersion": [ +// 0, +// 0 +// ], +// "preferredVisualisationType": "table" +// } +// Name: workflow_run +// Dimensions: 13 Fields by 2 Rows +// +----------------+-----------------+-------------------+-----------------+-------------------------------+-------------------------------+-----------------+-----------------+-----------------+------------------+-----------------+-------------------+------------------+ +// | Name: id | Name: name | Name: head_branch | Name: head_sha | Name: created_at | Name: updated_at | Name: html_url | Name: url | Name: status | Name: conclusion | Name: event | Name: workflow_id | Name: run_number | +// | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | +// | Type: []*int64 | Type: []*string | Type: []*string | Type: []*string | Type: []*time.Time | Type: []*time.Time | Type: []*string | Type: []*string | Type: []*string | Type: []*string | Type: []*string | Type: []*int64 | Type: []int64 | +// +----------------+-----------------+-------------------+-----------------+-------------------------------+-------------------------------+-----------------+-----------------+-----------------+------------------+-----------------+-------------------+------------------+ +// | 2 | name_2 | head_branch_2 | head_sha_2 | 2013-02-03 00:00:00 +0000 UTC | 2013-02-04 00:00:00 +0000 UTC | html_url_2 | url_2 | status_2 | conclusion_2 | event_2 | 2 | 2 | +// | 1 | name_1 | head_branch_1 | head_sha_1 | 2013-02-01 00:00:00 +0000 UTC | 2013-02-02 00:00:00 +0000 UTC | html_url_1 | url_1 | status_1 | conclusion_1 | event_1 | 1 | 1 | +// +----------------+-----------------+-------------------+-----------------+-------------------------------+-------------------------------+-----------------+-----------------+-----------------+------------------+-----------------+-------------------+------------------+ +// +// +// 🌟 This was machine generated. Do not edit. 🌟 +{ + "status": 200, + "frames": [ + { + "schema": { + "name": "workflow_run", + "meta": { + "typeVersion": [ + 0, + 0 + ], + "preferredVisualisationType": "table" + }, + "fields": [ + { + "name": "id", + "type": "number", + "typeInfo": { + "frame": "int64", + "nullable": true + } + }, + { + "name": "name", + "type": "string", + "typeInfo": { + "frame": "string", + "nullable": true + } + }, + { + "name": "head_branch", + "type": "string", + "typeInfo": { + "frame": "string", + "nullable": true + } + }, + { + "name": "head_sha", + "type": "string", + "typeInfo": { + "frame": "string", + "nullable": true + } + }, + { + "name": "created_at", + "type": "time", + "typeInfo": { + "frame": "time.Time", + "nullable": true + } + }, + { + "name": "updated_at", + "type": "time", + "typeInfo": { + "frame": "time.Time", + "nullable": true + } + }, + { + "name": "html_url", + "type": "string", + "typeInfo": { + "frame": "string", + "nullable": true + } + }, + { + "name": "url", + "type": "string", + "typeInfo": { + "frame": "string", + "nullable": true + } + }, + { + "name": "status", + "type": "string", + "typeInfo": { + "frame": "string", + "nullable": true + } + }, + { + "name": "conclusion", + "type": "string", + "typeInfo": { + "frame": "string", + "nullable": true + } + }, + { + "name": "event", + "type": "string", + "typeInfo": { + "frame": "string", + "nullable": true + } + }, + { + "name": "workflow_id", + "type": "number", + "typeInfo": { + "frame": "int64", + "nullable": true + } + }, + { + "name": "run_number", + "type": "number", + "typeInfo": { + "frame": "int64" + } + } + ] + }, + "data": { + "values": [ + [ + 2, + 1 + ], + [ + "name_2", + "name_1" + ], + [ + "head_branch_2", + "head_branch_1" + ], + [ + "head_sha_2", + "head_sha_1" + ], + [ + 1359849600000, + 1359676800000 + ], + [ + 1359936000000, + 1359763200000 + ], + [ + "html_url_2", + "html_url_1" + ], + [ + "url_2", + "url_1" + ], + [ + "status_2", + "status_1" + ], + [ + "conclusion_2", + "conclusion_1" + ], + [ + "event_2", + "event_1" + ], + [ + 2, + 1 + ], + [ + 2, + 1 + ] + ] + } + } + ] +} \ No newline at end of file diff --git a/pkg/github/workflows.go b/pkg/github/workflows.go index 1c057517..ba29dd2e 100644 --- a/pkg/github/workflows.go +++ b/pkg/github/workflows.go @@ -185,3 +185,62 @@ func GetWorkflowUsage(ctx context.Context, client models.Client, opts models.Wor return WorkflowUsageWrapper(data), nil } + +// WorkflowRunsWrapper is a list of GitHub workflow runs +type WorkflowRunsWrapper []*googlegithub.WorkflowRun + +// Frames converts the list of workflow runs to a Grafana DataFrame +func (workflowRuns WorkflowRunsWrapper) Frames() data.Frames { + frame := data.NewFrame( + "workflow_run", + data.NewField("id", nil, []*int64{}), + data.NewField("name", nil, []*string{}), + data.NewField("head_branch", nil, []*string{}), + data.NewField("head_sha", nil, []*string{}), + data.NewField("created_at", nil, []*time.Time{}), + data.NewField("updated_at", nil, []*time.Time{}), + data.NewField("html_url", nil, []*string{}), + data.NewField("url", nil, []*string{}), + data.NewField("status", nil, []*string{}), + data.NewField("conclusion", nil, []*string{}), + data.NewField("event", nil, []*string{}), + data.NewField("workflow_id", nil, []*int64{}), + data.NewField("run_number", nil, []int64{}), + ) + + for _, workflowRun := range workflowRuns { + frame.InsertRow( + 0, + workflowRun.ID, + workflowRun.Name, + workflowRun.HeadBranch, + workflowRun.HeadSHA, + workflowRun.CreatedAt.GetTime(), + workflowRun.UpdatedAt.GetTime(), + workflowRun.HTMLURL, + workflowRun.URL, + workflowRun.Status, + workflowRun.Conclusion, + workflowRun.Event, + workflowRun.WorkflowID, + int64(*workflowRun.RunNumber), + ) + } + + frame.Meta = &data.FrameMeta{PreferredVisualization: data.VisTypeTable} + return data.Frames{frame} +} + +// GetWorkflowRuns gets all workflows runs for a GitHub repository and workflow +func GetWorkflowRuns(ctx context.Context, client models.Client, opts models.WorkflowRunsOptions, timeRange backend.TimeRange) (WorkflowRunsWrapper, error) { + if opts.Owner == "" || opts.Repository == "" { + return nil, nil + } + + workflowRuns, err := client.GetWorkflowRuns(ctx, opts.Owner, opts.Repository, opts.Workflow, opts.Branch, timeRange) + if err != nil { + return nil, fmt.Errorf("listing workflows: opts=%+v %w", opts, err) + } + + return WorkflowRunsWrapper(workflowRuns), nil +} diff --git a/pkg/github/workflows_handler.go b/pkg/github/workflows_handler.go index 5fdabc7b..8215ca85 100644 --- a/pkg/github/workflows_handler.go +++ b/pkg/github/workflows_handler.go @@ -39,3 +39,19 @@ func (s *QueryHandler) HandleWorkflowUsage(ctx context.Context, req *backend.Que Responses: processQueries(ctx, req, s.handleWorkflowUsageQuery), }, nil } + +func (s *QueryHandler) handleWorkflowRunsQuery(ctx context.Context, q backend.DataQuery) backend.DataResponse { + query := &models.WorkflowRunsQuery{} + if err := UnmarshalQuery(q.JSON, query); err != nil { + return *err + } + + return dfutil.FrameResponseWithError(s.Datasource.HandleWorkflowRunsQuery(ctx, query, q)) +} + +// HandleWorkflowRuns handles the plugin query for GitHub workflows +func (s *QueryHandler) HandleWorkflowRuns(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) { + return &backend.QueryDataResponse{ + Responses: processQueries(ctx, req, s.handleWorkflowRunsQuery), + }, nil +} diff --git a/pkg/github/workflows_test.go b/pkg/github/workflows_test.go index 47056f5a..ec370fde 100644 --- a/pkg/github/workflows_test.go +++ b/pkg/github/workflows_test.go @@ -244,3 +244,54 @@ func TestWorkflowUsageDataframe(t *testing.T) { testutil.CheckGoldenFramer(t, "workflowUsage", usage) } + +func TestWorkflowRunsDataFrame(t *testing.T) { + t.Parallel() + + createdAt1, err := time.Parse("2006-Jan-02", "2013-Feb-01") + assert.NoError(t, err) + + updatedAt1, err := time.Parse("2006-Jan-02", "2013-Feb-02") + assert.NoError(t, err) + + createdAt2, err := time.Parse("2006-Jan-02", "2013-Feb-03") + assert.NoError(t, err) + + updatedAt2, err := time.Parse("2006-Jan-02", "2013-Feb-04") + assert.NoError(t, err) + + workflowRuns := WorkflowRunsWrapper([]*googlegithub.WorkflowRun{ + { + ID: ptr(int64(1)), + Name: ptr("name_1"), + HeadBranch: ptr("head_branch_1"), + HeadSHA: ptr("head_sha_1"), + CreatedAt: &googlegithub.Timestamp{Time: createdAt1}, + UpdatedAt: &googlegithub.Timestamp{Time: updatedAt1}, + HTMLURL: ptr("html_url_1"), + URL: ptr("url_1"), + Status: ptr("status_1"), + Conclusion: ptr("conclusion_1"), + Event: ptr("event_1"), + WorkflowID: ptr(int64(1)), + RunNumber: ptr(int(1)), + }, + { + ID: ptr(int64(2)), + Name: ptr("name_2"), + HeadBranch: ptr("head_branch_2"), + HeadSHA: ptr("head_sha_2"), + CreatedAt: &googlegithub.Timestamp{Time: createdAt2}, + UpdatedAt: &googlegithub.Timestamp{Time: updatedAt2}, + HTMLURL: ptr("html_url_2"), + URL: ptr("url_2"), + Status: ptr("status_2"), + Conclusion: ptr("conclusion_2"), + Event: ptr("event_2"), + WorkflowID: ptr(int64(2)), + RunNumber: ptr(int(2)), + }, + }) + + testutil.CheckGoldenFramer(t, "workflowRuns", workflowRuns) +} diff --git a/pkg/models/client.go b/pkg/models/client.go index e1d7165e..92c12874 100644 --- a/pkg/models/client.go +++ b/pkg/models/client.go @@ -13,4 +13,5 @@ type Client interface { Query(ctx context.Context, q interface{}, variables map[string]interface{}) error ListWorkflows(ctx context.Context, owner, repo string, opts *googlegithub.ListOptions) (*googlegithub.Workflows, *googlegithub.Response, error) GetWorkflowUsage(ctx context.Context, owner, repo, workflow string, timeRange backend.TimeRange) (WorkflowUsage, error) + GetWorkflowRuns(ctx context.Context, owner, repo, workflow string, branch string, timeRange backend.TimeRange) ([]*googlegithub.WorkflowRun, error) } diff --git a/pkg/models/query.go b/pkg/models/query.go index 09332020..17e5aad7 100644 --- a/pkg/models/query.go +++ b/pkg/models/query.go @@ -37,6 +37,8 @@ const ( QueryTypeWorkflows = "Workflows" // QueryTypeWorkflowUsage is used when querying a specific workflow usage QueryTypeWorkflowUsage = "Workflow_Usage" + // QueryTypeWorkflowRuns is used when querying workflow runs for a repository + QueryTypeWorkflowRuns = "Workflow_Runs" ) // Query refers to the structure of a query built using the QueryEditor. @@ -129,3 +131,9 @@ type WorkflowUsageQuery struct { Query Options WorkflowUsageOptions `json:"options"` } + +// WorkflowRunsQuery is used when querying workflow runs for a repository +type WorkflowRunsQuery struct { + Query + Options WorkflowRunsOptions `json:"options"` +} diff --git a/pkg/models/workflows.go b/pkg/models/workflows.go index a9120f68..6afdaccf 100644 --- a/pkg/models/workflows.go +++ b/pkg/models/workflows.go @@ -34,8 +34,13 @@ type WorkflowUsageOptions struct { // Workflow is the id or the workflow file name. Workflow string `json:"workflow"` + + // Branch is the branch to filter the runs by. + Branch string `json:"branch"` } +type WorkflowRunsOptions = WorkflowUsageOptions + // WorkflowUsage contains a specific workflow usage information. type WorkflowUsage struct { CostUSD float64 diff --git a/pkg/plugin/datasource.go b/pkg/plugin/datasource.go index 9eee382a..abc5e93a 100644 --- a/pkg/plugin/datasource.go +++ b/pkg/plugin/datasource.go @@ -25,6 +25,7 @@ type Datasource interface { HandleStargazersQuery(context.Context, *models.StargazersQuery, backend.DataQuery) (dfutil.Framer, error) 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) 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 87800fa7..8956c232 100644 --- a/pkg/plugin/datasource_caching.go +++ b/pkg/plugin/datasource_caching.go @@ -232,6 +232,16 @@ func (c *CachedDatasource) HandleWorkflowUsageQuery(ctx context.Context, q *mode return c.saveCache(req, f, err) } +// HandleWorkflowRunsQuery is the cache wrapper for the workflows runs query handler +func (c *CachedDatasource) HandleWorkflowRunsQuery(ctx context.Context, q *models.WorkflowRunsQuery, req backend.DataQuery) (dfutil.Framer, error) { + if value, err := c.getCache(req); err == nil { + return value, err + } + + f, err := c.datasource.HandleWorkflowRunsQuery(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 4368ebcf..2ad9e892 100644 --- a/pkg/testutil/client.go +++ b/pkg/testutil/client.go @@ -60,3 +60,8 @@ func (c *TestClient) ListWorkflows(ctx context.Context, owner, repo string, opts func (c *TestClient) GetWorkflowUsage(ctx context.Context, owner, repo, workflow string, timeRange backend.TimeRange) (models.WorkflowUsage, error) { panic("unimplemented") } + +// GetWorkflowRuns is not implemented because it is not being used at the moment. +func (c *TestClient) GetWorkflowRuns(ctx context.Context, owner, repo, workflow string, branch string, timeRange backend.TimeRange) ([]*googlegithub.WorkflowRun, error) { + panic("unimplemented") +} diff --git a/src/constants.ts b/src/constants.ts index 95c99c52..7b1cc805 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -17,6 +17,7 @@ export enum QueryType { Stargazers = 'Stargazers', Workflows = 'Workflows', Workflow_Usage = 'Workflow_Usage', + Workflow_Runs = 'Workflow_Runs', } export const DefaultQueryType = QueryType.Issues; diff --git a/src/types/query.ts b/src/types/query.ts index f408b029..1a9d9c0d 100644 --- a/src/types/query.ts +++ b/src/types/query.ts @@ -18,7 +18,8 @@ export interface GitHubQuery extends Indexable, DataQuery, RepositoryOptions { | ContributorsOptions | ProjectsOptions | WorkflowsOptions - | WorkflowUsageOptions; + | WorkflowUsageOptions + | WorkflowRunsOptions; } export interface Label { @@ -66,6 +67,11 @@ export interface WorkflowUsageOptions extends Indexable { workflowID?: number; } +export interface WorkflowRunsOptions extends Indexable { + workflowID?: string; + branch?: string; +} + export interface PackagesOptions extends Indexable { names?: string; packageType?: PackageType; diff --git a/src/views/QueryEditor.tsx b/src/views/QueryEditor.tsx index 5a47a3dd..0ed6e7a8 100644 --- a/src/views/QueryEditor.tsx +++ b/src/views/QueryEditor.tsx @@ -20,6 +20,7 @@ import QueryEditorVulnerabilities from './QueryEditorVulnerabilities'; import QueryEditorProjects from './QueryEditorProjects'; import QueryEditorWorkflows from './QueryEditorWorkflows'; import QueryEditorWorkflowUsage from './QueryEditorWorkflowUsage'; +import QueryEditorWorkflowRuns from './QueryEditorWorkflowRuns'; import { QueryType, DefaultQueryType } from '../constants'; import type { GitHubQuery } from '../types/query'; import type { GitHubDataSourceOptions } from '../types/config'; @@ -101,6 +102,11 @@ const queryEditors: { ), }, + [QueryType.Workflow_Runs]: { + component: (props: Props, onChange: (val: any) => void) => ( + + ), + }, }; /* eslint-enable react/display-name */ diff --git a/src/views/QueryEditorWorkflowRuns.tsx b/src/views/QueryEditorWorkflowRuns.tsx new file mode 100644 index 00000000..55072671 --- /dev/null +++ b/src/views/QueryEditorWorkflowRuns.tsx @@ -0,0 +1,54 @@ +import React, { useState } from 'react'; +import { Input, InlineField } from '@grafana/ui'; +import { RightColumnWidth, LeftColumnWidth } from './QueryEditor'; +import type { WorkflowRunsOptions } from 'types/query'; + +interface Props extends WorkflowRunsOptions { + onChange: (value: WorkflowRunsOptions) => void; +} + +const QueryEditorWorkflowRuns = (props: Props) => { + const [workflow, setWorkflow] = useState(props.workflow); + const [branch, setBranch] = useState(props.branch); + + return ( + <> + + setWorkflow(el.currentTarget.value)} + onBlur={(el) => + props.onChange({ + ...props, + workflow: el.currentTarget.value, + }) + } + /> + + + setBranch(el.currentTarget.value)} + onBlur={(el) => + props.onChange({ + ...props, + branch: el.currentTarget.value, + }) + } + /> + + + ); +}; + +export default QueryEditorWorkflowRuns;