diff --git a/pkg/github/client/client.go b/pkg/github/client/client.go index 8b5d38da..8bb7df5e 100644 --- a/pkg/github/client/client.go +++ b/pkg/github/client/client.go @@ -225,6 +225,25 @@ func (client *Client) ListDeployments(ctx context.Context, owner, repo string, o return deployments, 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) { + commit, resp, err := client.restClient.Repositories.GetCommit(ctx, owner, repo, sha, opts) + if err != nil { + return nil, nil, addErrorSourceToError(err, resp) + } + return commit.Files, resp, nil +} + +// ListPullRequestFiles returns the list of files changed in a specific pull request. +func (client *Client) ListPullRequestFiles(ctx context.Context, owner, repo string, prNumber int, opts *googlegithub.ListOptions) ([]*googlegithub.CommitFile, *googlegithub.Response, error) { + files, resp, err := client.restClient.PullRequests.ListFiles(ctx, owner, repo, prNumber, opts) + if err != nil { + return nil, nil, addErrorSourceToError(err, resp) + } + return files, resp, nil +} + // 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 b37e64ac..7f970041 100644 --- a/pkg/github/codescanning_test.go +++ b/pkg/github/codescanning_test.go @@ -60,6 +60,14 @@ func (m *mockClient) ListAllOrgRepositories(ctx context.Context, opts *googlegit return nil, nil, nil } +func (m *mockClient) GetCommitFiles(ctx context.Context, owner, repo, sha string, opts *googlegithub.ListOptions) ([]*googlegithub.CommitFile, *googlegithub.Response, error) { + return nil, nil, nil +} + +func (m *mockClient) ListPullRequestFiles(ctx context.Context, owner, repo string, prNumber int, opts *googlegithub.ListOptions) ([]*googlegithub.CommitFile, *googlegithub.Response, error) { + return nil, nil, nil +} + func TestGetCodeScanningAlerts(t *testing.T) { var ( ctx = context.Background() diff --git a/pkg/github/commit_files.go b/pkg/github/commit_files.go new file mode 100644 index 00000000..5bc31aa8 --- /dev/null +++ b/pkg/github/commit_files.go @@ -0,0 +1,144 @@ +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" +) + +// CommitFilesWrapper is a list of commit files returned by the GitHub API +type CommitFilesWrapper []*googlegithub.CommitFile + +// Frames converts the list of commit files to a Grafana DataFrame +func (files CommitFilesWrapper) Frames() data.Frames { + frame := data.NewFrame( + "commit_files", + data.NewField("path", nil, []string{}), + data.NewField("additions", nil, []int64{}), + data.NewField("deletions", nil, []int64{}), + data.NewField("changes", nil, []int64{}), + data.NewField("status", nil, []string{}), + data.NewField("previous_filename", nil, []string{}), + ) + + for _, f := range files { + frame.AppendRow( + f.GetFilename(), + int64(f.GetAdditions()), + int64(f.GetDeletions()), + int64(f.GetChanges()), + f.GetStatus(), + f.GetPreviousFilename(), + ) + } + + frame.Meta = &data.FrameMeta{PreferredVisualization: data.VisTypeTable} + return data.Frames{frame} +} + +// GetCommitFiles fetches the files changed in a specific commit. +// The GitHub REST API returns at most 300 files for a single commit. +func GetCommitFiles(ctx context.Context, client models.Client, opts models.CommitFilesOptions) (CommitFilesWrapper, error) { + if opts.Owner == "" || opts.Repository == "" || opts.Ref == "" { + return nil, nil + } + + files, _, err := client.GetCommitFiles(ctx, opts.Owner, opts.Repository, opts.Ref, &googlegithub.ListOptions{ + PerPage: 300, + }) + if err != nil { + return nil, fmt.Errorf("getting commit files: owner=%s repo=%s sha=%s: %w", opts.Owner, opts.Repository, opts.Ref, err) + } + + return CommitFilesWrapper(files), nil +} + +// GetPullRequestFiles fetches all files changed in a pull request, handling pagination. +func GetPullRequestFiles(ctx context.Context, client models.Client, opts models.PullRequestFilesOptions) (CommitFilesWrapper, error) { + if opts.Owner == "" || opts.Repository == "" || opts.PRNumber == 0 { + return nil, nil + } + + var allFiles []*googlegithub.CommitFile + page := 1 + + for { + files, resp, err := client.ListPullRequestFiles(ctx, opts.Owner, opts.Repository, int(opts.PRNumber), &googlegithub.ListOptions{ + Page: page, + PerPage: 100, + }) + if err != nil { + return nil, fmt.Errorf("listing PR files: owner=%s repo=%s pr=%d page=%d: %w", opts.Owner, opts.Repository, opts.PRNumber, page, err) + } + + allFiles = append(allFiles, files...) + + if resp == nil || resp.NextPage == 0 { + break + } + page = resp.NextPage + } + + return CommitFilesWrapper(allFiles), nil +} + +// CommitWithFiles holds a commit and the files changed in it +type CommitWithFiles struct { + Commit Commit + Files []*googlegithub.CommitFile +} + +// CommitsWithFiles is a list of commits each paired with their changed files +type CommitsWithFiles []CommitWithFiles + +// Frames converts the list of commits-with-files to a flattened Grafana DataFrame. +// Each row represents one file change within a commit (one row per commit × file). +func (c CommitsWithFiles) Frames() data.Frames { + frame := data.NewFrame( + "commits", + data.NewField("id", nil, []string{}), + data.NewField("author", nil, []string{}), + data.NewField("author_login", nil, []string{}), + data.NewField("author_email", nil, []string{}), + data.NewField("author_company", nil, []string{}), + data.NewField("committed_at", nil, []time.Time{}), + data.NewField("pushed_at", nil, []time.Time{}), + data.NewField("message", nil, []string{}), + data.NewField("file_path", nil, []string{}), + data.NewField("file_additions", nil, []int64{}), + data.NewField("file_deletions", nil, []int64{}), + data.NewField("file_changes", nil, []int64{}), + data.NewField("file_status", nil, []string{}), + data.NewField("previous_filename", nil, []string{}), + ) + + for _, cwf := range c { + for _, f := range cwf.Files { + frame.AppendRow( + cwf.Commit.OID, + cwf.Commit.Author.Name, + cwf.Commit.Author.User.Login, + cwf.Commit.Author.Email, + cwf.Commit.Author.User.Company, + cwf.Commit.CommittedDate.Time, + cwf.Commit.PushedDate.Time, + string(cwf.Commit.Message), + f.GetFilename(), + int64(f.GetAdditions()), + int64(f.GetDeletions()), + int64(f.GetChanges()), + f.GetStatus(), + f.GetPreviousFilename(), + ) + } + } + + frame.Meta = &data.FrameMeta{PreferredVisualization: data.VisTypeTable} + return data.Frames{frame} +} + diff --git a/pkg/github/commit_files_handler.go b/pkg/github/commit_files_handler.go new file mode 100644 index 00000000..f7d15b0d --- /dev/null +++ b/pkg/github/commit_files_handler.go @@ -0,0 +1,40 @@ +package github + +import ( + "context" + + "github.com/grafana/grafana-plugin-sdk-go/backend" + + "github.com/grafana/github-datasource/pkg/dfutil" + "github.com/grafana/github-datasource/pkg/models" +) + +func (s *QueryHandler) handleCommitFilesQuery(ctx context.Context, q backend.DataQuery) backend.DataResponse { + query := &models.CommitFilesQuery{} + if err := UnmarshalQuery(q.JSON, query); err != nil { + return *err + } + return dfutil.FrameResponseWithError(s.Datasource.HandleCommitFilesQuery(ctx, query, q)) +} + +// HandleCommitFiles handles the plugin query for files changed in a GitHub commit +func (s *QueryHandler) HandleCommitFiles(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) { + return &backend.QueryDataResponse{ + Responses: processQueries(ctx, req, s.handleCommitFilesQuery), + }, nil +} + +func (s *QueryHandler) handlePullRequestFilesQuery(ctx context.Context, q backend.DataQuery) backend.DataResponse { + query := &models.PullRequestFilesQuery{} + if err := UnmarshalQuery(q.JSON, query); err != nil { + return *err + } + return dfutil.FrameResponseWithError(s.Datasource.HandlePullRequestFilesQuery(ctx, query, q)) +} + +// HandlePullRequestFiles handles the plugin query for files changed in a GitHub pull request +func (s *QueryHandler) HandlePullRequestFiles(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) { + return &backend.QueryDataResponse{ + Responses: processQueries(ctx, req, s.handlePullRequestFilesQuery), + }, nil +} diff --git a/pkg/github/commit_files_test.go b/pkg/github/commit_files_test.go new file mode 100644 index 00000000..dfd9ffad --- /dev/null +++ b/pkg/github/commit_files_test.go @@ -0,0 +1,198 @@ +package github + +import ( + "context" + "testing" + + googlegithub "github.com/google/go-github/v81/github" + "github.com/grafana/grafana-plugin-sdk-go/backend" + + "github.com/grafana/github-datasource/pkg/models" + "github.com/grafana/github-datasource/pkg/testutil" +) + +// commitFilesMockClient satisfies models.Client for commit file tests +type commitFilesMockClient struct { + commitFiles []*googlegithub.CommitFile + prFiles []*googlegithub.CommitFile + nextPage int + expectedOwner string + expectedRepo string + t *testing.T +} + +func (m *commitFilesMockClient) Query(_ context.Context, _ interface{}, _ map[string]interface{}) error { + return nil +} +func (m *commitFilesMockClient) ListWorkflows(_ context.Context, _, _ string, _ *googlegithub.ListOptions) (*googlegithub.Workflows, *googlegithub.Response, error) { + return nil, nil, nil +} +func (m *commitFilesMockClient) GetWorkflowUsage(_ context.Context, _, _, _ string, _ backend.TimeRange) (models.WorkflowUsage, error) { + return models.WorkflowUsage{}, nil +} +func (m *commitFilesMockClient) GetWorkflowRuns(_ context.Context, _, _, _, _ string, _ backend.TimeRange) ([]*googlegithub.WorkflowRun, error) { + return nil, nil +} +func (m *commitFilesMockClient) ListAlertsForRepo(_ context.Context, _, _ string, _ *googlegithub.AlertListOptions) ([]*googlegithub.Alert, *googlegithub.Response, error) { + return nil, nil, nil +} +func (m *commitFilesMockClient) ListAlertsForOrg(_ context.Context, _ string, _ *googlegithub.AlertListOptions) ([]*googlegithub.Alert, *googlegithub.Response, error) { + return nil, nil, nil +} + +func (m *commitFilesMockClient) GetCommitFiles(_ context.Context, owner, repo, _ string, _ *googlegithub.ListOptions) ([]*googlegithub.CommitFile, *googlegithub.Response, error) { + if owner != m.expectedOwner || repo != m.expectedRepo { + m.t.Errorf("GetCommitFiles: expected owner/repo=%s/%s got=%s/%s", m.expectedOwner, m.expectedRepo, owner, repo) + } + return m.commitFiles, &googlegithub.Response{}, nil +} + +func (m *commitFilesMockClient) ListPullRequestFiles(_ context.Context, owner, repo string, _ int, _ *googlegithub.ListOptions) ([]*googlegithub.CommitFile, *googlegithub.Response, error) { + if owner != m.expectedOwner || repo != m.expectedRepo { + m.t.Errorf("ListPullRequestFiles: expected owner/repo=%s/%s got=%s/%s", m.expectedOwner, m.expectedRepo, owner, repo) + } + resp := &googlegithub.Response{} + resp.NextPage = m.nextPage + m.nextPage = 0 // only one page after the first call + return m.prFiles, resp, nil +} + +func TestGetCommitFiles(t *testing.T) { + ctx := context.Background() + opts := models.CommitFilesOptions{ + Owner: "grafana", + Repository: "grafana", + Ref: "abc123def456", + } + + filename := "pkg/server/server.go" + additions, deletions, changes := 10, 5, 15 + status := "modified" + + client := &commitFilesMockClient{ + commitFiles: []*googlegithub.CommitFile{ + { + Filename: &filename, + Additions: &additions, + Deletions: &deletions, + Changes: &changes, + Status: &status, + }, + }, + expectedOwner: "grafana", + expectedRepo: "grafana", + t: t, + } + + result, err := GetCommitFiles(ctx, client, opts) + if err != nil { + t.Fatal(err) + } + if len(result) != 1 { + t.Errorf("expected 1 file, got %d", len(result)) + } +} + +func TestGetCommitFilesEmptyRef(t *testing.T) { + ctx := context.Background() + opts := models.CommitFilesOptions{ + Owner: "grafana", + Repository: "grafana", + Ref: "", + } + + client := &commitFilesMockClient{t: t} + result, err := GetCommitFiles(ctx, client, opts) + if err != nil { + t.Fatal(err) + } + if result != nil { + t.Errorf("expected nil result for empty ref, got %v", result) + } +} + +func TestGetPullRequestFiles(t *testing.T) { + ctx := context.Background() + opts := models.PullRequestFilesOptions{ + Owner: "grafana", + Repository: "grafana", + PRNumber: 42, + } + + filename := "pkg/server/server.go" + additions, deletions, changes := 20, 3, 23 + status := "modified" + + client := &commitFilesMockClient{ + prFiles: []*googlegithub.CommitFile{ + { + Filename: &filename, + Additions: &additions, + Deletions: &deletions, + Changes: &changes, + Status: &status, + }, + }, + nextPage: 0, + expectedOwner: "grafana", + expectedRepo: "grafana", + t: t, + } + + result, err := GetPullRequestFiles(ctx, client, opts) + if err != nil { + t.Fatal(err) + } + if len(result) != 1 { + t.Errorf("expected 1 file, got %d", len(result)) + } +} + +func TestGetPullRequestFilesZeroPR(t *testing.T) { + ctx := context.Background() + opts := models.PullRequestFilesOptions{ + Owner: "grafana", + Repository: "grafana", + PRNumber: 0, + } + + client := &commitFilesMockClient{t: t} + result, err := GetPullRequestFiles(ctx, client, opts) + if err != nil { + t.Fatal(err) + } + if result != nil { + t.Errorf("expected nil result for zero PR number, got %v", result) + } +} + +func TestCommitFilesFrames(t *testing.T) { + filename1 := "src/main.go" + additions1, deletions1, changes1 := 10, 2, 12 + status1 := "modified" + + filename2new := "src/renamed.go" + filename2old := "src/old.go" + additions2, deletions2, changes2 := 0, 0, 0 + status2 := "renamed" + + files := CommitFilesWrapper([]*googlegithub.CommitFile{ + { + Filename: &filename1, + Additions: &additions1, + Deletions: &deletions1, + Changes: &changes1, + Status: &status1, + }, + { + Filename: &filename2new, + Additions: &additions2, + Deletions: &deletions2, + Changes: &changes2, + Status: &status2, + PreviousFilename: &filename2old, + }, + }) + + testutil.CheckGoldenFramer(t, "commit_files", files) +} diff --git a/pkg/github/commits.go b/pkg/github/commits.go index a7119c26..02f90323 100644 --- a/pkg/github/commits.go +++ b/pkg/github/commits.go @@ -2,8 +2,10 @@ package github import ( "context" + "fmt" "time" + googlegithub "github.com/google/go-github/v81/github" "github.com/grafana/github-datasource/pkg/models" "github.com/grafana/grafana-plugin-sdk-go/data" "github.com/shurcooL/githubv4" @@ -155,3 +157,26 @@ func GetCommitsInRange(ctx context.Context, client models.Client, opts models.Li return commits, nil } + +// GetCommitsWithFilesInRange fetches commits in a time range and enriches each +// with its changed files via one REST call per commit. This can be slow and +// rate-limit-heavy for large time ranges. +func GetCommitsWithFilesInRange(ctx context.Context, client models.Client, opts models.ListCommitsOptions, from time.Time, to time.Time) (CommitsWithFiles, error) { + commits, err := GetCommitsInRange(ctx, client, opts, from, to) + if err != nil { + return nil, err + } + + result := make(CommitsWithFiles, 0, len(commits)) + for _, c := range commits { + files, _, err := client.GetCommitFiles(ctx, opts.Owner, opts.Repository, c.OID, &googlegithub.ListOptions{PerPage: 300}) + if err != nil { + return nil, fmt.Errorf("getting files for commit %s: %w", c.OID, err) + } + if len(files) > 0 { + result = append(result, CommitWithFiles{Commit: c, Files: files}) + } + } + + return result, nil +} diff --git a/pkg/github/datasource.go b/pkg/github/datasource.go index 359aba3b..bbb71965 100644 --- a/pkg/github/datasource.go +++ b/pkg/github/datasource.go @@ -43,9 +43,24 @@ func (d *Datasource) HandleIssuesQuery(ctx context.Context, query *models.Issues // HandleCommitsQuery is the query handler for listing GitHub Commits func (d *Datasource) HandleCommitsQuery(ctx context.Context, query *models.CommitsQuery, req backend.DataQuery) (dfutil.Framer, error) { opt := models.CommitsOptionsWithRepo(query.Options, query.Owner, query.Repository) + if opt.IncludeFiles { + return GetCommitsWithFilesInRange(ctx, d.client, opt, req.TimeRange.From, req.TimeRange.To) + } return GetCommitsInRange(ctx, d.client, opt, req.TimeRange.From, req.TimeRange.To) } +// HandleCommitFilesQuery is the query handler for listing files changed in a GitHub commit +func (d *Datasource) HandleCommitFilesQuery(ctx context.Context, query *models.CommitFilesQuery, req backend.DataQuery) (dfutil.Framer, error) { + opt := models.CommitFilesOptionsWithRepo(query.Options, query.Owner, query.Repository) + return GetCommitFiles(ctx, d.client, opt) +} + +// HandlePullRequestFilesQuery is the query handler for listing files changed in a GitHub pull request +func (d *Datasource) HandlePullRequestFilesQuery(ctx context.Context, query *models.PullRequestFilesQuery, req backend.DataQuery) (dfutil.Framer, error) { + opt := models.PullRequestFilesOptionsWithRepo(query.Options, query.Owner, query.Repository) + return GetPullRequestFiles(ctx, d.client, opt) +} + // HandleCodeScanningQuery is the query handler for listing code scanning alerts of a GitHub repository func (d *Datasource) HandleCodeScanningQuery(ctx context.Context, query *models.CodeScanningQuery, req backend.DataQuery) (dfutil.Framer, error) { opt := models.CodeScanningOptionsWithRepo(query.Options, query.Owner, query.Repository) diff --git a/pkg/github/query_handler.go b/pkg/github/query_handler.go index 4b179d3c..1bd49314 100644 --- a/pkg/github/query_handler.go +++ b/pkg/github/query_handler.go @@ -63,6 +63,8 @@ func GetQueryHandlers(s *QueryHandler) *datasource.QueryTypeMux { mux.HandleFunc(models.QueryTypeCodeScanning, s.HandleCodeScanning) mux.HandleFunc(models.QueryTypeDeployments, s.HandleDeployments) mux.HandleFunc(models.QueryTypeOrganizations, s.HandleOrganizations) + mux.HandleFunc(models.QueryTypeCommitFiles, s.HandleCommitFiles) + mux.HandleFunc(models.QueryTypePullRequestFiles, s.HandlePullRequestFiles) return mux } diff --git a/pkg/github/testdata/commit_files.golden.jsonc b/pkg/github/testdata/commit_files.golden.jsonc new file mode 100644 index 00000000..485cbcc7 --- /dev/null +++ b/pkg/github/testdata/commit_files.golden.jsonc @@ -0,0 +1,111 @@ +// 🌟 This was machine generated. Do not edit. 🌟 +// +// Frame[0] { +// "typeVersion": [ +// 0, +// 0 +// ], +// "preferredVisualisationType": "table" +// } +// Name: commit_files +// Dimensions: 6 Fields by 2 Rows +// +----------------+-----------------+-----------------+---------------+----------------+-------------------------+ +// | Name: path | Name: additions | Name: deletions | Name: changes | Name: status | Name: previous_filename | +// | Labels: | Labels: | Labels: | Labels: | Labels: | Labels: | +// | Type: []string | Type: []int64 | Type: []int64 | Type: []int64 | Type: []string | Type: []string | +// +----------------+-----------------+-----------------+---------------+----------------+-------------------------+ +// | src/main.go | 10 | 2 | 12 | modified | | +// | src/renamed.go | 0 | 0 | 0 | renamed | src/old.go | +// +----------------+-----------------+-----------------+---------------+----------------+-------------------------+ +// +// +// 🌟 This was machine generated. Do not edit. 🌟 +{ + "status": 200, + "frames": [ + { + "schema": { + "name": "commit_files", + "meta": { + "typeVersion": [ + 0, + 0 + ], + "preferredVisualisationType": "table" + }, + "fields": [ + { + "name": "path", + "type": "string", + "typeInfo": { + "frame": "string" + } + }, + { + "name": "additions", + "type": "number", + "typeInfo": { + "frame": "int64" + } + }, + { + "name": "deletions", + "type": "number", + "typeInfo": { + "frame": "int64" + } + }, + { + "name": "changes", + "type": "number", + "typeInfo": { + "frame": "int64" + } + }, + { + "name": "status", + "type": "string", + "typeInfo": { + "frame": "string" + } + }, + { + "name": "previous_filename", + "type": "string", + "typeInfo": { + "frame": "string" + } + } + ] + }, + "data": { + "values": [ + [ + "src/main.go", + "src/renamed.go" + ], + [ + 10, + 0 + ], + [ + 2, + 0 + ], + [ + 12, + 0 + ], + [ + "modified", + "renamed" + ], + [ + "", + "src/old.go" + ] + ] + } + } + ] +} \ No newline at end of file diff --git a/pkg/models/client.go b/pkg/models/client.go index 591e43bb..696e7b47 100644 --- a/pkg/models/client.go +++ b/pkg/models/client.go @@ -18,4 +18,6 @@ type Client interface { ListAlertsForOrg(ctx context.Context, owner string, opts *googlegithub.AlertListOptions) ([]*googlegithub.Alert, *googlegithub.Response, error) ListAllOrgRepositories(ctx context.Context, opts *googlegithub.ListOptions) ([]*googlegithub.Repository, *googlegithub.Response, error) 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) } diff --git a/pkg/models/commit_files.go b/pkg/models/commit_files.go new file mode 100644 index 00000000..036b297f --- /dev/null +++ b/pkg/models/commit_files.go @@ -0,0 +1,35 @@ +package models + +// CommitFilesOptions provides options when retrieving files changed in a commit +type CommitFilesOptions struct { + Owner string `json:"owner"` + Repository string `json:"repository"` + // Ref is the commit SHA to retrieve changed files for + Ref string `json:"commitSha"` +} + +// CommitFilesOptionsWithRepo adds Owner and Repository to CommitFilesOptions +func CommitFilesOptionsWithRepo(opt CommitFilesOptions, owner, repo string) CommitFilesOptions { + return CommitFilesOptions{ + Owner: owner, + Repository: repo, + Ref: opt.Ref, + } +} + +// PullRequestFilesOptions provides options when retrieving files changed in a pull request +type PullRequestFilesOptions struct { + Owner string `json:"owner"` + Repository string `json:"repository"` + // PRNumber is the pull request number + PRNumber int64 `json:"prNumber"` +} + +// PullRequestFilesOptionsWithRepo adds Owner and Repository to PullRequestFilesOptions +func PullRequestFilesOptionsWithRepo(opt PullRequestFilesOptions, owner, repo string) PullRequestFilesOptions { + return PullRequestFilesOptions{ + Owner: owner, + Repository: repo, + PRNumber: opt.PRNumber, + } +} diff --git a/pkg/models/commits.go b/pkg/models/commits.go index 35faaf21..ad61b42d 100644 --- a/pkg/models/commits.go +++ b/pkg/models/commits.go @@ -2,16 +2,18 @@ package models // ListCommitsOptions provides options when retrieving commits type ListCommitsOptions struct { - Repository string `json:"repository"` - Owner string `json:"owner"` - Ref string `json:"gitRef"` + Repository string `json:"repository"` + Owner string `json:"owner"` + Ref string `json:"gitRef"` + IncludeFiles bool `json:"includeFiles"` } // CommitsOptionsWithRepo adds Owner and Repo to a ListCommitsOptions. This is just for convenience func CommitsOptionsWithRepo(opt ListCommitsOptions, owner string, repo string) ListCommitsOptions { return ListCommitsOptions{ - Owner: owner, - Repository: repo, - Ref: opt.Ref, + Owner: owner, + Repository: repo, + Ref: opt.Ref, + IncludeFiles: opt.IncludeFiles, } } diff --git a/pkg/models/query.go b/pkg/models/query.go index 95ad56d6..e062fe05 100644 --- a/pkg/models/query.go +++ b/pkg/models/query.go @@ -45,6 +45,10 @@ const ( QueryTypeCodeScanning = "Code_Scanning" // QueryTypeDeployments is used when querying deployments for a repository QueryTypeDeployments = "Deployments" + // QueryTypeCommitFiles is used when querying files changed in a specific commit + QueryTypeCommitFiles = "Commit_Files" + // QueryTypePullRequestFiles is used when querying files changed in a specific pull request + QueryTypePullRequestFiles = "Pull_Request_Files" ) // Query refers to the structure of a query built using the QueryEditor. @@ -165,3 +169,15 @@ type DeploymentsQuery struct { // OrganizationsQuery is used when querying for GitHub organizations type OrganizationsQuery struct { } + +// CommitFilesQuery is used when querying for files changed in a GitHub commit +type CommitFilesQuery struct { + Query + Options CommitFilesOptions `json:"options"` +} + +// PullRequestFilesQuery is used when querying for files changed in a GitHub pull request +type PullRequestFilesQuery struct { + Query + Options PullRequestFilesOptions `json:"options"` +} diff --git a/pkg/plugin/datasource.go b/pkg/plugin/datasource.go index 5af2e64d..68f111b3 100644 --- a/pkg/plugin/datasource.go +++ b/pkg/plugin/datasource.go @@ -15,6 +15,8 @@ type Datasource interface { HandleIssuesQuery(context.Context, *models.IssuesQuery, backend.DataQuery) (dfutil.Framer, error) HandleCommitsQuery(context.Context, *models.CommitsQuery, backend.DataQuery) (dfutil.Framer, error) HandleCodeScanningQuery(context.Context, *models.CodeScanningQuery, backend.DataQuery) (dfutil.Framer, error) + HandleCommitFilesQuery(context.Context, *models.CommitFilesQuery, backend.DataQuery) (dfutil.Framer, error) + HandlePullRequestFilesQuery(context.Context, *models.PullRequestFilesQuery, backend.DataQuery) (dfutil.Framer, error) HandleTagsQuery(context.Context, *models.TagsQuery, backend.DataQuery) (dfutil.Framer, error) HandleReleasesQuery(context.Context, *models.ReleasesQuery, backend.DataQuery) (dfutil.Framer, error) HandleContributorsQuery(context.Context, *models.ContributorsQuery, backend.DataQuery) (dfutil.Framer, error) diff --git a/pkg/plugin/datasource_caching.go b/pkg/plugin/datasource_caching.go index 0eef79bb..87ab4045 100644 --- a/pkg/plugin/datasource_caching.go +++ b/pkg/plugin/datasource_caching.go @@ -282,6 +282,26 @@ func (c *CachedDatasource) HandleOrganizationsQuery(ctx context.Context, q *mode return c.saveCache(req, f, err) } +// HandleCommitFilesQuery is the cache wrapper for the commit files query handler +func (c *CachedDatasource) HandleCommitFilesQuery(ctx context.Context, q *models.CommitFilesQuery, req backend.DataQuery) (dfutil.Framer, error) { + if value, err := c.getCache(req); err == nil { + return value, err + } + + f, err := c.datasource.HandleCommitFilesQuery(ctx, q, req) + return c.saveCache(req, f, err) +} + +// HandlePullRequestFilesQuery is the cache wrapper for the pull request files query handler +func (c *CachedDatasource) HandlePullRequestFilesQuery(ctx context.Context, q *models.PullRequestFilesQuery, req backend.DataQuery) (dfutil.Framer, error) { + if value, err := c.getCache(req); err == nil { + return value, err + } + + f, err := c.datasource.HandlePullRequestFilesQuery(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 26a753b3..eb94ff6b 100644 --- a/pkg/testutil/client.go +++ b/pkg/testutil/client.go @@ -86,3 +86,13 @@ func (c *TestClient) ListDeployments(ctx context.Context, owner, repo string, op func (c *TestClient) ListAllOrgRepositories(ctx context.Context, opts *googlegithub.ListOptions) ([]*googlegithub.Repository, *googlegithub.Response, error) { panic("unimplemented") } + +// GetCommitFiles is not implemented because it is not being used in tests at the moment. +func (c *TestClient) GetCommitFiles(ctx context.Context, owner, repo, sha string, opts *googlegithub.ListOptions) ([]*googlegithub.CommitFile, *googlegithub.Response, error) { + panic("unimplemented") +} + +// ListPullRequestFiles is not implemented because it is not being used in tests at the moment. +func (c *TestClient) ListPullRequestFiles(ctx context.Context, owner, repo string, prNumber int, opts *googlegithub.ListOptions) ([]*googlegithub.CommitFile, *googlegithub.Response, error) { + panic("unimplemented") +} diff --git a/src/constants.ts b/src/constants.ts index c7fbeb96..407a78d8 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -21,6 +21,8 @@ export enum QueryType { Workflow_Usage = 'Workflow_Usage', Workflow_Runs = 'Workflow_Runs', Deployments = 'Deployments', + Commit_Files = 'Commit_Files', + Pull_Request_Files = 'Pull_Request_Files', } export const DefaultQueryType = QueryType.Issues; diff --git a/src/types/query.ts b/src/types/query.ts index 33320822..d7d0fa31 100644 --- a/src/types/query.ts +++ b/src/types/query.ts @@ -21,7 +21,9 @@ export interface GitHubQuery extends Indexable, DataQuery, RepositoryOptions { | WorkflowsOptions | WorkflowUsageOptions | WorkflowRunsOptions - | DeploymentsOptions; + | DeploymentsOptions + | CommitFilesOptions + | PullRequestFilesOptions; } export interface Label { @@ -55,6 +57,15 @@ export interface CodeScanningOptions extends Indexable { export interface CommitsOptions extends Indexable { gitRef?: string; + includeFiles?: boolean; +} + +export interface CommitFilesOptions extends Indexable { + commitSha?: string; +} + +export interface PullRequestFilesOptions extends Indexable { + prNumber?: number; } export interface ContributorsOptions extends Indexable { diff --git a/src/validation.ts b/src/validation.ts index a06c7e4c..8380ada1 100644 --- a/src/validation.ts +++ b/src/validation.ts @@ -10,6 +10,8 @@ export const isValid = (query: GitHubQuery): boolean => { } if ( query.queryType === QueryType.Commits || + query.queryType === QueryType.Commit_Files || + query.queryType === QueryType.Pull_Request_Files || query.queryType === QueryType.Contributors || query.queryType === QueryType.Tags || query.queryType === QueryType.Releases || diff --git a/src/views/QueryEditor.tsx b/src/views/QueryEditor.tsx index 2bc064d1..e4ca526d 100644 --- a/src/views/QueryEditor.tsx +++ b/src/views/QueryEditor.tsx @@ -24,6 +24,8 @@ import QueryEditorWorkflowUsage from './QueryEditorWorkflowUsage'; import QueryEditorWorkflowRuns from './QueryEditorWorkflowRuns'; import QueryEditorCodeScanning from './QueryEditorCodeScanning'; import QueryEditorDeployments from './QueryEditorDeployments'; +import QueryEditorCommitFiles from './QueryEditorCommitFiles'; +import QueryEditorPullRequestFiles from './QueryEditorPullRequestFiles'; import { QueryType, DefaultQueryType } from '../constants'; import type { GitHubQuery } from '../types/query'; import type { GitHubDataSourceOptions } from '../types/config'; @@ -31,7 +33,7 @@ import type { GitHubDataSourceOptions } from '../types/config'; interface Props extends QueryEditorProps { queryTypes?: string[]; } -export const LeftColumnWidth = 10; +export const LeftColumnWidth = 12; export const RightColumnWidth = 36; /* eslint-disable react/display-name */ @@ -57,6 +59,16 @@ const queryEditors: { [QueryType.Code_Scanning]: { component: (props: Props, onChange: (val: any) => void) => , }, + [QueryType.Commit_Files]: { + component: (props: Props, onChange: (val: any) => void) => ( + + ), + }, + [QueryType.Pull_Request_Files]: { + component: (props: Props, onChange: (val: any) => void) => ( + + ), + }, [QueryType.Releases]: { component: (props: Props, _: (val: any) => void) => , }, diff --git a/src/views/QueryEditorCommitFiles.tsx b/src/views/QueryEditorCommitFiles.tsx new file mode 100644 index 00000000..1e436b18 --- /dev/null +++ b/src/views/QueryEditorCommitFiles.tsx @@ -0,0 +1,31 @@ +import React, { useState } from 'react'; +import { Input, InlineField } from '@grafana/ui'; +import { LeftColumnWidth, RightColumnWidth } from './QueryEditor'; +import type { CommitFilesOptions } from '../types/query'; + +interface Props extends CommitFilesOptions { + onChange: (value: CommitFilesOptions) => void; +} + +const QueryEditorCommitFiles = (props: Props) => { + const [commitSha, setCommitSha] = useState(props.commitSha || ''); + return ( + <> + + setCommitSha(el.currentTarget.value)} + onBlur={(el) => props.onChange({ ...props, commitSha: el.currentTarget.value })} + /> + + + ); +}; + +export default QueryEditorCommitFiles; diff --git a/src/views/QueryEditorCommits.tsx b/src/views/QueryEditorCommits.tsx index 9de38efc..7d3fe5dd 100644 --- a/src/views/QueryEditorCommits.tsx +++ b/src/views/QueryEditorCommits.tsx @@ -1,5 +1,5 @@ import React, { useState } from 'react'; -import { Input, InlineField } from '@grafana/ui'; +import { Input, InlineField, InlineSwitch } from '@grafana/ui'; import { RightColumnWidth, LeftColumnWidth } from './QueryEditor'; import { components } from 'components/selectors'; import type { CommitsOptions } from '../types/query'; @@ -21,6 +21,16 @@ const QueryEditorCommits = (props: Props) => { onBlur={(el) => props.onChange({ ...props, gitRef: el.currentTarget.value })} /> + + props.onChange({ ...props, includeFiles: el.currentTarget.checked })} + /> + ); }; diff --git a/src/views/QueryEditorPullRequestFiles.tsx b/src/views/QueryEditorPullRequestFiles.tsx new file mode 100644 index 00000000..e24517f0 --- /dev/null +++ b/src/views/QueryEditorPullRequestFiles.tsx @@ -0,0 +1,37 @@ +import React, { useState } from 'react'; +import { Input, InlineField } from '@grafana/ui'; +import { LeftColumnWidth, RightColumnWidth } from './QueryEditor'; +import type { PullRequestFilesOptions } from '../types/query'; + +interface Props extends PullRequestFilesOptions { + onChange: (value: PullRequestFilesOptions) => void; +} + +const QueryEditorPullRequestFiles = (props: Props) => { + const [prNumber, setPrNumber] = useState( + props.prNumber !== undefined ? String(props.prNumber) : '' + ); + return ( + <> + + setPrNumber(el.currentTarget.value)} + onBlur={(el) => { + const parsed = parseInt(el.currentTarget.value, 10); + props.onChange({ ...props, prNumber: isNaN(parsed) ? undefined : parsed }); + }} + /> + + + ); +}; + +export default QueryEditorPullRequestFiles;