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