Skip to content

Adding copilot metrics #487

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 6 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all 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