Skip to content
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion cspell.config.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@
"vals",
"vladimirdotk",
"Wrapf",
"confg" //this is unfortunately a typo in a file name that is not easy to fix
"confg", //this is unfortunately a typo in a file name that is not easy to fix
"neovim"
]
}
42 changes: 42 additions & 0 deletions pkg/github/client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -358,3 +358,45 @@ func (client *Client) getWorkflowRuns(ctx context.Context, owner, repo, workflow

return workflowRuns, response.NextPage, nil
}

// GetCopilotMetrics sends a request to the GitHub REST API to get Copilot metrics for an organization or team
func (client *Client) GetCopilotMetrics(ctx context.Context, organization string, opts models.ListCopilotMetricsOptions) ([]models.CopilotMetrics, *googlegithub.Response, error) {
var u string
if opts.TeamSlug != "" {
u = fmt.Sprintf("orgs/%s/team/%s/copilot/metrics", organization, opts.TeamSlug)
} else {
u = fmt.Sprintf("orgs/%s/copilot/metrics", organization)
}

// Build query parameters
params := url.Values{}
if opts.Since != nil {
params.Add("since", opts.Since.Format("2006-01-02"))
}
if opts.Until != nil {
params.Add("until", opts.Until.Format("2006-01-02"))
}
if opts.Page > 0 {
params.Add("page", strconv.Itoa(opts.Page))
}
if opts.PerPage > 0 {
params.Add("per_page", strconv.Itoa(opts.PerPage))
}

if len(params) > 0 {
u += "?" + params.Encode()
}

req, err := client.restClient.NewRequest("GET", u, nil)
if err != nil {
return nil, nil, err
}

var metrics []models.CopilotMetrics
resp, err := client.restClient.Do(ctx, req, &metrics)
if err != nil {
return nil, resp, addErrorSourceToError(err, resp)
}

return metrics, resp, nil
}
4 changes: 4 additions & 0 deletions pkg/github/codescanning_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,10 @@ func (m *mockClient) ListAlertsForOrg(ctx context.Context, owner string, opts *g
return m.mockAlerts, m.mockResponse, nil
}

func (m *mockClient) GetCopilotMetrics(ctx context.Context, organization string, opts models.ListCopilotMetricsOptions) ([]models.CopilotMetrics, *googlegithub.Response, error) {
return nil, nil, nil
}

func TestGetCodeScanningAlerts(t *testing.T) {
var (
ctx = context.Background()
Expand Down
158 changes: 158 additions & 0 deletions pkg/github/copilot_metrics.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
package github

import (
"context"
"encoding/json"
"fmt"
"time"

"github.com/grafana/github-datasource/pkg/dfutil"
"github.com/grafana/github-datasource/pkg/models"
"github.com/grafana/grafana-plugin-sdk-go/data"
)

// CopilotMetricsResponse represents the response from GitHub's Copilot metrics API
type CopilotMetricsResponse []models.CopilotMetrics

// GetCopilotMetrics retrieves Copilot metrics for an organization or team
func GetCopilotMetrics(ctx context.Context, client models.Client, opts models.ListCopilotMetricsOptions) (dfutil.Framer, error) {
metrics, _, err := client.GetCopilotMetrics(ctx, opts.Organization, opts)
if err != nil {
return nil, err
}

frameName := "copilot_metrics"
if opts.TeamSlug != "" {
frameName = "copilot_metrics_team"
}

return copilotMetricsToDataFrame(CopilotMetricsResponse(metrics), frameName)
}

// copilotMetricsToDataFrame converts Copilot metrics to a Grafana data frame
func copilotMetricsToDataFrame(metrics CopilotMetricsResponse, name string) (dfutil.Framer, error) {
return metrics, nil
}

// Frames converts the list of copilot metrics to a Grafana DataFrame
func (c CopilotMetricsResponse) Frames() data.Frames {
frame := data.NewFrame("copilot_metrics")

if len(c) == 0 {
return data.Frames{frame}
}

// Create time series for the main metrics
dates := make([]time.Time, len(c))
totalActiveUsers := make([]int64, len(c))
totalEngagedUsers := make([]int64, len(c))
ideCompletionUsers := make([]int64, len(c))
ideChatUsers := make([]int64, len(c))
dotcomChatUsers := make([]int64, len(c))
dotcomPRUsers := make([]int64, len(c))

for i, metric := range c {
date, err := time.Parse("2006-01-02", metric.Date)
if err != nil {
// If date parsing fails, use a default date
date = time.Now().AddDate(0, 0, -i)
}

dates[i] = date
totalActiveUsers[i] = int64(metric.TotalActiveUsers)
totalEngagedUsers[i] = int64(metric.TotalEngagedUsers)
ideCompletionUsers[i] = int64(metric.CopilotIDECodeCompletions.TotalEngagedUsers)
ideChatUsers[i] = int64(metric.CopilotIDEChat.TotalEngagedUsers)
dotcomChatUsers[i] = int64(metric.CopilotDotcomChat.TotalEngagedUsers)
dotcomPRUsers[i] = int64(metric.CopilotDotcomPullRequests.TotalEngagedUsers)
}

// Add fields to the frame
frame.Fields = append(frame.Fields, data.NewField("time", nil, dates))
frame.Fields = append(frame.Fields, data.NewField("total_active_users", nil, totalActiveUsers))
frame.Fields = append(frame.Fields, data.NewField("total_engaged_users", nil, totalEngagedUsers))
frame.Fields = append(frame.Fields, data.NewField("ide_completion_users", nil, ideCompletionUsers))
frame.Fields = append(frame.Fields, data.NewField("ide_chat_users", nil, ideChatUsers))
frame.Fields = append(frame.Fields, data.NewField("dotcom_chat_users", nil, dotcomChatUsers))
frame.Fields = append(frame.Fields, data.NewField("dotcom_pr_users", nil, dotcomPRUsers))

// Add language breakdown data if available
if len(c) > 0 && len(c[0].CopilotIDECodeCompletions.Languages) > 0 {
langData := make(map[string][]int64)
for _, metric := range c {
for _, lang := range metric.CopilotIDECodeCompletions.Languages {
if langData[lang.Name] == nil {
langData[lang.Name] = make([]int64, len(c))
}
}
}

for i, metric := range c {
for langName := range langData {
found := false
for _, lang := range metric.CopilotIDECodeCompletions.Languages {
if lang.Name == langName {
langData[langName][i] = int64(lang.TotalEngagedUsers)
found = true
break
}
}
if !found {
langData[langName][i] = 0
}
}
}

for langName, users := range langData {
fieldName := fmt.Sprintf("language_%s_users", langName)
frame.Fields = append(frame.Fields, data.NewField(fieldName, nil, users))
}
}

// Add editor breakdown data if available
if len(c) > 0 && len(c[0].CopilotIDECodeCompletions.Editors) > 0 {
editorData := make(map[string][]int64)
for _, metric := range c {
for _, editor := range metric.CopilotIDECodeCompletions.Editors {
if editorData[editor.Name] == nil {
editorData[editor.Name] = make([]int64, len(c))
}
}
}

for i, metric := range c {
for editorName := range editorData {
found := false
for _, editor := range metric.CopilotIDECodeCompletions.Editors {
if editor.Name == editorName {
editorData[editorName][i] = int64(editor.TotalEngagedUsers)
found = true
break
}
}
if !found {
editorData[editorName][i] = 0
}
}
}

for editorName, users := range editorData {
fieldName := fmt.Sprintf("editor_%s_users", editorName)
frame.Fields = append(frame.Fields, data.NewField(fieldName, nil, users))
}
}

// Add detailed JSON for complex nested data
detailedData := make([]string, len(c))
for i, metric := range c {
jsonData, err := json.Marshal(metric)
if err != nil {
detailedData[i] = ""
} else {
detailedData[i] = string(jsonData)
}
}
frame.Fields = append(frame.Fields, data.NewField("detailed_metrics", nil, detailedData))

return data.Frames{frame}
}
24 changes: 24 additions & 0 deletions pkg/github/copilot_metrics_handler.go
Original file line number Diff line number Diff line change
@@ -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) handleCopilotMetricsQuery(ctx context.Context, q backend.DataQuery) backend.DataResponse {
query := &models.CopilotMetricsQuery{}
if err := UnmarshalQuery(q.JSON, query); err != nil {
return *err
}
return dfutil.FrameResponseWithError(s.Datasource.HandleCopilotMetricsQuery(ctx, query, q))
}

// HandleCopilotMetrics handles the plugin query for GitHub Copilot metrics for an organization or team
func (s *QueryHandler) HandleCopilotMetrics(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) {
return &backend.QueryDataResponse{
Responses: processQueries(ctx, req, s.handleCopilotMetricsQuery),
}, nil
}
86 changes: 86 additions & 0 deletions pkg/github/copilot_metrics_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
package github

import (
"testing"

"github.com/grafana/github-datasource/pkg/models"
"github.com/stretchr/testify/assert"
)

func TestCopilotMetricsResponse_Frames(t *testing.T) {
// Test empty response
t.Run("empty response", func(t *testing.T) {
response := CopilotMetricsResponse{}
frames := response.Frames()
assert.Len(t, frames, 1)
assert.Equal(t, "copilot_metrics", frames[0].Name)
assert.Len(t, frames[0].Fields, 0)
})

// Test response with data
t.Run("response with data", func(t *testing.T) {
response := CopilotMetricsResponse{
{
Date: "2025-01-01",
TotalActiveUsers: 100,
TotalEngagedUsers: 75,
CopilotIDECodeCompletions: models.CopilotIDECodeCompletions{
TotalEngagedUsers: 50,
Languages: []models.CopilotLanguageMetrics{
{Name: "go", TotalEngagedUsers: 25},
{Name: "typescript", TotalEngagedUsers: 20},
},
Editors: []models.CopilotEditorMetrics{
{Name: "vscode", TotalEngagedUsers: 45},
{Name: "neovim", TotalEngagedUsers: 5},
},
},
CopilotIDEChat: models.CopilotIDEChat{
TotalEngagedUsers: 30,
},
CopilotDotcomChat: models.CopilotDotcomChat{
TotalEngagedUsers: 25,
},
CopilotDotcomPullRequests: models.CopilotDotcomPullRequests{
TotalEngagedUsers: 15,
},
},
}

frames := response.Frames()
assert.Len(t, frames, 1)
frame := frames[0]

assert.Equal(t, "copilot_metrics", frame.Name)

// Check that we have the expected fields
fieldNames := make([]string, len(frame.Fields))
for i, field := range frame.Fields {
fieldNames[i] = field.Name
}

expectedFields := []string{
"time",
"total_active_users",
"total_engaged_users",
"ide_completion_users",
"ide_chat_users",
"dotcom_chat_users",
"dotcom_pr_users",
"language_go_users",
"language_typescript_users",
"editor_vscode_users",
"editor_neovim_users",
"detailed_metrics",
}

for _, expected := range expectedFields {
assert.Contains(t, fieldNames, expected, "Field %s should be present", expected)
}

// Check that all fields have the correct length
for _, field := range frame.Fields {
assert.Equal(t, 1, field.Len(), "Field %s should have length 1", field.Name)
}
})
}
6 changes: 6 additions & 0 deletions pkg/github/datasource.go
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,12 @@ func (d *Datasource) HandleWorkflowRunsQuery(ctx context.Context, query *models.
return GetWorkflowRuns(ctx, d.client, opt, req.TimeRange)
}

// HandleCopilotMetricsQuery is the query handler for listing GitHub Copilot metrics for an organization or team
func (d *Datasource) HandleCopilotMetricsQuery(ctx context.Context, query *models.CopilotMetricsQuery, req backend.DataQuery) (dfutil.Framer, error) {
opt := models.CopilotMetricsOptionsWithOrg(query.Options, query.Owner)
return GetCopilotMetrics(ctx, d.client, opt)
}

// CheckHealth is the health check for GitHub
func (d *Datasource) CheckHealth(ctx context.Context, req *backend.CheckHealthRequest) (*backend.CheckHealthResult, error) {
_, err := GetAllRepositories(ctx, d.client, models.ListRepositoriesOptions{
Expand Down
1 change: 1 addition & 0 deletions pkg/github/query_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ 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.QueryTypeCopilotMetrics, s.HandleCopilotMetrics)

return mux
}
1 change: 1 addition & 0 deletions pkg/models/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,5 @@ type Client interface {
GetWorkflowRuns(ctx context.Context, owner, repo, workflow string, branch string, timeRange backend.TimeRange) ([]*googlegithub.WorkflowRun, error)
ListAlertsForRepo(ctx context.Context, owner, repo string, opts *googlegithub.AlertListOptions) ([]*googlegithub.Alert, *googlegithub.Response, error)
ListAlertsForOrg(ctx context.Context, owner string, opts *googlegithub.AlertListOptions) ([]*googlegithub.Alert, *googlegithub.Response, error)
GetCopilotMetrics(ctx context.Context, organization string, opts ListCopilotMetricsOptions) ([]CopilotMetrics, *googlegithub.Response, error)
}
Loading