From db77a9cfabf92a77e7c4fd3db969fa94533d5bc9 Mon Sep 17 00:00:00 2001 From: ewega Date: Fri, 20 Feb 2026 18:44:12 +0300 Subject: [PATCH 1/4] feat(gh-copilot): add backend plugin --- backend/plugins/gh-copilot/README.md | 108 ++++++ .../plugins/gh-copilot/api/blueprint_v200.go | 87 +++++ backend/plugins/gh-copilot/api/connection.go | 106 ++++++ .../plugins/gh-copilot/api/connection_test.go | 91 +++++ backend/plugins/gh-copilot/api/init.go | 60 +++ backend/plugins/gh-copilot/api/remote_api.go | 191 ++++++++++ backend/plugins/gh-copilot/api/scope.go | 59 +++ .../plugins/gh-copilot/api/scope_config.go | 108 ++++++ .../plugins/gh-copilot/api/test_connection.go | 70 ++++ backend/plugins/gh-copilot/copilot.go | 45 +++ .../e2e/metrics/language_breakdown.csv | 5 + .../raw_tables/_raw_copilot_metrics.csv | 3 + .../metrics/raw_tables/_raw_copilot_seats.csv | 3 + .../_tool_copilot_org_metrics.csv | 3 + .../snapshot_tables/_tool_copilot_seats.csv | 3 + .../plugins/gh-copilot/e2e/metrics_test.go | 87 +++++ .../gh-copilot/impl/connection_helper.go | 20 + .../gh-copilot/impl/connection_helper_test.go | 20 + backend/plugins/gh-copilot/impl/impl.go | 170 +++++++++ backend/plugins/gh-copilot/impl/options.go | 25 ++ .../plugins/gh-copilot/models/connection.go | 121 ++++++ .../gh-copilot/models/connection_auth_test.go | 65 ++++ .../gh-copilot/models/enterprise_metrics.go | 149 ++++++++ .../gh-copilot/models/language_metrics.go | 45 +++ .../migrationscripts/20250100_initialize.go | 47 +++ ...add_raw_data_origin_to_language_metrics.go | 44 +++ .../20260105_add_raw_data_origin_to_seats.go | 44 +++ .../20260116_add_name_fields_to_scopes.go | 44 +++ .../20260121_add_scope_configs.go | 51 +++ ...212_add_pr_fields_to_enterprise_metrics.go | 349 ++++++++++++++++++ .../20260212_v2_usage_metrics.go | 349 ++++++++++++++++++ .../models/migrationscripts/register.go | 33 ++ backend/plugins/gh-copilot/models/models.go | 49 +++ .../plugins/gh-copilot/models/models_test.go | 62 ++++ .../plugins/gh-copilot/models/org_metrics.go | 55 +++ backend/plugins/gh-copilot/models/scope.go | 96 +++++ .../plugins/gh-copilot/models/scope_config.go | 58 +++ .../gh-copilot/models/scope_config_test.go | 98 +++++ .../plugins/gh-copilot/models/scope_test.go | 168 +++++++++ backend/plugins/gh-copilot/models/seat.go | 45 +++ .../plugins/gh-copilot/models/user_metrics.go | 131 +++++++ .../service/connection_test_helper.go | 221 +++++++++++ .../service/connection_test_helper_test.go | 52 +++ .../plugins/gh-copilot/tasks/api_client.go | 83 +++++ .../gh-copilot/tasks/api_client_test.go | 77 ++++ .../tasks/enterprise_metrics_collector.go | 176 +++++++++ .../tasks/enterprise_metrics_extractor.go | 298 +++++++++++++++ .../plugins/gh-copilot/tasks/github_errors.go | 63 ++++ .../gh-copilot/tasks/github_errors_test.go | 57 +++ .../tasks/metrics_collector_test.go | 69 ++++ .../gh-copilot/tasks/metrics_extractor.go | 224 +++++++++++ backend/plugins/gh-copilot/tasks/options.go | 24 ++ .../gh-copilot/tasks/org_metrics_collector.go | 121 ++++++ backend/plugins/gh-copilot/tasks/register.go | 36 ++ .../tasks/report_download_helper.go | 174 +++++++++ .../plugins/gh-copilot/tasks/retry_after.go | 44 +++ .../gh-copilot/tasks/seat_collector.go | 129 +++++++ .../gh-copilot/tasks/seat_collector_test.go | 54 +++ .../gh-copilot/tasks/seat_extractor.go | 115 ++++++ backend/plugins/gh-copilot/tasks/subtasks.go | 90 +++++ backend/plugins/gh-copilot/tasks/task_data.go | 26 ++ .../tasks/user_metrics_collector.go | 130 +++++++ .../tasks/user_metrics_extractor.go | 249 +++++++++++++ backend/plugins/table_info_test.go | 2 + 64 files changed, 5881 insertions(+) create mode 100644 backend/plugins/gh-copilot/README.md create mode 100644 backend/plugins/gh-copilot/api/blueprint_v200.go create mode 100644 backend/plugins/gh-copilot/api/connection.go create mode 100644 backend/plugins/gh-copilot/api/connection_test.go create mode 100644 backend/plugins/gh-copilot/api/init.go create mode 100644 backend/plugins/gh-copilot/api/remote_api.go create mode 100644 backend/plugins/gh-copilot/api/scope.go create mode 100644 backend/plugins/gh-copilot/api/scope_config.go create mode 100644 backend/plugins/gh-copilot/api/test_connection.go create mode 100644 backend/plugins/gh-copilot/copilot.go create mode 100644 backend/plugins/gh-copilot/e2e/metrics/language_breakdown.csv create mode 100644 backend/plugins/gh-copilot/e2e/metrics/raw_tables/_raw_copilot_metrics.csv create mode 100644 backend/plugins/gh-copilot/e2e/metrics/raw_tables/_raw_copilot_seats.csv create mode 100644 backend/plugins/gh-copilot/e2e/metrics/snapshot_tables/_tool_copilot_org_metrics.csv create mode 100644 backend/plugins/gh-copilot/e2e/metrics/snapshot_tables/_tool_copilot_seats.csv create mode 100644 backend/plugins/gh-copilot/e2e/metrics_test.go create mode 100644 backend/plugins/gh-copilot/impl/connection_helper.go create mode 100644 backend/plugins/gh-copilot/impl/connection_helper_test.go create mode 100644 backend/plugins/gh-copilot/impl/impl.go create mode 100644 backend/plugins/gh-copilot/impl/options.go create mode 100644 backend/plugins/gh-copilot/models/connection.go create mode 100644 backend/plugins/gh-copilot/models/connection_auth_test.go create mode 100644 backend/plugins/gh-copilot/models/enterprise_metrics.go create mode 100644 backend/plugins/gh-copilot/models/language_metrics.go create mode 100644 backend/plugins/gh-copilot/models/migrationscripts/20250100_initialize.go create mode 100644 backend/plugins/gh-copilot/models/migrationscripts/20260104_add_raw_data_origin_to_language_metrics.go create mode 100644 backend/plugins/gh-copilot/models/migrationscripts/20260105_add_raw_data_origin_to_seats.go create mode 100644 backend/plugins/gh-copilot/models/migrationscripts/20260116_add_name_fields_to_scopes.go create mode 100644 backend/plugins/gh-copilot/models/migrationscripts/20260121_add_scope_configs.go create mode 100644 backend/plugins/gh-copilot/models/migrationscripts/20260212_add_pr_fields_to_enterprise_metrics.go create mode 100644 backend/plugins/gh-copilot/models/migrationscripts/20260212_v2_usage_metrics.go create mode 100644 backend/plugins/gh-copilot/models/migrationscripts/register.go create mode 100644 backend/plugins/gh-copilot/models/models.go create mode 100644 backend/plugins/gh-copilot/models/models_test.go create mode 100644 backend/plugins/gh-copilot/models/org_metrics.go create mode 100644 backend/plugins/gh-copilot/models/scope.go create mode 100644 backend/plugins/gh-copilot/models/scope_config.go create mode 100644 backend/plugins/gh-copilot/models/scope_config_test.go create mode 100644 backend/plugins/gh-copilot/models/scope_test.go create mode 100644 backend/plugins/gh-copilot/models/seat.go create mode 100644 backend/plugins/gh-copilot/models/user_metrics.go create mode 100644 backend/plugins/gh-copilot/service/connection_test_helper.go create mode 100644 backend/plugins/gh-copilot/service/connection_test_helper_test.go create mode 100644 backend/plugins/gh-copilot/tasks/api_client.go create mode 100644 backend/plugins/gh-copilot/tasks/api_client_test.go create mode 100644 backend/plugins/gh-copilot/tasks/enterprise_metrics_collector.go create mode 100644 backend/plugins/gh-copilot/tasks/enterprise_metrics_extractor.go create mode 100644 backend/plugins/gh-copilot/tasks/github_errors.go create mode 100644 backend/plugins/gh-copilot/tasks/github_errors_test.go create mode 100644 backend/plugins/gh-copilot/tasks/metrics_collector_test.go create mode 100644 backend/plugins/gh-copilot/tasks/metrics_extractor.go create mode 100644 backend/plugins/gh-copilot/tasks/options.go create mode 100644 backend/plugins/gh-copilot/tasks/org_metrics_collector.go create mode 100644 backend/plugins/gh-copilot/tasks/register.go create mode 100644 backend/plugins/gh-copilot/tasks/report_download_helper.go create mode 100644 backend/plugins/gh-copilot/tasks/retry_after.go create mode 100644 backend/plugins/gh-copilot/tasks/seat_collector.go create mode 100644 backend/plugins/gh-copilot/tasks/seat_collector_test.go create mode 100644 backend/plugins/gh-copilot/tasks/seat_extractor.go create mode 100644 backend/plugins/gh-copilot/tasks/subtasks.go create mode 100644 backend/plugins/gh-copilot/tasks/task_data.go create mode 100644 backend/plugins/gh-copilot/tasks/user_metrics_collector.go create mode 100644 backend/plugins/gh-copilot/tasks/user_metrics_extractor.go diff --git a/backend/plugins/gh-copilot/README.md b/backend/plugins/gh-copilot/README.md new file mode 100644 index 00000000000..644f1833d6b --- /dev/null +++ b/backend/plugins/gh-copilot/README.md @@ -0,0 +1,108 @@ +# GitHub Copilot Plugin (Adoption Metrics) + +This plugin ingests GitHub Copilot **organization-level adoption metrics** (daily usage and seat assignments) and provides a Grafana dashboard for adoption trends. + +It follows the same structure/patterns as other DevLake data-source plugins (notably `backend/plugins/q_dev`). + +## What it collects + +**Phase 1 endpoints** (GitHub Copilot REST API): + +- `GET /orgs/{org}/copilot/billing` +- `GET /orgs/{org}/copilot/billing/seats` +- `GET /orgs/{org}/copilot/metrics` + +**Stored data (tool layer)**: + +- `_tool_copilot_org_metrics` (daily aggregates) +- `_tool_copilot_language_metrics` (editor/language breakdown) +- `_tool_copilot_seats` (seat assignments) + +## Data flow (high level) + +```mermaid +flowchart LR + GH[GitHub Copilot REST API] + RAW[(Raw tables\n_raw_copilot_*)] + TOOL[(Tool tables\n_tool_copilot_*)] + GRAF[Grafana Dashboard\nGitHub Copilot Adoption] + + GH --> RAW --> TOOL --> GRAF +``` + +## Repository layout + +- `api/` – REST layer for connections/scopes +- `impl/` – plugin meta, options, connection helpers +- `models/` – tool-layer models + migrations +- `tasks/` – collectors/extractors and pipeline registration +- `e2e/` – E2E fixtures and golden CSV assertions +- `docs/` – documentation assets + +## Setup + +### Prerequisites + +- GitHub Copilot Business or Enterprise enabled for the target organization +- A token that can access GitHub Copilot billing/metrics (classic PAT with `manage_billing:copilot` works) + +### 1) Create a connection + +1. DevLake UI → **Data Integrations → Add Connection → GitHub Copilot** +2. Fill in: + - **Name**: e.g. `GitHub Copilot Octodemo` + - **Endpoint**: defaults to `https://api.github.com` + - **Organization**: GitHub org slug + - **Token**: PAT with required scope +3. Click **Test Connection** (calls `GET /orgs/{org}/copilot/billing`). +4. Save the connection. + +### 2) Create a scope + +Add an organization scope for that connection. For Phase 1, `implementationDate` is optional. + +### 3) Create a blueprint (recipe) + +Use a blueprint plan like: + +```json +[ + [ + { + "plugin": "gh-copilot", + "options": { + "connectionId": 1, + "scopeId": "octodemo" + } + } + ] +] +``` + +Run the blueprint daily to keep metrics up to date. + +## Dashboard + +The Grafana dashboard JSON is in `grafana/dashboards/copilot/adoption.json`. + +Link: `grafana/dashboards/copilot/adoption.json` + +## Error handling guidance + +- **403 Forbidden** → token missing required billing/metrics scope, or org lacks GitHub Copilot access +- **404 Not Found** → incorrect org slug, or GitHub Copilot endpoints unavailable for the org +- **422 Unprocessable Entity** → GitHub Copilot metrics disabled in GitHub org settings + +- **429 Too Many Requests** → respect `Retry-After`; collectors implement backoff/retry + +Tokens are sanitized before persisting. When patching an existing connection, omit the token to retain the encrypted value already stored in DevLake. + +## Limitations (Phase 1) + +- Metrics endpoint is limited to a rolling **100-day** window (GitHub API constraint) +- GitHub enforces a privacy threshold (often **≥ 5 engaged users**) and may omit daily data +- Enterprise download endpoints and per-user metrics (JSONL exports) are intentionally deferred to Phase 2+ + +## More docs + +- Spec quickstart: `specs/001-copilot-metrics-plugin/quickstart.md` diff --git a/backend/plugins/gh-copilot/api/blueprint_v200.go b/backend/plugins/gh-copilot/api/blueprint_v200.go new file mode 100644 index 00000000000..97db93a5022 --- /dev/null +++ b/backend/plugins/gh-copilot/api/blueprint_v200.go @@ -0,0 +1,87 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package api + +import ( + "github.com/apache/incubator-devlake/core/errors" + coreModels "github.com/apache/incubator-devlake/core/models" + "github.com/apache/incubator-devlake/core/plugin" + helper "github.com/apache/incubator-devlake/helpers/pluginhelper/api" + "github.com/apache/incubator-devlake/helpers/srvhelper" + "github.com/apache/incubator-devlake/plugins/gh-copilot/models" + "github.com/apache/incubator-devlake/plugins/gh-copilot/tasks" +) + +// MakeDataSourcePipelinePlanV200 generates the pipeline plan for blueprint v2.0.0. +func MakeDataSourcePipelinePlanV200( + subtaskMetas []plugin.SubTaskMeta, + connectionId uint64, + bpScopes []*coreModels.BlueprintScope, +) (coreModels.PipelinePlan, []plugin.Scope, errors.Error) { + // load connection and scopes from the db + _, err := dsHelper.ConnSrv.FindByPk(connectionId) + if err != nil { + return nil, nil, err + } + // map blueprint scopes (scopeId/scopeConfigId) to concrete stored scopes + scopeDetails, err := dsHelper.ScopeSrv.MapScopeDetails(connectionId, bpScopes) + if err != nil { + return nil, nil, err + } + + plan, err := makeDataSourcePipelinePlanV200(subtaskMetas, scopeDetails) + if err != nil { + return nil, nil, err + } + + // Copilot metrics are org-level and currently don't map to a standard domain-layer top-level entity. + // Return an empty scope list to avoid adding meaningless project mappings. + return plan, nil, nil +} + +func makeDataSourcePipelinePlanV200( + subtaskMetas []plugin.SubTaskMeta, + scopeDetails []*srvhelper.ScopeDetail[models.GhCopilotScope, models.GhCopilotScopeConfig], +) (coreModels.PipelinePlan, errors.Error) { + plan := make(coreModels.PipelinePlan, len(scopeDetails)) + for i, scopeDetail := range scopeDetails { + stage := plan[i] + if stage == nil { + stage = coreModels.PipelineStage{} + } + + scope := scopeDetail.Scope + task, err := helper.MakePipelinePlanTask( + "gh-copilot", + subtaskMetas, + nil, + tasks.GhCopilotOptions{ + ConnectionId: scope.ConnectionId, + ScopeId: scope.Id, + }, + ) + if err != nil { + return nil, err + } + + stage = append(stage, task) + plan[i] = stage + } + + return plan, nil +} diff --git a/backend/plugins/gh-copilot/api/connection.go b/backend/plugins/gh-copilot/api/connection.go new file mode 100644 index 00000000000..fe02f16d0cb --- /dev/null +++ b/backend/plugins/gh-copilot/api/connection.go @@ -0,0 +1,106 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package api + +import ( + "github.com/apache/incubator-devlake/core/errors" + "github.com/apache/incubator-devlake/core/plugin" + helper "github.com/apache/incubator-devlake/helpers/pluginhelper/api" + "github.com/apache/incubator-devlake/plugins/gh-copilot/models" +) + +// PostConnections creates a new Copilot connection. +func PostConnections(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + connection := &models.GhCopilotConnection{} + if err := helper.Decode(input.Body, connection, vld); err != nil { + return nil, err + } + + connection.Normalize() + if err := validateConnection(connection); err != nil { + return nil, err + } + + if err := connectionHelper.Create(connection, input); err != nil { + return nil, err + } + return &plugin.ApiResourceOutput{Body: connection.Sanitize()}, nil +} + +func PatchConnection(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + connection := &models.GhCopilotConnection{} + if err := connectionHelper.First(connection, input.Params); err != nil { + return nil, err + } + if err := (&models.GhCopilotConnection{}).MergeFromRequest(connection, input.Body); err != nil { + return nil, errors.Convert(err) + } + connection.Normalize() + if err := validateConnection(connection); err != nil { + return nil, err + } + if err := connectionHelper.SaveWithCreateOrUpdate(connection); err != nil { + return nil, err + } + return &plugin.ApiResourceOutput{Body: connection.Sanitize()}, nil +} + +func DeleteConnection(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + conn := &models.GhCopilotConnection{} + output, err := connectionHelper.Delete(conn, input) + if err != nil { + return output, err + } + output.Body = conn.Sanitize() + return output, nil +} + +func ListConnections(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + var connections []models.GhCopilotConnection + if err := connectionHelper.List(&connections); err != nil { + return nil, err + } + for i := range connections { + connections[i] = connections[i].Sanitize() + } + return &plugin.ApiResourceOutput{Body: connections}, nil +} + +func GetConnection(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + connection := &models.GhCopilotConnection{} + if err := connectionHelper.First(connection, input.Params); err != nil { + return nil, err + } + return &plugin.ApiResourceOutput{Body: connection.Sanitize()}, nil +} + +func validateConnection(connection *models.GhCopilotConnection) errors.Error { + if connection == nil { + return errors.BadInput.New("connection is required") + } + if connection.Organization == "" && !connection.HasEnterprise() { + return errors.BadInput.New("either enterprise or organization is required") + } + if connection.Token == "" { + return errors.BadInput.New("token is required") + } + if connection.RateLimitPerHour < 0 { + return errors.BadInput.New("rateLimitPerHour must be non-negative") + } + return nil +} diff --git a/backend/plugins/gh-copilot/api/connection_test.go b/backend/plugins/gh-copilot/api/connection_test.go new file mode 100644 index 00000000000..aa16ff6cfbf --- /dev/null +++ b/backend/plugins/gh-copilot/api/connection_test.go @@ -0,0 +1,91 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package api + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/apache/incubator-devlake/plugins/gh-copilot/models" +) + +func TestValidateConnection_Success(t *testing.T) { + connection := &models.GhCopilotConnection{ + GhCopilotConn: models.GhCopilotConn{ + Organization: "octodemo", + Token: "ghp_example", + }, + } + connection.Normalize() + + err := validateConnection(connection) + assert.NoError(t, err) +} + +func TestValidateConnection_MissingOrganization(t *testing.T) { + connection := &models.GhCopilotConnection{ + GhCopilotConn: models.GhCopilotConn{ + Token: "ghp_example", + }, + } + + err := validateConnection(connection) + assert.Error(t, err) + assert.Contains(t, err.Error(), "either enterprise or organization is required") +} + +func TestValidateConnection_EnterpriseOnly(t *testing.T) { + connection := &models.GhCopilotConnection{ + GhCopilotConn: models.GhCopilotConn{ + Enterprise: "my-enterprise", + Token: "ghp_example", + }, + } + connection.Normalize() + + err := validateConnection(connection) + assert.NoError(t, err) +} + +func TestValidateConnection_MissingToken(t *testing.T) { + connection := &models.GhCopilotConnection{ + GhCopilotConn: models.GhCopilotConn{ + Organization: "octodemo", + Token: "", + }, + } + + err := validateConnection(connection) + assert.Error(t, err) + assert.Contains(t, err.Error(), "token is required") +} + +func TestValidateConnection_InvalidRateLimit(t *testing.T) { + connection := &models.GhCopilotConnection{ + GhCopilotConn: models.GhCopilotConn{ + Organization: "octodemo", + Token: "ghp_example", + RateLimitPerHour: -1, + }, + } + + err := validateConnection(connection) + assert.Error(t, err) + assert.Contains(t, err.Error(), "rateLimitPerHour must be non-negative") +} diff --git a/backend/plugins/gh-copilot/api/init.go b/backend/plugins/gh-copilot/api/init.go new file mode 100644 index 00000000000..2cc920919df --- /dev/null +++ b/backend/plugins/gh-copilot/api/init.go @@ -0,0 +1,60 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package api + +import ( + "github.com/go-playground/validator/v10" + + "github.com/apache/incubator-devlake/core/context" + "github.com/apache/incubator-devlake/core/plugin" + helper "github.com/apache/incubator-devlake/helpers/pluginhelper/api" + "github.com/apache/incubator-devlake/plugins/gh-copilot/models" +) + +var ( + basicRes context.BasicRes + vld *validator.Validate + connectionHelper *helper.ConnectionApiHelper + dsHelper *helper.DsHelper[models.GhCopilotConnection, models.GhCopilotScope, models.GhCopilotScopeConfig] + raProxy *helper.DsRemoteApiProxyHelper[models.GhCopilotConnection] + raScopeList *helper.DsRemoteApiScopeListHelper[models.GhCopilotConnection, models.GhCopilotScope, GhCopilotRemotePagination] + raScopeSearch *helper.DsRemoteApiScopeSearchHelper[models.GhCopilotConnection, models.GhCopilotScope] +) + +// Init stores basic resources and configures shared helpers for API handlers. +func Init(br context.BasicRes, meta plugin.PluginMeta) { + basicRes = br + vld = validator.New() + connectionHelper = helper.NewConnectionHelper(basicRes, vld, meta.Name()) + dsHelper = helper.NewDataSourceHelper[ + models.GhCopilotConnection, models.GhCopilotScope, models.GhCopilotScopeConfig, + ]( + basicRes, + meta.Name(), + []string{"id", "organization"}, + func(c models.GhCopilotConnection) models.GhCopilotConnection { + c.Normalize() + return c.Sanitize() + }, + func(s models.GhCopilotScope) models.GhCopilotScope { return s }, + nil, + ) + raProxy = helper.NewDsRemoteApiProxyHelper[models.GhCopilotConnection](dsHelper.ConnApi.ModelApiHelper) + raScopeList = helper.NewDsRemoteApiScopeListHelper[models.GhCopilotConnection, models.GhCopilotScope, GhCopilotRemotePagination](raProxy, listGhCopilotRemoteScopes) + raScopeSearch = helper.NewDsRemoteApiScopeSearchHelper[models.GhCopilotConnection, models.GhCopilotScope](raProxy, searchGhCopilotRemoteScopes) +} diff --git a/backend/plugins/gh-copilot/api/remote_api.go b/backend/plugins/gh-copilot/api/remote_api.go new file mode 100644 index 00000000000..0ce2697f5ed --- /dev/null +++ b/backend/plugins/gh-copilot/api/remote_api.go @@ -0,0 +1,191 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package api + +import ( + "net/url" + "strconv" + "strings" + + "github.com/apache/incubator-devlake/core/errors" + "github.com/apache/incubator-devlake/core/plugin" + "github.com/apache/incubator-devlake/helpers/pluginhelper/api" + dsmodels "github.com/apache/incubator-devlake/helpers/pluginhelper/api/models" + "github.com/apache/incubator-devlake/plugins/gh-copilot/models" +) + +// GhCopilotRemotePagination is a placeholder for scope list pagination. +// Copilot scopes are organization-level and currently return a single entry. +type GhCopilotRemotePagination struct { + Page int `json:"page"` +} + +func listGhCopilotRemoteScopes( + connection *models.GhCopilotConnection, + _ plugin.ApiClient, + _ string, + _ GhCopilotRemotePagination, +) ( + children []dsmodels.DsRemoteApiScopeListEntry[models.GhCopilotScope], + nextPage *GhCopilotRemotePagination, + err errors.Error, +) { + if connection == nil { + return nil, nil, errors.BadInput.New("connection is required") + } + organization := strings.TrimSpace(connection.Organization) + + if connection.HasEnterprise() { + enterprise := strings.TrimSpace(connection.Enterprise) + if enterprise != "" { + scopeId := enterprise + if organization != "" { + scopeId = enterprise + "/" + organization + } + children = append(children, dsmodels.DsRemoteApiScopeListEntry[models.GhCopilotScope]{ + Type: api.RAS_ENTRY_TYPE_SCOPE, + Id: scopeId, + Name: scopeId, + FullName: scopeId, + Data: &models.GhCopilotScope{ + Id: scopeId, + Organization: organization, + Enterprise: enterprise, + Name: scopeId, + FullName: scopeId, + }, + }) + } + } else if organization != "" { + children = append(children, dsmodels.DsRemoteApiScopeListEntry[models.GhCopilotScope]{ + Type: api.RAS_ENTRY_TYPE_SCOPE, + Id: organization, + Name: organization, + FullName: organization, + Data: &models.GhCopilotScope{ + Id: organization, + Organization: organization, + Name: organization, + FullName: organization, + }, + }) + } + + return children, nil, nil +} + +func searchGhCopilotRemoteScopes( + apiClient plugin.ApiClient, + params *dsmodels.DsRemoteApiScopeSearchParams, +) ( + children []dsmodels.DsRemoteApiScopeListEntry[models.GhCopilotScope], + err errors.Error, +) { + if params == nil { + return []dsmodels.DsRemoteApiScopeListEntry[models.GhCopilotScope]{}, nil + } + query := strings.TrimSpace(params.Search) + if query == "" { + return []dsmodels.DsRemoteApiScopeListEntry[models.GhCopilotScope]{}, nil + } + page := params.Page + pageSize := params.PageSize + if page <= 0 { + page = 1 + } + if pageSize <= 0 { + pageSize = 50 + } + + resp, err := apiClient.Get( + "user/orgs", + url.Values{ + "page": []string{strconv.Itoa(page)}, + "per_page": []string{strconv.Itoa(pageSize)}, + }, + nil, + ) + if err != nil { + return nil, err + } + + var orgs []struct { + Login string `json:"login"` + } + if err := api.UnmarshalResponse(resp, &orgs); err != nil { + return nil, err + } + + queryLower := strings.ToLower(query) + for _, org := range orgs { + orgName := strings.TrimSpace(org.Login) + if orgName == "" { + continue + } + if !strings.Contains(strings.ToLower(orgName), queryLower) { + continue + } + children = append(children, dsmodels.DsRemoteApiScopeListEntry[models.GhCopilotScope]{ + Type: api.RAS_ENTRY_TYPE_SCOPE, + Id: orgName, + Name: orgName, + FullName: orgName, + Data: &models.GhCopilotScope{ + Id: orgName, + Organization: orgName, + Name: orgName, + FullName: orgName, + }, + }) + } + + return children, nil +} + +// RemoteScopes list all available scopes (organizations) for this connection +// @Summary list all available scopes (organizations) for this connection +// @Description list all available scopes (organizations) for this connection +// @Tags plugins/gh-copilot +// @Accept application/json +// @Param connectionId path int false "connection ID" +// @Param groupId query string false "group ID" +// @Param pageToken query string false "page Token" +// @Success 200 {object} dsmodels.DsRemoteApiScopeList[models.GhCopilotScope] +// @Failure 400 {object} shared.ApiBody "Bad Request" +// @Failure 500 {object} shared.ApiBody "Internal Error" +// @Router /plugins/gh-copilot/connections/{connectionId}/remote-scopes [GET] +func RemoteScopes(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + return raScopeList.Get(input) +} + +// SearchRemoteScopes searches organization scopes for this connection +// @Summary searches organization scopes for this connection +// @Description searches organization scopes for this connection +// @Tags plugins/gh-copilot +// @Accept application/json +// @Param connectionId path int false "connection ID" +// @Param search query string false "search" +// @Param page query int false "page number" +// @Param pageSize query int false "page size per page" +// @Success 200 {object} dsmodels.DsRemoteApiScopeList[models.GhCopilotScope] "the parentIds are always null" +// @Failure 400 {object} shared.ApiBody "Bad Request" +// @Failure 500 {object} shared.ApiBody "Internal Error" +// @Router /plugins/gh-copilot/connections/{connectionId}/search-remote-scopes [GET] +func SearchRemoteScopes(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + return raScopeSearch.Get(input) +} diff --git a/backend/plugins/gh-copilot/api/scope.go b/backend/plugins/gh-copilot/api/scope.go new file mode 100644 index 00000000000..dbd8cca59b1 --- /dev/null +++ b/backend/plugins/gh-copilot/api/scope.go @@ -0,0 +1,59 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package api + +import ( + "github.com/apache/incubator-devlake/core/errors" + "github.com/apache/incubator-devlake/core/plugin" + helper "github.com/apache/incubator-devlake/helpers/pluginhelper/api" + "github.com/apache/incubator-devlake/helpers/srvhelper" + "github.com/apache/incubator-devlake/plugins/gh-copilot/models" +) + +type PutScopesReqBody = helper.PutScopesReqBody[models.GhCopilotScope] +type ScopeDetail = srvhelper.ScopeDetail[models.GhCopilotScope, models.GhCopilotScopeConfig] + +// PutScopes creates or updates Copilot organization scopes. +func PutScopes(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + return dsHelper.ScopeApi.PutMultiple(input) +} + +// GetScopeList retrieves scopes for a connection with optional pagination. +func GetScopeList(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + return dsHelper.ScopeApi.GetPage(input) +} + +// GetScope returns the scope detail for a given scope ID. +func GetScope(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + return dsHelper.ScopeApi.GetScopeDetail(input) +} + +// PatchScope updates a scope record. +func PatchScope(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + return dsHelper.ScopeApi.Patch(input) +} + +// DeleteScope removes a scope and optionally associated data. +func DeleteScope(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + return dsHelper.ScopeApi.Delete(input) +} + +// GetScopeLatestSyncState returns the latest sync state for a scope. +func GetScopeLatestSyncState(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + return dsHelper.ScopeApi.GetScopeLatestSyncState(input) +} diff --git a/backend/plugins/gh-copilot/api/scope_config.go b/backend/plugins/gh-copilot/api/scope_config.go new file mode 100644 index 00000000000..7184652191d --- /dev/null +++ b/backend/plugins/gh-copilot/api/scope_config.go @@ -0,0 +1,108 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package api + +import ( + "github.com/apache/incubator-devlake/core/errors" + "github.com/apache/incubator-devlake/core/plugin" +) + +// PostScopeConfig creates a new scope configuration +// @Summary Create scope config +// @Description Create scope config for GitHub Copilot +// @Tags plugins/gh-copilot +// @Accept json +// @Param connectionId path int true "connection ID" +// @Param request body models.GhCopilotScopeConfig true "scope config" +// @Success 200 {object} models.GhCopilotScopeConfig +// @Failure 400 {object} shared.ApiBody "bad request" +// @Failure 500 {object} shared.ApiBody "internal error" +// @Router /plugins/gh-copilot/connections/{connectionId}/scope-configs [POST] +func PostScopeConfig(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + return dsHelper.ScopeConfigApi.Post(input) +} + +// GetScopeConfigList returns all scope configurations for a connection +// @Summary Get scope configs +// @Description Get all scope configs for a connection +// @Tags plugins/gh-copilot +// @Param connectionId path int true "connection ID" +// @Success 200 {array} models.GhCopilotScopeConfig +// @Failure 400 {object} shared.ApiBody "bad request" +// @Failure 500 {object} shared.ApiBody "internal error" +// @Router /plugins/gh-copilot/connections/{connectionId}/scope-configs [GET] +func GetScopeConfigList(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + return dsHelper.ScopeConfigApi.GetAll(input) +} + +// GetScopeConfig returns a scope configuration by id +// @Summary Get scope config +// @Description Get a scope config by ID +// @Tags plugins/gh-copilot +// @Param connectionId path int true "connection ID" +// @Param scopeConfigId path int true "scope config ID" +// @Success 200 {object} models.GhCopilotScopeConfig +// @Failure 400 {object} shared.ApiBody "bad request" +// @Failure 500 {object} shared.ApiBody "internal error" +// @Router /plugins/gh-copilot/connections/{connectionId}/scope-configs/{scopeConfigId} [GET] +func GetScopeConfig(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + return dsHelper.ScopeConfigApi.GetDetail(input) +} + +// PatchScopeConfig updates a scope configuration +// @Summary Patch scope config +// @Description Update a scope config +// @Tags plugins/gh-copilot +// @Accept json +// @Param connectionId path int true "connection ID" +// @Param scopeConfigId path int true "scope config ID" +// @Param request body models.GhCopilotScopeConfig true "scope config" +// @Success 200 {object} models.GhCopilotScopeConfig +// @Failure 400 {object} shared.ApiBody "bad request" +// @Failure 500 {object} shared.ApiBody "internal error" +// @Router /plugins/gh-copilot/connections/{connectionId}/scope-configs/{scopeConfigId} [PATCH] +func PatchScopeConfig(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + return dsHelper.ScopeConfigApi.Patch(input) +} + +// DeleteScopeConfig deletes a scope configuration +// @Summary Delete scope config +// @Description Delete a scope config +// @Tags plugins/gh-copilot +// @Param connectionId path int true "connection ID" +// @Param scopeConfigId path int true "scope config ID" +// @Success 200 {object} models.GhCopilotScopeConfig +// @Failure 400 {object} shared.ApiBody "bad request" +// @Failure 500 {object} shared.ApiBody "internal error" +// @Router /plugins/gh-copilot/connections/{connectionId}/scope-configs/{scopeConfigId} [DELETE] +func DeleteScopeConfig(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + return dsHelper.ScopeConfigApi.Delete(input) +} + +// GetProjectsByScopeConfig returns projects related to a scope config +// @Summary Get projects by scope config +// @Description Get projects details related by scope config +// @Tags plugins/gh-copilot +// @Param scopeConfigId path int true "scope config ID" +// @Success 200 {object} models.GhCopilotScopeConfig +// @Failure 400 {object} shared.ApiBody "bad request" +// @Failure 500 {object} shared.ApiBody "internal error" +// @Router /plugins/gh-copilot/scope-config/{scopeConfigId}/projects [GET] +func GetProjectsByScopeConfig(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + return dsHelper.ScopeConfigApi.GetProjectsByScopeConfig(input) +} diff --git a/backend/plugins/gh-copilot/api/test_connection.go b/backend/plugins/gh-copilot/api/test_connection.go new file mode 100644 index 00000000000..3dedc859a67 --- /dev/null +++ b/backend/plugins/gh-copilot/api/test_connection.go @@ -0,0 +1,70 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package api + +import ( + "context" + "net/http" + + "github.com/apache/incubator-devlake/core/errors" + "github.com/apache/incubator-devlake/core/plugin" + helper "github.com/apache/incubator-devlake/helpers/pluginhelper/api" + "github.com/apache/incubator-devlake/plugins/gh-copilot/models" + "github.com/apache/incubator-devlake/plugins/gh-copilot/service" +) + +// TestConnection validates a Copilot connection before saving it. +func TestConnection(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + connection := &models.GhCopilotConnection{} + if err := helper.Decode(input.Body, connection, vld); err != nil { + return nil, plugin.WrapTestConnectionErrResp(basicRes, err) + } + + connection.Normalize() + if err := validateConnection(connection); err != nil { + return nil, plugin.WrapTestConnectionErrResp(basicRes, err) + } + + result, err := service.TestConnection(context.Background(), basicRes, connection) + if err != nil { + return nil, plugin.WrapTestConnectionErrResp(basicRes, err) + } + return &plugin.ApiResourceOutput{Body: result, Status: http.StatusOK}, nil +} + +// TestExistingConnection validates a stored Copilot connection with optional overrides. +func TestExistingConnection(input *plugin.ApiResourceInput) (*plugin.ApiResourceOutput, errors.Error) { + connection := &models.GhCopilotConnection{} + if err := connectionHelper.First(connection, input.Params); err != nil { + return nil, plugin.WrapTestConnectionErrResp(basicRes, errors.BadInput.Wrap(err, "find connection from db")) + } + if err := helper.DecodeMapStruct(input.Body, connection, false); err != nil { + return nil, plugin.WrapTestConnectionErrResp(basicRes, err) + } + + connection.Normalize() + if err := validateConnection(connection); err != nil { + return nil, plugin.WrapTestConnectionErrResp(basicRes, err) + } + + result, err := service.TestConnection(context.Background(), basicRes, connection) + if err != nil { + return nil, plugin.WrapTestConnectionErrResp(basicRes, err) + } + return &plugin.ApiResourceOutput{Body: result, Status: http.StatusOK}, nil +} diff --git a/backend/plugins/gh-copilot/copilot.go b/backend/plugins/gh-copilot/copilot.go new file mode 100644 index 00000000000..c4de27e9f04 --- /dev/null +++ b/backend/plugins/gh-copilot/copilot.go @@ -0,0 +1,45 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package main + +import ( + "github.com/apache/incubator-devlake/core/runner" + "github.com/apache/incubator-devlake/plugins/gh-copilot/impl" + "github.com/spf13/cobra" +) + +var PluginEntry impl.GhCopilot + +// standalone mode for debugging collectors. +func main() { + cmd := &cobra.Command{Use: "gh-copilot"} + connectionId := cmd.Flags().Uint64P("connectionId", "c", 0, "gh-copilot connection id") + scopeId := cmd.Flags().StringP("scopeId", "s", "", "gh-copilot scope id (organization)") + timeAfter := cmd.Flags().StringP("timeAfter", "a", "", "collect data created after the specified time") + + _ = cmd.MarkFlagRequired("connectionId") + _ = cmd.MarkFlagRequired("scopeId") + + cmd.Run = func(cmd *cobra.Command, args []string) { + runner.DirectRun(cmd, args, PluginEntry, map[string]interface{}{ + "connectionId": *connectionId, + "scopeId": *scopeId, + }, *timeAfter) + } + runner.RunCmd(cmd) +} diff --git a/backend/plugins/gh-copilot/e2e/metrics/language_breakdown.csv b/backend/plugins/gh-copilot/e2e/metrics/language_breakdown.csv new file mode 100644 index 00000000000..6e522f2bb03 --- /dev/null +++ b/backend/plugins/gh-copilot/e2e/metrics/language_breakdown.csv @@ -0,0 +1,5 @@ +connection_id,scope_id,date,editor,language,engaged_users,suggestions,acceptances,lines_suggested,lines_accepted +1,octodemo,2025-09-01T00:00:00.000+00:00,vscode,python,6,100,20,300,60 +1,octodemo,2025-09-01T00:00:00.000+00:00,jetbrains,ruby,4,50,10,120,30 +1,octodemo,2025-09-02T00:00:00.000+00:00,vscode,python,8,180,36,450,180 +1,octodemo,2025-09-02T00:00:00.000+00:00,jetbrains,go,2,20,4,50,20 diff --git a/backend/plugins/gh-copilot/e2e/metrics/raw_tables/_raw_copilot_metrics.csv b/backend/plugins/gh-copilot/e2e/metrics/raw_tables/_raw_copilot_metrics.csv new file mode 100644 index 00000000000..203eb7ae4ea --- /dev/null +++ b/backend/plugins/gh-copilot/e2e/metrics/raw_tables/_raw_copilot_metrics.csv @@ -0,0 +1,3 @@ +id,params,data,url,input,created_at +1,"{""ConnectionId"":1,""ScopeId"":""octodemo"",""Organization"":""octodemo"",""Endpoint"":""https://api.github.com""}","{""date"":""2025-09-01"",""total_active_users"":10,""total_engaged_users"":8,""copilot_ide_code_completions"":{""total_engaged_users"":8,""editors"": [{""name"":""vscode"",""total_engaged_users"":6,""models"": [{""name"":""default"",""is_custom_model"":false,""total_engaged_users"":6,""languages"": [{""name"":""python"",""total_engaged_users"":6,""total_code_suggestions"":100,""total_code_acceptances"":20,""total_code_lines_suggested"":300,""total_code_lines_accepted"":60}]}]},{""name"":""jetbrains"",""total_engaged_users"":4,""models"": [{""name"":""default"",""is_custom_model"":false,""total_engaged_users"":4,""languages"": [{""name"":""ruby"",""total_engaged_users"":4,""total_code_suggestions"":50,""total_code_acceptances"":10,""total_code_lines_suggested"":120,""total_code_lines_accepted"":30}]}]}]},""copilot_ide_chat"":{""total_engaged_users"":3,""editors"": [{""name"":""vscode"",""total_engaged_users"":3,""models"": [{""name"":""default"",""is_custom_model"":false,""total_engaged_users"":3,""total_chats"":5,""total_chat_copy_events"":2,""total_chat_insertion_events"":1},{""name"":""default"",""is_custom_model"":false,""total_engaged_users"":3,""total_chats"":3,""total_chat_copy_events"":1,""total_chat_insertion_events"":0}]}]},""copilot_dotcom_chat"":{""total_engaged_users"":2,""models"": [{""name"":""default"",""is_custom_model"":false,""total_engaged_users"":2,""total_chats"":4},{""name"":""default"",""is_custom_model"":false,""total_engaged_users"":1,""total_chats"":1}]}}",https://api.github.com/orgs/octodemo/copilot/metrics,null,2025-09-03 00:00:00.000 +2,"{""ConnectionId"":1,""ScopeId"":""octodemo"",""Organization"":""octodemo"",""Endpoint"":""https://api.github.com""}","{""date"":""2025-09-02"",""total_active_users"":12,""total_engaged_users"":9,""copilot_ide_code_completions"":{""total_engaged_users"":9,""editors"": [{""name"":""vscode"",""total_engaged_users"":8,""models"": [{""name"":""default"",""is_custom_model"":false,""total_engaged_users"":8,""languages"": [{""name"":""python"",""total_engaged_users"":8,""total_code_suggestions"":180,""total_code_acceptances"":36,""total_code_lines_suggested"":450,""total_code_lines_accepted"":180}]}]},{""name"":""jetbrains"",""total_engaged_users"":2,""models"": [{""name"":""default"",""is_custom_model"":false,""total_engaged_users"":2,""languages"": [{""name"":""go"",""total_engaged_users"":2,""total_code_suggestions"":20,""total_code_acceptances"":4,""total_code_lines_suggested"":50,""total_code_lines_accepted"":20}]}]}]},""copilot_ide_chat"":{""total_engaged_users"":4,""editors"": [{""name"":""vscode"",""total_engaged_users"":4,""models"": [{""name"":""default"",""is_custom_model"":false,""total_engaged_users"":4,""total_chats"":10,""total_chat_copy_events"":4,""total_chat_insertion_events"":2}]}]},""copilot_dotcom_chat"":{""total_engaged_users"":1,""models"": [{""name"":""default"",""is_custom_model"":false,""total_engaged_users"":1,""total_chats"":2}]}}",https://api.github.com/orgs/octodemo/copilot/metrics,null,2025-09-03 00:00:00.000 diff --git a/backend/plugins/gh-copilot/e2e/metrics/raw_tables/_raw_copilot_seats.csv b/backend/plugins/gh-copilot/e2e/metrics/raw_tables/_raw_copilot_seats.csv new file mode 100644 index 00000000000..efefd419869 --- /dev/null +++ b/backend/plugins/gh-copilot/e2e/metrics/raw_tables/_raw_copilot_seats.csv @@ -0,0 +1,3 @@ +id,params,data,url,input,created_at +1,"{""ConnectionId"":1,""ScopeId"":""octodemo"",""Organization"":""octodemo"",""Endpoint"":""https://api.github.com""}","{""created_at"":""2023-08-29T02:50:42+03:00"",""assignee"":{""login"":""nathos"",""id"":4215,""type"":""User""},""pending_cancellation_date"":null,""plan_type"":""enterprise"",""last_authenticated_at"":""2025-12-04T15:53:22Z"",""updated_at"":""2024-02-01T03:00:00+03:00"",""last_activity_at"":""2025-11-06T19:12:15+03:00"",""last_activity_editor"":""copilot_pr_review""}",https://api.github.com/orgs/octodemo/copilot/billing/seats,null,2025-09-03 00:00:00.000 +2,"{""ConnectionId"":1,""ScopeId"":""octodemo"",""Organization"":""octodemo"",""Endpoint"":""https://api.github.com""}","{""created_at"":""2024-01-10T10:11:12Z"",""assignee"":{""login"":""octocat"",""id"":1,""type"":""User""},""pending_cancellation_date"":null,""plan_type"":""enterprise"",""last_authenticated_at"":null,""updated_at"":""2024-02-02T00:00:00Z"",""last_activity_at"":null,""last_activity_editor"":""vscode/1.0.0/copilot-chat/0.1.0""}",https://api.github.com/orgs/octodemo/copilot/billing/seats,null,2025-09-03 00:00:00.000 diff --git a/backend/plugins/gh-copilot/e2e/metrics/snapshot_tables/_tool_copilot_org_metrics.csv b/backend/plugins/gh-copilot/e2e/metrics/snapshot_tables/_tool_copilot_org_metrics.csv new file mode 100644 index 00000000000..8986efcb879 --- /dev/null +++ b/backend/plugins/gh-copilot/e2e/metrics/snapshot_tables/_tool_copilot_org_metrics.csv @@ -0,0 +1,3 @@ +connection_id,scope_id,date,total_active_users,total_engaged_users,completion_suggestions,completion_acceptances,completion_lines_suggested,completion_lines_accepted,ide_chats,ide_chat_copy_events,ide_chat_insertion_events,ide_chat_engaged_users,dotcom_chats,dotcom_chat_engaged_users,seat_active_count,seat_total +1,octodemo,2025-09-01T00:00:00.000+00:00,10,8,150,30,420,90,8,3,1,3,5,2,1,2 +1,octodemo,2025-09-02T00:00:00.000+00:00,12,9,200,40,500,200,10,4,2,4,2,1,1,2 diff --git a/backend/plugins/gh-copilot/e2e/metrics/snapshot_tables/_tool_copilot_seats.csv b/backend/plugins/gh-copilot/e2e/metrics/snapshot_tables/_tool_copilot_seats.csv new file mode 100644 index 00000000000..87b28f6b8fb --- /dev/null +++ b/backend/plugins/gh-copilot/e2e/metrics/snapshot_tables/_tool_copilot_seats.csv @@ -0,0 +1,3 @@ +connection_id,organization,user_login,user_id,plan_type,created_at,last_activity_at,last_activity_editor,last_authenticated_at,pending_cancellation_date,updated_at +1,octodemo,nathos,4215,enterprise,2023-08-28T23:50:42.000+00:00,2025-11-06T16:12:15.000+00:00,copilot_pr_review,2025-12-04T15:53:22.000+00:00,,2024-02-01T00:00:00.000+00:00 +1,octodemo,octocat,1,enterprise,2024-01-10T10:11:12.000+00:00,,vscode/1.0.0/copilot-chat/0.1.0,,,2024-02-02T00:00:00.000+00:00 diff --git a/backend/plugins/gh-copilot/e2e/metrics_test.go b/backend/plugins/gh-copilot/e2e/metrics_test.go new file mode 100644 index 00000000000..cf699ab64be --- /dev/null +++ b/backend/plugins/gh-copilot/e2e/metrics_test.go @@ -0,0 +1,87 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package e2e + +import ( + "testing" + "time" + + "github.com/apache/incubator-devlake/core/config" + "github.com/apache/incubator-devlake/core/models/common" + "github.com/apache/incubator-devlake/core/runner" + "github.com/apache/incubator-devlake/helpers/e2ehelper" + helper "github.com/apache/incubator-devlake/helpers/pluginhelper/api" + "github.com/apache/incubator-devlake/plugins/gh-copilot/impl" + "github.com/apache/incubator-devlake/plugins/gh-copilot/models" + "github.com/apache/incubator-devlake/plugins/gh-copilot/tasks" +) + +func TestCopilotMetricsDataFlow(t *testing.T) { + cfg := config.GetConfig() + dbUrl := cfg.GetString("E2E_DB_URL") + if dbUrl == "" { + t.Skip("skipping e2e test: E2E_DB_URL is not set") + } + if err := runner.CheckDbConnection(dbUrl, 10*time.Second); err != nil { + t.Skipf("skipping e2e test: cannot connect to E2E_DB_URL: %v", err) + } + + var copilot impl.GhCopilot + dataflowTester := e2ehelper.NewDataFlowTester(t, "gh-copilot", copilot) + + taskData := &tasks.GhCopilotTaskData{ + Options: &tasks.GhCopilotOptions{ + ConnectionId: 1, + ScopeId: "octodemo", + }, + Connection: &models.GhCopilotConnection{ + GhCopilotConn: models.GhCopilotConn{ + RestConnection: helper.RestConnection{Endpoint: "https://api.github.com"}, + Organization: "octodemo", + RateLimitPerHour: 5000, + }, + }, + } + + dataflowTester.ImportCsvIntoRawTable("./metrics/raw_tables/_raw_copilot_metrics.csv", "_raw_copilot_org_metrics") + dataflowTester.ImportCsvIntoRawTable("./metrics/raw_tables/_raw_copilot_seats.csv", "_raw_copilot_seats") + + dataflowTester.FlushTabler(&models.GhCopilotOrgMetrics{}) + dataflowTester.FlushTabler(&models.GhCopilotLanguageMetrics{}) + dataflowTester.FlushTabler(&models.GhCopilotSeat{}) + + dataflowTester.Subtask(tasks.ExtractSeatsMeta, taskData) + dataflowTester.Subtask(tasks.ExtractOrgMetricsMeta, taskData) + + dataflowTester.VerifyTableWithOptions(&models.GhCopilotOrgMetrics{}, e2ehelper.TableOptions{ + CSVRelPath: "./metrics/snapshot_tables/_tool_copilot_org_daily_metrics.csv", + IgnoreTypes: []interface{}{common.NoPKModel{}}, + }) + + dataflowTester.VerifyTableWithOptions(&models.GhCopilotSeat{}, e2ehelper.TableOptions{ + CSVRelPath: "./metrics/snapshot_tables/_tool_copilot_seats.csv", + IgnoreTypes: []interface{}{ + common.RawDataOrigin{}, + }, + }) + + dataflowTester.VerifyTableWithOptions(&models.GhCopilotLanguageMetrics{}, e2ehelper.TableOptions{ + CSVRelPath: "./metrics/language_breakdown.csv", + IgnoreTypes: []interface{}{common.NoPKModel{}}, + }) +} diff --git a/backend/plugins/gh-copilot/impl/connection_helper.go b/backend/plugins/gh-copilot/impl/connection_helper.go new file mode 100644 index 00000000000..d94f9947906 --- /dev/null +++ b/backend/plugins/gh-copilot/impl/connection_helper.go @@ -0,0 +1,20 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package impl + +// Deprecated stub file: connection testing helpers now reside in the service package. diff --git a/backend/plugins/gh-copilot/impl/connection_helper_test.go b/backend/plugins/gh-copilot/impl/connection_helper_test.go new file mode 100644 index 00000000000..713aec48b7a --- /dev/null +++ b/backend/plugins/gh-copilot/impl/connection_helper_test.go @@ -0,0 +1,20 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package impl + +// Legacy placeholder: connection helper tests moved to the service package. diff --git a/backend/plugins/gh-copilot/impl/impl.go b/backend/plugins/gh-copilot/impl/impl.go new file mode 100644 index 00000000000..aefcd1ba6a4 --- /dev/null +++ b/backend/plugins/gh-copilot/impl/impl.go @@ -0,0 +1,170 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package impl + +import ( + "github.com/apache/incubator-devlake/core/context" + "github.com/apache/incubator-devlake/core/dal" + "github.com/apache/incubator-devlake/core/errors" + coreModels "github.com/apache/incubator-devlake/core/models" + "github.com/apache/incubator-devlake/core/plugin" + helper "github.com/apache/incubator-devlake/helpers/pluginhelper/api" + "github.com/apache/incubator-devlake/plugins/gh-copilot/api" + "github.com/apache/incubator-devlake/plugins/gh-copilot/models" + "github.com/apache/incubator-devlake/plugins/gh-copilot/models/migrationscripts" + "github.com/apache/incubator-devlake/plugins/gh-copilot/tasks" +) + +var _ interface { + plugin.PluginMeta + plugin.PluginInit + plugin.PluginTask + plugin.PluginApi + plugin.PluginModel + plugin.PluginSource + plugin.DataSourcePluginBlueprintV200 + plugin.PluginMigration + plugin.CloseablePluginTask +} = (*GhCopilot)(nil) + +// GhCopilot is the plugin entrypoint implementing DevLake interfaces. +type GhCopilot struct{} + +func (p GhCopilot) Init(basicRes context.BasicRes) errors.Error { + api.Init(basicRes, p) + return nil +} + +func (p GhCopilot) Description() string { + return "Collect GitHub Copilot usage metrics (enterprise and organization level)" +} + +func (p GhCopilot) Name() string { + return "gh-copilot" +} + +func (p GhCopilot) Connection() dal.Tabler { + return &models.GhCopilotConnection{} +} + +func (p GhCopilot) Scope() plugin.ToolLayerScope { + return &models.GhCopilotScope{} +} + +func (p GhCopilot) ScopeConfig() dal.Tabler { + return &models.GhCopilotScopeConfig{} +} + +func (p GhCopilot) GetTablesInfo() []dal.Tabler { + return models.GetTablesInfo() +} + +func (p GhCopilot) SubTaskMetas() []plugin.SubTaskMeta { + return tasks.GetSubTaskMetas() +} + +func (p GhCopilot) PrepareTaskData(taskCtx plugin.TaskContext, options map[string]interface{}) (interface{}, errors.Error) { + var op tasks.GhCopilotOptions + if err := helper.Decode(options, &op, nil); err != nil { + return nil, err + } + + connectionHelper := helper.NewConnectionHelper(taskCtx, nil, p.Name()) + connection := &models.GhCopilotConnection{} + if err := connectionHelper.FirstById(connection, op.ConnectionId); err != nil { + return nil, err + } + + NormalizeConnection(connection) + + taskData := &tasks.GhCopilotTaskData{ + Options: &op, + Connection: connection, + } + + return taskData, nil +} + +func (p GhCopilot) ApiResources() map[string]map[string]plugin.ApiResourceHandler { + return map[string]map[string]plugin.ApiResourceHandler{ + "test": { + "POST": api.TestConnection, + }, + "connections": { + "POST": api.PostConnections, + "GET": api.ListConnections, + }, + "connections/:connectionId": { + "GET": api.GetConnection, + "PATCH": api.PatchConnection, + "DELETE": api.DeleteConnection, + }, + "connections/:connectionId/test": { + "POST": api.TestExistingConnection, + }, + "connections/:connectionId/scopes": { + "GET": api.GetScopeList, + "PUT": api.PutScopes, + }, + "connections/:connectionId/scopes/:scopeId": { + "GET": api.GetScope, + "PATCH": api.PatchScope, + "DELETE": api.DeleteScope, + }, + "connections/:connectionId/scopes/:scopeId/latest-sync-state": { + "GET": api.GetScopeLatestSyncState, + }, + "connections/:connectionId/remote-scopes": { + "GET": api.RemoteScopes, + }, + "connections/:connectionId/search-remote-scopes": { + "GET": api.SearchRemoteScopes, + }, + "connections/:connectionId/scope-configs": { + "POST": api.PostScopeConfig, + "GET": api.GetScopeConfigList, + }, + "connections/:connectionId/scope-configs/:scopeConfigId": { + "GET": api.GetScopeConfig, + "PATCH": api.PatchScopeConfig, + "DELETE": api.DeleteScopeConfig, + }, + "scope-config/:scopeConfigId/projects": { + "GET": api.GetProjectsByScopeConfig, + }, + } +} + +func (p GhCopilot) MakeDataSourcePipelinePlanV200( + connectionId uint64, + scopes []*coreModels.BlueprintScope, +) (coreModels.PipelinePlan, []plugin.Scope, errors.Error) { + return api.MakeDataSourcePipelinePlanV200(p.SubTaskMetas(), connectionId, scopes) +} + +func (p GhCopilot) RootPkgPath() string { + return "github.com/apache/incubator-devlake/plugins/gh-copilot" +} + +func (p GhCopilot) MigrationScripts() []plugin.MigrationScript { + return migrationscripts.All() +} + +func (p GhCopilot) Close(taskCtx plugin.TaskContext) errors.Error { + return nil +} diff --git a/backend/plugins/gh-copilot/impl/options.go b/backend/plugins/gh-copilot/impl/options.go new file mode 100644 index 00000000000..015196c8a80 --- /dev/null +++ b/backend/plugins/gh-copilot/impl/options.go @@ -0,0 +1,25 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package impl + +import "github.com/apache/incubator-devlake/plugins/gh-copilot/models" + +// NormalizeConnection ensures required defaults are set before use. +func NormalizeConnection(connection *models.GhCopilotConnection) { + connection.Normalize() +} diff --git a/backend/plugins/gh-copilot/models/connection.go b/backend/plugins/gh-copilot/models/connection.go new file mode 100644 index 00000000000..676d06b4f85 --- /dev/null +++ b/backend/plugins/gh-copilot/models/connection.go @@ -0,0 +1,121 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package models + +import ( + "net/http" + "strings" + + "github.com/apache/incubator-devlake/core/errors" + "github.com/apache/incubator-devlake/core/utils" + helper "github.com/apache/incubator-devlake/helpers/pluginhelper/api" +) + +const ( + // DefaultEndpoint is the GitHub REST API endpoint used for Copilot metrics. + DefaultEndpoint = "https://api.github.com" + // DefaultRateLimitPerHour mirrors GitHub's default rate limit for PATs. + DefaultRateLimitPerHour = 5000 +) + +// GhCopilotConn stores GitHub Copilot connection settings. +type GhCopilotConn struct { + helper.RestConnection `mapstructure:",squash"` + + Token string `mapstructure:"token" json:"token"` + Organization string `mapstructure:"organization" json:"organization"` + Enterprise string `mapstructure:"enterprise" json:"enterprise,omitempty" gorm:"type:varchar(100)"` + RateLimitPerHour int `mapstructure:"rateLimitPerHour" json:"rateLimitPerHour"` +} + +// HasEnterprise returns true if the connection is configured for enterprise-level access. +func (conn *GhCopilotConn) HasEnterprise() bool { + return conn != nil && strings.TrimSpace(conn.Enterprise) != "" +} + +// SetupAuthentication implements plugin.ApiAuthenticator so helper.NewApiClientFromConnection +// can attach the Authorization header for GitHub API requests. +func (conn *GhCopilotConn) SetupAuthentication(request *http.Request) errors.Error { + if conn == nil { + return errors.BadInput.New("connection is required") + } + token := strings.TrimSpace(conn.Token) + if token == "" { + return errors.BadInput.New("token is required") + } + + lower := strings.ToLower(token) + if strings.HasPrefix(lower, "bearer ") || strings.HasPrefix(lower, "token ") { + request.Header.Set("Authorization", token) + return nil + } + request.Header.Set("Authorization", "Bearer "+token) + return nil +} + +func (conn *GhCopilotConn) Sanitize() GhCopilotConn { + if conn == nil { + return GhCopilotConn{} + } + clone := *conn + clone.Token = utils.SanitizeString(clone.Token) + return clone +} + +// GhCopilotConnection persists connection details with metadata required by DevLake. +type GhCopilotConnection struct { + helper.BaseConnection `mapstructure:",squash"` + GhCopilotConn `mapstructure:",squash"` +} + +func (GhCopilotConnection) TableName() string { + return "_tool_copilot_connections" +} + +func (connection GhCopilotConnection) Sanitize() GhCopilotConnection { + connection.GhCopilotConn = connection.GhCopilotConn.Sanitize() + return connection +} + +func (connection *GhCopilotConnection) MergeFromRequest(target *GhCopilotConnection, body map[string]interface{}) error { + if target == nil { + return nil + } + originalToken := target.Token + if err := helper.DecodeMapStruct(body, target, true); err != nil { + return err + } + sanitizedOriginal := utils.SanitizeString(originalToken) + if target.Token == "" || target.Token == sanitizedOriginal { + target.Token = originalToken + } + return nil +} + +// Normalize applies default connection values where necessary. +func (connection *GhCopilotConnection) Normalize() { + if connection == nil { + return + } + if connection.Endpoint == "" { + connection.Endpoint = DefaultEndpoint + } + if connection.RateLimitPerHour <= 0 { + connection.RateLimitPerHour = DefaultRateLimitPerHour + } +} diff --git a/backend/plugins/gh-copilot/models/connection_auth_test.go b/backend/plugins/gh-copilot/models/connection_auth_test.go new file mode 100644 index 00000000000..3403f43fe7b --- /dev/null +++ b/backend/plugins/gh-copilot/models/connection_auth_test.go @@ -0,0 +1,65 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package models + +import ( + "net/http" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestGhCopilotConn_SetupAuthentication_BearerPrefix(t *testing.T) { + conn := &GhCopilotConn{Token: "Bearer abc"} + req, err := http.NewRequest(http.MethodGet, "https://example.com", nil) + require.NoError(t, err) + + err2 := conn.SetupAuthentication(req) + require.NoError(t, err2) + require.Equal(t, "Bearer abc", req.Header.Get("Authorization")) +} + +func TestGhCopilotConn_SetupAuthentication_TokenPrefix(t *testing.T) { + conn := &GhCopilotConn{Token: "token abc"} + req, err := http.NewRequest(http.MethodGet, "https://example.com", nil) + require.NoError(t, err) + + err2 := conn.SetupAuthentication(req) + require.NoError(t, err2) + require.Equal(t, "token abc", req.Header.Get("Authorization")) +} + +func TestGhCopilotConn_SetupAuthentication_RawToken(t *testing.T) { + conn := &GhCopilotConn{Token: "abc"} + req, err := http.NewRequest(http.MethodGet, "https://example.com", nil) + require.NoError(t, err) + + err2 := conn.SetupAuthentication(req) + require.NoError(t, err2) + require.Equal(t, "Bearer abc", req.Header.Get("Authorization")) +} + +func TestGhCopilotConn_SetupAuthentication_TrimsWhitespace(t *testing.T) { + conn := &GhCopilotConn{Token: " abc "} + req, err := http.NewRequest(http.MethodGet, "https://example.com", nil) + require.NoError(t, err) + + err2 := conn.SetupAuthentication(req) + require.NoError(t, err2) + require.Equal(t, "Bearer abc", req.Header.Get("Authorization")) +} diff --git a/backend/plugins/gh-copilot/models/enterprise_metrics.go b/backend/plugins/gh-copilot/models/enterprise_metrics.go new file mode 100644 index 00000000000..07663aa6dd5 --- /dev/null +++ b/backend/plugins/gh-copilot/models/enterprise_metrics.go @@ -0,0 +1,149 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package models + +import ( + "time" + + "github.com/apache/incubator-devlake/core/models/common" +) + +// CopilotActivityMetrics contains the common activity/LOC fields shared across all breakdown tables. +type CopilotActivityMetrics struct { + UserInitiatedInteractionCount int `json:"userInitiatedInteractionCount" gorm:"comment:Chat messages and inline prompts initiated by user"` + CodeGenerationActivityCount int `json:"codeGenerationActivityCount" gorm:"comment:Number of code suggestions/generations made"` + CodeAcceptanceActivityCount int `json:"codeAcceptanceActivityCount" gorm:"comment:Number of suggestions accepted by user"` + LocSuggestedToAddSum int `json:"locSuggestedToAddSum" gorm:"comment:Lines of code suggested for addition"` + LocSuggestedToDeleteSum int `json:"locSuggestedToDeleteSum" gorm:"comment:Lines of code suggested for deletion"` + LocAddedSum int `json:"locAddedSum" gorm:"comment:Lines of code actually added (accepted)"` + LocDeletedSum int `json:"locDeletedSum" gorm:"comment:Lines of code actually deleted (accepted)"` +} + +// CopilotCodeMetrics contains code generation/acceptance metrics without user interaction count. +type CopilotCodeMetrics struct { + CodeGenerationActivityCount int `json:"codeGenerationActivityCount"` + CodeAcceptanceActivityCount int `json:"codeAcceptanceActivityCount"` + LocSuggestedToAddSum int `json:"locSuggestedToAddSum"` + LocSuggestedToDeleteSum int `json:"locSuggestedToDeleteSum"` + LocAddedSum int `json:"locAddedSum"` + LocDeletedSum int `json:"locDeletedSum"` +} + +// GhCopilotEnterpriseDailyMetrics captures daily enterprise-level aggregate Copilot metrics. +type GhCopilotEnterpriseDailyMetrics struct { + ConnectionId uint64 `gorm:"primaryKey" json:"connectionId"` + ScopeId string `gorm:"primaryKey;type:varchar(255)" json:"scopeId"` + Day time.Time `gorm:"primaryKey;type:date" json:"day"` + + EnterpriseId string `json:"enterpriseId" gorm:"type:varchar(100)"` + DailyActiveUsers int `json:"dailyActiveUsers"` + WeeklyActiveUsers int `json:"weeklyActiveUsers"` + MonthlyActiveUsers int `json:"monthlyActiveUsers"` + MonthlyActiveChatUsers int `json:"monthlyActiveChatUsers"` + MonthlyActiveAgentUsers int `json:"monthlyActiveAgentUsers"` + + PRTotalReviewed int `json:"prTotalReviewed" gorm:"comment:Total PRs reviewed"` + PRTotalCreated int `json:"prTotalCreated" gorm:"comment:Total PRs created"` + PRTotalCreatedByCopilot int `json:"prTotalCreatedByCopilot" gorm:"comment:PRs created by Copilot"` + PRTotalReviewedByCopilot int `json:"prTotalReviewedByCopilot" gorm:"comment:PRs reviewed by Copilot"` + + CopilotActivityMetrics `mapstructure:",squash"` + common.NoPKModel +} + +func (GhCopilotEnterpriseDailyMetrics) TableName() string { + return "_tool_copilot_enterprise_daily_metrics" +} + +// GhCopilotMetricsByIde stores enterprise/org metrics broken down by IDE. +type GhCopilotMetricsByIde struct { + ConnectionId uint64 `gorm:"primaryKey" json:"connectionId"` + ScopeId string `gorm:"primaryKey;type:varchar(255)" json:"scopeId"` + Day time.Time `gorm:"primaryKey;type:date" json:"day"` + Ide string `gorm:"primaryKey;type:varchar(50)" json:"ide"` + + CopilotActivityMetrics `mapstructure:",squash"` + common.NoPKModel +} + +func (GhCopilotMetricsByIde) TableName() string { + return "_tool_copilot_metrics_by_ide" +} + +// GhCopilotMetricsByFeature stores enterprise/org metrics broken down by feature. +type GhCopilotMetricsByFeature struct { + ConnectionId uint64 `gorm:"primaryKey" json:"connectionId"` + ScopeId string `gorm:"primaryKey;type:varchar(255)" json:"scopeId"` + Day time.Time `gorm:"primaryKey;type:date" json:"day"` + Feature string `gorm:"primaryKey;type:varchar(100)" json:"feature"` + + CopilotActivityMetrics `mapstructure:",squash"` + common.NoPKModel +} + +func (GhCopilotMetricsByFeature) TableName() string { + return "_tool_copilot_metrics_by_feature" +} + +// GhCopilotMetricsByLanguageFeature stores metrics broken down by language and feature. +type GhCopilotMetricsByLanguageFeature struct { + ConnectionId uint64 `gorm:"primaryKey" json:"connectionId"` + ScopeId string `gorm:"primaryKey;type:varchar(255)" json:"scopeId"` + Day time.Time `gorm:"primaryKey;type:date" json:"day"` + Language string `gorm:"primaryKey;type:varchar(50)" json:"language"` + Feature string `gorm:"primaryKey;type:varchar(100)" json:"feature"` + + CopilotCodeMetrics `mapstructure:",squash"` + common.NoPKModel +} + +func (GhCopilotMetricsByLanguageFeature) TableName() string { + return "_tool_copilot_metrics_by_language_feature" +} + +// GhCopilotMetricsByLanguageModel stores metrics broken down by language and model. +type GhCopilotMetricsByLanguageModel struct { + ConnectionId uint64 `gorm:"primaryKey" json:"connectionId"` + ScopeId string `gorm:"primaryKey;type:varchar(255)" json:"scopeId"` + Day time.Time `gorm:"primaryKey;type:date" json:"day"` + Language string `gorm:"primaryKey;type:varchar(50)" json:"language"` + Model string `gorm:"primaryKey;type:varchar(100)" json:"model"` + + CopilotCodeMetrics `mapstructure:",squash"` + common.NoPKModel +} + +func (GhCopilotMetricsByLanguageModel) TableName() string { + return "_tool_copilot_metrics_by_language_model" +} + +// GhCopilotMetricsByModelFeature stores metrics broken down by model and feature. +type GhCopilotMetricsByModelFeature struct { + ConnectionId uint64 `gorm:"primaryKey" json:"connectionId"` + ScopeId string `gorm:"primaryKey;type:varchar(255)" json:"scopeId"` + Day time.Time `gorm:"primaryKey;type:date" json:"day"` + Model string `gorm:"primaryKey;type:varchar(100)" json:"model"` + Feature string `gorm:"primaryKey;type:varchar(100)" json:"feature"` + + CopilotActivityMetrics `mapstructure:",squash"` + common.NoPKModel +} + +func (GhCopilotMetricsByModelFeature) TableName() string { + return "_tool_copilot_metrics_by_model_feature" +} diff --git a/backend/plugins/gh-copilot/models/language_metrics.go b/backend/plugins/gh-copilot/models/language_metrics.go new file mode 100644 index 00000000000..c183abd8309 --- /dev/null +++ b/backend/plugins/gh-copilot/models/language_metrics.go @@ -0,0 +1,45 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package models + +import ( + "time" + + "github.com/apache/incubator-devlake/core/models/common" +) + +// GhCopilotLanguageMetrics represents engagement statistics broken down by editor and language +// from the organization usage report downloads. +type GhCopilotLanguageMetrics struct { + ConnectionId uint64 `gorm:"primaryKey"` + ScopeId string `gorm:"primaryKey;type:varchar(255)"` + Date time.Time `gorm:"primaryKey;type:date"` + Editor string `gorm:"primaryKey;type:varchar(50)"` + Language string `gorm:"primaryKey;type:varchar(50)"` + + EngagedUsers int `json:"engagedUsers"` + Suggestions int `json:"suggestions"` + Acceptances int `json:"acceptances"` + LinesSuggested int `json:"linesSuggested"` + LinesAccepted int `json:"linesAccepted"` + common.NoPKModel +} + +func (GhCopilotLanguageMetrics) TableName() string { + return "_tool_copilot_org_language_metrics" +} diff --git a/backend/plugins/gh-copilot/models/migrationscripts/20250100_initialize.go b/backend/plugins/gh-copilot/models/migrationscripts/20250100_initialize.go new file mode 100644 index 00000000000..95acfe09a20 --- /dev/null +++ b/backend/plugins/gh-copilot/models/migrationscripts/20250100_initialize.go @@ -0,0 +1,47 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package migrationscripts + +import ( + "github.com/apache/incubator-devlake/core/context" + "github.com/apache/incubator-devlake/core/errors" + "github.com/apache/incubator-devlake/helpers/migrationhelper" + "github.com/apache/incubator-devlake/plugins/gh-copilot/models" +) + +// addCopilotInitialTables creates the initial Copilot tool-layer tables. +type addCopilotInitialTables struct{} + +func (script *addCopilotInitialTables) Up(basicRes context.BasicRes) errors.Error { + return migrationhelper.AutoMigrateTables( + basicRes, + &models.GhCopilotConnection{}, + &models.GhCopilotScope{}, + &models.GhCopilotOrgMetrics{}, + &models.GhCopilotLanguageMetrics{}, + &models.GhCopilotSeat{}, + ) +} + +func (*addCopilotInitialTables) Version() uint64 { + return 20250100000000 +} + +func (*addCopilotInitialTables) Name() string { + return "copilot init tables" +} diff --git a/backend/plugins/gh-copilot/models/migrationscripts/20260104_add_raw_data_origin_to_language_metrics.go b/backend/plugins/gh-copilot/models/migrationscripts/20260104_add_raw_data_origin_to_language_metrics.go new file mode 100644 index 00000000000..67b7d6561bc --- /dev/null +++ b/backend/plugins/gh-copilot/models/migrationscripts/20260104_add_raw_data_origin_to_language_metrics.go @@ -0,0 +1,44 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package migrationscripts + +import ( + "github.com/apache/incubator-devlake/core/context" + "github.com/apache/incubator-devlake/core/errors" + "github.com/apache/incubator-devlake/helpers/migrationhelper" + "github.com/apache/incubator-devlake/plugins/gh-copilot/models" +) + +// addRawDataOriginToCopilotLanguageMetrics ensures _tool_copilot_language_metrics includes RawDataOrigin columns. +// This is required by StatefulApiExtractor, which attaches provenance to extracted records. +type addRawDataOriginToCopilotLanguageMetrics struct{} + +func (script *addRawDataOriginToCopilotLanguageMetrics) Up(basicRes context.BasicRes) errors.Error { + return migrationhelper.AutoMigrateTables( + basicRes, + &models.GhCopilotLanguageMetrics{}, + ) +} + +func (*addRawDataOriginToCopilotLanguageMetrics) Version() uint64 { + return 20260104000000 +} + +func (*addRawDataOriginToCopilotLanguageMetrics) Name() string { + return "copilot add raw data origin to language metrics" +} diff --git a/backend/plugins/gh-copilot/models/migrationscripts/20260105_add_raw_data_origin_to_seats.go b/backend/plugins/gh-copilot/models/migrationscripts/20260105_add_raw_data_origin_to_seats.go new file mode 100644 index 00000000000..cf066d03630 --- /dev/null +++ b/backend/plugins/gh-copilot/models/migrationscripts/20260105_add_raw_data_origin_to_seats.go @@ -0,0 +1,44 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package migrationscripts + +import ( + "github.com/apache/incubator-devlake/core/context" + "github.com/apache/incubator-devlake/core/errors" + "github.com/apache/incubator-devlake/helpers/migrationhelper" + "github.com/apache/incubator-devlake/plugins/gh-copilot/models" +) + +// addRawDataOriginToCopilotSeats ensures _tool_copilot_seats includes RawDataOrigin columns. +// This is required by ApiExtractor/StatefulApiExtractor, which attach provenance to extracted records. +type addRawDataOriginToCopilotSeats struct{} + +func (script *addRawDataOriginToCopilotSeats) Up(basicRes context.BasicRes) errors.Error { + return migrationhelper.AutoMigrateTables( + basicRes, + &models.GhCopilotSeat{}, + ) +} + +func (*addRawDataOriginToCopilotSeats) Version() uint64 { + return 20260105000000 +} + +func (*addRawDataOriginToCopilotSeats) Name() string { + return "copilot add raw data origin to seats" +} diff --git a/backend/plugins/gh-copilot/models/migrationscripts/20260116_add_name_fields_to_scopes.go b/backend/plugins/gh-copilot/models/migrationscripts/20260116_add_name_fields_to_scopes.go new file mode 100644 index 00000000000..ebcc6c54abb --- /dev/null +++ b/backend/plugins/gh-copilot/models/migrationscripts/20260116_add_name_fields_to_scopes.go @@ -0,0 +1,44 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package migrationscripts + +import ( + "github.com/apache/incubator-devlake/core/context" + "github.com/apache/incubator-devlake/core/errors" + "github.com/apache/incubator-devlake/helpers/migrationhelper" + "github.com/apache/incubator-devlake/plugins/gh-copilot/models" +) + +// addNameFieldsToScopes adds name and fullName columns to _tool_copilot_scopes. +// These fields are required by the UI for displaying data scopes in the connection page. +type addNameFieldsToScopes struct{} + +func (script *addNameFieldsToScopes) Up(basicRes context.BasicRes) errors.Error { + return migrationhelper.AutoMigrateTables( + basicRes, + &models.GhCopilotScope{}, + ) +} + +func (*addNameFieldsToScopes) Version() uint64 { + return 20260116000000 +} + +func (*addNameFieldsToScopes) Name() string { + return "copilot add name fields to scopes" +} diff --git a/backend/plugins/gh-copilot/models/migrationscripts/20260121_add_scope_configs.go b/backend/plugins/gh-copilot/models/migrationscripts/20260121_add_scope_configs.go new file mode 100644 index 00000000000..66d3b8bdbbd --- /dev/null +++ b/backend/plugins/gh-copilot/models/migrationscripts/20260121_add_scope_configs.go @@ -0,0 +1,51 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package migrationscripts + +import ( + "time" + + "github.com/apache/incubator-devlake/core/context" + "github.com/apache/incubator-devlake/core/errors" + "github.com/apache/incubator-devlake/core/models/common" + "github.com/apache/incubator-devlake/helpers/migrationhelper" +) + +type addScopeConfig20260121 struct{} + +type scopeConfig20260121 struct { + common.ScopeConfig `mapstructure:",squash" json:",inline" gorm:"embedded"` + ImplementationDate *time.Time `json:"implementationDate" mapstructure:"implementationDate" gorm:"type:datetime"` + BaselinePeriodDays int `json:"baselinePeriodDays" mapstructure:"baselinePeriodDays" gorm:"default:90"` +} + +func (scopeConfig20260121) TableName() string { + return "_tool_copilot_scope_configs" +} + +func (*addScopeConfig20260121) Up(basicRes context.BasicRes) errors.Error { + return migrationhelper.AutoMigrateTables(basicRes, &scopeConfig20260121{}) +} + +func (*addScopeConfig20260121) Version() uint64 { + return 20260121000000 +} + +func (*addScopeConfig20260121) Name() string { + return "Add scope_configs table for GitHub Copilot impact analysis" +} diff --git a/backend/plugins/gh-copilot/models/migrationscripts/20260212_add_pr_fields_to_enterprise_metrics.go b/backend/plugins/gh-copilot/models/migrationscripts/20260212_add_pr_fields_to_enterprise_metrics.go new file mode 100644 index 00000000000..b2eacaa5dc1 --- /dev/null +++ b/backend/plugins/gh-copilot/models/migrationscripts/20260212_add_pr_fields_to_enterprise_metrics.go @@ -0,0 +1,349 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package migrationscripts + +import ( + "time" + + "github.com/apache/incubator-devlake/core/context" + "github.com/apache/incubator-devlake/core/errors" + "github.com/apache/incubator-devlake/core/models/common" + "github.com/apache/incubator-devlake/helpers/migrationhelper" +) + +type addPRFieldsToEnterpriseMetrics struct{} + +// Properly exported embedded struct for activity metrics +type ActivityMetrics20260212v2 struct { + UserInitiatedInteractionCount int + CodeGenerationActivityCount int + CodeAcceptanceActivityCount int + LocSuggestedToAddSum int + LocSuggestedToDeleteSum int + LocAddedSum int + LocDeletedSum int +} + +type CodeMetrics20260212v2 struct { + CodeGenerationActivityCount int + CodeAcceptanceActivityCount int + LocSuggestedToAddSum int + LocSuggestedToDeleteSum int + LocAddedSum int + LocDeletedSum int +} + +// Enterprise daily metrics with all fields including PR stats +type enterpriseDailyMetrics20260212v2 struct { + ConnectionId uint64 `gorm:"primaryKey"` + ScopeId string `gorm:"primaryKey;type:varchar(255)"` + Day time.Time `gorm:"primaryKey;type:date"` + EnterpriseId string `gorm:"type:varchar(100)"` + DailyActiveUsers int + WeeklyActiveUsers int + MonthlyActiveUsers int + MonthlyActiveChatUsers int + MonthlyActiveAgentUsers int + UserInitiatedInteractionCount int + CodeGenerationActivityCount int + CodeAcceptanceActivityCount int + LocSuggestedToAddSum int + LocSuggestedToDeleteSum int + LocAddedSum int + LocDeletedSum int + PRTotalReviewed int + PRTotalCreated int + PRTotalCreatedByCopilot int + PRTotalReviewedByCopilot int + common.NoPKModel +} + +func (enterpriseDailyMetrics20260212v2) TableName() string { + return "_tool_copilot_enterprise_daily_metrics" +} + +type metricsByIde20260212v2 struct { + ConnectionId uint64 `gorm:"primaryKey"` + ScopeId string `gorm:"primaryKey;type:varchar(255)"` + Day time.Time `gorm:"primaryKey;type:date"` + Ide string `gorm:"primaryKey;type:varchar(50)"` + UserInitiatedInteractionCount int + CodeGenerationActivityCount int + CodeAcceptanceActivityCount int + LocSuggestedToAddSum int + LocSuggestedToDeleteSum int + LocAddedSum int + LocDeletedSum int + common.NoPKModel +} + +func (metricsByIde20260212v2) TableName() string { + return "_tool_copilot_metrics_by_ide" +} + +type metricsByFeature20260212v2 struct { + ConnectionId uint64 `gorm:"primaryKey"` + ScopeId string `gorm:"primaryKey;type:varchar(255)"` + Day time.Time `gorm:"primaryKey;type:date"` + Feature string `gorm:"primaryKey;type:varchar(100)"` + UserInitiatedInteractionCount int + CodeGenerationActivityCount int + CodeAcceptanceActivityCount int + LocSuggestedToAddSum int + LocSuggestedToDeleteSum int + LocAddedSum int + LocDeletedSum int + common.NoPKModel +} + +func (metricsByFeature20260212v2) TableName() string { + return "_tool_copilot_metrics_by_feature" +} + +type metricsByLanguageFeature20260212v2 struct { + ConnectionId uint64 `gorm:"primaryKey"` + ScopeId string `gorm:"primaryKey;type:varchar(255)"` + Day time.Time `gorm:"primaryKey;type:date"` + Language string `gorm:"primaryKey;type:varchar(50)"` + Feature string `gorm:"primaryKey;type:varchar(100)"` + CodeGenerationActivityCount int + CodeAcceptanceActivityCount int + LocSuggestedToAddSum int + LocSuggestedToDeleteSum int + LocAddedSum int + LocDeletedSum int + common.NoPKModel +} + +func (metricsByLanguageFeature20260212v2) TableName() string { + return "_tool_copilot_metrics_by_language_feature" +} + +type metricsByLanguageModel20260212v2 struct { + ConnectionId uint64 `gorm:"primaryKey"` + ScopeId string `gorm:"primaryKey;type:varchar(255)"` + Day time.Time `gorm:"primaryKey;type:date"` + Language string `gorm:"primaryKey;type:varchar(50)"` + Model string `gorm:"primaryKey;type:varchar(100)"` + CodeGenerationActivityCount int + CodeAcceptanceActivityCount int + LocSuggestedToAddSum int + LocSuggestedToDeleteSum int + LocAddedSum int + LocDeletedSum int + common.NoPKModel +} + +func (metricsByLanguageModel20260212v2) TableName() string { + return "_tool_copilot_metrics_by_language_model" +} + +type metricsByModelFeature20260212v2 struct { + ConnectionId uint64 `gorm:"primaryKey"` + ScopeId string `gorm:"primaryKey;type:varchar(255)"` + Day time.Time `gorm:"primaryKey;type:date"` + Model string `gorm:"primaryKey;type:varchar(100)"` + Feature string `gorm:"primaryKey;type:varchar(100)"` + UserInitiatedInteractionCount int + CodeGenerationActivityCount int + CodeAcceptanceActivityCount int + LocSuggestedToAddSum int + LocSuggestedToDeleteSum int + LocAddedSum int + LocDeletedSum int + common.NoPKModel +} + +func (metricsByModelFeature20260212v2) TableName() string { + return "_tool_copilot_metrics_by_model_feature" +} + +// User metrics tables +type userDailyMetrics20260212v2 struct { + ConnectionId uint64 `gorm:"primaryKey"` + ScopeId string `gorm:"primaryKey;type:varchar(255)"` + Day time.Time `gorm:"primaryKey;type:date"` + UserId int64 `gorm:"primaryKey"` + EnterpriseId string `gorm:"type:varchar(100)"` + UserLogin string `gorm:"type:varchar(255);index"` + UsedAgent bool + UsedChat bool + UserInitiatedInteractionCount int + CodeGenerationActivityCount int + CodeAcceptanceActivityCount int + LocSuggestedToAddSum int + LocSuggestedToDeleteSum int + LocAddedSum int + LocDeletedSum int + common.NoPKModel +} + +func (userDailyMetrics20260212v2) TableName() string { + return "_tool_copilot_user_daily_metrics" +} + +type userMetricsByIde20260212v2 struct { + ConnectionId uint64 `gorm:"primaryKey"` + ScopeId string `gorm:"primaryKey;type:varchar(255)"` + Day time.Time `gorm:"primaryKey;type:date"` + UserId int64 `gorm:"primaryKey"` + Ide string `gorm:"primaryKey;type:varchar(50)"` + LastKnownPluginName string `gorm:"type:varchar(100)"` + LastKnownPluginVersion string `gorm:"type:varchar(50)"` + LastKnownIdeVersion string `gorm:"type:varchar(50)"` + UserInitiatedInteractionCount int + CodeGenerationActivityCount int + CodeAcceptanceActivityCount int + LocSuggestedToAddSum int + LocSuggestedToDeleteSum int + LocAddedSum int + LocDeletedSum int + common.NoPKModel +} + +func (userMetricsByIde20260212v2) TableName() string { + return "_tool_copilot_user_metrics_by_ide" +} + +type userMetricsByFeature20260212v2 struct { + ConnectionId uint64 `gorm:"primaryKey"` + ScopeId string `gorm:"primaryKey;type:varchar(255)"` + Day time.Time `gorm:"primaryKey;type:date"` + UserId int64 `gorm:"primaryKey"` + Feature string `gorm:"primaryKey;type:varchar(100)"` + UserInitiatedInteractionCount int + CodeGenerationActivityCount int + CodeAcceptanceActivityCount int + LocSuggestedToAddSum int + LocSuggestedToDeleteSum int + LocAddedSum int + LocDeletedSum int + common.NoPKModel +} + +func (userMetricsByFeature20260212v2) TableName() string { + return "_tool_copilot_user_metrics_by_feature" +} + +type userMetricsByLanguageFeature20260212v2 struct { + ConnectionId uint64 `gorm:"primaryKey"` + ScopeId string `gorm:"primaryKey;type:varchar(255)"` + Day time.Time `gorm:"primaryKey;type:date"` + UserId int64 `gorm:"primaryKey"` + Language string `gorm:"primaryKey;type:varchar(50)"` + Feature string `gorm:"primaryKey;type:varchar(100)"` + CodeGenerationActivityCount int + CodeAcceptanceActivityCount int + LocSuggestedToAddSum int + LocSuggestedToDeleteSum int + LocAddedSum int + LocDeletedSum int + common.NoPKModel +} + +func (userMetricsByLanguageFeature20260212v2) TableName() string { + return "_tool_copilot_user_metrics_by_language_feature" +} + +type userMetricsByLanguageModel20260212v2 struct { + ConnectionId uint64 `gorm:"primaryKey"` + ScopeId string `gorm:"primaryKey;type:varchar(255)"` + Day time.Time `gorm:"primaryKey;type:date"` + UserId int64 `gorm:"primaryKey"` + Language string `gorm:"primaryKey;type:varchar(50)"` + Model string `gorm:"primaryKey;type:varchar(100)"` + CodeGenerationActivityCount int + CodeAcceptanceActivityCount int + LocSuggestedToAddSum int + LocSuggestedToDeleteSum int + LocAddedSum int + LocDeletedSum int + common.NoPKModel +} + +func (userMetricsByLanguageModel20260212v2) TableName() string { + return "_tool_copilot_user_metrics_by_language_model" +} + +type userMetricsByModelFeature20260212v2 struct { + ConnectionId uint64 `gorm:"primaryKey"` + ScopeId string `gorm:"primaryKey;type:varchar(255)"` + Day time.Time `gorm:"primaryKey;type:date"` + UserId int64 `gorm:"primaryKey"` + Model string `gorm:"primaryKey;type:varchar(100)"` + Feature string `gorm:"primaryKey;type:varchar(100)"` + UserInitiatedInteractionCount int + CodeGenerationActivityCount int + CodeAcceptanceActivityCount int + LocSuggestedToAddSum int + LocSuggestedToDeleteSum int + LocAddedSum int + LocDeletedSum int + common.NoPKModel +} + +func (userMetricsByModelFeature20260212v2) TableName() string { + return "_tool_copilot_user_metrics_by_model_feature" +} + +func (script *addPRFieldsToEnterpriseMetrics) Up(basicRes context.BasicRes) errors.Error { + db := basicRes.GetDal() + + // Drop all metric tables created by v2 migration (they have missing columns + // due to unexported embedded struct bug in GORM). Data will be re-collected. + if err := db.DropTables( + "_tool_copilot_enterprise_daily_metrics", + "_tool_copilot_metrics_by_ide", + "_tool_copilot_metrics_by_feature", + "_tool_copilot_metrics_by_language_feature", + "_tool_copilot_metrics_by_language_model", + "_tool_copilot_metrics_by_model_feature", + "_tool_copilot_user_daily_metrics", + "_tool_copilot_user_metrics_by_ide", + "_tool_copilot_user_metrics_by_feature", + "_tool_copilot_user_metrics_by_language_feature", + "_tool_copilot_user_metrics_by_language_model", + "_tool_copilot_user_metrics_by_model_feature", + ); err != nil { + basicRes.GetLogger().Warn(err, "Failed to drop tables for recreation") + } + + // Recreate with all columns properly defined (no embedded structs) + return migrationhelper.AutoMigrateTables(basicRes, + &enterpriseDailyMetrics20260212v2{}, + &metricsByIde20260212v2{}, + &metricsByFeature20260212v2{}, + &metricsByLanguageFeature20260212v2{}, + &metricsByLanguageModel20260212v2{}, + &metricsByModelFeature20260212v2{}, + &userDailyMetrics20260212v2{}, + &userMetricsByIde20260212v2{}, + &userMetricsByFeature20260212v2{}, + &userMetricsByLanguageFeature20260212v2{}, + &userMetricsByLanguageModel20260212v2{}, + &userMetricsByModelFeature20260212v2{}, + ) +} + +func (*addPRFieldsToEnterpriseMetrics) Version() uint64 { + return 20260212100000 +} + +func (*addPRFieldsToEnterpriseMetrics) Name() string { + return "Recreate metric tables with all columns and PR fields" +} diff --git a/backend/plugins/gh-copilot/models/migrationscripts/20260212_v2_usage_metrics.go b/backend/plugins/gh-copilot/models/migrationscripts/20260212_v2_usage_metrics.go new file mode 100644 index 00000000000..72d82a467ee --- /dev/null +++ b/backend/plugins/gh-copilot/models/migrationscripts/20260212_v2_usage_metrics.go @@ -0,0 +1,349 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package migrationscripts + +import ( + "time" + + "github.com/apache/incubator-devlake/core/context" + "github.com/apache/incubator-devlake/core/errors" + "github.com/apache/incubator-devlake/core/models/common" + "github.com/apache/incubator-devlake/helpers/migrationhelper" +) + +type migrateToUsageMetricsV2 struct{} + +// --- Snapshot structs for migration (avoid importing models package to prevent drift) --- + +// Connection: add Enterprise column +type connection20260212 struct { + Enterprise string `gorm:"type:varchar(100)"` +} + +func (connection20260212) TableName() string { + return "_tool_copilot_connections" +} + +// Scope: add Enterprise column +type scope20260212 struct { + Enterprise string `gorm:"type:varchar(100)"` +} + +func (scope20260212) TableName() string { + return "_tool_copilot_scopes" +} + +// --- Common embedded structs --- + +type activityMetrics20260212 struct { + UserInitiatedInteractionCount int `gorm:"comment:Chat messages and inline prompts initiated by user"` + CodeGenerationActivityCount int + CodeAcceptanceActivityCount int + LocSuggestedToAddSum int + LocSuggestedToDeleteSum int + LocAddedSum int + LocDeletedSum int +} + +type codeMetrics20260212 struct { + CodeGenerationActivityCount int + CodeAcceptanceActivityCount int + LocSuggestedToAddSum int + LocSuggestedToDeleteSum int + LocAddedSum int + LocDeletedSum int +} + +// --- Enterprise metrics tables --- + +type enterpriseDailyMetrics20260212 struct { + ConnectionId uint64 `gorm:"primaryKey"` + ScopeId string `gorm:"primaryKey;type:varchar(255)"` + Day time.Time `gorm:"primaryKey;type:date"` + EnterpriseId string `gorm:"type:varchar(100)"` + DailyActiveUsers int + WeeklyActiveUsers int + MonthlyActiveUsers int + MonthlyActiveChatUsers int + MonthlyActiveAgentUsers int + activityMetrics20260212 `gorm:"embedded"` + common.NoPKModel +} + +func (enterpriseDailyMetrics20260212) TableName() string { + return "_tool_copilot_enterprise_daily_metrics" +} + +type metricsByIde20260212 struct { + ConnectionId uint64 `gorm:"primaryKey"` + ScopeId string `gorm:"primaryKey;type:varchar(255)"` + Day time.Time `gorm:"primaryKey;type:date"` + Ide string `gorm:"primaryKey;type:varchar(50)"` + activityMetrics20260212 `gorm:"embedded"` + common.NoPKModel +} + +func (metricsByIde20260212) TableName() string { + return "_tool_copilot_metrics_by_ide" +} + +type metricsByFeature20260212 struct { + ConnectionId uint64 `gorm:"primaryKey"` + ScopeId string `gorm:"primaryKey;type:varchar(255)"` + Day time.Time `gorm:"primaryKey;type:date"` + Feature string `gorm:"primaryKey;type:varchar(100)"` + activityMetrics20260212 `gorm:"embedded"` + common.NoPKModel +} + +func (metricsByFeature20260212) TableName() string { + return "_tool_copilot_metrics_by_feature" +} + +type metricsByLanguageFeature20260212 struct { + ConnectionId uint64 `gorm:"primaryKey"` + ScopeId string `gorm:"primaryKey;type:varchar(255)"` + Day time.Time `gorm:"primaryKey;type:date"` + Language string `gorm:"primaryKey;type:varchar(50)"` + Feature string `gorm:"primaryKey;type:varchar(100)"` + codeMetrics20260212 `gorm:"embedded"` + common.NoPKModel +} + +func (metricsByLanguageFeature20260212) TableName() string { + return "_tool_copilot_metrics_by_language_feature" +} + +type metricsByLanguageModel20260212 struct { + ConnectionId uint64 `gorm:"primaryKey"` + ScopeId string `gorm:"primaryKey;type:varchar(255)"` + Day time.Time `gorm:"primaryKey;type:date"` + Language string `gorm:"primaryKey;type:varchar(50)"` + Model string `gorm:"primaryKey;type:varchar(100)"` + codeMetrics20260212 `gorm:"embedded"` + common.NoPKModel +} + +func (metricsByLanguageModel20260212) TableName() string { + return "_tool_copilot_metrics_by_language_model" +} + +type metricsByModelFeature20260212 struct { + ConnectionId uint64 `gorm:"primaryKey"` + ScopeId string `gorm:"primaryKey;type:varchar(255)"` + Day time.Time `gorm:"primaryKey;type:date"` + Model string `gorm:"primaryKey;type:varchar(100)"` + Feature string `gorm:"primaryKey;type:varchar(100)"` + activityMetrics20260212 `gorm:"embedded"` + common.NoPKModel +} + +func (metricsByModelFeature20260212) TableName() string { + return "_tool_copilot_metrics_by_model_feature" +} + +// --- User metrics tables --- + +type userDailyMetrics20260212 struct { + ConnectionId uint64 `gorm:"primaryKey"` + ScopeId string `gorm:"primaryKey;type:varchar(255)"` + Day time.Time `gorm:"primaryKey;type:date"` + UserId int64 `gorm:"primaryKey"` + EnterpriseId string `gorm:"type:varchar(100)"` + UserLogin string `gorm:"type:varchar(255);index"` + UsedAgent bool + UsedChat bool + activityMetrics20260212 `gorm:"embedded"` + common.NoPKModel +} + +func (userDailyMetrics20260212) TableName() string { + return "_tool_copilot_user_daily_metrics" +} + +type userMetricsByIde20260212 struct { + ConnectionId uint64 `gorm:"primaryKey"` + ScopeId string `gorm:"primaryKey;type:varchar(255)"` + Day time.Time `gorm:"primaryKey;type:date"` + UserId int64 `gorm:"primaryKey"` + Ide string `gorm:"primaryKey;type:varchar(50)"` + LastKnownPluginName string `gorm:"type:varchar(100)"` + LastKnownPluginVersion string `gorm:"type:varchar(50)"` + LastKnownIdeVersion string `gorm:"type:varchar(50)"` + activityMetrics20260212 `gorm:"embedded"` + common.NoPKModel +} + +func (userMetricsByIde20260212) TableName() string { + return "_tool_copilot_user_metrics_by_ide" +} + +type userMetricsByFeature20260212 struct { + ConnectionId uint64 `gorm:"primaryKey"` + ScopeId string `gorm:"primaryKey;type:varchar(255)"` + Day time.Time `gorm:"primaryKey;type:date"` + UserId int64 `gorm:"primaryKey"` + Feature string `gorm:"primaryKey;type:varchar(100)"` + activityMetrics20260212 `gorm:"embedded"` + common.NoPKModel +} + +func (userMetricsByFeature20260212) TableName() string { + return "_tool_copilot_user_metrics_by_feature" +} + +type userMetricsByLanguageFeature20260212 struct { + ConnectionId uint64 `gorm:"primaryKey"` + ScopeId string `gorm:"primaryKey;type:varchar(255)"` + Day time.Time `gorm:"primaryKey;type:date"` + UserId int64 `gorm:"primaryKey"` + Language string `gorm:"primaryKey;type:varchar(50)"` + Feature string `gorm:"primaryKey;type:varchar(100)"` + codeMetrics20260212 `gorm:"embedded"` + common.NoPKModel +} + +func (userMetricsByLanguageFeature20260212) TableName() string { + return "_tool_copilot_user_metrics_by_language_feature" +} + +type userMetricsByLanguageModel20260212 struct { + ConnectionId uint64 `gorm:"primaryKey"` + ScopeId string `gorm:"primaryKey;type:varchar(255)"` + Day time.Time `gorm:"primaryKey;type:date"` + UserId int64 `gorm:"primaryKey"` + Language string `gorm:"primaryKey;type:varchar(50)"` + Model string `gorm:"primaryKey;type:varchar(100)"` + codeMetrics20260212 `gorm:"embedded"` + common.NoPKModel +} + +func (userMetricsByLanguageModel20260212) TableName() string { + return "_tool_copilot_user_metrics_by_language_model" +} + +type userMetricsByModelFeature20260212 struct { + ConnectionId uint64 `gorm:"primaryKey"` + ScopeId string `gorm:"primaryKey;type:varchar(255)"` + Day time.Time `gorm:"primaryKey;type:date"` + UserId int64 `gorm:"primaryKey"` + Model string `gorm:"primaryKey;type:varchar(100)"` + Feature string `gorm:"primaryKey;type:varchar(100)"` + activityMetrics20260212 `gorm:"embedded"` + common.NoPKModel +} + +func (userMetricsByModelFeature20260212) TableName() string { + return "_tool_copilot_user_metrics_by_model_feature" +} + +// --- New org metrics tables (replacing old ones) --- + +type orgDailyMetrics20260212 struct { + ConnectionId uint64 `gorm:"primaryKey"` + ScopeId string `gorm:"primaryKey;type:varchar(255)"` + Date time.Time `gorm:"primaryKey;type:date"` + TotalActiveUsers int + TotalEngagedUsers int + CompletionSuggestions int + CompletionAcceptances int + CompletionLinesSuggested int + CompletionLinesAccepted int + IdeChats int + IdeChatCopyEvents int + IdeChatInsertionEvents int + IdeChatEngagedUsers int + DotcomChats int + DotcomChatEngagedUsers int + PRSummariesCreated int + PREngagedUsers int + SeatActiveCount int + SeatTotal int + common.NoPKModel +} + +func (orgDailyMetrics20260212) TableName() string { + return "_tool_copilot_org_daily_metrics" +} + +type orgLanguageMetrics20260212 struct { + ConnectionId uint64 `gorm:"primaryKey"` + ScopeId string `gorm:"primaryKey;type:varchar(255)"` + Date time.Time `gorm:"primaryKey;type:date"` + Editor string `gorm:"primaryKey;type:varchar(50)"` + Language string `gorm:"primaryKey;type:varchar(50)"` + EngagedUsers int + Suggestions int + Acceptances int + LinesSuggested int + LinesAccepted int + common.NoPKModel +} + +func (orgLanguageMetrics20260212) TableName() string { + return "_tool_copilot_org_language_metrics" +} + +func (script *migrateToUsageMetricsV2) Up(basicRes context.BasicRes) errors.Error { + db := basicRes.GetDal() + + // Drop legacy metrics tables (data must be re-collected from new API) + if err := db.DropTables( + "_tool_copilot_org_metrics", + "_tool_copilot_language_metrics", + ); err != nil { + basicRes.GetLogger().Warn(err, "Failed to drop legacy copilot tables (may not exist)") + } + + // Add Enterprise column to connections and scopes + if err := migrationhelper.AutoMigrateTables(basicRes, + &connection20260212{}, + &scope20260212{}, + ); err != nil { + return err + } + + // Create all new tables + return migrationhelper.AutoMigrateTables(basicRes, + // New org metrics (replacing dropped tables) + &orgDailyMetrics20260212{}, + &orgLanguageMetrics20260212{}, + // Enterprise metrics + &enterpriseDailyMetrics20260212{}, + &metricsByIde20260212{}, + &metricsByFeature20260212{}, + &metricsByLanguageFeature20260212{}, + &metricsByLanguageModel20260212{}, + &metricsByModelFeature20260212{}, + // User metrics + &userDailyMetrics20260212{}, + &userMetricsByIde20260212{}, + &userMetricsByFeature20260212{}, + &userMetricsByLanguageFeature20260212{}, + &userMetricsByLanguageModel20260212{}, + &userMetricsByModelFeature20260212{}, + ) +} + +func (*migrateToUsageMetricsV2) Version() uint64 { + return 20260212000000 +} + +func (*migrateToUsageMetricsV2) Name() string { + return "Migrate GitHub Copilot to Usage Metrics Report API v2" +} diff --git a/backend/plugins/gh-copilot/models/migrationscripts/register.go b/backend/plugins/gh-copilot/models/migrationscripts/register.go new file mode 100644 index 00000000000..5e275f943bf --- /dev/null +++ b/backend/plugins/gh-copilot/models/migrationscripts/register.go @@ -0,0 +1,33 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package migrationscripts + +import "github.com/apache/incubator-devlake/core/plugin" + +// All returns the ordered list of migration scripts for the Copilot plugin. +func All() []plugin.MigrationScript { + return []plugin.MigrationScript{ + new(addCopilotInitialTables), + new(addRawDataOriginToCopilotSeats), + new(addRawDataOriginToCopilotLanguageMetrics), + new(addNameFieldsToScopes), + new(addScopeConfig20260121), + new(migrateToUsageMetricsV2), + new(addPRFieldsToEnterpriseMetrics), + } +} diff --git a/backend/plugins/gh-copilot/models/models.go b/backend/plugins/gh-copilot/models/models.go new file mode 100644 index 00000000000..f223c821827 --- /dev/null +++ b/backend/plugins/gh-copilot/models/models.go @@ -0,0 +1,49 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package models + +import "github.com/apache/incubator-devlake/core/dal" + +// GetTablesInfo returns the list of tool-layer tables managed by the Copilot plugin. +func GetTablesInfo() []dal.Tabler { + return []dal.Tabler{ + // Connection, scope, config + &GhCopilotConnection{}, + &GhCopilotScope{}, + &GhCopilotScopeConfig{}, + // Org-level metrics (from organization reports) + &GhCopilotOrgMetrics{}, + &GhCopilotLanguageMetrics{}, + // Enterprise-level metrics (from enterprise reports) + &GhCopilotEnterpriseDailyMetrics{}, + &GhCopilotMetricsByIde{}, + &GhCopilotMetricsByFeature{}, + &GhCopilotMetricsByLanguageFeature{}, + &GhCopilotMetricsByLanguageModel{}, + &GhCopilotMetricsByModelFeature{}, + // User-level metrics (from enterprise user reports) + &GhCopilotUserDailyMetrics{}, + &GhCopilotUserMetricsByIde{}, + &GhCopilotUserMetricsByFeature{}, + &GhCopilotUserMetricsByLanguageFeature{}, + &GhCopilotUserMetricsByLanguageModel{}, + &GhCopilotUserMetricsByModelFeature{}, + // Seat assignments + &GhCopilotSeat{}, + } +} diff --git a/backend/plugins/gh-copilot/models/models_test.go b/backend/plugins/gh-copilot/models/models_test.go new file mode 100644 index 00000000000..72ead8a65e9 --- /dev/null +++ b/backend/plugins/gh-copilot/models/models_test.go @@ -0,0 +1,62 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package models + +import "testing" + +func TestGetTablesInfo(t *testing.T) { + tables := GetTablesInfo() + expected := map[string]bool{ + (&GhCopilotConnection{}).TableName(): false, + (&GhCopilotScope{}).TableName(): false, + (&GhCopilotScopeConfig{}).TableName(): false, + (&GhCopilotOrgMetrics{}).TableName(): false, + (&GhCopilotLanguageMetrics{}).TableName(): false, + (&GhCopilotEnterpriseDailyMetrics{}).TableName(): false, + (&GhCopilotMetricsByIde{}).TableName(): false, + (&GhCopilotMetricsByFeature{}).TableName(): false, + (&GhCopilotMetricsByLanguageFeature{}).TableName(): false, + (&GhCopilotMetricsByLanguageModel{}).TableName(): false, + (&GhCopilotMetricsByModelFeature{}).TableName(): false, + (&GhCopilotUserDailyMetrics{}).TableName(): false, + (&GhCopilotUserMetricsByIde{}).TableName(): false, + (&GhCopilotUserMetricsByFeature{}).TableName(): false, + (&GhCopilotUserMetricsByLanguageFeature{}).TableName(): false, + (&GhCopilotUserMetricsByLanguageModel{}).TableName(): false, + (&GhCopilotUserMetricsByModelFeature{}).TableName(): false, + (&GhCopilotSeat{}).TableName(): false, + } + + if len(tables) != len(expected) { + t.Fatalf("unexpected number of tables: want %d, got %d", len(expected), len(tables)) + } + + for _, table := range tables { + tableName := table.TableName() + if _, ok := expected[tableName]; !ok { + t.Fatalf("unexpected table registered: %s", tableName) + } + expected[tableName] = true + } + + for name, seen := range expected { + if !seen { + t.Fatalf("table not registered: %s", name) + } + } +} diff --git a/backend/plugins/gh-copilot/models/org_metrics.go b/backend/plugins/gh-copilot/models/org_metrics.go new file mode 100644 index 00000000000..22401c80e35 --- /dev/null +++ b/backend/plugins/gh-copilot/models/org_metrics.go @@ -0,0 +1,55 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package models + +import ( + "time" + + "github.com/apache/incubator-devlake/core/models/common" +) + +// GhCopilotOrgMetrics captures daily organization-level Copilot adoption metrics +// from the organization usage report downloads. +type GhCopilotOrgMetrics struct { + ConnectionId uint64 `gorm:"primaryKey" json:"connectionId"` + ScopeId string `gorm:"primaryKey;type:varchar(255)" json:"scopeId"` + Date time.Time `gorm:"primaryKey;type:date" json:"date"` + + TotalActiveUsers int `json:"totalActiveUsers"` + TotalEngagedUsers int `json:"totalEngagedUsers"` + CompletionSuggestions int `json:"completionSuggestions"` + CompletionAcceptances int `json:"completionAcceptances"` + CompletionLinesSuggested int `json:"completionLinesSuggested"` + CompletionLinesAccepted int `json:"completionLinesAccepted"` + IdeChats int `json:"ideChats"` + IdeChatCopyEvents int `json:"ideChatCopyEvents"` + IdeChatInsertionEvents int `json:"ideChatInsertionEvents"` + IdeChatEngagedUsers int `json:"ideChatEngagedUsers"` + DotcomChats int `json:"dotcomChats"` + DotcomChatEngagedUsers int `json:"dotcomChatEngagedUsers"` + PRSummariesCreated int `json:"prSummariesCreated"` + PREngagedUsers int `json:"prEngagedUsers"` + SeatActiveCount int `json:"seatActiveCount"` + SeatTotal int `json:"seatTotal"` + + common.NoPKModel +} + +func (GhCopilotOrgMetrics) TableName() string { + return "_tool_copilot_org_daily_metrics" +} diff --git a/backend/plugins/gh-copilot/models/scope.go b/backend/plugins/gh-copilot/models/scope.go new file mode 100644 index 00000000000..426f30169a2 --- /dev/null +++ b/backend/plugins/gh-copilot/models/scope.go @@ -0,0 +1,96 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package models + +import ( + "strings" + "time" + + "github.com/apache/incubator-devlake/core/models/common" + "github.com/apache/incubator-devlake/core/plugin" + "gorm.io/gorm" +) + +// GhCopilotScope represents an organization or enterprise-level collection scope. +type GhCopilotScope struct { + common.Scope `mapstructure:",squash"` + Id string `json:"id" mapstructure:"id" gorm:"primaryKey;type:varchar(255)"` + Organization string `json:"organization" mapstructure:"organization" gorm:"type:varchar(255)"` + Enterprise string `json:"enterprise,omitempty" mapstructure:"enterprise" gorm:"type:varchar(100)"` + Name string `json:"name" mapstructure:"name" gorm:"type:varchar(255)"` + FullName string `json:"fullName" mapstructure:"fullName" gorm:"type:varchar(255)"` + ImplementationDate *time.Time `json:"implementationDate" mapstructure:"implementationDate"` + BaselinePeriodDays int `json:"baselinePeriodDays" mapstructure:"baselinePeriodDays"` + SeatsLastSyncedAt *time.Time `json:"seatsLastSyncedAt" mapstructure:"seatsLastSyncedAt"` +} + +// IsEnterprise returns true if this scope targets an enterprise. +func (s *GhCopilotScope) IsEnterprise() bool { + return s != nil && strings.TrimSpace(s.Enterprise) != "" +} + +func (GhCopilotScope) TableName() string { + return "_tool_copilot_scopes" +} + +func (s *GhCopilotScope) BeforeSave(tx *gorm.DB) error { + // Populate Name and FullName from Organization and Id + if s.Name == "" { + s.Name = s.ScopeName() + } + if s.FullName == "" { + s.FullName = s.ScopeFullName() + } + // Validate and normalize BaselinePeriodDays (7-365 range, default 90) + if s.BaselinePeriodDays < 7 { + s.BaselinePeriodDays = 90 // Default to 90 days + } else if s.BaselinePeriodDays > 365 { + s.BaselinePeriodDays = 365 // Cap at 1 year + } + return nil +} + +func (s GhCopilotScope) ScopeId() string { + return s.Id +} + +func (s GhCopilotScope) ScopeName() string { + if s.Id != "" { + return s.Id + } + return s.Organization +} + +func (s GhCopilotScope) ScopeFullName() string { + return s.ScopeName() +} + +func (s GhCopilotScope) ScopeParams() interface{} { + return &GhCopilotScopeParams{ + ConnectionId: s.ConnectionId, + ScopeId: s.Id, + } +} + +// GhCopilotScopeParams is returned for blueprint configuration. +type GhCopilotScopeParams struct { + ConnectionId uint64 `json:"connectionId"` + ScopeId string `json:"scopeId"` +} + +var _ plugin.ToolLayerScope = (*GhCopilotScope)(nil) diff --git a/backend/plugins/gh-copilot/models/scope_config.go b/backend/plugins/gh-copilot/models/scope_config.go new file mode 100644 index 00000000000..2618b87e5c1 --- /dev/null +++ b/backend/plugins/gh-copilot/models/scope_config.go @@ -0,0 +1,58 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package models + +import ( + "time" + + "github.com/apache/incubator-devlake/core/models/common" + "github.com/apache/incubator-devlake/core/plugin" + "gorm.io/gorm" +) + +var _ plugin.ToolLayerScopeConfig = (*GhCopilotScopeConfig)(nil) + +// GhCopilotScopeConfig contains configuration for GitHub Copilot data scope. +// This includes settings for the Impact Dashboard analysis. +type GhCopilotScopeConfig struct { + common.ScopeConfig `mapstructure:",squash" json:",inline" gorm:"embedded"` + // ImplementationDate is the optional rollout milestone date for before/after analysis + ImplementationDate *time.Time `json:"implementationDate" mapstructure:"implementationDate" gorm:"type:datetime"` + // BaselinePeriodDays is the number of days to use for baseline comparison (default: 90) + BaselinePeriodDays int `json:"baselinePeriodDays" mapstructure:"baselinePeriodDays" gorm:"default:90"` +} + +func (GhCopilotScopeConfig) TableName() string { + return "_tool_copilot_scope_configs" +} + +// GetConnectionId implements plugin.ToolLayerScopeConfig. +func (sc GhCopilotScopeConfig) GetConnectionId() uint64 { + return sc.ConnectionId +} + +// BeforeSave validates and normalizes the scope config before saving. +func (sc *GhCopilotScopeConfig) BeforeSave(tx *gorm.DB) error { + // Validate and normalize BaselinePeriodDays (7-365 range, default 90) + if sc.BaselinePeriodDays < 7 { + sc.BaselinePeriodDays = 90 // Default to 90 days + } else if sc.BaselinePeriodDays > 365 { + sc.BaselinePeriodDays = 365 // Cap at 1 year + } + return nil +} diff --git a/backend/plugins/gh-copilot/models/scope_config_test.go b/backend/plugins/gh-copilot/models/scope_config_test.go new file mode 100644 index 00000000000..54091aaa7f1 --- /dev/null +++ b/backend/plugins/gh-copilot/models/scope_config_test.go @@ -0,0 +1,98 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package models + +import ( + "testing" + "time" +) + +func TestGhCopilotScopeConfig_TableName(t *testing.T) { + sc := GhCopilotScopeConfig{} + expected := "_tool_copilot_scope_configs" + if sc.TableName() != expected { + t.Errorf("TableName() = %v, want %v", sc.TableName(), expected) + } +} + +func TestGhCopilotScopeConfig_BeforeSave_BaselinePeriodDays(t *testing.T) { + tests := []struct { + name string + input int + expected int + }{ + {"zero defaults to 90", 0, 90}, + {"negative defaults to 90", -10, 90}, + {"below minimum (5) defaults to 90", 5, 90}, + {"below minimum (6) defaults to 90", 6, 90}, + {"minimum valid (7) unchanged", 7, 7}, + {"typical value (30) unchanged", 30, 30}, + {"default value (90) unchanged", 90, 90}, + {"high value (180) unchanged", 180, 180}, + {"maximum valid (365) unchanged", 365, 365}, + {"above maximum (400) capped to 365", 400, 365}, + {"far above maximum (1000) capped to 365", 1000, 365}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + sc := &GhCopilotScopeConfig{ + BaselinePeriodDays: tt.input, + } + err := sc.BeforeSave(nil) + if err != nil { + t.Errorf("BeforeSave() error = %v, want nil", err) + } + if sc.BaselinePeriodDays != tt.expected { + t.Errorf("BaselinePeriodDays = %v, want %v", sc.BaselinePeriodDays, tt.expected) + } + }) + } +} + +func TestGhCopilotScopeConfig_BeforeSave_PreservesOtherFields(t *testing.T) { + now := time.Now() + sc := &GhCopilotScopeConfig{ + ImplementationDate: &now, + BaselinePeriodDays: 60, + } + + err := sc.BeforeSave(nil) + if err != nil { + t.Errorf("BeforeSave() error = %v, want nil", err) + } + + // ImplementationDate should be preserved + if sc.ImplementationDate == nil || !sc.ImplementationDate.Equal(now) { + t.Error("ImplementationDate was modified unexpectedly") + } + + // BaselinePeriodDays should be unchanged (valid value) + if sc.BaselinePeriodDays != 60 { + t.Errorf("BaselinePeriodDays = %v, want 60", sc.BaselinePeriodDays) + } +} + +func TestGhCopilotScopeConfig_GetConnectionId(t *testing.T) { + sc := GhCopilotScopeConfig{} + sc.ConnectionId = 42 + + if sc.GetConnectionId() != 42 { + t.Errorf("GetConnectionId() = %v, want 42", sc.GetConnectionId()) + } +} diff --git a/backend/plugins/gh-copilot/models/scope_test.go b/backend/plugins/gh-copilot/models/scope_test.go new file mode 100644 index 00000000000..92a83952096 --- /dev/null +++ b/backend/plugins/gh-copilot/models/scope_test.go @@ -0,0 +1,168 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package models + +import "testing" + +func TestGhCopilotScope_TableName(t *testing.T) { + s := GhCopilotScope{} + expected := "_tool_copilot_scopes" + if s.TableName() != expected { + t.Errorf("TableName() = %v, want %v", s.TableName(), expected) + } +} + +func TestGhCopilotScope_ScopeName(t *testing.T) { + tests := []struct { + name string + id string + organization string + expected string + }{ + {"returns Id when set", "my-org", "fallback-org", "my-org"}, + {"returns Organization when Id empty", "", "my-org", "my-org"}, + {"returns empty when both empty", "", "", ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := GhCopilotScope{ + Id: tt.id, + Organization: tt.organization, + } + if got := s.ScopeName(); got != tt.expected { + t.Errorf("ScopeName() = %v, want %v", got, tt.expected) + } + }) + } +} + +func TestGhCopilotScope_ScopeFullName(t *testing.T) { + s := GhCopilotScope{Id: "test-org"} + if got := s.ScopeFullName(); got != "test-org" { + t.Errorf("ScopeFullName() = %v, want test-org", got) + } +} + +func TestGhCopilotScope_BeforeSave_PopulatesName(t *testing.T) { + tests := []struct { + name string + id string + organization string + initialName string + expectedName string + expectedFullName string + }{ + { + name: "populates Name from Id when empty", + id: "my-org", + organization: "fallback", + initialName: "", + expectedName: "my-org", + expectedFullName: "my-org", + }, + { + name: "populates Name from Organization when Id empty", + id: "", + organization: "my-org", + initialName: "", + expectedName: "my-org", + expectedFullName: "my-org", + }, + { + name: "preserves existing Name", + id: "org-id", + organization: "org", + initialName: "custom-name", + expectedName: "custom-name", + expectedFullName: "org-id", // FullName still populated from ScopeName + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := &GhCopilotScope{ + Id: tt.id, + Organization: tt.organization, + Name: tt.initialName, + } + err := s.BeforeSave(nil) + if err != nil { + t.Errorf("BeforeSave() error = %v, want nil", err) + } + if s.Name != tt.expectedName { + t.Errorf("Name = %v, want %v", s.Name, tt.expectedName) + } + if s.FullName != tt.expectedFullName { + t.Errorf("FullName = %v, want %v", s.FullName, tt.expectedFullName) + } + }) + } +} + +func TestGhCopilotScope_BeforeSave_BaselinePeriodDays(t *testing.T) { + tests := []struct { + name string + input int + expected int + }{ + {"zero defaults to 90", 0, 90}, + {"negative defaults to 90", -10, 90}, + {"below minimum (6) defaults to 90", 6, 90}, + {"minimum valid (7) unchanged", 7, 7}, + {"typical value (90) unchanged", 90, 90}, + {"maximum valid (365) unchanged", 365, 365}, + {"above maximum (400) capped to 365", 400, 365}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := &GhCopilotScope{ + Id: "test-org", + BaselinePeriodDays: tt.input, + } + err := s.BeforeSave(nil) + if err != nil { + t.Errorf("BeforeSave() error = %v, want nil", err) + } + if s.BaselinePeriodDays != tt.expected { + t.Errorf("BaselinePeriodDays = %v, want %v", s.BaselinePeriodDays, tt.expected) + } + }) + } +} + +func TestGhCopilotScope_ScopeId(t *testing.T) { + s := GhCopilotScope{Id: "test-id"} + if got := s.ScopeId(); got != "test-id" { + t.Errorf("ScopeId() = %v, want test-id", got) + } +} + +func TestGhCopilotScope_ScopeParams(t *testing.T) { + s := GhCopilotScope{Id: "test-org"} + s.ConnectionId = 42 + + params := s.ScopeParams().(*GhCopilotScopeParams) + if params.ConnectionId != 42 { + t.Errorf("ScopeParams().ConnectionId = %v, want 42", params.ConnectionId) + } + if params.ScopeId != "test-org" { + t.Errorf("ScopeParams().ScopeId = %v, want test-org", params.ScopeId) + } +} diff --git a/backend/plugins/gh-copilot/models/seat.go b/backend/plugins/gh-copilot/models/seat.go new file mode 100644 index 00000000000..85ebf177ae4 --- /dev/null +++ b/backend/plugins/gh-copilot/models/seat.go @@ -0,0 +1,45 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package models + +import ( + "time" + + "github.com/apache/incubator-devlake/core/models/common" +) + +// GhCopilotSeat represents a seat assignment snapshot for Copilot. +type GhCopilotSeat struct { + ConnectionId uint64 `gorm:"primaryKey"` + Organization string `gorm:"primaryKey;type:varchar(255)"` + UserLogin string `gorm:"primaryKey;type:varchar(255)"` + UserId int64 `gorm:"index"` + PlanType string `gorm:"type:varchar(32)"` + CreatedAt time.Time + LastActivityAt *time.Time + LastActivityEditor string + LastAuthenticatedAt *time.Time + PendingCancellationDate *time.Time + UpdatedAt time.Time + + common.RawDataOrigin +} + +func (GhCopilotSeat) TableName() string { + return "_tool_copilot_seats" +} diff --git a/backend/plugins/gh-copilot/models/user_metrics.go b/backend/plugins/gh-copilot/models/user_metrics.go new file mode 100644 index 00000000000..c53f767ffbc --- /dev/null +++ b/backend/plugins/gh-copilot/models/user_metrics.go @@ -0,0 +1,131 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package models + +import ( + "time" + + "github.com/apache/incubator-devlake/core/models/common" +) + +// GhCopilotUserDailyMetrics captures per-user daily Copilot usage metrics from enterprise reports. +type GhCopilotUserDailyMetrics struct { + ConnectionId uint64 `gorm:"primaryKey" json:"connectionId"` + ScopeId string `gorm:"primaryKey;type:varchar(255)" json:"scopeId"` + Day time.Time `gorm:"primaryKey;type:date" json:"day"` + UserId int64 `gorm:"primaryKey" json:"userId"` + + EnterpriseId string `json:"enterpriseId" gorm:"type:varchar(100)"` + UserLogin string `json:"userLogin" gorm:"type:varchar(255);index"` + UsedAgent bool `json:"usedAgent"` + UsedChat bool `json:"usedChat"` + + CopilotActivityMetrics `mapstructure:",squash"` + common.NoPKModel +} + +func (GhCopilotUserDailyMetrics) TableName() string { + return "_tool_copilot_user_daily_metrics" +} + +// GhCopilotUserMetricsByIde stores per-user metrics broken down by IDE. +type GhCopilotUserMetricsByIde struct { + ConnectionId uint64 `gorm:"primaryKey" json:"connectionId"` + ScopeId string `gorm:"primaryKey;type:varchar(255)" json:"scopeId"` + Day time.Time `gorm:"primaryKey;type:date" json:"day"` + UserId int64 `gorm:"primaryKey" json:"userId"` + Ide string `gorm:"primaryKey;type:varchar(50)" json:"ide"` + + LastKnownPluginName string `json:"lastKnownPluginName" gorm:"type:varchar(100)"` + LastKnownPluginVersion string `json:"lastKnownPluginVersion" gorm:"type:varchar(50)"` + LastKnownIdeVersion string `json:"lastKnownIdeVersion" gorm:"type:varchar(50)"` + + CopilotActivityMetrics `mapstructure:",squash"` + common.NoPKModel +} + +func (GhCopilotUserMetricsByIde) TableName() string { + return "_tool_copilot_user_metrics_by_ide" +} + +// GhCopilotUserMetricsByFeature stores per-user metrics broken down by feature. +type GhCopilotUserMetricsByFeature struct { + ConnectionId uint64 `gorm:"primaryKey" json:"connectionId"` + ScopeId string `gorm:"primaryKey;type:varchar(255)" json:"scopeId"` + Day time.Time `gorm:"primaryKey;type:date" json:"day"` + UserId int64 `gorm:"primaryKey" json:"userId"` + Feature string `gorm:"primaryKey;type:varchar(100)" json:"feature"` + + CopilotActivityMetrics `mapstructure:",squash"` + common.NoPKModel +} + +func (GhCopilotUserMetricsByFeature) TableName() string { + return "_tool_copilot_user_metrics_by_feature" +} + +// GhCopilotUserMetricsByLanguageFeature stores per-user metrics by language and feature. +type GhCopilotUserMetricsByLanguageFeature struct { + ConnectionId uint64 `gorm:"primaryKey" json:"connectionId"` + ScopeId string `gorm:"primaryKey;type:varchar(255)" json:"scopeId"` + Day time.Time `gorm:"primaryKey;type:date" json:"day"` + UserId int64 `gorm:"primaryKey" json:"userId"` + Language string `gorm:"primaryKey;type:varchar(50)" json:"language"` + Feature string `gorm:"primaryKey;type:varchar(100)" json:"feature"` + + CopilotCodeMetrics `mapstructure:",squash"` + common.NoPKModel +} + +func (GhCopilotUserMetricsByLanguageFeature) TableName() string { + return "_tool_copilot_user_metrics_by_language_feature" +} + +// GhCopilotUserMetricsByLanguageModel stores per-user metrics by language and model. +type GhCopilotUserMetricsByLanguageModel struct { + ConnectionId uint64 `gorm:"primaryKey" json:"connectionId"` + ScopeId string `gorm:"primaryKey;type:varchar(255)" json:"scopeId"` + Day time.Time `gorm:"primaryKey;type:date" json:"day"` + UserId int64 `gorm:"primaryKey" json:"userId"` + Language string `gorm:"primaryKey;type:varchar(50)" json:"language"` + Model string `gorm:"primaryKey;type:varchar(100)" json:"model"` + + CopilotCodeMetrics `mapstructure:",squash"` + common.NoPKModel +} + +func (GhCopilotUserMetricsByLanguageModel) TableName() string { + return "_tool_copilot_user_metrics_by_language_model" +} + +// GhCopilotUserMetricsByModelFeature stores per-user metrics by model and feature. +type GhCopilotUserMetricsByModelFeature struct { + ConnectionId uint64 `gorm:"primaryKey" json:"connectionId"` + ScopeId string `gorm:"primaryKey;type:varchar(255)" json:"scopeId"` + Day time.Time `gorm:"primaryKey;type:date" json:"day"` + UserId int64 `gorm:"primaryKey" json:"userId"` + Model string `gorm:"primaryKey;type:varchar(100)" json:"model"` + Feature string `gorm:"primaryKey;type:varchar(100)" json:"feature"` + + CopilotActivityMetrics `mapstructure:",squash"` + common.NoPKModel +} + +func (GhCopilotUserMetricsByModelFeature) TableName() string { + return "_tool_copilot_user_metrics_by_model_feature" +} diff --git a/backend/plugins/gh-copilot/service/connection_test_helper.go b/backend/plugins/gh-copilot/service/connection_test_helper.go new file mode 100644 index 00000000000..5bffb63b4b0 --- /dev/null +++ b/backend/plugins/gh-copilot/service/connection_test_helper.go @@ -0,0 +1,221 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package service + +import ( + stdctx "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strconv" + "strings" + "time" + + corectx "github.com/apache/incubator-devlake/core/context" + "github.com/apache/incubator-devlake/core/errors" + helper "github.com/apache/incubator-devlake/helpers/pluginhelper/api" + "github.com/apache/incubator-devlake/plugins/gh-copilot/models" +) + +// TestConnectionResult represents the payload returned by the connection test endpoints. +type TestConnectionResult struct { + Success bool `json:"success"` + Message string `json:"message"` + Enterprise string `json:"enterprise,omitempty"` + Organization string `json:"organization,omitempty"` + PlanType string `json:"planType,omitempty"` + TotalSeats int `json:"totalSeats,omitempty"` + ActiveSeats int `json:"activeSeats,omitempty"` +} + +type copilotBillingSummary struct { + Organization string `json:"organization"` + PlanType string `json:"plan_type"` + TotalSeats int `json:"total_seats"` + ActiveSeats int `json:"active_seats"` + ActiveThisCycle int `json:"active_this_cycle"` +} + +// TestConnection exercises the GitHub Copilot billing endpoint to validate credentials. +func TestConnection(ctx stdctx.Context, br corectx.BasicRes, connection *models.GhCopilotConnection) (*TestConnectionResult, errors.Error) { + if connection == nil { + return nil, errors.BadInput.New("connection is required") + } + + connection.Normalize() + + hasEnterprise := connection.HasEnterprise() + hasOrg := strings.TrimSpace(connection.Organization) != "" + + if !hasEnterprise && !hasOrg { + return nil, errors.BadInput.New("either enterprise or organization must be specified") + } + + apiClient, err := helper.NewApiClientFromConnection(ctx, br, connection) + if err != nil { + return nil, err + } + apiClient.SetHeaders(map[string]string{ + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + }) + + result := &TestConnectionResult{ + Success: true, + Message: "Successfully connected to GitHub Copilot", + } + + // Test enterprise endpoint first when configured. + // Note: /enterprises/{ent}/copilot/billing does not exist — use /billing/seats instead. + if hasEnterprise { + entSlug := strings.TrimSpace(connection.Enterprise) + seatsPath := fmt.Sprintf("enterprises/%s/copilot/billing/seats", entSlug) + entSummary, entErr := fetchSeatsSummary(apiClient, seatsPath) + if entErr != nil { + return nil, entErr + } + result.Enterprise = entSlug + result.PlanType = "enterprise" + result.TotalSeats = entSummary.TotalSeats + } + + // Test org endpoint when configured. + if hasOrg { + orgSummary, orgErr := fetchBillingSummary(apiClient, fmt.Sprintf("orgs/%s/copilot/billing", connection.Organization)) + if orgErr != nil { + return nil, orgErr + } + organization := orgSummary.Organization + if organization == "" { + organization = connection.Organization + } + result.Organization = organization + + // When enterprise is not set, use org-level data for the result. + if !hasEnterprise { + result.PlanType = orgSummary.PlanType + result.TotalSeats = orgSummary.TotalSeats + activeSeats := orgSummary.ActiveSeats + if activeSeats == 0 && orgSummary.ActiveThisCycle > 0 { + activeSeats = orgSummary.ActiveThisCycle + } + result.ActiveSeats = activeSeats + } + } + + return result, nil +} + +// fetchBillingSummary calls a billing endpoint and returns the parsed summary or error. +func fetchBillingSummary(apiClient *helper.ApiClient, path string) (*copilotBillingSummary, errors.Error) { + res, err := apiClient.Get(path, nil, nil) + if err != nil { + return nil, err + } + + if res.StatusCode >= 400 { + body, readErr := io.ReadAll(res.Body) + res.Body.Close() + if readErr != nil { + return nil, errors.Convert(readErr) + } + return nil, buildGitHubApiError(res.StatusCode, path, body, res.Header.Get("Retry-After")) + } + + summary := &copilotBillingSummary{} + if err := helper.UnmarshalResponse(res, summary); err != nil { + return nil, err + } + return summary, nil +} + +// enterpriseSeatsSummary represents the top-level response from /copilot/billing/seats. +type enterpriseSeatsSummary struct { + TotalSeats int `json:"total_seats"` +} + +// fetchSeatsSummary calls the seats endpoint and returns the total seat count. +func fetchSeatsSummary(apiClient *helper.ApiClient, path string) (*enterpriseSeatsSummary, errors.Error) { + res, err := apiClient.Get(path, nil, nil) + if err != nil { + return nil, err + } + + if res.StatusCode >= 400 { + body, readErr := io.ReadAll(res.Body) + res.Body.Close() + if readErr != nil { + return nil, errors.Convert(readErr) + } + return nil, buildGitHubApiError(res.StatusCode, path, body, res.Header.Get("Retry-After")) + } + + summary := &enterpriseSeatsSummary{} + if err := helper.UnmarshalResponse(res, summary); err != nil { + return nil, err + } + return summary, nil +} + +func buildGitHubApiError(status int, resource string, body []byte, retryAfter string) errors.Error { + type githubError struct { + Message string `json:"message"` + } + + msg := strings.TrimSpace(string(body)) + if len(body) > 0 { + errPayload := &githubError{} + if jsonErr := json.Unmarshal(body, errPayload); jsonErr == nil && errPayload.Message != "" { + msg = errPayload.Message + } + } + + var prefix string + switch status { + case http.StatusForbidden: + prefix = "GitHub returned 403 Forbidden. Ensure the PAT includes manage_billing:copilot and the resource has Copilot access." + case http.StatusNotFound: + prefix = fmt.Sprintf("GitHub returned 404 Not Found for '%s'. Verify the organization/enterprise slug and Copilot availability.", resource) + case http.StatusUnprocessableEntity: + prefix = "GitHub returned 422 Unprocessable Entity. Enable Copilot metrics before testing." + case http.StatusTooManyRequests: + prefix = "GitHub rate limited the request (429). Respect Retry-After guidance before retrying." + default: + prefix = fmt.Sprintf("GitHub API request failed with status %d.", status) + } + + if retryAfter != "" { + if seconds, err := strconv.Atoi(retryAfter); err == nil { + prefix = fmt.Sprintf("%s Retry after %d seconds.", prefix, seconds) + } else if delay, err := http.ParseTime(retryAfter); err == nil { + seconds := int(time.Until(delay).Seconds()) + if seconds > 0 { + prefix = fmt.Sprintf("%s Retry after %d seconds.", prefix, seconds) + } + } else { + prefix = fmt.Sprintf("%s Retry-After: %s.", prefix, retryAfter) + } + } + + if msg != "" { + prefix = fmt.Sprintf("%s Details: %s", prefix, msg) + } + + return errors.HttpStatus(status).New(strings.TrimSpace(prefix)) +} diff --git a/backend/plugins/gh-copilot/service/connection_test_helper_test.go b/backend/plugins/gh-copilot/service/connection_test_helper_test.go new file mode 100644 index 00000000000..a50742fc9ee --- /dev/null +++ b/backend/plugins/gh-copilot/service/connection_test_helper_test.go @@ -0,0 +1,52 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package service + +import ( + "net/http" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestBuildGitHubApiError_Forbidden(t *testing.T) { + err := buildGitHubApiError(http.StatusForbidden, "octodemo", []byte(`{"message":"Resource not accessible"}`), "") + assert.Equal(t, http.StatusForbidden, err.GetType().GetHttpCode()) + assert.Contains(t, err.Error(), "Forbidden") + assert.Contains(t, err.Error(), "manage_billing:copilot") +} + +func TestBuildGitHubApiError_NotFound(t *testing.T) { + err := buildGitHubApiError(http.StatusNotFound, "octodemo", []byte(`{"message":"Not Found"}`), "") + assert.Equal(t, http.StatusNotFound, err.GetType().GetHttpCode()) + assert.Contains(t, err.Error(), "octodemo") +} + +func TestBuildGitHubApiError_UnprocessableEntity(t *testing.T) { + err := buildGitHubApiError(http.StatusUnprocessableEntity, "octodemo", []byte(`{"message":"Metrics disabled"}`), "") + assert.Equal(t, http.StatusUnprocessableEntity, err.GetType().GetHttpCode()) + assert.Contains(t, err.Error(), "Unprocessable") + assert.Contains(t, err.Error(), "Metrics disabled") +} + +func TestBuildGitHubApiError_TooManyRequests(t *testing.T) { + err := buildGitHubApiError(http.StatusTooManyRequests, "octodemo", []byte(`{"message":"Slow down"}`), "60") + assert.Equal(t, http.StatusTooManyRequests, err.GetType().GetHttpCode()) + assert.Contains(t, err.Error(), "429") + assert.Contains(t, err.Error(), "60 seconds") +} diff --git a/backend/plugins/gh-copilot/tasks/api_client.go b/backend/plugins/gh-copilot/tasks/api_client.go new file mode 100644 index 00000000000..d217a687bd5 --- /dev/null +++ b/backend/plugins/gh-copilot/tasks/api_client.go @@ -0,0 +1,83 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import ( + "net/http" + "time" + + "github.com/apache/incubator-devlake/core/errors" + "github.com/apache/incubator-devlake/core/log" + "github.com/apache/incubator-devlake/core/plugin" + helper "github.com/apache/incubator-devlake/helpers/pluginhelper/api" + "github.com/apache/incubator-devlake/plugins/gh-copilot/models" +) + +type nowFunc func() time.Time +type sleepFunc func(time.Duration) + +func handleGitHubRetryAfter(res *http.Response, logger log.Logger, now nowFunc, sleep sleepFunc) errors.Error { + if res == nil { + return nil + } + if res.StatusCode != http.StatusTooManyRequests { + return nil + } + + if now == nil { + now = time.Now + } + if sleep == nil { + sleep = time.Sleep + } + + wait := parseRetryAfter(res.Header.Get("Retry-After"), now().UTC()) + if wait > 0 { + if logger != nil { + logger.Warn(nil, "GitHub returned 429; sleeping %s per Retry-After", wait.String()) + } + sleep(wait) + } + // Return an error so the async client will retry. + return errors.HttpStatus(http.StatusTooManyRequests).New("GitHub rate limited the request") +} + +func CreateApiClient(taskCtx plugin.TaskContext, connection *models.GhCopilotConnection) (*helper.ApiAsyncClient, errors.Error) { + apiClient, err := helper.NewApiClientFromConnection(taskCtx.GetContext(), taskCtx, connection) + if err != nil { + return nil, err + } + + apiClient.SetHeaders(map[string]string{ + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + }) + + rateLimiter := &helper.ApiRateLimitCalculator{UserRateLimitPerHour: connection.RateLimitPerHour} + asyncClient, err := helper.CreateAsyncApiClient(taskCtx, apiClient, rateLimiter) + if err != nil { + return nil, err + } + + // Ensure we respect GitHub Retry-After on 429s before retrying. + apiClient.SetAfterFunction(func(res *http.Response) errors.Error { + return handleGitHubRetryAfter(res, taskCtx.GetLogger(), time.Now, time.Sleep) + }) + + return asyncClient, nil +} diff --git a/backend/plugins/gh-copilot/tasks/api_client_test.go b/backend/plugins/gh-copilot/tasks/api_client_test.go new file mode 100644 index 00000000000..0574b137e0e --- /dev/null +++ b/backend/plugins/gh-copilot/tasks/api_client_test.go @@ -0,0 +1,77 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import ( + "fmt" + "net/http" + "testing" + "time" + + "github.com/apache/incubator-devlake/core/log" + "github.com/stretchr/testify/require" +) + +type captureLogger struct { + warnings []string +} + +func (l *captureLogger) IsLevelEnabled(level log.LogLevel) bool { return true } +func (l *captureLogger) Printf(format string, a ...interface{}) {} +func (l *captureLogger) Log(level log.LogLevel, format string, a ...interface{}) { +} +func (l *captureLogger) Debug(format string, a ...interface{}) {} +func (l *captureLogger) Info(format string, a ...interface{}) {} +func (l *captureLogger) Warn(err error, format string, a ...interface{}) { + l.warnings = append(l.warnings, fmt.Sprintf(format, a...)) +} +func (l *captureLogger) Error(err error, format string, a ...interface{}) {} +func (l *captureLogger) Nested(name string) log.Logger { return l } +func (l *captureLogger) GetConfig() *log.LoggerConfig { return &log.LoggerConfig{} } +func (l *captureLogger) SetStream(config *log.LoggerStreamConfig) {} + +func TestHandleGitHubRetryAfterSleepsAndReturnsError(t *testing.T) { + res := &http.Response{StatusCode: http.StatusTooManyRequests, Header: http.Header{}} + res.Header.Set("Retry-After", "10") + + logger := &captureLogger{} + var slept time.Duration + + err := handleGitHubRetryAfter( + res, + logger, + func() time.Time { return time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) }, + func(d time.Duration) { slept = d }, + ) + + require.NotNil(t, err) + require.Equal(t, 10*time.Second, slept) + require.Len(t, logger.warnings, 1) + require.Contains(t, logger.warnings[0], "sleeping") +} + +func TestHandleGitHubRetryAfterNoopOnNon429(t *testing.T) { + res := &http.Response{StatusCode: http.StatusOK, Header: http.Header{}} + var slept time.Duration + logger := &captureLogger{} + + err := handleGitHubRetryAfter(res, logger, nil, func(d time.Duration) { slept = d }) + require.Nil(t, err) + require.Equal(t, time.Duration(0), slept) + require.Len(t, logger.warnings, 0) +} diff --git a/backend/plugins/gh-copilot/tasks/enterprise_metrics_collector.go b/backend/plugins/gh-copilot/tasks/enterprise_metrics_collector.go new file mode 100644 index 00000000000..e0e4335f977 --- /dev/null +++ b/backend/plugins/gh-copilot/tasks/enterprise_metrics_collector.go @@ -0,0 +1,176 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "time" + + "github.com/apache/incubator-devlake/core/errors" + "github.com/apache/incubator-devlake/core/plugin" + helper "github.com/apache/incubator-devlake/helpers/pluginhelper/api" +) + +const rawEnterpriseMetricsTable = "copilot_enterprise_metrics" + +// dayInput is passed to each collector request via the Input iterator. +type dayInput struct { + Day string `json:"day"` +} + +// CollectEnterpriseMetrics collects enterprise-level daily Copilot usage reports. +// It iterates day-by-day using the enterprise-1-day report endpoint, downloads +// the report files from the returned links, and stores them as raw data. +func CollectEnterpriseMetrics(taskCtx plugin.SubTaskContext) errors.Error { + data, ok := taskCtx.TaskContext().GetData().(*GhCopilotTaskData) + if !ok { + return errors.Default.New("task data is not GhCopilotTaskData") + } + connection := data.Connection + connection.Normalize() + + if !connection.HasEnterprise() { + taskCtx.GetLogger().Info("No enterprise configured, skipping enterprise metrics collection") + return nil + } + + apiClient, err := CreateApiClient(taskCtx.TaskContext(), connection) + if err != nil { + return err + } + + rawArgs := helper.RawDataSubTaskArgs{ + Ctx: taskCtx, + Table: rawEnterpriseMetricsTable, + Options: copilotRawParams{ + ConnectionId: data.Options.ConnectionId, + ScopeId: data.Options.ScopeId, + Organization: connection.Organization, + Endpoint: connection.Endpoint, + }, + } + + collector, err := helper.NewStatefulApiCollector(rawArgs) + if err != nil { + return err + } + + now := time.Now().UTC() + start, until := computeReportDateRange(now, collector.GetSince()) + logger := taskCtx.GetLogger() + + dayIter := newDayIterator(start, until) + + err = collector.InitCollector(helper.ApiCollectorArgs{ + ApiClient: apiClient, + Input: dayIter, + UrlTemplate: fmt.Sprintf("enterprises/%s/copilot/metrics/reports/enterprise-1-day", + connection.Enterprise), + Query: func(reqData *helper.RequestData) (url.Values, errors.Error) { + input := reqData.Input.(*dayInput) + q := url.Values{} + q.Set("day", input.Day) + return q, nil + }, + Incremental: true, + Concurrency: 1, + AfterResponse: ignore404, + ResponseParser: func(res *http.Response) ([]json.RawMessage, errors.Error) { + // Parse metadata response to get download links + body, readErr := io.ReadAll(res.Body) + res.Body.Close() + if readErr != nil { + return nil, errors.Default.Wrap(readErr, "failed to read report metadata") + } + + var meta reportMetadataResponse + if jsonErr := json.Unmarshal(body, &meta); jsonErr != nil { + snippet := string(body) + if len(snippet) > 200 { + snippet = snippet[:200] + } + logger.Error(jsonErr, "failed to parse report metadata, body=%s", snippet) + return nil, errors.Default.Wrap(jsonErr, "failed to parse report metadata") + } + + if len(meta.DownloadLinks) == 0 { + logger.Info("No download links for report day=%s, skipping", meta.ReportDay) + return nil, nil + } + + // Download each report file and return contents as raw messages + var results []json.RawMessage + for _, link := range meta.DownloadLinks { + reportBody, dlErr := downloadReport(link, logger) + if dlErr != nil { + logger.Error(nil, "failed to download report for day=%s: %s", meta.ReportDay, dlErr.Error()) + return nil, dlErr + } + if reportBody == nil { + continue // blob not found, skip + } + results = append(results, json.RawMessage(reportBody)) + } + return results, nil + }, + }) + if err != nil { + return err + } + return collector.Execute() +} + +// dayIterator implements helper.Iterator to yield one dayInput per day in a range. +type dayIterator struct { + days []dayInput + idx int +} + +func newDayIterator(start, end time.Time) *dayIterator { + var days []dayInput + for d := start; !d.After(end); d = d.AddDate(0, 0, 1) { + days = append(days, dayInput{Day: d.Format("2006-01-02")}) + } + return &dayIterator{days: days} +} + +func (it *dayIterator) HasNext() bool { + return it.idx < len(it.days) +} + +func (it *dayIterator) Fetch() (interface{}, errors.Error) { + if it.idx >= len(it.days) { + return nil, nil + } + day := it.days[it.idx] + it.idx++ + return &day, nil +} + +func (it *dayIterator) Close() errors.Error { + return nil +} + +func mustMarshal(v interface{}) string { + b, _ := json.Marshal(v) + return string(b) +} diff --git a/backend/plugins/gh-copilot/tasks/enterprise_metrics_extractor.go b/backend/plugins/gh-copilot/tasks/enterprise_metrics_extractor.go new file mode 100644 index 00000000000..1ad704458ee --- /dev/null +++ b/backend/plugins/gh-copilot/tasks/enterprise_metrics_extractor.go @@ -0,0 +1,298 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import ( + "encoding/json" + "time" + + "github.com/apache/incubator-devlake/core/errors" + "github.com/apache/incubator-devlake/core/plugin" + helper "github.com/apache/incubator-devlake/helpers/pluginhelper/api" + "github.com/apache/incubator-devlake/plugins/gh-copilot/models" +) + +// --- Enterprise report JSON structures --- + +type enterpriseReport struct { + ReportStartDay string `json:"report_start_day"` + ReportEndDay string `json:"report_end_day"` + EnterpriseId string `json:"enterprise_id"` + DayTotals []enterpriseDayTotal `json:"day_totals"` +} + +type enterpriseDayTotal struct { + Day string `json:"day"` + EnterpriseId string `json:"enterprise_id"` + DailyActiveUsers int `json:"daily_active_users"` + WeeklyActiveUsers int `json:"weekly_active_users"` + MonthlyActiveUsers int `json:"monthly_active_users"` + MonthlyActiveChatUsers int `json:"monthly_active_chat_users"` + MonthlyActiveAgentUsers int `json:"monthly_active_agent_users"` + UserInitiatedInteractionCount int `json:"user_initiated_interaction_count"` + CodeGenerationActivityCount int `json:"code_generation_activity_count"` + CodeAcceptanceActivityCount int `json:"code_acceptance_activity_count"` + LocSuggestedToAddSum int `json:"loc_suggested_to_add_sum"` + LocSuggestedToDeleteSum int `json:"loc_suggested_to_delete_sum"` + LocAddedSum int `json:"loc_added_sum"` + LocDeletedSum int `json:"loc_deleted_sum"` + TotalsByIde []totalsByIde `json:"totals_by_ide"` + TotalsByFeature []totalsByFeature `json:"totals_by_feature"` + TotalsByLanguageFeature []totalsByLangFeature `json:"totals_by_language_feature"` + TotalsByLanguageModel []totalsByLangModel `json:"totals_by_language_model"` + TotalsByModelFeature []totalsByModelFeature `json:"totals_by_model_feature"` + PullRequests *pullRequestStats `json:"pull_requests"` +} + +type totalsByIde struct { + Ide string `json:"ide"` + UserInitiatedInteractionCount int `json:"user_initiated_interaction_count"` + CodeGenerationActivityCount int `json:"code_generation_activity_count"` + CodeAcceptanceActivityCount int `json:"code_acceptance_activity_count"` + LocSuggestedToAddSum int `json:"loc_suggested_to_add_sum"` + LocSuggestedToDeleteSum int `json:"loc_suggested_to_delete_sum"` + LocAddedSum int `json:"loc_added_sum"` + LocDeletedSum int `json:"loc_deleted_sum"` +} + +type totalsByFeature struct { + Feature string `json:"feature"` + UserInitiatedInteractionCount int `json:"user_initiated_interaction_count"` + CodeGenerationActivityCount int `json:"code_generation_activity_count"` + CodeAcceptanceActivityCount int `json:"code_acceptance_activity_count"` + LocSuggestedToAddSum int `json:"loc_suggested_to_add_sum"` + LocSuggestedToDeleteSum int `json:"loc_suggested_to_delete_sum"` + LocAddedSum int `json:"loc_added_sum"` + LocDeletedSum int `json:"loc_deleted_sum"` +} + +type totalsByLangFeature struct { + Language string `json:"language"` + Feature string `json:"feature"` + CodeGenerationActivityCount int `json:"code_generation_activity_count"` + CodeAcceptanceActivityCount int `json:"code_acceptance_activity_count"` + LocSuggestedToAddSum int `json:"loc_suggested_to_add_sum"` + LocSuggestedToDeleteSum int `json:"loc_suggested_to_delete_sum"` + LocAddedSum int `json:"loc_added_sum"` + LocDeletedSum int `json:"loc_deleted_sum"` +} + +type totalsByLangModel struct { + Language string `json:"language"` + Model string `json:"model"` + CodeGenerationActivityCount int `json:"code_generation_activity_count"` + CodeAcceptanceActivityCount int `json:"code_acceptance_activity_count"` + LocSuggestedToAddSum int `json:"loc_suggested_to_add_sum"` + LocSuggestedToDeleteSum int `json:"loc_suggested_to_delete_sum"` + LocAddedSum int `json:"loc_added_sum"` + LocDeletedSum int `json:"loc_deleted_sum"` +} + +type pullRequestStats struct { + TotalReviewed int `json:"total_reviewed"` + TotalCreated int `json:"total_created"` + TotalCreatedByCopilot int `json:"total_created_by_copilot"` + TotalReviewedByCopilot int `json:"total_reviewed_by_copilot"` +} + +type totalsByModelFeature struct { + Model string `json:"model"` + Feature string `json:"feature"` + UserInitiatedInteractionCount int `json:"user_initiated_interaction_count"` + CodeGenerationActivityCount int `json:"code_generation_activity_count"` + CodeAcceptanceActivityCount int `json:"code_acceptance_activity_count"` + LocSuggestedToAddSum int `json:"loc_suggested_to_add_sum"` + LocSuggestedToDeleteSum int `json:"loc_suggested_to_delete_sum"` + LocAddedSum int `json:"loc_added_sum"` + LocDeletedSum int `json:"loc_deleted_sum"` +} + +// ExtractEnterpriseMetrics parses enterprise report JSON and extracts to tool-layer tables. +func ExtractEnterpriseMetrics(taskCtx plugin.SubTaskContext) errors.Error { + data, ok := taskCtx.TaskContext().GetData().(*GhCopilotTaskData) + if !ok { + return errors.Default.New("task data is not GhCopilotTaskData") + } + connection := data.Connection + connection.Normalize() + + if !connection.HasEnterprise() { + taskCtx.GetLogger().Info("No enterprise configured, skipping enterprise metrics extraction") + return nil + } + + params := copilotRawParams{ + ConnectionId: data.Options.ConnectionId, + ScopeId: data.Options.ScopeId, + Organization: connection.Organization, + Endpoint: connection.Endpoint, + } + + extractor, err := helper.NewApiExtractor(helper.ApiExtractorArgs{ + RawDataSubTaskArgs: helper.RawDataSubTaskArgs{ + Ctx: taskCtx, + Table: rawEnterpriseMetricsTable, + Options: params, + }, + Extract: func(row *helper.RawData) ([]interface{}, errors.Error) { + // The API returns a flat enterpriseDayTotal object per raw row, not a wrapper. + var dt enterpriseDayTotal + if err := errors.Convert(json.Unmarshal(row.Data, &dt)); err != nil { + return nil, err + } + + day, parseErr := time.Parse("2006-01-02", dt.Day) + if parseErr != nil { + return nil, errors.BadInput.Wrap(parseErr, "invalid day in enterprise report") + } + + var results []interface{} + + // Main daily metrics + dailyMetrics := &models.GhCopilotEnterpriseDailyMetrics{ + ConnectionId: data.Options.ConnectionId, + ScopeId: data.Options.ScopeId, + Day: day, + EnterpriseId: dt.EnterpriseId, + DailyActiveUsers: dt.DailyActiveUsers, + WeeklyActiveUsers: dt.WeeklyActiveUsers, + MonthlyActiveUsers: dt.MonthlyActiveUsers, + MonthlyActiveChatUsers: dt.MonthlyActiveChatUsers, + MonthlyActiveAgentUsers: dt.MonthlyActiveAgentUsers, + CopilotActivityMetrics: models.CopilotActivityMetrics{ + UserInitiatedInteractionCount: dt.UserInitiatedInteractionCount, + CodeGenerationActivityCount: dt.CodeGenerationActivityCount, + CodeAcceptanceActivityCount: dt.CodeAcceptanceActivityCount, + LocSuggestedToAddSum: dt.LocSuggestedToAddSum, + LocSuggestedToDeleteSum: dt.LocSuggestedToDeleteSum, + LocAddedSum: dt.LocAddedSum, + LocDeletedSum: dt.LocDeletedSum, + }, + } + if dt.PullRequests != nil { + dailyMetrics.PRTotalReviewed = dt.PullRequests.TotalReviewed + dailyMetrics.PRTotalCreated = dt.PullRequests.TotalCreated + dailyMetrics.PRTotalCreatedByCopilot = dt.PullRequests.TotalCreatedByCopilot + dailyMetrics.PRTotalReviewedByCopilot = dt.PullRequests.TotalReviewedByCopilot + } + results = append(results, dailyMetrics) + + // By IDE + for _, ide := range dt.TotalsByIde { + results = append(results, &models.GhCopilotMetricsByIde{ + ConnectionId: data.Options.ConnectionId, + ScopeId: data.Options.ScopeId, + Day: day, + Ide: ide.Ide, + CopilotActivityMetrics: models.CopilotActivityMetrics{ + UserInitiatedInteractionCount: ide.UserInitiatedInteractionCount, + CodeGenerationActivityCount: ide.CodeGenerationActivityCount, + CodeAcceptanceActivityCount: ide.CodeAcceptanceActivityCount, + LocSuggestedToAddSum: ide.LocSuggestedToAddSum, + LocSuggestedToDeleteSum: ide.LocSuggestedToDeleteSum, + LocAddedSum: ide.LocAddedSum, + LocDeletedSum: ide.LocDeletedSum, + }, + }) + } + + // By Feature + for _, f := range dt.TotalsByFeature { + results = append(results, &models.GhCopilotMetricsByFeature{ + ConnectionId: data.Options.ConnectionId, + ScopeId: data.Options.ScopeId, + Day: day, + Feature: f.Feature, + CopilotActivityMetrics: models.CopilotActivityMetrics{ + UserInitiatedInteractionCount: f.UserInitiatedInteractionCount, + CodeGenerationActivityCount: f.CodeGenerationActivityCount, + CodeAcceptanceActivityCount: f.CodeAcceptanceActivityCount, + LocSuggestedToAddSum: f.LocSuggestedToAddSum, + LocSuggestedToDeleteSum: f.LocSuggestedToDeleteSum, + LocAddedSum: f.LocAddedSum, + LocDeletedSum: f.LocDeletedSum, + }, + }) + } + + // By Language+Feature + for _, lf := range dt.TotalsByLanguageFeature { + results = append(results, &models.GhCopilotMetricsByLanguageFeature{ + ConnectionId: data.Options.ConnectionId, + ScopeId: data.Options.ScopeId, + Day: day, + Language: lf.Language, + Feature: lf.Feature, + CopilotCodeMetrics: models.CopilotCodeMetrics{ + CodeGenerationActivityCount: lf.CodeGenerationActivityCount, + CodeAcceptanceActivityCount: lf.CodeAcceptanceActivityCount, + LocSuggestedToAddSum: lf.LocSuggestedToAddSum, + LocSuggestedToDeleteSum: lf.LocSuggestedToDeleteSum, + LocAddedSum: lf.LocAddedSum, + LocDeletedSum: lf.LocDeletedSum, + }, + }) + } + + // By Language+Model + for _, lm := range dt.TotalsByLanguageModel { + results = append(results, &models.GhCopilotMetricsByLanguageModel{ + ConnectionId: data.Options.ConnectionId, + ScopeId: data.Options.ScopeId, + Day: day, + Language: lm.Language, + Model: lm.Model, + CopilotCodeMetrics: models.CopilotCodeMetrics{ + CodeGenerationActivityCount: lm.CodeGenerationActivityCount, + CodeAcceptanceActivityCount: lm.CodeAcceptanceActivityCount, + LocSuggestedToAddSum: lm.LocSuggestedToAddSum, + LocSuggestedToDeleteSum: lm.LocSuggestedToDeleteSum, + LocAddedSum: lm.LocAddedSum, + LocDeletedSum: lm.LocDeletedSum, + }, + }) + } + + // By Model+Feature + for _, mf := range dt.TotalsByModelFeature { + results = append(results, &models.GhCopilotMetricsByModelFeature{ + ConnectionId: data.Options.ConnectionId, + ScopeId: data.Options.ScopeId, + Day: day, + Model: mf.Model, + Feature: mf.Feature, + CopilotActivityMetrics: models.CopilotActivityMetrics{ + UserInitiatedInteractionCount: mf.UserInitiatedInteractionCount, + CodeGenerationActivityCount: mf.CodeGenerationActivityCount, + CodeAcceptanceActivityCount: mf.CodeAcceptanceActivityCount, + LocSuggestedToAddSum: mf.LocSuggestedToAddSum, + LocSuggestedToDeleteSum: mf.LocSuggestedToDeleteSum, + LocAddedSum: mf.LocAddedSum, + LocDeletedSum: mf.LocDeletedSum, + }, + }) + } + + return results, nil + }, + }) + if err != nil { + return err + } + return extractor.Execute() +} diff --git a/backend/plugins/gh-copilot/tasks/github_errors.go b/backend/plugins/gh-copilot/tasks/github_errors.go new file mode 100644 index 00000000000..2280013307b --- /dev/null +++ b/backend/plugins/gh-copilot/tasks/github_errors.go @@ -0,0 +1,63 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" + + "github.com/apache/incubator-devlake/core/errors" +) + +type githubErrorPayload struct { + Message string `json:"message"` +} + +func buildGitHubApiError(status int, organization string, body []byte, retryAfter string) errors.Error { + msg := strings.TrimSpace(string(body)) + if len(body) > 0 { + payload := &githubErrorPayload{} + if err := json.Unmarshal(body, payload); err == nil && payload.Message != "" { + msg = payload.Message + } + } + + var prefix string + switch status { + case http.StatusForbidden: + prefix = "GitHub returned 403 Forbidden. Ensure the PAT includes manage_billing:copilot and the organization has Copilot access." + case http.StatusNotFound: + prefix = fmt.Sprintf("GitHub returned 404 Not Found for organization '%s'. Verify the organization slug and Copilot availability.", organization) + case http.StatusUnprocessableEntity: + prefix = "GitHub returned 422 Unprocessable Entity. Enable Copilot metrics for the organization before running collection." + case http.StatusTooManyRequests: + prefix = "GitHub rate limited the request (429). Respect Retry-After guidance before retrying." + default: + prefix = fmt.Sprintf("GitHub API request failed with status %d.", status) + } + + if retryAfter != "" { + prefix = fmt.Sprintf("%s Retry-After: %s.", prefix, retryAfter) + } + if msg != "" { + prefix = fmt.Sprintf("%s Details: %s", prefix, msg) + } + return errors.HttpStatus(status).New(strings.TrimSpace(prefix)) +} diff --git a/backend/plugins/gh-copilot/tasks/github_errors_test.go b/backend/plugins/gh-copilot/tasks/github_errors_test.go new file mode 100644 index 00000000000..ef817b77b79 --- /dev/null +++ b/backend/plugins/gh-copilot/tasks/github_errors_test.go @@ -0,0 +1,57 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import ( + "net/http" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestBuildGitHubApiErrorExtractsMessageFromJson(t *testing.T) { + err := buildGitHubApiError(http.StatusForbidden, "octodemo", []byte(`{"message":"nope"}`), "") + require.Contains(t, err.Error(), "403") + require.Contains(t, err.Error(), "Details: nope") +} + +func TestBuildGitHubApiErrorIncludesRetryAfter(t *testing.T) { + err := buildGitHubApiError(http.StatusTooManyRequests, "octodemo", []byte(""), "120") + require.Contains(t, err.Error(), "429") + require.Contains(t, err.Error(), "Retry-After: 120") +} + +func TestBuildGitHubApiErrorStatusSpecificPrefixes(t *testing.T) { + tests := []struct { + name string + status int + want string + }{ + {name: "403", status: http.StatusForbidden, want: "403 Forbidden"}, + {name: "404", status: http.StatusNotFound, want: "404 Not Found"}, + {name: "422", status: http.StatusUnprocessableEntity, want: "422 Unprocessable Entity"}, + {name: "429", status: http.StatusTooManyRequests, want: "429"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := buildGitHubApiError(tt.status, "octodemo", []byte("boom"), "") + require.Contains(t, err.Error(), tt.want) + }) + } +} diff --git a/backend/plugins/gh-copilot/tasks/metrics_collector_test.go b/backend/plugins/gh-copilot/tasks/metrics_collector_test.go new file mode 100644 index 00000000000..cfbab30406c --- /dev/null +++ b/backend/plugins/gh-copilot/tasks/metrics_collector_test.go @@ -0,0 +1,69 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import ( + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestParseRetryAfterSeconds(t *testing.T) { + wait := parseRetryAfter("10", time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)) + require.Equal(t, 10*time.Second, wait) +} + +func TestParseRetryAfterHttpDate(t *testing.T) { + now := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) + value := now.Add(5 * time.Second).Format(http.TimeFormat) + wait := parseRetryAfter(value, now) + require.True(t, wait >= 4*time.Second && wait <= 6*time.Second) +} + +func TestComputeReportDateRangeDefaultLookback(t *testing.T) { + now := time.Date(2025, 1, 10, 12, 0, 0, 0, time.UTC) + start, until := computeReportDateRange(now, nil) + require.Equal(t, time.Date(2025, 1, 9, 0, 0, 0, 0, time.UTC), until) + require.Equal(t, time.Date(2024, 1, 11, 0, 0, 0, 0, time.UTC), start) +} + +func TestComputeReportDateRangeUsesSince(t *testing.T) { + now := time.Date(2025, 1, 10, 12, 0, 0, 0, time.UTC) + since := time.Date(2025, 1, 3, 12, 0, 0, 0, time.UTC) + start, until := computeReportDateRange(now, &since) + require.Equal(t, time.Date(2025, 1, 9, 0, 0, 0, 0, time.UTC), until) + require.Equal(t, time.Date(2025, 1, 3, 0, 0, 0, 0, time.UTC), start) +} + +func TestComputeReportDateRangeClampsToLookback(t *testing.T) { + now := time.Date(2025, 1, 10, 12, 0, 0, 0, time.UTC) + since := time.Date(2024, 6, 24, 12, 0, 0, 0, time.UTC) + start, until := computeReportDateRange(now, &since) + require.Equal(t, time.Date(2025, 1, 9, 0, 0, 0, 0, time.UTC), until) + require.Equal(t, time.Date(2024, 6, 24, 0, 0, 0, 0, time.UTC), start) +} + +func TestComputeReportDateRangeClampsFutureSince(t *testing.T) { + now := time.Date(2025, 1, 10, 12, 0, 0, 0, time.UTC) + since := now.Add(24 * time.Hour) + start, until := computeReportDateRange(now, &since) + require.Equal(t, time.Date(2025, 1, 9, 0, 0, 0, 0, time.UTC), until) + require.Equal(t, time.Date(2025, 1, 9, 0, 0, 0, 0, time.UTC), start) +} diff --git a/backend/plugins/gh-copilot/tasks/metrics_extractor.go b/backend/plugins/gh-copilot/tasks/metrics_extractor.go new file mode 100644 index 00000000000..4d635c1723e --- /dev/null +++ b/backend/plugins/gh-copilot/tasks/metrics_extractor.go @@ -0,0 +1,224 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import ( + "encoding/json" + "time" + + "github.com/apache/incubator-devlake/core/errors" + "github.com/apache/incubator-devlake/core/plugin" + helper "github.com/apache/incubator-devlake/helpers/pluginhelper/api" + "github.com/apache/incubator-devlake/plugins/gh-copilot/models" +) + +// Seat response structs (used by seat_extractor.go) + +type copilotSeatResponse struct { + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + PlanType string `json:"plan_type"` + PendingCancellationDate *string `json:"pending_cancellation_date"` + LastAuthenticatedAt *string `json:"last_authenticated_at"` + LastActivityAt *string `json:"last_activity_at"` + LastActivityEditor string `json:"last_activity_editor"` + Assignee copilotAssignee `json:"assignee"` +} + +type copilotAssignee struct { + Login string `json:"login"` + Id int64 `json:"id"` + Type string `json:"type"` +} + +// ExtractOrgMetrics parses org report data from the new report download API. +// The org report uses the same flat format as enterprise reports (day, totals_by_*). +// It writes to the same unified tables as ExtractEnterpriseMetrics so the +// Grafana dashboard works identically for org-only and enterprise connections. +func ExtractOrgMetrics(taskCtx plugin.SubTaskContext) errors.Error { + data, ok := taskCtx.TaskContext().GetData().(*GhCopilotTaskData) + if !ok { + return errors.Default.New("task data is not GhCopilotTaskData") + } + connection := data.Connection + connection.Normalize() + + if connection.Organization == "" { + taskCtx.GetLogger().Info("No organization configured, skipping org metrics extraction") + return nil + } + + params := copilotRawParams{ + ConnectionId: data.Options.ConnectionId, + ScopeId: data.Options.ScopeId, + Organization: connection.Organization, + Endpoint: connection.Endpoint, + } + + extractor, err := helper.NewApiExtractor(helper.ApiExtractorArgs{ + RawDataSubTaskArgs: helper.RawDataSubTaskArgs{ + Ctx: taskCtx, + Table: rawOrgMetricsTable, + Options: params, + }, + Extract: func(row *helper.RawData) ([]interface{}, errors.Error) { + var dt enterpriseDayTotal + if err := errors.Convert(json.Unmarshal(row.Data, &dt)); err != nil { + return nil, err + } + + day, parseErr := time.Parse("2006-01-02", dt.Day) + if parseErr != nil { + return nil, errors.BadInput.Wrap(parseErr, "invalid day in org report") + } + + var results []interface{} + + // Main daily metrics — same model as enterprise extractor + dailyMetrics := &models.GhCopilotEnterpriseDailyMetrics{ + ConnectionId: data.Options.ConnectionId, + ScopeId: data.Options.ScopeId, + Day: day, + EnterpriseId: "", // org-level, no enterprise + DailyActiveUsers: dt.DailyActiveUsers, + WeeklyActiveUsers: dt.WeeklyActiveUsers, + MonthlyActiveUsers: dt.MonthlyActiveUsers, + MonthlyActiveChatUsers: dt.MonthlyActiveChatUsers, + MonthlyActiveAgentUsers: dt.MonthlyActiveAgentUsers, + CopilotActivityMetrics: models.CopilotActivityMetrics{ + UserInitiatedInteractionCount: dt.UserInitiatedInteractionCount, + CodeGenerationActivityCount: dt.CodeGenerationActivityCount, + CodeAcceptanceActivityCount: dt.CodeAcceptanceActivityCount, + LocSuggestedToAddSum: dt.LocSuggestedToAddSum, + LocSuggestedToDeleteSum: dt.LocSuggestedToDeleteSum, + LocAddedSum: dt.LocAddedSum, + LocDeletedSum: dt.LocDeletedSum, + }, + } + if dt.PullRequests != nil { + dailyMetrics.PRTotalReviewed = dt.PullRequests.TotalReviewed + dailyMetrics.PRTotalCreated = dt.PullRequests.TotalCreated + dailyMetrics.PRTotalCreatedByCopilot = dt.PullRequests.TotalCreatedByCopilot + dailyMetrics.PRTotalReviewedByCopilot = dt.PullRequests.TotalReviewedByCopilot + } + results = append(results, dailyMetrics) + + // By IDE + for _, ide := range dt.TotalsByIde { + results = append(results, &models.GhCopilotMetricsByIde{ + ConnectionId: data.Options.ConnectionId, + ScopeId: data.Options.ScopeId, + Day: day, + Ide: ide.Ide, + CopilotActivityMetrics: models.CopilotActivityMetrics{ + UserInitiatedInteractionCount: ide.UserInitiatedInteractionCount, + CodeGenerationActivityCount: ide.CodeGenerationActivityCount, + CodeAcceptanceActivityCount: ide.CodeAcceptanceActivityCount, + LocSuggestedToAddSum: ide.LocSuggestedToAddSum, + LocSuggestedToDeleteSum: ide.LocSuggestedToDeleteSum, + LocAddedSum: ide.LocAddedSum, + LocDeletedSum: ide.LocDeletedSum, + }, + }) + } + + // By Feature + for _, f := range dt.TotalsByFeature { + results = append(results, &models.GhCopilotMetricsByFeature{ + ConnectionId: data.Options.ConnectionId, + ScopeId: data.Options.ScopeId, + Day: day, + Feature: f.Feature, + CopilotActivityMetrics: models.CopilotActivityMetrics{ + UserInitiatedInteractionCount: f.UserInitiatedInteractionCount, + CodeGenerationActivityCount: f.CodeGenerationActivityCount, + CodeAcceptanceActivityCount: f.CodeAcceptanceActivityCount, + LocSuggestedToAddSum: f.LocSuggestedToAddSum, + LocSuggestedToDeleteSum: f.LocSuggestedToDeleteSum, + LocAddedSum: f.LocAddedSum, + LocDeletedSum: f.LocDeletedSum, + }, + }) + } + + // By Language+Feature + for _, lf := range dt.TotalsByLanguageFeature { + results = append(results, &models.GhCopilotMetricsByLanguageFeature{ + ConnectionId: data.Options.ConnectionId, + ScopeId: data.Options.ScopeId, + Day: day, + Language: lf.Language, + Feature: lf.Feature, + CopilotCodeMetrics: models.CopilotCodeMetrics{ + CodeGenerationActivityCount: lf.CodeGenerationActivityCount, + CodeAcceptanceActivityCount: lf.CodeAcceptanceActivityCount, + LocSuggestedToAddSum: lf.LocSuggestedToAddSum, + LocSuggestedToDeleteSum: lf.LocSuggestedToDeleteSum, + LocAddedSum: lf.LocAddedSum, + LocDeletedSum: lf.LocDeletedSum, + }, + }) + } + + // By Language+Model + for _, lm := range dt.TotalsByLanguageModel { + results = append(results, &models.GhCopilotMetricsByLanguageModel{ + ConnectionId: data.Options.ConnectionId, + ScopeId: data.Options.ScopeId, + Day: day, + Language: lm.Language, + Model: lm.Model, + CopilotCodeMetrics: models.CopilotCodeMetrics{ + CodeGenerationActivityCount: lm.CodeGenerationActivityCount, + CodeAcceptanceActivityCount: lm.CodeAcceptanceActivityCount, + LocSuggestedToAddSum: lm.LocSuggestedToAddSum, + LocSuggestedToDeleteSum: lm.LocSuggestedToDeleteSum, + LocAddedSum: lm.LocAddedSum, + LocDeletedSum: lm.LocDeletedSum, + }, + }) + } + + // By Model+Feature + for _, mf := range dt.TotalsByModelFeature { + results = append(results, &models.GhCopilotMetricsByModelFeature{ + ConnectionId: data.Options.ConnectionId, + ScopeId: data.Options.ScopeId, + Day: day, + Model: mf.Model, + Feature: mf.Feature, + CopilotActivityMetrics: models.CopilotActivityMetrics{ + UserInitiatedInteractionCount: mf.UserInitiatedInteractionCount, + CodeGenerationActivityCount: mf.CodeGenerationActivityCount, + CodeAcceptanceActivityCount: mf.CodeAcceptanceActivityCount, + LocSuggestedToAddSum: mf.LocSuggestedToAddSum, + LocSuggestedToDeleteSum: mf.LocSuggestedToDeleteSum, + LocAddedSum: mf.LocAddedSum, + LocDeletedSum: mf.LocDeletedSum, + }, + }) + } + + return results, nil + }, + }) + if err != nil { + return err + } + return extractor.Execute() +} diff --git a/backend/plugins/gh-copilot/tasks/options.go b/backend/plugins/gh-copilot/tasks/options.go new file mode 100644 index 00000000000..705c0370571 --- /dev/null +++ b/backend/plugins/gh-copilot/tasks/options.go @@ -0,0 +1,24 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +// GhCopilotOptions defines task-level options passed from pipeline plans. +type GhCopilotOptions struct { + ConnectionId uint64 `mapstructure:"connectionId" json:"connectionId"` + ScopeId string `mapstructure:"scopeId" json:"scopeId"` +} diff --git a/backend/plugins/gh-copilot/tasks/org_metrics_collector.go b/backend/plugins/gh-copilot/tasks/org_metrics_collector.go new file mode 100644 index 00000000000..7844b546c9c --- /dev/null +++ b/backend/plugins/gh-copilot/tasks/org_metrics_collector.go @@ -0,0 +1,121 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "time" + + "github.com/apache/incubator-devlake/core/errors" + "github.com/apache/incubator-devlake/core/plugin" + helper "github.com/apache/incubator-devlake/helpers/pluginhelper/api" +) + +const rawOrgMetricsTable = "copilot_org_metrics" + +// CollectOrgMetrics collects organization-level daily Copilot usage reports +// using the new report download API. Replaces the deprecated /orgs/{org}/copilot/metrics endpoint. +func CollectOrgMetrics(taskCtx plugin.SubTaskContext) errors.Error { + data, ok := taskCtx.TaskContext().GetData().(*GhCopilotTaskData) + if !ok { + return errors.Default.New("task data is not GhCopilotTaskData") + } + connection := data.Connection + connection.Normalize() + + if connection.Organization == "" { + taskCtx.GetLogger().Info("No organization configured, skipping org metrics collection") + return nil + } + + apiClient, err := CreateApiClient(taskCtx.TaskContext(), connection) + if err != nil { + return err + } + + rawArgs := helper.RawDataSubTaskArgs{ + Ctx: taskCtx, + Table: rawOrgMetricsTable, + Options: copilotRawParams{ + ConnectionId: data.Options.ConnectionId, + ScopeId: data.Options.ScopeId, + Organization: connection.Organization, + Endpoint: connection.Endpoint, + }, + } + + collector, err := helper.NewStatefulApiCollector(rawArgs) + if err != nil { + return err + } + + now := time.Now().UTC() + start, until := computeReportDateRange(now, collector.GetSince()) + logger := taskCtx.GetLogger() + + dayIter := newDayIterator(start, until) + + err = collector.InitCollector(helper.ApiCollectorArgs{ + ApiClient: apiClient, + Input: dayIter, + UrlTemplate: fmt.Sprintf("orgs/%s/copilot/metrics/reports/organization-1-day", + connection.Organization), + Query: func(reqData *helper.RequestData) (url.Values, errors.Error) { + input := reqData.Input.(*dayInput) + q := url.Values{} + q.Set("day", input.Day) + return q, nil + }, + Incremental: true, + Concurrency: 1, + AfterResponse: ignore404, + ResponseParser: func(res *http.Response) ([]json.RawMessage, errors.Error) { + body, readErr := io.ReadAll(res.Body) + res.Body.Close() + if readErr != nil { + return nil, errors.Default.Wrap(readErr, "failed to read report metadata") + } + + var meta reportMetadataResponse + if jsonErr := json.Unmarshal(body, &meta); jsonErr != nil { + return nil, errors.Default.Wrap(jsonErr, "failed to parse report metadata") + } + + var results []json.RawMessage + for _, link := range meta.DownloadLinks { + reportBody, dlErr := downloadReport(link, logger) + if dlErr != nil { + return nil, dlErr + } + if reportBody == nil { + continue // blob not found, skip + } + results = append(results, json.RawMessage(reportBody)) + } + return results, nil + }, + }) + if err != nil { + return err + } + return collector.Execute() +} diff --git a/backend/plugins/gh-copilot/tasks/register.go b/backend/plugins/gh-copilot/tasks/register.go new file mode 100644 index 00000000000..ee1dcc797fc --- /dev/null +++ b/backend/plugins/gh-copilot/tasks/register.go @@ -0,0 +1,36 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import "github.com/apache/incubator-devlake/core/plugin" + +// GetSubTaskMetas returns the ordered list of Copilot subtasks. +func GetSubTaskMetas() []plugin.SubTaskMeta { + return []plugin.SubTaskMeta{ + // Collectors + CollectOrgMetricsMeta, + CollectCopilotSeatAssignmentsMeta, + CollectEnterpriseMetricsMeta, + CollectUserMetricsMeta, + // Extractors + ExtractSeatsMeta, + ExtractOrgMetricsMeta, + ExtractEnterpriseMetricsMeta, + ExtractUserMetricsMeta, + } +} diff --git a/backend/plugins/gh-copilot/tasks/report_download_helper.go b/backend/plugins/gh-copilot/tasks/report_download_helper.go new file mode 100644 index 00000000000..3e7e395ca93 --- /dev/null +++ b/backend/plugins/gh-copilot/tasks/report_download_helper.go @@ -0,0 +1,174 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "time" + + "github.com/apache/incubator-devlake/core/errors" + "github.com/apache/incubator-devlake/core/log" + helper "github.com/apache/incubator-devlake/helpers/pluginhelper/api" +) + +// reportMaxDays is the maximum historical window the new report API supports (1 year). +const reportMaxDays = 365 + +// copilotRawParams identifies a set of raw data records for a given connection/scope. +type copilotRawParams struct { + ConnectionId uint64 + ScopeId string + Organization string + Endpoint string +} + +func (p copilotRawParams) GetParams() any { + return p +} + +func utcDate(t time.Time) time.Time { + y, m, d := t.UTC().Date() + return time.Date(y, m, d, 0, 0, 0, 0, time.UTC) +} + +// ignore404 is an AfterResponse callback that skips 404 responses. +// The report API returns 404 when no report is available for a given day, +// which is normal and should not be treated as an error. +func ignore404(res *http.Response) errors.Error { + if res.StatusCode == http.StatusNotFound { + return helper.ErrIgnoreAndContinue + } + return nil +} + +// reportMetadataResponse represents the JSON returned by the report metadata endpoints. +type reportMetadataResponse struct { + DownloadLinks []string `json:"download_links"` + ReportDay string `json:"report_day"` + // 28-day variants use start/end instead of a single day. + ReportStartDay string `json:"report_start_day"` + ReportEndDay string `json:"report_end_day"` +} + +// computeReportDateRange returns the range of dates to collect, clamped to the API max. +func computeReportDateRange(now time.Time, since *time.Time) (start, until time.Time) { + until = utcDate(now).AddDate(0, 0, -1) // reports are available for the previous day + min := until.AddDate(0, 0, -(reportMaxDays - 1)) + start = min + if since != nil { + start = utcDate(*since) + if start.Before(min) { + start = min + } + if start.After(until) { + start = until + } + } + return start, until +} + +// fetchReportMetadata calls a report metadata endpoint for a specific day and returns the download links. +func fetchReportMetadata( + apiClient *helper.ApiAsyncClient, + endpoint string, + day time.Time, + logger log.Logger, +) (*reportMetadataResponse, errors.Error) { + dayStr := day.Format("2006-01-02") + uri := fmt.Sprintf("%s?day=%s", endpoint, dayStr) + + res, err := apiClient.Get(uri, nil, nil) + if err != nil { + return nil, errors.Default.Wrap(err, fmt.Sprintf("failed to fetch report metadata for %s", dayStr)) + } + defer res.Body.Close() + + if res.StatusCode == http.StatusNotFound { + // Report not available for this day (data not yet processed or no activity) + if logger != nil { + logger.Info("No report available for %s (404), skipping", dayStr) + } + return nil, nil + } + if res.StatusCode >= 400 { + body, _ := io.ReadAll(res.Body) + return nil, buildGitHubApiError(res.StatusCode, "", body, res.Header.Get("Retry-After")) + } + + body, readErr := io.ReadAll(res.Body) + if readErr != nil { + return nil, errors.Default.Wrap(readErr, "failed to read report metadata response") + } + + var meta reportMetadataResponse + if jsonErr := json.Unmarshal(body, &meta); jsonErr != nil { + return nil, errors.Default.Wrap(jsonErr, fmt.Sprintf("failed to parse report metadata for %s", dayStr)) + } + return &meta, nil +} + +// downloadReport downloads a single report file from a signed URL and returns the raw body. +// Returns nil, nil when the blob is not found (404) — the caller should skip such reports. +func downloadReport(url string, logger log.Logger) ([]byte, errors.Error) { + resp, err := http.Get(url) + if err != nil { + return nil, errors.Default.Wrap(err, "failed to download report file") + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + if logger != nil { + logger.Info("Report blob not found (404), skipping") + } + return nil, nil + } + + if resp.StatusCode >= 400 { + body, _ := io.ReadAll(resp.Body) + return nil, errors.Default.New(fmt.Sprintf("report download failed with status %d: %s", resp.StatusCode, string(body))) + } + + body, readErr := io.ReadAll(resp.Body) + if readErr != nil { + return nil, errors.Default.Wrap(readErr, "failed to read report file body") + } + + if logger != nil { + logger.Info("Downloaded report file (%d bytes)", len(body)) + } + return body, nil +} + +// parseJSONL splits a JSONL (JSON Lines) byte slice into individual JSON messages. +// Each non-empty line is treated as a separate JSON object. +func parseJSONL(data []byte) ([]json.RawMessage, errors.Error) { + var results []json.RawMessage + lines := bytes.Split(data, []byte("\n")) + for _, line := range lines { + line = bytes.TrimSpace(line) + if len(line) == 0 { + continue + } + results = append(results, json.RawMessage(line)) + } + return results, nil +} diff --git a/backend/plugins/gh-copilot/tasks/retry_after.go b/backend/plugins/gh-copilot/tasks/retry_after.go new file mode 100644 index 00000000000..6b0a36b805e --- /dev/null +++ b/backend/plugins/gh-copilot/tasks/retry_after.go @@ -0,0 +1,44 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import ( + "net/http" + "strconv" + "time" +) + +func parseRetryAfter(value string, now time.Time) time.Duration { + if value == "" { + return 0 + } + if seconds, err := strconv.Atoi(value); err == nil { + if seconds <= 0 { + return 0 + } + return time.Duration(seconds) * time.Second + } + if t, err := http.ParseTime(value); err == nil { + wait := t.Sub(now) + if wait < 0 { + return 0 + } + return wait + } + return 0 +} diff --git a/backend/plugins/gh-copilot/tasks/seat_collector.go b/backend/plugins/gh-copilot/tasks/seat_collector.go new file mode 100644 index 00000000000..8724805f666 --- /dev/null +++ b/backend/plugins/gh-copilot/tasks/seat_collector.go @@ -0,0 +1,129 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + + "github.com/apache/incubator-devlake/core/errors" + "github.com/apache/incubator-devlake/core/plugin" + helper "github.com/apache/incubator-devlake/helpers/pluginhelper/api" +) + +const rawCopilotSeatsTable = "copilot_seats" + +func parseCopilotSeatsFromResponse(res *http.Response) ([]json.RawMessage, errors.Error) { + if res == nil { + return nil, errors.Default.New("res is nil") + } + defer res.Body.Close() + + body, err := io.ReadAll(res.Body) + if err != nil { + return nil, errors.Default.Wrap(err, fmt.Sprintf("error reading response body of %s", res.Request.URL.String())) + } + + // Some GitHub endpoints return a top-level array, others return an object wrapper. + // Support both, returning the underlying array of seat objects. + var rawMessages []json.RawMessage + if jsonErr := json.Unmarshal(body, &rawMessages); jsonErr == nil { + return rawMessages, nil + } + + var wrapped struct { + Seats []json.RawMessage `json:"seats"` + } + if jsonErr := json.Unmarshal(body, &wrapped); jsonErr != nil { + return nil, errors.Default.Wrap(errors.Convert(jsonErr), fmt.Sprintf("error decoding response of %s: raw response: %s", res.Request.URL.String(), string(body))) + } + return wrapped.Seats, nil +} + +func CollectCopilotSeatAssignments(taskCtx plugin.SubTaskContext) errors.Error { + data, ok := taskCtx.TaskContext().GetData().(*GhCopilotTaskData) + if !ok { + return errors.Default.New("task data is not GhCopilotTaskData") + } + connection := data.Connection + connection.Normalize() + + apiClient, err := CreateApiClient(taskCtx.TaskContext(), connection) + if err != nil { + return err + } + + rawArgs := helper.RawDataSubTaskArgs{ + Ctx: taskCtx, + Table: rawCopilotSeatsTable, + Options: copilotRawParams{ + ConnectionId: data.Options.ConnectionId, + ScopeId: data.Options.ScopeId, + Organization: connection.Organization, + Endpoint: connection.Endpoint, + }, + } + + collector, err := helper.NewStatefulApiCollector(rawArgs) + if err != nil { + return err + } + + var urlTemplate string + switch { + case connection.HasEnterprise(): + urlTemplate = fmt.Sprintf("enterprises/%s/copilot/billing/seats", connection.Enterprise) + case connection.Organization != "": + urlTemplate = fmt.Sprintf("orgs/%s/copilot/billing/seats", connection.Organization) + default: + taskCtx.GetLogger().Warn(nil, "skipping seat collection: no enterprise or organization configured on connection %d", connection.ID) + return nil + } + + perPage := 100 + err = collector.InitCollector(helper.ApiCollectorArgs{ + ApiClient: apiClient, + PageSize: perPage, + UrlTemplate: urlTemplate, + Query: func(reqData *helper.RequestData) (url.Values, errors.Error) { + q := url.Values{} + q.Set("per_page", fmt.Sprintf("%d", reqData.Pager.Size)) + q.Set("page", fmt.Sprintf("%d", reqData.Pager.Page)) + return q, nil + }, + GetNextPageCustomData: func(prevReqData *helper.RequestData, prevPageResponse *http.Response) (interface{}, errors.Error) { + // Standard page/per_page pagination; nothing extra to carry between pages. + return nil, nil + }, + ResponseParser: func(res *http.Response) ([]json.RawMessage, errors.Error) { + if res.StatusCode >= 400 { + body, _ := io.ReadAll(res.Body) + res.Body.Close() + return nil, buildGitHubApiError(res.StatusCode, connection.Organization, body, res.Header.Get("Retry-After")) + } + return parseCopilotSeatsFromResponse(res) + }, + }) + if err != nil { + return err + } + return collector.Execute() +} diff --git a/backend/plugins/gh-copilot/tasks/seat_collector_test.go b/backend/plugins/gh-copilot/tasks/seat_collector_test.go new file mode 100644 index 00000000000..5c01d041b86 --- /dev/null +++ b/backend/plugins/gh-copilot/tasks/seat_collector_test.go @@ -0,0 +1,54 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import ( + "bytes" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestParseCopilotSeatsFromResponse_WrappedObject(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "https://api.github.com/orgs/octodemo/copilot/billing/seats?page=1", nil) + res := &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewBufferString(`{"total_seats":2,"seats":[{"assignee":{"login":"a"}},{"assignee":{"login":"b"}}]}`)), + Request: req, + } + + msgs, err := parseCopilotSeatsFromResponse(res) + require.NoError(t, err) + require.Len(t, msgs, 2) +} + +func TestParseCopilotSeatsFromResponse_Array(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "https://api.github.com/orgs/octodemo/copilot/billing/seats?page=1", nil) + res := &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewBufferString(`[{"assignee":{"login":"a"}}]`)), + Request: req, + } + + msgs, err := parseCopilotSeatsFromResponse(res) + require.NoError(t, err) + require.Len(t, msgs, 1) +} diff --git a/backend/plugins/gh-copilot/tasks/seat_extractor.go b/backend/plugins/gh-copilot/tasks/seat_extractor.go new file mode 100644 index 00000000000..48abc3c0ce1 --- /dev/null +++ b/backend/plugins/gh-copilot/tasks/seat_extractor.go @@ -0,0 +1,115 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import ( + "encoding/json" + "time" + + "github.com/apache/incubator-devlake/core/errors" + "github.com/apache/incubator-devlake/core/plugin" + helper "github.com/apache/incubator-devlake/helpers/pluginhelper/api" + "github.com/apache/incubator-devlake/plugins/gh-copilot/models" +) + +// ExtractSeats parses raw seat assignment data into the GhCopilotSeat tool-layer model. +func ExtractSeats(taskCtx plugin.SubTaskContext) errors.Error { + data, ok := taskCtx.TaskContext().GetData().(*GhCopilotTaskData) + if !ok { + return errors.Default.New("task data is not GhCopilotTaskData") + } + connection := data.Connection + connection.Normalize() + + params := copilotRawParams{ + ConnectionId: data.Options.ConnectionId, + ScopeId: data.Options.ScopeId, + Organization: connection.Organization, + Endpoint: connection.Endpoint, + } + + extractor, err := helper.NewApiExtractor(helper.ApiExtractorArgs{ + RawDataSubTaskArgs: helper.RawDataSubTaskArgs{ + Ctx: taskCtx, + Table: rawCopilotSeatsTable, + Options: params, + }, + Extract: func(row *helper.RawData) ([]interface{}, errors.Error) { + seat := &copilotSeatResponse{} + if err := errors.Convert(json.Unmarshal(row.Data, seat)); err != nil { + return nil, err + } + + createdAt, parseErr := time.Parse(time.RFC3339, seat.CreatedAt) + if parseErr != nil { + return nil, errors.BadInput.Wrap(parseErr, "invalid seat created_at") + } + updatedAt, parseErr := time.Parse(time.RFC3339, seat.UpdatedAt) + if parseErr != nil { + return nil, errors.BadInput.Wrap(parseErr, "invalid seat updated_at") + } + + parseOptional := func(v *string) (*time.Time, errors.Error) { + if v == nil || *v == "" { + return nil, nil + } + if t, parseErr := time.Parse(time.RFC3339, *v); parseErr == nil { + return &t, nil + } + t, parseErr := time.Parse("2006-01-02", *v) + if parseErr != nil { + return nil, errors.BadInput.Wrap(parseErr, "invalid timestamp") + } + return &t, nil + } + + lastAuth, err := parseOptional(seat.LastAuthenticatedAt) + if err != nil { + return nil, err + } + lastAct, err := parseOptional(seat.LastActivityAt) + if err != nil { + return nil, err + } + pendingCancel, err := parseOptional(seat.PendingCancellationDate) + if err != nil { + return nil, err + } + + toolSeat := &models.GhCopilotSeat{ + ConnectionId: data.Options.ConnectionId, + Organization: connection.Organization, + UserLogin: seat.Assignee.Login, + UserId: seat.Assignee.Id, + PlanType: seat.PlanType, + CreatedAt: createdAt, + LastActivityAt: lastAct, + LastActivityEditor: seat.LastActivityEditor, + LastAuthenticatedAt: lastAuth, + PendingCancellationDate: pendingCancel, + UpdatedAt: updatedAt, + } + + return []interface{}{toolSeat}, nil + }, + }) + if err != nil { + return err + } + return extractor.Execute() +} diff --git a/backend/plugins/gh-copilot/tasks/subtasks.go b/backend/plugins/gh-copilot/tasks/subtasks.go new file mode 100644 index 00000000000..24a2c95f1c5 --- /dev/null +++ b/backend/plugins/gh-copilot/tasks/subtasks.go @@ -0,0 +1,90 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import ( + "github.com/apache/incubator-devlake/core/plugin" +) + +var CollectOrgMetricsMeta = plugin.SubTaskMeta{ + Name: "collectOrgMetrics", + EntryPoint: CollectOrgMetrics, + EnabledByDefault: true, + DomainTypes: []string{plugin.DOMAIN_TYPE_CROSS}, + Description: "Collect GitHub Copilot organization usage metrics reports", +} + +var CollectCopilotSeatAssignmentsMeta = plugin.SubTaskMeta{ + Name: "collectCopilotSeatAssignments", + EntryPoint: CollectCopilotSeatAssignments, + EnabledByDefault: true, + DomainTypes: []string{plugin.DOMAIN_TYPE_CROSS}, + Description: "Collect GitHub Copilot seat assignments", +} + +var CollectEnterpriseMetricsMeta = plugin.SubTaskMeta{ + Name: "collectEnterpriseMetrics", + EntryPoint: CollectEnterpriseMetrics, + EnabledByDefault: true, + DomainTypes: []string{plugin.DOMAIN_TYPE_CROSS}, + Description: "Collect GitHub Copilot enterprise usage metrics reports", +} + +var CollectUserMetricsMeta = plugin.SubTaskMeta{ + Name: "collectUserMetrics", + EntryPoint: CollectUserMetrics, + EnabledByDefault: true, + DomainTypes: []string{plugin.DOMAIN_TYPE_CROSS}, + Description: "Collect GitHub Copilot enterprise user-level usage metrics reports", +} + +var ExtractOrgMetricsMeta = plugin.SubTaskMeta{ + Name: "extractOrgMetrics", + EntryPoint: ExtractOrgMetrics, + EnabledByDefault: true, + DomainTypes: []string{plugin.DOMAIN_TYPE_CROSS}, + Description: "Extract Copilot org metrics into unified daily metrics tables", + Dependencies: []*plugin.SubTaskMeta{&CollectOrgMetricsMeta}, +} + +var ExtractSeatsMeta = plugin.SubTaskMeta{ + Name: "extractSeats", + EntryPoint: ExtractSeats, + EnabledByDefault: true, + DomainTypes: []string{plugin.DOMAIN_TYPE_CROSS}, + Description: "Extract Copilot seat assignments into tool-layer tables", + Dependencies: []*plugin.SubTaskMeta{&CollectCopilotSeatAssignmentsMeta}, +} + +var ExtractEnterpriseMetricsMeta = plugin.SubTaskMeta{ + Name: "extractEnterpriseMetrics", + EntryPoint: ExtractEnterpriseMetrics, + EnabledByDefault: true, + DomainTypes: []string{plugin.DOMAIN_TYPE_CROSS}, + Description: "Extract Copilot enterprise metrics into tool-layer tables", + Dependencies: []*plugin.SubTaskMeta{&CollectEnterpriseMetricsMeta}, +} + +var ExtractUserMetricsMeta = plugin.SubTaskMeta{ + Name: "extractUserMetrics", + EntryPoint: ExtractUserMetrics, + EnabledByDefault: true, + DomainTypes: []string{plugin.DOMAIN_TYPE_CROSS}, + Description: "Extract Copilot user metrics into tool-layer tables", + Dependencies: []*plugin.SubTaskMeta{&CollectUserMetricsMeta}, +} diff --git a/backend/plugins/gh-copilot/tasks/task_data.go b/backend/plugins/gh-copilot/tasks/task_data.go new file mode 100644 index 00000000000..59b92fe96e3 --- /dev/null +++ b/backend/plugins/gh-copilot/tasks/task_data.go @@ -0,0 +1,26 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import "github.com/apache/incubator-devlake/plugins/gh-copilot/models" + +// GhCopilotTaskData stores runtime dependencies for subtasks. +type GhCopilotTaskData struct { + Options *GhCopilotOptions + Connection *models.GhCopilotConnection +} diff --git a/backend/plugins/gh-copilot/tasks/user_metrics_collector.go b/backend/plugins/gh-copilot/tasks/user_metrics_collector.go new file mode 100644 index 00000000000..263210a53ed --- /dev/null +++ b/backend/plugins/gh-copilot/tasks/user_metrics_collector.go @@ -0,0 +1,130 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "time" + + "github.com/apache/incubator-devlake/core/errors" + "github.com/apache/incubator-devlake/core/plugin" + helper "github.com/apache/incubator-devlake/helpers/pluginhelper/api" +) + +const rawUserMetricsTable = "copilot_user_metrics" + +// CollectUserMetrics collects enterprise user-level daily Copilot usage reports. +// These reports are in JSONL format (one JSON object per line per user). +// Only available for enterprise-scoped connections. +func CollectUserMetrics(taskCtx plugin.SubTaskContext) errors.Error { + data, ok := taskCtx.TaskContext().GetData().(*GhCopilotTaskData) + if !ok { + return errors.Default.New("task data is not GhCopilotTaskData") + } + connection := data.Connection + connection.Normalize() + + if !connection.HasEnterprise() { + taskCtx.GetLogger().Info("No enterprise configured, skipping user metrics collection") + return nil + } + + apiClient, err := CreateApiClient(taskCtx.TaskContext(), connection) + if err != nil { + return err + } + + rawArgs := helper.RawDataSubTaskArgs{ + Ctx: taskCtx, + Table: rawUserMetricsTable, + Options: copilotRawParams{ + ConnectionId: data.Options.ConnectionId, + ScopeId: data.Options.ScopeId, + Organization: connection.Organization, + Endpoint: connection.Endpoint, + }, + } + + collector, err := helper.NewStatefulApiCollector(rawArgs) + if err != nil { + return err + } + + now := time.Now().UTC() + start, until := computeReportDateRange(now, collector.GetSince()) + logger := taskCtx.GetLogger() + + dayIter := newDayIterator(start, until) + + err = collector.InitCollector(helper.ApiCollectorArgs{ + ApiClient: apiClient, + Input: dayIter, + UrlTemplate: fmt.Sprintf("enterprises/%s/copilot/metrics/reports/users-1-day", + connection.Enterprise), + Query: func(reqData *helper.RequestData) (url.Values, errors.Error) { + input := reqData.Input.(*dayInput) + q := url.Values{} + q.Set("day", input.Day) + return q, nil + }, + Incremental: true, + Concurrency: 1, + AfterResponse: ignore404, + ResponseParser: func(res *http.Response) ([]json.RawMessage, errors.Error) { + body, readErr := io.ReadAll(res.Body) + res.Body.Close() + if readErr != nil { + return nil, errors.Default.Wrap(readErr, "failed to read report metadata") + } + + var meta reportMetadataResponse + if jsonErr := json.Unmarshal(body, &meta); jsonErr != nil { + return nil, errors.Default.Wrap(jsonErr, "failed to parse report metadata") + } + + // User reports are JSONL — each download link returns one file where + // each line is a separate JSON object for one user's daily metrics. + // We download the file and split into individual JSON messages. + var results []json.RawMessage + for _, link := range meta.DownloadLinks { + reportBody, dlErr := downloadReport(link, logger) + if dlErr != nil { + return nil, dlErr + } + if reportBody == nil { + continue // blob not found, skip + } + // Parse JSONL: split by newlines and return each non-empty line + userRecords, parseErr := parseJSONL(reportBody) + if parseErr != nil { + return nil, parseErr + } + results = append(results, userRecords...) + } + return results, nil + }, + }) + if err != nil { + return err + } + return collector.Execute() +} diff --git a/backend/plugins/gh-copilot/tasks/user_metrics_extractor.go b/backend/plugins/gh-copilot/tasks/user_metrics_extractor.go new file mode 100644 index 00000000000..53dad200f0b --- /dev/null +++ b/backend/plugins/gh-copilot/tasks/user_metrics_extractor.go @@ -0,0 +1,249 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tasks + +import ( + "encoding/json" + "time" + + "github.com/apache/incubator-devlake/core/errors" + "github.com/apache/incubator-devlake/core/plugin" + helper "github.com/apache/incubator-devlake/helpers/pluginhelper/api" + "github.com/apache/incubator-devlake/plugins/gh-copilot/models" +) + +// --- User report JSONL structures (one line per user) --- + +type userDailyReport struct { + ReportStartDay string `json:"report_start_day"` + ReportEndDay string `json:"report_end_day"` + Day string `json:"day"` + EnterpriseId string `json:"enterprise_id"` + UserId int64 `json:"user_id"` + UserLogin string `json:"user_login"` + UserInitiatedInteractionCount int `json:"user_initiated_interaction_count"` + CodeGenerationActivityCount int `json:"code_generation_activity_count"` + CodeAcceptanceActivityCount int `json:"code_acceptance_activity_count"` + LocSuggestedToAddSum int `json:"loc_suggested_to_add_sum"` + LocSuggestedToDeleteSum int `json:"loc_suggested_to_delete_sum"` + LocAddedSum int `json:"loc_added_sum"` + LocDeletedSum int `json:"loc_deleted_sum"` + UsedAgent bool `json:"used_agent"` + UsedChat bool `json:"used_chat"` + TotalsByIde []userTotalsByIde `json:"totals_by_ide"` + TotalsByFeature []totalsByFeature `json:"totals_by_feature"` + TotalsByLanguageFeature []totalsByLangFeature `json:"totals_by_language_feature"` + TotalsByLanguageModel []totalsByLangModel `json:"totals_by_language_model"` + TotalsByModelFeature []totalsByModelFeature `json:"totals_by_model_feature"` +} + +type userTotalsByIde struct { + totalsByIde + LastKnownPluginVersion *pluginVersion `json:"last_known_plugin_version"` + LastKnownIdeVersion *ideVersion `json:"last_known_ide_version"` +} + +type pluginVersion struct { + SampledAt string `json:"sampled_at"` + Plugin string `json:"plugin"` + PluginVersion string `json:"plugin_version"` +} + +type ideVersion struct { + SampledAt string `json:"sampled_at"` + IdeVersion string `json:"ide_version"` +} + +// ExtractUserMetrics parses user report JSONL records and extracts to tool-layer tables. +func ExtractUserMetrics(taskCtx plugin.SubTaskContext) errors.Error { + data, ok := taskCtx.TaskContext().GetData().(*GhCopilotTaskData) + if !ok { + return errors.Default.New("task data is not GhCopilotTaskData") + } + connection := data.Connection + connection.Normalize() + + if !connection.HasEnterprise() { + taskCtx.GetLogger().Info("No enterprise configured, skipping user metrics extraction") + return nil + } + + params := copilotRawParams{ + ConnectionId: data.Options.ConnectionId, + ScopeId: data.Options.ScopeId, + Organization: connection.Organization, + Endpoint: connection.Endpoint, + } + + extractor, err := helper.NewApiExtractor(helper.ApiExtractorArgs{ + RawDataSubTaskArgs: helper.RawDataSubTaskArgs{ + Ctx: taskCtx, + Table: rawUserMetricsTable, + Options: params, + }, + Extract: func(row *helper.RawData) ([]interface{}, errors.Error) { + var u userDailyReport + if err := errors.Convert(json.Unmarshal(row.Data, &u)); err != nil { + return nil, err + } + + day, parseErr := time.Parse("2006-01-02", u.Day) + if parseErr != nil { + return nil, errors.BadInput.Wrap(parseErr, "invalid day in user report") + } + + var results []interface{} + + // Main user daily metrics + results = append(results, &models.GhCopilotUserDailyMetrics{ + ConnectionId: data.Options.ConnectionId, + ScopeId: data.Options.ScopeId, + Day: day, + UserId: u.UserId, + EnterpriseId: u.EnterpriseId, + UserLogin: u.UserLogin, + UsedAgent: u.UsedAgent, + UsedChat: u.UsedChat, + CopilotActivityMetrics: models.CopilotActivityMetrics{ + UserInitiatedInteractionCount: u.UserInitiatedInteractionCount, + CodeGenerationActivityCount: u.CodeGenerationActivityCount, + CodeAcceptanceActivityCount: u.CodeAcceptanceActivityCount, + LocSuggestedToAddSum: u.LocSuggestedToAddSum, + LocSuggestedToDeleteSum: u.LocSuggestedToDeleteSum, + LocAddedSum: u.LocAddedSum, + LocDeletedSum: u.LocDeletedSum, + }, + }) + + // User by IDE + for _, ide := range u.TotalsByIde { + rec := &models.GhCopilotUserMetricsByIde{ + ConnectionId: data.Options.ConnectionId, + ScopeId: data.Options.ScopeId, + Day: day, + UserId: u.UserId, + Ide: ide.Ide, + CopilotActivityMetrics: models.CopilotActivityMetrics{ + UserInitiatedInteractionCount: ide.UserInitiatedInteractionCount, + CodeGenerationActivityCount: ide.CodeGenerationActivityCount, + CodeAcceptanceActivityCount: ide.CodeAcceptanceActivityCount, + LocSuggestedToAddSum: ide.LocSuggestedToAddSum, + LocSuggestedToDeleteSum: ide.LocSuggestedToDeleteSum, + LocAddedSum: ide.LocAddedSum, + LocDeletedSum: ide.LocDeletedSum, + }, + } + if ide.LastKnownPluginVersion != nil { + rec.LastKnownPluginName = ide.LastKnownPluginVersion.Plugin + rec.LastKnownPluginVersion = ide.LastKnownPluginVersion.PluginVersion + } + if ide.LastKnownIdeVersion != nil { + rec.LastKnownIdeVersion = ide.LastKnownIdeVersion.IdeVersion + } + results = append(results, rec) + } + + // User by Feature + for _, f := range u.TotalsByFeature { + results = append(results, &models.GhCopilotUserMetricsByFeature{ + ConnectionId: data.Options.ConnectionId, + ScopeId: data.Options.ScopeId, + Day: day, + UserId: u.UserId, + Feature: f.Feature, + CopilotActivityMetrics: models.CopilotActivityMetrics{ + UserInitiatedInteractionCount: f.UserInitiatedInteractionCount, + CodeGenerationActivityCount: f.CodeGenerationActivityCount, + CodeAcceptanceActivityCount: f.CodeAcceptanceActivityCount, + LocSuggestedToAddSum: f.LocSuggestedToAddSum, + LocSuggestedToDeleteSum: f.LocSuggestedToDeleteSum, + LocAddedSum: f.LocAddedSum, + LocDeletedSum: f.LocDeletedSum, + }, + }) + } + + // User by Language+Feature + for _, lf := range u.TotalsByLanguageFeature { + results = append(results, &models.GhCopilotUserMetricsByLanguageFeature{ + ConnectionId: data.Options.ConnectionId, + ScopeId: data.Options.ScopeId, + Day: day, + UserId: u.UserId, + Language: lf.Language, + Feature: lf.Feature, + CopilotCodeMetrics: models.CopilotCodeMetrics{ + CodeGenerationActivityCount: lf.CodeGenerationActivityCount, + CodeAcceptanceActivityCount: lf.CodeAcceptanceActivityCount, + LocSuggestedToAddSum: lf.LocSuggestedToAddSum, + LocSuggestedToDeleteSum: lf.LocSuggestedToDeleteSum, + LocAddedSum: lf.LocAddedSum, + LocDeletedSum: lf.LocDeletedSum, + }, + }) + } + + // User by Language+Model + for _, lm := range u.TotalsByLanguageModel { + results = append(results, &models.GhCopilotUserMetricsByLanguageModel{ + ConnectionId: data.Options.ConnectionId, + ScopeId: data.Options.ScopeId, + Day: day, + UserId: u.UserId, + Language: lm.Language, + Model: lm.Model, + CopilotCodeMetrics: models.CopilotCodeMetrics{ + CodeGenerationActivityCount: lm.CodeGenerationActivityCount, + CodeAcceptanceActivityCount: lm.CodeAcceptanceActivityCount, + LocSuggestedToAddSum: lm.LocSuggestedToAddSum, + LocSuggestedToDeleteSum: lm.LocSuggestedToDeleteSum, + LocAddedSum: lm.LocAddedSum, + LocDeletedSum: lm.LocDeletedSum, + }, + }) + } + + // User by Model+Feature + for _, mf := range u.TotalsByModelFeature { + results = append(results, &models.GhCopilotUserMetricsByModelFeature{ + ConnectionId: data.Options.ConnectionId, + ScopeId: data.Options.ScopeId, + Day: day, + UserId: u.UserId, + Model: mf.Model, + Feature: mf.Feature, + CopilotActivityMetrics: models.CopilotActivityMetrics{ + UserInitiatedInteractionCount: mf.UserInitiatedInteractionCount, + CodeGenerationActivityCount: mf.CodeGenerationActivityCount, + CodeAcceptanceActivityCount: mf.CodeAcceptanceActivityCount, + LocSuggestedToAddSum: mf.LocSuggestedToAddSum, + LocSuggestedToDeleteSum: mf.LocSuggestedToDeleteSum, + LocAddedSum: mf.LocAddedSum, + LocDeletedSum: mf.LocDeletedSum, + }, + }) + } + + return results, nil + }, + }) + if err != nil { + return err + } + return extractor.Execute() +} diff --git a/backend/plugins/table_info_test.go b/backend/plugins/table_info_test.go index 3bf09037255..834bc6d2e7c 100644 --- a/backend/plugins/table_info_test.go +++ b/backend/plugins/table_info_test.go @@ -28,6 +28,7 @@ import ( bitbucket "github.com/apache/incubator-devlake/plugins/bitbucket/impl" bitbucket_server "github.com/apache/incubator-devlake/plugins/bitbucket_server/impl" circleci "github.com/apache/incubator-devlake/plugins/circleci/impl" + copilot "github.com/apache/incubator-devlake/plugins/gh-copilot/impl" customize "github.com/apache/incubator-devlake/plugins/customize/impl" dbt "github.com/apache/incubator-devlake/plugins/dbt/impl" dora "github.com/apache/incubator-devlake/plugins/dora/impl" @@ -98,6 +99,7 @@ func Test_GetPluginTablesInfo(t *testing.T) { checker.FeedIn("linker/models", linker.Linker{}.GetTablesInfo) checker.FeedIn("issue_trace/models", issueTrace.IssueTrace{}.GetTablesInfo) checker.FeedIn("q_dev/models", q_dev.QDev{}.GetTablesInfo) + checker.FeedIn("gh-copilot/models", copilot.GhCopilot{}.GetTablesInfo) err := checker.Verify() if err != nil { t.Error(err) From 38443d2973309fbc1d9d5e103d33dcac059c44ba Mon Sep 17 00:00:00 2001 From: ewega Date: Fri, 20 Feb 2026 18:44:29 +0300 Subject: [PATCH 2/4] feat(config-ui): add gh-copilot connection UI --- .gitignore | 3 + .../data-scope-remote/search-local.tsx | 3 +- .../data-scope-remote/search-remote.tsx | 7 +- .../components/data-scope-select/index.tsx | 6 +- .../components/scope-config-form/index.tsx | 34 +++-- .../register/gh-copilot/assets/icon.svg | 19 +++ .../plugins/register/gh-copilot/config.tsx | 85 +++++++++++ .../connection-fields/enterprise.tsx | 69 +++++++++ .../gh-copilot/connection-fields/index.ts | 20 +++ .../connection-fields/organization.tsx | 69 +++++++++ .../gh-copilot/connection-fields/styled.ts | 25 ++++ .../src/plugins/register/gh-copilot/index.ts | 20 +++ .../register/gh-copilot/transformation.tsx | 134 ++++++++++++++++++ config-ui/src/plugins/register/index.ts | 2 + config-ui/src/plugins/utils.ts | 76 +++++++++- .../blueprint/connection-detail/table.tsx | 4 +- .../src/routes/connection/connection.tsx | 3 +- .../src/routes/connection/connections.tsx | 7 +- config-ui/src/routes/onboard/step-3.tsx | 4 +- 19 files changed, 558 insertions(+), 32 deletions(-) create mode 100644 config-ui/src/plugins/register/gh-copilot/assets/icon.svg create mode 100644 config-ui/src/plugins/register/gh-copilot/config.tsx create mode 100644 config-ui/src/plugins/register/gh-copilot/connection-fields/enterprise.tsx create mode 100644 config-ui/src/plugins/register/gh-copilot/connection-fields/index.ts create mode 100644 config-ui/src/plugins/register/gh-copilot/connection-fields/organization.tsx create mode 100644 config-ui/src/plugins/register/gh-copilot/connection-fields/styled.ts create mode 100644 config-ui/src/plugins/register/gh-copilot/index.ts create mode 100644 config-ui/src/plugins/register/gh-copilot/transformation.tsx diff --git a/.gitignore b/.gitignore index 73141d98f51..88f07eb195b 100644 --- a/.gitignore +++ b/.gitignore @@ -150,6 +150,9 @@ bin libgit2 .air.toml +# Playwright CLI snapshots/logs (local dev) +.playwright-cli/ + # auto generated code backend/mocks/ backend/server/api/docs/swagger.json diff --git a/config-ui/src/plugins/components/data-scope-remote/search-local.tsx b/config-ui/src/plugins/components/data-scope-remote/search-local.tsx index 70660c71fc5..39e1282c28d 100644 --- a/config-ui/src/plugins/components/data-scope-remote/search-local.tsx +++ b/config-ui/src/plugins/components/data-scope-remote/search-local.tsx @@ -26,6 +26,7 @@ import { useDebounce } from 'ahooks'; import API from '@/api'; import { Loading, Block, Message } from '@/components'; import { IPluginConfig } from '@/types'; +import { getPluginScopeName } from '@/plugins'; import * as T from './types'; import * as S from './styled'; @@ -197,7 +198,7 @@ export const SearchLocal = ({ mode, plugin, connectionId, config, disabledScope, closable onClose={() => onChange(selectedScope.filter((it) => it.id !== sc.id))} > - {sc.fullName ?? sc.name} + {getPluginScopeName(plugin, sc) || sc.fullName || sc.name || sc.id} )) ) : ( diff --git a/config-ui/src/plugins/components/data-scope-remote/search-remote.tsx b/config-ui/src/plugins/components/data-scope-remote/search-remote.tsx index 4679b856409..82cbf380492 100644 --- a/config-ui/src/plugins/components/data-scope-remote/search-remote.tsx +++ b/config-ui/src/plugins/components/data-scope-remote/search-remote.tsx @@ -27,6 +27,7 @@ import { uniqBy } from 'lodash'; import API from '@/api'; import { Loading, Block } from '@/components'; import { IPluginConfig } from '@/types'; +import { getPluginScopeName } from '@/plugins'; import * as T from './types'; import * as S from './styled'; @@ -93,7 +94,7 @@ export const SearchRemote = ({ mode, plugin, connectionId, config, disabledScope newItems = (res.children ?? []).map((it) => ({ ...it, - title: it.name, + title: getPluginScopeName(plugin, it) || it.name, })); nextPageToken = res.nextPageToken; @@ -136,7 +137,7 @@ export const SearchRemote = ({ mode, plugin, connectionId, config, disabledScope const newItems = (res.children ?? []).map((it) => ({ ...it, - title: it.fullName ?? it.name, + title: getPluginScopeName(plugin, it) || it.fullName || it.name, })); setSearch((s) => ({ @@ -164,7 +165,7 @@ export const SearchRemote = ({ mode, plugin, connectionId, config, disabledScope closable onClose={() => onChange(selectedScope.filter((it) => it.id !== sc.id))} > - {sc.fullName} + {getPluginScopeName(plugin, sc) || sc.fullName || sc.name || sc.id} )) ) : ( diff --git a/config-ui/src/plugins/components/data-scope-select/index.tsx b/config-ui/src/plugins/components/data-scope-select/index.tsx index 79bd8b082f9..860cce6cd06 100644 --- a/config-ui/src/plugins/components/data-scope-select/index.tsx +++ b/config-ui/src/plugins/components/data-scope-select/index.tsx @@ -27,7 +27,7 @@ import API from '@/api'; import { PATHS } from '@/config'; import { Loading, Block, ExternalLink, Message } from '@/components'; import { useRefreshData } from '@/hooks'; -import { getPluginScopeId } from '@/plugins'; +import { getPluginScopeId, getPluginScopeName } from '@/plugins'; interface Props { plugin: string; @@ -70,7 +70,7 @@ export const DataScopeSelect = ({ ...res.scopes.map((sc) => ({ parentId: null, id: getPluginScopeId(plugin, sc.scope), - title: sc.scope.fullName ?? sc.scope.name, + title: getPluginScopeName(plugin, sc.scope) || sc.scope.fullName || sc.scope.name, data: sc.scope, })), ]); @@ -93,7 +93,7 @@ export const DataScopeSelect = ({ const searchOptions = useMemo( () => data?.scopes.map((sc) => ({ - label: sc.scope.fullName ?? sc.scope.name, + label: getPluginScopeName(plugin, sc.scope) || sc.scope.fullName || sc.scope.name, value: getPluginScopeId(plugin, sc.scope), })) ?? [], [data], diff --git a/config-ui/src/plugins/components/scope-config-form/index.tsx b/config-ui/src/plugins/components/scope-config-form/index.tsx index 0f45a5708ff..8e1c784185c 100644 --- a/config-ui/src/plugins/components/scope-config-form/index.tsx +++ b/config-ui/src/plugins/components/scope-config-form/index.tsx @@ -35,6 +35,7 @@ import { TapdTransformation } from '@/plugins/register/tapd'; import { BambooTransformation } from '@/plugins/register/bamboo'; import { CircleCITransformation } from '@/plugins/register/circleci'; import { ArgoCDTransformation } from '@/plugins/register/argocd'; +import { GhCopilotTransformation } from '@/plugins/register/gh-copilot'; import { DOC_URL } from '@/release'; import { operator } from '@/utils'; @@ -118,18 +119,19 @@ export const ScopeConfigForm = ({ return ( - - To learn about how {config.name} transformation is used in DevLake, - {/* @ts-ignore */} - - check out this doc - - . - - } - /> + {DOC_URL.PLUGIN[config.plugin.toUpperCase()]?.TRANSFORMATION && ( + + To learn about how {config.name} transformation is used in DevLake, + + check out this doc + + . + + } + /> + )} {step === 1 && ( <> @@ -259,6 +261,14 @@ export const ScopeConfigForm = ({ /> )} + {plugin === 'gh-copilot' && ( + + )} + {plugin === 'gitlab' && ( + + + diff --git a/config-ui/src/plugins/register/gh-copilot/config.tsx b/config-ui/src/plugins/register/gh-copilot/config.tsx new file mode 100644 index 00000000000..5382b0f0d6f --- /dev/null +++ b/config-ui/src/plugins/register/gh-copilot/config.tsx @@ -0,0 +1,85 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +import { IPluginConfig } from '@/types'; + +import Icon from './assets/icon.svg?react'; +import { Organization, Enterprise } from './connection-fields'; + +export const GhCopilotConfig: IPluginConfig = { + plugin: 'gh-copilot', + name: 'GitHub Copilot', + icon: ({ color }) => , + sort: 6.5, + isBeta: true, + connection: { + docLink: 'https://github.com/apache/incubator-devlake/blob/main/backend/plugins/gh-copilot/README.md', + initialValues: { + endpoint: 'https://api.github.com', + organization: '', + enterprise: '', + token: '', + rateLimitPerHour: 5000, + }, + fields: [ + 'name', + 'endpoint', + ({ type, initialValues, values, setValues, setErrors }: any) => ( + + ), + ({ type, initialValues, values, setValues, setErrors }: any) => ( + + ), + { + key: 'token', + label: 'GitHub Personal Access Token', + subLabel: + 'Use a token with access to the organization/enterprise Copilot metrics (for example: manage_billing:copilot, read:enterprise).', + }, + 'proxy', + { + key: 'rateLimitPerHour', + subLabel: + 'By default, DevLake uses 5,000 requests/hour for GitHub Copilot data collection. Adjust this value to throttle collection speed.', + defaultValue: 5000, + }, + ], + }, + dataScope: { + title: 'Scopes', + }, + scopeConfig: { + entities: ['COPILOT'], + transformation: { + implementationDate: null, + baselinePeriodDays: 90, + }, + }, +}; diff --git a/config-ui/src/plugins/register/gh-copilot/connection-fields/enterprise.tsx b/config-ui/src/plugins/register/gh-copilot/connection-fields/enterprise.tsx new file mode 100644 index 00000000000..d35a1368d19 --- /dev/null +++ b/config-ui/src/plugins/register/gh-copilot/connection-fields/enterprise.tsx @@ -0,0 +1,69 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +import { useEffect, useMemo } from 'react'; +import { Input } from 'antd'; + +import { Block } from '@/components'; + +import * as S from './styled'; + +interface Props { + type: 'create' | 'update'; + initialValues: any; + values: any; + setValues: (value: any) => void; + setErrors: (value: any) => void; +} + +export const Enterprise = ({ type, initialValues, values, setValues, setErrors }: Props) => { + useEffect(() => { + setValues({ enterprise: initialValues.enterprise ?? '' }); + }, [initialValues.enterprise]); + + const error = useMemo(() => { + if (type === 'update') return ''; + const hasOrg = !!values.organization?.trim(); + const hasEnt = !!values.enterprise?.trim(); + return hasOrg || hasEnt ? '' : 'At least one of Organization or Enterprise Slug is required'; + }, [type, values.organization, values.enterprise]); + + useEffect(() => { + setErrors({ scopeIdentity: error }); + }, [error]); + + const handleChange = (e: React.ChangeEvent) => { + setValues({ enterprise: e.target.value }); + }; + + return ( + + + {error && {error}} + + ); +}; diff --git a/config-ui/src/plugins/register/gh-copilot/connection-fields/index.ts b/config-ui/src/plugins/register/gh-copilot/connection-fields/index.ts new file mode 100644 index 00000000000..0b6fa90e9e9 --- /dev/null +++ b/config-ui/src/plugins/register/gh-copilot/connection-fields/index.ts @@ -0,0 +1,20 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +export * from './organization'; +export * from './enterprise'; diff --git a/config-ui/src/plugins/register/gh-copilot/connection-fields/organization.tsx b/config-ui/src/plugins/register/gh-copilot/connection-fields/organization.tsx new file mode 100644 index 00000000000..85f4836020c --- /dev/null +++ b/config-ui/src/plugins/register/gh-copilot/connection-fields/organization.tsx @@ -0,0 +1,69 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +import { useEffect, useMemo } from 'react'; +import { Input } from 'antd'; + +import { Block } from '@/components'; + +import * as S from './styled'; + +interface Props { + type: 'create' | 'update'; + initialValues: any; + values: any; + setValues: (value: any) => void; + setErrors: (value: any) => void; +} + +export const Organization = ({ type, initialValues, values, setValues, setErrors }: Props) => { + useEffect(() => { + setValues({ organization: initialValues.organization ?? '' }); + }, [initialValues.organization]); + + const error = useMemo(() => { + if (type === 'update') return ''; + const hasOrg = !!values.organization?.trim(); + const hasEnt = !!values.enterprise?.trim(); + return hasOrg || hasEnt ? '' : 'At least one of Organization or Enterprise Slug is required'; + }, [type, values.organization, values.enterprise]); + + useEffect(() => { + setErrors({ scopeIdentity: error }); + }, [error]); + + const handleChange = (e: React.ChangeEvent) => { + setValues({ organization: e.target.value }); + }; + + return ( + + + {error && {error}} + + ); +}; diff --git a/config-ui/src/plugins/register/gh-copilot/connection-fields/styled.ts b/config-ui/src/plugins/register/gh-copilot/connection-fields/styled.ts new file mode 100644 index 00000000000..82ba9f717d0 --- /dev/null +++ b/config-ui/src/plugins/register/gh-copilot/connection-fields/styled.ts @@ -0,0 +1,25 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +import styled from 'styled-components'; + +export const ErrorText = styled.div` + margin-top: 4px; + color: #f5222d; + font-size: 12px; +`; diff --git a/config-ui/src/plugins/register/gh-copilot/index.ts b/config-ui/src/plugins/register/gh-copilot/index.ts new file mode 100644 index 00000000000..5f16858cbe4 --- /dev/null +++ b/config-ui/src/plugins/register/gh-copilot/index.ts @@ -0,0 +1,20 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +export * from './config'; +export * from './transformation'; diff --git a/config-ui/src/plugins/register/gh-copilot/transformation.tsx b/config-ui/src/plugins/register/gh-copilot/transformation.tsx new file mode 100644 index 00000000000..a715b638828 --- /dev/null +++ b/config-ui/src/plugins/register/gh-copilot/transformation.tsx @@ -0,0 +1,134 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +import { CaretRightOutlined } from '@ant-design/icons'; +import { theme, Collapse, Tag, DatePicker, InputNumber, Alert } from 'antd'; +import dayjs from 'dayjs'; + +import { HelpTooltip } from '@/components'; + +interface Props { + entities: string[]; + transformation: any; + setTransformation: React.Dispatch>; +} + +export const GhCopilotTransformation = ({ entities, transformation, setTransformation }: Props) => { + const { token } = theme.useToken(); + + const panelStyle: React.CSSProperties = { + marginBottom: 24, + background: token.colorFillAlter, + borderRadius: token.borderRadiusLG, + border: 'none', + }; + + return ( + } + style={{ background: token.colorBgContainer }} + size="large" + items={renderCollapseItems({ + entities, + panelStyle, + transformation, + onChangeTransformation: setTransformation, + token, + })} + /> + ); +}; + +const renderCollapseItems = ({ + entities, + panelStyle, + transformation, + onChangeTransformation, + token, +}: { + entities: string[]; + panelStyle: React.CSSProperties; + transformation: any; + onChangeTransformation: any; + token: any; +}) => + [ + { + key: 'COPILOT', + label: 'GitHub Copilot Impact Analysis', + style: panelStyle, + children: ( + <> +

+ Rollout Milestone Configuration + + Impact Dashboard + +

+ +
+ GitHub Copilot Rollout Date (Optional) + +
+
+ + onChangeTransformation({ + ...transformation, + implementationDate: date ? date.format('YYYY-MM-DD') : null, + }) + } + /> +
+
+ Baseline Period (Days) + +
+
+ + onChangeTransformation({ + ...transformation, + baselinePeriodDays: value ?? 90, + }) + } + /> + + (Recommended: 30-90 days for meaningful comparison) + +
+ + ), + }, + ].filter((it) => entities.includes(it.key) || entities.includes('COPILOT')); diff --git a/config-ui/src/plugins/register/index.ts b/config-ui/src/plugins/register/index.ts index 225032a5b67..a58a60d7fd2 100644 --- a/config-ui/src/plugins/register/index.ts +++ b/config-ui/src/plugins/register/index.ts @@ -25,6 +25,7 @@ import { BitbucketConfig } from './bitbucket'; import { BitbucketServerConfig } from './bitbucket-server'; import { CircleCIConfig } from './circleci'; import { GitHubConfig } from './github'; +import { GhCopilotConfig } from './gh-copilot'; import { GitLabConfig } from './gitlab'; import { JenkinsConfig } from './jenkins'; import { JiraConfig } from './jira'; @@ -48,6 +49,7 @@ export const pluginConfigs: IPluginConfig[] = [ BitbucketServerConfig, CircleCIConfig, GitHubConfig, + GhCopilotConfig, GitLabConfig, JenkinsConfig, JiraConfig, diff --git a/config-ui/src/plugins/utils.ts b/config-ui/src/plugins/utils.ts index c008f4b4da1..94739d40526 100644 --- a/config-ui/src/plugins/utils.ts +++ b/config-ui/src/plugins/utils.ts @@ -16,7 +16,9 @@ * */ -import PluginIcon from '@/images/plugin-icon.svg'; +import React from 'react'; + +import PluginIcon from '@/images/plugin-icon.svg?react'; import { pluginConfigs } from './register'; import { IPluginConfig } from '@/types'; @@ -46,15 +48,83 @@ export const getPluginScopeId = (plugin: string, scope: any) => { } }; -export const getRegisterPlugins = () => pluginConfigs.map((it) => it.plugin); +export const getPluginScopeName = (plugin: string, scope: any) => { + if (!scope) { + return ''; + } + + if (plugin === 'gh-copilot') { + const scopeData = scope.data ?? scope; + + const rawId = `${scope.id ?? scopeData.id ?? scope.fullName ?? scope.name ?? ''}`.trim(); + const enterprise = `${scopeData.enterprise ?? ''}`.trim(); + const organization = `${scopeData.organization ?? ''}`.trim(); + + if (enterprise && organization) { + return `${rawId} (Enterprise + Organization)`; + } + if (enterprise) { + return `${rawId} (Enterprise)`; + } + if (organization) { + return `${rawId} (Organization)`; + } + if (rawId.includes('/')) { + return `${rawId} (Enterprise + Organization)`; + } + return rawId; + } + + return `${scope.fullName ?? scope.name ?? scope.id ?? ''}`; +}; + +const pluginAliasMap: Record = { + copilot: 'gh-copilot', +}; + +const aliasByTarget = Object.entries(pluginAliasMap).reduce>((acc, [alias, target]) => { + acc[target] ??= []; + acc[target].push(alias); + return acc; +}, {}); + +export const getRegisterPlugins = () => { + const ordered: string[] = []; + const seen = new Set(); + + for (const config of pluginConfigs) { + if (!seen.has(config.plugin)) { + ordered.push(config.plugin); + seen.add(config.plugin); + } + + for (const alias of aliasByTarget[config.plugin] ?? []) { + if (!seen.has(alias)) { + ordered.push(alias); + seen.add(alias); + } + } + } + + for (const alias of Object.keys(pluginAliasMap)) { + if (!seen.has(alias)) { + ordered.push(alias); + } + } + + return ordered; +}; export const getPluginConfig = (name: string): IPluginConfig => { let pluginConfig = pluginConfigs.find((it) => it.plugin === name); + if (!pluginConfig && pluginAliasMap[name]) { + pluginConfig = pluginConfigs.find((it) => it.plugin === pluginAliasMap[name]); + } if (!pluginConfig) { pluginConfig = { plugin: name, name: name, - icon: PluginIcon, + icon: ({ color }) => React.createElement(PluginIcon, { fill: color }), sort: 101, connection: { docLink: '', diff --git a/config-ui/src/routes/blueprint/connection-detail/table.tsx b/config-ui/src/routes/blueprint/connection-detail/table.tsx index 7036d59a233..e2c77a67f7e 100644 --- a/config-ui/src/routes/blueprint/connection-detail/table.tsx +++ b/config-ui/src/routes/blueprint/connection-detail/table.tsx @@ -21,7 +21,7 @@ import { Table } from 'antd'; import API from '@/api'; import { useRefreshData } from '@/hooks'; -import { getPluginScopeId, ScopeConfig } from '@/plugins'; +import { getPluginScopeId, getPluginScopeName, ScopeConfig } from '@/plugins'; interface Props { plugin: string; @@ -36,7 +36,7 @@ export const BlueprintConnectionDetailTable = ({ plugin, connectionId, scopeIds const scopes = await Promise.all(scopeIds.map((scopeId) => API.scope.get(plugin, connectionId, scopeId))); return scopes.map((sc) => ({ id: getPluginScopeId(plugin, sc.scope), - name: sc.scope.fullName ?? sc.scope.name, + name: getPluginScopeName(plugin, sc.scope) || sc.scope.fullName || sc.scope.name, scopeConfigId: sc.scopeConfig?.id, scopeConfigName: sc.scopeConfig?.name, })); diff --git a/config-ui/src/routes/connection/connection.tsx b/config-ui/src/routes/connection/connection.tsx index d9a5e4b7ab5..43faeeede26 100644 --- a/config-ui/src/routes/connection/connection.tsx +++ b/config-ui/src/routes/connection/connection.tsx @@ -40,6 +40,7 @@ import { IConnection } from '@/types'; import { operator } from '@/utils'; import * as S from './styled'; +import { getPluginScopeName } from '@/plugins'; const brandName = import.meta.env.DEVLAKE_BRAND_NAME ?? 'DevLake'; @@ -92,7 +93,7 @@ export const Connection = () => { () => [ data?.scopes.map((it: any) => ({ id: getPluginScopeId(plugin, it.scope), - name: it.scope.fullName ?? it.scope.name, + name: getPluginScopeName(plugin, it.scope) || it.scope.fullName || it.scope.name, projects: it.blueprints?.map((bp: any) => bp.projectName) ?? [], configId: it.scopeConfig?.id, configName: it.scopeConfig?.name, diff --git a/config-ui/src/routes/connection/connections.tsx b/config-ui/src/routes/connection/connections.tsx index 776089b0a80..31b463bc065 100644 --- a/config-ui/src/routes/connection/connections.tsx +++ b/config-ui/src/routes/connection/connections.tsx @@ -165,7 +165,7 @@ export const Connections = () => { footer={null} onCancel={handleHideDialog} > - + )} {type === 'form' && pluginConfig && ( @@ -182,10 +182,7 @@ export const Connections = () => { footer={null} onCancel={handleHideDialog} > - handleSuccessAfter(pluginConfig.plugin, id)} - /> + handleSuccessAfter(plugin, id)} /> )} diff --git a/config-ui/src/routes/onboard/step-3.tsx b/config-ui/src/routes/onboard/step-3.tsx index 4ee900bfac9..db080fc01d2 100644 --- a/config-ui/src/routes/onboard/step-3.tsx +++ b/config-ui/src/routes/onboard/step-3.tsx @@ -22,7 +22,7 @@ import dayjs from 'dayjs'; import API from '@/api'; import { Markdown } from '@/components'; -import { DataScopeRemote, getPluginScopeId } from '@/plugins'; +import { DataScopeRemote, getPluginScopeId, getPluginScopeName } from '@/plugins'; import { operator, formatTime } from '@/utils'; import { Context } from './context'; @@ -96,7 +96,7 @@ export const Step3 = () => { ...it, blueprintId: blueprint.id, pipelineId: pipeline.pipelines[0].id, - scopeName: scopes[0]?.fullName ?? scopes[0].name, + scopeName: getPluginScopeName(plugin, scopes[0]) || scopes[0]?.fullName || scopes[0]?.name, }, ); From 76b0ec8a24dc307f7d83617a5a7dd680d5db40e7 Mon Sep 17 00:00:00 2001 From: ewega Date: Fri, 20 Feb 2026 18:44:44 +0300 Subject: [PATCH 3/4] feat(grafana): add GitHub Copilot dashboards --- grafana/dashboards/GithubCopilotAdoption.json | 1 + .../GithubCopilotDORACorrelation.json | 1 + grafana/dashboards/GithubCopilotREADME.md | 111 ++++++++++++++++++ 3 files changed, 113 insertions(+) create mode 100644 grafana/dashboards/GithubCopilotAdoption.json create mode 100644 grafana/dashboards/GithubCopilotDORACorrelation.json create mode 100644 grafana/dashboards/GithubCopilotREADME.md diff --git a/grafana/dashboards/GithubCopilotAdoption.json b/grafana/dashboards/GithubCopilotAdoption.json new file mode 100644 index 00000000000..e018b6ede64 --- /dev/null +++ b/grafana/dashboards/GithubCopilotAdoption.json @@ -0,0 +1 @@ +{"annotations":{"list":[{"builtIn":1,"datasource":{"type":"datasource","uid":"grafana"},"enable":true,"hide":true,"iconColor":"rgba(0, 211, 255, 1)","name":"Annotations & Alerts","type":"dashboard"}]},"editable":true,"fiscalYearStartMonth":0,"graphTooltip":0,"links":[],"liveNow":false,"panels":[{"datasource":"mysql","description":"Latest daily active users count","fieldConfig":{"defaults":{"mappings":[],"thresholds":{"mode":"absolute","steps":[{"color":"green","value":null},{"color":"yellow","value":50},{"color":"red","value":100}]}},"overrides":[]},"gridPos":{"h":4,"w":6,"x":0,"y":0},"id":1,"options":{"colorMode":"value","graphMode":"none","justifyMode":"auto","orientation":"auto","reduceOptions":{"calcs":["lastNotNull"],"fields":"","values":false},"showPercentChange":false,"textMode":"auto","wideLayout":true},"pluginVersion":"11.0.0","targets":[{"datasource":"mysql","format":"table","rawQuery":true,"rawSql":"SELECT daily_active_users as \"Daily Active Users\"\nFROM _tool_copilot_enterprise_daily_metrics\nWHERE connection_id = ${connection_id} AND scope_id = '${scope_id}'\nORDER BY day DESC LIMIT 1","refId":"A"}],"title":"Daily Active Users","type":"stat"},{"datasource":"mysql","description":"Code acceptance rate across the selected time range","fieldConfig":{"defaults":{"mappings":[],"min":0,"max":100,"unit":"percent","thresholds":{"mode":"absolute","steps":[{"color":"red","value":null},{"color":"yellow","value":20},{"color":"green","value":40}]}},"overrides":[]},"gridPos":{"h":4,"w":6,"x":6,"y":0},"id":2,"options":{"minVizHeight":75,"minVizWidth":75,"orientation":"auto","reduceOptions":{"calcs":["lastNotNull"],"fields":"","values":false},"showThresholdLabels":false,"showThresholdMarkers":true,"sizing":"auto"},"pluginVersion":"11.0.0","targets":[{"datasource":"mysql","format":"table","rawQuery":true,"rawSql":"SELECT ROUND(SUM(code_acceptance_activity_count) * 100.0 / NULLIF(SUM(code_generation_activity_count), 0), 1) as \"Acceptance Rate %\"\nFROM _tool_copilot_enterprise_daily_metrics\nWHERE $__timeFilter(day) AND connection_id = ${connection_id} AND scope_id = '${scope_id}'","refId":"A"}],"title":"Code Acceptance Rate","type":"gauge"},{"datasource":"mysql","description":"Total lines of code added by Copilot in the selected time range","fieldConfig":{"defaults":{"mappings":[],"thresholds":{"mode":"absolute","steps":[{"color":"green","value":null}]}},"overrides":[]},"gridPos":{"h":4,"w":6,"x":12,"y":0},"id":3,"options":{"colorMode":"value","graphMode":"none","justifyMode":"auto","orientation":"auto","reduceOptions":{"calcs":["lastNotNull"],"fields":"","values":false},"showPercentChange":false,"textMode":"auto","wideLayout":true},"pluginVersion":"11.0.0","targets":[{"datasource":"mysql","format":"table","rawQuery":true,"rawSql":"SELECT SUM(loc_added_sum) as \"Lines Added\"\nFROM _tool_copilot_enterprise_daily_metrics\nWHERE $__timeFilter(day) AND connection_id = ${connection_id} AND scope_id = '${scope_id}'","refId":"A"}],"title":"Lines of Code Added","type":"stat"},{"datasource":"mysql","description":"Total pull requests created by Copilot in the selected time range","fieldConfig":{"defaults":{"mappings":[],"thresholds":{"mode":"absolute","steps":[{"color":"blue","value":null}]}},"overrides":[]},"gridPos":{"h":4,"w":6,"x":18,"y":0},"id":4,"options":{"colorMode":"value","graphMode":"none","justifyMode":"auto","orientation":"auto","reduceOptions":{"calcs":["lastNotNull"],"fields":"","values":false},"showPercentChange":false,"textMode":"auto","wideLayout":true},"pluginVersion":"11.0.0","targets":[{"datasource":"mysql","format":"table","rawQuery":true,"rawSql":"SELECT SUM(pr_total_created_by_copilot) as \"PRs by Copilot\"\nFROM _tool_copilot_enterprise_daily_metrics\nWHERE $__timeFilter(day) AND connection_id = ${connection_id} AND scope_id = '${scope_id}'","refId":"A"}],"title":"PRs Created by Copilot","type":"stat"},{"datasource":"mysql","description":"Latest weekly active users count","fieldConfig":{"defaults":{"mappings":[],"thresholds":{"mode":"absolute","steps":[{"color":"green","value":null}]}},"overrides":[]},"gridPos":{"h":4,"w":6,"x":0,"y":4},"id":15,"options":{"colorMode":"value","graphMode":"none","justifyMode":"auto","orientation":"auto","reduceOptions":{"calcs":["lastNotNull"],"fields":"","values":false},"showPercentChange":false,"textMode":"auto","wideLayout":true},"pluginVersion":"11.0.0","targets":[{"datasource":"mysql","format":"table","rawQuery":true,"rawSql":"SELECT COUNT(DISTINCT user_login) as \"Weekly Active Users\"\nFROM _tool_copilot_user_daily_metrics\nWHERE day >= DATE_SUB(CURDATE(), INTERVAL 7 DAY)\n AND connection_id = ${connection_id} AND scope_id = '${scope_id}'\n","refId":"A"}],"title":"Weekly Active Users (WAU)","type":"stat"},{"datasource":"mysql","description":"Latest monthly active users count","fieldConfig":{"defaults":{"mappings":[],"thresholds":{"mode":"absolute","steps":[{"color":"green","value":null}]}},"overrides":[]},"gridPos":{"h":4,"w":6,"x":6,"y":4},"id":16,"options":{"colorMode":"value","graphMode":"none","justifyMode":"auto","orientation":"auto","reduceOptions":{"calcs":["lastNotNull"],"fields":"","values":false},"showPercentChange":false,"textMode":"auto","wideLayout":true},"pluginVersion":"11.0.0","targets":[{"datasource":"mysql","format":"table","rawQuery":true,"rawSql":"SELECT COUNT(DISTINCT user_login) as \"Monthly Active Users\"\nFROM _tool_copilot_user_daily_metrics\nWHERE day >= DATE_SUB(CURDATE(), INTERVAL 28 DAY)\n AND connection_id = ${connection_id} AND scope_id = '${scope_id}'\n","refId":"A"}],"title":"Monthly Active Users (MAU)","type":"stat"},{"datasource":"mysql","description":"Total pull requests reviewed by Copilot in the selected time range","fieldConfig":{"defaults":{"mappings":[],"thresholds":{"mode":"absolute","steps":[{"color":"blue","value":null}]}},"overrides":[]},"gridPos":{"h":4,"w":6,"x":12,"y":4},"id":17,"options":{"colorMode":"value","graphMode":"none","justifyMode":"auto","orientation":"auto","reduceOptions":{"calcs":["lastNotNull"],"fields":"","values":false},"showPercentChange":false,"textMode":"auto","wideLayout":true},"pluginVersion":"11.0.0","targets":[{"datasource":"mysql","format":"table","rawQuery":true,"rawSql":"SELECT SUM(pr_total_reviewed_by_copilot) as \"PRs Reviewed by Copilot\"\nFROM _tool_copilot_enterprise_daily_metrics\nWHERE $__timeFilter(day) AND connection_id = ${connection_id} AND scope_id = '${scope_id}'","refId":"A"}],"title":"PRs Reviewed by Copilot","type":"stat"},{"datasource":"mysql","description":"Total user-initiated interactions in the selected time range","fieldConfig":{"defaults":{"mappings":[],"thresholds":{"mode":"absolute","steps":[{"color":"purple","value":null}]}},"overrides":[]},"gridPos":{"h":4,"w":6,"x":18,"y":4},"id":18,"options":{"colorMode":"value","graphMode":"none","justifyMode":"auto","orientation":"auto","reduceOptions":{"calcs":["lastNotNull"],"fields":"","values":false},"showPercentChange":false,"textMode":"auto","wideLayout":true},"pluginVersion":"11.0.0","targets":[{"datasource":"mysql","format":"table","rawQuery":true,"rawSql":"SELECT SUM(user_initiated_interaction_count) as \"User-Initiated Interactions\"\nFROM _tool_copilot_enterprise_daily_metrics\nWHERE $__timeFilter(day) AND connection_id = ${connection_id} AND scope_id = '${scope_id}'","refId":"A"}],"title":"User-Initiated Interactions","type":"stat"},{"datasource":"mysql","description":"Daily and monthly active users over time","fieldConfig":{"defaults":{"color":{"mode":"palette-classic"},"custom":{"axisBorderShow":false,"axisCenteredZero":false,"axisColorMode":"text","axisLabel":"Users","axisPlacement":"auto","barAlignment":0,"drawStyle":"line","fillOpacity":10,"gradientMode":"none","hideFrom":{"legend":false,"tooltip":false,"viz":false},"insertNulls":false,"lineInterpolation":"linear","lineWidth":2,"pointSize":5,"scaleDistribution":{"type":"linear"},"showPoints":"never","spanNulls":false,"stacking":{"group":"A","mode":"none"},"thresholdsStyle":{"mode":"off"}},"mappings":[],"thresholds":{"mode":"absolute","steps":[{"color":"green","value":null}]}},"overrides":[]},"gridPos":{"h":8,"w":12,"x":0,"y":8},"id":5,"options":{"legend":{"calcs":[],"displayMode":"list","placement":"bottom","showLegend":true},"tooltip":{"mode":"single","sort":"none"}},"pluginVersion":"11.0.0","targets":[{"datasource":"mysql","format":"table","rawQuery":true,"rawSql":"SELECT day as time, daily_active_users as \"Daily Active\", monthly_active_users as \"Monthly Active\"\nFROM _tool_copilot_enterprise_daily_metrics\nWHERE $__timeFilter(day) AND connection_id = ${connection_id} AND scope_id = '${scope_id}'\nORDER BY 1","refId":"A"}],"title":"Active Users Over Time","type":"timeseries"},{"datasource":"mysql","description":"Code suggestions generated vs accepted over time","fieldConfig":{"defaults":{"color":{"mode":"palette-classic"},"custom":{"axisBorderShow":false,"axisCenteredZero":false,"axisColorMode":"text","axisLabel":"Count","axisPlacement":"auto","barAlignment":0,"drawStyle":"line","fillOpacity":10,"gradientMode":"none","hideFrom":{"legend":false,"tooltip":false,"viz":false},"insertNulls":false,"lineInterpolation":"linear","lineWidth":2,"pointSize":5,"scaleDistribution":{"type":"linear"},"showPoints":"never","spanNulls":false,"stacking":{"group":"A","mode":"none"},"thresholdsStyle":{"mode":"off"}},"mappings":[],"thresholds":{"mode":"absolute","steps":[{"color":"green","value":null}]}},"overrides":[]},"gridPos":{"h":8,"w":12,"x":12,"y":8},"id":6,"options":{"legend":{"calcs":[],"displayMode":"list","placement":"bottom","showLegend":true},"tooltip":{"mode":"single","sort":"none"}},"pluginVersion":"11.0.0","targets":[{"datasource":"mysql","format":"table","rawQuery":true,"rawSql":"SELECT day as time, code_generation_activity_count as \"Suggestions\", code_acceptance_activity_count as \"Acceptances\"\nFROM _tool_copilot_enterprise_daily_metrics\nWHERE $__timeFilter(day) AND connection_id = ${connection_id} AND scope_id = '${scope_id}'\nORDER BY 1","refId":"A"}],"title":"Code Suggestions & Acceptances","type":"timeseries"},{"datasource":"mysql","description":"Lines of code suggested to add versus lines added over time","fieldConfig":{"defaults":{"color":{"mode":"palette-classic"},"custom":{"axisBorderShow":false,"axisCenteredZero":false,"axisColorMode":"text","axisLabel":"Lines of Code","axisPlacement":"auto","barAlignment":0,"drawStyle":"line","fillOpacity":10,"gradientMode":"none","hideFrom":{"legend":false,"tooltip":false,"viz":false},"insertNulls":false,"lineInterpolation":"linear","lineWidth":2,"pointSize":5,"scaleDistribution":{"type":"linear"},"showPoints":"never","spanNulls":false,"stacking":{"group":"A","mode":"none"},"thresholdsStyle":{"mode":"off"}},"mappings":[],"thresholds":{"mode":"absolute","steps":[{"color":"green","value":null}]}},"overrides":[]},"gridPos":{"h":8,"w":12,"x":0,"y":16},"id":19,"options":{"legend":{"calcs":[],"displayMode":"list","placement":"bottom","showLegend":true},"tooltip":{"mode":"single","sort":"none"}},"pluginVersion":"11.0.0","targets":[{"datasource":"mysql","format":"table","rawQuery":true,"rawSql":"SELECT day as time, loc_suggested_to_add_sum as \"LOC Suggested\", loc_added_sum as \"LOC Added\"\nFROM _tool_copilot_enterprise_daily_metrics\nWHERE $__timeFilter(day) AND connection_id = ${connection_id} AND scope_id = '${scope_id}'\nORDER BY 1","refId":"A"}],"title":"LOC Suggested vs LOC Added Over Time","type":"timeseries"},{"datasource":"mysql","description":"Pull request activity over time","fieldConfig":{"defaults":{"color":{"mode":"palette-classic"},"custom":{"axisBorderShow":false,"axisCenteredZero":false,"axisColorMode":"text","axisLabel":"Pull Requests","axisPlacement":"auto","barAlignment":0,"drawStyle":"line","fillOpacity":10,"gradientMode":"none","hideFrom":{"legend":false,"tooltip":false,"viz":false},"insertNulls":false,"lineInterpolation":"linear","lineWidth":2,"pointSize":5,"scaleDistribution":{"type":"linear"},"showPoints":"never","spanNulls":false,"stacking":{"group":"A","mode":"none"},"thresholdsStyle":{"mode":"off"}},"mappings":[],"thresholds":{"mode":"absolute","steps":[{"color":"green","value":null}]}},"overrides":[]},"gridPos":{"h":8,"w":12,"x":12,"y":16},"id":20,"options":{"legend":{"calcs":[],"displayMode":"list","placement":"bottom","showLegend":true},"tooltip":{"mode":"single","sort":"none"}},"pluginVersion":"11.0.0","targets":[{"datasource":"mysql","format":"table","rawQuery":true,"rawSql":"SELECT day as time, pr_total_created as \"PRs Created\", pr_total_created_by_copilot as \"PRs Created by Copilot\", pr_total_reviewed as \"PRs Reviewed\"\nFROM _tool_copilot_enterprise_daily_metrics\nWHERE $__timeFilter(day) AND connection_id = ${connection_id} AND scope_id = '${scope_id}'\nORDER BY 1","refId":"A"}],"title":"PR Activity Over Time","type":"timeseries"},{"datasource":"mysql","description":"Weekly user-initiated interactions by top Copilot features","fieldConfig":{"defaults":{"color":{"mode":"palette-classic"},"custom":{"axisBorderShow":false,"axisCenteredZero":false,"axisColorMode":"text","axisLabel":"Interactions","axisPlacement":"auto","barAlignment":0,"drawStyle":"bars","fillOpacity":80,"gradientMode":"none","hideFrom":{"legend":false,"tooltip":false,"viz":false},"insertNulls":false,"lineInterpolation":"linear","lineWidth":0,"pointSize":5,"scaleDistribution":{"type":"linear"},"showPoints":"never","spanNulls":false,"stacking":{"mode":"normal","group":"A"},"thresholdsStyle":{"mode":"off"}},"mappings":[],"thresholds":{"mode":"absolute","steps":[{"color":"green","value":null}]}},"overrides":[]},"gridPos":{"h":8,"w":12,"x":0,"y":24},"id":7,"options":{"legend":{"calcs":[],"displayMode":"list","placement":"bottom","showLegend":true},"tooltip":{"mode":"single","sort":"none"},"stacking":{"mode":"normal","group":"A"}},"pluginVersion":"11.0.0","targets":[{"datasource":"mysql","format":"table","rawQuery":true,"rawSql":"SELECT DATE(DATE_SUB(day, INTERVAL WEEKDAY(day) DAY)) as time,\n CASE\n WHEN feature IN (\n SELECT feature FROM (\n SELECT feature\n FROM _tool_copilot_metrics_by_feature\n WHERE $__timeFilter(day) AND connection_id = ${connection_id} AND scope_id = '${scope_id}'\n GROUP BY feature\n HAVING SUM(user_initiated_interaction_count) > 0\n ORDER BY SUM(user_initiated_interaction_count) DESC\n LIMIT 4\n ) top_features\n ) THEN REPLACE(REPLACE(feature, 'chat_panel_', ''), '_mode', '')\n ELSE 'Other'\n END as metric,\n SUM(user_initiated_interaction_count) as value\nFROM _tool_copilot_metrics_by_feature\nWHERE $__timeFilter(day) AND connection_id = ${connection_id} AND scope_id = '${scope_id}'\nGROUP BY time, metric\nORDER BY time","refId":"A"}],"title":"Feature Mix Over Time","type":"timeseries","transformations":[{"id":"prepareTimeSeries","options":{"format":"many"}},{"id":"renameByRegex","options":{"regex":"^value (.+)$","renamePattern":"$1"}}]},{"datasource":"mysql","description":"Weekly user-initiated interactions by top IDEs (Top 5 + Other)","fieldConfig":{"defaults":{"color":{"mode":"palette-classic"},"custom":{"axisBorderShow":false,"axisCenteredZero":false,"axisColorMode":"text","axisLabel":"Weekly Interactions","axisPlacement":"auto","barAlignment":0,"drawStyle":"bars","fillOpacity":80,"gradientMode":"none","hideFrom":{"legend":false,"tooltip":false,"viz":false},"insertNulls":false,"lineInterpolation":"linear","lineWidth":0,"pointSize":5,"scaleDistribution":{"type":"linear"},"showPoints":"never","spanNulls":false,"stacking":{"mode":"normal","group":"A"},"thresholdsStyle":{"mode":"off"}},"mappings":[],"thresholds":{"mode":"absolute","steps":[{"color":"green","value":null}]}},"overrides":[]},"gridPos":{"h":8,"w":12,"x":12,"y":24},"id":8,"options":{"legend":{"calcs":[],"displayMode":"list","placement":"bottom","showLegend":true},"tooltip":{"mode":"single","sort":"none"},"stacking":{"mode":"normal","group":"A"}},"pluginVersion":"11.0.0","targets":[{"datasource":"mysql","format":"table","rawQuery":true,"rawSql":"SELECT DATE(DATE_SUB(day, INTERVAL WEEKDAY(day) DAY)) as time,\n CASE\n WHEN ide IN (\n SELECT ide FROM (\n SELECT ide\n FROM _tool_copilot_metrics_by_ide\n WHERE $__timeFilter(day) AND connection_id = ${connection_id} AND scope_id = '${scope_id}'\n GROUP BY ide\n HAVING SUM(user_initiated_interaction_count) > 0\n ORDER BY SUM(user_initiated_interaction_count) DESC\n LIMIT 4\n ) top_ides\n ) THEN ide\n ELSE 'Other'\n END as metric,\n SUM(user_initiated_interaction_count) as value\nFROM _tool_copilot_metrics_by_ide\nWHERE $__timeFilter(day) AND connection_id = ${connection_id} AND scope_id = '${scope_id}'\nGROUP BY time, metric\nORDER BY time","refId":"A"}],"title":"IDE Adoption Over Time","type":"timeseries","transformations":[{"id":"prepareTimeSeries","options":{"format":"many"}},{"id":"renameByRegex","options":{"regex":"^value (.+)$","renamePattern":"$1"}}]},{"datasource":"mysql","description":"Top languages ranked by code acceptance rate","fieldConfig":{"defaults":{"mappings":[],"thresholds":{"mode":"absolute","steps":[{"color":"green","value":null}]}},"overrides":[]},"gridPos":{"h":8,"w":12,"x":0,"y":40},"id":9,"options":{"cellHeight":"sm","footer":{"countRows":false,"fields":"","reducer":["sum"],"show":false},"showHeader":true},"pluginVersion":"11.0.0","targets":[{"datasource":"mysql","format":"table","rawQuery":true,"rawSql":"SELECT language as \"Language\",\n SUM(code_generation_activity_count) as \"Suggestions\",\n SUM(code_acceptance_activity_count) as \"Acceptances\",\n ROUND(SUM(code_acceptance_activity_count) * 100.0 / NULLIF(SUM(code_generation_activity_count), 0), 1) as \"Acceptance Rate %\"\nFROM _tool_copilot_metrics_by_language_feature\nWHERE $__timeFilter(day) AND connection_id = ${connection_id} AND scope_id = '${scope_id}'\nGROUP BY language\nHAVING SUM(code_generation_activity_count) > 0\nORDER BY ROUND(SUM(code_acceptance_activity_count) * 100.0 / NULLIF(SUM(code_generation_activity_count), 0), 1) DESC, SUM(code_generation_activity_count) DESC\nLIMIT 15","refId":"A"}],"title":"Top Languages by Acceptance Rate","type":"table"},{"datasource":"mysql","description":"Top models ranked by code acceptance rate","fieldConfig":{"defaults":{"mappings":[],"thresholds":{"mode":"absolute","steps":[{"color":"green","value":null}]}},"overrides":[]},"gridPos":{"h":8,"w":12,"x":12,"y":40},"id":10,"options":{"cellHeight":"sm","footer":{"countRows":false,"fields":"","reducer":["sum"],"show":false},"showHeader":true},"pluginVersion":"11.0.0","targets":[{"datasource":"mysql","format":"table","rawQuery":true,"rawSql":"SELECT model as \"Model\",\n SUM(code_generation_activity_count) as \"Suggestions\",\n SUM(code_acceptance_activity_count) as \"Acceptances\",\n ROUND(SUM(code_acceptance_activity_count) * 100.0 / NULLIF(SUM(code_generation_activity_count), 0), 1) as \"Acceptance Rate %\"\nFROM _tool_copilot_metrics_by_model_feature\nWHERE $__timeFilter(day) AND connection_id = ${connection_id} AND scope_id = '${scope_id}'\nGROUP BY model\nHAVING SUM(code_generation_activity_count) > 0\nORDER BY ROUND(SUM(code_acceptance_activity_count) * 100.0 / NULLIF(SUM(code_generation_activity_count), 0), 1) DESC, SUM(code_generation_activity_count) DESC\nLIMIT 10","refId":"A"}],"title":"Top Models by Acceptance Rate","type":"table"},{"datasource":"mysql","description":"Matrix-style breakdown of suggestions and acceptances by language and feature","fieldConfig":{"defaults":{"mappings":[],"thresholds":{"mode":"absolute","steps":[{"color":"green","value":null}]}},"overrides":[]},"gridPos":{"h":8,"w":24,"x":0,"y":48},"id":21,"options":{"cellHeight":"sm","footer":{"countRows":false,"fields":"","reducer":["sum"],"show":false},"showHeader":true},"pluginVersion":"11.0.0","targets":[{"datasource":"mysql","format":"table","rawQuery":true,"rawSql":"SELECT language as \"Language\",\n feature as \"Feature\",\n SUM(code_generation_activity_count) as \"Suggestions\",\n SUM(code_acceptance_activity_count) as \"Acceptances\"\nFROM _tool_copilot_metrics_by_language_feature\nWHERE $__timeFilter(day) AND connection_id = ${connection_id} AND scope_id = '${scope_id}'\nGROUP BY language, feature\nHAVING SUM(code_generation_activity_count) > 0\nORDER BY language, SUM(code_generation_activity_count) DESC\nLIMIT 100","refId":"A"}],"title":"Feature x Language Matrix","type":"table"},{"datasource":"mysql","description":"Count of distinct users active in the selected time range","fieldConfig":{"defaults":{"mappings":[],"thresholds":{"mode":"absolute","steps":[{"color":"green","value":null}]}},"overrides":[]},"gridPos":{"h":4,"w":6,"x":0,"y":56},"id":11,"options":{"colorMode":"value","graphMode":"none","justifyMode":"auto","orientation":"auto","reduceOptions":{"calcs":["lastNotNull"],"fields":"","values":false},"showPercentChange":false,"textMode":"auto","wideLayout":true},"pluginVersion":"11.0.0","targets":[{"datasource":"mysql","format":"table","rawQuery":true,"rawSql":"SELECT COUNT(DISTINCT user_login) as \"Unique Users\"\nFROM _tool_copilot_user_daily_metrics\nWHERE $__timeFilter(day) AND connection_id = ${connection_id} AND scope_id = '${scope_id}'","refId":"A"}],"title":"Unique Users (Period)","type":"stat"},{"datasource":"mysql","description":"Users who have used Copilot agent mode","fieldConfig":{"defaults":{"mappings":[],"thresholds":{"mode":"absolute","steps":[{"color":"purple","value":null}]}},"overrides":[]},"gridPos":{"h":4,"w":6,"x":6,"y":56},"id":12,"options":{"colorMode":"value","graphMode":"none","justifyMode":"auto","orientation":"auto","reduceOptions":{"calcs":["lastNotNull"],"fields":"","values":false},"showPercentChange":false,"textMode":"auto","wideLayout":true},"pluginVersion":"11.0.0","targets":[{"datasource":"mysql","format":"table","rawQuery":true,"rawSql":"SELECT COUNT(DISTINCT user_login) as \"Agent Users\"\nFROM _tool_copilot_user_daily_metrics\nWHERE $__timeFilter(day) AND connection_id = ${connection_id} AND scope_id = '${scope_id}' AND used_agent = 1","refId":"A"}],"title":"Agent Mode Adopters","type":"stat"},{"datasource":"mysql","description":"Users who have used Copilot chat","fieldConfig":{"defaults":{"mappings":[],"thresholds":{"mode":"absolute","steps":[{"color":"orange","value":null}]}},"overrides":[]},"gridPos":{"h":4,"w":6,"x":12,"y":56},"id":13,"options":{"colorMode":"value","graphMode":"none","justifyMode":"auto","orientation":"auto","reduceOptions":{"calcs":["lastNotNull"],"fields":"","values":false},"showPercentChange":false,"textMode":"auto","wideLayout":true},"pluginVersion":"11.0.0","targets":[{"datasource":"mysql","format":"table","rawQuery":true,"rawSql":"SELECT COUNT(DISTINCT user_login) as \"Chat Users\"\nFROM _tool_copilot_user_daily_metrics\nWHERE $__timeFilter(day) AND connection_id = ${connection_id} AND scope_id = '${scope_id}' AND used_chat = 1","refId":"A"}],"title":"Chat Adopters","type":"stat"},{"datasource":"mysql","description":"Percentage of seats with recorded activity","fieldConfig":{"defaults":{"mappings":[],"min":0,"max":100,"unit":"percent","thresholds":{"mode":"absolute","steps":[{"color":"red","value":null},{"color":"yellow","value":50},{"color":"green","value":75}]}},"overrides":[]},"gridPos":{"h":4,"w":6,"x":18,"y":56},"id":14,"options":{"minVizHeight":75,"minVizWidth":75,"orientation":"auto","reduceOptions":{"calcs":["lastNotNull"],"fields":"","values":false},"showThresholdLabels":false,"showThresholdMarkers":true,"sizing":"auto"},"pluginVersion":"11.0.0","targets":[{"datasource":"mysql","format":"table","rawQuery":true,"rawSql":"SELECT\n COUNT(CASE WHEN last_activity_at IS NOT NULL THEN 1 END) * 100.0 / NULLIF(COUNT(*), 0) as \"Utilization %\"\nFROM _tool_copilot_seats\nWHERE connection_id = ${connection_id}","refId":"A"}],"title":"Seat Utilization","type":"gauge"},{"collapsed":false,"gridPos":{"h":1,"w":24,"x":0,"y":60},"id":22,"panels":[],"title":"Quality & Efficiency Ratios","type":"row"},{"datasource":"mysql","description":"Daily acceptance rate trend computed as acceptances divided by suggestions","fieldConfig":{"defaults":{"color":{"mode":"palette-classic"},"mappings":[],"max":1,"min":0,"unit":"percentunit","thresholds":{"mode":"absolute","steps":[{"color":"green","value":null}]}},"overrides":[]},"gridPos":{"h":8,"w":12,"x":0,"y":61},"id":23,"options":{"legend":{"calcs":[],"displayMode":"list","placement":"bottom","showLegend":true},"tooltip":{"mode":"single","sort":"none"}},"pluginVersion":"11.0.0","targets":[{"datasource":"mysql","format":"table","rawQuery":true,"rawSql":"SELECT day as time,\n code_acceptance_activity_count / NULLIF(code_generation_activity_count, 0) as \"Acceptance Rate\"\nFROM _tool_copilot_enterprise_daily_metrics\nWHERE $__timeFilter(day) AND connection_id = ${connection_id} AND scope_id = '${scope_id}'\nORDER BY 1","refId":"A"}],"title":"Overall Acceptance Rate Trend","type":"timeseries"},{"datasource":"mysql","description":"Daily LOC yield trend computed as added LOC divided by suggested LOC","fieldConfig":{"defaults":{"color":{"mode":"palette-classic"},"mappings":[],"min":0,"thresholds":{"mode":"absolute","steps":[{"color":"green","value":null}]}},"overrides":[]},"gridPos":{"h":8,"w":12,"x":12,"y":61},"id":24,"options":{"legend":{"calcs":[],"displayMode":"list","placement":"bottom","showLegend":true},"tooltip":{"mode":"single","sort":"none"}},"pluginVersion":"11.0.0","targets":[{"datasource":"mysql","format":"table","rawQuery":true,"rawSql":"SELECT day as time,\n ROUND(loc_added_sum / NULLIF(loc_suggested_to_add_sum, 0), 2) as \"LOC Yield\"\nFROM _tool_copilot_enterprise_daily_metrics\nWHERE $__timeFilter(day) AND connection_id = ${connection_id} AND scope_id = '${scope_id}'\nORDER BY 1","refId":"A"}],"title":"LOC Yield Trend","type":"timeseries"},{"datasource":"mysql","description":"Acceptance ratio by Copilot feature","fieldConfig":{"defaults":{"mappings":[],"thresholds":{"mode":"absolute","steps":[{"color":"green","value":null}]}},"overrides":[]},"gridPos":{"h":8,"w":8,"x":0,"y":69},"id":25,"options":{"cellHeight":"sm","footer":{"countRows":false,"fields":"","reducer":["sum"],"show":false},"showHeader":true},"pluginVersion":"11.0.0","targets":[{"datasource":"mysql","format":"table","rawQuery":true,"rawSql":"SELECT feature as \"Feature\",\n SUM(code_generation_activity_count) as \"Suggestions\",\n SUM(code_acceptance_activity_count) as \"Acceptances\",\n ROUND(SUM(code_acceptance_activity_count) * 100.0 / NULLIF(SUM(code_generation_activity_count), 0), 1) as \"Acceptance Rate %\"\nFROM _tool_copilot_metrics_by_feature\nWHERE $__timeFilter(day) AND connection_id = ${connection_id} AND scope_id = '${scope_id}'\nGROUP BY feature\nHAVING SUM(code_generation_activity_count) > 0\nORDER BY ROUND(SUM(code_acceptance_activity_count) * 100.0 / NULLIF(SUM(code_generation_activity_count), 0), 1) DESC, SUM(code_generation_activity_count) DESC\nLIMIT 20","refId":"A"}],"title":"Acceptance Rate by Feature","type":"table"},{"datasource":"mysql","description":"Acceptance ratio by programming language","fieldConfig":{"defaults":{"mappings":[],"thresholds":{"mode":"absolute","steps":[{"color":"green","value":null}]}},"overrides":[]},"gridPos":{"h":8,"w":8,"x":8,"y":69},"id":26,"options":{"cellHeight":"sm","footer":{"countRows":false,"fields":"","reducer":["sum"],"show":false},"showHeader":true},"pluginVersion":"11.0.0","targets":[{"datasource":"mysql","format":"table","rawQuery":true,"rawSql":"SELECT language as \"Language\",\n SUM(code_generation_activity_count) as \"Suggestions\",\n SUM(code_acceptance_activity_count) as \"Acceptances\",\n ROUND(SUM(code_acceptance_activity_count) * 100.0 / NULLIF(SUM(code_generation_activity_count), 0), 1) as \"Acceptance Rate %\"\nFROM _tool_copilot_metrics_by_language_feature\nWHERE $__timeFilter(day) AND connection_id = ${connection_id} AND scope_id = '${scope_id}'\nGROUP BY language\nHAVING SUM(code_generation_activity_count) > 0\nORDER BY ROUND(SUM(code_acceptance_activity_count) * 100.0 / NULLIF(SUM(code_generation_activity_count), 0), 1) DESC, SUM(code_generation_activity_count) DESC\nLIMIT 20","refId":"A"}],"title":"Acceptance Rate by Language","type":"table"},{"datasource":"mysql","description":"Acceptance ratio by model","fieldConfig":{"defaults":{"mappings":[],"thresholds":{"mode":"absolute","steps":[{"color":"green","value":null}]}},"overrides":[]},"gridPos":{"h":8,"w":8,"x":16,"y":69},"id":27,"options":{"cellHeight":"sm","footer":{"countRows":false,"fields":"","reducer":["sum"],"show":false},"showHeader":true},"pluginVersion":"11.0.0","targets":[{"datasource":"mysql","format":"table","rawQuery":true,"rawSql":"SELECT model as \"Model\",\n SUM(code_generation_activity_count) as \"Suggestions\",\n SUM(code_acceptance_activity_count) as \"Acceptances\",\n ROUND(SUM(code_acceptance_activity_count) * 100.0 / NULLIF(SUM(code_generation_activity_count), 0), 1) as \"Acceptance Rate %\"\nFROM _tool_copilot_metrics_by_model_feature\nWHERE $__timeFilter(day) AND connection_id = ${connection_id} AND scope_id = '${scope_id}'\nGROUP BY model\nHAVING SUM(code_generation_activity_count) > 0\nORDER BY ROUND(SUM(code_acceptance_activity_count) * 100.0 / NULLIF(SUM(code_generation_activity_count), 0), 1) DESC, SUM(code_generation_activity_count) DESC\nLIMIT 20","refId":"A"}],"title":"Acceptance Rate by Model","type":"table"},{"collapsed":false,"gridPos":{"h":1,"w":24,"x":0,"y":77},"id":28,"panels":[],"title":"User Behavior","type":"row"},{"datasource":"mysql","description":"Top users ranked by code generation activity in the selected time range","fieldConfig":{"defaults":{"mappings":[],"thresholds":{"mode":"absolute","steps":[{"color":"green","value":null}]}},"overrides":[]},"gridPos":{"h":8,"w":12,"x":0,"y":78},"id":29,"options":{"cellHeight":"sm","footer":{"countRows":false,"fields":"","reducer":["sum"],"show":false},"showHeader":true},"pluginVersion":"11.0.0","targets":[{"datasource":"mysql","format":"table","rawQuery":true,"rawSql":"SELECT user_login as \"user_login\",\n SUM(code_generation_activity_count) as \"suggestions\",\n SUM(code_acceptance_activity_count) as \"acceptances\",\n ROUND(SUM(code_acceptance_activity_count) * 100.0 / NULLIF(SUM(code_generation_activity_count), 0), 1) as \"acceptance %\"\nFROM _tool_copilot_user_daily_metrics\nWHERE $__timeFilter(day) AND connection_id = ${connection_id} AND scope_id = '${scope_id}'\nGROUP BY user_login\nHAVING SUM(code_generation_activity_count) > 0\nORDER BY SUM(code_generation_activity_count) DESC\nLIMIT 25","refId":"A"}],"title":"Top Users by Code Generations","type":"table"},{"datasource":"mysql","description":"Daily trend of distinct users engaging with agent mode versus chat","fieldConfig":{"defaults":{"color":{"mode":"palette-classic"},"custom":{"axisBorderShow":false,"axisCenteredZero":false,"axisColorMode":"text","axisLabel":"Users","axisPlacement":"auto","barAlignment":0,"drawStyle":"line","fillOpacity":10,"gradientMode":"none","hideFrom":{"legend":false,"tooltip":false,"viz":false},"insertNulls":false,"lineInterpolation":"linear","lineWidth":2,"pointSize":5,"scaleDistribution":{"type":"linear"},"showPoints":"never","spanNulls":false,"stacking":{"group":"A","mode":"none"},"thresholdsStyle":{"mode":"off"}},"mappings":[],"thresholds":{"mode":"absolute","steps":[{"color":"green","value":null}]}},"overrides":[]},"gridPos":{"h":8,"w":12,"x":12,"y":78},"id":30,"options":{"legend":{"calcs":[],"displayMode":"list","placement":"bottom","showLegend":true},"tooltip":{"mode":"single","sort":"none"}},"pluginVersion":"11.0.0","targets":[{"datasource":"mysql","format":"table","rawQuery":true,"rawSql":"SELECT day as time,\n COUNT(DISTINCT CASE WHEN used_agent = 1 THEN user_login END) as \"Agent Users\",\n COUNT(DISTINCT CASE WHEN used_chat = 1 THEN user_login END) as \"Chat Users\"\nFROM _tool_copilot_user_daily_metrics\nWHERE $__timeFilter(day) AND connection_id = ${connection_id} AND scope_id = '${scope_id}'\nGROUP BY day\nORDER BY 1","refId":"A"}],"title":"Agent Users vs Chat Users Trend","type":"timeseries"},{"datasource":"mysql","description":"First-seen daily trend of users becoming active for the first time","fieldConfig":{"defaults":{"color":{"mode":"palette-classic"},"custom":{"axisBorderShow":false,"axisCenteredZero":false,"axisColorMode":"text","axisLabel":"Users","axisPlacement":"auto","barAlignment":0,"drawStyle":"line","fillOpacity":10,"gradientMode":"none","hideFrom":{"legend":false,"tooltip":false,"viz":false},"insertNulls":false,"lineInterpolation":"linear","lineWidth":2,"pointSize":5,"scaleDistribution":{"type":"linear"},"showPoints":"never","spanNulls":false,"stacking":{"group":"A","mode":"none"},"thresholdsStyle":{"mode":"off"}},"mappings":[],"thresholds":{"mode":"absolute","steps":[{"color":"green","value":null}]}},"overrides":[]},"gridPos":{"h":8,"w":24,"x":0,"y":86},"id":31,"options":{"legend":{"calcs":[],"displayMode":"list","placement":"bottom","showLegend":true},"tooltip":{"mode":"single","sort":"none"}},"pluginVersion":"11.0.0","targets":[{"datasource":"mysql","format":"table","rawQuery":true,"rawSql":"SELECT first_day as time,\n COUNT(*) as \"New Active Users\"\nFROM (\n SELECT user_login,\n MIN(day) as first_day\n FROM _tool_copilot_user_daily_metrics\n WHERE connection_id = ${connection_id} AND scope_id = '${scope_id}'\n GROUP BY user_login\n) first_seen\nWHERE $__timeFilter(first_day)\nGROUP BY first_day\nORDER BY 1","refId":"A"}],"title":"New Active Users per Day","type":"timeseries"},{"collapsed":false,"gridPos":{"h":1,"w":24,"x":0,"y":94},"id":32,"panels":[],"title":"Seat Effectiveness","type":"row"},{"datasource":"mysql","description":"Distribution of seat activity by last recorded editor","fieldConfig":{"defaults":{"mappings":[],"thresholds":{"mode":"absolute","steps":[{"color":"green","value":null}]}},"overrides":[]},"gridPos":{"h":8,"w":8,"x":0,"y":95},"id":33,"options":{"displayLabels":["name","percent"],"legend":{"displayMode":"list","placement":"right","showLegend":true},"pieType":"pie","reduceOptions":{"calcs":["lastNotNull"],"fields":"","values":false},"tooltip":{"mode":"single","sort":"none"}},"pluginVersion":"11.0.0","targets":[{"datasource":"mysql","format":"table","rawQuery":true,"rawSql":"SELECT COALESCE(NULLIF(last_activity_editor, ''), 'unknown') as \"Editor\",\n COUNT(*) as \"Seats\"\nFROM _tool_copilot_seats\nWHERE connection_id = ${connection_id} AND ('${scope_id}' = '' OR organization = '${scope_id}' OR organization = '')\nGROUP BY COALESCE(NULLIF(last_activity_editor, ''), 'unknown')\nORDER BY COUNT(*) DESC","refId":"A"}],"title":"Seat Activity by Editor","type":"piechart"},{"datasource":"mysql","description":"Total, active (last 30 days), and inactive seats for the selected scope","fieldConfig":{"defaults":{"mappings":[],"thresholds":{"mode":"absolute","steps":[{"color":"green","value":null}]}},"overrides":[]},"gridPos":{"h":8,"w":8,"x":8,"y":95},"id":34,"options":{"cellHeight":"sm","footer":{"countRows":false,"fields":"","reducer":["sum"],"show":false},"showHeader":true},"pluginVersion":"11.0.0","targets":[{"datasource":"mysql","format":"table","rawQuery":true,"rawSql":"SELECT COUNT(*) as \"total_seats\",\n SUM(CASE WHEN last_activity_at IS NOT NULL AND last_activity_at >= DATE_SUB(UTC_TIMESTAMP(), INTERVAL 30 DAY) THEN 1 ELSE 0 END) as \"active_seats\",\n SUM(CASE WHEN last_activity_at IS NULL OR last_activity_at < DATE_SUB(UTC_TIMESTAMP(), INTERVAL 30 DAY) THEN 1 ELSE 0 END) as \"inactive_seats\"\nFROM _tool_copilot_seats\nWHERE connection_id = ${connection_id} AND ('${scope_id}' = '' OR organization = '${scope_id}' OR organization = '')","refId":"A"}],"title":"Seats Summary","type":"table"},{"datasource":"mysql","description":"Seats with no activity or activity older than 30 days","fieldConfig":{"defaults":{"mappings":[],"thresholds":{"mode":"absolute","steps":[{"color":"green","value":null}]}},"overrides":[]},"gridPos":{"h":8,"w":8,"x":16,"y":95},"id":35,"options":{"cellHeight":"sm","footer":{"countRows":false,"fields":"","reducer":["sum"],"show":false},"showHeader":true},"pluginVersion":"11.0.0","targets":[{"datasource":"mysql","format":"table","rawQuery":true,"rawSql":"SELECT user_login as \"user_login\",\n last_activity_at as \"last_activity_at\",\n plan_type as \"plan_type\"\nFROM _tool_copilot_seats\nWHERE connection_id = ${connection_id}\n AND ('${scope_id}' = '' OR organization = '${scope_id}' OR organization = '')\n AND (last_activity_at IS NULL OR last_activity_at < DATE_SUB(UTC_TIMESTAMP(), INTERVAL 30 DAY))\nORDER BY last_activity_at IS NULL DESC, last_activity_at ASC\nLIMIT 100","refId":"A"}],"title":"Inactive Seats","type":"table"},{"collapsed":false,"gridPos":{"h":1,"w":24,"x":0,"y":103},"id":36,"panels":[],"title":"Diagnostics","type":"row"},{"datasource":"mysql","description":"Latest available day in enterprise-level Copilot metrics","fieldConfig":{"defaults":{"mappings":[],"thresholds":{"mode":"absolute","steps":[{"color":"green","value":null}]}},"overrides":[]},"gridPos":{"h":4,"w":6,"x":0,"y":104},"id":37,"options":{"colorMode":"value","graphMode":"none","justifyMode":"auto","orientation":"auto","reduceOptions":{"calcs":["lastNotNull"],"fields":"","values":false},"showPercentChange":false,"textMode":"auto","wideLayout":true},"pluginVersion":"11.0.0","targets":[{"datasource":"mysql","format":"table","rawQuery":true,"rawSql":"SELECT COALESCE(TIMESTAMPDIFF(DAY, MAX(day), UTC_DATE()), 9999) as \"Days Since Latest Data\"\nFROM _tool_copilot_enterprise_daily_metrics\nWHERE connection_id = ${connection_id} AND scope_id = '${scope_id}'","refId":"A"}],"title":"Data Freshness","type":"stat"},{"datasource":"mysql","description":"Share of generated suggestions tied to unknown model/feature taxonomy","fieldConfig":{"defaults":{"mappings":[],"min":0,"max":100,"unit":"percent","thresholds":{"mode":"absolute","steps":[{"color":"green","value":null},{"color":"yellow","value":5},{"color":"red","value":20}]}},"overrides":[]},"gridPos":{"h":4,"w":6,"x":6,"y":104},"id":38,"options":{"minVizHeight":75,"minVizWidth":75,"orientation":"auto","reduceOptions":{"calcs":["lastNotNull"],"fields":"","values":false},"showThresholdLabels":false,"showThresholdMarkers":true,"sizing":"auto"},"pluginVersion":"11.0.0","targets":[{"datasource":"mysql","format":"table","rawQuery":true,"rawSql":"SELECT ROUND(\n SUM(CASE WHEN LOWER(COALESCE(model, '')) = 'unknown' OR LOWER(COALESCE(feature, '')) LIKE '%unknown%' THEN code_generation_activity_count ELSE 0 END) * 100.0\n / NULLIF(SUM(code_generation_activity_count), 0),\n 2\n) as \"Unknown Taxonomy %\"\nFROM _tool_copilot_metrics_by_model_feature\nWHERE $__timeFilter(day) AND connection_id = ${connection_id} AND scope_id = '${scope_id}'","refId":"A"}],"title":"Unknown Taxonomy Share","type":"gauge"},{"datasource":"mysql","description":"Row counts for core Copilot metrics tables","fieldConfig":{"defaults":{"mappings":[],"thresholds":{"mode":"absolute","steps":[{"color":"green","value":null}]}},"overrides":[]},"gridPos":{"h":8,"w":12,"x":12,"y":104},"id":39,"options":{"cellHeight":"sm","footer":{"countRows":false,"fields":"","reducer":["sum"],"show":false},"showHeader":true},"pluginVersion":"11.0.0","targets":[{"datasource":"mysql","format":"table","rawQuery":true,"rawSql":"SELECT *\nFROM (\n SELECT '_tool_copilot_enterprise_daily_metrics' as table_name,\n COUNT(*) as row_count\n FROM _tool_copilot_enterprise_daily_metrics\n WHERE connection_id = ${connection_id} AND scope_id = '${scope_id}'\n UNION ALL\n SELECT '_tool_copilot_user_daily_metrics' as table_name,\n COUNT(*) as row_count\n FROM _tool_copilot_user_daily_metrics\n WHERE connection_id = ${connection_id} AND scope_id = '${scope_id}'\n UNION ALL\n SELECT '_tool_copilot_metrics_by_feature' as table_name,\n COUNT(*) as row_count\n FROM _tool_copilot_metrics_by_feature\n WHERE connection_id = ${connection_id} AND scope_id = '${scope_id}'\n UNION ALL\n SELECT '_tool_copilot_metrics_by_model_feature' as table_name,\n COUNT(*) as row_count\n FROM _tool_copilot_metrics_by_model_feature\n WHERE connection_id = ${connection_id} AND scope_id = '${scope_id}'\n UNION ALL\n SELECT '_tool_copilot_seats' as table_name,\n COUNT(*) as row_count\n FROM _tool_copilot_seats\n WHERE connection_id = ${connection_id} AND ('${scope_id}' = '' OR organization = '${scope_id}' OR organization = '')\n) data_volume\nORDER BY row_count DESC","refId":"A"}],"title":"Data Volume by Table","type":"table"}],"refresh":"","schemaVersion":38,"tags":["copilot","devlake"],"templating":{"list":[{"current":{"selected":false,"text":"","value":""},"datasource":"mysql","definition":"SELECT DISTINCT connection_id FROM _tool_copilot_enterprise_daily_metrics ORDER BY 1","hide":0,"includeAll":false,"label":"Connection ID","multi":false,"name":"connection_id","options":[],"query":"SELECT DISTINCT connection_id FROM _tool_copilot_scopes ORDER BY connection_id DESC","refresh":1,"regex":"","skipUrlSync":false,"sort":0,"type":"query"},{"current":{"selected":false,"text":"","value":""},"datasource":"mysql","definition":"SELECT DISTINCT id as scope_id FROM _tool_copilot_scopes WHERE connection_id = ${connection_id} ORDER BY 1","hide":0,"includeAll":false,"label":"Scope ID","multi":false,"name":"scope_id","options":[],"query":"SELECT DISTINCT id as scope_id FROM _tool_copilot_scopes WHERE connection_id = ${connection_id} ORDER BY 1","refresh":2,"regex":"","skipUrlSync":false,"sort":0,"type":"query"}]},"time":{"from":"now-90d","to":"now"},"timepicker":{},"timezone":"utc","title":"GitHub Copilot Adoption","uid":"copilot_adoption","version":1,"weekStart":""} \ No newline at end of file diff --git a/grafana/dashboards/GithubCopilotDORACorrelation.json b/grafana/dashboards/GithubCopilotDORACorrelation.json new file mode 100644 index 00000000000..72cf5b01c7f --- /dev/null +++ b/grafana/dashboards/GithubCopilotDORACorrelation.json @@ -0,0 +1 @@ +{"annotations":{"list":[{"builtIn":1,"datasource":{"type":"datasource","uid":"grafana"},"enable":true,"hide":true,"iconColor":"rgba(0, 211, 255, 1)","name":"Annotations & Alerts","type":"dashboard"},{"datasource":"mysql","enable":true,"hide":false,"iconColor":"purple","name":"Copilot Implementation Date","rawQuery":"SELECT\n UNIX_TIMESTAMP(implementation_date) * 1000 AS time,\n 'Copilot Rollout' AS text,\n 'Implementation Date' AS title\nFROM _tool_copilot_scopes\nWHERE id = '${scope_id}'\n AND implementation_date IS NOT NULL","type":"dashboard"}]},"editable":true,"fiscalYearStartMonth":0,"graphTooltip":0,"links":[],"liveNow":false,"panels":[{"datasource":{"type":"datasource","uid":"grafana"},"gridPos":{"h":5,"w":24,"x":0,"y":0},"id":1,"options":{"code":{"language":"plaintext","showLineNumbers":false,"showMiniMap":false},"content":"## GitHub Copilot + DORA Correlation Dashboard\n\n**Purpose**: Analyze the **correlation** between GitHub Copilot adoption levels and engineering productivity metrics.\n\n**How it works**:\n- \ud83d\udcc8 **Primary Analysis**: Continuous correlation showing how DORA metrics trend alongside GitHub Copilot adoption intensity\n- \ud83d\udcca **Adoption Tiers**: Metrics grouped by adoption level (<25%, 25-50%, 50-75%, >75% of seats active)\n- \ud83d\udccd **Optional Milestone**: Configure a rollout date to add annotation markers on charts\n\n**Prerequisites**:\n- Ensure **GitHub Copilot metrics** are being collected via the GitHub Copilot plugin\n- Ensure **PR data** exists in DevLake from GitHub/GitLab plugins\n- For DORA metrics (CFR, MTTR): Configure deployments and incidents\n- For Code Quality: Configure SonarQube integration (optional)\n\n**\u26a0\ufe0f Disclaimer**: Correlation does not imply causation. Other factors may influence the metrics shown.","mode":"markdown"},"pluginVersion":"11.0.0","targets":[{"datasource":{"type":"datasource","uid":"grafana"},"queryType":"randomWalk","refId":"A"}],"title":"Dashboard Introduction","type":"text"},{"datasource":"mysql","description":"Weekly GitHub Copilot adoption percentage (active users / total seats) over time. Higher adoption correlates with expected productivity improvements.","fieldConfig":{"defaults":{"color":{"mode":"palette-classic"},"custom":{"axisBorderShow":false,"axisCenteredZero":false,"axisColorMode":"text","axisLabel":"Adoption %","axisPlacement":"auto","barAlignment":0,"drawStyle":"line","fillOpacity":20,"gradientMode":"scheme","hideFrom":{"legend":false,"tooltip":false,"viz":false},"insertNulls":false,"lineInterpolation":"smooth","lineWidth":2,"pointSize":5,"scaleDistribution":{"type":"linear"},"showPoints":"auto","spanNulls":false,"stacking":{"group":"A","mode":"none"},"thresholdsStyle":{"mode":"off"}},"mappings":[],"max":100,"min":0,"thresholds":{"mode":"absolute","steps":[{"color":"blue","value":null},{"color":"green","value":50}]},"unit":"percent"},"overrides":[]},"gridPos":{"h":8,"w":12,"x":0,"y":14},"id":2,"options":{"legend":{"calcs":["mean","lastNotNull"],"displayMode":"table","placement":"bottom","showLegend":true},"tooltip":{"mode":"single","sort":"none"}},"pluginVersion":"11.0.0","targets":[{"datasource":"mysql","format":"time_series","rawQuery":true,"rawSql":"WITH _copilot_adoption AS (\n SELECT metric_date, active_users, total_seats,\n ROUND(active_users * 100.0 / NULLIF(total_seats, 0), 1) AS adoption_pct\n FROM (\n SELECT day AS metric_date, SUM(daily_active_users) AS active_users,\n (SELECT COUNT(*) FROM _tool_copilot_seats s WHERE s.connection_id = ${connection_id}\n AND COALESCE(s.organization, '') = COALESCE((SELECT sc.organization FROM _tool_copilot_scopes sc\n WHERE sc.connection_id = ${connection_id} AND sc.id = '${scope_id}' LIMIT 1), '')) AS total_seats\n FROM _tool_copilot_enterprise_daily_metrics\n WHERE connection_id = ${connection_id} AND scope_id = '${scope_id}' AND $__timeFilter(day)\n GROUP BY day\n UNION ALL\n SELECT date AS metric_date, total_active_users AS active_users, seat_total AS total_seats\n FROM _tool_copilot_org_daily_metrics\n WHERE connection_id = ${connection_id} AND scope_id = '${scope_id}' AND $__timeFilter(date)\n ) _unified),\n_adoption_weekly AS (\n SELECT\n DATE_SUB(metric_date, INTERVAL WEEKDAY(metric_date) DAY) AS week_start,\n ROUND(AVG(adoption_pct), 1) AS adoption_pct,\n ROUND(AVG(active_users), 0) AS avg_active_users,\n ROUND(AVG(total_seats), 0) AS avg_total_seats\n FROM _copilot_adoption\n GROUP BY DATE_SUB(metric_date, INTERVAL WEEKDAY(metric_date) DAY)\n)\nSELECT\n week_start AS time,\n adoption_pct AS 'Adoption %'\nFROM _adoption_weekly\nORDER BY week_start","refId":"A"}],"title":"GitHub Copilot Adoption Trend","type":"timeseries"},{"datasource":"mysql","description":"Pearson correlation coefficient (r) between GitHub Copilot adoption % and PR cycle time. Negative values indicate inverse correlation (higher adoption \u2192 faster PRs). |r| > 0.7 = strong, 0.3-0.7 = moderate, < 0.3 = weak.","fieldConfig":{"defaults":{"color":{"mode":"thresholds"},"mappings":[{"options":{"from":-1,"result":{"color":"green","index":0,"text":"Strong \u2193"},"to":-0.7},"type":"range"},{"options":{"from":-0.7,"result":{"color":"yellow","index":1,"text":"Moderate \u2193"},"to":-0.3},"type":"range"},{"options":{"from":-0.3,"result":{"color":"text","index":2,"text":"Weak"},"to":0.3},"type":"range"},{"options":{"from":0.3,"result":{"color":"orange","index":3,"text":"Moderate \u2191"},"to":0.7},"type":"range"},{"options":{"from":0.7,"result":{"color":"red","index":4,"text":"Strong \u2191"},"to":1},"type":"range"}],"noValue":"\u26a0\ufe0f Insufficient data (need 4+ weeks)","thresholds":{"mode":"absolute","steps":[{"color":"green","value":null}]},"unit":"none","decimals":2},"overrides":[]},"gridPos":{"h":8,"w":4,"x":20,"y":31},"id":3,"options":{"colorMode":"value","graphMode":"none","justifyMode":"auto","orientation":"auto","reduceOptions":{"calcs":["lastNotNull"],"fields":"","values":false},"showPercentChange":false,"textMode":"value_and_name","wideLayout":true},"pluginVersion":"11.0.0","targets":[{"datasource":"mysql","format":"table","rawQuery":true,"rawSql":"WITH _copilot_adoption AS (\n SELECT metric_date, ROUND(active_users * 100.0 / NULLIF(total_seats, 0), 1) AS adoption_pct\n FROM (\n SELECT day AS metric_date, SUM(daily_active_users) AS active_users,\n (SELECT COUNT(*) FROM _tool_copilot_seats s WHERE s.connection_id = ${connection_id}\n AND COALESCE(s.organization, '') = COALESCE((SELECT sc.organization FROM _tool_copilot_scopes sc\n WHERE sc.connection_id = ${connection_id} AND sc.id = '${scope_id}' LIMIT 1), '')) AS total_seats\n FROM _tool_copilot_enterprise_daily_metrics\n WHERE connection_id = ${connection_id} AND scope_id = '${scope_id}' AND $__timeFilter(day)\n GROUP BY day\n UNION ALL\n SELECT date AS metric_date, total_active_users AS active_users, seat_total AS total_seats\n FROM _tool_copilot_org_daily_metrics\n WHERE connection_id = ${connection_id} AND scope_id = '${scope_id}' AND $__timeFilter(date)\n ) _unified),\n_adoption_weekly AS (\n SELECT\n DATE_SUB(metric_date, INTERVAL WEEKDAY(metric_date) DAY) AS week_start,\n AVG(adoption_pct) AS adoption_pct\n FROM _copilot_adoption\n GROUP BY DATE_SUB(metric_date, INTERVAL WEEKDAY(metric_date) DAY)\n),\n_pr_metrics_weekly AS (\n SELECT\n DATE_SUB(DATE(pr_merged_date), INTERVAL WEEKDAY(DATE(pr_merged_date)) DAY) AS week_start,\n AVG(pr_cycle_time) / 60.0 AS avg_cycle_time_hours\n FROM project_pr_metrics\n WHERE project_name = ${project:sqlstring}\n AND $__timeFilter(pr_merged_date)\n GROUP BY DATE_SUB(DATE(pr_merged_date), INTERVAL WEEKDAY(DATE(pr_merged_date)) DAY)\n),\n_correlation_data AS (\n SELECT\n aw.week_start,\n aw.adoption_pct,\n pm.avg_cycle_time_hours\n FROM _adoption_weekly aw\n INNER JOIN _pr_metrics_weekly pm ON aw.week_start = pm.week_start\n WHERE aw.adoption_pct IS NOT NULL AND pm.avg_cycle_time_hours IS NOT NULL\n),\n_stats AS (\n SELECT\n COUNT(*) AS n,\n AVG(adoption_pct) AS mean_x,\n AVG(avg_cycle_time_hours) AS mean_y,\n STDDEV_POP(adoption_pct) AS stddev_x,\n STDDEV_POP(avg_cycle_time_hours) AS stddev_y\n FROM _correlation_data\n),\n_pearson AS (\n SELECT\n CASE\n WHEN s.n < 4 THEN NULL\n WHEN s.stddev_x = 0 OR s.stddev_y = 0 THEN 0\n ELSE (\n SELECT\n SUM((cd.adoption_pct - s.mean_x) * (cd.avg_cycle_time_hours - s.mean_y)) / (s.n * s.stddev_x * s.stddev_y)\n FROM _correlation_data cd\n )\n END AS r\n FROM _stats s\n)\nSELECT ROUND(r, 2) AS value FROM _pearson","refId":"A"}],"title":"Adoption vs PR Cycle Time (r)","type":"stat"},{"datasource":"mysql","description":"Current week's GitHub Copilot adoption percentage (active users / total seats)","fieldConfig":{"defaults":{"color":{"mode":"thresholds"},"mappings":[],"noValue":"No data","thresholds":{"mode":"absolute","steps":[{"color":"red","value":null},{"color":"yellow","value":25},{"color":"green","value":50},{"color":"dark-green","value":75}]},"unit":"percent"},"overrides":[]},"gridPos":{"h":8,"w":6,"x":18,"y":14},"id":5,"options":{"colorMode":"value","graphMode":"area","justifyMode":"auto","orientation":"auto","reduceOptions":{"calcs":["lastNotNull"],"fields":"","values":false},"showPercentChange":false,"textMode":"value","wideLayout":true},"pluginVersion":"11.0.0","targets":[{"datasource":"mysql","format":"table","rawQuery":true,"rawSql":"SELECT ROUND(active_users * 100.0 / NULLIF(total_seats, 0), 1) AS value\nFROM (\n SELECT daily_active_users AS active_users,\n (SELECT COUNT(*) FROM _tool_copilot_seats s WHERE s.connection_id = ${connection_id}\n AND COALESCE(s.organization, '') = COALESCE((SELECT sc.organization FROM _tool_copilot_scopes sc\n WHERE sc.connection_id = ${connection_id} AND sc.id = '${scope_id}' LIMIT 1), '')) AS total_seats\n FROM _tool_copilot_enterprise_daily_metrics\n WHERE connection_id = ${connection_id} AND scope_id = '${scope_id}'\n ORDER BY day DESC LIMIT 1\n) _ent\nUNION ALL\nSELECT ROUND(active_users * 100.0 / NULLIF(total_seats, 0), 1) AS value\nFROM (\n SELECT total_active_users AS active_users, seat_total AS total_seats\n FROM _tool_copilot_org_daily_metrics\n WHERE connection_id = ${connection_id} AND scope_id = '${scope_id}'\n ORDER BY date DESC LIMIT 1\n) _org\nLIMIT 1","refId":"A"}],"title":"Current Adoption %","type":"stat"},{"datasource":{"type":"datasource","uid":"grafana"},"gridPos":{"h":8,"w":6,"x":12,"y":10},"id":6,"options":{"code":{"language":"plaintext","showLineNumbers":false,"showMiniMap":false},"content":"### Interpreting Correlation (r)\n\n| Value | Meaning |\n|-------|--------|\n| **-1 to -0.7** | \ud83d\udfe2 Strong inverse (higher adoption \u2192 faster PRs) |\n| **-0.7 to -0.3** | \ud83d\udfe1 Moderate inverse |\n| **-0.3 to 0.3** | \u26aa Weak/No correlation |\n| **0.3 to 0.7** | \ud83d\udfe0 Moderate positive |\n| **0.7 to 1** | \ud83d\udd34 Strong positive (unexpected) |\n\n*Note: r < 0 is expected (adoption \u2191 = cycle time \u2193)*","mode":"markdown"},"pluginVersion":"11.0.0","targets":[{"datasource":{"type":"datasource","uid":"grafana"},"queryType":"randomWalk","refId":"A"}],"title":"\ud83d\udcca How to Read Correlation","type":"text"},{"collapsed":false,"gridPos":{"h":1,"w":24,"x":0,"y":30},"id":100,"panels":[],"title":"PR Velocity Impact","type":"row"},{"datasource":"mysql","description":"Percentage difference in PR cycle time between high adoption weeks (>50%) and low adoption weeks (<50%). Negative values indicate improvement (faster PRs during high adoption).","fieldConfig":{"defaults":{"color":{"mode":"thresholds"},"mappings":[],"noValue":"\u26a0\ufe0f Insufficient data","thresholds":{"mode":"absolute","steps":[{"color":"green","value":null},{"color":"yellow","value":0},{"color":"red","value":10}]},"unit":"percent"},"overrides":[]},"gridPos":{"h":8,"w":4,"x":16,"y":31},"id":4,"options":{"colorMode":"value","graphMode":"none","justifyMode":"auto","orientation":"auto","reduceOptions":{"calcs":["lastNotNull"],"fields":"","values":false},"showPercentChange":false,"textMode":"value","wideLayout":true},"pluginVersion":"11.0.0","targets":[{"datasource":"mysql","format":"table","rawQuery":true,"rawSql":"WITH _copilot_adoption AS (\n SELECT metric_date, ROUND(active_users * 100.0 / NULLIF(total_seats, 0), 1) AS adoption_pct\n FROM (\n SELECT day AS metric_date, SUM(daily_active_users) AS active_users,\n (SELECT COUNT(*) FROM _tool_copilot_seats s WHERE s.connection_id = ${connection_id}\n AND COALESCE(s.organization, '') = COALESCE((SELECT sc.organization FROM _tool_copilot_scopes sc\n WHERE sc.connection_id = ${connection_id} AND sc.id = '${scope_id}' LIMIT 1), '')) AS total_seats\n FROM _tool_copilot_enterprise_daily_metrics\n WHERE connection_id = ${connection_id} AND scope_id = '${scope_id}' AND $__timeFilter(day)\n GROUP BY day\n UNION ALL\n SELECT date AS metric_date, total_active_users AS active_users, seat_total AS total_seats\n FROM _tool_copilot_org_daily_metrics\n WHERE connection_id = ${connection_id} AND scope_id = '${scope_id}' AND $__timeFilter(date)\n ) _unified),\n_adoption_weekly AS (\n SELECT\n DATE_SUB(metric_date, INTERVAL WEEKDAY(metric_date) DAY) AS week_start,\n AVG(adoption_pct) AS adoption_pct\n FROM _copilot_adoption\n GROUP BY DATE_SUB(metric_date, INTERVAL WEEKDAY(metric_date) DAY)\n),\n_pr_metrics_weekly AS (\n SELECT\n DATE_SUB(DATE(pr_merged_date), INTERVAL WEEKDAY(DATE(pr_merged_date)) DAY) AS week_start,\n AVG(pr_cycle_time) / 60.0 AS avg_cycle_time_hours\n FROM project_pr_metrics\n WHERE project_name = ${project:sqlstring}\n AND $__timeFilter(pr_merged_date)\n GROUP BY DATE_SUB(DATE(pr_merged_date), INTERVAL WEEKDAY(DATE(pr_merged_date)) DAY)\n),\n_adoption_pr_joined AS (\n SELECT\n aw.week_start,\n aw.adoption_pct,\n pm.avg_cycle_time_hours,\n CASE\n WHEN aw.adoption_pct >= 50 THEN 'high'\n ELSE 'low'\n END AS adoption_tier\n FROM _adoption_weekly aw\n INNER JOIN _pr_metrics_weekly pm ON aw.week_start = pm.week_start\n),\n_tier_averages AS (\n SELECT\n adoption_tier,\n AVG(avg_cycle_time_hours) AS avg_cycle_time\n FROM _adoption_pr_joined\n GROUP BY adoption_tier\n)\nSELECT\n ROUND(\n ((SELECT avg_cycle_time FROM _tier_averages WHERE adoption_tier = 'high') -\n (SELECT avg_cycle_time FROM _tier_averages WHERE adoption_tier = 'low')) /\n NULLIF((SELECT avg_cycle_time FROM _tier_averages WHERE adoption_tier = 'low'), 0) * 100,\n 1\n ) AS value","refId":"A"}],"title":"PR Cycle Time Change (High vs Low Adoption)","type":"stat"},{"datasource":"mysql","description":"Dual-axis chart showing weekly GitHub Copilot adoption % (left axis) and PR cycle time in hours (right axis). Look for inverse correlation - adoption up, cycle time down.","fieldConfig":{"defaults":{"color":{"mode":"palette-classic"},"custom":{"axisBorderShow":false,"axisCenteredZero":false,"axisColorMode":"text","axisLabel":"","axisPlacement":"auto","barAlignment":0,"drawStyle":"line","fillOpacity":10,"gradientMode":"none","hideFrom":{"legend":false,"tooltip":false,"viz":false},"insertNulls":false,"lineInterpolation":"smooth","lineWidth":2,"pointSize":5,"scaleDistribution":{"type":"linear"},"showPoints":"auto","spanNulls":false,"stacking":{"group":"A","mode":"none"},"thresholdsStyle":{"mode":"off"}},"mappings":[],"thresholds":{"mode":"absolute","steps":[{"color":"green","value":null}]}},"overrides":[{"matcher":{"id":"byName","options":"Adoption %"},"properties":[{"id":"custom.axisPlacement","value":"left"},{"id":"unit","value":"percent"},{"id":"color","value":{"fixedColor":"blue","mode":"fixed"}},{"id":"max","value":100},{"id":"min","value":0}]},{"matcher":{"id":"byName","options":"PR Cycle Time (hrs)"},"properties":[{"id":"custom.axisPlacement","value":"right"},{"id":"unit","value":"h"},{"id":"color","value":{"fixedColor":"orange","mode":"fixed"}}]}]},"gridPos":{"h":8,"w":12,"x":0,"y":31},"id":10,"options":{"legend":{"calcs":["mean"],"displayMode":"table","placement":"bottom","showLegend":true},"tooltip":{"mode":"multi","sort":"none"}},"pluginVersion":"11.0.0","targets":[{"datasource":"mysql","format":"time_series","rawQuery":true,"rawSql":"WITH _copilot_adoption AS (\n SELECT metric_date, ROUND(active_users * 100.0 / NULLIF(total_seats, 0), 1) AS adoption_pct\n FROM (\n SELECT day AS metric_date, SUM(daily_active_users) AS active_users,\n (SELECT COUNT(*) FROM _tool_copilot_seats s WHERE s.connection_id = ${connection_id}\n AND COALESCE(s.organization, '') = COALESCE((SELECT sc.organization FROM _tool_copilot_scopes sc\n WHERE sc.connection_id = ${connection_id} AND sc.id = '${scope_id}' LIMIT 1), '')) AS total_seats\n FROM _tool_copilot_enterprise_daily_metrics\n WHERE connection_id = ${connection_id} AND scope_id = '${scope_id}' AND $__timeFilter(day)\n GROUP BY day\n UNION ALL\n SELECT date AS metric_date, total_active_users AS active_users, seat_total AS total_seats\n FROM _tool_copilot_org_daily_metrics\n WHERE connection_id = ${connection_id} AND scope_id = '${scope_id}' AND $__timeFilter(date)\n ) _unified),\n_adoption_weekly AS (\n SELECT\n DATE_SUB(metric_date, INTERVAL WEEKDAY(metric_date) DAY) AS week_start,\n AVG(adoption_pct) AS adoption_pct\n FROM _copilot_adoption\n GROUP BY DATE_SUB(metric_date, INTERVAL WEEKDAY(metric_date) DAY)\n)\nSELECT\n week_start AS time,\n ROUND(adoption_pct, 1) AS 'Adoption %'\nFROM _adoption_weekly\nORDER BY week_start","refId":"Adoption"},{"datasource":"mysql","format":"time_series","rawQuery":true,"rawSql":"WITH _pr_metrics_weekly AS (\n SELECT\n DATE_SUB(DATE(pr_merged_date), INTERVAL WEEKDAY(DATE(pr_merged_date)) DAY) AS week_start,\n AVG(pr_cycle_time) / 60.0 AS avg_cycle_time_hours\n FROM project_pr_metrics\n WHERE project_name = ${project:sqlstring}\n AND $__timeFilter(pr_merged_date)\n GROUP BY DATE_SUB(DATE(pr_merged_date), INTERVAL WEEKDAY(DATE(pr_merged_date)) DAY)\n)\nSELECT\n week_start AS time,\n ROUND(avg_cycle_time_hours, 1) AS 'PR Cycle Time (hrs)'\nFROM _pr_metrics_weekly\nORDER BY week_start","refId":"PRTime"}],"title":"Adoption vs PR Cycle Time","type":"timeseries"},{"datasource":"mysql","description":"Average PR cycle time by adoption tier. Lower times in higher adoption tiers suggest positive Copilot impact.","fieldConfig":{"defaults":{"color":{"mode":"thresholds"},"mappings":[],"thresholds":{"mode":"absolute","steps":[{"color":"green","value":null},{"color":"yellow","value":24},{"color":"red","value":48}]},"unit":"h"},"overrides":[]},"gridPos":{"h":8,"w":4,"x":12,"y":31},"id":14,"options":{"displayMode":"gradient","maxVizHeight":300,"minVizHeight":16,"minVizWidth":8,"namePlacement":"auto","orientation":"horizontal","reduceOptions":{"calcs":["lastNotNull"],"fields":"","values":false},"showUnfilled":true,"sizing":"auto","valueMode":"color"},"transformations":[{"id":"rowsToFields","options":{"labelField":"metric","valueField":"value"}}],"pluginVersion":"11.0.0","targets":[{"datasource":"mysql","format":"table","rawQuery":true,"rawSql":"WITH _copilot_adoption AS (\n SELECT metric_date, ROUND(active_users * 100.0 / NULLIF(total_seats, 0), 1) AS adoption_pct\n FROM (\n SELECT day AS metric_date, SUM(daily_active_users) AS active_users,\n (SELECT COUNT(*) FROM _tool_copilot_seats s WHERE s.connection_id = ${connection_id}\n AND COALESCE(s.organization, '') = COALESCE((SELECT sc.organization FROM _tool_copilot_scopes sc\n WHERE sc.connection_id = ${connection_id} AND sc.id = '${scope_id}' LIMIT 1), '')) AS total_seats\n FROM _tool_copilot_enterprise_daily_metrics\n WHERE connection_id = ${connection_id} AND scope_id = '${scope_id}' AND $__timeFilter(day)\n GROUP BY day\n UNION ALL\n SELECT date AS metric_date, total_active_users AS active_users, seat_total AS total_seats\n FROM _tool_copilot_org_daily_metrics\n WHERE connection_id = ${connection_id} AND scope_id = '${scope_id}' AND $__timeFilter(date)\n ) _unified),\n_adoption_weekly AS (\n SELECT\n DATE_SUB(metric_date, INTERVAL WEEKDAY(metric_date) DAY) AS week_start,\n AVG(adoption_pct) AS adoption_pct\n FROM _copilot_adoption\n GROUP BY DATE_SUB(metric_date, INTERVAL WEEKDAY(metric_date) DAY)\n),\n_pr_metrics_weekly AS (\n SELECT\n DATE_SUB(DATE(pr_merged_date), INTERVAL WEEKDAY(DATE(pr_merged_date)) DAY) AS week_start,\n AVG(pr_cycle_time) / 60.0 AS avg_cycle_time_hours\n FROM project_pr_metrics\n WHERE project_name = ${project:sqlstring}\n AND $__timeFilter(pr_merged_date)\n GROUP BY DATE_SUB(DATE(pr_merged_date), INTERVAL WEEKDAY(DATE(pr_merged_date)) DAY)\n),\n_adoption_pr_joined AS (\n SELECT\n aw.adoption_pct,\n pm.avg_cycle_time_hours,\n CASE\n WHEN aw.adoption_pct < 25 THEN '1. <25%'\n WHEN aw.adoption_pct < 50 THEN '2. 25-50%'\n WHEN aw.adoption_pct < 75 THEN '3. 50-75%'\n ELSE '4. >75%'\n END AS adoption_tier\n FROM _adoption_weekly aw\n INNER JOIN _pr_metrics_weekly pm ON aw.week_start = pm.week_start\n)\nSELECT\n adoption_tier AS metric,\n ROUND(AVG(avg_cycle_time_hours), 1) AS value\nFROM _adoption_pr_joined\nGROUP BY adoption_tier\nORDER BY adoption_tier","refId":"A"}],"title":"PR Cycle Time by Adoption Tier","type":"bargauge"},{"datasource":{"type":"datasource","uid":"grafana"},"gridPos":{"h":6,"w":4,"x":12,"y":35},"id":15,"options":{"code":{"language":"plaintext","showLineNumbers":false,"showMiniMap":false},"content":"**Cycle Time Components** break down PR lifecycle into three phases:\n\n- **Coding Time**: PR creation \u2192 first commit (development speed)\n- **Pickup Time**: PR creation \u2192 first review (reviewer responsiveness)\n- **Review Time**: First review \u2192 merge (actual review duration)\n\nComparing <50% vs \u226550% adoption periods shows which phase improved most with Copilot. For example, if Coding Time shrinks significantly, Copilot accelerates development.","mode":"markdown"},"pluginVersion":"11.0.0","targets":[{"datasource":{"type":"datasource","uid":"grafana"},"queryType":"randomWalk","refId":"A"}],"title":"Understanding Cycle Time Breakdown","type":"text"},{"datasource":"mysql","description":"Scatter plot showing weekly data points. Each point = one week. X-axis = adoption %, Y-axis = PR cycle time. Downward trend line indicates positive Copilot impact.","fieldConfig":{"defaults":{"color":{"mode":"palette-classic"},"custom":{"axisBorderShow":false,"axisCenteredZero":false,"axisColorMode":"text","axisLabel":"Adoption %","axisPlacement":"auto","hideFrom":{"legend":false,"tooltip":false,"viz":false},"pointSize":{"fixed":10},"scaleDistribution":{"type":"linear"},"show":"points"},"mappings":[],"thresholds":{"mode":"absolute","steps":[{"color":"green","value":null}]}},"overrides":[]},"gridPos":{"h":6,"w":12,"x":0,"y":35},"id":11,"options":{"dims":{"x":"adoption_pct"},"legend":{"calcs":[],"displayMode":"list","placement":"bottom","showLegend":false},"series":[],"seriesMapping":"auto","tooltip":{"mode":"single","sort":"none"}},"pluginVersion":"11.0.0","targets":[{"datasource":"mysql","format":"table","rawQuery":true,"rawSql":"WITH _copilot_adoption AS (\n SELECT metric_date, ROUND(active_users * 100.0 / NULLIF(total_seats, 0), 1) AS adoption_pct\n FROM (\n SELECT day AS metric_date, SUM(daily_active_users) AS active_users,\n (SELECT COUNT(*) FROM _tool_copilot_seats s WHERE s.connection_id = ${connection_id}\n AND COALESCE(s.organization, '') = COALESCE((SELECT sc.organization FROM _tool_copilot_scopes sc\n WHERE sc.connection_id = ${connection_id} AND sc.id = '${scope_id}' LIMIT 1), '')) AS total_seats\n FROM _tool_copilot_enterprise_daily_metrics\n WHERE connection_id = ${connection_id} AND scope_id = '${scope_id}' AND $__timeFilter(day)\n GROUP BY day\n UNION ALL\n SELECT date AS metric_date, total_active_users AS active_users, seat_total AS total_seats\n FROM _tool_copilot_org_daily_metrics\n WHERE connection_id = ${connection_id} AND scope_id = '${scope_id}' AND $__timeFilter(date)\n ) _unified),\n_adoption_weekly AS (\n SELECT\n DATE_SUB(metric_date, INTERVAL WEEKDAY(metric_date) DAY) AS week_start,\n AVG(adoption_pct) AS adoption_pct\n FROM _copilot_adoption\n GROUP BY DATE_SUB(metric_date, INTERVAL WEEKDAY(metric_date) DAY)\n),\n_pr_metrics_weekly AS (\n SELECT\n DATE_SUB(DATE(pr_merged_date), INTERVAL WEEKDAY(DATE(pr_merged_date)) DAY) AS week_start,\n AVG(pr_cycle_time) / 60.0 AS avg_cycle_time_hours\n FROM project_pr_metrics\n WHERE project_name = ${project:sqlstring}\n AND $__timeFilter(pr_merged_date)\n GROUP BY DATE_SUB(DATE(pr_merged_date), INTERVAL WEEKDAY(DATE(pr_merged_date)) DAY)\n)\nSELECT\n ROUND(aw.adoption_pct, 1) AS adoption_pct,\n ROUND(pm.avg_cycle_time_hours, 1) AS cycle_time_hours\nFROM _adoption_weekly aw\nINNER JOIN _pr_metrics_weekly pm ON aw.week_start = pm.week_start\nORDER BY aw.adoption_pct","refId":"A"}],"title":"Adoption vs PR Cycle Time (Scatter)","type":"xychart"},{"datasource":"mysql","description":"PR cycle time breakdown during low adoption periods (<50%). Compare with high adoption to see which components improved.","fieldConfig":{"defaults":{"color":{"mode":"palette-classic"},"mappings":[],"unit":"h"},"overrides":[]},"gridPos":{"h":6,"w":4,"x":16,"y":35},"id":12,"options":{"displayLabels":["name","percent"],"legend":{"displayMode":"table","placement":"right","showLegend":true,"values":["value"]},"pieType":"pie","reduceOptions":{"calcs":["lastNotNull"],"fields":"","values":true},"tooltip":{"mode":"single","sort":"none"}},"pluginVersion":"11.0.0","targets":[{"datasource":"mysql","format":"table","rawQuery":true,"rawSql":"WITH _copilot_adoption AS (\n SELECT metric_date, ROUND(active_users * 100.0 / NULLIF(total_seats, 0), 1) AS adoption_pct\n FROM (\n SELECT day AS metric_date, SUM(daily_active_users) AS active_users,\n (SELECT COUNT(*) FROM _tool_copilot_seats s WHERE s.connection_id = ${connection_id}\n AND COALESCE(s.organization, '') = COALESCE((SELECT sc.organization FROM _tool_copilot_scopes sc\n WHERE sc.connection_id = ${connection_id} AND sc.id = '${scope_id}' LIMIT 1), '')) AS total_seats\n FROM _tool_copilot_enterprise_daily_metrics\n WHERE connection_id = ${connection_id} AND scope_id = '${scope_id}' AND $__timeFilter(day)\n GROUP BY day\n UNION ALL\n SELECT date AS metric_date, total_active_users AS active_users, seat_total AS total_seats\n FROM _tool_copilot_org_daily_metrics\n WHERE connection_id = ${connection_id} AND scope_id = '${scope_id}' AND $__timeFilter(date)\n ) _unified),\n_adoption_weekly AS (\n SELECT\n DATE_SUB(metric_date, INTERVAL WEEKDAY(metric_date) DAY) AS week_start,\n AVG(adoption_pct) AS adoption_pct\n FROM _copilot_adoption\n GROUP BY DATE_SUB(metric_date, INTERVAL WEEKDAY(metric_date) DAY)\n),\n_low_adoption_weeks AS (\n SELECT week_start FROM _adoption_weekly WHERE adoption_pct < 50\n)\nSELECT\n 'Coding Time' AS component,\n ROUND(AVG(pr_coding_time) / 60.0, 1) AS value\nFROM project_pr_metrics\n WHERE project_name = ${project:sqlstring}\n AND DATE_SUB(DATE(pr_merged_date), INTERVAL WEEKDAY(DATE(pr_merged_date)) DAY) IN (SELECT week_start FROM _low_adoption_weeks)\nUNION ALL\nSELECT\n 'Pickup Time' AS component,\n ROUND(AVG(pr_pickup_time) / 60.0, 1) AS value\nFROM project_pr_metrics\n WHERE project_name = ${project:sqlstring}\n AND DATE_SUB(DATE(pr_merged_date), INTERVAL WEEKDAY(DATE(pr_merged_date)) DAY) IN (SELECT week_start FROM _low_adoption_weeks)\nUNION ALL\nSELECT\n 'Review Time' AS component,\n ROUND(AVG(pr_review_time) / 60.0, 1) AS value\nFROM project_pr_metrics\n WHERE project_name = ${project:sqlstring}\n AND DATE_SUB(DATE(pr_merged_date), INTERVAL WEEKDAY(DATE(pr_merged_date)) DAY) IN (SELECT week_start FROM _low_adoption_weeks)","refId":"A"}],"title":"Cycle Time Components: Low Adoption","type":"piechart"},{"datasource":"mysql","description":"PR cycle time breakdown during high adoption periods (>=50%). Compare with low adoption to see which components improved.","fieldConfig":{"defaults":{"color":{"mode":"palette-classic"},"mappings":[],"unit":"h"},"overrides":[]},"gridPos":{"h":6,"w":4,"x":20,"y":35},"id":13,"options":{"displayLabels":["name","percent"],"legend":{"displayMode":"table","placement":"right","showLegend":true,"values":["value"]},"pieType":"pie","reduceOptions":{"calcs":["lastNotNull"],"fields":"","values":true},"tooltip":{"mode":"single","sort":"none"}},"pluginVersion":"11.0.0","targets":[{"datasource":"mysql","format":"table","rawQuery":true,"rawSql":"WITH _copilot_adoption AS (\n SELECT metric_date, ROUND(active_users * 100.0 / NULLIF(total_seats, 0), 1) AS adoption_pct\n FROM (\n SELECT day AS metric_date, SUM(daily_active_users) AS active_users,\n (SELECT COUNT(*) FROM _tool_copilot_seats s WHERE s.connection_id = ${connection_id}\n AND COALESCE(s.organization, '') = COALESCE((SELECT sc.organization FROM _tool_copilot_scopes sc\n WHERE sc.connection_id = ${connection_id} AND sc.id = '${scope_id}' LIMIT 1), '')) AS total_seats\n FROM _tool_copilot_enterprise_daily_metrics\n WHERE connection_id = ${connection_id} AND scope_id = '${scope_id}' AND $__timeFilter(day)\n GROUP BY day\n UNION ALL\n SELECT date AS metric_date, total_active_users AS active_users, seat_total AS total_seats\n FROM _tool_copilot_org_daily_metrics\n WHERE connection_id = ${connection_id} AND scope_id = '${scope_id}' AND $__timeFilter(date)\n ) _unified),\n_adoption_weekly AS (\n SELECT\n DATE_SUB(metric_date, INTERVAL WEEKDAY(metric_date) DAY) AS week_start,\n AVG(adoption_pct) AS adoption_pct\n FROM _copilot_adoption\n GROUP BY DATE_SUB(metric_date, INTERVAL WEEKDAY(metric_date) DAY)\n),\n_high_adoption_weeks AS (\n SELECT week_start FROM _adoption_weekly WHERE adoption_pct >= 50\n)\nSELECT\n 'Coding Time' AS component,\n ROUND(AVG(pr_coding_time) / 60.0, 1) AS value\nFROM project_pr_metrics\n WHERE project_name = ${project:sqlstring}\n AND DATE_SUB(DATE(pr_merged_date), INTERVAL WEEKDAY(DATE(pr_merged_date)) DAY) IN (SELECT week_start FROM _high_adoption_weeks)\nUNION ALL\nSELECT\n 'Pickup Time' AS component,\n ROUND(AVG(pr_pickup_time) / 60.0, 1) AS value\nFROM project_pr_metrics\n WHERE project_name = ${project:sqlstring}\n AND DATE_SUB(DATE(pr_merged_date), INTERVAL WEEKDAY(DATE(pr_merged_date)) DAY) IN (SELECT week_start FROM _high_adoption_weeks)\nUNION ALL\nSELECT\n 'Review Time' AS component,\n ROUND(AVG(pr_review_time) / 60.0, 1) AS value\nFROM project_pr_metrics\n WHERE project_name = ${project:sqlstring}\n AND DATE_SUB(DATE(pr_merged_date), INTERVAL WEEKDAY(DATE(pr_merged_date)) DAY) IN (SELECT week_start FROM _high_adoption_weeks)","refId":"A"}],"title":"Cycle Time Components: High Adoption","type":"piechart"},{"collapsed":false,"gridPos":{"h":1,"w":24,"x":0,"y":45},"id":200,"panels":[],"title":"Deployment Frequency Impact","type":"row"},{"datasource":"mysql","description":"Dual-axis chart showing GitHub Copilot adoption percentage and deployment frequency over time. Look for correlation patterns - does higher adoption correlate with more frequent deployments?","fieldConfig":{"defaults":{"color":{"mode":"palette-classic"},"custom":{"axisBorderShow":false,"axisCenteredZero":false,"axisColorMode":"text","axisLabel":"","axisPlacement":"auto","barAlignment":0,"drawStyle":"line","fillOpacity":10,"gradientMode":"none","hideFrom":{"legend":false,"tooltip":false,"viz":false},"insertNulls":false,"lineInterpolation":"smooth","lineWidth":2,"pointSize":5,"scaleDistribution":{"type":"linear"},"showPoints":"auto","spanNulls":false,"stacking":{"group":"A","mode":"none"},"thresholdsStyle":{"mode":"off"}},"mappings":[],"thresholds":{"mode":"absolute","steps":[{"color":"green","value":null}]}},"overrides":[{"matcher":{"id":"byName","options":"Adoption %"},"properties":[{"id":"custom.axisPlacement","value":"left"},{"id":"unit","value":"percent"},{"id":"color","value":{"fixedColor":"blue","mode":"fixed"}},{"id":"min","value":0},{"id":"max","value":100}]},{"matcher":{"id":"byName","options":"Deployments"},"properties":[{"id":"custom.axisPlacement","value":"right"},{"id":"unit","value":"short"},{"id":"color","value":{"fixedColor":"orange","mode":"fixed"}}]}]},"gridPos":{"h":8,"w":12,"x":0,"y":46},"id":31,"options":{"legend":{"calcs":["mean"],"displayMode":"table","placement":"bottom","showLegend":true},"tooltip":{"mode":"multi","sort":"none"}},"pluginVersion":"11.0.0","targets":[{"datasource":"mysql","format":"time_series","rawQuery":true,"rawSql":"WITH _copilot_adoption AS (\n SELECT metric_date, ROUND(active_users * 100.0 / NULLIF(total_seats, 0), 1) AS adoption_pct\n FROM (\n SELECT day AS metric_date, SUM(daily_active_users) AS active_users,\n (SELECT COUNT(*) FROM _tool_copilot_seats s WHERE s.connection_id = ${connection_id}\n AND COALESCE(s.organization, '') = COALESCE((SELECT sc.organization FROM _tool_copilot_scopes sc\n WHERE sc.connection_id = ${connection_id} AND sc.id = '${scope_id}' LIMIT 1), '')) AS total_seats\n FROM _tool_copilot_enterprise_daily_metrics\n WHERE connection_id = ${connection_id} AND scope_id = '${scope_id}' AND $__timeFilter(day)\n GROUP BY day\n UNION ALL\n SELECT date AS metric_date, total_active_users AS active_users, seat_total AS total_seats\n FROM _tool_copilot_org_daily_metrics\n WHERE connection_id = ${connection_id} AND scope_id = '${scope_id}' AND $__timeFilter(date)\n ) _unified),\n_adoption_weekly AS (\n SELECT\n DATE_SUB(metric_date, INTERVAL WEEKDAY(metric_date) DAY) AS week_start,\n AVG(adoption_pct) AS adoption_pct\n FROM _copilot_adoption\n GROUP BY DATE_SUB(metric_date, INTERVAL WEEKDAY(metric_date) DAY)\n),\n_deployments_weekly AS (\n SELECT\n DATE_SUB(DATE(finished_date), INTERVAL WEEKDAY(DATE(finished_date)) DAY) AS week_start,\n COUNT(*) AS deploy_count\n FROM cicd_deployment_commits\n WHERE result = 'SUCCESS'\n AND $__timeFilter(finished_date)\n GROUP BY DATE_SUB(DATE(finished_date), INTERVAL WEEKDAY(DATE(finished_date)) DAY)\n)\nSELECT\n UNIX_TIMESTAMP(aw.week_start) AS time_sec,\n aw.adoption_pct AS 'Adoption %',\n COALESCE(dw.deploy_count, 0) AS 'Deployments'\nFROM _adoption_weekly aw\nLEFT JOIN _deployments_weekly dw ON aw.week_start = dw.week_start\nORDER BY aw.week_start","refId":"A"}],"title":"Adoption vs Deployment Frequency","type":"timeseries"},{"datasource":"mysql","description":"Deployment frequency grouped by GitHub Copilot adoption tier. Higher adoption should correlate with more frequent deployments if Copilot accelerates development velocity.","fieldConfig":{"defaults":{"color":{"mode":"thresholds"},"mappings":[],"thresholds":{"mode":"absolute","steps":[{"color":"yellow","value":null},{"color":"green","value":5}]},"unit":"short"},"overrides":[]},"gridPos":{"h":8,"w":6,"x":12,"y":46},"id":32,"options":{"displayMode":"gradient","maxVizHeight":300,"minVizHeight":10,"minVizWidth":0,"namePlacement":"auto","orientation":"horizontal","reduceOptions":{"calcs":["sum"],"fields":"","values":false},"showUnfilled":true,"sizing":"auto","valueMode":"color"},"transformations":[{"id":"rowsToFields","options":{"labelField":"Adoption Tier","valueField":"Deploys/Week"}}],"pluginVersion":"11.0.0","targets":[{"datasource":"mysql","format":"table","rawQuery":true,"rawSql":"WITH _copilot_adoption AS (\n SELECT metric_date, ROUND(active_users * 100.0 / NULLIF(total_seats, 0), 1) AS adoption_pct\n FROM (\n SELECT day AS metric_date, SUM(daily_active_users) AS active_users,\n (SELECT COUNT(*) FROM _tool_copilot_seats s WHERE s.connection_id = ${connection_id}\n AND COALESCE(s.organization, '') = COALESCE((SELECT sc.organization FROM _tool_copilot_scopes sc\n WHERE sc.connection_id = ${connection_id} AND sc.id = '${scope_id}' LIMIT 1), '')) AS total_seats\n FROM _tool_copilot_enterprise_daily_metrics\n WHERE connection_id = ${connection_id} AND scope_id = '${scope_id}'\n GROUP BY day\n UNION ALL\n SELECT date AS metric_date, total_active_users AS active_users, seat_total AS total_seats\n FROM _tool_copilot_org_daily_metrics\n WHERE connection_id = ${connection_id} AND scope_id = '${scope_id}'\n ) _unified),\n_adoption_weekly AS (\n SELECT\n DATE_SUB(metric_date, INTERVAL WEEKDAY(metric_date) DAY) AS week_start,\n AVG(adoption_pct) AS adoption_pct,\n CASE\n WHEN AVG(adoption_pct) < 25 THEN '1: <25%'\n WHEN AVG(adoption_pct) < 50 THEN '2: 25-50%'\n WHEN AVG(adoption_pct) < 75 THEN '3: 50-75%'\n ELSE '4: >75%'\n END AS adoption_tier\n FROM _copilot_adoption\n GROUP BY DATE_SUB(metric_date, INTERVAL WEEKDAY(metric_date) DAY)\n),\n_deployments_weekly AS (\n SELECT\n DATE_SUB(DATE(finished_date), INTERVAL WEEKDAY(DATE(finished_date)) DAY) AS week_start,\n COUNT(*) AS deploy_count\n FROM cicd_deployment_commits\n WHERE result = 'SUCCESS'\n AND $__timeFilter(finished_date)\n GROUP BY DATE_SUB(DATE(finished_date), INTERVAL WEEKDAY(DATE(finished_date)) DAY)\n)\nSELECT\n aw.adoption_tier AS 'Adoption Tier',\n ROUND(AVG(COALESCE(dw.deploy_count, 0)), 1) AS 'Deploys/Week'\nFROM _adoption_weekly aw\nLEFT JOIN _deployments_weekly dw ON aw.week_start = dw.week_start\nGROUP BY aw.adoption_tier\nORDER BY aw.adoption_tier","refId":"A"}],"title":"Deployments per Week by Adoption Tier","type":"bargauge"},{"datasource":"mysql","description":"Pearson correlation coefficient (r) between GitHub Copilot adoption % and weekly deployment count. Positive values indicate higher adoption correlates with more deployments.","fieldConfig":{"defaults":{"color":{"mode":"thresholds"},"mappings":[{"options":{"match":"null","result":{"text":"N/A"}},"type":"special"}],"thresholds":{"mode":"absolute","steps":[{"color":"red","value":null},{"color":"yellow","value":0.3},{"color":"green","value":0.7}]},"unit":"none"},"overrides":[]},"gridPos":{"h":4,"w":6,"x":18,"y":46},"id":33,"options":{"colorMode":"value","graphMode":"none","justifyMode":"auto","orientation":"auto","reduceOptions":{"calcs":["lastNotNull"],"fields":"","values":false},"showPercentChange":false,"textMode":"auto","wideLayout":true},"pluginVersion":"11.0.0","targets":[{"datasource":"mysql","format":"table","rawQuery":true,"rawSql":"WITH _copilot_adoption AS (\n SELECT metric_date, ROUND(active_users * 100.0 / NULLIF(total_seats, 0), 1) AS adoption_pct\n FROM (\n SELECT day AS metric_date, SUM(daily_active_users) AS active_users,\n (SELECT COUNT(*) FROM _tool_copilot_seats s WHERE s.connection_id = ${connection_id}\n AND COALESCE(s.organization, '') = COALESCE((SELECT sc.organization FROM _tool_copilot_scopes sc\n WHERE sc.connection_id = ${connection_id} AND sc.id = '${scope_id}' LIMIT 1), '')) AS total_seats\n FROM _tool_copilot_enterprise_daily_metrics\n WHERE connection_id = ${connection_id} AND scope_id = '${scope_id}' AND $__timeFilter(day)\n GROUP BY day\n UNION ALL\n SELECT date AS metric_date, total_active_users AS active_users, seat_total AS total_seats\n FROM _tool_copilot_org_daily_metrics\n WHERE connection_id = ${connection_id} AND scope_id = '${scope_id}' AND $__timeFilter(date)\n ) _unified),\n_adoption_weekly AS (\n SELECT\n DATE_SUB(metric_date, INTERVAL WEEKDAY(metric_date) DAY) AS week_start,\n AVG(adoption_pct) AS adoption_pct\n FROM _copilot_adoption\n GROUP BY DATE_SUB(metric_date, INTERVAL WEEKDAY(metric_date) DAY)\n),\n_deployments_weekly AS (\n SELECT\n DATE_SUB(DATE(finished_date), INTERVAL WEEKDAY(DATE(finished_date)) DAY) AS week_start,\n COUNT(*) AS deploy_count\n FROM cicd_deployment_commits\n WHERE result = 'SUCCESS'\n AND $__timeFilter(finished_date)\n GROUP BY DATE_SUB(DATE(finished_date), INTERVAL WEEKDAY(DATE(finished_date)) DAY)\n),\n_joined AS (\n SELECT\n aw.adoption_pct,\n COALESCE(dw.deploy_count, 0) AS deploy_count\n FROM _adoption_weekly aw\n LEFT JOIN _deployments_weekly dw ON aw.week_start = dw.week_start\n)\nSELECT\n ROUND(\n (COUNT(*) * SUM(adoption_pct * deploy_count) - SUM(adoption_pct) * SUM(deploy_count)) /\n NULLIF(\n SQRT(\n (COUNT(*) * SUM(adoption_pct * adoption_pct) - POW(SUM(adoption_pct), 2)) *\n (COUNT(*) * SUM(deploy_count * deploy_count) - POW(SUM(deploy_count), 2))\n ),\n 0\n ),\n 2\n ) AS correlation_r\nFROM _joined\nWHERE adoption_pct IS NOT NULL","refId":"A"}],"title":"Adoption vs Deployment Frequency (r)","type":"stat"},{"datasource":"mysql","description":"Percentage change in deployment frequency between low adoption (<50%) and high adoption (\u226550%) periods.","fieldConfig":{"defaults":{"color":{"mode":"thresholds"},"mappings":[{"options":{"match":"null","result":{"text":"N/A"}},"type":"special"}],"thresholds":{"mode":"absolute","steps":[{"color":"red","value":null},{"color":"yellow","value":0},{"color":"green","value":20}]},"unit":"percent"},"overrides":[]},"gridPos":{"h":4,"w":6,"x":18,"y":50},"id":34,"options":{"colorMode":"value","graphMode":"none","justifyMode":"auto","orientation":"auto","reduceOptions":{"calcs":["lastNotNull"],"fields":"","values":false},"showPercentChange":false,"textMode":"auto","wideLayout":true},"pluginVersion":"11.0.0","targets":[{"datasource":"mysql","format":"table","rawQuery":true,"rawSql":"WITH _copilot_adoption AS (\n SELECT metric_date, ROUND(active_users * 100.0 / NULLIF(total_seats, 0), 1) AS adoption_pct\n FROM (\n SELECT day AS metric_date, SUM(daily_active_users) AS active_users,\n (SELECT COUNT(*) FROM _tool_copilot_seats s WHERE s.connection_id = ${connection_id}\n AND COALESCE(s.organization, '') = COALESCE((SELECT sc.organization FROM _tool_copilot_scopes sc\n WHERE sc.connection_id = ${connection_id} AND sc.id = '${scope_id}' LIMIT 1), '')) AS total_seats\n FROM _tool_copilot_enterprise_daily_metrics\n WHERE connection_id = ${connection_id} AND scope_id = '${scope_id}'\n GROUP BY day\n UNION ALL\n SELECT date AS metric_date, total_active_users AS active_users, seat_total AS total_seats\n FROM _tool_copilot_org_daily_metrics\n WHERE connection_id = ${connection_id} AND scope_id = '${scope_id}'\n ) _unified),\n_adoption_weekly AS (\n SELECT\n DATE_SUB(metric_date, INTERVAL WEEKDAY(metric_date) DAY) AS week_start,\n AVG(adoption_pct) AS adoption_pct\n FROM _copilot_adoption\n GROUP BY DATE_SUB(metric_date, INTERVAL WEEKDAY(metric_date) DAY)\n),\n_deployments_weekly AS (\n SELECT\n DATE_SUB(DATE(finished_date), INTERVAL WEEKDAY(DATE(finished_date)) DAY) AS week_start,\n COUNT(*) AS deploy_count\n FROM cicd_deployment_commits\n WHERE result = 'SUCCESS'\n AND $__timeFilter(finished_date)\n GROUP BY DATE_SUB(DATE(finished_date), INTERVAL WEEKDAY(DATE(finished_date)) DAY)\n),\n_adoption_deploy AS (\n SELECT\n CASE WHEN aw.adoption_pct < 50 THEN 'low' ELSE 'high' END AS tier,\n COALESCE(dw.deploy_count, 0) AS deploy_count\n FROM _adoption_weekly aw\n LEFT JOIN _deployments_weekly dw ON aw.week_start = dw.week_start\n),\n_tier_avg AS (\n SELECT\n tier,\n AVG(deploy_count) AS avg_deploys\n FROM _adoption_deploy\n GROUP BY tier\n)\nSELECT\n ROUND(\n ((SELECT avg_deploys FROM _tier_avg WHERE tier = 'high') -\n (SELECT avg_deploys FROM _tier_avg WHERE tier = 'low')) /\n NULLIF((SELECT avg_deploys FROM _tier_avg WHERE tier = 'low'), 0) * 100,\n 1\n ) AS change_pct","refId":"A"}],"title":"Deployment Frequency Change (High vs Low Adoption)","type":"stat"},{"collapsed":false,"gridPos":{"h":1,"w":24,"x":0,"y":5},"id":250,"panels":[],"title":"Adoption Intensity Analysis","type":"row"},{"datasource":"mysql","description":"Comprehensive comparison of all DORA metrics across GitHub Copilot adoption tiers. Lower values (green) are better for cycle time, CFR, and MTTR. Higher values (green) are better for deployment frequency.","fieldConfig":{"defaults":{"color":{"mode":"thresholds"},"custom":{"align":"auto","cellOptions":{"type":"auto"},"inspect":false},"mappings":[],"thresholds":{"mode":"absolute","steps":[{"color":"green","value":null}]}},"overrides":[]},"gridPos":{"h":8,"w":24,"x":0,"y":6},"id":72,"options":{"cellHeight":"sm","footer":{"countRows":false,"fields":"","reducer":["sum"],"show":false},"showHeader":true},"pluginVersion":"11.0.0","targets":[{"datasource":"mysql","format":"table","rawQuery":true,"rawSql":"WITH _copilot_adoption AS (\n SELECT metric_date, ROUND(active_users * 100.0 / NULLIF(total_seats, 0), 1) AS adoption_pct\n FROM (\n SELECT day AS metric_date, SUM(daily_active_users) AS active_users,\n (SELECT COUNT(*) FROM _tool_copilot_seats s WHERE s.connection_id = ${connection_id}\n AND COALESCE(s.organization, '') = COALESCE((SELECT sc.organization FROM _tool_copilot_scopes sc\n WHERE sc.connection_id = ${connection_id} AND sc.id = '${scope_id}' LIMIT 1), '')) AS total_seats\n FROM _tool_copilot_enterprise_daily_metrics\n WHERE connection_id = ${connection_id} AND scope_id = '${scope_id}'\n GROUP BY day\n UNION ALL\n SELECT date AS metric_date, total_active_users AS active_users, seat_total AS total_seats\n FROM _tool_copilot_org_daily_metrics\n WHERE connection_id = ${connection_id} AND scope_id = '${scope_id}'\n ) _unified),\n_adoption_weekly AS (\n SELECT\n DATE_SUB(metric_date, INTERVAL WEEKDAY(metric_date) DAY) AS week_start,\n AVG(adoption_pct) AS adoption_pct\n FROM _copilot_adoption\n GROUP BY DATE_SUB(metric_date, INTERVAL WEEKDAY(metric_date) DAY)\n),\n_adoption_tiers AS (\n SELECT\n week_start,\n adoption_pct,\n CASE\n WHEN adoption_pct < 25 THEN '<25%'\n WHEN adoption_pct < 50 THEN '25-50%'\n WHEN adoption_pct < 75 THEN '50-75%'\n ELSE '>75%'\n END AS tier\n FROM _adoption_weekly\n),\n_pr_weekly AS (\n SELECT\n DATE_SUB(DATE(pr_merged_date), INTERVAL WEEKDAY(DATE(pr_merged_date)) DAY) AS week_start,\n AVG(pr_cycle_time / 60) AS cycle_time_hrs\n FROM project_pr_metrics\n WHERE project_name = ${project:sqlstring}\n AND pr_merged_date IS NOT NULL AND $__timeFilter(pr_merged_date)\n GROUP BY DATE_SUB(DATE(pr_merged_date), INTERVAL WEEKDAY(DATE(pr_merged_date)) DAY)\n),\n_deploy_weekly AS (\n SELECT\n DATE_SUB(DATE(finished_date), INTERVAL WEEKDAY(DATE(finished_date)) DAY) AS week_start,\n COUNT(*) AS deploy_count,\n SUM(CASE WHEN result = 'FAILURE' THEN 1 ELSE 0 END) AS failed_deploys\n FROM cicd_deployment_commits\n WHERE $__timeFilter(finished_date)\n GROUP BY DATE_SUB(DATE(finished_date), INTERVAL WEEKDAY(DATE(finished_date)) DAY)\n),\n_cfr_weekly AS (\n SELECT\n week_start,\n ROUND(failed_deploys * 100.0 / NULLIF(deploy_count, 0), 1) AS cfr_pct\n FROM _deploy_weekly\n),\n_mttr_weekly AS (\n SELECT\n DATE_SUB(DATE(resolution_date), INTERVAL WEEKDAY(DATE(resolution_date)) DAY) AS week_start,\n AVG(TIMESTAMPDIFF(HOUR, created_date, resolution_date)) AS mttr_hours\n FROM issues\n WHERE type = 'INCIDENT'\n AND resolution_date IS NOT NULL\n AND $__timeFilter(resolution_date)\n GROUP BY DATE_SUB(DATE(resolution_date), INTERVAL WEEKDAY(DATE(resolution_date)) DAY)\n),\n_tier_metrics AS (\n SELECT\n at.tier,\n ROUND(AVG(pw.cycle_time_hrs), 1) AS pr_cycle_time,\n ROUND(AVG(dw.deploy_count), 1) AS deploy_freq,\n ROUND(AVG(cfr.cfr_pct), 1) AS cfr_pct,\n ROUND(AVG(mttr.mttr_hours), 1) AS mttr_hours\n FROM _adoption_tiers at\n LEFT JOIN _pr_weekly pw ON at.week_start = pw.week_start\n LEFT JOIN _deploy_weekly dw ON at.week_start = dw.week_start\n LEFT JOIN _cfr_weekly cfr ON at.week_start = cfr.week_start\n LEFT JOIN _mttr_weekly mttr ON at.week_start = mttr.week_start\n GROUP BY at.tier\n)\nSELECT\n tier AS 'Adoption Tier',\n COALESCE(pr_cycle_time, 0) AS 'PR Cycle Time (hrs)',\n COALESCE(deploy_freq, 0) AS 'Deploys/Week',\n COALESCE(cfr_pct, 0) AS 'CFR %',\n COALESCE(mttr_hours, 0) AS 'MTTR (hours)'\nFROM _tier_metrics\nORDER BY FIELD(tier, '<25%', '25-50%', '50-75%', '>75%')","refId":"A"}],"title":"DORA Metrics by Adoption Tier","type":"table"},{"collapsed":false,"gridPos":{"h":1,"w":24,"x":0,"y":54},"id":300,"panels":[],"title":"Change Failure Rate Impact","type":"row"},{"datasource":"mysql","description":"Dual-axis chart showing GitHub Copilot adoption percentage and Change Failure Rate over time. CFR should decrease (improve) as adoption increases if Copilot improves code quality.","fieldConfig":{"defaults":{"color":{"mode":"palette-classic"},"custom":{"axisBorderShow":false,"axisCenteredZero":false,"axisColorMode":"text","axisLabel":"","axisPlacement":"auto","barAlignment":0,"drawStyle":"line","fillOpacity":10,"gradientMode":"none","hideFrom":{"legend":false,"tooltip":false,"viz":false},"insertNulls":false,"lineInterpolation":"smooth","lineWidth":2,"pointSize":5,"scaleDistribution":{"type":"linear"},"showPoints":"auto","spanNulls":false,"stacking":{"group":"A","mode":"none"},"thresholdsStyle":{"mode":"off"}},"mappings":[],"thresholds":{"mode":"absolute","steps":[{"color":"green","value":null}]}},"overrides":[{"matcher":{"id":"byName","options":"Adoption %"},"properties":[{"id":"custom.axisPlacement","value":"left"},{"id":"unit","value":"percent"},{"id":"color","value":{"fixedColor":"blue","mode":"fixed"}},{"id":"min","value":0},{"id":"max","value":100}]},{"matcher":{"id":"byName","options":"CFR %"},"properties":[{"id":"custom.axisPlacement","value":"right"},{"id":"unit","value":"percent"},{"id":"color","value":{"fixedColor":"red","mode":"fixed"}}]}]},"gridPos":{"h":8,"w":12,"x":0,"y":55},"id":41,"options":{"legend":{"calcs":["mean"],"displayMode":"table","placement":"bottom","showLegend":true},"tooltip":{"mode":"multi","sort":"none"}},"pluginVersion":"11.0.0","targets":[{"datasource":"mysql","format":"time_series","rawQuery":true,"rawSql":"WITH _copilot_adoption AS (\n SELECT metric_date, ROUND(active_users * 100.0 / NULLIF(total_seats, 0), 1) AS adoption_pct\n FROM (\n SELECT day AS metric_date, SUM(daily_active_users) AS active_users,\n (SELECT COUNT(*) FROM _tool_copilot_seats s WHERE s.connection_id = ${connection_id}\n AND COALESCE(s.organization, '') = COALESCE((SELECT sc.organization FROM _tool_copilot_scopes sc\n WHERE sc.connection_id = ${connection_id} AND sc.id = '${scope_id}' LIMIT 1), '')) AS total_seats\n FROM _tool_copilot_enterprise_daily_metrics\n WHERE connection_id = ${connection_id} AND scope_id = '${scope_id}' AND $__timeFilter(day)\n GROUP BY day\n UNION ALL\n SELECT date AS metric_date, total_active_users AS active_users, seat_total AS total_seats\n FROM _tool_copilot_org_daily_metrics\n WHERE connection_id = ${connection_id} AND scope_id = '${scope_id}' AND $__timeFilter(date)\n ) _unified),\n_adoption_weekly AS (\n SELECT\n DATE_SUB(metric_date, INTERVAL WEEKDAY(metric_date) DAY) AS week_start,\n AVG(adoption_pct) AS adoption_pct\n FROM _copilot_adoption\n GROUP BY DATE_SUB(metric_date, INTERVAL WEEKDAY(metric_date) DAY)\n),\n_deployments_weekly AS (\n SELECT\n DATE_SUB(DATE(finished_date), INTERVAL WEEKDAY(DATE(finished_date)) DAY) AS week_start,\n COUNT(*) AS total_deploys,\n SUM(CASE WHEN result = 'FAILURE' THEN 1 ELSE 0 END) AS failed_deploys\n FROM cicd_deployment_commits\n WHERE $__timeFilter(finished_date)\n GROUP BY DATE_SUB(DATE(finished_date), INTERVAL WEEKDAY(DATE(finished_date)) DAY)\n)\nSELECT\n UNIX_TIMESTAMP(aw.week_start) AS time_sec,\n aw.adoption_pct AS 'Adoption %',\n ROUND(dw.failed_deploys * 100.0 / NULLIF(dw.total_deploys, 0), 1) AS 'CFR %'\nFROM _adoption_weekly aw\nLEFT JOIN _deployments_weekly dw ON aw.week_start = dw.week_start\nORDER BY aw.week_start","refId":"A"}],"title":"Adoption vs Change Failure Rate","type":"timeseries"},{"datasource":"mysql","description":"Change Failure Rate grouped by GitHub Copilot adoption tier. Lower CFR at higher adoption indicates Copilot improves code quality and reduces failures.","fieldConfig":{"defaults":{"color":{"mode":"thresholds"},"mappings":[],"thresholds":{"mode":"absolute","steps":[{"color":"green","value":null},{"color":"yellow","value":10},{"color":"red","value":15}]},"unit":"percent"},"overrides":[]},"gridPos":{"h":8,"w":6,"x":12,"y":55},"id":42,"options":{"displayMode":"gradient","maxVizHeight":300,"minVizHeight":10,"minVizWidth":0,"namePlacement":"auto","orientation":"horizontal","reduceOptions":{"calcs":["mean"],"fields":"","values":false},"showUnfilled":true,"sizing":"auto","valueMode":"color"},"transformations":[{"id":"rowsToFields","options":{"labelField":"Adoption Tier","valueField":"CFR %"}}],"pluginVersion":"11.0.0","targets":[{"datasource":"mysql","format":"table","rawQuery":true,"rawSql":"WITH _copilot_adoption AS (\n SELECT metric_date, ROUND(active_users * 100.0 / NULLIF(total_seats, 0), 1) AS adoption_pct\n FROM (\n SELECT day AS metric_date, SUM(daily_active_users) AS active_users,\n (SELECT COUNT(*) FROM _tool_copilot_seats s WHERE s.connection_id = ${connection_id}\n AND COALESCE(s.organization, '') = COALESCE((SELECT sc.organization FROM _tool_copilot_scopes sc\n WHERE sc.connection_id = ${connection_id} AND sc.id = '${scope_id}' LIMIT 1), '')) AS total_seats\n FROM _tool_copilot_enterprise_daily_metrics\n WHERE connection_id = ${connection_id} AND scope_id = '${scope_id}'\n GROUP BY day\n UNION ALL\n SELECT date AS metric_date, total_active_users AS active_users, seat_total AS total_seats\n FROM _tool_copilot_org_daily_metrics\n WHERE connection_id = ${connection_id} AND scope_id = '${scope_id}'\n ) _unified),\n_adoption_weekly AS (\n SELECT\n DATE_SUB(metric_date, INTERVAL WEEKDAY(metric_date) DAY) AS week_start,\n AVG(adoption_pct) AS adoption_pct,\n CASE\n WHEN AVG(adoption_pct) < 25 THEN '1: <25%'\n WHEN AVG(adoption_pct) < 50 THEN '2: 25-50%'\n WHEN AVG(adoption_pct) < 75 THEN '3: 50-75%'\n ELSE '4: >75%'\n END AS adoption_tier\n FROM _copilot_adoption\n GROUP BY DATE_SUB(metric_date, INTERVAL WEEKDAY(metric_date) DAY)\n),\n_deployments_weekly AS (\n SELECT\n DATE_SUB(DATE(finished_date), INTERVAL WEEKDAY(DATE(finished_date)) DAY) AS week_start,\n COUNT(*) AS total_deploys,\n SUM(CASE WHEN result = 'FAILURE' THEN 1 ELSE 0 END) AS failed_deploys\n FROM cicd_deployment_commits\n WHERE $__timeFilter(finished_date)\n GROUP BY DATE_SUB(DATE(finished_date), INTERVAL WEEKDAY(DATE(finished_date)) DAY)\n)\nSELECT\n aw.adoption_tier AS 'Adoption Tier',\n ROUND(AVG(dw.failed_deploys * 100.0 / NULLIF(dw.total_deploys, 0)), 1) AS 'CFR %'\nFROM _adoption_weekly aw\nLEFT JOIN _deployments_weekly dw ON aw.week_start = dw.week_start\nGROUP BY aw.adoption_tier\nORDER BY aw.adoption_tier","refId":"A"}],"title":"CFR by Adoption Tier","type":"bargauge"},{"datasource":"mysql","description":"Pearson correlation coefficient (r) between GitHub Copilot adoption % and Change Failure Rate. NEGATIVE values are good - they indicate higher adoption correlates with lower failure rates.","fieldConfig":{"defaults":{"color":{"mode":"thresholds"},"mappings":[{"options":{"match":"null","result":{"text":"N/A"}},"type":"special"}],"thresholds":{"mode":"absolute","steps":[{"color":"green","value":null},{"color":"yellow","value":-0.3},{"color":"red","value":0}]},"unit":"none"},"overrides":[]},"gridPos":{"h":4,"w":6,"x":18,"y":55},"id":43,"options":{"colorMode":"value","graphMode":"none","justifyMode":"auto","orientation":"auto","reduceOptions":{"calcs":["lastNotNull"],"fields":"","values":false},"showPercentChange":false,"textMode":"auto","wideLayout":true},"pluginVersion":"11.0.0","targets":[{"datasource":"mysql","format":"table","rawQuery":true,"rawSql":"WITH _copilot_adoption AS (\n SELECT metric_date, ROUND(active_users * 100.0 / NULLIF(total_seats, 0), 1) AS adoption_pct\n FROM (\n SELECT day AS metric_date, SUM(daily_active_users) AS active_users,\n (SELECT COUNT(*) FROM _tool_copilot_seats s WHERE s.connection_id = ${connection_id}\n AND COALESCE(s.organization, '') = COALESCE((SELECT sc.organization FROM _tool_copilot_scopes sc\n WHERE sc.connection_id = ${connection_id} AND sc.id = '${scope_id}' LIMIT 1), '')) AS total_seats\n FROM _tool_copilot_enterprise_daily_metrics\n WHERE connection_id = ${connection_id} AND scope_id = '${scope_id}' AND $__timeFilter(day)\n GROUP BY day\n UNION ALL\n SELECT date AS metric_date, total_active_users AS active_users, seat_total AS total_seats\n FROM _tool_copilot_org_daily_metrics\n WHERE connection_id = ${connection_id} AND scope_id = '${scope_id}' AND $__timeFilter(date)\n ) _unified),\n_adoption_weekly AS (\n SELECT\n DATE_SUB(metric_date, INTERVAL WEEKDAY(metric_date) DAY) AS week_start,\n AVG(adoption_pct) AS adoption_pct\n FROM _copilot_adoption\n GROUP BY DATE_SUB(metric_date, INTERVAL WEEKDAY(metric_date) DAY)\n),\n_deployments_weekly AS (\n SELECT\n DATE_SUB(DATE(finished_date), INTERVAL WEEKDAY(DATE(finished_date)) DAY) AS week_start,\n COUNT(*) AS total_deploys,\n SUM(CASE WHEN result = 'FAILURE' THEN 1 ELSE 0 END) AS failed_deploys\n FROM cicd_deployment_commits\n WHERE $__timeFilter(finished_date)\n GROUP BY DATE_SUB(DATE(finished_date), INTERVAL WEEKDAY(DATE(finished_date)) DAY)\n),\n_joined AS (\n SELECT\n aw.adoption_pct,\n (dw.failed_deploys * 100.0 / NULLIF(dw.total_deploys, 0)) AS cfr_pct\n FROM _adoption_weekly aw\n LEFT JOIN _deployments_weekly dw ON aw.week_start = dw.week_start\n WHERE dw.total_deploys > 0\n)\nSELECT\n ROUND(\n (COUNT(*) * SUM(adoption_pct * cfr_pct) - SUM(adoption_pct) * SUM(cfr_pct)) /\n NULLIF(\n SQRT(\n (COUNT(*) * SUM(adoption_pct * adoption_pct) - POW(SUM(adoption_pct), 2)) *\n (COUNT(*) * SUM(cfr_pct * cfr_pct) - POW(SUM(cfr_pct), 2))\n ),\n 0\n ),\n 2\n ) AS correlation_r\nFROM _joined","refId":"A"}],"title":"Adoption vs CFR (r)","type":"stat"},{"datasource":"mysql","description":"Percentage change in Change Failure Rate between low adoption (<50%) and high adoption (\u226550%) periods. Negative values indicate improvement.","fieldConfig":{"defaults":{"color":{"mode":"thresholds"},"mappings":[{"options":{"match":"null","result":{"text":"N/A"}},"type":"special"}],"thresholds":{"mode":"absolute","steps":[{"color":"green","value":null},{"color":"yellow","value":-10},{"color":"red","value":0}]},"unit":"percent"},"overrides":[]},"gridPos":{"h":4,"w":6,"x":18,"y":59},"id":44,"options":{"colorMode":"value","graphMode":"none","justifyMode":"auto","orientation":"auto","reduceOptions":{"calcs":["lastNotNull"],"fields":"","values":false},"showPercentChange":false,"textMode":"auto","wideLayout":true},"pluginVersion":"11.0.0","targets":[{"datasource":"mysql","format":"table","rawQuery":true,"rawSql":"WITH _copilot_adoption AS (\n SELECT metric_date, ROUND(active_users * 100.0 / NULLIF(total_seats, 0), 1) AS adoption_pct\n FROM (\n SELECT day AS metric_date, SUM(daily_active_users) AS active_users,\n (SELECT COUNT(*) FROM _tool_copilot_seats s WHERE s.connection_id = ${connection_id}\n AND COALESCE(s.organization, '') = COALESCE((SELECT sc.organization FROM _tool_copilot_scopes sc\n WHERE sc.connection_id = ${connection_id} AND sc.id = '${scope_id}' LIMIT 1), '')) AS total_seats\n FROM _tool_copilot_enterprise_daily_metrics\n WHERE connection_id = ${connection_id} AND scope_id = '${scope_id}'\n GROUP BY day\n UNION ALL\n SELECT date AS metric_date, total_active_users AS active_users, seat_total AS total_seats\n FROM _tool_copilot_org_daily_metrics\n WHERE connection_id = ${connection_id} AND scope_id = '${scope_id}'\n ) _unified),\n_adoption_weekly AS (\n SELECT\n DATE_SUB(metric_date, INTERVAL WEEKDAY(metric_date) DAY) AS week_start,\n AVG(adoption_pct) AS adoption_pct\n FROM _copilot_adoption\n GROUP BY DATE_SUB(metric_date, INTERVAL WEEKDAY(metric_date) DAY)\n),\n_deployments_weekly AS (\n SELECT\n DATE_SUB(DATE(finished_date), INTERVAL WEEKDAY(DATE(finished_date)) DAY) AS week_start,\n COUNT(*) AS total_deploys,\n SUM(CASE WHEN result = 'FAILURE' THEN 1 ELSE 0 END) AS failed_deploys\n FROM cicd_deployment_commits\n WHERE $__timeFilter(finished_date)\n GROUP BY DATE_SUB(DATE(finished_date), INTERVAL WEEKDAY(DATE(finished_date)) DAY)\n),\n_adoption_cfr AS (\n SELECT\n CASE WHEN aw.adoption_pct < 50 THEN 'low' ELSE 'high' END AS tier,\n (dw.failed_deploys * 100.0 / NULLIF(dw.total_deploys, 0)) AS cfr_pct\n FROM _adoption_weekly aw\n LEFT JOIN _deployments_weekly dw ON aw.week_start = dw.week_start\n WHERE dw.total_deploys > 0\n),\n_tier_avg AS (\n SELECT\n tier,\n AVG(cfr_pct) AS avg_cfr\n FROM _adoption_cfr\n GROUP BY tier\n)\nSELECT\n ROUND(\n ((SELECT avg_cfr FROM _tier_avg WHERE tier = 'high') -\n (SELECT avg_cfr FROM _tier_avg WHERE tier = 'low')) /\n NULLIF((SELECT avg_cfr FROM _tier_avg WHERE tier = 'low'), 0) * 100,\n 1\n ) AS change_pct","refId":"A"}],"title":"CFR Change (High vs Low Adoption)","type":"stat"},{"collapsed":false,"gridPos":{"h":1,"w":24,"x":0,"y":63},"id":400,"panels":[],"title":"Recovery Time (MTTR) Impact","type":"row"},{"datasource":"mysql","description":"Dual-axis chart showing GitHub Copilot adoption percentage and Mean Time to Recovery (MTTR) over time. MTTR should decrease (improve) as adoption increases if Copilot helps faster incident resolution.","fieldConfig":{"defaults":{"color":{"mode":"palette-classic"},"custom":{"axisBorderShow":false,"axisCenteredZero":false,"axisColorMode":"text","axisLabel":"","axisPlacement":"auto","barAlignment":0,"drawStyle":"line","fillOpacity":10,"gradientMode":"none","hideFrom":{"legend":false,"tooltip":false,"viz":false},"insertNulls":false,"lineInterpolation":"smooth","lineWidth":2,"pointSize":5,"scaleDistribution":{"type":"linear"},"showPoints":"auto","spanNulls":false,"stacking":{"group":"A","mode":"none"},"thresholdsStyle":{"mode":"off"}},"mappings":[],"thresholds":{"mode":"absolute","steps":[{"color":"green","value":null}]}},"overrides":[{"matcher":{"id":"byName","options":"Adoption %"},"properties":[{"id":"custom.axisPlacement","value":"left"},{"id":"unit","value":"percent"},{"id":"color","value":{"fixedColor":"blue","mode":"fixed"}},{"id":"min","value":0},{"id":"max","value":100}]},{"matcher":{"id":"byName","options":"MTTR (hours)"},"properties":[{"id":"custom.axisPlacement","value":"right"},{"id":"unit","value":"h"},{"id":"color","value":{"fixedColor":"purple","mode":"fixed"}}]}]},"gridPos":{"h":8,"w":12,"x":0,"y":64},"id":51,"options":{"legend":{"calcs":["mean"],"displayMode":"table","placement":"bottom","showLegend":true},"tooltip":{"mode":"multi","sort":"none"}},"pluginVersion":"11.0.0","targets":[{"datasource":"mysql","format":"time_series","rawQuery":true,"rawSql":"WITH _copilot_adoption AS (\n SELECT metric_date, ROUND(active_users * 100.0 / NULLIF(total_seats, 0), 1) AS adoption_pct\n FROM (\n SELECT day AS metric_date, SUM(daily_active_users) AS active_users,\n (SELECT COUNT(*) FROM _tool_copilot_seats s WHERE s.connection_id = ${connection_id}\n AND COALESCE(s.organization, '') = COALESCE((SELECT sc.organization FROM _tool_copilot_scopes sc\n WHERE sc.connection_id = ${connection_id} AND sc.id = '${scope_id}' LIMIT 1), '')) AS total_seats\n FROM _tool_copilot_enterprise_daily_metrics\n WHERE connection_id = ${connection_id} AND scope_id = '${scope_id}' AND $__timeFilter(day)\n GROUP BY day\n UNION ALL\n SELECT date AS metric_date, total_active_users AS active_users, seat_total AS total_seats\n FROM _tool_copilot_org_daily_metrics\n WHERE connection_id = ${connection_id} AND scope_id = '${scope_id}' AND $__timeFilter(date)\n ) _unified),\n_adoption_weekly AS (\n SELECT\n DATE_SUB(metric_date, INTERVAL WEEKDAY(metric_date) DAY) AS week_start,\n AVG(adoption_pct) AS adoption_pct\n FROM _copilot_adoption\n GROUP BY DATE_SUB(metric_date, INTERVAL WEEKDAY(metric_date) DAY)\n)\nSELECT\n week_start AS time,\n ROUND(adoption_pct, 1) AS 'Adoption %'\nFROM _adoption_weekly\nORDER BY week_start","refId":"Adoption"},{"datasource":"mysql","format":"time_series","rawQuery":true,"rawSql":"WITH _incidents_weekly AS (\n SELECT\n DATE_SUB(DATE(resolution_date), INTERVAL WEEKDAY(DATE(resolution_date)) DAY) AS week_start,\n AVG(TIMESTAMPDIFF(HOUR, created_date, resolution_date)) AS mttr_hours\n FROM issues\n WHERE type = 'INCIDENT'\n AND resolution_date IS NOT NULL\n AND $__timeFilter(resolution_date)\n GROUP BY DATE_SUB(DATE(resolution_date), INTERVAL WEEKDAY(DATE(resolution_date)) DAY)\n)\nSELECT\n week_start AS time,\n ROUND(mttr_hours, 1) AS 'MTTR (hours)'\nFROM _incidents_weekly\nORDER BY week_start","refId":"MTTR"}],"title":"Adoption vs MTTR","type":"timeseries"},{"datasource":"mysql","description":"Mean Time to Recovery grouped by GitHub Copilot adoption tier. Lower MTTR at higher adoption indicates Copilot helps faster incident resolution.","fieldConfig":{"defaults":{"color":{"mode":"thresholds"},"mappings":[],"thresholds":{"mode":"absolute","steps":[{"color":"green","value":null},{"color":"yellow","value":24},{"color":"red","value":72}]},"unit":"h"},"overrides":[]},"gridPos":{"h":8,"w":6,"x":12,"y":64},"id":52,"options":{"displayMode":"gradient","maxVizHeight":300,"minVizHeight":10,"minVizWidth":0,"namePlacement":"auto","orientation":"horizontal","reduceOptions":{"calcs":["mean"],"fields":"","values":false},"showUnfilled":true,"sizing":"auto","valueMode":"color"},"transformations":[{"id":"rowsToFields","options":{"labelField":"Adoption Tier","valueField":"MTTR (hours)"}}],"pluginVersion":"11.0.0","targets":[{"datasource":"mysql","format":"table","rawQuery":true,"rawSql":"WITH _copilot_adoption AS (\n SELECT metric_date, ROUND(active_users * 100.0 / NULLIF(total_seats, 0), 1) AS adoption_pct\n FROM (\n SELECT day AS metric_date, SUM(daily_active_users) AS active_users,\n (SELECT COUNT(*) FROM _tool_copilot_seats s WHERE s.connection_id = ${connection_id}\n AND COALESCE(s.organization, '') = COALESCE((SELECT sc.organization FROM _tool_copilot_scopes sc\n WHERE sc.connection_id = ${connection_id} AND sc.id = '${scope_id}' LIMIT 1), '')) AS total_seats\n FROM _tool_copilot_enterprise_daily_metrics\n WHERE connection_id = ${connection_id} AND scope_id = '${scope_id}'\n GROUP BY day\n UNION ALL\n SELECT date AS metric_date, total_active_users AS active_users, seat_total AS total_seats\n FROM _tool_copilot_org_daily_metrics\n WHERE connection_id = ${connection_id} AND scope_id = '${scope_id}'\n ) _unified),\n_adoption_weekly AS (\n SELECT\n DATE_SUB(metric_date, INTERVAL WEEKDAY(metric_date) DAY) AS week_start,\n AVG(adoption_pct) AS adoption_pct,\n CASE\n WHEN AVG(adoption_pct) < 25 THEN '1: <25%'\n WHEN AVG(adoption_pct) < 50 THEN '2: 25-50%'\n WHEN AVG(adoption_pct) < 75 THEN '3: 50-75%'\n ELSE '4: >75%'\n END AS adoption_tier\n FROM _copilot_adoption\n GROUP BY DATE_SUB(metric_date, INTERVAL WEEKDAY(metric_date) DAY)\n),\n_incidents_weekly AS (\n SELECT\n DATE_SUB(DATE(resolution_date), INTERVAL WEEKDAY(DATE(resolution_date)) DAY) AS week_start,\n AVG(TIMESTAMPDIFF(HOUR, created_date, resolution_date)) AS mttr_hours\n FROM issues\n WHERE type = 'INCIDENT'\n AND resolution_date IS NOT NULL\n AND $__timeFilter(resolution_date)\n GROUP BY DATE_SUB(DATE(resolution_date), INTERVAL WEEKDAY(DATE(resolution_date)) DAY)\n)\nSELECT\n aw.adoption_tier AS 'Adoption Tier',\n ROUND(AVG(iw.mttr_hours), 1) AS 'MTTR (hours)'\nFROM _adoption_weekly aw\nLEFT JOIN _incidents_weekly iw ON aw.week_start = iw.week_start\nWHERE iw.mttr_hours IS NOT NULL\nGROUP BY aw.adoption_tier\nORDER BY aw.adoption_tier","refId":"A"}],"title":"MTTR by Adoption Tier","type":"bargauge"},{"datasource":"mysql","description":"Pearson correlation coefficient (r) between GitHub Copilot adoption % and MTTR. NEGATIVE values are good - they indicate higher adoption correlates with faster recovery.","fieldConfig":{"defaults":{"color":{"mode":"thresholds"},"mappings":[{"options":{"match":"null","result":{"text":"N/A"}},"type":"special"}],"thresholds":{"mode":"absolute","steps":[{"color":"green","value":null},{"color":"yellow","value":-0.3},{"color":"red","value":0}]},"unit":"none"},"overrides":[]},"gridPos":{"h":4,"w":6,"x":18,"y":64},"id":53,"options":{"colorMode":"value","graphMode":"none","justifyMode":"auto","orientation":"auto","reduceOptions":{"calcs":["lastNotNull"],"fields":"","values":false},"showPercentChange":false,"textMode":"auto","wideLayout":true},"pluginVersion":"11.0.0","targets":[{"datasource":"mysql","format":"table","rawQuery":true,"rawSql":"WITH _copilot_adoption AS (\n SELECT metric_date, ROUND(active_users * 100.0 / NULLIF(total_seats, 0), 1) AS adoption_pct\n FROM (\n SELECT day AS metric_date, SUM(daily_active_users) AS active_users,\n (SELECT COUNT(*) FROM _tool_copilot_seats s WHERE s.connection_id = ${connection_id}\n AND COALESCE(s.organization, '') = COALESCE((SELECT sc.organization FROM _tool_copilot_scopes sc\n WHERE sc.connection_id = ${connection_id} AND sc.id = '${scope_id}' LIMIT 1), '')) AS total_seats\n FROM _tool_copilot_enterprise_daily_metrics\n WHERE connection_id = ${connection_id} AND scope_id = '${scope_id}' AND $__timeFilter(day)\n GROUP BY day\n UNION ALL\n SELECT date AS metric_date, total_active_users AS active_users, seat_total AS total_seats\n FROM _tool_copilot_org_daily_metrics\n WHERE connection_id = ${connection_id} AND scope_id = '${scope_id}' AND $__timeFilter(date)\n ) _unified),\n_adoption_weekly AS (\n SELECT\n DATE_SUB(metric_date, INTERVAL WEEKDAY(metric_date) DAY) AS week_start,\n AVG(adoption_pct) AS adoption_pct\n FROM _copilot_adoption\n GROUP BY DATE_SUB(metric_date, INTERVAL WEEKDAY(metric_date) DAY)\n),\n_incidents_weekly AS (\n SELECT\n DATE_SUB(DATE(resolution_date), INTERVAL WEEKDAY(DATE(resolution_date)) DAY) AS week_start,\n AVG(TIMESTAMPDIFF(HOUR, created_date, resolution_date)) AS mttr_hours\n FROM issues\n WHERE type = 'INCIDENT'\n AND resolution_date IS NOT NULL\n AND $__timeFilter(resolution_date)\n GROUP BY DATE_SUB(DATE(resolution_date), INTERVAL WEEKDAY(DATE(resolution_date)) DAY)\n),\n_joined AS (\n SELECT\n aw.adoption_pct,\n iw.mttr_hours\n FROM _adoption_weekly aw\n INNER JOIN _incidents_weekly iw ON aw.week_start = iw.week_start\n)\nSELECT\n ROUND(\n (COUNT(*) * SUM(adoption_pct * mttr_hours) - SUM(adoption_pct) * SUM(mttr_hours)) /\n NULLIF(\n SQRT(\n (COUNT(*) * SUM(adoption_pct * adoption_pct) - POW(SUM(adoption_pct), 2)) *\n (COUNT(*) * SUM(mttr_hours * mttr_hours) - POW(SUM(mttr_hours), 2))\n ),\n 0\n ),\n 2\n ) AS correlation_r\nFROM _joined","refId":"A"}],"title":"Adoption vs MTTR (r)","type":"stat"},{"datasource":"mysql","description":"Percentage change in MTTR between low adoption (<50%) and high adoption (\u226550%) periods. Negative values indicate faster recovery.","fieldConfig":{"defaults":{"color":{"mode":"thresholds"},"mappings":[{"options":{"match":"null","result":{"text":"N/A"}},"type":"special"}],"thresholds":{"mode":"absolute","steps":[{"color":"green","value":null},{"color":"yellow","value":-20},{"color":"red","value":0}]},"unit":"percent"},"overrides":[]},"gridPos":{"h":4,"w":6,"x":18,"y":68},"id":54,"options":{"colorMode":"value","graphMode":"none","justifyMode":"auto","orientation":"auto","reduceOptions":{"calcs":["lastNotNull"],"fields":"","values":false},"showPercentChange":false,"textMode":"auto","wideLayout":true},"pluginVersion":"11.0.0","targets":[{"datasource":"mysql","format":"table","rawQuery":true,"rawSql":"WITH _copilot_adoption AS (\n SELECT metric_date, ROUND(active_users * 100.0 / NULLIF(total_seats, 0), 1) AS adoption_pct\n FROM (\n SELECT day AS metric_date, SUM(daily_active_users) AS active_users,\n (SELECT COUNT(*) FROM _tool_copilot_seats s WHERE s.connection_id = ${connection_id}\n AND COALESCE(s.organization, '') = COALESCE((SELECT sc.organization FROM _tool_copilot_scopes sc\n WHERE sc.connection_id = ${connection_id} AND sc.id = '${scope_id}' LIMIT 1), '')) AS total_seats\n FROM _tool_copilot_enterprise_daily_metrics\n WHERE connection_id = ${connection_id} AND scope_id = '${scope_id}'\n GROUP BY day\n UNION ALL\n SELECT date AS metric_date, total_active_users AS active_users, seat_total AS total_seats\n FROM _tool_copilot_org_daily_metrics\n WHERE connection_id = ${connection_id} AND scope_id = '${scope_id}'\n ) _unified),\n_adoption_weekly AS (\n SELECT\n DATE_SUB(metric_date, INTERVAL WEEKDAY(metric_date) DAY) AS week_start,\n AVG(adoption_pct) AS adoption_pct\n FROM _copilot_adoption\n GROUP BY DATE_SUB(metric_date, INTERVAL WEEKDAY(metric_date) DAY)\n),\n_incidents_weekly AS (\n SELECT\n DATE_SUB(DATE(resolution_date), INTERVAL WEEKDAY(DATE(resolution_date)) DAY) AS week_start,\n AVG(TIMESTAMPDIFF(HOUR, created_date, resolution_date)) AS mttr_hours\n FROM issues\n WHERE type = 'INCIDENT'\n AND resolution_date IS NOT NULL\n AND $__timeFilter(resolution_date)\n GROUP BY DATE_SUB(DATE(resolution_date), INTERVAL WEEKDAY(DATE(resolution_date)) DAY)\n),\n_adoption_mttr AS (\n SELECT\n CASE WHEN aw.adoption_pct < 50 THEN 'low' ELSE 'high' END AS tier,\n iw.mttr_hours\n FROM _adoption_weekly aw\n INNER JOIN _incidents_weekly iw ON aw.week_start = iw.week_start\n),\n_tier_avg AS (\n SELECT\n tier,\n AVG(mttr_hours) AS avg_mttr\n FROM _adoption_mttr\n GROUP BY tier\n)\nSELECT\n ROUND(\n ((SELECT avg_mttr FROM _tier_avg WHERE tier = 'high') -\n (SELECT avg_mttr FROM _tier_avg WHERE tier = 'low')) /\n NULLIF((SELECT avg_mttr FROM _tier_avg WHERE tier = 'low'), 0) * 100,\n 1\n ) AS change_pct","refId":"A"}],"title":"MTTR Change (High vs Low Adoption)","type":"stat"},{"collapsed":false,"gridPos":{"h":1,"w":24,"x":0,"y":72},"id":500,"panels":[],"title":"Code Review Time Impact","type":"row"},{"datasource":"mysql","description":"Code review time (time from first review request to merge) grouped by GitHub Copilot adoption tier. Shorter review times at higher adoption may indicate Copilot-assisted code is easier to review.","fieldConfig":{"defaults":{"color":{"mode":"thresholds"},"mappings":[],"thresholds":{"mode":"absolute","steps":[{"color":"green","value":null},{"color":"yellow","value":24},{"color":"red","value":48}]},"unit":"h"},"overrides":[]},"gridPos":{"h":8,"w":6,"x":12,"y":73},"id":61,"options":{"displayMode":"gradient","maxVizHeight":300,"minVizHeight":10,"minVizWidth":0,"namePlacement":"auto","orientation":"horizontal","reduceOptions":{"calcs":["mean"],"fields":"","values":false},"showUnfilled":true,"sizing":"auto","valueMode":"color"},"transformations":[{"id":"rowsToFields","options":{"labelField":"Adoption Tier","valueField":"Review Time (hours)"}}],"pluginVersion":"11.0.0","targets":[{"datasource":"mysql","format":"table","rawQuery":true,"rawSql":"WITH _copilot_adoption AS (\n SELECT metric_date, ROUND(active_users * 100.0 / NULLIF(total_seats, 0), 1) AS adoption_pct\n FROM (\n SELECT day AS metric_date, SUM(daily_active_users) AS active_users,\n (SELECT COUNT(*) FROM _tool_copilot_seats s WHERE s.connection_id = ${connection_id}\n AND COALESCE(s.organization, '') = COALESCE((SELECT sc.organization FROM _tool_copilot_scopes sc\n WHERE sc.connection_id = ${connection_id} AND sc.id = '${scope_id}' LIMIT 1), '')) AS total_seats\n FROM _tool_copilot_enterprise_daily_metrics\n WHERE connection_id = ${connection_id} AND scope_id = '${scope_id}'\n GROUP BY day\n UNION ALL\n SELECT date AS metric_date, total_active_users AS active_users, seat_total AS total_seats\n FROM _tool_copilot_org_daily_metrics\n WHERE connection_id = ${connection_id} AND scope_id = '${scope_id}'\n ) _unified),\n_adoption_weekly AS (\n SELECT\n DATE_SUB(metric_date, INTERVAL WEEKDAY(metric_date) DAY) AS week_start,\n AVG(adoption_pct) AS adoption_pct,\n CASE\n WHEN AVG(adoption_pct) < 25 THEN '1: <25%'\n WHEN AVG(adoption_pct) < 50 THEN '2: 25-50%'\n WHEN AVG(adoption_pct) < 75 THEN '3: 50-75%'\n ELSE '4: >75%'\n END AS adoption_tier\n FROM _copilot_adoption\n GROUP BY DATE_SUB(metric_date, INTERVAL WEEKDAY(metric_date) DAY)\n),\n_pr_review_weekly AS (\n SELECT\n DATE_SUB(DATE(pr_merged_date), INTERVAL WEEKDAY(DATE(pr_merged_date)) DAY) AS week_start,\n AVG(pr_review_time) / 60.0 AS avg_review_time_hours\n FROM project_pr_metrics\n WHERE project_name = ${project:sqlstring}\n AND $__timeFilter(pr_merged_date)\n AND pr_review_time > 0\n GROUP BY DATE_SUB(DATE(pr_merged_date), INTERVAL WEEKDAY(DATE(pr_merged_date)) DAY)\n)\nSELECT\n aw.adoption_tier AS 'Adoption Tier',\n ROUND(AVG(prw.avg_review_time_hours), 1) AS 'Review Time (hours)'\nFROM _adoption_weekly aw\nLEFT JOIN _pr_review_weekly prw ON aw.week_start = prw.week_start\nWHERE prw.avg_review_time_hours IS NOT NULL\nGROUP BY aw.adoption_tier\nORDER BY aw.adoption_tier","refId":"A"}],"title":"Review Time by Adoption Tier","type":"bargauge"},{"datasource":"mysql","description":"Dual-axis chart showing GitHub Copilot adoption % and average code review time over time. Look for patterns indicating whether Copilot-assisted PRs get reviewed faster.","fieldConfig":{"defaults":{"color":{"mode":"palette-classic"},"custom":{"axisBorderShow":false,"axisCenteredZero":false,"axisColorMode":"text","axisLabel":"","axisPlacement":"auto","barAlignment":0,"drawStyle":"line","fillOpacity":10,"gradientMode":"none","hideFrom":{"legend":false,"tooltip":false,"viz":false},"insertNulls":false,"lineInterpolation":"smooth","lineWidth":2,"pointSize":5,"scaleDistribution":{"type":"linear"},"showPoints":"auto","spanNulls":false,"stacking":{"group":"A","mode":"none"},"thresholdsStyle":{"mode":"off"}},"mappings":[],"thresholds":{"mode":"absolute","steps":[{"color":"green","value":null}]}},"overrides":[{"matcher":{"id":"byName","options":"Adoption %"},"properties":[{"id":"custom.axisPlacement","value":"left"},{"id":"unit","value":"percent"},{"id":"color","value":{"fixedColor":"blue","mode":"fixed"}},{"id":"min","value":0},{"id":"max","value":100}]},{"matcher":{"id":"byName","options":"Review Time (h)"},"properties":[{"id":"custom.axisPlacement","value":"right"},{"id":"unit","value":"h"},{"id":"color","value":{"fixedColor":"green","mode":"fixed"}}]}]},"gridPos":{"h":8,"w":12,"x":0,"y":73},"id":62,"options":{"legend":{"calcs":["mean"],"displayMode":"table","placement":"bottom","showLegend":true},"tooltip":{"mode":"multi","sort":"none"}},"pluginVersion":"11.0.0","targets":[{"datasource":"mysql","format":"time_series","rawQuery":true,"rawSql":"WITH _copilot_adoption AS (\n SELECT metric_date, ROUND(active_users * 100.0 / NULLIF(total_seats, 0), 1) AS adoption_pct\n FROM (\n SELECT day AS metric_date, SUM(daily_active_users) AS active_users,\n (SELECT COUNT(*) FROM _tool_copilot_seats s WHERE s.connection_id = ${connection_id}\n AND COALESCE(s.organization, '') = COALESCE((SELECT sc.organization FROM _tool_copilot_scopes sc\n WHERE sc.connection_id = ${connection_id} AND sc.id = '${scope_id}' LIMIT 1), '')) AS total_seats\n FROM _tool_copilot_enterprise_daily_metrics\n WHERE connection_id = ${connection_id} AND scope_id = '${scope_id}' AND $__timeFilter(day)\n GROUP BY day\n UNION ALL\n SELECT date AS metric_date, total_active_users AS active_users, seat_total AS total_seats\n FROM _tool_copilot_org_daily_metrics\n WHERE connection_id = ${connection_id} AND scope_id = '${scope_id}' AND $__timeFilter(date)\n ) _unified),\n_adoption_weekly AS (\n SELECT\n DATE_SUB(metric_date, INTERVAL WEEKDAY(metric_date) DAY) AS week_start,\n AVG(adoption_pct) AS adoption_pct\n FROM _copilot_adoption\n GROUP BY DATE_SUB(metric_date, INTERVAL WEEKDAY(metric_date) DAY)\n),\n_pr_review_weekly AS (\n SELECT\n DATE_SUB(DATE(pr_merged_date), INTERVAL WEEKDAY(DATE(pr_merged_date)) DAY) AS week_start,\n AVG(pr_review_time) / 60.0 AS avg_review_time_hours\n FROM project_pr_metrics\n WHERE project_name = ${project:sqlstring}\n AND $__timeFilter(pr_merged_date)\n AND pr_review_time > 0\n GROUP BY DATE_SUB(DATE(pr_merged_date), INTERVAL WEEKDAY(DATE(pr_merged_date)) DAY)\n)\nSELECT\n UNIX_TIMESTAMP(aw.week_start) AS time_sec,\n aw.adoption_pct AS 'Adoption %',\n ROUND(prw.avg_review_time_hours, 1) AS 'Review Time (h)'\nFROM _adoption_weekly aw\nLEFT JOIN _pr_review_weekly prw ON aw.week_start = prw.week_start\nORDER BY aw.week_start","refId":"A"}],"title":"Adoption vs Review Time Trend","type":"timeseries"},{"datasource":"mysql","description":"Percentage change in code review time between low adoption (<50%) and high adoption (\u226550%) periods. Negative values indicate faster reviews.","fieldConfig":{"defaults":{"color":{"mode":"thresholds"},"mappings":[{"options":{"match":"null","result":{"text":"N/A"}},"type":"special"}],"thresholds":{"mode":"absolute","steps":[{"color":"green","value":null},{"color":"yellow","value":-10},{"color":"red","value":0}]},"unit":"percent"},"overrides":[]},"gridPos":{"h":4,"w":6,"x":18,"y":77},"id":63,"options":{"colorMode":"value","graphMode":"none","justifyMode":"auto","orientation":"auto","reduceOptions":{"calcs":["lastNotNull"],"fields":"","values":false},"showPercentChange":false,"textMode":"auto","wideLayout":true},"pluginVersion":"11.0.0","targets":[{"datasource":"mysql","format":"table","rawQuery":true,"rawSql":"WITH _copilot_adoption AS (\n SELECT metric_date, ROUND(active_users * 100.0 / NULLIF(total_seats, 0), 1) AS adoption_pct\n FROM (\n SELECT day AS metric_date, SUM(daily_active_users) AS active_users,\n (SELECT COUNT(*) FROM _tool_copilot_seats s WHERE s.connection_id = ${connection_id}\n AND COALESCE(s.organization, '') = COALESCE((SELECT sc.organization FROM _tool_copilot_scopes sc\n WHERE sc.connection_id = ${connection_id} AND sc.id = '${scope_id}' LIMIT 1), '')) AS total_seats\n FROM _tool_copilot_enterprise_daily_metrics\n WHERE connection_id = ${connection_id} AND scope_id = '${scope_id}'\n GROUP BY day\n UNION ALL\n SELECT date AS metric_date, total_active_users AS active_users, seat_total AS total_seats\n FROM _tool_copilot_org_daily_metrics\n WHERE connection_id = ${connection_id} AND scope_id = '${scope_id}'\n ) _unified),\n_adoption_weekly AS (\n SELECT\n DATE_SUB(metric_date, INTERVAL WEEKDAY(metric_date) DAY) AS week_start,\n AVG(adoption_pct) AS adoption_pct\n FROM _copilot_adoption\n GROUP BY DATE_SUB(metric_date, INTERVAL WEEKDAY(metric_date) DAY)\n),\n_pr_review_weekly AS (\n SELECT\n DATE_SUB(DATE(pr_merged_date), INTERVAL WEEKDAY(DATE(pr_merged_date)) DAY) AS week_start,\n AVG(pr_review_time) / 60.0 AS avg_review_time_hours\n FROM project_pr_metrics\n WHERE project_name = ${project:sqlstring}\n AND $__timeFilter(pr_merged_date)\n AND pr_review_time > 0\n GROUP BY DATE_SUB(DATE(pr_merged_date), INTERVAL WEEKDAY(DATE(pr_merged_date)) DAY)\n),\n_adoption_review AS (\n SELECT\n CASE WHEN aw.adoption_pct < 50 THEN 'low' ELSE 'high' END AS tier,\n prw.avg_review_time_hours\n FROM _adoption_weekly aw\n INNER JOIN _pr_review_weekly prw ON aw.week_start = prw.week_start\n),\n_tier_avg AS (\n SELECT\n tier,\n AVG(avg_review_time_hours) AS avg_review\n FROM _adoption_review\n GROUP BY tier\n)\nSELECT\n ROUND(\n ((SELECT avg_review FROM _tier_avg WHERE tier = 'high') -\n (SELECT avg_review FROM _tier_avg WHERE tier = 'low')) /\n NULLIF((SELECT avg_review FROM _tier_avg WHERE tier = 'low'), 0) * 100,\n 1\n ) AS change_pct","refId":"A"}],"title":"Review Time Change (High vs Low Adoption)","type":"stat"},{"datasource":"mysql","description":"Pearson correlation coefficient (r) between GitHub Copilot adoption % and code review time. NEGATIVE values indicate higher adoption correlates with faster reviews.","fieldConfig":{"defaults":{"color":{"mode":"thresholds"},"mappings":[{"options":{"match":"null","result":{"text":"N/A"}},"type":"special"}],"thresholds":{"mode":"absolute","steps":[{"color":"green","value":null},{"color":"yellow","value":-0.3},{"color":"red","value":0}]},"unit":"none"},"overrides":[]},"gridPos":{"h":4,"w":6,"x":18,"y":73},"id":64,"options":{"colorMode":"value","graphMode":"none","justifyMode":"auto","orientation":"auto","reduceOptions":{"calcs":["lastNotNull"],"fields":"","values":false},"showPercentChange":false,"textMode":"auto","wideLayout":true},"pluginVersion":"11.0.0","targets":[{"datasource":"mysql","format":"table","rawQuery":true,"rawSql":"WITH _copilot_adoption AS (\n SELECT metric_date, ROUND(active_users * 100.0 / NULLIF(total_seats, 0), 1) AS adoption_pct\n FROM (\n SELECT day AS metric_date, SUM(daily_active_users) AS active_users,\n (SELECT COUNT(*) FROM _tool_copilot_seats s WHERE s.connection_id = ${connection_id}\n AND COALESCE(s.organization, '') = COALESCE((SELECT sc.organization FROM _tool_copilot_scopes sc\n WHERE sc.connection_id = ${connection_id} AND sc.id = '${scope_id}' LIMIT 1), '')) AS total_seats\n FROM _tool_copilot_enterprise_daily_metrics\n WHERE connection_id = ${connection_id} AND scope_id = '${scope_id}' AND $__timeFilter(day)\n GROUP BY day\n UNION ALL\n SELECT date AS metric_date, total_active_users AS active_users, seat_total AS total_seats\n FROM _tool_copilot_org_daily_metrics\n WHERE connection_id = ${connection_id} AND scope_id = '${scope_id}' AND $__timeFilter(date)\n ) _unified),\n_adoption_weekly AS (\n SELECT\n DATE_SUB(metric_date, INTERVAL WEEKDAY(metric_date) DAY) AS week_start,\n AVG(adoption_pct) AS adoption_pct\n FROM _copilot_adoption\n GROUP BY DATE_SUB(metric_date, INTERVAL WEEKDAY(metric_date) DAY)\n),\n_pr_review_weekly AS (\n SELECT\n DATE_SUB(DATE(pr_merged_date), INTERVAL WEEKDAY(DATE(pr_merged_date)) DAY) AS week_start,\n AVG(pr_review_time) / 60.0 AS avg_review_time_hours\n FROM project_pr_metrics\n WHERE project_name = ${project:sqlstring}\n AND $__timeFilter(pr_merged_date)\n AND pr_review_time > 0\n GROUP BY DATE_SUB(DATE(pr_merged_date), INTERVAL WEEKDAY(DATE(pr_merged_date)) DAY)\n),\n_joined AS (\n SELECT\n aw.adoption_pct,\n prw.avg_review_time_hours\n FROM _adoption_weekly aw\n INNER JOIN _pr_review_weekly prw ON aw.week_start = prw.week_start\n)\nSELECT\n ROUND(\n (COUNT(*) * SUM(adoption_pct * avg_review_time_hours) - SUM(adoption_pct) * SUM(avg_review_time_hours)) /\n NULLIF(\n SQRT(\n (COUNT(*) * SUM(adoption_pct * adoption_pct) - POW(SUM(adoption_pct), 2)) *\n (COUNT(*) * SUM(avg_review_time_hours * avg_review_time_hours) - POW(SUM(avg_review_time_hours), 2))\n ),\n 0\n ),\n 2\n ) AS correlation_r\nFROM _joined","refId":"A"}],"title":"Adoption vs Review Time (r)","type":"stat"},{"collapsed":false,"gridPos":{"h":1,"w":24,"x":0,"y":81},"id":600,"panels":[],"title":"Code Quality Impact (Optional - Requires SonarQube)","type":"row"},{"datasource":"mysql","description":"Bug count per file by GitHub Copilot adoption tier. Lower is better. Requires SonarQube integration.","fieldConfig":{"defaults":{"color":{"mode":"thresholds"},"mappings":[],"thresholds":{"mode":"absolute","steps":[{"color":"green","value":null},{"color":"yellow","value":1},{"color":"red","value":5}]}},"overrides":[]},"gridPos":{"h":6,"w":6,"x":0,"y":82},"id":80,"options":{"displayMode":"gradient","minVizHeight":10,"minVizWidth":0,"namePlacement":"auto","orientation":"horizontal","reduceOptions":{"calcs":["lastNotNull"],"fields":"","values":false},"showUnfilled":true,"sizing":"auto","valueMode":"color"},"pluginVersion":"11.0.0","targets":[{"datasource":"mysql","format":"table","rawQuery":true,"rawSql":"WITH _copilot_adoption AS (\n SELECT metric_date, ROUND(active_users * 100.0 / NULLIF(total_seats, 0), 1) AS adoption_pct\n FROM (\n SELECT day AS metric_date, SUM(daily_active_users) AS active_users,\n (SELECT COUNT(*) FROM _tool_copilot_seats s WHERE s.connection_id = ${connection_id}\n AND COALESCE(s.organization, '') = COALESCE((SELECT sc.organization FROM _tool_copilot_scopes sc\n WHERE sc.connection_id = ${connection_id} AND sc.id = '${scope_id}' LIMIT 1), '')) AS total_seats\n FROM _tool_copilot_enterprise_daily_metrics\n WHERE connection_id = ${connection_id} AND scope_id = '${scope_id}'\n GROUP BY day\n UNION ALL\n SELECT date AS metric_date, total_active_users AS active_users, seat_total AS total_seats\n FROM _tool_copilot_org_daily_metrics\n WHERE connection_id = ${connection_id} AND scope_id = '${scope_id}'\n ) _unified),\n_adoption_weekly AS (\n SELECT\n DATE_SUB(metric_date, INTERVAL WEEKDAY(metric_date) DAY) AS week_start,\n AVG(adoption_pct) AS adoption_pct\n FROM _copilot_adoption\n GROUP BY DATE_SUB(metric_date, INTERVAL WEEKDAY(metric_date) DAY)\n),\n_adoption_tiers AS (\n SELECT\n week_start,\n CASE\n WHEN adoption_pct < 25 THEN '<25%'\n WHEN adoption_pct < 50 THEN '25-50%'\n WHEN adoption_pct < 75 THEN '50-75%'\n ELSE '>75%'\n END AS tier\n FROM _adoption_weekly\n)\nSELECT 'N/A - Configure SonarQube' AS Tier, 0 AS 'Bugs/File'\nFROM (SELECT 1) AS d\nWHERE NOT EXISTS (SELECT 1 FROM cq_file_metrics LIMIT 1)","refId":"A"}],"title":"Bugs per File by Adoption","type":"bargauge"},{"datasource":"mysql","description":"Code smell count per file by GitHub Copilot adoption tier. Lower is better. Requires SonarQube integration.","fieldConfig":{"defaults":{"color":{"mode":"thresholds"},"mappings":[],"thresholds":{"mode":"absolute","steps":[{"color":"green","value":null},{"color":"yellow","value":5},{"color":"red","value":20}]}},"overrides":[]},"gridPos":{"h":6,"w":6,"x":6,"y":82},"id":81,"options":{"displayMode":"gradient","minVizHeight":10,"minVizWidth":0,"namePlacement":"auto","orientation":"horizontal","reduceOptions":{"calcs":["lastNotNull"],"fields":"","values":false},"showUnfilled":true,"sizing":"auto","valueMode":"color"},"pluginVersion":"11.0.0","targets":[{"datasource":"mysql","format":"table","rawQuery":true,"rawSql":"SELECT 'N/A - Configure SonarQube' AS Tier, 0 AS 'Code Smells/File'\nFROM (SELECT 1) AS d\nWHERE NOT EXISTS (SELECT 1 FROM cq_file_metrics LIMIT 1)","refId":"A"}],"title":"Code Smells by Adoption","type":"bargauge"},{"datasource":"mysql","description":"Code complexity by GitHub Copilot adoption tier. Lower is better. Requires SonarQube integration.","fieldConfig":{"defaults":{"color":{"mode":"thresholds"},"mappings":[],"thresholds":{"mode":"absolute","steps":[{"color":"green","value":null},{"color":"yellow","value":10},{"color":"red","value":25}]}},"overrides":[]},"gridPos":{"h":6,"w":6,"x":12,"y":82},"id":82,"options":{"displayMode":"gradient","minVizHeight":10,"minVizWidth":0,"namePlacement":"auto","orientation":"horizontal","reduceOptions":{"calcs":["lastNotNull"],"fields":"","values":false},"showUnfilled":true,"sizing":"auto","valueMode":"color"},"pluginVersion":"11.0.0","targets":[{"datasource":"mysql","format":"table","rawQuery":true,"rawSql":"SELECT 'N/A - Configure SonarQube' AS Tier, 0 AS Complexity\nFROM (SELECT 1) AS d\nWHERE NOT EXISTS (SELECT 1 FROM cq_file_metrics LIMIT 1)","refId":"A"}],"title":"Complexity by Adoption","type":"bargauge"},{"datasource":"mysql","description":"Code coverage percentage by GitHub Copilot adoption tier. Higher is better. Requires SonarQube integration.","fieldConfig":{"defaults":{"color":{"mode":"thresholds"},"mappings":[],"max":100,"min":0,"thresholds":{"mode":"absolute","steps":[{"color":"red","value":null},{"color":"yellow","value":50},{"color":"green","value":80}]},"unit":"percent"},"overrides":[]},"gridPos":{"h":6,"w":6,"x":18,"y":82},"id":83,"options":{"displayMode":"gradient","minVizHeight":10,"minVizWidth":0,"namePlacement":"auto","orientation":"horizontal","reduceOptions":{"calcs":["lastNotNull"],"fields":"","values":false},"showUnfilled":true,"sizing":"auto","valueMode":"color"},"pluginVersion":"11.0.0","targets":[{"datasource":"mysql","format":"table","rawQuery":true,"rawSql":"SELECT 'N/A - Configure SonarQube' AS Tier, 0 AS Coverage\nFROM (SELECT 1) AS d\nWHERE NOT EXISTS (SELECT 1 FROM cq_file_metrics LIMIT 1)","refId":"A"}],"title":"Coverage by Adoption","type":"bargauge"}],"refresh":"","schemaVersion":38,"tags":["copilot","devlake","impact","dora"],"templating":{"list":[{"current":{"selected":false,"text":"","value":""},"datasource":"mysql","definition":"SELECT DISTINCT connection_id FROM _tool_copilot_scopes ORDER BY 1","hide":0,"includeAll":false,"label":"Connection ID","multi":false,"name":"connection_id","options":[],"query":"SELECT DISTINCT connection_id FROM _tool_copilot_scopes ORDER BY connection_id DESC","refresh":1,"regex":"","skipUrlSync":false,"sort":0,"type":"query"},{"current":{"selected":false,"text":"","value":""},"datasource":"mysql","definition":"SELECT DISTINCT id FROM _tool_copilot_scopes WHERE connection_id = CAST('${connection_id}' AS UNSIGNED) ORDER BY 1","hide":0,"includeAll":false,"label":"Scope ID (Organization)","multi":false,"name":"scope_id","options":[],"query":"SELECT DISTINCT id FROM _tool_copilot_scopes WHERE connection_id = CAST('${connection_id}' AS UNSIGNED) ORDER BY 1","refresh":2,"regex":"","skipUrlSync":false,"sort":0,"type":"query"},{"current":{"selected":false,"text":"","value":""},"datasource":"mysql","definition":"SELECT DISTINCT project_name FROM project_pr_metrics ORDER BY 1","hide":0,"includeAll":false,"label":"Project","multi":false,"name":"project","options":[],"query":"SELECT DISTINCT project_name FROM project_pr_metrics ORDER BY 1","refresh":1,"regex":"","skipUrlSync":false,"sort":1,"type":"query"},{"current":{"selected":false,"text":"2023","value":"2023"},"hide":0,"includeAll":false,"label":"DORA Report","multi":false,"name":"dora_report","options":[{"selected":false,"text":"2021","value":"2021"},{"selected":true,"text":"2023","value":"2023"}],"query":"2021, 2023","skipUrlSync":false,"type":"custom"}]},"time":{"from":"now-90d","to":"now"},"timepicker":{},"timezone":"utc","title":"GitHub Copilot + DORA Correlation","uid":"copilot_impact","version":1,"weekStart":""} \ No newline at end of file diff --git a/grafana/dashboards/GithubCopilotREADME.md b/grafana/dashboards/GithubCopilotREADME.md new file mode 100644 index 00000000000..77a164e825a --- /dev/null +++ b/grafana/dashboards/GithubCopilotREADME.md @@ -0,0 +1,111 @@ +# GitHub Copilot Dashboards + +Grafana dashboards for analyzing GitHub Copilot usage and its correlation with developer productivity metrics. + +## Dashboards + +### 1. GitHub Copilot Adoption Dashboard (`GithubCopilotAdoption.json`) + +**UID**: `copilot_adoption` + +Tracks GitHub Copilot usage metrics across your organization: +- Active users and seat utilization +- Language breakdown of GitHub Copilot activity +- IDE distribution (VS Code, JetBrains, Neovim, etc.) +- Acceptance rates for code suggestions +- Chat and PR summary feature usage + +### 2. GitHub Copilot Impact Dashboard (`GithubCopilotImpact.json`) + +**UID**: `copilot_impact` + +Correlates GitHub Copilot adoption with DORA metrics and engineering productivity: + +#### Key Features + +- **Correlation-First Analysis**: No implementation date required! Dashboard automatically correlates GitHub Copilot adoption intensity with productivity metrics. +- **Adoption Tier Comparison**: Groups metrics by adoption level (<25%, 25-50%, 50-75%, >75%) +- **Dual-Axis Charts**: See GitHub Copilot adoption trends alongside each DORA metric +- **Pearson Correlation Coefficients**: Statistical correlation (r) values for each metric pair +- **Optional Rollout Milestone**: Annotate specific dates when GitHub Copilot was rolled out to different teams + +#### Panels by Section + +| Section | Metrics Tracked | +|---------|-----------------| +| Correlation Overview | Adoption trend, aggregate correlation, current adoption % | +| PR Velocity Impact | PR cycle time, coding time, pickup time, review time, PR throughput | +| Deployment Frequency | Deploys per week, correlation with adoption | +| Change Failure Rate | CFR %, correlation (negative r = improvement) | +| Recovery Time (MTTR) | Mean time to recovery, adoption tier comparison | +| Code Review Time | Review time by adoption tier, trend analysis | +| Code Quality | Optional (requires SonarQube): complexity, coverage, duplicates | + +#### Correlation Interpretation + +The dashboard uses Pearson correlation coefficients (r): + +| Value | Interpretation | +|-------|----------------| +| r > 0.7 | Strong positive correlation | +| 0.3 < r < 0.7 | Moderate correlation | +| r < 0.3 | Weak correlation | +| r < 0 | Negative correlation (for CFR/MTTR, negative = improvement) | + +**Note**: For failure-related metrics (CFR, MTTR, Review Time), **negative** correlations are desirable - they indicate that higher GitHub Copilot adoption correlates with fewer failures or faster resolution. + +## Prerequisites + +These dashboards require: + +1. **GitHub Copilot Plugin** (`gh-copilot`) - Configured and collecting data +2. **GitHub Plugin** - For PR metrics (`project_pr_metrics` table) +3. **DORA Metrics** - Deployments and incidents data from your CI/CD tools + +## Configuration + +### Variables + +Both dashboards use these template variables: + +| Variable | Description | +|----------|-------------| +| `connection_id` | DevLake GitHub Copilot connection | +| `scope_id` | Enterprise/Organization scope | +| `project` | DevLake project filter | + +### Optional: Rollout Milestone + +To add an implementation date annotation: + +1. Go to **Connections** > **GitHub Copilot** > Edit Scope +2. Set **Implementation Date** (when GitHub Copilot was rolled out) +3. Set **Baseline Period** (days to use for "before" comparison) + +The dashboard works fully without these settings—correlation analysis doesn't require an implementation date. + +## Data Model + +The Impact Dashboard joins GitHub Copilot metrics with DORA data: + +```sql +-- Weekly aggregation pattern +_copilot_adoption → _adoption_weekly (by week_start) + → JOIN with metric CTEs (pr_metrics, deployments, incidents) +``` + +Key tables used: +- `_tool_copilot_org_metrics` - Daily GitHub Copilot usage +- `project_pr_metrics` - PR cycle time, review time +- `cicd_deployment_commits` - Deployment frequency, CFR +- `issues` (type='INCIDENT') - MTTR calculation +- `cq_file_metrics` - Code quality (SonarQube) + +## Troubleshooting + +| Issue | Solution | +|-------|----------| +| "No data" on panels | Check that time range includes GitHub Copilot data; verify `scope_id` variable | +| Correlation shows "N/A" | Need at least 2 weeks of overlapping data | +| CFR/MTTR empty | Ensure incidents are collected (Jira/GitHub issues with 'incident' label) | +| Code Quality empty | Configure SonarQube integration | From 9f433bdc9f55fefa681e9da77c3929ff689e3de4 Mon Sep 17 00:00:00 2001 From: ewega Date: Tue, 24 Feb 2026 00:53:59 +0300 Subject: [PATCH 4/4] fix: gh-copilot CI fixes --- backend/plugins/gh-copilot/README.md | 16 +++ .../raw_tables/_raw_copilot_metrics.csv | 4 +- ..._tool_copilot_enterprise_daily_metrics.csv | 3 + ...ol_copilot_metrics_by_language_feature.csv | 5 + .../plugins/gh-copilot/e2e/metrics_test.go | 12 +- .../migrationscripts/20250100_initialize.go | 115 +++++++++++++++++- ...add_raw_data_origin_to_language_metrics.go | 26 +++- .../20260105_add_raw_data_origin_to_seats.go | 26 +++- .../20260116_add_name_fields_to_scopes.go | 23 +++- .../20260121_add_scope_configs.go | 7 +- ...212_add_pr_fields_to_enterprise_metrics.go | 70 +++++------ .../20260212_v2_usage_metrics.go | 64 +++++----- .../tasks/enterprise_metrics_collector.go | 9 +- .../tasks/enterprise_metrics_extractor.go | 61 ++++------ .../gh-copilot/tasks/org_metrics_collector.go | 4 +- .../tasks/report_download_helper.go | 40 ------ .../tasks/user_metrics_collector.go | 4 +- .../tasks/user_metrics_extractor.go | 40 +++--- config-ui/src/hooks/use-refresh-data.ts | 4 +- .../components/scope-config-form/index.tsx | 14 ++- .../register/argocd/transformation.tsx | 6 +- .../connection-fields/aws-credentials.tsx | 29 +++-- .../connection-fields/connection-test.tsx | 25 ++-- .../src/plugins/register/q-dev/data-scope.tsx | 24 ++-- .../connection-fields/organization.tsx | 2 +- .../src/plugins/register/testmo/config.tsx | 6 +- .../webhook/components/create-dialog.tsx | 8 +- .../register/webhook/components/utils.ts | 20 +-- config-ui/src/plugins/utils.ts | 4 +- config-ui/src/release/stable.ts | 3 +- .../blueprint/detail/blueprint-detail.tsx | 7 +- .../src/routes/connection/connection.tsx | 27 ++-- grafana/dashboards/GithubCopilotREADME.md | 16 +++ 33 files changed, 453 insertions(+), 271 deletions(-) create mode 100644 backend/plugins/gh-copilot/e2e/metrics/snapshot_tables/_tool_copilot_enterprise_daily_metrics.csv create mode 100644 backend/plugins/gh-copilot/e2e/metrics/snapshot_tables/_tool_copilot_metrics_by_language_feature.csv diff --git a/backend/plugins/gh-copilot/README.md b/backend/plugins/gh-copilot/README.md index 644f1833d6b..1c4aeda2481 100644 --- a/backend/plugins/gh-copilot/README.md +++ b/backend/plugins/gh-copilot/README.md @@ -1,3 +1,19 @@ + # GitHub Copilot Plugin (Adoption Metrics) This plugin ingests GitHub Copilot **organization-level adoption metrics** (daily usage and seat assignments) and provides a Grafana dashboard for adoption trends. diff --git a/backend/plugins/gh-copilot/e2e/metrics/raw_tables/_raw_copilot_metrics.csv b/backend/plugins/gh-copilot/e2e/metrics/raw_tables/_raw_copilot_metrics.csv index 203eb7ae4ea..7bde72fcc49 100644 --- a/backend/plugins/gh-copilot/e2e/metrics/raw_tables/_raw_copilot_metrics.csv +++ b/backend/plugins/gh-copilot/e2e/metrics/raw_tables/_raw_copilot_metrics.csv @@ -1,3 +1,3 @@ id,params,data,url,input,created_at -1,"{""ConnectionId"":1,""ScopeId"":""octodemo"",""Organization"":""octodemo"",""Endpoint"":""https://api.github.com""}","{""date"":""2025-09-01"",""total_active_users"":10,""total_engaged_users"":8,""copilot_ide_code_completions"":{""total_engaged_users"":8,""editors"": [{""name"":""vscode"",""total_engaged_users"":6,""models"": [{""name"":""default"",""is_custom_model"":false,""total_engaged_users"":6,""languages"": [{""name"":""python"",""total_engaged_users"":6,""total_code_suggestions"":100,""total_code_acceptances"":20,""total_code_lines_suggested"":300,""total_code_lines_accepted"":60}]}]},{""name"":""jetbrains"",""total_engaged_users"":4,""models"": [{""name"":""default"",""is_custom_model"":false,""total_engaged_users"":4,""languages"": [{""name"":""ruby"",""total_engaged_users"":4,""total_code_suggestions"":50,""total_code_acceptances"":10,""total_code_lines_suggested"":120,""total_code_lines_accepted"":30}]}]}]},""copilot_ide_chat"":{""total_engaged_users"":3,""editors"": [{""name"":""vscode"",""total_engaged_users"":3,""models"": [{""name"":""default"",""is_custom_model"":false,""total_engaged_users"":3,""total_chats"":5,""total_chat_copy_events"":2,""total_chat_insertion_events"":1},{""name"":""default"",""is_custom_model"":false,""total_engaged_users"":3,""total_chats"":3,""total_chat_copy_events"":1,""total_chat_insertion_events"":0}]}]},""copilot_dotcom_chat"":{""total_engaged_users"":2,""models"": [{""name"":""default"",""is_custom_model"":false,""total_engaged_users"":2,""total_chats"":4},{""name"":""default"",""is_custom_model"":false,""total_engaged_users"":1,""total_chats"":1}]}}",https://api.github.com/orgs/octodemo/copilot/metrics,null,2025-09-03 00:00:00.000 -2,"{""ConnectionId"":1,""ScopeId"":""octodemo"",""Organization"":""octodemo"",""Endpoint"":""https://api.github.com""}","{""date"":""2025-09-02"",""total_active_users"":12,""total_engaged_users"":9,""copilot_ide_code_completions"":{""total_engaged_users"":9,""editors"": [{""name"":""vscode"",""total_engaged_users"":8,""models"": [{""name"":""default"",""is_custom_model"":false,""total_engaged_users"":8,""languages"": [{""name"":""python"",""total_engaged_users"":8,""total_code_suggestions"":180,""total_code_acceptances"":36,""total_code_lines_suggested"":450,""total_code_lines_accepted"":180}]}]},{""name"":""jetbrains"",""total_engaged_users"":2,""models"": [{""name"":""default"",""is_custom_model"":false,""total_engaged_users"":2,""languages"": [{""name"":""go"",""total_engaged_users"":2,""total_code_suggestions"":20,""total_code_acceptances"":4,""total_code_lines_suggested"":50,""total_code_lines_accepted"":20}]}]}]},""copilot_ide_chat"":{""total_engaged_users"":4,""editors"": [{""name"":""vscode"",""total_engaged_users"":4,""models"": [{""name"":""default"",""is_custom_model"":false,""total_engaged_users"":4,""total_chats"":10,""total_chat_copy_events"":4,""total_chat_insertion_events"":2}]}]},""copilot_dotcom_chat"":{""total_engaged_users"":1,""models"": [{""name"":""default"",""is_custom_model"":false,""total_engaged_users"":1,""total_chats"":2}]}}",https://api.github.com/orgs/octodemo/copilot/metrics,null,2025-09-03 00:00:00.000 +1,"{""ConnectionId"":1,""ScopeId"":""octodemo"",""Organization"":""octodemo"",""Endpoint"":""https://api.github.com""}","{""day"":""2025-09-01"",""daily_active_users"":10,""totals_by_language_feature"":[{""language"":""python"",""feature"":""code"",""code_generation_activity_count"":100,""code_acceptance_activity_count"":20,""loc_suggested_to_add_sum"":300,""loc_suggested_to_delete_sum"":0,""loc_added_sum"":60,""loc_deleted_sum"":0},{""language"":""ruby"",""feature"":""code"",""code_generation_activity_count"":50,""code_acceptance_activity_count"":10,""loc_suggested_to_add_sum"":120,""loc_suggested_to_delete_sum"":0,""loc_added_sum"":30,""loc_deleted_sum"":0}]}",https://api.github.com/orgs/octodemo/copilot/metrics/reports/organization-1-day?day=2025-09-01,null,2025-09-03 00:00:00.000 +2,"{""ConnectionId"":1,""ScopeId"":""octodemo"",""Organization"":""octodemo"",""Endpoint"":""https://api.github.com""}","{""day"":""2025-09-02"",""daily_active_users"":12,""totals_by_language_feature"":[{""language"":""python"",""feature"":""code"",""code_generation_activity_count"":180,""code_acceptance_activity_count"":36,""loc_suggested_to_add_sum"":450,""loc_suggested_to_delete_sum"":0,""loc_added_sum"":180,""loc_deleted_sum"":0},{""language"":""go"",""feature"":""code"",""code_generation_activity_count"":20,""code_acceptance_activity_count"":4,""loc_suggested_to_add_sum"":50,""loc_suggested_to_delete_sum"":0,""loc_added_sum"":20,""loc_deleted_sum"":0}]}",https://api.github.com/orgs/octodemo/copilot/metrics/reports/organization-1-day?day=2025-09-02,null,2025-09-03 00:00:00.000 diff --git a/backend/plugins/gh-copilot/e2e/metrics/snapshot_tables/_tool_copilot_enterprise_daily_metrics.csv b/backend/plugins/gh-copilot/e2e/metrics/snapshot_tables/_tool_copilot_enterprise_daily_metrics.csv new file mode 100644 index 00000000000..57e3f362712 --- /dev/null +++ b/backend/plugins/gh-copilot/e2e/metrics/snapshot_tables/_tool_copilot_enterprise_daily_metrics.csv @@ -0,0 +1,3 @@ +connection_id,scope_id,day,enterprise_id,daily_active_users,weekly_active_users,monthly_active_users,monthly_active_chat_users,monthly_active_agent_users,pr_total_reviewed,pr_total_created,pr_total_created_by_copilot,pr_total_reviewed_by_copilot,user_initiated_interaction_count,code_generation_activity_count,code_acceptance_activity_count,loc_suggested_to_add_sum,loc_suggested_to_delete_sum,loc_added_sum,loc_deleted_sum +1,octodemo,2025-09-01T00:00:00.000+00:00,,10,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 +1,octodemo,2025-09-02T00:00:00.000+00:00,,12,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 diff --git a/backend/plugins/gh-copilot/e2e/metrics/snapshot_tables/_tool_copilot_metrics_by_language_feature.csv b/backend/plugins/gh-copilot/e2e/metrics/snapshot_tables/_tool_copilot_metrics_by_language_feature.csv new file mode 100644 index 00000000000..8cf3cd6e045 --- /dev/null +++ b/backend/plugins/gh-copilot/e2e/metrics/snapshot_tables/_tool_copilot_metrics_by_language_feature.csv @@ -0,0 +1,5 @@ +connection_id,scope_id,day,language,feature,code_generation_activity_count,code_acceptance_activity_count,loc_suggested_to_add_sum,loc_suggested_to_delete_sum,loc_added_sum,loc_deleted_sum +1,octodemo,2025-09-01T00:00:00.000+00:00,python,code,100,20,300,0,60,0 +1,octodemo,2025-09-01T00:00:00.000+00:00,ruby,code,50,10,120,0,30,0 +1,octodemo,2025-09-02T00:00:00.000+00:00,python,code,180,36,450,0,180,0 +1,octodemo,2025-09-02T00:00:00.000+00:00,go,code,20,4,50,0,20,0 diff --git a/backend/plugins/gh-copilot/e2e/metrics_test.go b/backend/plugins/gh-copilot/e2e/metrics_test.go index cf699ab64be..2f339f95d16 100644 --- a/backend/plugins/gh-copilot/e2e/metrics_test.go +++ b/backend/plugins/gh-copilot/e2e/metrics_test.go @@ -61,15 +61,15 @@ func TestCopilotMetricsDataFlow(t *testing.T) { dataflowTester.ImportCsvIntoRawTable("./metrics/raw_tables/_raw_copilot_metrics.csv", "_raw_copilot_org_metrics") dataflowTester.ImportCsvIntoRawTable("./metrics/raw_tables/_raw_copilot_seats.csv", "_raw_copilot_seats") - dataflowTester.FlushTabler(&models.GhCopilotOrgMetrics{}) - dataflowTester.FlushTabler(&models.GhCopilotLanguageMetrics{}) dataflowTester.FlushTabler(&models.GhCopilotSeat{}) + dataflowTester.FlushTabler(&models.GhCopilotEnterpriseDailyMetrics{}) + dataflowTester.FlushTabler(&models.GhCopilotMetricsByLanguageFeature{}) dataflowTester.Subtask(tasks.ExtractSeatsMeta, taskData) dataflowTester.Subtask(tasks.ExtractOrgMetricsMeta, taskData) - dataflowTester.VerifyTableWithOptions(&models.GhCopilotOrgMetrics{}, e2ehelper.TableOptions{ - CSVRelPath: "./metrics/snapshot_tables/_tool_copilot_org_daily_metrics.csv", + dataflowTester.VerifyTableWithOptions(&models.GhCopilotEnterpriseDailyMetrics{}, e2ehelper.TableOptions{ + CSVRelPath: "./metrics/snapshot_tables/_tool_copilot_enterprise_daily_metrics.csv", IgnoreTypes: []interface{}{common.NoPKModel{}}, }) @@ -80,8 +80,8 @@ func TestCopilotMetricsDataFlow(t *testing.T) { }, }) - dataflowTester.VerifyTableWithOptions(&models.GhCopilotLanguageMetrics{}, e2ehelper.TableOptions{ - CSVRelPath: "./metrics/language_breakdown.csv", + dataflowTester.VerifyTableWithOptions(&models.GhCopilotMetricsByLanguageFeature{}, e2ehelper.TableOptions{ + CSVRelPath: "./metrics/snapshot_tables/_tool_copilot_metrics_by_language_feature.csv", IgnoreTypes: []interface{}{common.NoPKModel{}}, }) } diff --git a/backend/plugins/gh-copilot/models/migrationscripts/20250100_initialize.go b/backend/plugins/gh-copilot/models/migrationscripts/20250100_initialize.go index 95acfe09a20..23b4f67b48d 100644 --- a/backend/plugins/gh-copilot/models/migrationscripts/20250100_initialize.go +++ b/backend/plugins/gh-copilot/models/migrationscripts/20250100_initialize.go @@ -18,10 +18,12 @@ limitations under the License. package migrationscripts import ( + "time" + "github.com/apache/incubator-devlake/core/context" "github.com/apache/incubator-devlake/core/errors" + "github.com/apache/incubator-devlake/core/models/migrationscripts/archived" "github.com/apache/incubator-devlake/helpers/migrationhelper" - "github.com/apache/incubator-devlake/plugins/gh-copilot/models" ) // addCopilotInitialTables creates the initial Copilot tool-layer tables. @@ -30,14 +32,115 @@ type addCopilotInitialTables struct{} func (script *addCopilotInitialTables) Up(basicRes context.BasicRes) errors.Error { return migrationhelper.AutoMigrateTables( basicRes, - &models.GhCopilotConnection{}, - &models.GhCopilotScope{}, - &models.GhCopilotOrgMetrics{}, - &models.GhCopilotLanguageMetrics{}, - &models.GhCopilotSeat{}, + &ghCopilotConnection20250100{}, + &ghCopilotScope20250100{}, + &ghCopilotOrgMetrics20250100{}, + &ghCopilotLanguageMetrics20250100{}, + &ghCopilotSeat20250100{}, ) } +type noPKModel20250100 struct { + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type ghCopilotConnection20250100 struct { + archived.Model + Name string `gorm:"type:varchar(100);uniqueIndex" json:"name"` + Endpoint string `gorm:"type:varchar(255)" json:"endpoint"` + Proxy string `gorm:"type:varchar(255)" json:"proxy"` + RateLimitPerHour int `json:"rateLimitPerHour"` + Token string `json:"token"` + Organization string `gorm:"type:varchar(255)" json:"organization"` +} + +func (ghCopilotConnection20250100) TableName() string { + return "_tool_copilot_connections" +} + +type ghCopilotScope20250100 struct { + archived.NoPKModel + ConnectionId uint64 `json:"connectionId" gorm:"primaryKey"` + ScopeConfigId uint64 `json:"scopeConfigId,omitempty"` + Id string `json:"id" gorm:"primaryKey;type:varchar(255)"` + Organization string `json:"organization" gorm:"type:varchar(255)"` + ImplementationDate *time.Time `json:"implementationDate" gorm:"type:datetime"` + BaselinePeriodDays int `json:"baselinePeriodDays" gorm:"default:90"` + SeatsLastSyncedAt *time.Time `json:"seatsLastSyncedAt" gorm:"type:datetime"` +} + +func (ghCopilotScope20250100) TableName() string { + return "_tool_copilot_scopes" +} + +type ghCopilotOrgMetrics20250100 struct { + ConnectionId uint64 `gorm:"primaryKey" json:"connectionId"` + ScopeId string `gorm:"primaryKey;type:varchar(255)" json:"scopeId"` + Date time.Time `gorm:"primaryKey;type:date" json:"date"` + + TotalActiveUsers int `json:"totalActiveUsers"` + TotalEngagedUsers int `json:"totalEngagedUsers"` + CompletionSuggestions int `json:"completionSuggestions"` + CompletionAcceptances int `json:"completionAcceptances"` + CompletionLinesSuggested int `json:"completionLinesSuggested"` + CompletionLinesAccepted int `json:"completionLinesAccepted"` + IdeChats int `json:"ideChats"` + IdeChatCopyEvents int `json:"ideChatCopyEvents"` + IdeChatInsertionEvents int `json:"ideChatInsertionEvents"` + IdeChatEngagedUsers int `json:"ideChatEngagedUsers"` + DotcomChats int `json:"dotcomChats"` + DotcomChatEngagedUsers int `json:"dotcomChatEngagedUsers"` + PRSummariesCreated int `json:"prSummariesCreated"` + PREngagedUsers int `json:"prEngagedUsers"` + SeatActiveCount int `json:"seatActiveCount"` + SeatTotal int `json:"seatTotal"` + + archived.NoPKModel +} + +func (ghCopilotOrgMetrics20250100) TableName() string { + return "_tool_copilot_org_daily_metrics" +} + +type ghCopilotLanguageMetrics20250100 struct { + ConnectionId uint64 `gorm:"primaryKey"` + ScopeId string `gorm:"primaryKey;type:varchar(255)"` + Date time.Time `gorm:"primaryKey;type:date"` + Editor string `gorm:"primaryKey;type:varchar(50)"` + Language string `gorm:"primaryKey;type:varchar(50)"` + + EngagedUsers int `json:"engagedUsers"` + Suggestions int `json:"suggestions"` + Acceptances int `json:"acceptances"` + LinesSuggested int `json:"linesSuggested"` + LinesAccepted int `json:"linesAccepted"` + + noPKModel20250100 +} + +func (ghCopilotLanguageMetrics20250100) TableName() string { + return "_tool_copilot_org_language_metrics" +} + +type ghCopilotSeat20250100 struct { + ConnectionId uint64 `gorm:"primaryKey"` + Organization string `gorm:"primaryKey;type:varchar(255)"` + UserLogin string `gorm:"primaryKey;type:varchar(255)"` + UserId int64 `gorm:"index"` + PlanType string `gorm:"type:varchar(32)"` + CreatedAt time.Time + LastActivityAt *time.Time + LastActivityEditor string + LastAuthenticatedAt *time.Time + PendingCancellationDate *time.Time + UpdatedAt time.Time +} + +func (ghCopilotSeat20250100) TableName() string { + return "_tool_copilot_seats" +} + func (*addCopilotInitialTables) Version() uint64 { return 20250100000000 } diff --git a/backend/plugins/gh-copilot/models/migrationscripts/20260104_add_raw_data_origin_to_language_metrics.go b/backend/plugins/gh-copilot/models/migrationscripts/20260104_add_raw_data_origin_to_language_metrics.go index 67b7d6561bc..e5e98e15b5d 100644 --- a/backend/plugins/gh-copilot/models/migrationscripts/20260104_add_raw_data_origin_to_language_metrics.go +++ b/backend/plugins/gh-copilot/models/migrationscripts/20260104_add_raw_data_origin_to_language_metrics.go @@ -18,20 +18,42 @@ limitations under the License. package migrationscripts import ( + "time" + "github.com/apache/incubator-devlake/core/context" "github.com/apache/incubator-devlake/core/errors" + "github.com/apache/incubator-devlake/core/models/migrationscripts/archived" "github.com/apache/incubator-devlake/helpers/migrationhelper" - "github.com/apache/incubator-devlake/plugins/gh-copilot/models" ) // addRawDataOriginToCopilotLanguageMetrics ensures _tool_copilot_language_metrics includes RawDataOrigin columns. // This is required by StatefulApiExtractor, which attaches provenance to extracted records. type addRawDataOriginToCopilotLanguageMetrics struct{} +type ghCopilotLanguageMetrics20260104 struct { + ConnectionId uint64 `gorm:"primaryKey"` + ScopeId string `gorm:"primaryKey;type:varchar(255)"` + Date time.Time `gorm:"primaryKey;type:date"` + Editor string `gorm:"primaryKey;type:varchar(50)"` + Language string `gorm:"primaryKey;type:varchar(50)"` + + EngagedUsers int `json:"engagedUsers"` + Suggestions int `json:"suggestions"` + Acceptances int `json:"acceptances"` + LinesSuggested int `json:"linesSuggested"` + LinesAccepted int `json:"linesAccepted"` + + archived.NoPKModel +} + +func (ghCopilotLanguageMetrics20260104) TableName() string { + return "_tool_copilot_org_language_metrics" +} + func (script *addRawDataOriginToCopilotLanguageMetrics) Up(basicRes context.BasicRes) errors.Error { return migrationhelper.AutoMigrateTables( basicRes, - &models.GhCopilotLanguageMetrics{}, + &ghCopilotLanguageMetrics20260104{}, ) } diff --git a/backend/plugins/gh-copilot/models/migrationscripts/20260105_add_raw_data_origin_to_seats.go b/backend/plugins/gh-copilot/models/migrationscripts/20260105_add_raw_data_origin_to_seats.go index cf066d03630..2a921912d6d 100644 --- a/backend/plugins/gh-copilot/models/migrationscripts/20260105_add_raw_data_origin_to_seats.go +++ b/backend/plugins/gh-copilot/models/migrationscripts/20260105_add_raw_data_origin_to_seats.go @@ -18,20 +18,42 @@ limitations under the License. package migrationscripts import ( + "time" + "github.com/apache/incubator-devlake/core/context" "github.com/apache/incubator-devlake/core/errors" + "github.com/apache/incubator-devlake/core/models/migrationscripts/archived" "github.com/apache/incubator-devlake/helpers/migrationhelper" - "github.com/apache/incubator-devlake/plugins/gh-copilot/models" ) // addRawDataOriginToCopilotSeats ensures _tool_copilot_seats includes RawDataOrigin columns. // This is required by ApiExtractor/StatefulApiExtractor, which attach provenance to extracted records. type addRawDataOriginToCopilotSeats struct{} +type ghCopilotSeat20260105 struct { + ConnectionId uint64 `gorm:"primaryKey"` + Organization string `gorm:"primaryKey;type:varchar(255)"` + UserLogin string `gorm:"primaryKey;type:varchar(255)"` + UserId int64 `gorm:"index"` + PlanType string `gorm:"type:varchar(32)"` + CreatedAt time.Time + LastActivityAt *time.Time + LastActivityEditor string + LastAuthenticatedAt *time.Time + PendingCancellationDate *time.Time + UpdatedAt time.Time + + archived.RawDataOrigin +} + +func (ghCopilotSeat20260105) TableName() string { + return "_tool_copilot_seats" +} + func (script *addRawDataOriginToCopilotSeats) Up(basicRes context.BasicRes) errors.Error { return migrationhelper.AutoMigrateTables( basicRes, - &models.GhCopilotSeat{}, + &ghCopilotSeat20260105{}, ) } diff --git a/backend/plugins/gh-copilot/models/migrationscripts/20260116_add_name_fields_to_scopes.go b/backend/plugins/gh-copilot/models/migrationscripts/20260116_add_name_fields_to_scopes.go index ebcc6c54abb..a1e7c8c99ee 100644 --- a/backend/plugins/gh-copilot/models/migrationscripts/20260116_add_name_fields_to_scopes.go +++ b/backend/plugins/gh-copilot/models/migrationscripts/20260116_add_name_fields_to_scopes.go @@ -18,20 +18,39 @@ limitations under the License. package migrationscripts import ( + "time" + "github.com/apache/incubator-devlake/core/context" "github.com/apache/incubator-devlake/core/errors" + "github.com/apache/incubator-devlake/core/models/migrationscripts/archived" "github.com/apache/incubator-devlake/helpers/migrationhelper" - "github.com/apache/incubator-devlake/plugins/gh-copilot/models" ) // addNameFieldsToScopes adds name and fullName columns to _tool_copilot_scopes. // These fields are required by the UI for displaying data scopes in the connection page. type addNameFieldsToScopes struct{} +type ghCopilotScope20260116 struct { + archived.NoPKModel + ConnectionId uint64 `json:"connectionId" gorm:"primaryKey"` + ScopeConfigId uint64 `json:"scopeConfigId,omitempty"` + Id string `json:"id" gorm:"primaryKey;type:varchar(255)"` + Organization string `json:"organization" gorm:"type:varchar(255)"` + Name string `json:"name" gorm:"type:varchar(255)"` + FullName string `json:"fullName" gorm:"type:varchar(255)"` + ImplementationDate *time.Time `json:"implementationDate" gorm:"type:datetime"` + BaselinePeriodDays int `json:"baselinePeriodDays" gorm:"default:90"` + SeatsLastSyncedAt *time.Time `json:"seatsLastSyncedAt" gorm:"type:datetime"` +} + +func (ghCopilotScope20260116) TableName() string { + return "_tool_copilot_scopes" +} + func (script *addNameFieldsToScopes) Up(basicRes context.BasicRes) errors.Error { return migrationhelper.AutoMigrateTables( basicRes, - &models.GhCopilotScope{}, + &ghCopilotScope20260116{}, ) } diff --git a/backend/plugins/gh-copilot/models/migrationscripts/20260121_add_scope_configs.go b/backend/plugins/gh-copilot/models/migrationscripts/20260121_add_scope_configs.go index 66d3b8bdbbd..eb53b0a0640 100644 --- a/backend/plugins/gh-copilot/models/migrationscripts/20260121_add_scope_configs.go +++ b/backend/plugins/gh-copilot/models/migrationscripts/20260121_add_scope_configs.go @@ -22,14 +22,17 @@ import ( "github.com/apache/incubator-devlake/core/context" "github.com/apache/incubator-devlake/core/errors" - "github.com/apache/incubator-devlake/core/models/common" + "github.com/apache/incubator-devlake/core/models/migrationscripts/archived" "github.com/apache/incubator-devlake/helpers/migrationhelper" ) type addScopeConfig20260121 struct{} type scopeConfig20260121 struct { - common.ScopeConfig `mapstructure:",squash" json:",inline" gorm:"embedded"` + archived.Model + Entities []string `gorm:"type:json;serializer:json" json:"entities" mapstructure:"entities"` + ConnectionId uint64 `json:"connectionId" gorm:"index" validate:"required" mapstructure:"connectionId,omitempty"` + Name string `mapstructure:"name" json:"name" gorm:"type:varchar(255);uniqueIndex" validate:"required"` ImplementationDate *time.Time `json:"implementationDate" mapstructure:"implementationDate" gorm:"type:datetime"` BaselinePeriodDays int `json:"baselinePeriodDays" mapstructure:"baselinePeriodDays" gorm:"default:90"` } diff --git a/backend/plugins/gh-copilot/models/migrationscripts/20260212_add_pr_fields_to_enterprise_metrics.go b/backend/plugins/gh-copilot/models/migrationscripts/20260212_add_pr_fields_to_enterprise_metrics.go index b2eacaa5dc1..6da416668a1 100644 --- a/backend/plugins/gh-copilot/models/migrationscripts/20260212_add_pr_fields_to_enterprise_metrics.go +++ b/backend/plugins/gh-copilot/models/migrationscripts/20260212_add_pr_fields_to_enterprise_metrics.go @@ -22,7 +22,7 @@ import ( "github.com/apache/incubator-devlake/core/context" "github.com/apache/incubator-devlake/core/errors" - "github.com/apache/incubator-devlake/core/models/common" + "github.com/apache/incubator-devlake/core/models/migrationscripts/archived" "github.com/apache/incubator-devlake/helpers/migrationhelper" ) @@ -70,7 +70,7 @@ type enterpriseDailyMetrics20260212v2 struct { PRTotalCreated int PRTotalCreatedByCopilot int PRTotalReviewedByCopilot int - common.NoPKModel + archived.NoPKModel } func (enterpriseDailyMetrics20260212v2) TableName() string { @@ -89,7 +89,7 @@ type metricsByIde20260212v2 struct { LocSuggestedToDeleteSum int LocAddedSum int LocDeletedSum int - common.NoPKModel + archived.NoPKModel } func (metricsByIde20260212v2) TableName() string { @@ -108,7 +108,7 @@ type metricsByFeature20260212v2 struct { LocSuggestedToDeleteSum int LocAddedSum int LocDeletedSum int - common.NoPKModel + archived.NoPKModel } func (metricsByFeature20260212v2) TableName() string { @@ -116,18 +116,18 @@ func (metricsByFeature20260212v2) TableName() string { } type metricsByLanguageFeature20260212v2 struct { - ConnectionId uint64 `gorm:"primaryKey"` - ScopeId string `gorm:"primaryKey;type:varchar(255)"` - Day time.Time `gorm:"primaryKey;type:date"` - Language string `gorm:"primaryKey;type:varchar(50)"` - Feature string `gorm:"primaryKey;type:varchar(100)"` + ConnectionId uint64 `gorm:"primaryKey"` + ScopeId string `gorm:"primaryKey;type:varchar(255)"` + Day time.Time `gorm:"primaryKey;type:date"` + Language string `gorm:"primaryKey;type:varchar(50)"` + Feature string `gorm:"primaryKey;type:varchar(100)"` CodeGenerationActivityCount int CodeAcceptanceActivityCount int LocSuggestedToAddSum int LocSuggestedToDeleteSum int LocAddedSum int LocDeletedSum int - common.NoPKModel + archived.NoPKModel } func (metricsByLanguageFeature20260212v2) TableName() string { @@ -135,18 +135,18 @@ func (metricsByLanguageFeature20260212v2) TableName() string { } type metricsByLanguageModel20260212v2 struct { - ConnectionId uint64 `gorm:"primaryKey"` - ScopeId string `gorm:"primaryKey;type:varchar(255)"` - Day time.Time `gorm:"primaryKey;type:date"` - Language string `gorm:"primaryKey;type:varchar(50)"` - Model string `gorm:"primaryKey;type:varchar(100)"` + ConnectionId uint64 `gorm:"primaryKey"` + ScopeId string `gorm:"primaryKey;type:varchar(255)"` + Day time.Time `gorm:"primaryKey;type:date"` + Language string `gorm:"primaryKey;type:varchar(50)"` + Model string `gorm:"primaryKey;type:varchar(100)"` CodeGenerationActivityCount int CodeAcceptanceActivityCount int LocSuggestedToAddSum int LocSuggestedToDeleteSum int LocAddedSum int LocDeletedSum int - common.NoPKModel + archived.NoPKModel } func (metricsByLanguageModel20260212v2) TableName() string { @@ -166,7 +166,7 @@ type metricsByModelFeature20260212v2 struct { LocSuggestedToDeleteSum int LocAddedSum int LocDeletedSum int - common.NoPKModel + archived.NoPKModel } func (metricsByModelFeature20260212v2) TableName() string { @@ -190,7 +190,7 @@ type userDailyMetrics20260212v2 struct { LocSuggestedToDeleteSum int LocAddedSum int LocDeletedSum int - common.NoPKModel + archived.NoPKModel } func (userDailyMetrics20260212v2) TableName() string { @@ -213,7 +213,7 @@ type userMetricsByIde20260212v2 struct { LocSuggestedToDeleteSum int LocAddedSum int LocDeletedSum int - common.NoPKModel + archived.NoPKModel } func (userMetricsByIde20260212v2) TableName() string { @@ -233,7 +233,7 @@ type userMetricsByFeature20260212v2 struct { LocSuggestedToDeleteSum int LocAddedSum int LocDeletedSum int - common.NoPKModel + archived.NoPKModel } func (userMetricsByFeature20260212v2) TableName() string { @@ -241,19 +241,19 @@ func (userMetricsByFeature20260212v2) TableName() string { } type userMetricsByLanguageFeature20260212v2 struct { - ConnectionId uint64 `gorm:"primaryKey"` - ScopeId string `gorm:"primaryKey;type:varchar(255)"` - Day time.Time `gorm:"primaryKey;type:date"` - UserId int64 `gorm:"primaryKey"` - Language string `gorm:"primaryKey;type:varchar(50)"` - Feature string `gorm:"primaryKey;type:varchar(100)"` + ConnectionId uint64 `gorm:"primaryKey"` + ScopeId string `gorm:"primaryKey;type:varchar(255)"` + Day time.Time `gorm:"primaryKey;type:date"` + UserId int64 `gorm:"primaryKey"` + Language string `gorm:"primaryKey;type:varchar(50)"` + Feature string `gorm:"primaryKey;type:varchar(100)"` CodeGenerationActivityCount int CodeAcceptanceActivityCount int LocSuggestedToAddSum int LocSuggestedToDeleteSum int LocAddedSum int LocDeletedSum int - common.NoPKModel + archived.NoPKModel } func (userMetricsByLanguageFeature20260212v2) TableName() string { @@ -261,19 +261,19 @@ func (userMetricsByLanguageFeature20260212v2) TableName() string { } type userMetricsByLanguageModel20260212v2 struct { - ConnectionId uint64 `gorm:"primaryKey"` - ScopeId string `gorm:"primaryKey;type:varchar(255)"` - Day time.Time `gorm:"primaryKey;type:date"` - UserId int64 `gorm:"primaryKey"` - Language string `gorm:"primaryKey;type:varchar(50)"` - Model string `gorm:"primaryKey;type:varchar(100)"` + ConnectionId uint64 `gorm:"primaryKey"` + ScopeId string `gorm:"primaryKey;type:varchar(255)"` + Day time.Time `gorm:"primaryKey;type:date"` + UserId int64 `gorm:"primaryKey"` + Language string `gorm:"primaryKey;type:varchar(50)"` + Model string `gorm:"primaryKey;type:varchar(100)"` CodeGenerationActivityCount int CodeAcceptanceActivityCount int LocSuggestedToAddSum int LocSuggestedToDeleteSum int LocAddedSum int LocDeletedSum int - common.NoPKModel + archived.NoPKModel } func (userMetricsByLanguageModel20260212v2) TableName() string { @@ -294,7 +294,7 @@ type userMetricsByModelFeature20260212v2 struct { LocSuggestedToDeleteSum int LocAddedSum int LocDeletedSum int - common.NoPKModel + archived.NoPKModel } func (userMetricsByModelFeature20260212v2) TableName() string { diff --git a/backend/plugins/gh-copilot/models/migrationscripts/20260212_v2_usage_metrics.go b/backend/plugins/gh-copilot/models/migrationscripts/20260212_v2_usage_metrics.go index 72d82a467ee..7ede2b680d8 100644 --- a/backend/plugins/gh-copilot/models/migrationscripts/20260212_v2_usage_metrics.go +++ b/backend/plugins/gh-copilot/models/migrationscripts/20260212_v2_usage_metrics.go @@ -22,7 +22,7 @@ import ( "github.com/apache/incubator-devlake/core/context" "github.com/apache/incubator-devlake/core/errors" - "github.com/apache/incubator-devlake/core/models/common" + "github.com/apache/incubator-devlake/core/models/migrationscripts/archived" "github.com/apache/incubator-devlake/helpers/migrationhelper" ) @@ -82,7 +82,7 @@ type enterpriseDailyMetrics20260212 struct { MonthlyActiveChatUsers int MonthlyActiveAgentUsers int activityMetrics20260212 `gorm:"embedded"` - common.NoPKModel + archived.NoPKModel } func (enterpriseDailyMetrics20260212) TableName() string { @@ -95,7 +95,7 @@ type metricsByIde20260212 struct { Day time.Time `gorm:"primaryKey;type:date"` Ide string `gorm:"primaryKey;type:varchar(50)"` activityMetrics20260212 `gorm:"embedded"` - common.NoPKModel + archived.NoPKModel } func (metricsByIde20260212) TableName() string { @@ -108,7 +108,7 @@ type metricsByFeature20260212 struct { Day time.Time `gorm:"primaryKey;type:date"` Feature string `gorm:"primaryKey;type:varchar(100)"` activityMetrics20260212 `gorm:"embedded"` - common.NoPKModel + archived.NoPKModel } func (metricsByFeature20260212) TableName() string { @@ -116,13 +116,13 @@ func (metricsByFeature20260212) TableName() string { } type metricsByLanguageFeature20260212 struct { - ConnectionId uint64 `gorm:"primaryKey"` - ScopeId string `gorm:"primaryKey;type:varchar(255)"` - Day time.Time `gorm:"primaryKey;type:date"` - Language string `gorm:"primaryKey;type:varchar(50)"` - Feature string `gorm:"primaryKey;type:varchar(100)"` - codeMetrics20260212 `gorm:"embedded"` - common.NoPKModel + ConnectionId uint64 `gorm:"primaryKey"` + ScopeId string `gorm:"primaryKey;type:varchar(255)"` + Day time.Time `gorm:"primaryKey;type:date"` + Language string `gorm:"primaryKey;type:varchar(50)"` + Feature string `gorm:"primaryKey;type:varchar(100)"` + codeMetrics20260212 `gorm:"embedded"` + archived.NoPKModel } func (metricsByLanguageFeature20260212) TableName() string { @@ -130,13 +130,13 @@ func (metricsByLanguageFeature20260212) TableName() string { } type metricsByLanguageModel20260212 struct { - ConnectionId uint64 `gorm:"primaryKey"` - ScopeId string `gorm:"primaryKey;type:varchar(255)"` - Day time.Time `gorm:"primaryKey;type:date"` - Language string `gorm:"primaryKey;type:varchar(50)"` - Model string `gorm:"primaryKey;type:varchar(100)"` - codeMetrics20260212 `gorm:"embedded"` - common.NoPKModel + ConnectionId uint64 `gorm:"primaryKey"` + ScopeId string `gorm:"primaryKey;type:varchar(255)"` + Day time.Time `gorm:"primaryKey;type:date"` + Language string `gorm:"primaryKey;type:varchar(50)"` + Model string `gorm:"primaryKey;type:varchar(100)"` + codeMetrics20260212 `gorm:"embedded"` + archived.NoPKModel } func (metricsByLanguageModel20260212) TableName() string { @@ -150,7 +150,7 @@ type metricsByModelFeature20260212 struct { Model string `gorm:"primaryKey;type:varchar(100)"` Feature string `gorm:"primaryKey;type:varchar(100)"` activityMetrics20260212 `gorm:"embedded"` - common.NoPKModel + archived.NoPKModel } func (metricsByModelFeature20260212) TableName() string { @@ -169,7 +169,7 @@ type userDailyMetrics20260212 struct { UsedAgent bool UsedChat bool activityMetrics20260212 `gorm:"embedded"` - common.NoPKModel + archived.NoPKModel } func (userDailyMetrics20260212) TableName() string { @@ -186,7 +186,7 @@ type userMetricsByIde20260212 struct { LastKnownPluginVersion string `gorm:"type:varchar(50)"` LastKnownIdeVersion string `gorm:"type:varchar(50)"` activityMetrics20260212 `gorm:"embedded"` - common.NoPKModel + archived.NoPKModel } func (userMetricsByIde20260212) TableName() string { @@ -200,7 +200,7 @@ type userMetricsByFeature20260212 struct { UserId int64 `gorm:"primaryKey"` Feature string `gorm:"primaryKey;type:varchar(100)"` activityMetrics20260212 `gorm:"embedded"` - common.NoPKModel + archived.NoPKModel } func (userMetricsByFeature20260212) TableName() string { @@ -215,7 +215,7 @@ type userMetricsByLanguageFeature20260212 struct { Language string `gorm:"primaryKey;type:varchar(50)"` Feature string `gorm:"primaryKey;type:varchar(100)"` codeMetrics20260212 `gorm:"embedded"` - common.NoPKModel + archived.NoPKModel } func (userMetricsByLanguageFeature20260212) TableName() string { @@ -230,7 +230,7 @@ type userMetricsByLanguageModel20260212 struct { Language string `gorm:"primaryKey;type:varchar(50)"` Model string `gorm:"primaryKey;type:varchar(100)"` codeMetrics20260212 `gorm:"embedded"` - common.NoPKModel + archived.NoPKModel } func (userMetricsByLanguageModel20260212) TableName() string { @@ -245,7 +245,7 @@ type userMetricsByModelFeature20260212 struct { Model string `gorm:"primaryKey;type:varchar(100)"` Feature string `gorm:"primaryKey;type:varchar(100)"` activityMetrics20260212 `gorm:"embedded"` - common.NoPKModel + archived.NoPKModel } func (userMetricsByModelFeature20260212) TableName() string { @@ -274,7 +274,7 @@ type orgDailyMetrics20260212 struct { PREngagedUsers int SeatActiveCount int SeatTotal int - common.NoPKModel + archived.NoPKModel } func (orgDailyMetrics20260212) TableName() string { @@ -282,17 +282,17 @@ func (orgDailyMetrics20260212) TableName() string { } type orgLanguageMetrics20260212 struct { - ConnectionId uint64 `gorm:"primaryKey"` - ScopeId string `gorm:"primaryKey;type:varchar(255)"` - Date time.Time `gorm:"primaryKey;type:date"` - Editor string `gorm:"primaryKey;type:varchar(50)"` - Language string `gorm:"primaryKey;type:varchar(50)"` + ConnectionId uint64 `gorm:"primaryKey"` + ScopeId string `gorm:"primaryKey;type:varchar(255)"` + Date time.Time `gorm:"primaryKey;type:date"` + Editor string `gorm:"primaryKey;type:varchar(50)"` + Language string `gorm:"primaryKey;type:varchar(50)"` EngagedUsers int Suggestions int Acceptances int LinesSuggested int LinesAccepted int - common.NoPKModel + archived.NoPKModel } func (orgLanguageMetrics20260212) TableName() string { diff --git a/backend/plugins/gh-copilot/tasks/enterprise_metrics_collector.go b/backend/plugins/gh-copilot/tasks/enterprise_metrics_collector.go index e0e4335f977..076b7df7799 100644 --- a/backend/plugins/gh-copilot/tasks/enterprise_metrics_collector.go +++ b/backend/plugins/gh-copilot/tasks/enterprise_metrics_collector.go @@ -91,8 +91,8 @@ func CollectEnterpriseMetrics(taskCtx plugin.SubTaskContext) errors.Error { q.Set("day", input.Day) return q, nil }, - Incremental: true, - Concurrency: 1, + Incremental: true, + Concurrency: 1, AfterResponse: ignore404, ResponseParser: func(res *http.Response) ([]json.RawMessage, errors.Error) { // Parse metadata response to get download links @@ -169,8 +169,3 @@ func (it *dayIterator) Fetch() (interface{}, errors.Error) { func (it *dayIterator) Close() errors.Error { return nil } - -func mustMarshal(v interface{}) string { - b, _ := json.Marshal(v) - return string(b) -} diff --git a/backend/plugins/gh-copilot/tasks/enterprise_metrics_extractor.go b/backend/plugins/gh-copilot/tasks/enterprise_metrics_extractor.go index 1ad704458ee..8686b8cc415 100644 --- a/backend/plugins/gh-copilot/tasks/enterprise_metrics_extractor.go +++ b/backend/plugins/gh-copilot/tasks/enterprise_metrics_extractor.go @@ -29,34 +29,27 @@ import ( // --- Enterprise report JSON structures --- -type enterpriseReport struct { - ReportStartDay string `json:"report_start_day"` - ReportEndDay string `json:"report_end_day"` - EnterpriseId string `json:"enterprise_id"` - DayTotals []enterpriseDayTotal `json:"day_totals"` -} - type enterpriseDayTotal struct { - Day string `json:"day"` - EnterpriseId string `json:"enterprise_id"` - DailyActiveUsers int `json:"daily_active_users"` - WeeklyActiveUsers int `json:"weekly_active_users"` - MonthlyActiveUsers int `json:"monthly_active_users"` - MonthlyActiveChatUsers int `json:"monthly_active_chat_users"` - MonthlyActiveAgentUsers int `json:"monthly_active_agent_users"` - UserInitiatedInteractionCount int `json:"user_initiated_interaction_count"` - CodeGenerationActivityCount int `json:"code_generation_activity_count"` - CodeAcceptanceActivityCount int `json:"code_acceptance_activity_count"` - LocSuggestedToAddSum int `json:"loc_suggested_to_add_sum"` - LocSuggestedToDeleteSum int `json:"loc_suggested_to_delete_sum"` - LocAddedSum int `json:"loc_added_sum"` - LocDeletedSum int `json:"loc_deleted_sum"` - TotalsByIde []totalsByIde `json:"totals_by_ide"` - TotalsByFeature []totalsByFeature `json:"totals_by_feature"` - TotalsByLanguageFeature []totalsByLangFeature `json:"totals_by_language_feature"` - TotalsByLanguageModel []totalsByLangModel `json:"totals_by_language_model"` - TotalsByModelFeature []totalsByModelFeature `json:"totals_by_model_feature"` - PullRequests *pullRequestStats `json:"pull_requests"` + Day string `json:"day"` + EnterpriseId string `json:"enterprise_id"` + DailyActiveUsers int `json:"daily_active_users"` + WeeklyActiveUsers int `json:"weekly_active_users"` + MonthlyActiveUsers int `json:"monthly_active_users"` + MonthlyActiveChatUsers int `json:"monthly_active_chat_users"` + MonthlyActiveAgentUsers int `json:"monthly_active_agent_users"` + UserInitiatedInteractionCount int `json:"user_initiated_interaction_count"` + CodeGenerationActivityCount int `json:"code_generation_activity_count"` + CodeAcceptanceActivityCount int `json:"code_acceptance_activity_count"` + LocSuggestedToAddSum int `json:"loc_suggested_to_add_sum"` + LocSuggestedToDeleteSum int `json:"loc_suggested_to_delete_sum"` + LocAddedSum int `json:"loc_added_sum"` + LocDeletedSum int `json:"loc_deleted_sum"` + TotalsByIde []totalsByIde `json:"totals_by_ide"` + TotalsByFeature []totalsByFeature `json:"totals_by_feature"` + TotalsByLanguageFeature []totalsByLangFeature `json:"totals_by_language_feature"` + TotalsByLanguageModel []totalsByLangModel `json:"totals_by_language_model"` + TotalsByModelFeature []totalsByModelFeature `json:"totals_by_model_feature"` + PullRequests *pullRequestStats `json:"pull_requests"` } type totalsByIde struct { @@ -165,13 +158,13 @@ func ExtractEnterpriseMetrics(taskCtx plugin.SubTaskContext) errors.Error { // Main daily metrics dailyMetrics := &models.GhCopilotEnterpriseDailyMetrics{ - ConnectionId: data.Options.ConnectionId, - ScopeId: data.Options.ScopeId, - Day: day, - EnterpriseId: dt.EnterpriseId, - DailyActiveUsers: dt.DailyActiveUsers, - WeeklyActiveUsers: dt.WeeklyActiveUsers, - MonthlyActiveUsers: dt.MonthlyActiveUsers, + ConnectionId: data.Options.ConnectionId, + ScopeId: data.Options.ScopeId, + Day: day, + EnterpriseId: dt.EnterpriseId, + DailyActiveUsers: dt.DailyActiveUsers, + WeeklyActiveUsers: dt.WeeklyActiveUsers, + MonthlyActiveUsers: dt.MonthlyActiveUsers, MonthlyActiveChatUsers: dt.MonthlyActiveChatUsers, MonthlyActiveAgentUsers: dt.MonthlyActiveAgentUsers, CopilotActivityMetrics: models.CopilotActivityMetrics{ diff --git a/backend/plugins/gh-copilot/tasks/org_metrics_collector.go b/backend/plugins/gh-copilot/tasks/org_metrics_collector.go index 7844b546c9c..9eee0fe33fa 100644 --- a/backend/plugins/gh-copilot/tasks/org_metrics_collector.go +++ b/backend/plugins/gh-copilot/tasks/org_metrics_collector.go @@ -85,8 +85,8 @@ func CollectOrgMetrics(taskCtx plugin.SubTaskContext) errors.Error { q.Set("day", input.Day) return q, nil }, - Incremental: true, - Concurrency: 1, + Incremental: true, + Concurrency: 1, AfterResponse: ignore404, ResponseParser: func(res *http.Response) ([]json.RawMessage, errors.Error) { body, readErr := io.ReadAll(res.Body) diff --git a/backend/plugins/gh-copilot/tasks/report_download_helper.go b/backend/plugins/gh-copilot/tasks/report_download_helper.go index 3e7e395ca93..857712b8a30 100644 --- a/backend/plugins/gh-copilot/tasks/report_download_helper.go +++ b/backend/plugins/gh-copilot/tasks/report_download_helper.go @@ -86,46 +86,6 @@ func computeReportDateRange(now time.Time, since *time.Time) (start, until time. return start, until } -// fetchReportMetadata calls a report metadata endpoint for a specific day and returns the download links. -func fetchReportMetadata( - apiClient *helper.ApiAsyncClient, - endpoint string, - day time.Time, - logger log.Logger, -) (*reportMetadataResponse, errors.Error) { - dayStr := day.Format("2006-01-02") - uri := fmt.Sprintf("%s?day=%s", endpoint, dayStr) - - res, err := apiClient.Get(uri, nil, nil) - if err != nil { - return nil, errors.Default.Wrap(err, fmt.Sprintf("failed to fetch report metadata for %s", dayStr)) - } - defer res.Body.Close() - - if res.StatusCode == http.StatusNotFound { - // Report not available for this day (data not yet processed or no activity) - if logger != nil { - logger.Info("No report available for %s (404), skipping", dayStr) - } - return nil, nil - } - if res.StatusCode >= 400 { - body, _ := io.ReadAll(res.Body) - return nil, buildGitHubApiError(res.StatusCode, "", body, res.Header.Get("Retry-After")) - } - - body, readErr := io.ReadAll(res.Body) - if readErr != nil { - return nil, errors.Default.Wrap(readErr, "failed to read report metadata response") - } - - var meta reportMetadataResponse - if jsonErr := json.Unmarshal(body, &meta); jsonErr != nil { - return nil, errors.Default.Wrap(jsonErr, fmt.Sprintf("failed to parse report metadata for %s", dayStr)) - } - return &meta, nil -} - // downloadReport downloads a single report file from a signed URL and returns the raw body. // Returns nil, nil when the blob is not found (404) — the caller should skip such reports. func downloadReport(url string, logger log.Logger) ([]byte, errors.Error) { diff --git a/backend/plugins/gh-copilot/tasks/user_metrics_collector.go b/backend/plugins/gh-copilot/tasks/user_metrics_collector.go index 263210a53ed..526c13bf34b 100644 --- a/backend/plugins/gh-copilot/tasks/user_metrics_collector.go +++ b/backend/plugins/gh-copilot/tasks/user_metrics_collector.go @@ -86,8 +86,8 @@ func CollectUserMetrics(taskCtx plugin.SubTaskContext) errors.Error { q.Set("day", input.Day) return q, nil }, - Incremental: true, - Concurrency: 1, + Incremental: true, + Concurrency: 1, AfterResponse: ignore404, ResponseParser: func(res *http.Response) ([]json.RawMessage, errors.Error) { body, readErr := io.ReadAll(res.Body) diff --git a/backend/plugins/gh-copilot/tasks/user_metrics_extractor.go b/backend/plugins/gh-copilot/tasks/user_metrics_extractor.go index 53dad200f0b..1eb73554bb3 100644 --- a/backend/plugins/gh-copilot/tasks/user_metrics_extractor.go +++ b/backend/plugins/gh-copilot/tasks/user_metrics_extractor.go @@ -30,26 +30,26 @@ import ( // --- User report JSONL structures (one line per user) --- type userDailyReport struct { - ReportStartDay string `json:"report_start_day"` - ReportEndDay string `json:"report_end_day"` - Day string `json:"day"` - EnterpriseId string `json:"enterprise_id"` - UserId int64 `json:"user_id"` - UserLogin string `json:"user_login"` - UserInitiatedInteractionCount int `json:"user_initiated_interaction_count"` - CodeGenerationActivityCount int `json:"code_generation_activity_count"` - CodeAcceptanceActivityCount int `json:"code_acceptance_activity_count"` - LocSuggestedToAddSum int `json:"loc_suggested_to_add_sum"` - LocSuggestedToDeleteSum int `json:"loc_suggested_to_delete_sum"` - LocAddedSum int `json:"loc_added_sum"` - LocDeletedSum int `json:"loc_deleted_sum"` - UsedAgent bool `json:"used_agent"` - UsedChat bool `json:"used_chat"` - TotalsByIde []userTotalsByIde `json:"totals_by_ide"` - TotalsByFeature []totalsByFeature `json:"totals_by_feature"` - TotalsByLanguageFeature []totalsByLangFeature `json:"totals_by_language_feature"` - TotalsByLanguageModel []totalsByLangModel `json:"totals_by_language_model"` - TotalsByModelFeature []totalsByModelFeature `json:"totals_by_model_feature"` + ReportStartDay string `json:"report_start_day"` + ReportEndDay string `json:"report_end_day"` + Day string `json:"day"` + EnterpriseId string `json:"enterprise_id"` + UserId int64 `json:"user_id"` + UserLogin string `json:"user_login"` + UserInitiatedInteractionCount int `json:"user_initiated_interaction_count"` + CodeGenerationActivityCount int `json:"code_generation_activity_count"` + CodeAcceptanceActivityCount int `json:"code_acceptance_activity_count"` + LocSuggestedToAddSum int `json:"loc_suggested_to_add_sum"` + LocSuggestedToDeleteSum int `json:"loc_suggested_to_delete_sum"` + LocAddedSum int `json:"loc_added_sum"` + LocDeletedSum int `json:"loc_deleted_sum"` + UsedAgent bool `json:"used_agent"` + UsedChat bool `json:"used_chat"` + TotalsByIde []userTotalsByIde `json:"totals_by_ide"` + TotalsByFeature []totalsByFeature `json:"totals_by_feature"` + TotalsByLanguageFeature []totalsByLangFeature `json:"totals_by_language_feature"` + TotalsByLanguageModel []totalsByLangModel `json:"totals_by_language_model"` + TotalsByModelFeature []totalsByModelFeature `json:"totals_by_model_feature"` } type userTotalsByIde struct { diff --git a/config-ui/src/hooks/use-refresh-data.ts b/config-ui/src/hooks/use-refresh-data.ts index 5e1fa7a0f84..be9776fec3d 100644 --- a/config-ui/src/hooks/use-refresh-data.ts +++ b/config-ui/src/hooks/use-refresh-data.ts @@ -26,7 +26,7 @@ export const useRefreshData = (request: (signal: AbortSignal) => Promise, state: 'ready' | 'pending' | 'error'; deps?: React.DependencyList; data?: T; - error?: unknown; + error?: unknown; abortController?: AbortController; timer?: number; }>({ @@ -38,7 +38,7 @@ export const useRefreshData = (request: (signal: AbortSignal) => Promise, data: ref.current.data, ready: ref.current.state === 'ready', pending: ref.current.state === 'pending', - error: ref.current.error, + error: ref.current.error, }; } diff --git a/config-ui/src/plugins/components/scope-config-form/index.tsx b/config-ui/src/plugins/components/scope-config-form/index.tsx index 8e1c784185c..e5095ff0983 100644 --- a/config-ui/src/plugins/components/scope-config-form/index.tsx +++ b/config-ui/src/plugins/components/scope-config-form/index.tsx @@ -71,6 +71,11 @@ export const ScopeConfigForm = ({ const config = useMemo(() => getPluginConfig(plugin), []); + const pluginDoc = DOC_URL.PLUGIN[config.plugin.toUpperCase() as keyof typeof DOC_URL.PLUGIN]; + + const transformationDoc = + typeof pluginDoc === 'object' && pluginDoc && 'TRANSFORMATION' in pluginDoc ? pluginDoc.TRANSFORMATION : undefined; + useEffect(() => { setTransformation(config.scopeConfig?.transformation ?? {}); }, [config.scopeConfig?.transformation]); @@ -88,7 +93,7 @@ export const ScopeConfigForm = ({ setName(forceCreate ? `${res.name}-copy` : res.name); setEntities(res.entities ?? []); setTransformation(omit(res, ['id', 'connectionId', 'name', 'entities', 'createdAt', 'updatedAt'])); - } catch { } + } catch {} })(); }, [scopeConfigId]); @@ -119,15 +124,12 @@ export const ScopeConfigForm = ({ return ( - {DOC_URL.PLUGIN[config.plugin.toUpperCase()]?.TRANSFORMATION && ( + {transformationDoc && ( To learn about how {config.name} transformation is used in DevLake, - - check out this doc - - . + check out this doc. } /> diff --git a/config-ui/src/plugins/register/argocd/transformation.tsx b/config-ui/src/plugins/register/argocd/transformation.tsx index 732d5c81480..be94536f346 100644 --- a/config-ui/src/plugins/register/argocd/transformation.tsx +++ b/config-ui/src/plugins/register/argocd/transformation.tsx @@ -136,9 +136,9 @@ const renderCollapseItems = ({
- Note: ArgoCD limits deployment history to the last 10 sync operations by default (controlled - by revisionHistoryLimit). Consider increasing this value in your ArgoCD application settings for - better historical metrics. + Note: ArgoCD limits deployment history to the last 10 sync operations by default + (controlled by revisionHistoryLimit). Consider increasing this value in your ArgoCD application + settings for better historical metrics.
), diff --git a/config-ui/src/plugins/register/q-dev/connection-fields/aws-credentials.tsx b/config-ui/src/plugins/register/q-dev/connection-fields/aws-credentials.tsx index 19953d4459b..7b4c20d232f 100644 --- a/config-ui/src/plugins/register/q-dev/connection-fields/aws-credentials.tsx +++ b/config-ui/src/plugins/register/q-dev/connection-fields/aws-credentials.tsx @@ -51,7 +51,7 @@ export const AwsCredentials = ({ type, initialValues, values, setValues, setErro const accessKeyId = values.accessKeyId ?? ''; const secretAccessKey = values.secretAccessKey ?? ''; const region = values.region ?? ''; - + const isAccessKeyAuth = authType === 'access_key'; useEffect(() => { @@ -141,13 +141,13 @@ export const AwsCredentials = ({ type, initialValues, values, setValues, setErro const handleAuthTypeChange = (e: any) => { const newAuthType = e.target.value; setValues({ authType: newAuthType }); - + // Clear access key fields when switching to IAM role if (newAuthType === 'iam_role') { - setValues({ + setValues({ authType: newAuthType, accessKeyId: '', - secretAccessKey: '' + secretAccessKey: '', }); } }; @@ -163,7 +163,11 @@ export const AwsCredentials = ({ type, initialValues, values, setValues, setErro {isAccessKeyAuth && ( <> - + {accessKeyError}} - + +

- Make sure the IAM role has the necessary S3 permissions to access your bucket. - No additional credentials are required when using IAM role authentication. + Make sure the IAM role has the necessary S3 permissions to access your bucket. No additional credentials + are required when using IAM role authentication.

diff --git a/config-ui/src/plugins/register/q-dev/connection-fields/connection-test.tsx b/config-ui/src/plugins/register/q-dev/connection-fields/connection-test.tsx index bc0eb73398d..2b5d6033933 100644 --- a/config-ui/src/plugins/register/q-dev/connection-fields/connection-test.tsx +++ b/config-ui/src/plugins/register/q-dev/connection-fields/connection-test.tsx @@ -56,12 +56,18 @@ export const QDevConnectionTest = ({ plugin, connectionId, values, initialValues return API.connection.test(plugin, connectionId, { authType: values.authType !== initialValues.authType ? values.authType : undefined, accessKeyId: values.accessKeyId !== initialValues.accessKeyId ? values.accessKeyId : undefined, - secretAccessKey: values.secretAccessKey !== initialValues.secretAccessKey ? values.secretAccessKey : undefined, + secretAccessKey: + values.secretAccessKey !== initialValues.secretAccessKey ? values.secretAccessKey : undefined, region: values.region !== initialValues.region ? values.region : undefined, bucket: values.bucket !== initialValues.bucket ? values.bucket : undefined, - identityStoreId: values.identityStoreId !== initialValues.identityStoreId ? values.identityStoreId : undefined, - identityStoreRegion: values.identityStoreRegion !== initialValues.identityStoreRegion ? values.identityStoreRegion : undefined, - rateLimitPerHour: values.rateLimitPerHour !== initialValues.rateLimitPerHour ? values.rateLimitPerHour : undefined, + identityStoreId: + values.identityStoreId !== initialValues.identityStoreId ? values.identityStoreId : undefined, + identityStoreRegion: + values.identityStoreRegion !== initialValues.identityStoreRegion + ? values.identityStoreRegion + : undefined, + rateLimitPerHour: + values.rateLimitPerHour !== initialValues.rateLimitPerHour ? values.rateLimitPerHour : undefined, proxy: values.proxy !== initialValues.proxy ? values.proxy : undefined, } as any); } else { @@ -104,7 +110,7 @@ export const QDevConnectionTest = ({ plugin, connectionId, values, initialValues } } catch (error: any) { let errorMessage = 'Connection test failed. Please check your configuration.'; - + if (error?.response?.data?.message) { errorMessage = error.response.data.message; } else if (error?.message) { @@ -121,7 +127,8 @@ export const QDevConnectionTest = ({ plugin, connectionId, values, initialValues } else if (errorMessage.includes('InvalidBucketName')) { errorMessage = 'Invalid S3 bucket name. Please check the bucket name format.'; } else if (errorMessage.includes('NoCredentialsError')) { - errorMessage = 'AWS credentials not found. Please provide valid Access Key ID and Secret Access Key, or ensure IAM role is properly configured.'; + errorMessage = + 'AWS credentials not found. Please provide valid Access Key ID and Secret Access Key, or ensure IAM role is properly configured.'; } setTestResult({ @@ -165,9 +172,7 @@ export const QDevConnectionTest = ({ plugin, connectionId, values, initialValues testResult?.success && testResult.details ? (
✓ S3 Access: Verified
- {testResult.details.identityCenterAccess && ( -
✓ IAM Identity Center: Configured
- )} + {testResult.details.identityCenterAccess &&
✓ IAM Identity Center: Configured
} {!values.identityStoreId && (
⚠️ IAM Identity Center not configured - user display names will show as user IDs @@ -182,4 +187,4 @@ export const QDevConnectionTest = ({ plugin, connectionId, values, initialValues )} ); -}; \ No newline at end of file +}; diff --git a/config-ui/src/plugins/register/q-dev/data-scope.tsx b/config-ui/src/plugins/register/q-dev/data-scope.tsx index e576591b8a0..c5eff68db6a 100644 --- a/config-ui/src/plugins/register/q-dev/data-scope.tsx +++ b/config-ui/src/plugins/register/q-dev/data-scope.tsx @@ -205,10 +205,7 @@ export const QDevDataScope = ({ [selectedItems], ); - const derivedAccountId = useMemo( - () => deriveAccountIdFromSelection(selectedItems) ?? '', - [selectedItems], - ); + const derivedAccountId = useMemo(() => deriveAccountIdFromSelection(selectedItems) ?? '', [selectedItems]); useEffect(() => { if (!form.isFieldsTouched(['basePath'])) { @@ -256,7 +253,12 @@ export const QDevDataScope = ({ const hasMonths = selectedItems.some((item) => { const meta = extractScopeMeta(item); - return meta.basePath === normalizedBase && meta.accountId === normalizedAccountId && meta.year === normalizedYear && meta.month !== null; + return ( + meta.basePath === normalizedBase && + meta.accountId === normalizedAccountId && + meta.year === normalizedYear && + meta.month !== null + ); }); if (hasMonths) { @@ -321,12 +323,14 @@ export const QDevDataScope = ({ render: (_: unknown, item) => { const meta = extractScopeMeta(item); if (meta.accountId) { - const timePart = meta.month - ? `${meta.year}/${ensureLeadingZero(meta.month)}` - : `${meta.year}`; + const timePart = meta.month ? `${meta.year}/${ensureLeadingZero(meta.month)}` : `${meta.year}`; return ( - - {meta.basePath}/…/{meta.accountId}/…/{timePart} + + + {meta.basePath}/…/{meta.accountId}/…/{timePart} + ); } diff --git a/config-ui/src/plugins/register/sonarqube/connection-fields/organization.tsx b/config-ui/src/plugins/register/sonarqube/connection-fields/organization.tsx index 8cd71f61a38..89cf00a04ce 100644 --- a/config-ui/src/plugins/register/sonarqube/connection-fields/organization.tsx +++ b/config-ui/src/plugins/register/sonarqube/connection-fields/organization.tsx @@ -39,7 +39,7 @@ export const Organization = ({ initialValues, values, setValues, setErrors }: Pr useEffect(() => { setErrors({ - org: (values.endpoint != 'https://sonarcloud.io/api/' || values.org) ? '' : 'organization is required', + org: values.endpoint != 'https://sonarcloud.io/api/' || values.org ? '' : 'organization is required', }); }, [values.org]); diff --git a/config-ui/src/plugins/register/testmo/config.tsx b/config-ui/src/plugins/register/testmo/config.tsx index 378ef4fa7ad..8b8f7d15788 100644 --- a/config-ui/src/plugins/register/testmo/config.tsx +++ b/config-ui/src/plugins/register/testmo/config.tsx @@ -42,14 +42,12 @@ export const TestmoConfig: IPluginConfig = { label: 'API Token', type: 'password', placeholder: 'Enter your Testmo API token', - subLabel: - 'Generate an API token from your Testmo account settings: Settings → API', + subLabel: 'Generate an API token from your Testmo account settings: Settings → API', }, 'proxy', { key: 'rateLimitPerHour', - subLabel: - 'By default, DevLake will not limit API requests per hour. But you can set a number if you want to.', + subLabel: 'By default, DevLake will not limit API requests per hour. But you can set a number if you want to.', learnMore: 'https://devlake.apache.org/docs/Configuration/Testmo/#rate-limit-api-requests-per-hour', externalInfo: 'Testmo does not specify a maximum number of requests per hour.', defaultValue: 10000, diff --git a/config-ui/src/plugins/register/webhook/components/create-dialog.tsx b/config-ui/src/plugins/register/webhook/components/create-dialog.tsx index bc08a0750d9..3157b6585a2 100644 --- a/config-ui/src/plugins/register/webhook/components/create-dialog.tsx +++ b/config-ui/src/plugins/register/webhook/components/create-dialog.tsx @@ -56,7 +56,13 @@ export const CreateDialog = ({ open, onCancel, onSubmitAfter }: Props) => { const [success, res] = await operator( async () => { const { - webhook: { id, postIssuesEndpoint, closeIssuesEndpoint, postPipelineDeployTaskEndpoint, postPullRequestsEndpoint }, + webhook: { + id, + postIssuesEndpoint, + closeIssuesEndpoint, + postPipelineDeployTaskEndpoint, + postPullRequestsEndpoint, + }, apiKey, } = await dispatch(addWebhook({ name })).unwrap(); diff --git a/config-ui/src/plugins/register/webhook/components/utils.ts b/config-ui/src/plugins/register/webhook/components/utils.ts index ba2fe78bb9c..8675be979ce 100644 --- a/config-ui/src/plugins/register/webhook/components/utils.ts +++ b/config-ui/src/plugins/register/webhook/components/utils.ts @@ -20,8 +20,9 @@ import { IWebhook } from '@/types'; export const transformURI = (prefix: string, webhook: IWebhook, apiKey: string) => { return { - postIssuesEndpoint: `curl ${prefix}${webhook.postIssuesEndpoint} -X 'POST' -H 'Authorization: Bearer ${apiKey ?? '{API_KEY}' - }' -d '{ + postIssuesEndpoint: `curl ${prefix}${webhook.postIssuesEndpoint} -X 'POST' -H 'Authorization: Bearer ${ + apiKey ?? '{API_KEY}' + }' -d '{ "issueKey":"DLK-1234", "title":"an incident from DLK", "type":"INCIDENT", @@ -30,10 +31,12 @@ export const transformURI = (prefix: string, webhook: IWebhook, apiKey: string) "createdDate":"2020-01-01T12:00:00+00:00", "updatedDate":"2020-01-01T12:00:00+00:00" }'`, - closeIssuesEndpoint: `curl ${prefix}${webhook.closeIssuesEndpoint} -X 'POST' -H 'Authorization: Bearer ${apiKey ?? '{API_KEY}' - }'`, - postDeploymentsCurl: `curl ${prefix}${webhook.postPipelineDeployTaskEndpoint} -X 'POST' -H 'Authorization: Bearer ${apiKey ?? '{API_KEY}' - }' -d '{ + closeIssuesEndpoint: `curl ${prefix}${webhook.closeIssuesEndpoint} -X 'POST' -H 'Authorization: Bearer ${ + apiKey ?? '{API_KEY}' + }'`, + postDeploymentsCurl: `curl ${prefix}${webhook.postPipelineDeployTaskEndpoint} -X 'POST' -H 'Authorization: Bearer ${ + apiKey ?? '{API_KEY}' + }' -d '{ "id": "Required. This will be the unique ID of the deployment", "startedDate": "2023-01-01T12:00:00+00:00", "finishedDate": "2023-01-01T12:00:00+00:00", @@ -49,8 +52,9 @@ export const transformURI = (prefix: string, webhook: IWebhook, apiKey: string) } ] }'`, - postPullRequestsEndpoint: `curl ${prefix}${webhook.postPullRequestsEndpoint} -X 'POST' -H 'Authorization: Bearer ${apiKey ?? '{API_KEY}' - }' -d '{ + postPullRequestsEndpoint: `curl ${prefix}${webhook.postPullRequestsEndpoint} -X 'POST' -H 'Authorization: Bearer ${ + apiKey ?? '{API_KEY}' + }' -d '{ "id": "Required. This will be the unique ID of the pull request", "baseRepoId": "your-repo-id", "headRepoId": "your-repo-id", diff --git a/config-ui/src/plugins/utils.ts b/config-ui/src/plugins/utils.ts index 94739d40526..ea1b8d51487 100644 --- a/config-ui/src/plugins/utils.ts +++ b/config-ui/src/plugins/utils.ts @@ -83,7 +83,9 @@ const pluginAliasMap: Record = { }; const aliasByTarget = Object.entries(pluginAliasMap).reduce>((acc, [alias, target]) => { - acc[target] ??= []; + if (!acc[target]) { + acc[target] = []; + } acc[target].push(alias); return acc; }, {}); diff --git a/config-ui/src/release/stable.ts b/config-ui/src/release/stable.ts index c31162fdb85..7911e06e0dd 100644 --- a/config-ui/src/release/stable.ts +++ b/config-ui/src/release/stable.ts @@ -25,7 +25,8 @@ const URLS = { PLUGIN: { ARGOCD: { BASIS: 'https://devlake.apache.org/docs/Configuration/ArgoCD', - TRANSFORMATION: 'https://devlake.apache.org/docs/Configuration/ArgoCD#step-3---adding-transformation-rules-optional', + TRANSFORMATION: + 'https://devlake.apache.org/docs/Configuration/ArgoCD#step-3---adding-transformation-rules-optional', }, AZUREDEVOPS: { BASIS: 'https://devlake.apache.org/docs/Configuration/AzureDevOps', diff --git a/config-ui/src/routes/blueprint/detail/blueprint-detail.tsx b/config-ui/src/routes/blueprint/detail/blueprint-detail.tsx index 52491bac936..7a211f239af 100644 --- a/config-ui/src/routes/blueprint/detail/blueprint-detail.tsx +++ b/config-ui/src/routes/blueprint/detail/blueprint-detail.tsx @@ -48,15 +48,12 @@ export const BlueprintDetail = ({ id, from }: Props) => { }, [state]); const { ready, data, error } = useRefreshData(async () => { - const [bpRes, pipelineRes] = await Promise.all([ - API.blueprint.get(id), - API.blueprint.pipelines(id), - ]); + const [bpRes, pipelineRes] = await Promise.all([API.blueprint.get(id), API.blueprint.pipelines(id)]); return [bpRes, pipelineRes.pipelines[0]]; }, [version]); useEffect(() => { - if (axios.isAxiosError(error) && error.response?.status === 404) { + if (axios.isAxiosError(error) && error.response?.status === 404) { message.error(`Blueprint not found with id: ${id}`); navigate(PATHS.BLUEPRINTS(), { replace: true }); } diff --git a/config-ui/src/routes/connection/connection.tsx b/config-ui/src/routes/connection/connection.tsx index 43faeeede26..7f21a7ff5f8 100644 --- a/config-ui/src/routes/connection/connection.tsx +++ b/config-ui/src/routes/connection/connection.tsx @@ -246,10 +246,9 @@ export const Connection = () => { setOperating(false); setVersion((v) => v + 1); setScopeIds([]); - setPage(1) + setPage(1); }; - const handleAssociateScopeConfig = async (trId: ID) => { const [success] = await operator( () => @@ -317,12 +316,14 @@ export const Connection = () => { )} {dataSource.length > 0 && ( - )} @@ -594,19 +595,15 @@ export const Connection = () => { >
-
You are about to delete {scopeIds.length} data scopes:
+
+ You are about to delete {scopeIds.length} data scopes: +
    {scopeIds.slice(0, 5).map((id) => { const scope = dataSource.find((s) => s.id === id); - return ( -
  • - {scope?.name || `Scope ID ${id}`} -
  • - ); + return
  • {scope?.name || `Scope ID ${id}`}
  • ; })} - {scopeIds.length > 5 && ( -
  • ...and {scopeIds.length - 5} more
  • - )} + {scopeIds.length > 5 &&
  • ...and {scopeIds.length - 5} more
  • }
@@ -622,9 +619,7 @@ export const Connection = () => { cancelButtonProps={{ style: { display: 'none' } }} onOk={handleHideDialog} > - +
Progress: {bulkDeleteProgress.completed}/{bulkDeleteProgress.total} diff --git a/grafana/dashboards/GithubCopilotREADME.md b/grafana/dashboards/GithubCopilotREADME.md index 77a164e825a..642ff425296 100644 --- a/grafana/dashboards/GithubCopilotREADME.md +++ b/grafana/dashboards/GithubCopilotREADME.md @@ -1,3 +1,19 @@ + # GitHub Copilot Dashboards Grafana dashboards for analyzing GitHub Copilot usage and its correlation with developer productivity metrics.