diff --git a/pkg/github/codeowners.go b/pkg/github/codeowners.go new file mode 100644 index 00000000..1e091070 --- /dev/null +++ b/pkg/github/codeowners.go @@ -0,0 +1,212 @@ +package github + +import ( + "context" + "path/filepath" + "strings" + + "github.com/grafana/github-datasource/pkg/models" + "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/grafana/grafana-plugin-sdk-go/data" + "github.com/shurcooL/githubv4" +) + +// QueryGetCodeowners is the GraphQL query for retrieving the CODEOWNERS file from a repository +type QueryGetCodeowners struct { + Repository struct { + Object struct { + Blob struct { + Text string `graphql:"text"` + } `graphql:"... on Blob"` + } `graphql:"object(expression: $expression)"` + } `graphql:"repository(owner: $owner, name: $name)"` +} + +// CodeownersEntry represents a single line in the CODEOWNERS file +type CodeownersEntry struct { + PathPattern string + Owners []string +} + +// Codeowners is a list of CODEOWNERS entries +type Codeowners struct { + Entries []CodeownersEntry +} + +// Frames converts the list of codeowners entries to a Grafana DataFrame +func (c Codeowners) Frames() data.Frames { + backend.Logger.Info("Creating data frame", "entries_count", len(c.Entries)) + + pathPatterns := make([]string, len(c.Entries)) + owners := make([]string, len(c.Entries)) + + for i, entry := range c.Entries { + backend.Logger.Info("Processing entry for frame", "i", i, "pathPattern", entry.PathPattern) + pathPatterns[i] = entry.PathPattern + owners[i] = strings.Join(entry.Owners, ", ") + } + + backend.Logger.Info("Final frame data", "pathPatterns", pathPatterns) + + frame := data.NewFrame( + "codeowners", + data.NewField("path_pattern", nil, pathPatterns), + data.NewField("owners", nil, owners), + ) + + return data.Frames{frame} +} + +// GetCodeowners retrieves and parses the CODEOWNERS file from a repository +func GetCodeowners(ctx context.Context, client models.Client, opts models.ListCodeownersOptions) (Codeowners, error) { + backend.Logger.Info("GetCodeowners called", "opts.FilePath", opts.FilePath) + + // Try different possible locations for CODEOWNERS file + possiblePaths := []string{ + "HEAD:CODEOWNERS", + "HEAD:.github/CODEOWNERS", + "HEAD:docs/CODEOWNERS", + } + + variables := map[string]interface{}{ + "owner": githubv4.String(opts.Owner), + "name": githubv4.String(opts.Repository), + } + + var codeownersContent string + for _, path := range possiblePaths { + variables["expression"] = githubv4.String(path) + + q := &QueryGetCodeowners{} + if err := client.Query(ctx, q, variables); err != nil { + continue // Try next location + } + + if q.Repository.Object.Blob.Text != "" { + codeownersContent = q.Repository.Object.Blob.Text + break + } + } + + if codeownersContent == "" { + return Codeowners{}, nil // Return empty result if no CODEOWNERS file found + } + + // Parse the codeowners content + codeOwners := parseCodeowners(codeownersContent, opts.FilePath) + + return codeOwners, nil +} + +// parseCodeowners parses the CODEOWNERS file content and returns structured data +// If filePath is provided, returns only the closest match (last matching pattern) +func parseCodeowners(content string, filePath string) Codeowners { + lines := strings.Split(content, "\n") + var allEntries []CodeownersEntry + + // First, parse all entries from the CODEOWNERS file + for _, line := range lines { + line = strings.TrimSpace(line) + + // Skip empty lines and comments + if line == "" || strings.HasPrefix(line, "#") { + continue + } + + parts := strings.Fields(line) + if len(parts) < 2 { + continue + } + + pathPattern := parts[0] + owners := parts[1:] + + entry := CodeownersEntry{ + PathPattern: pathPattern, + Owners: owners, + } + + allEntries = append(allEntries, entry) + } + + // If no filePath specified, return all entries + if filePath == "" { + backend.Logger.Info("No filePath specified, returning all entries", "count", len(allEntries)) + for i, entry := range allEntries { + backend.Logger.Info("Returning entry", "i", i, "pattern", entry.PathPattern) + } + return Codeowners{Entries: allEntries} + } + + // Find the closest match (last matching pattern wins in CODEOWNERS) + var closestMatch *CodeownersEntry + for i, entry := range allEntries { + if matchesPattern(entry.PathPattern, filePath) { + closestMatch = &allEntries[i] // Keep updating to get the last match + } + } + + // Return only the closest match + if closestMatch != nil { + backend.Logger.Info("Returning closest match", "pattern", closestMatch.PathPattern, "owners", closestMatch.Owners) + return Codeowners{Entries: []CodeownersEntry{*closestMatch}} + } + + // No matches found + backend.Logger.Info("No matches found for filePath", "filePath", filePath) + return Codeowners{Entries: []CodeownersEntry{}} +} + +// matchesPattern checks if a file path matches a CODEOWNERS pattern +func matchesPattern(pattern, filePath string) bool { + // Handle different CODEOWNERS pattern types + + // Remove leading slash from pattern if present (GitHub CODEOWNERS format) + pattern = strings.TrimPrefix(pattern, "/") + + // Normalize filePath by removing trailing slash (files shouldn't have trailing slashes) + filePath = strings.TrimSuffix(filePath, "/") + + // Empty pattern should not match anything + if pattern == "" { + return false + } + + // Handle directory patterns (ending with /), or just clear prefix matches + if strings.HasSuffix(pattern, "/") || strings.HasPrefix(filePath, pattern) || strings.HasPrefix(filePath+"/", pattern) { + return strings.HasPrefix(filePath, pattern) || strings.HasPrefix(filePath+"/", pattern) + } + // Handle glob patterns + if strings.Contains(pattern, "*") { + // Use filepath.Match for simple glob patterns + matched, err := filepath.Match(pattern, filePath) + if err == nil && matched { + return true + } + + // Also try matching just the filename for patterns like *.js + filename := filepath.Base(filePath) + matched, err = filepath.Match(pattern, filename) + if err == nil && matched { + return true + } + + // Handle directory + glob patterns like docs/*.md + if strings.Contains(pattern, "/") { + matched, err := filepath.Match(pattern, filePath) + return err == nil && matched + } + } + + // Exact match + if pattern == filePath { + return true + } + + // Check if pattern matches a parent directory + if strings.HasSuffix(filePath, pattern) { + return true + } + + return false +} diff --git a/pkg/github/codeowners_handler.go b/pkg/github/codeowners_handler.go new file mode 100644 index 00000000..7dff765e --- /dev/null +++ b/pkg/github/codeowners_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) handleCodeownersQuery(ctx context.Context, q backend.DataQuery) backend.DataResponse { + query := &models.CodeownersQuery{} + if err := UnmarshalQuery(q.JSON, query); err != nil { + return *err + } + return dfutil.FrameResponseWithError(s.Datasource.HandleCodeownersQuery(ctx, query, q)) +} + +// HandleCodeowners handles the plugin query for github codeowners +func (s *QueryHandler) HandleCodeowners(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) { + return &backend.QueryDataResponse{ + Responses: processQueries(ctx, req, s.handleCodeownersQuery), + }, nil +} diff --git a/pkg/github/codeowners_test.go b/pkg/github/codeowners_test.go new file mode 100644 index 00000000..87e5a643 --- /dev/null +++ b/pkg/github/codeowners_test.go @@ -0,0 +1,478 @@ +package github + +import ( + "context" + "testing" + + "github.com/grafana/github-datasource/pkg/models" + "github.com/grafana/github-datasource/pkg/testutil" + "github.com/grafana/grafana-plugin-sdk-go/data" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGetCodeowners(t *testing.T) { + ctx := context.Background() + + t.Run("successful retrieval", func(t *testing.T) { + opts := models.ListCodeownersOptions{ + Repository: "grafana", + Owner: "grafana", + FilePath: "", + } + + testVariables := testutil.GetTestVariablesFunction("owner", "name", "expression") + + client := testutil.NewTestClient(t, + testVariables, + func(t *testing.T, q interface{}) { + query := q.(*QueryGetCodeowners) + // Mock the CODEOWNERS content + query.Repository.Object.Blob.Text = `# This is a comment +* @global-owner1 @global-owner2 +/docs/ @docs-team +*.md @docs-team +src/backend/ @backend-team +src/frontend/ @frontend-team +` + }, + ) + + result, err := GetCodeowners(ctx, client, opts) + require.NoError(t, err) + + assert.Len(t, result.Entries, 5) + assert.Equal(t, "*", result.Entries[0].PathPattern) + assert.Equal(t, []string{"@global-owner1", "@global-owner2"}, result.Entries[0].Owners) + assert.Equal(t, "/docs/", result.Entries[1].PathPattern) + assert.Equal(t, []string{"@docs-team"}, result.Entries[1].Owners) + }) + + t.Run("no CODEOWNERS file found", func(t *testing.T) { + opts := models.ListCodeownersOptions{ + Repository: "grafana", + Owner: "grafana", + FilePath: "", + } + + testVariables := testutil.GetTestVariablesFunction("owner", "name", "expression") + + client := testutil.NewTestClient(t, + testVariables, + func(t *testing.T, q interface{}) { + query := q.(*QueryGetCodeowners) + // Mock empty CODEOWNERS content + query.Repository.Object.Blob.Text = "" + }, + ) + + result, err := GetCodeowners(ctx, client, opts) + require.NoError(t, err) + + assert.Empty(t, result.Entries) + }) + + t.Run("with file path filter", func(t *testing.T) { + opts := models.ListCodeownersOptions{ + Repository: "grafana", + Owner: "grafana", + FilePath: "src/backend/main.go", + } + + testVariables := testutil.GetTestVariablesFunction("owner", "name", "expression") + + client := testutil.NewTestClient(t, + testVariables, + func(t *testing.T, q interface{}) { + query := q.(*QueryGetCodeowners) + query.Repository.Object.Blob.Text = `* @global-owner +src/backend/ @backend-team +src/frontend/ @frontend-team +` + }, + ) + + result, err := GetCodeowners(ctx, client, opts) + require.NoError(t, err) + + // Should return only the most specific match (last matching pattern) + assert.Len(t, result.Entries, 1) + assert.Equal(t, "src/backend/", result.Entries[0].PathPattern) + assert.Equal(t, []string{"@backend-team"}, result.Entries[0].Owners) + }) +} + +func TestParseCodeowners(t *testing.T) { + t.Run("parse all entries", func(t *testing.T) { + content := `# This is a comment + +* @global-owner1 @global-owner2 +/docs/ @docs-team +*.md @docs-team +src/backend/ @backend-team +src/frontend/ @frontend-team + +# Another comment +config/ @config-team +` + + result := parseCodeowners(content, "") + + assert.Len(t, result.Entries, 6) + + // Check first entry + assert.Equal(t, "*", result.Entries[0].PathPattern) + assert.Equal(t, []string{"@global-owner1", "@global-owner2"}, result.Entries[0].Owners) + + // Check docs entry + assert.Equal(t, "/docs/", result.Entries[1].PathPattern) + assert.Equal(t, []string{"@docs-team"}, result.Entries[1].Owners) + + // Check markdown files + assert.Equal(t, "*.md", result.Entries[2].PathPattern) + assert.Equal(t, []string{"@docs-team"}, result.Entries[2].Owners) + + // Check config team + assert.Equal(t, "config/", result.Entries[5].PathPattern) + assert.Equal(t, []string{"@config-team"}, result.Entries[5].Owners) + }) + + t.Run("parse with file path - exact match", func(t *testing.T) { + content := `* @global-owner +README.md @docs-team +src/backend/ @backend-team +` + + result := parseCodeowners(content, "README.md") + + assert.Len(t, result.Entries, 1) + assert.Equal(t, "README.md", result.Entries[0].PathPattern) + assert.Equal(t, []string{"@docs-team"}, result.Entries[0].Owners) + }) + + t.Run("parse with file path - directory match", func(t *testing.T) { + content := `* @global-owner +docs/ @docs-team +src/backend/ @backend-team +` + + result := parseCodeowners(content, "src/backend/main.go") + + assert.Len(t, result.Entries, 1) + assert.Equal(t, "src/backend/", result.Entries[0].PathPattern) + assert.Equal(t, []string{"@backend-team"}, result.Entries[0].Owners) + }) + + t.Run("parse with file path - last match wins", func(t *testing.T) { + content := `* @global-owner +src/ @src-team +src/backend/ @backend-team +` + + result := parseCodeowners(content, "src/backend/main.go") + + // Should return the most specific match (last matching pattern) + assert.Len(t, result.Entries, 1) + assert.Equal(t, "src/backend/", result.Entries[0].PathPattern) + assert.Equal(t, []string{"@backend-team"}, result.Entries[0].Owners) + }) + + t.Run("parse with file path - no match", func(t *testing.T) { + content := `docs/ @docs-team +src/backend/ @backend-team +` + + result := parseCodeowners(content, "config/settings.yaml") + + assert.Empty(t, result.Entries) + }) + + t.Run("skip invalid lines", func(t *testing.T) { + content := `# Comment +* @global-owner +invalid-line-without-owner +docs/ @docs-team + +another-invalid +` + + result := parseCodeowners(content, "") + + assert.Len(t, result.Entries, 2) + assert.Equal(t, "*", result.Entries[0].PathPattern) + assert.Equal(t, "docs/", result.Entries[1].PathPattern) + }) + + t.Run("empty content", func(t *testing.T) { + result := parseCodeowners("", "") + assert.Empty(t, result.Entries) + }) + + t.Run("only comments", func(t *testing.T) { + content := `# This is a comment +# Another comment +` + result := parseCodeowners(content, "") + assert.Empty(t, result.Entries) + }) +} + +func TestMatchesPattern(t *testing.T) { + testCases := []struct { + name string + pattern string + filePath string + expected bool + }{ + // Exact matches + { + name: "exact match", + pattern: "README.md", + filePath: "README.md", + expected: true, + }, + { + name: "exact match with leading slash", + pattern: "/README.md", + filePath: "README.md", + expected: true, + }, + + // Directory patterns + { + name: "directory pattern with trailing slash", + pattern: "docs/", + filePath: "docs/README.md", + expected: true, + }, + { + name: "directory pattern without trailing slash", + pattern: "docs", + filePath: "docs/README.md", + expected: true, + }, + { + name: "nested directory pattern", + pattern: "src/backend/", + filePath: "src/backend/main.go", + expected: true, + }, + { + name: "directory pattern with leading slash", + pattern: "/src/backend/", + filePath: "src/backend/main.go", + expected: true, + }, + + // Glob patterns + { + name: "glob pattern for file extension", + pattern: "*.md", + filePath: "README.md", + expected: true, + }, + { + name: "glob pattern for file extension in subdirectory", + pattern: "*.md", + filePath: "docs/README.md", + expected: true, + }, + { + name: "glob pattern with directory", + pattern: "docs/*.md", + filePath: "docs/README.md", + expected: true, + }, + { + name: "glob pattern with wildcards", + pattern: "src/*/main.go", + filePath: "src/backend/main.go", + expected: true, + }, + + // Non-matches + { + name: "no match - different file", + pattern: "README.md", + filePath: "CHANGELOG.md", + expected: false, + }, + { + name: "no match - different directory", + pattern: "docs/", + filePath: "src/main.go", + expected: false, + }, + { + name: "no match - wrong extension", + pattern: "*.md", + filePath: "README.txt", + expected: false, + }, + { + name: "no match - glob pattern mismatch", + pattern: "docs/*.md", + filePath: "src/README.md", + expected: false, + }, + + // Edge cases + { + name: "empty pattern", + pattern: "", + filePath: "README.md", + expected: false, + }, + { + name: "empty file path", + pattern: "README.md", + filePath: "", + expected: false, + }, + { + name: "root pattern", + pattern: "*", + filePath: "README.md", + expected: true, + }, + { + name: "root pattern matches nested file", + pattern: "*", + filePath: "src/backend/main.go", + expected: true, + }, + { + name: "no match - partial directory name", + pattern: "backend", + filePath: "src/backend/main.go", + expected: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + result := matchesPattern(tc.pattern, tc.filePath) + assert.Equal(t, tc.expected, result, "Pattern: %s, FilePath: %s", tc.pattern, tc.filePath) + }) + } +} + +func TestCodeownersFrames(t *testing.T) { + t.Run("empty codeowners", func(t *testing.T) { + codeowners := Codeowners{ + Entries: []CodeownersEntry{}, + } + + frames := codeowners.Frames() + + assert.Len(t, frames, 1) + frame := frames[0] + assert.Equal(t, "codeowners", frame.Name) + assert.Len(t, frame.Fields, 2) + assert.Equal(t, "path_pattern", frame.Fields[0].Name) + assert.Equal(t, "owners", frame.Fields[1].Name) + assert.Equal(t, 0, frame.Fields[0].Len()) + assert.Equal(t, 0, frame.Fields[1].Len()) + }) + + t.Run("single entry", func(t *testing.T) { + codeowners := Codeowners{ + Entries: []CodeownersEntry{ + { + PathPattern: "*.md", + Owners: []string{"@docs-team", "@owner2"}, + }, + }, + } + + frames := codeowners.Frames() + + assert.Len(t, frames, 1) + frame := frames[0] + assert.Equal(t, "codeowners", frame.Name) + assert.Len(t, frame.Fields, 2) + + // Check path_pattern field + assert.Equal(t, "path_pattern", frame.Fields[0].Name) + assert.Equal(t, data.FieldTypeString, frame.Fields[0].Type()) + assert.Equal(t, 1, frame.Fields[0].Len()) + pathValue, ok := frame.Fields[0].At(0).(string) + assert.True(t, ok) + assert.Equal(t, "*.md", pathValue) + + // Check owners field + assert.Equal(t, "owners", frame.Fields[1].Name) + assert.Equal(t, data.FieldTypeString, frame.Fields[1].Type()) + assert.Equal(t, 1, frame.Fields[1].Len()) + ownersValue, ok := frame.Fields[1].At(0).(string) + assert.True(t, ok) + assert.Equal(t, "@docs-team, @owner2", ownersValue) + }) + + t.Run("multiple entries", func(t *testing.T) { + codeowners := Codeowners{ + Entries: []CodeownersEntry{ + { + PathPattern: "*", + Owners: []string{"@global-owner"}, + }, + { + PathPattern: "docs/", + Owners: []string{"@docs-team"}, + }, + { + PathPattern: "src/backend/", + Owners: []string{"@backend-team", "@lead-dev"}, + }, + }, + } + + frames := codeowners.Frames() + + assert.Len(t, frames, 1) + frame := frames[0] + assert.Equal(t, "codeowners", frame.Name) + assert.Len(t, frame.Fields, 2) + assert.Equal(t, 3, frame.Fields[0].Len()) + assert.Equal(t, 3, frame.Fields[1].Len()) + + // Check all values + expectedPatterns := []string{"*", "docs/", "src/backend/"} + expectedOwners := []string{"@global-owner", "@docs-team", "@backend-team, @lead-dev"} + + for i := 0; i < 3; i++ { + pathValue, ok := frame.Fields[0].At(i).(string) + assert.True(t, ok) + assert.Equal(t, expectedPatterns[i], pathValue) + + ownersValue, ok := frame.Fields[1].At(i).(string) + assert.True(t, ok) + assert.Equal(t, expectedOwners[i], ownersValue) + } + }) +} + +func TestCodeownersDataFrame(t *testing.T) { + codeowners := Codeowners{ + Entries: []CodeownersEntry{ + { + PathPattern: "*", + Owners: []string{"@global-owner1", "@global-owner2"}, + }, + { + PathPattern: "docs/", + Owners: []string{"@docs-team"}, + }, + { + PathPattern: "*.md", + Owners: []string{"@docs-team"}, + }, + { + PathPattern: "src/backend/", + Owners: []string{"@backend-team"}, + }, + }, + } + + testutil.CheckGoldenFramer(t, "codeowners", codeowners) +} diff --git a/pkg/github/datasource.go b/pkg/github/datasource.go index 574d5245..b71d6489 100644 --- a/pkg/github/datasource.go +++ b/pkg/github/datasource.go @@ -34,6 +34,39 @@ func (d *Datasource) HandleRepositoriesQuery(ctx context.Context, query *models. return GetAllRepositories(ctx, d.client, opt) } +// HandleCodeownersQuery is the query handler for getting the CODEOWNERS file from a GitHub repository +func (d *Datasource) HandleCodeownersQuery(ctx context.Context, query *models.CodeownersQuery, req backend.DataQuery) (dfutil.Framer, error) { + opt := models.ListCodeownersOptions{ + Owner: query.Owner, + Repository: query.Repository, + FilePath: query.Options.FilePath, + } + + return GetCodeowners(ctx, d.client, opt) +} + +// HandleTeamsQuery is the query handler for getting teams from a GitHub organization +func (d *Datasource) HandleTeamsQuery(ctx context.Context, query *models.TeamsQuery, req backend.DataQuery) (dfutil.Framer, error) { + opt := models.ListTeamsOptions{ + Organization: query.Owner, // Using Owner field as Organization for teams + Query: query.Options.Query, + } + + return GetTeams(ctx, d.client, opt) +} + +// HandleFileContributorsQuery is the query handler for listing contributors to a specific file +func (d *Datasource) HandleFileContributorsQuery(ctx context.Context, query *models.FileContributorsQuery, req backend.DataQuery) (dfutil.Framer, error) { + opt := models.ListFileContributorsOptions{ + Repository: query.Repository, + Owner: query.Owner, + FilePath: query.Options.FilePath, + Limit: query.Options.Limit, + } + + return GetFileContributors(ctx, d.client, opt) +} + // HandleIssuesQuery is the query handler for listing GitHub Issues func (d *Datasource) HandleIssuesQuery(ctx context.Context, query *models.IssuesQuery, req backend.DataQuery) (dfutil.Framer, error) { opt := models.IssueOptionsWithRepo(query.Options, query.Owner, query.Repository) diff --git a/pkg/github/file_contributors.go b/pkg/github/file_contributors.go new file mode 100644 index 00000000..2fd1641f --- /dev/null +++ b/pkg/github/file_contributors.go @@ -0,0 +1,166 @@ +package github + +import ( + "context" + "time" + + "github.com/grafana/github-datasource/pkg/models" + "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/grafana/grafana-plugin-sdk-go/data" + "github.com/shurcooL/githubv4" +) + +// FileContributorEntry represents a single file contributor entry +type FileContributorEntry struct { + Login string + Name string + Email string + CommitCount int + LastCommitDate time.Time +} + +// FileContributors is a list of file contributor entries +type FileContributors []FileContributorEntry + +// Frames converts the list of file contributors to a Grafana DataFrame +func (fc FileContributors) Frames() data.Frames { + backend.Logger.Info("Creating file contributors data frame", "entries_count", len(fc)) + + names := make([]string, len(fc)) + logins := make([]string, len(fc)) + + for i, contributor := range fc { + names[i] = contributor.Name + logins[i] = contributor.Login + } + + frame := data.NewFrame( + "file_contributors", + data.NewField("name", nil, names), + data.NewField("login", nil, logins), + ) + + return data.Frames{frame} +} + +// QueryGetFileContributors is the GraphQL query for retrieving commits for a specific file +type QueryGetFileContributors struct { + Repository struct { + Object struct { + Commit struct { + History struct { + Nodes []struct { + OID string + CommittedDate githubv4.DateTime + Author struct { + User struct { + Login string + Name string + Email string + } + Name string + Email string + } + } + PageInfo models.PageInfo + } `graphql:"history(first: 100, after: $cursor, path: $path)"` + } `graphql:"... on Commit"` + } `graphql:"object(expression: $ref)"` + } `graphql:"repository(name: $name, owner: $owner)"` +} + +// GetFileContributors retrieves the contributors for a specific file +func GetFileContributors(ctx context.Context, client models.Client, opts models.ListFileContributorsOptions) (FileContributors, error) { + backend.Logger.Info("GetFileContributors called", "owner", opts.Owner, "repo", opts.Repository, "filePath", opts.FilePath) + + // Default limit to 10 if not specified + limit := opts.Limit + if limit <= 0 { + limit = 10 + } + + // Track contributors by login to aggregate commits + contributorMap := make(map[string]*FileContributorEntry) + var cursor *githubv4.String + + variables := map[string]interface{}{ + "name": githubv4.String(opts.Repository), + "owner": githubv4.String(opts.Owner), + "ref": githubv4.String("HEAD"), // Use HEAD as default branch + "path": githubv4.String(opts.FilePath), + "cursor": cursor, + } + + // Fetch commits for the file + for { + q := &QueryGetFileContributors{} + if err := client.Query(ctx, q, variables); err != nil { + backend.Logger.Error("Failed to query file contributors", "error", err) + return nil, err + } + + for _, commit := range q.Repository.Object.Commit.History.Nodes { + var login, name, email string + + // Try to get user information from commit author + if commit.Author.User.Login != "" { + login = commit.Author.User.Login + name = commit.Author.User.Name + email = commit.Author.User.Email + } else { + // Fall back to commit author name/email if user is not available + name = commit.Author.Name + email = commit.Author.Email + login = commit.Author.Email // Use email as login fallback + } + + if login == "" { + continue // Skip commits without identifiable authors + } + + // Aggregate contributors + if existing, exists := contributorMap[login]; exists { + existing.CommitCount++ + if commit.CommittedDate.Time.After(existing.LastCommitDate) { + existing.LastCommitDate = commit.CommittedDate.Time + } + } else { + contributorMap[login] = &FileContributorEntry{ + Login: login, + Name: name, + Email: email, + CommitCount: 1, + LastCommitDate: commit.CommittedDate.Time, + } + } + } + + if !q.Repository.Object.Commit.History.PageInfo.HasNextPage { + break + } + variables["cursor"] = q.Repository.Object.Commit.History.PageInfo.EndCursor + } + + // Convert map to slice and sort by last commit date (most recent first) + contributors := make(FileContributors, 0, len(contributorMap)) + for _, contributor := range contributorMap { + contributors = append(contributors, *contributor) + } + + // Sort by last commit date (most recent first) + for i := 0; i < len(contributors)-1; i++ { + for j := i + 1; j < len(contributors); j++ { + if contributors[j].LastCommitDate.After(contributors[i].LastCommitDate) { + contributors[i], contributors[j] = contributors[j], contributors[i] + } + } + } + + // Limit results + if len(contributors) > limit { + contributors = contributors[:limit] + } + + backend.Logger.Info("Retrieved file contributors", "count", len(contributors)) + return contributors, nil +} diff --git a/pkg/github/file_contributors_handler.go b/pkg/github/file_contributors_handler.go new file mode 100644 index 00000000..b1b62963 --- /dev/null +++ b/pkg/github/file_contributors_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) handleFileContributorsQuery(ctx context.Context, q backend.DataQuery) backend.DataResponse { + query := &models.FileContributorsQuery{} + if err := UnmarshalQuery(q.JSON, query); err != nil { + return *err + } + return dfutil.FrameResponseWithError(s.Datasource.HandleFileContributorsQuery(ctx, query, q)) +} + +// HandleFileContributors handles the plugin query for github file contributors +func (s *QueryHandler) HandleFileContributors(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) { + return &backend.QueryDataResponse{ + Responses: processQueries(ctx, req, s.handleFileContributorsQuery), + }, nil +} diff --git a/pkg/github/query_handler.go b/pkg/github/query_handler.go index 74a29b5b..262bf3b2 100644 --- a/pkg/github/query_handler.go +++ b/pkg/github/query_handler.go @@ -61,6 +61,9 @@ 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.QueryTypeCodeowners, s.HandleCodeowners) + mux.HandleFunc(models.QueryTypeTeams, s.HandleTeams) + mux.HandleFunc(models.QueryTypeFileContributors, s.HandleFileContributors) return mux } diff --git a/pkg/github/teams.go b/pkg/github/teams.go new file mode 100644 index 00000000..d2c76e14 --- /dev/null +++ b/pkg/github/teams.go @@ -0,0 +1,254 @@ +package github + +import ( + "context" + "strings" + + "github.com/grafana/github-datasource/pkg/models" + "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/grafana/grafana-plugin-sdk-go/data" + "github.com/shurcooL/githubv4" +) + +// Member represents a team member +type Member struct { + Login string + Name string + AvatarURL string +} + +// QueryGetTeams is the GraphQL query for retrieving teams from an organization +type QueryGetTeams struct { + Organization struct { + Teams struct { + Nodes []struct { + ID githubv4.ID + Name string + Description string + Privacy githubv4.TeamPrivacy + Members struct { + TotalCount int64 + Nodes []struct { + Login string + Name string + AvatarURL string `graphql:"avatarUrl"` + } + } `graphql:"members(first: 100)"` + Repositories struct { + TotalCount int64 + } `graphql:"repositories"` + ParentTeam *struct { + ID githubv4.ID + Name string + } + URL string + } + PageInfo models.PageInfo + } `graphql:"teams(first: 100, after: $cursor)"` + } `graphql:"organization(login: $login)"` +} + +// QueryGetSingleTeam is the GraphQL query for retrieving a single team by slug +type QueryGetSingleTeam struct { + Organization struct { + Team struct { + ID githubv4.ID + Name string + Description string + Privacy githubv4.TeamPrivacy + Members struct { + TotalCount int64 + Nodes []struct { + Login string + Name string + AvatarURL string `graphql:"avatarUrl"` + } + } `graphql:"members(first: 100)"` + Repositories struct { + TotalCount int64 + } `graphql:"repositories"` + ParentTeam *struct { + ID githubv4.ID + Name string + } + URL string + } `graphql:"team(slug: $teamSlug)"` + } `graphql:"organization(login: $login)"` +} + +// TeamEntry represents a single team entry +type TeamEntry struct { + ID string + Name string + Description string + Privacy string + MembersCount int64 + Members []Member + ReposCount int64 + ParentTeam string + URL string +} + +// Teams is a list of team entries +type Teams []TeamEntry + +// Frames converts the list of teams to a Grafana DataFrame +func (t Teams) Frames() data.Frames { + backend.Logger.Info("Creating teams data frame", "entries_count", len(t)) + + names := make([]string, len(t)) + membersLogins := make([]string, len(t)) + + for i, team := range t { + names[i] = team.Name + + // Create comma-separated list of member logins + memberLogins := make([]string, len(team.Members)) + for j, member := range team.Members { + memberLogins[j] = member.Login + } + membersLogins[i] = strings.Join(memberLogins, ", ") + } + + frame := data.NewFrame( + "teams", + data.NewField("name", nil, names), + data.NewField("member_logins", nil, membersLogins), + ) + + return data.Frames{frame} +} + +// GetTeams retrieves teams from an organization +func GetTeams(ctx context.Context, client models.Client, opts models.ListTeamsOptions) (Teams, error) { + backend.Logger.Info("GetTeams called", "organization", opts.Organization, "query", opts.Query) + + // If query looks like a team slug (no spaces, alphanumeric with hyphens/underscores), try single team query first + if opts.Query != "" && isTeamSlug(opts.Query) { + team, err := getSingleTeam(ctx, client, opts.Organization, opts.Query) + if err == nil && team != nil { + backend.Logger.Info("Retrieved single team", "name", team.Name) + return Teams{*team}, nil + } + // If single team query fails, fall back to list and filter + backend.Logger.Info("Single team query failed, falling back to list and filter", "error", err) + } + + // Default behavior: list all teams and filter + return getAllTeams(ctx, client, opts) +} + +// isTeamSlug checks if a string looks like a GitHub team slug +func isTeamSlug(query string) bool { + // Team slugs typically don't contain spaces and are URL-friendly + return !strings.Contains(query, " ") && len(query) > 0 +} + +// getSingleTeam retrieves a specific team by slug +func getSingleTeam(ctx context.Context, client models.Client, organization, teamSlug string) (*TeamEntry, error) { + variables := map[string]interface{}{ + "login": githubv4.String(organization), + "teamSlug": githubv4.String(teamSlug), + } + + q := &QueryGetSingleTeam{} + if err := client.Query(ctx, q, variables); err != nil { + return nil, err + } + + team := q.Organization.Team + parentTeamName := "" + if team.ParentTeam != nil { + parentTeamName = team.ParentTeam.Name + } + + // Convert member nodes to Member structs + members := make([]Member, len(team.Members.Nodes)) + for i, member := range team.Members.Nodes { + members[i] = Member{ + Login: member.Login, + Name: member.Name, + AvatarURL: member.AvatarURL, + } + } + + teamEntry := &TeamEntry{ + ID: team.ID.(string), + Name: team.Name, + Description: team.Description, + Privacy: string(team.Privacy), + MembersCount: team.Members.TotalCount, + Members: members, + ReposCount: team.Repositories.TotalCount, + ParentTeam: parentTeamName, + URL: team.URL, + } + + return teamEntry, nil +} + +// getAllTeams retrieves all teams from an organization with optional filtering +func getAllTeams(ctx context.Context, client models.Client, opts models.ListTeamsOptions) (Teams, error) { + var teams []TeamEntry + var cursor *githubv4.String + + for { + variables := map[string]interface{}{ + "login": githubv4.String(opts.Organization), + "cursor": cursor, + } + + q := &QueryGetTeams{} + if err := client.Query(ctx, q, variables); err != nil { + backend.Logger.Error("Failed to query teams", "error", err) + return nil, err + } + + for _, team := range q.Organization.Teams.Nodes { + // Filter by query if specified + if opts.Query != "" { + if !strings.Contains(strings.ToLower(team.Name), strings.ToLower(opts.Query)) && + !strings.Contains(strings.ToLower(team.Description), strings.ToLower(opts.Query)) { + continue + } + } + + parentTeamName := "" + if team.ParentTeam != nil { + parentTeamName = team.ParentTeam.Name + } + + // Convert member nodes to Member structs + members := make([]Member, len(team.Members.Nodes)) + for i, member := range team.Members.Nodes { + members[i] = Member{ + Login: member.Login, + Name: member.Name, + AvatarURL: member.AvatarURL, + } + } + + teamEntry := TeamEntry{ + ID: team.ID.(string), + Name: team.Name, + Description: team.Description, + Privacy: string(team.Privacy), + MembersCount: team.Members.TotalCount, + Members: members, + ReposCount: team.Repositories.TotalCount, + ParentTeam: parentTeamName, + URL: team.URL, + } + + teams = append(teams, teamEntry) + } + + if !q.Organization.Teams.PageInfo.HasNextPage { + break + } + cursor = &q.Organization.Teams.PageInfo.EndCursor + } + + backend.Logger.Info("Retrieved teams", "count", len(teams)) + return teams, nil +} diff --git a/pkg/github/teams_handler.go b/pkg/github/teams_handler.go new file mode 100644 index 00000000..61c705fd --- /dev/null +++ b/pkg/github/teams_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) handleTeamsQuery(ctx context.Context, q backend.DataQuery) backend.DataResponse { + query := &models.TeamsQuery{} + if err := UnmarshalQuery(q.JSON, query); err != nil { + return *err + } + return dfutil.FrameResponseWithError(s.Datasource.HandleTeamsQuery(ctx, query, q)) +} + +// HandleTeams handles the plugin query for github teams +func (s *QueryHandler) HandleTeams(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) { + return &backend.QueryDataResponse{ + Responses: processQueries(ctx, req, s.handleTeamsQuery), + }, nil +} diff --git a/pkg/github/testdata/codeowners.golden.jsonc b/pkg/github/testdata/codeowners.golden.jsonc new file mode 100644 index 00000000..c8fa2a37 --- /dev/null +++ b/pkg/github/testdata/codeowners.golden.jsonc @@ -0,0 +1,60 @@ +// 🌟 This was machine generated. Do not edit. 🌟 +// +// Frame[0] +// Name: codeowners +// Dimensions: 2 Fields by 4 Rows +// +--------------------+--------------------------------+ +// | Name: path_pattern | Name: owners | +// | Labels: | Labels: | +// | Type: []string | Type: []string | +// +--------------------+--------------------------------+ +// | * | @global-owner1, @global-owner2 | +// | docs/ | @docs-team | +// | *.md | @docs-team | +// | src/backend/ | @backend-team | +// +--------------------+--------------------------------+ +// +// +// 🌟 This was machine generated. Do not edit. 🌟 +{ + "status": 200, + "frames": [ + { + "schema": { + "name": "codeowners", + "fields": [ + { + "name": "path_pattern", + "type": "string", + "typeInfo": { + "frame": "string" + } + }, + { + "name": "owners", + "type": "string", + "typeInfo": { + "frame": "string" + } + } + ] + }, + "data": { + "values": [ + [ + "*", + "docs/", + "*.md", + "src/backend/" + ], + [ + "@global-owner1, @global-owner2", + "@docs-team", + "@docs-team", + "@backend-team" + ] + ] + } + } + ] +} \ No newline at end of file diff --git a/pkg/models/codeowners.go b/pkg/models/codeowners.go new file mode 100644 index 00000000..cdb8cc69 --- /dev/null +++ b/pkg/models/codeowners.go @@ -0,0 +1,13 @@ +package models + +// ListCodeownersOptions is provided when querying the CODEOWNERS file from a repository +type ListCodeownersOptions 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"` + + // FilePath is an optional file path to find owners for (ex: "src/main.go") + FilePath string `json:"filePath"` +} diff --git a/pkg/models/file_contributors.go b/pkg/models/file_contributors.go new file mode 100644 index 00000000..2489885c --- /dev/null +++ b/pkg/models/file_contributors.go @@ -0,0 +1,25 @@ +package models + +// ListFileContributorsOptions are the options for listing contributors to a specific file +type ListFileContributorsOptions struct { + // Repository is the name of the repository being queried (ex: grafana) + Repository string `json:"repository"` + + // Owner is the owner of the repository being queried (ex: grafana) + Owner string `json:"owner"` + + // FilePath is the path to the file for which to get contributors + FilePath string `json:"filePath"` + + // Limit is the maximum number of contributors to return (default: 10) + Limit int `json:"limit"` +} + +// FileContributor represents a contributor to a specific file +type FileContributor struct { + Login string + Name string + Email string + CommitCount int + LastCommitDate string +} diff --git a/pkg/models/query.go b/pkg/models/query.go index f8a26759..ac3c1702 100644 --- a/pkg/models/query.go +++ b/pkg/models/query.go @@ -41,6 +41,12 @@ const ( QueryTypeWorkflowRuns = "Workflow_Runs" // QueryTypeCodeScanning is used when querying code scanning alerts for a repository QueryTypeCodeScanning = "Code_Scanning" + // QueryTypeCodeowners is used when querying the CODEOWNERS file for a repository + QueryTypeCodeowners = "Codeowners" + // QueryTypeTeams is used when querying teams for an organization + QueryTypeTeams = "Teams" + // QueryTypeFileContributors is used when querying contributors for a specific file + QueryTypeFileContributors = "File_Contributors" ) // Query refers to the structure of a query built using the QueryEditor. @@ -145,3 +151,21 @@ type CodeScanningQuery struct { Query Options CodeScanningOptions `json:"options"` } + +// CodeownersQuery is used when querying the CODEOWNERS file for a repository +type CodeownersQuery struct { + Query + Options ListCodeownersOptions `json:"options"` +} + +// TeamsQuery is used when querying teams for an organization +type TeamsQuery struct { + Query + Options ListTeamsOptions `json:"options"` +} + +// FileContributorsQuery is used when querying contributors for a specific file +type FileContributorsQuery struct { + Query + Options ListFileContributorsOptions `json:"options"` +} diff --git a/pkg/models/teams.go b/pkg/models/teams.go new file mode 100644 index 00000000..e84ec477 --- /dev/null +++ b/pkg/models/teams.go @@ -0,0 +1,25 @@ +package models + +// ListTeamsOptions is provided when listing teams for an organization +type ListTeamsOptions struct { + // Organization is the name of the organization being queried (ex: grafana) + Organization string `json:"organization"` + + // Query searches teams by name and description + Query string `json:"query"` +} + +// Team represents a GitHub team +type Team struct { + ID int64 + Name string + Description string + Privacy string + MembersCount int64 + ReposCount int64 + ParentTeam *Team + URL string + Organization struct { + Login string + } +} diff --git a/src/constants.ts b/src/constants.ts index 9032bb7f..2dbedd44 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -19,6 +19,9 @@ export enum QueryType { Workflows = 'Workflows', Workflow_Usage = 'Workflow_Usage', Workflow_Runs = 'Workflow_Runs', + Codeowners = 'Codeowners', + Teams = 'Teams', + File_Contributors = 'File_Contributors', } export const DefaultQueryType = QueryType.Issues; diff --git a/src/types/query.ts b/src/types/query.ts index 2788ad48..7cec1045 100644 --- a/src/types/query.ts +++ b/src/types/query.ts @@ -19,7 +19,10 @@ export interface GitHubQuery extends Indexable, DataQuery, RepositoryOptions { | ProjectsOptions | WorkflowsOptions | WorkflowUsageOptions - | WorkflowRunsOptions; + | WorkflowRunsOptions + | CodeownersOptions + | TeamsOptions + | FileContributorsOptions; } export interface Label { @@ -94,6 +97,19 @@ export interface ProjectsOptions extends Indexable { filters?: Filter[]; } +export interface CodeownersOptions extends Indexable { + filePath?: string; +} + +export interface TeamsOptions extends Indexable { + query?: string; +} + +export interface FileContributorsOptions extends Indexable { + filePath?: string; + limit?: number; +} + export interface GitHubVariableQuery extends GitHubQuery { key?: string; field?: string; diff --git a/src/views/QueryEditor.tsx b/src/views/QueryEditor.tsx index 7b5d0f53..8747e729 100644 --- a/src/views/QueryEditor.tsx +++ b/src/views/QueryEditor.tsx @@ -22,6 +22,9 @@ import QueryEditorWorkflows from './QueryEditorWorkflows'; import QueryEditorWorkflowUsage from './QueryEditorWorkflowUsage'; import QueryEditorWorkflowRuns from './QueryEditorWorkflowRuns'; import QueryEditorCodeScanning from './QueryEditorCodeScanning'; +import QueryEditorCodeowners from './QueryEditorCodeowners'; +import QueryEditorTeams from './QueryEditorTeams'; +import { QueryEditorFileContributors } from './QueryEditorFileContributors'; import { QueryType, DefaultQueryType } from '../constants'; import type { GitHubQuery } from '../types/query'; import type { GitHubDataSourceOptions } from '../types/config'; @@ -39,6 +42,11 @@ const queryEditors: { [QueryType.Repositories]: { component: (_: Props, onChange: (val: any) => void) => <>, }, + [QueryType.Codeowners]: { + component: (props: Props, onChange: (val: any) => void) => ( + + ), + }, [QueryType.Labels]: { component: (props: Props, onChange: (val: any) => void) => ( @@ -111,13 +119,44 @@ const queryEditors: { ), }, + [QueryType.Teams]: { + component: (props: Props, onChange: (val: any) => void) => ( + { + // Update both options and owner (organization) + props.onChange({ + ...props.query, + owner: value.organization, + options: { query: value.query } + }); + }} + /> + ), + }, + [QueryType.File_Contributors]: { + component: (props: Props, onChange: (val: any) => void) => ( + + ), + }, }; /* eslint-enable react/display-name */ const queryTypeOptions: Array> = Object.keys(QueryType).map((v) => { + let label = v.replace(/_/gi, ' '); + + // Add (beta) suffix for beta features + if (v === 'Teams' || v === 'File_Contributors' || v === 'Codeowners') { + label += ' (beta)'; + } + return { - label: v.replace(/_/gi, ' '), + label, value: v, }; }); @@ -183,7 +222,7 @@ const QueryEditor = (props: Props) => { ); }; -const nonRepoTypes = [QueryType.Projects, QueryType.ProjectItems]; +const nonRepoTypes = [QueryType.Projects, QueryType.ProjectItems, QueryType.Teams]; function hasRepo(qt?: string) { return !nonRepoTypes.includes(qt as QueryType); diff --git a/src/views/QueryEditorCodeowners.tsx b/src/views/QueryEditorCodeowners.tsx new file mode 100644 index 00000000..4dd8df31 --- /dev/null +++ b/src/views/QueryEditorCodeowners.tsx @@ -0,0 +1,38 @@ +import React, { useState } from 'react'; +import { Input, InlineField } from '@grafana/ui'; +import { RightColumnWidth, LeftColumnWidth } from './QueryEditor'; +import type { CodeownersOptions } from '../types/query'; + +interface Props extends CodeownersOptions { + onChange: (value: CodeownersOptions) => void; +} + +const QueryEditorCodeowners = (props: Props) => { + const [filePath, setFilePath] = useState(props.filePath || ''); + + const handleFilePathChange = (value: string) => { + setFilePath(value); + props.onChange({ ...props, filePath: value }); + }; + + return ( + <> + + setFilePath(el.currentTarget.value)} + onBlur={(el) => handleFilePathChange(el.currentTarget.value)} + /> + + + ); +}; + +export default QueryEditorCodeowners; diff --git a/src/views/QueryEditorFileContributors.tsx b/src/views/QueryEditorFileContributors.tsx new file mode 100644 index 00000000..dce5d7ea --- /dev/null +++ b/src/views/QueryEditorFileContributors.tsx @@ -0,0 +1,58 @@ +import React from 'react'; +import { Field, Input } from '@grafana/ui'; + +import { FileContributorsOptions } from '../types/query'; + +export interface QueryEditorFileContributorsProps { + options: FileContributorsOptions; + onOptionsChange: (options: FileContributorsOptions) => void; +} + +export const QueryEditorFileContributors: React.FC = ({ + options, + onOptionsChange, +}) => { + const onFilePathChange = (e: React.ChangeEvent) => { + onOptionsChange({ + ...options, + filePath: e.target.value, + }); + }; + + const onLimitChange = (e: React.ChangeEvent) => { + const limit = parseInt(e.target.value, 10); + onOptionsChange({ + ...options, + limit: isNaN(limit) ? 10 : limit, + }); + }; + + return ( + <> + + + + + + + + ); +}; \ No newline at end of file diff --git a/src/views/QueryEditorTeams.tsx b/src/views/QueryEditorTeams.tsx new file mode 100644 index 00000000..65815bf5 --- /dev/null +++ b/src/views/QueryEditorTeams.tsx @@ -0,0 +1,60 @@ +import React, { useState } from 'react'; +import { Input, InlineField } from '@grafana/ui'; +import { RightColumnWidth, LeftColumnWidth } from './QueryEditor'; +import type { TeamsOptions } from '../types/query'; + +interface Props extends TeamsOptions { + organization?: string; + onChange: (value: TeamsOptions & { organization?: string }) => void; +} + +const QueryEditorTeams = (props: Props) => { + const [organization, setOrganization] = useState(props.organization || ''); + const [query, setQuery] = useState(props.query || ''); + + const handleOrganizationChange = (value: string) => { + setOrganization(value); + props.onChange({ ...props, organization: value }); + }; + + const handleQueryChange = (value: string) => { + setQuery(value); + props.onChange({ ...props, query: value }); + }; + + return ( + <> + + setOrganization(el.currentTarget.value)} + onBlur={(el) => handleOrganizationChange(el.currentTarget.value)} + /> + + + + setQuery(el.currentTarget.value)} + onBlur={(el) => handleQueryChange(el.currentTarget.value)} + /> + + + ); +}; + +export default QueryEditorTeams; \ No newline at end of file