Skip to content

Commit b5c7797

Browse files
committed
Adding copilot metrics
1 parent 6e259a7 commit b5c7797

20 files changed

+25334
-2180
lines changed

package-lock.json

Lines changed: 21714 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pkg/github/client/client.go

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -358,3 +358,77 @@ func (client *Client) getWorkflowRuns(ctx context.Context, owner, repo, workflow
358358

359359
return workflowRuns, response.NextPage, nil
360360
}
361+
362+
// GetCopilotMetrics sends a request to the GitHub REST API to get Copilot metrics for an organization
363+
func (client *Client) GetCopilotMetrics(ctx context.Context, organization string, opts models.ListCopilotMetricsOptions) ([]models.CopilotMetrics, *googlegithub.Response, error) {
364+
u := fmt.Sprintf("orgs/%s/copilot/metrics", organization)
365+
366+
// Build query parameters
367+
params := url.Values{}
368+
if opts.Since != nil {
369+
params.Add("since", opts.Since.Format("2006-01-02"))
370+
}
371+
if opts.Until != nil {
372+
params.Add("until", opts.Until.Format("2006-01-02"))
373+
}
374+
if opts.Page > 0 {
375+
params.Add("page", strconv.Itoa(opts.Page))
376+
}
377+
if opts.PerPage > 0 {
378+
params.Add("per_page", strconv.Itoa(opts.PerPage))
379+
}
380+
381+
if len(params) > 0 {
382+
u += "?" + params.Encode()
383+
}
384+
385+
req, err := client.restClient.NewRequest("GET", u, nil)
386+
if err != nil {
387+
return nil, nil, err
388+
}
389+
390+
var metrics []models.CopilotMetrics
391+
resp, err := client.restClient.Do(ctx, req, &metrics)
392+
if err != nil {
393+
return nil, resp, addErrorSourceToError(err, resp)
394+
}
395+
396+
return metrics, resp, nil
397+
}
398+
399+
// GetCopilotMetricsTeam sends a request to the GitHub REST API to get Copilot metrics for a team
400+
func (client *Client) GetCopilotMetricsTeam(ctx context.Context, organization, teamSlug string, opts models.ListCopilotMetricsTeamOptions) ([]models.CopilotMetrics, *googlegithub.Response, error) {
401+
u := fmt.Sprintf("orgs/%s/team/%s/copilot/metrics", organization, teamSlug)
402+
403+
// Build query parameters
404+
params := url.Values{}
405+
if opts.Since != nil {
406+
params.Add("since", opts.Since.Format("2006-01-02"))
407+
}
408+
if opts.Until != nil {
409+
params.Add("until", opts.Until.Format("2006-01-02"))
410+
}
411+
if opts.Page > 0 {
412+
params.Add("page", strconv.Itoa(opts.Page))
413+
}
414+
if opts.PerPage > 0 {
415+
params.Add("per_page", strconv.Itoa(opts.PerPage))
416+
}
417+
418+
if len(params) > 0 {
419+
u += "?" + params.Encode()
420+
}
421+
422+
req, err := client.restClient.NewRequest("GET", u, nil)
423+
if err != nil {
424+
return nil, nil, err
425+
}
426+
427+
var metrics []models.CopilotMetrics
428+
resp, err := client.restClient.Do(ctx, req, &metrics)
429+
if err != nil {
430+
return nil, resp, addErrorSourceToError(err, resp)
431+
}
432+
433+
return metrics, resp, nil
434+
}

pkg/github/codescanning_test.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,14 @@ func (m *mockClient) ListAlertsForOrg(ctx context.Context, owner string, opts *g
5252
return m.mockAlerts, m.mockResponse, nil
5353
}
5454

55+
func (m *mockClient) GetCopilotMetrics(ctx context.Context, organization string, opts models.ListCopilotMetricsOptions) ([]models.CopilotMetrics, *googlegithub.Response, error) {
56+
return nil, nil, nil
57+
}
58+
59+
func (m *mockClient) GetCopilotMetricsTeam(ctx context.Context, organization, teamSlug string, opts models.ListCopilotMetricsTeamOptions) ([]models.CopilotMetrics, *googlegithub.Response, error) {
60+
return nil, nil, nil
61+
}
62+
5563
func TestGetCodeScanningAlerts(t *testing.T) {
5664
var (
5765
ctx = context.Background()

pkg/github/copilot_metrics.go

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
package github
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"time"
8+
9+
"github.com/grafana/github-datasource/pkg/dfutil"
10+
"github.com/grafana/github-datasource/pkg/models"
11+
"github.com/grafana/grafana-plugin-sdk-go/data"
12+
)
13+
14+
// CopilotMetricsResponse represents the response from GitHub's Copilot metrics API
15+
type CopilotMetricsResponse []models.CopilotMetrics
16+
17+
// GetCopilotMetrics retrieves Copilot metrics for an organization
18+
func GetCopilotMetrics(ctx context.Context, client models.Client, opts models.ListCopilotMetricsOptions) (dfutil.Framer, error) {
19+
metrics, _, err := client.GetCopilotMetrics(ctx, opts.Organization, opts)
20+
if err != nil {
21+
return nil, err
22+
}
23+
24+
return copilotMetricsToDataFrame(CopilotMetricsResponse(metrics), "copilot_metrics")
25+
}
26+
27+
// GetCopilotMetricsTeam retrieves Copilot metrics for a team
28+
func GetCopilotMetricsTeam(ctx context.Context, client models.Client, opts models.ListCopilotMetricsTeamOptions) (dfutil.Framer, error) {
29+
metrics, _, err := client.GetCopilotMetricsTeam(ctx, opts.Organization, opts.TeamSlug, opts)
30+
if err != nil {
31+
return nil, err
32+
}
33+
34+
return copilotMetricsToDataFrame(CopilotMetricsResponse(metrics), "copilot_metrics_team")
35+
}
36+
37+
// copilotMetricsToDataFrame converts Copilot metrics to a Grafana data frame
38+
func copilotMetricsToDataFrame(metrics CopilotMetricsResponse, name string) (dfutil.Framer, error) {
39+
return metrics, nil
40+
}
41+
42+
// Frames converts the list of copilot metrics to a Grafana DataFrame
43+
func (c CopilotMetricsResponse) Frames() data.Frames {
44+
frame := data.NewFrame("copilot_metrics")
45+
46+
if len(c) == 0 {
47+
return data.Frames{frame}
48+
}
49+
50+
// Create time series for the main metrics
51+
dates := make([]time.Time, len(c))
52+
totalActiveUsers := make([]int64, len(c))
53+
totalEngagedUsers := make([]int64, len(c))
54+
ideCompletionUsers := make([]int64, len(c))
55+
ideChatUsers := make([]int64, len(c))
56+
dotcomChatUsers := make([]int64, len(c))
57+
dotcomPRUsers := make([]int64, len(c))
58+
59+
for i, metric := range c {
60+
date, err := time.Parse("2006-01-02", metric.Date)
61+
if err != nil {
62+
// If date parsing fails, use a default date
63+
date = time.Now().AddDate(0, 0, -i)
64+
}
65+
66+
dates[i] = date
67+
totalActiveUsers[i] = int64(metric.TotalActiveUsers)
68+
totalEngagedUsers[i] = int64(metric.TotalEngagedUsers)
69+
ideCompletionUsers[i] = int64(metric.CopilotIDECodeCompletions.TotalEngagedUsers)
70+
ideChatUsers[i] = int64(metric.CopilotIDEChat.TotalEngagedUsers)
71+
dotcomChatUsers[i] = int64(metric.CopilotDotcomChat.TotalEngagedUsers)
72+
dotcomPRUsers[i] = int64(metric.CopilotDotcomPullRequests.TotalEngagedUsers)
73+
}
74+
75+
// Add fields to the frame
76+
frame.Fields = append(frame.Fields, data.NewField("time", nil, dates))
77+
frame.Fields = append(frame.Fields, data.NewField("total_active_users", nil, totalActiveUsers))
78+
frame.Fields = append(frame.Fields, data.NewField("total_engaged_users", nil, totalEngagedUsers))
79+
frame.Fields = append(frame.Fields, data.NewField("ide_completion_users", nil, ideCompletionUsers))
80+
frame.Fields = append(frame.Fields, data.NewField("ide_chat_users", nil, ideChatUsers))
81+
frame.Fields = append(frame.Fields, data.NewField("dotcom_chat_users", nil, dotcomChatUsers))
82+
frame.Fields = append(frame.Fields, data.NewField("dotcom_pr_users", nil, dotcomPRUsers))
83+
84+
// Add language breakdown data if available
85+
if len(c) > 0 && len(c[0].CopilotIDECodeCompletions.Languages) > 0 {
86+
langData := make(map[string][]int64)
87+
for _, metric := range c {
88+
for _, lang := range metric.CopilotIDECodeCompletions.Languages {
89+
if langData[lang.Name] == nil {
90+
langData[lang.Name] = make([]int64, len(c))
91+
}
92+
}
93+
}
94+
95+
for i, metric := range c {
96+
for langName := range langData {
97+
found := false
98+
for _, lang := range metric.CopilotIDECodeCompletions.Languages {
99+
if lang.Name == langName {
100+
langData[langName][i] = int64(lang.TotalEngagedUsers)
101+
found = true
102+
break
103+
}
104+
}
105+
if !found {
106+
langData[langName][i] = 0
107+
}
108+
}
109+
}
110+
111+
for langName, users := range langData {
112+
fieldName := fmt.Sprintf("language_%s_users", langName)
113+
frame.Fields = append(frame.Fields, data.NewField(fieldName, nil, users))
114+
}
115+
}
116+
117+
// Add editor breakdown data if available
118+
if len(c) > 0 && len(c[0].CopilotIDECodeCompletions.Editors) > 0 {
119+
editorData := make(map[string][]int64)
120+
for _, metric := range c {
121+
for _, editor := range metric.CopilotIDECodeCompletions.Editors {
122+
if editorData[editor.Name] == nil {
123+
editorData[editor.Name] = make([]int64, len(c))
124+
}
125+
}
126+
}
127+
128+
for i, metric := range c {
129+
for editorName := range editorData {
130+
found := false
131+
for _, editor := range metric.CopilotIDECodeCompletions.Editors {
132+
if editor.Name == editorName {
133+
editorData[editorName][i] = int64(editor.TotalEngagedUsers)
134+
found = true
135+
break
136+
}
137+
}
138+
if !found {
139+
editorData[editorName][i] = 0
140+
}
141+
}
142+
}
143+
144+
for editorName, users := range editorData {
145+
fieldName := fmt.Sprintf("editor_%s_users", editorName)
146+
frame.Fields = append(frame.Fields, data.NewField(fieldName, nil, users))
147+
}
148+
}
149+
150+
// Add detailed JSON for complex nested data
151+
detailedData := make([]string, len(c))
152+
for i, metric := range c {
153+
jsonData, err := json.Marshal(metric)
154+
if err != nil {
155+
detailedData[i] = ""
156+
} else {
157+
detailedData[i] = string(jsonData)
158+
}
159+
}
160+
frame.Fields = append(frame.Fields, data.NewField("detailed_metrics", nil, detailedData))
161+
162+
return data.Frames{frame}
163+
}

pkg/github/copilot_metrics_handler.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
package github
2+
3+
import (
4+
"context"
5+
6+
"github.com/grafana/github-datasource/pkg/dfutil"
7+
"github.com/grafana/github-datasource/pkg/models"
8+
"github.com/grafana/grafana-plugin-sdk-go/backend"
9+
)
10+
11+
func (s *QueryHandler) handleCopilotMetricsQuery(ctx context.Context, q backend.DataQuery) backend.DataResponse {
12+
query := &models.CopilotMetricsQuery{}
13+
if err := UnmarshalQuery(q.JSON, query); err != nil {
14+
return *err
15+
}
16+
return dfutil.FrameResponseWithError(s.Datasource.HandleCopilotMetricsQuery(ctx, query, q))
17+
}
18+
19+
// HandleCopilotMetrics handles the plugin query for GitHub Copilot metrics
20+
func (s *QueryHandler) HandleCopilotMetrics(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) {
21+
return &backend.QueryDataResponse{
22+
Responses: processQueries(ctx, req, s.handleCopilotMetricsQuery),
23+
}, nil
24+
}
25+
26+
func (s *QueryHandler) handleCopilotMetricsTeamQuery(ctx context.Context, q backend.DataQuery) backend.DataResponse {
27+
query := &models.CopilotMetricsTeamQuery{}
28+
if err := UnmarshalQuery(q.JSON, query); err != nil {
29+
return *err
30+
}
31+
return dfutil.FrameResponseWithError(s.Datasource.HandleCopilotMetricsTeamQuery(ctx, query, q))
32+
}
33+
34+
// HandleCopilotMetricsTeam handles the plugin query for GitHub Copilot metrics for a team
35+
func (s *QueryHandler) HandleCopilotMetricsTeam(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) {
36+
return &backend.QueryDataResponse{
37+
Responses: processQueries(ctx, req, s.handleCopilotMetricsTeamQuery),
38+
}, nil
39+
}

pkg/github/copilot_metrics_test.go

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
package github
2+
3+
import (
4+
"testing"
5+
6+
"github.com/grafana/github-datasource/pkg/models"
7+
"github.com/stretchr/testify/assert"
8+
)
9+
10+
func TestCopilotMetricsResponse_Frames(t *testing.T) {
11+
// Test empty response
12+
t.Run("empty response", func(t *testing.T) {
13+
response := CopilotMetricsResponse{}
14+
frames := response.Frames()
15+
assert.Len(t, frames, 1)
16+
assert.Equal(t, "copilot_metrics", frames[0].Name)
17+
assert.Len(t, frames[0].Fields, 0)
18+
})
19+
20+
// Test response with data
21+
t.Run("response with data", func(t *testing.T) {
22+
response := CopilotMetricsResponse{
23+
{
24+
Date: "2025-01-01",
25+
TotalActiveUsers: 100,
26+
TotalEngagedUsers: 75,
27+
CopilotIDECodeCompletions: models.CopilotIDECodeCompletions{
28+
TotalEngagedUsers: 50,
29+
Languages: []models.CopilotLanguageMetrics{
30+
{Name: "go", TotalEngagedUsers: 25},
31+
{Name: "typescript", TotalEngagedUsers: 20},
32+
},
33+
Editors: []models.CopilotEditorMetrics{
34+
{Name: "vscode", TotalEngagedUsers: 45},
35+
{Name: "neovim", TotalEngagedUsers: 5},
36+
},
37+
},
38+
CopilotIDEChat: models.CopilotIDEChat{
39+
TotalEngagedUsers: 30,
40+
},
41+
CopilotDotcomChat: models.CopilotDotcomChat{
42+
TotalEngagedUsers: 25,
43+
},
44+
CopilotDotcomPullRequests: models.CopilotDotcomPullRequests{
45+
TotalEngagedUsers: 15,
46+
},
47+
},
48+
}
49+
50+
frames := response.Frames()
51+
assert.Len(t, frames, 1)
52+
frame := frames[0]
53+
54+
assert.Equal(t, "copilot_metrics", frame.Name)
55+
56+
// Check that we have the expected fields
57+
fieldNames := make([]string, len(frame.Fields))
58+
for i, field := range frame.Fields {
59+
fieldNames[i] = field.Name
60+
}
61+
62+
expectedFields := []string{
63+
"time",
64+
"total_active_users",
65+
"total_engaged_users",
66+
"ide_completion_users",
67+
"ide_chat_users",
68+
"dotcom_chat_users",
69+
"dotcom_pr_users",
70+
"language_go_users",
71+
"language_typescript_users",
72+
"editor_vscode_users",
73+
"editor_neovim_users",
74+
"detailed_metrics",
75+
}
76+
77+
for _, expected := range expectedFields {
78+
assert.Contains(t, fieldNames, expected, "Field %s should be present", expected)
79+
}
80+
81+
// Check that all fields have the correct length
82+
for _, field := range frame.Fields {
83+
assert.Equal(t, 1, field.Len(), "Field %s should have length 1", field.Name)
84+
}
85+
})
86+
}

0 commit comments

Comments
 (0)