From 367957221979560d14100f65149ece688bd7abe6 Mon Sep 17 00:00:00 2001 From: Bujjibabukatta Date: Mon, 20 Jul 2026 20:45:38 +0530 Subject: [PATCH] fix(gh-copilot): update Copilot metrics endpoints & schemas to latest API --- .../gh-copilot/models/enterprise_metrics.go | 66 ++++++++-- .../models/migrationscripts/register.go | 2 + backend/plugins/gh-copilot/models/models.go | 4 + .../plugins/gh-copilot/models/user_metrics.go | 7 ++ .../tasks/enterprise_metrics_extractor.go | 117 ++++++++++++++++-- .../gh-copilot/tasks/metrics_extractor.go | 48 ++++++- backend/plugins/gh-copilot/tasks/register.go | 6 + .../tasks/report_download_helper.go | 22 +--- backend/plugins/gh-copilot/tasks/subtasks.go | 50 ++++++++ .../tasks/user_metrics_extractor.go | 79 +++++++----- 10 files changed, 333 insertions(+), 68 deletions(-) diff --git a/backend/plugins/gh-copilot/models/enterprise_metrics.go b/backend/plugins/gh-copilot/models/enterprise_metrics.go index 967e3ecd326..d10040e4127 100644 --- a/backend/plugins/gh-copilot/models/enterprise_metrics.go +++ b/backend/plugins/gh-copilot/models/enterprise_metrics.go @@ -46,11 +46,13 @@ type CopilotCodeMetrics struct { // CopilotCliMetrics contains CLI usage breakdown metrics. type CopilotCliMetrics struct { - CliSessionCount int `json:"cliSessionCount" gorm:"comment:Number of CLI sessions"` - CliRequestCount int `json:"cliRequestCount" gorm:"comment:Number of CLI requests"` - CliPromptCount int `json:"cliPromptCount" gorm:"comment:Number of CLI prompts"` - CliOutputTokenSum int `json:"cliOutputTokenSum" gorm:"comment:Total output tokens from CLI"` - CliPromptTokenSum int `json:"cliPromptTokenSum" gorm:"comment:Total prompt tokens from CLI"` + CliSessionCount int `json:"cliSessionCount" gorm:"comment:Number of CLI sessions"` + CliRequestCount int `json:"cliRequestCount" gorm:"comment:Number of CLI requests"` + CliPromptCount int `json:"cliPromptCount" gorm:"comment:Number of CLI prompts"` + CliOutputTokenSum int `json:"cliOutputTokenSum" gorm:"comment:Total output tokens from CLI"` + CliPromptTokenSum int `json:"cliPromptTokenSum" gorm:"comment:Total prompt tokens from CLI"` + CliLastKnownVersion string `json:"cliLastKnownVersion" gorm:"type:varchar(50);comment:Last known Copilot CLI version"` + CliAvgTokensPerRequest float64 `json:"cliAvgTokensPerRequest" gorm:"comment:Average tokens consumed per CLI request"` } // GhCopilotEnterpriseDailyMetrics captures daily enterprise-level aggregate Copilot metrics. @@ -59,7 +61,12 @@ type GhCopilotEnterpriseDailyMetrics struct { 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)"` + EnterpriseId string `json:"enterpriseId" gorm:"type:varchar(100)"` + // OrganizationId identifies the owning org for org-level connections/rows. + // Previously always written as "" by ExtractOrgMetrics, making org-level + // rows in this shared table unidentifiable without a join back to the + // connection/scope config. + OrganizationId string `json:"organizationId" gorm:"type:varchar(100)"` DailyActiveUsers int `json:"dailyActiveUsers"` WeeklyActiveUsers int `json:"weeklyActiveUsers"` MonthlyActiveUsers int `json:"monthlyActiveUsers"` @@ -69,6 +76,11 @@ type GhCopilotEnterpriseDailyMetrics struct { // CLI active users DailyActiveCliUsers int `json:"dailyActiveCliUsers" gorm:"comment:Daily active CLI users"` + // Copilot cloud agent (coding agent) active user counts + DailyActiveCopilotCloudAgentUsers int `json:"dailyActiveCopilotCloudAgentUsers" gorm:"comment:Daily active Copilot cloud/coding agent users"` + WeeklyActiveCopilotCloudAgentUsers int `json:"weeklyActiveCopilotCloudAgentUsers" gorm:"comment:Weekly active Copilot cloud/coding agent users"` + MonthlyActiveCopilotCloudAgentUsers int `json:"monthlyActiveCopilotCloudAgentUsers" gorm:"comment:Monthly active Copilot cloud/coding agent users"` + // Code review user counts DailyActiveCopilotCodeReviewUsers int `json:"dailyActiveCopilotCodeReviewUsers"` DailyPassiveCopilotCodeReviewUsers int `json:"dailyPassiveCopilotCodeReviewUsers"` @@ -100,7 +112,11 @@ type GhCopilotEnterpriseDailyMetrics struct { PRMedianMinToMergeCopilotReviewed float64 `json:"prMedianMinToMergeCopilotReviewed" gorm:"comment:Median min to merge Copilot-reviewed PRs"` PRTotalCopilotSuggestions int `json:"prTotalCopilotSuggestions" gorm:"comment:Total Copilot review suggestions"` PRTotalCopilotAppliedSuggestions int `json:"prTotalCopilotAppliedSuggestions" gorm:"comment:Total Copilot applied suggestions"` - + // PRCopilotSuggestionsByCommentType is a JSON-encoded array of + // {comment_type, total_suggestions, total_applied_suggestions}, e.g. the + // split between "suggestion" and "explanation" style review comments. + PRCopilotSuggestionsByCommentType string `json:"prCopilotSuggestionsByCommentType" gorm:"type:text;comment:JSON breakdown of Copilot PR suggestions by comment type"` + CopilotActivityMetrics `mapstructure:",squash"` CopilotCliMetrics `mapstructure:",squash"` common.NoPKModel @@ -187,3 +203,39 @@ type GhCopilotMetricsByModelFeature struct { func (GhCopilotMetricsByModelFeature) TableName() string { return "_tool_copilot_metrics_by_model_feature" } + +// GhCopilotMetricsByAiAdoptionPhase stores enterprise/org metrics broken down +// by AI adoption cohort (phase 0-3, see GitHub's totals_by_ai_adoption_phase). +// Unlike CopilotActivityMetrics elsewhere in this file, most of these fields +// are per-user averages within the phase rather than sums. +type GhCopilotMetricsByAiAdoptionPhase 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"` + Phase int `gorm:"primaryKey" json:"phase"` + + PhaseVersion int `json:"phaseVersion" gorm:"comment:Adoption-phase classification version, starts at 1"` + EngagedUsers int `json:"engagedUsers" gorm:"comment:Users engaged in this phase (2-day-in-28 window)"` + + AvgUserInitiatedInteractionCount float64 `json:"avgUserInitiatedInteractionCount"` + AvgCodeGenerationActivityCount float64 `json:"avgCodeGenerationActivityCount"` + AvgCodeAcceptanceActivityCount float64 `json:"avgCodeAcceptanceActivityCount"` + AvgLocAddedSum float64 `json:"avgLocAddedSum"` + AvgLocDeletedSum float64 `json:"avgLocDeletedSum"` + + AvgPullRequestsCreated float64 `json:"avgPullRequestsCreated"` + AvgPullRequestsMerged float64 `json:"avgPullRequestsMerged"` + AvgPullRequestsReviewed float64 `json:"avgPullRequestsReviewed"` + AvgMedianMinutesToMerge float64 `json:"avgMedianMinutesToMerge"` + + AvgPullRequestsMinutesToReview float64 `json:"avgPullRequestsMinutesToReview" gorm:"comment:Added 2026-07-07"` + AvgPullRequestsReviewCycles float64 `json:"avgPullRequestsReviewCycles" gorm:"comment:Added 2026-07-07"` + + TotalPullRequestsMerged int `json:"totalPullRequestsMerged" gorm:"comment:True sum (not average), added 2026-06-26"` + + common.NoPKModel +} + +func (GhCopilotMetricsByAiAdoptionPhase) TableName() string { + return "_tool_copilot_metrics_by_ai_adoption_phase" +} diff --git a/backend/plugins/gh-copilot/models/migrationscripts/register.go b/backend/plugins/gh-copilot/models/migrationscripts/register.go index 399735695e0..1a86cdf35af 100644 --- a/backend/plugins/gh-copilot/models/migrationscripts/register.go +++ b/backend/plugins/gh-copilot/models/migrationscripts/register.go @@ -31,5 +31,7 @@ func All() []plugin.MigrationScript { new(addPRFieldsToEnterpriseMetrics), new(addOrganizationIdToUserMetrics), new(addCopilotMetricsGaps), + new(addCopilotMetricsGapsV2), + new(addCopilot28DayReports), } } diff --git a/backend/plugins/gh-copilot/models/models.go b/backend/plugins/gh-copilot/models/models.go index 5143ce5f8b7..bf12554c8fb 100644 --- a/backend/plugins/gh-copilot/models/models.go +++ b/backend/plugins/gh-copilot/models/models.go @@ -36,6 +36,7 @@ func GetTablesInfo() []dal.Tabler { &GhCopilotMetricsByLanguageFeature{}, &GhCopilotMetricsByLanguageModel{}, &GhCopilotMetricsByModelFeature{}, + &GhCopilotMetricsByAiAdoptionPhase{}, // User-level metrics (from enterprise user reports) &GhCopilotUserDailyMetrics{}, &GhCopilotUserMetricsByIde{}, @@ -47,5 +48,8 @@ func GetTablesInfo() []dal.Tabler { &GhCopilotSeat{}, // User-team mappings &GhCopilotUserTeam{}, + // 28-day rolling-window snapshots (separate cadence from the daily tables above) + &GhCopilotEnterprise28DayMetrics{}, + &GhCopilotUser28DayMetrics{}, } } diff --git a/backend/plugins/gh-copilot/models/user_metrics.go b/backend/plugins/gh-copilot/models/user_metrics.go index 18e9134c226..f58fad3687b 100644 --- a/backend/plugins/gh-copilot/models/user_metrics.go +++ b/backend/plugins/gh-copilot/models/user_metrics.go @@ -38,6 +38,13 @@ type GhCopilotUserDailyMetrics struct { UsedCli bool `json:"usedCli" gorm:"comment:Whether user used Copilot CLI"` UsedCopilotCodeReviewActive bool `json:"usedCopilotCodeReviewActive" gorm:"comment:Whether user actively used code review"` UsedCopilotCodeReviewPassive bool `json:"usedCopilotCodeReviewPassive" gorm:"comment:Whether user passively used code review"` + UsedCopilotCodingAgent bool `json:"usedCopilotCodingAgent" gorm:"comment:Whether user used the Copilot coding agent"` + UsedCopilotCloudAgent bool `json:"usedCopilotCloudAgent" gorm:"comment:Whether user used the Copilot cloud agent"` + + // AI adoption cohort classification (phase 0-3), added 2026-05-29. + AiAdoptionPhase int `json:"aiAdoptionPhase" gorm:"comment:0=no cohort,1=code-first,2=agent-first,3=multi-agent"` + AiAdoptionPhaseVersion int `json:"aiAdoptionPhaseVersion" gorm:"comment:Classification logic version, starts at 1"` + CopilotActivityMetrics `mapstructure:",squash"` CopilotCliMetrics `mapstructure:",squash"` diff --git a/backend/plugins/gh-copilot/tasks/enterprise_metrics_extractor.go b/backend/plugins/gh-copilot/tasks/enterprise_metrics_extractor.go index e98a3c4f0e5..d3134019b52 100644 --- a/backend/plugins/gh-copilot/tasks/enterprise_metrics_extractor.go +++ b/backend/plugins/gh-copilot/tasks/enterprise_metrics_extractor.go @@ -30,8 +30,12 @@ import ( // --- Enterprise report JSON structures --- type enterpriseDayTotal struct { - Day string `json:"day"` - EnterpriseId string `json:"enterprise_id"` + Day string `json:"day"` + EnterpriseId string `json:"enterprise_id"` + // OrganizationId is only present on organization-level reports (org report + // shares this same flat schema). Needed so ExtractOrgMetrics can stamp the + // owning org onto rows instead of leaving them unidentifiable. + OrganizationId string `json:"organization_id"` DailyActiveUsers int `json:"daily_active_users"` WeeklyActiveUsers int `json:"weekly_active_users"` MonthlyActiveUsers int `json:"monthly_active_users"` @@ -39,6 +43,11 @@ type enterpriseDayTotal struct { MonthlyActiveAgentUsers int `json:"monthly_active_agent_users"` DailyActiveCliUsers int `json:"daily_active_cli_users"` + // Copilot cloud agent (coding agent) active user counts — added in 2026-03-10. + DailyActiveCopilotCloudAgentUsers int `json:"daily_active_copilot_cloud_agent_users"` + WeeklyActiveCopilotCloudAgentUsers int `json:"weekly_active_copilot_cloud_agent_users"` + MonthlyActiveCopilotCloudAgentUsers int `json:"monthly_active_copilot_cloud_agent_users"` + // Code review user counts DailyActiveCopilotCodeReviewUsers int `json:"daily_active_copilot_code_review_users"` DailyPassiveCopilotCodeReviewUsers int `json:"daily_passive_copilot_code_review_users"` @@ -69,6 +78,12 @@ type enterpriseDayTotal struct { TotalsByModelFeature []totalsByModelFeature `json:"totals_by_model_feature"` TotalsByCli *totalsByCli `json:"totals_by_cli"` PullRequests *pullRequestStats `json:"pull_requests"` + + // TotalsByAiAdoptionPhase groups engagement/activity metrics by AI adoption + // cohort (phase 0-3). Added 2026-05-29, extended with review-cycle metrics + // 2026-07-07. Only present on enterprise/org reports, not user reports. + TotalsByAiAdoptionPhase []totalsByAiAdoptionPhase `json:"totals_by_ai_adoption_phase"` + } type totalsByIde struct { @@ -130,18 +145,58 @@ type pullRequestStats struct { MedianMinToMergeCopilotReviewed float64 `json:"median_minutes_to_merge_copilot_reviewed"` TotalCopilotSuggestions int `json:"total_copilot_suggestions"` TotalCopilotAppliedSuggestions int `json:"total_copilot_applied_suggestions"` + + // CopilotSuggestionsByCommentType breaks total_copilot_suggestions down by + // the kind of PR review comment Copilot generated (e.g. "suggestion", "explanation"). + CopilotSuggestionsByCommentType []prSuggestionsByCommentType `json:"copilot_suggestions_by_comment_type"` +} + +type prSuggestionsByCommentType struct { + CommentType string `json:"comment_type"` + TotalSuggestions int `json:"total_suggestions"` + TotalApplied int `json:"total_applied_suggestions"` } type totalsByCli struct { - SessionCount int `json:"session_count"` - RequestCount int `json:"request_count"` - PromptCount int `json:"prompt_count"` - TokenUsage *cliTokens `json:"token_usage"` + SessionCount int `json:"session_count"` + RequestCount int `json:"request_count"` + PromptCount int `json:"prompt_count"` + TokenUsage *cliTokens `json:"token_usage"` + LastKnownCliVersion string `json:"last_known_cli_version"` } type cliTokens struct { - OutputTokensSum int `json:"output_tokens_sum"` - PromptTokensSum int `json:"prompt_tokens_sum"` + OutputTokensSum int `json:"output_tokens_sum"` + PromptTokensSum int `json:"prompt_tokens_sum"` + AvgTokensPerRequest float64 `json:"avg_tokens_per_request"` +} + +// totalsByAiAdoptionPhase is one entry of the totals_by_ai_adoption_phase +// breakdown on enterprise/org reports. Per-user fields are averages within the +// phase, not sums (per GitHub's docs) — the *_total fields are true sums. +type totalsByAiAdoptionPhase struct { + Phase int `json:"phase"` + Version int `json:"version"` + + EngagedUsers int `json:"engaged_users"` + + AvgUserInitiatedInteractionCount float64 `json:"avg_user_initiated_interaction_count"` + AvgCodeGenerationActivityCount float64 `json:"avg_code_generation_activity_count"` + AvgCodeAcceptanceActivityCount float64 `json:"avg_code_acceptance_activity_count"` + AvgLocAddedSum float64 `json:"avg_loc_added_sum"` + AvgLocDeletedSum float64 `json:"avg_loc_deleted_sum"` + + AvgPullRequestsCreated float64 `json:"avg_pull_requests_created"` + AvgPullRequestsMerged float64 `json:"avg_pull_requests_merged"` + AvgPullRequestsReviewed float64 `json:"avg_pull_requests_reviewed"` + AvgMedianMinutesToMerge float64 `json:"avg_median_minutes_to_merge"` + + // Added 2026-07-07. + AvgPullRequestsMinutesToReview float64 `json:"avg_pull_requests_minutes_to_review"` + AvgPullRequestsReviewCycles float64 `json:"avg_pull_requests_review_cycles"` + + // Added 2026-06-26 — true total rather than a per-user average. + TotalPullRequestsMerged int `json:"total_pull_requests_merged"` } type totalsByModelFeature struct { @@ -203,6 +258,7 @@ func ExtractEnterpriseMetrics(taskCtx plugin.SubTaskContext) errors.Error { ScopeId: data.Options.ScopeId, Day: day, EnterpriseId: dt.EnterpriseId, + OrganizationId: dt.OrganizationId, DailyActiveUsers: dt.DailyActiveUsers, WeeklyActiveUsers: dt.WeeklyActiveUsers, MonthlyActiveUsers: dt.MonthlyActiveUsers, @@ -210,6 +266,10 @@ func ExtractEnterpriseMetrics(taskCtx plugin.SubTaskContext) errors.Error { MonthlyActiveAgentUsers: dt.MonthlyActiveAgentUsers, DailyActiveCliUsers: dt.DailyActiveCliUsers, + DailyActiveCopilotCloudAgentUsers: dt.DailyActiveCopilotCloudAgentUsers, + WeeklyActiveCopilotCloudAgentUsers: dt.WeeklyActiveCopilotCloudAgentUsers, + MonthlyActiveCopilotCloudAgentUsers: dt.MonthlyActiveCopilotCloudAgentUsers, + DailyActiveCopilotCodeReviewUsers: dt.DailyActiveCopilotCodeReviewUsers, DailyPassiveCopilotCodeReviewUsers: dt.DailyPassiveCopilotCodeReviewUsers, WeeklyActiveCopilotCodeReviewUsers: dt.WeeklyActiveCopilotCodeReviewUsers, @@ -236,13 +296,15 @@ func ExtractEnterpriseMetrics(taskCtx plugin.SubTaskContext) errors.Error { } if dt.TotalsByCli != nil { dailyMetrics.CopilotCliMetrics = models.CopilotCliMetrics{ - CliSessionCount: dt.TotalsByCli.SessionCount, - CliRequestCount: dt.TotalsByCli.RequestCount, - CliPromptCount: dt.TotalsByCli.PromptCount, + CliSessionCount: dt.TotalsByCli.SessionCount, + CliRequestCount: dt.TotalsByCli.RequestCount, + CliPromptCount: dt.TotalsByCli.PromptCount, + CliLastKnownVersion: dt.TotalsByCli.LastKnownCliVersion, } if dt.TotalsByCli.TokenUsage != nil { dailyMetrics.CopilotCliMetrics.CliOutputTokenSum = dt.TotalsByCli.TokenUsage.OutputTokensSum dailyMetrics.CopilotCliMetrics.CliPromptTokenSum = dt.TotalsByCli.TokenUsage.PromptTokensSum + dailyMetrics.CopilotCliMetrics.CliAvgTokensPerRequest = dt.TotalsByCli.TokenUsage.AvgTokensPerRequest } } if dt.PullRequests != nil { @@ -260,8 +322,41 @@ func ExtractEnterpriseMetrics(taskCtx plugin.SubTaskContext) errors.Error { dailyMetrics.PRMedianMinToMergeCopilotReviewed = dt.PullRequests.MedianMinToMergeCopilotReviewed dailyMetrics.PRTotalCopilotSuggestions = dt.PullRequests.TotalCopilotSuggestions dailyMetrics.PRTotalCopilotAppliedSuggestions = dt.PullRequests.TotalCopilotAppliedSuggestions + + // Stored as a JSON blob rather than a normalized breakdown table for + // now — comment-type cardinality is small (suggestion/explanation) + // and doesn't warrant its own table + primary key the way IDE/feature + // breakdowns do. Revisit if GitHub adds more comment types. + if len(dt.PullRequests.CopilotSuggestionsByCommentType) > 0 { + if b, jsonErr := json.Marshal(dt.PullRequests.CopilotSuggestionsByCommentType); jsonErr == nil { + dailyMetrics.PRCopilotSuggestionsByCommentType = string(b) + } + } } results = append(results, dailyMetrics) + // By AI adoption phase + for _, phase := range dt.TotalsByAiAdoptionPhase { + results = append(results, &models.GhCopilotMetricsByAiAdoptionPhase{ + ConnectionId: data.Options.ConnectionId, + ScopeId: data.Options.ScopeId, + Day: day, + Phase: phase.Phase, + PhaseVersion: phase.Version, + EngagedUsers: phase.EngagedUsers, + AvgUserInitiatedInteractionCount: phase.AvgUserInitiatedInteractionCount, + AvgCodeGenerationActivityCount: phase.AvgCodeGenerationActivityCount, + AvgCodeAcceptanceActivityCount: phase.AvgCodeAcceptanceActivityCount, + AvgLocAddedSum: phase.AvgLocAddedSum, + AvgLocDeletedSum: phase.AvgLocDeletedSum, + AvgPullRequestsCreated: phase.AvgPullRequestsCreated, + AvgPullRequestsMerged: phase.AvgPullRequestsMerged, + AvgPullRequestsReviewed: phase.AvgPullRequestsReviewed, + AvgMedianMinutesToMerge: phase.AvgMedianMinutesToMerge, + AvgPullRequestsMinutesToReview: phase.AvgPullRequestsMinutesToReview, + AvgPullRequestsReviewCycles: phase.AvgPullRequestsReviewCycles, + TotalPullRequestsMerged: phase.TotalPullRequestsMerged, + }) + } // By IDE for _, ide := range dt.TotalsByIde { diff --git a/backend/plugins/gh-copilot/tasks/metrics_extractor.go b/backend/plugins/gh-copilot/tasks/metrics_extractor.go index d89eababde6..561434c922b 100644 --- a/backend/plugins/gh-copilot/tasks/metrics_extractor.go +++ b/backend/plugins/gh-copilot/tasks/metrics_extractor.go @@ -99,11 +99,16 @@ func ExtractOrgMetrics(taskCtx plugin.SubTaskContext) errors.Error { var results []interface{} // Main daily metrics — same model as enterprise extractor + organizationId := dt.OrganizationId + if organizationId == "" { + organizationId = connection.Organization + } dailyMetrics := &models.GhCopilotEnterpriseDailyMetrics{ ConnectionId: data.Options.ConnectionId, ScopeId: data.Options.ScopeId, Day: day, - EnterpriseId: "", // org-level, no enterprise + EnterpriseId: dt.EnterpriseId, + OrganizationId: organizationId, DailyActiveUsers: dt.DailyActiveUsers, WeeklyActiveUsers: dt.WeeklyActiveUsers, MonthlyActiveUsers: dt.MonthlyActiveUsers, @@ -111,6 +116,10 @@ func ExtractOrgMetrics(taskCtx plugin.SubTaskContext) errors.Error { MonthlyActiveAgentUsers: dt.MonthlyActiveAgentUsers, DailyActiveCliUsers: dt.DailyActiveCliUsers, + DailyActiveCopilotCloudAgentUsers: dt.DailyActiveCopilotCloudAgentUsers, + WeeklyActiveCopilotCloudAgentUsers: dt.WeeklyActiveCopilotCloudAgentUsers, + MonthlyActiveCopilotCloudAgentUsers: dt.MonthlyActiveCopilotCloudAgentUsers, + DailyActiveCopilotCodeReviewUsers: dt.DailyActiveCopilotCodeReviewUsers, DailyPassiveCopilotCodeReviewUsers: dt.DailyPassiveCopilotCodeReviewUsers, WeeklyActiveCopilotCodeReviewUsers: dt.WeeklyActiveCopilotCodeReviewUsers, @@ -137,13 +146,15 @@ func ExtractOrgMetrics(taskCtx plugin.SubTaskContext) errors.Error { } if dt.TotalsByCli != nil { dailyMetrics.CopilotCliMetrics = models.CopilotCliMetrics{ - CliSessionCount: dt.TotalsByCli.SessionCount, - CliRequestCount: dt.TotalsByCli.RequestCount, - CliPromptCount: dt.TotalsByCli.PromptCount, + CliSessionCount: dt.TotalsByCli.SessionCount, + CliRequestCount: dt.TotalsByCli.RequestCount, + CliPromptCount: dt.TotalsByCli.PromptCount, + CliLastKnownVersion: dt.TotalsByCli.LastKnownCliVersion, } if dt.TotalsByCli.TokenUsage != nil { dailyMetrics.CopilotCliMetrics.CliOutputTokenSum = dt.TotalsByCli.TokenUsage.OutputTokensSum dailyMetrics.CopilotCliMetrics.CliPromptTokenSum = dt.TotalsByCli.TokenUsage.PromptTokensSum + dailyMetrics.CopilotCliMetrics.CliAvgTokensPerRequest = dt.TotalsByCli.TokenUsage.AvgTokensPerRequest } } if dt.PullRequests != nil { @@ -161,9 +172,38 @@ func ExtractOrgMetrics(taskCtx plugin.SubTaskContext) errors.Error { dailyMetrics.PRMedianMinToMergeCopilotReviewed = dt.PullRequests.MedianMinToMergeCopilotReviewed dailyMetrics.PRTotalCopilotSuggestions = dt.PullRequests.TotalCopilotSuggestions dailyMetrics.PRTotalCopilotAppliedSuggestions = dt.PullRequests.TotalCopilotAppliedSuggestions + if len(dt.PullRequests.CopilotSuggestionsByCommentType) > 0 { + if b, jsonErr := json.Marshal(dt.PullRequests.CopilotSuggestionsByCommentType); jsonErr == nil { + dailyMetrics.PRCopilotSuggestionsByCommentType = string(b) + } + } } results = append(results, dailyMetrics) + // By AI adoption phase + for _, phase := range dt.TotalsByAiAdoptionPhase { + results = append(results, &models.GhCopilotMetricsByAiAdoptionPhase{ + ConnectionId: data.Options.ConnectionId, + ScopeId: data.Options.ScopeId, + Day: day, + Phase: phase.Phase, + PhaseVersion: phase.Version, + EngagedUsers: phase.EngagedUsers, + AvgUserInitiatedInteractionCount: phase.AvgUserInitiatedInteractionCount, + AvgCodeGenerationActivityCount: phase.AvgCodeGenerationActivityCount, + AvgCodeAcceptanceActivityCount: phase.AvgCodeAcceptanceActivityCount, + AvgLocAddedSum: phase.AvgLocAddedSum, + AvgLocDeletedSum: phase.AvgLocDeletedSum, + AvgPullRequestsCreated: phase.AvgPullRequestsCreated, + AvgPullRequestsMerged: phase.AvgPullRequestsMerged, + AvgPullRequestsReviewed: phase.AvgPullRequestsReviewed, + AvgMedianMinutesToMerge: phase.AvgMedianMinutesToMerge, + AvgPullRequestsMinutesToReview: phase.AvgPullRequestsMinutesToReview, + AvgPullRequestsReviewCycles: phase.AvgPullRequestsReviewCycles, + TotalPullRequestsMerged: phase.TotalPullRequestsMerged, + }) + } + // By IDE for _, ide := range dt.TotalsByIde { results = append(results, &models.GhCopilotMetricsByIde{ diff --git a/backend/plugins/gh-copilot/tasks/register.go b/backend/plugins/gh-copilot/tasks/register.go index 3c7e5b1eeb9..8733de27142 100644 --- a/backend/plugins/gh-copilot/tasks/register.go +++ b/backend/plugins/gh-copilot/tasks/register.go @@ -28,11 +28,17 @@ func GetSubTaskMetas() []plugin.SubTaskMeta { CollectEnterpriseMetricsMeta, CollectUserMetricsMeta, CollectUserTeamsMeta, + CollectEnterpriseMetrics28DayMeta, + CollectOrgMetrics28DayMeta, + CollectUserMetrics28DayMeta, // Extractors ExtractSeatsMeta, ExtractOrgMetricsMeta, ExtractEnterpriseMetricsMeta, ExtractUserMetricsMeta, ExtractUserTeamsMeta, + ExtractEnterpriseMetrics28DayMeta, + ExtractOrgMetrics28DayMeta, + ExtractUserMetrics28DayMeta, } } diff --git a/backend/plugins/gh-copilot/tasks/report_download_helper.go b/backend/plugins/gh-copilot/tasks/report_download_helper.go index 236b039889b..d2a165059ec 100644 --- a/backend/plugins/gh-copilot/tasks/report_download_helper.go +++ b/backend/plugins/gh-copilot/tasks/report_download_helper.go @@ -203,24 +203,10 @@ func collectRawReportRecords(meta *reportMetadataResponse, logger log.Logger) ([ } func parseRawReportResponse(res *http.Response, logger log.Logger) ([]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") - } - if isEmptyReport(body) { - return nil, nil - } - - 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") - } + // NOTE: res.Body must only be read once. It previously was read here AND + // again inside parseReportMetadataResponse, so the second read always + // returned an empty buffer (io.Reader is not rewindable) and every + // enterprise report was silently treated as having no download links. meta, err := parseReportMetadataResponse(res, logger) if err != nil || meta == nil { diff --git a/backend/plugins/gh-copilot/tasks/subtasks.go b/backend/plugins/gh-copilot/tasks/subtasks.go index 61ed5799525..da08bf2321a 100644 --- a/backend/plugins/gh-copilot/tasks/subtasks.go +++ b/backend/plugins/gh-copilot/tasks/subtasks.go @@ -60,6 +60,30 @@ var CollectUserTeamsMeta = plugin.SubTaskMeta{ DomainTypes: []string{plugin.DOMAIN_TYPE_CROSS}, Description: "Collect GitHub Copilot user-team mappings from user-teams-1-day report", } +var CollectEnterpriseMetrics28DayMeta = plugin.SubTaskMeta{ + Name: "collectEnterpriseMetrics28Day", + EntryPoint: CollectEnterpriseMetrics28Day, + EnabledByDefault: true, + DomainTypes: []string{plugin.DOMAIN_TYPE_CROSS}, + Description: "Collect GitHub Copilot enterprise-28-day/latest rolling-window usage report", +} + +var CollectOrgMetrics28DayMeta = plugin.SubTaskMeta{ + Name: "collectOrgMetrics28Day", + EntryPoint: CollectOrgMetrics28Day, + EnabledByDefault: true, + DomainTypes: []string{plugin.DOMAIN_TYPE_CROSS}, + Description: "Collect GitHub Copilot organization-28-day/latest rolling-window usage report", +} + +var CollectUserMetrics28DayMeta = plugin.SubTaskMeta{ + Name: "collectUserMetrics28Day", + EntryPoint: CollectUserMetrics28Day, + EnabledByDefault: true, + DomainTypes: []string{plugin.DOMAIN_TYPE_CROSS}, + Description: "Collect GitHub Copilot users-28-day/latest rolling-window usage report", +} + var ExtractOrgMetricsMeta = plugin.SubTaskMeta{ Name: "extractOrgMetrics", @@ -70,6 +94,32 @@ var ExtractOrgMetricsMeta = plugin.SubTaskMeta{ Dependencies: []*plugin.SubTaskMeta{&CollectOrgMetricsMeta}, } +var ExtractEnterpriseMetrics28DayMeta = plugin.SubTaskMeta{ + Name: "extractEnterpriseMetrics28Day", + EntryPoint: ExtractEnterpriseMetrics28Day, + EnabledByDefault: true, + DomainTypes: []string{plugin.DOMAIN_TYPE_CROSS}, + Description: "Extract Copilot enterprise 28-day rolling-window metrics into tool-layer table", + Dependencies: []*plugin.SubTaskMeta{&CollectEnterpriseMetrics28DayMeta}, +} + +var ExtractOrgMetrics28DayMeta = plugin.SubTaskMeta{ + Name: "extractOrgMetrics28Day", + EntryPoint: ExtractOrgMetrics28Day, + EnabledByDefault: true, + DomainTypes: []string{plugin.DOMAIN_TYPE_CROSS}, + Description: "Extract Copilot org 28-day rolling-window metrics into tool-layer table", + Dependencies: []*plugin.SubTaskMeta{&CollectOrgMetrics28DayMeta}, +} + +var ExtractUserMetrics28DayMeta = plugin.SubTaskMeta{ + Name: "extractUserMetrics28Day", + EntryPoint: ExtractUserMetrics28Day, + EnabledByDefault: true, + DomainTypes: []string{plugin.DOMAIN_TYPE_CROSS}, + Description: "Extract Copilot user 28-day rolling-window metrics into tool-layer table", + Dependencies: []*plugin.SubTaskMeta{&CollectUserMetrics28DayMeta}, +} var ExtractSeatsMeta = plugin.SubTaskMeta{ Name: "extractSeats", EntryPoint: ExtractSeats, diff --git a/backend/plugins/gh-copilot/tasks/user_metrics_extractor.go b/backend/plugins/gh-copilot/tasks/user_metrics_extractor.go index 72992194063..64958ae9938 100644 --- a/backend/plugins/gh-copilot/tasks/user_metrics_extractor.go +++ b/backend/plugins/gh-copilot/tasks/user_metrics_extractor.go @@ -30,33 +30,48 @@ 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"` - OrganizationId string `json:"organization_id"` - 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"` - UsedCli bool `json:"used_cli"` - UsedCopilotCodeReviewActive bool `json:"used_copilot_code_review_active"` - UsedCopilotCodeReviewPassive bool `json:"used_copilot_code_review_passive"` - 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"` - TotalsByCli *totalsByCli `json:"totals_by_cli"` + ReportStartDay string `json:"report_start_day"` + ReportEndDay string `json:"report_end_day"` + Day string `json:"day"` + OrganizationId string `json:"organization_id"` + 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"` + UsedCli bool `json:"used_cli"` + UsedCopilotCodeReviewActive bool `json:"used_copilot_code_review_active"` + UsedCopilotCodeReviewPassive bool `json:"used_copilot_code_review_passive"` + // UsedCopilotCodingAgent / UsedCopilotCloudAgent — added in API 2026-03-10. + // GitHub's docs use "coding agent" and "cloud agent" somewhat + // interchangeably for the same async PR-authoring surface; both fields + // are collected in case they're ever populated independently. + UsedCopilotCodingAgent bool `json:"used_copilot_coding_agent"` + UsedCopilotCloudAgent bool `json:"used_copilot_cloud_agent"` + AiAdoptionPhase *aiAdoptionPhase `json:"ai_adoption_phase"` + 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"` + TotalsByCli *totalsByCli `json:"totals_by_cli"` } +// aiAdoptionPhase is the per-user cohort classification (phase 0-3), added +// 2026-05-29. version starts at 1 and increments if GitHub changes the +// classification logic, so historical rows stay interpretable. +type aiAdoptionPhase struct { + Phase int `json:"phase"` + Version int `json:"version"` + } + type userTotalsByIde struct { totalsByIde LastKnownPluginVersion *pluginVersion `json:"last_known_plugin_version"` @@ -123,6 +138,8 @@ func ExtractUserMetrics(taskCtx plugin.SubTaskContext) errors.Error { UsedCli: u.UsedCli, UsedCopilotCodeReviewActive: u.UsedCopilotCodeReviewActive, UsedCopilotCodeReviewPassive: u.UsedCopilotCodeReviewPassive, + UsedCopilotCodingAgent: u.UsedCopilotCodingAgent, + UsedCopilotCloudAgent: u.UsedCopilotCloudAgent, CopilotActivityMetrics: models.CopilotActivityMetrics{ UserInitiatedInteractionCount: u.UserInitiatedInteractionCount, CodeGenerationActivityCount: u.CodeGenerationActivityCount, @@ -133,15 +150,21 @@ func ExtractUserMetrics(taskCtx plugin.SubTaskContext) errors.Error { LocDeletedSum: u.LocDeletedSum, }, } + if u.AiAdoptionPhase != nil { + userMetrics.AiAdoptionPhase = u.AiAdoptionPhase.Phase + userMetrics.AiAdoptionPhaseVersion = u.AiAdoptionPhase.Version + } if u.TotalsByCli != nil { userMetrics.CopilotCliMetrics = models.CopilotCliMetrics{ - CliSessionCount: u.TotalsByCli.SessionCount, - CliRequestCount: u.TotalsByCli.RequestCount, - CliPromptCount: u.TotalsByCli.PromptCount, + CliSessionCount: u.TotalsByCli.SessionCount, + CliRequestCount: u.TotalsByCli.RequestCount, + CliPromptCount: u.TotalsByCli.PromptCount, + CliLastKnownVersion: u.TotalsByCli.LastKnownCliVersion, } if u.TotalsByCli.TokenUsage != nil { userMetrics.CopilotCliMetrics.CliOutputTokenSum = u.TotalsByCli.TokenUsage.OutputTokensSum userMetrics.CopilotCliMetrics.CliPromptTokenSum = u.TotalsByCli.TokenUsage.PromptTokensSum + userMetrics.CopilotCliMetrics.CliAvgTokensPerRequest = u.TotalsByCli.TokenUsage.AvgTokensPerRequest } } results = append(results, userMetrics)