diff --git a/pkg/github/branches.go b/pkg/github/branches.go new file mode 100644 index 00000000..5fd90e18 --- /dev/null +++ b/pkg/github/branches.go @@ -0,0 +1,124 @@ +package github + +import ( + "context" + "time" + + "github.com/grafana/github-datasource/pkg/models" + "github.com/grafana/grafana-plugin-sdk-go/data" + "github.com/shurcooL/githubv4" +) + +type branchDTO struct { + Name string + CommitSHA string + AuthorName string + AuthorLogin string + CommitDate time.Time +} + +// Branches is a list of GitHub branches +type Branches []branchDTO + +// Frames converts the list of branches to a Grafana DataFrame +func (b Branches) Frames() data.Frames { + frame := data.NewFrame( + "branches", + data.NewField("name", nil, []string{}), + data.NewField("commit_sha", nil, []string{}), + data.NewField("author", nil, []string{}), + data.NewField("author_login", nil, []string{}), + data.NewField("commit_date", nil, []time.Time{}), + ) + + for _, v := range b { + frame.AppendRow( + v.Name, + v.CommitSHA, + v.AuthorName, + v.AuthorLogin, + v.CommitDate, + ) + } + + return data.Frames{frame} +} + +// QueryListBranches is the GraphQL query for listing GitHub branches in a repository +// +// { +// repository(name: "grafana", owner: "grafana") { +// refs(refPrefix: "refs/heads/", first: 100, after: $cursor, query: $query) { +// nodes { +// name +// target { +// ... on Commit { +// oid +// author { +// date +// user { +// login +// name +// } +// } +// } +// } +// } +// pageInfo { +// hasNextPage +// endCursor +// } +// } +// } +// } +type QueryListBranches struct { + Repository struct { + Refs struct { + Nodes []struct { + Name string + Target struct { + Commit commit `graphql:"... on Commit"` + } + } + PageInfo models.PageInfo + } `graphql:"refs(refPrefix: \"refs/heads/\", first: 100, after: $cursor, query: $query)"` + } `graphql:"repository(name: $name, owner: $owner)"` +} + +// GetAllBranches retrieves every branch from a repository, filtered by the optional query string +func GetAllBranches(ctx context.Context, client models.Client, opts models.ListBranchesOptions) (Branches, error) { + var ( + variables = map[string]interface{}{ + "cursor": (*githubv4.String)(nil), + "owner": githubv4.String(opts.Owner), + "name": githubv4.String(opts.Repository), + "query": githubv4.String(opts.Query), + } + + branches = []branchDTO{} + ) + + for { + q := &QueryListBranches{} + if err := client.Query(ctx, q, variables); err != nil { + return nil, err + } + + for _, node := range q.Repository.Refs.Nodes { + branches = append(branches, branchDTO{ + Name: node.Name, + CommitSHA: node.Target.Commit.OID, + AuthorName: node.Target.Commit.Author.User.Name, + AuthorLogin: node.Target.Commit.Author.User.Login, + CommitDate: node.Target.Commit.Author.Date.Time, + }) + } + + if !q.Repository.Refs.PageInfo.HasNextPage { + break + } + variables["cursor"] = q.Repository.Refs.PageInfo.EndCursor + } + + return branches, nil +} diff --git a/pkg/github/branches_handler.go b/pkg/github/branches_handler.go new file mode 100644 index 00000000..f04a82d6 --- /dev/null +++ b/pkg/github/branches_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) handleBranchesQuery(ctx context.Context, q backend.DataQuery) backend.DataResponse { + query := &models.BranchesQuery{} + if err := UnmarshalQuery(q.JSON, query); err != nil { + return *err + } + return dfutil.FrameResponseWithError(s.Datasource.HandleBranchesQuery(ctx, query, q)) +} + +// HandleBranches handles the plugin query for github branches +func (s *QueryHandler) HandleBranches(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) { + return &backend.QueryDataResponse{ + Responses: processQueries(ctx, req, s.handleBranchesQuery), + }, nil +} diff --git a/pkg/github/branches_test.go b/pkg/github/branches_test.go new file mode 100644 index 00000000..38462602 --- /dev/null +++ b/pkg/github/branches_test.go @@ -0,0 +1,32 @@ +package github + +import ( + "context" + "testing" + + "github.com/grafana/github-datasource/pkg/models" + "github.com/grafana/github-datasource/pkg/testutil" +) + +func TestListBranches(t *testing.T) { + var ( + ctx = context.Background() + opts = models.ListBranchesOptions{ + Repository: "grafana", + Owner: "grafana", + Query: "release/", + } + ) + + testVariables := testutil.GetTestVariablesFunction("query", "name", "owner", "cursor") + + client := testutil.NewTestClient(t, + testVariables, + testutil.GetTestQueryFunction(&QueryListBranches{}), + ) + + _, err := GetAllBranches(ctx, client, opts) + if err != nil { + t.Fatal(err) + } +} diff --git a/pkg/github/datasource.go b/pkg/github/datasource.go index bbb71965..f69142fd 100644 --- a/pkg/github/datasource.go +++ b/pkg/github/datasource.go @@ -81,6 +81,16 @@ func (d *Datasource) HandleTagsQuery(ctx context.Context, query *models.TagsQuer return GetTagsInRange(ctx, d.client, opt, req.TimeRange.From, req.TimeRange.To) } +// HandleBranchesQuery is the query handler for listing GitHub Branches +func (d *Datasource) HandleBranchesQuery(ctx context.Context, query *models.BranchesQuery, req backend.DataQuery) (dfutil.Framer, error) { + opt := models.ListBranchesOptions{ + Repository: query.Repository, + Owner: query.Owner, + Query: query.Options.Query, + } + return GetAllBranches(ctx, d.client, opt) +} + // 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/query_handler.go b/pkg/github/query_handler.go index 1b8a856d..e90cc21e 100644 --- a/pkg/github/query_handler.go +++ b/pkg/github/query_handler.go @@ -54,6 +54,7 @@ func GetQueryHandlers(s *QueryHandler) *datasource.QueryTypeMux { register(models.QueryTypePullRequestReviews, s.HandlePullRequestReviews) register(models.QueryTypeReleases, s.HandleReleases) register(models.QueryTypeTags, s.HandleTags) + register(models.QueryTypeBranches, s.HandleBranches) register(models.QueryTypePackages, s.HandlePackages) register(models.QueryTypeMilestones, s.HandleMilestones) register(models.QueryTypeRepositories, s.HandleRepositories) diff --git a/pkg/models/branches.go b/pkg/models/branches.go new file mode 100644 index 00000000..2a460923 --- /dev/null +++ b/pkg/models/branches.go @@ -0,0 +1,13 @@ +package models + +// ListBranchesOptions are the available options when listing branches +type ListBranchesOptions 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"` + + // Query filters branches by name prefix/substring (ex: release/) + Query string `json:"query"` +} diff --git a/pkg/models/query.go b/pkg/models/query.go index 08f171de..a010485f 100644 --- a/pkg/models/query.go +++ b/pkg/models/query.go @@ -53,6 +53,8 @@ const ( QueryTypeCommitFiles QueryType = "Commit_Files" // QueryTypePullRequestFiles is used when querying files changed in a specific pull request QueryTypePullRequestFiles QueryType = "Pull_Request_Files" + // QueryTypeBranches is used when querying branches in a GitHub repository + QueryTypeBranches QueryType = "Branches" ) // Query refers to the structure of a query built using the QueryEditor. @@ -185,3 +187,9 @@ type PullRequestFilesQuery struct { Query Options PullRequestFilesOptions `json:"options"` } + +// BranchesQuery is used when querying for branches in a GitHub repository +type BranchesQuery struct { + Query + Options ListBranchesOptions `json:"options"` +} diff --git a/src/constants.ts b/src/constants.ts index 3dd3263e..7814d5a1 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -7,6 +7,7 @@ export const QueryTypes = [ 'Issues', 'Contributors', 'Tags', + 'Branches', 'Releases', 'Pull_Requests', 'Pull_Request_Reviews', diff --git a/src/types/query.ts b/src/types/query.ts index 3bfc305d..d4e171b8 100644 --- a/src/types/query.ts +++ b/src/types/query.ts @@ -52,6 +52,13 @@ export type TagsOptions = Options & {} type TagsQuery = BaseQuery<'Tags', TagsOptions> //#endregion +//#region Branches Query +export type BranchesOptions = Options & { + query?: string; +} +type BranchesQuery = BaseQuery<'Branches', BranchesOptions> +//#endregion + //#region Releases Query export type ReleasesOptions = Options & {} type ReleasesQuery = BaseQuery<'Releases', ReleasesOptions> @@ -194,7 +201,8 @@ export type GitHubQuery = Workflow_RunsQuery | Workflow_UsageQuery | WorkflowsQuery | - DeploymentsQuery + DeploymentsQuery | + BranchesQuery export type GitHubVariableQuery = { key?: string; field?: string; } & GitHubQuery diff --git a/src/views/QueryEditor.tsx b/src/views/QueryEditor.tsx index c4036ab7..99ccf05c 100644 --- a/src/views/QueryEditor.tsx +++ b/src/views/QueryEditor.tsx @@ -24,6 +24,7 @@ import { QueryEditorWorkflowUsage } from './QueryEditorWorkflowUsage'; import { QueryEditorWorkflowRuns } from './QueryEditorWorkflowRuns'; import { QueryEditorCodeScanning } from './QueryEditorCodeScanning'; import { QueryEditorDeployments } from './QueryEditorDeployments'; +import { QueryEditorBranches } from './QueryEditorBranches'; import { DefaultQueryType, QueryTypes } from '../constants'; @@ -42,6 +43,11 @@ const queryEditors: Record <> }, ['ProjectItems']: { component: () => <> }, ['Tags']: { component: () => <> }, + ['Branches']: { + component: (props: Props, onChange: (val: any) => void) => ( + + ), + }, ['Releases']: { component: () => <> }, ['Vulnerabilities']: { component: () => <> }, ['Stargazers']: { component: () => <> }, diff --git a/src/views/QueryEditorBranches.test.tsx b/src/views/QueryEditorBranches.test.tsx new file mode 100644 index 00000000..4db931a8 --- /dev/null +++ b/src/views/QueryEditorBranches.test.tsx @@ -0,0 +1,18 @@ +import React from 'react'; +import { QueryEditorBranches } from './QueryEditorBranches'; +import { render, screen } from '@testing-library/react'; + +describe('QueryEditorBranches', () => { + it('renders a Filter input field', () => { + const props = { onChange: jest.fn() }; + render(); + expect(screen.getByPlaceholderText('release/')).toBeInTheDocument(); + }); + + it('shows existing query value', () => { + const onChange = jest.fn(); + render(); + const input = screen.getByPlaceholderText('release/'); + expect(input).toHaveValue('release/'); + }); +}); diff --git a/src/views/QueryEditorBranches.tsx b/src/views/QueryEditorBranches.tsx new file mode 100644 index 00000000..1c6e9cd1 --- /dev/null +++ b/src/views/QueryEditorBranches.tsx @@ -0,0 +1,32 @@ +import React, { useState } from 'react'; +import { Input } from '@grafana/ui'; +import { EditorField, EditorRow } from '@grafana/plugin-ui'; +import { BranchesOptions } from '../types/query'; +import { LeftColumnWidth, RightColumnWidth } from './QueryEditor'; + +interface Props extends BranchesOptions { + onChange: (value: BranchesOptions) => void; +} + +export const QueryEditorBranches = ({ query = '', onChange }: Props) => { + const [filter, setFilter] = useState(query); + + return ( + + + setFilter(e.currentTarget.value)} + onBlur={() => onChange({ query: filter })} + width={RightColumnWidth} + /> + + + ); +};